From b69976b470a8ca50a73b6a1757bc77b804163179 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 9 Sep 2026 09:00:03 -0700 Subject: [PATCH] RG-T66 Implement inventory readiness integrations --- .../Areas/User/Checklists/Checklists.ar.resx | 1 + .../Areas/User/Checklists/Checklists.de.resx | 1 + .../Areas/User/Checklists/Checklists.el.resx | 1 + .../Areas/User/Checklists/Checklists.en.resx | 1 + .../Areas/User/Checklists/Checklists.es.resx | 1 + .../Areas/User/Checklists/Checklists.fr.resx | 1 + .../Areas/User/Checklists/Checklists.it.resx | 1 + .../Areas/User/Checklists/Checklists.pl.resx | 1 + .../Areas/User/Checklists/Checklists.resx | 1 + .../Areas/User/Checklists/Checklists.sv.resx | 1 + .../Areas/User/Checklists/Checklists.uk.resx | 1 + .../Areas/User/Inventory/Inventory.ar.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.de.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.el.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.en.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.es.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.fr.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.it.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.pl.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.sv.resx | 315 +++++++ .../Areas/User/Inventory/Inventory.uk.resx | 315 +++++++ .../Areas/User/Security/Security.ar.resx | 18 + .../Areas/User/Security/Security.de.resx | 18 + .../Areas/User/Security/Security.el.resx | 18 + .../Areas/User/Security/Security.en.resx | 18 + .../Areas/User/Security/Security.es.resx | 18 + .../Areas/User/Security/Security.fr.resx | 18 + .../Areas/User/Security/Security.it.resx | 18 + .../Areas/User/Security/Security.pl.resx | 18 + .../Areas/User/Security/Security.sv.resx | 18 + .../Areas/User/Security/Security.uk.resx | 18 + Core/Resgrid.Model/AuditLogTypes.cs | 3 +- .../Checklists/ChecklistWorkflowPayload.cs | 10 +- .../Checklists/ReadinessHistoryFields.cs | 2 +- .../Inventories/InventoryContracts.cs | 52 ++ .../Inventories/InventoryModels.cs | 152 ++++ .../Inventories/InventoryPermissionCatalog.cs | 14 + .../Inventories/InventoryQuery.cs | 12 + .../Inventories/InventoryWorkflowPayload.cs | 122 +++ Core/Resgrid.Model/PermissionTypes.cs | 9 +- .../Repositories/IInventoryStore.cs | 24 + .../Services/IChecklistsService.cs | 2 +- .../IInventoryModernizationService.cs | 48 + .../Services/IRmsInventoryUsageAdapter.cs | 6 +- .../Services/IWorkOrdersService.cs | 4 +- .../WorkOrders/WorkOrderWorkflowPayload.cs | 4 +- .../WorkflowTemplateVariableCatalog.cs | 56 +- .../Resgrid.Model/WorkflowTriggerEventType.cs | 9 +- Core/Resgrid.Services/AdpTableBindings.cs | 5 +- Core/Resgrid.Services/ChecklistMobile.cs | 2 +- .../ChecklistReportDocuments.cs | 2 +- Core/Resgrid.Services/ChecklistsScheduling.cs | 4 +- Core/Resgrid.Services/ChecklistsService.cs | 2 +- Core/Resgrid.Services/DeleteService.cs | 12 +- .../DepartmentGroupsService.cs | 8 +- Core/Resgrid.Services/FeatureFlagMutations.cs | 18 +- .../Resgrid.Services/GdprDataExportService.cs | 6 +- .../InventoryAuthorizationService.cs | 70 ++ Core/Resgrid.Services/InventoryCatalog.cs | 108 +++ .../InventoryChecklistAssets.cs | 208 +++++ Core/Resgrid.Services/InventoryGdprExport.cs | 44 + .../InventoryHolderRetention.cs | 33 + Core/Resgrid.Services/InventoryIssuance.cs | 220 +++++ .../InventoryLegacyMigration.cs | 164 ++++ .../InventoryModernizationService.cs | 158 ++++ Core/Resgrid.Services/InventoryPosting.cs | 327 +++++++ Core/Resgrid.Services/InventoryQueries.cs | 27 + Core/Resgrid.Services/InventoryReferences.cs | 32 + Core/Resgrid.Services/InventoryService.cs | 13 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 7 +- .../ReadinessProBillingService.cs | 11 +- .../Records/DomainEventOutboxService.cs | 4 +- .../Evidence/RecordEvidenceAdapters.cs | 2 +- .../Records/RmsInventoryUsageAdapter.cs | 169 +++- Core/Resgrid.Services/ServicesModule.cs | 12 +- Core/Resgrid.Services/UnitsService.cs | 8 +- .../WorkOrderAuthorizationService.cs | 67 +- Core/Resgrid.Services/WorkOrderFiles.cs | 4 +- .../WorkOrderNotificationService.cs | 21 +- Core/Resgrid.Services/WorkOrdersService.cs | 3 +- .../WorkflowSampleDataGenerator.cs | 90 +- Core/Resgrid.Services/WorkflowService.cs | 2 +- .../WorkflowTemplateContextBuilder.cs | 50 +- .../M0198_AddInventoryModernization.cs | 179 ++++ .../M0199_FenceLegacyInventoryWrites.cs | 48 + .../M0198_AddInventoryModernizationPg.cs | 179 ++++ .../M0199_FenceLegacyInventoryWritesPg.cs | 55 ++ .../ChecklistDepartmentCleanup.cs | 15 +- .../ChecklistRepository.cs | 9 +- .../InventoryDepartmentCleanup.cs | 65 ++ .../InventoryStore.cs | 164 ++++ .../Modules/ApiDataModule.cs | 1 + .../Modules/DataModule.cs | 1 + .../Modules/NonWebDataModule.cs | 1 + .../Modules/TestingDataModule.cs | 1 + .../ReadinessProBillingRepository.cs | 8 +- .../RmsRepositories.cs | 8 +- .../WorkOrderRepository.cs | 2 +- .../Allocations/trigger-baseline.json | 7 +- .../Rms/RmsIdentifierPinTests.cs | 15 +- .../Services/ChecklistEventDeliveryTests.cs | 2 +- .../Services/ChecklistGdprTests.cs | 5 +- .../Services/ChecklistPr504BoundaryTests.cs | 2 +- .../Services/ChecklistPr504SecurityTests.cs | 4 +- .../Services/ChecklistPr504ServiceTests.cs | 3 +- .../Services/ChecklistPr505Tests.cs | 108 +++ .../Services/GdprExportProtectedDataTests.cs | 2 +- .../Services/InventoryApiTests.cs | 213 +++++ .../Services/InventoryAuthorizationTests.cs | 159 ++++ .../Services/InventoryDatabaseFixture.cs | 231 +++++ .../Services/InventoryDatabaseTests.cs | 368 ++++++++ .../Services/InventoryGdprTests.cs | 158 ++++ .../Services/InventoryHolderRetentionTests.cs | 113 +++ .../Services/InventoryModernizationTests.cs | 860 ++++++++++++++++++ .../Services/InventoryWorkflowTests.cs | 217 +++++ .../ReadinessProBillingClientTests.cs | 69 ++ .../Services/RmsInventoryModernUsageTests.cs | 202 ++++ .../Services/WorkOrderAuthorizationTests.cs | 47 + .../Services/WorkOrderDatabaseTests.cs | 6 + .../Services/WorkOrderEvidenceTests.cs | 6 +- .../Services/WorkOrderGdprTests.cs | 2 +- .../Services/WorkOrderNotificationTests.cs | 12 +- .../Services/WorkOrderP2M1Tests.cs | 2 +- .../Services/WorkOrderPr505Tests.cs | 108 +++ .../Web/User/InventoryWorkspaceTests.cs | 158 ++++ .../Web/User/SecurityControllerTests.cs | 33 +- .../Web/inventory-modern.test.cjs | 153 ++++ Tests/Resgrid.Tests/Web/work-orders.test.cjs | 7 +- .../Tools/InventoryToolProvider.cs | 86 +- .../v4/ChecklistManagementController.cs | 2 +- .../Controllers/v4/InventoryController.cs | 225 +++++ .../v4/RecordInventoryController.cs | 7 +- .../Models/v4/Inventory/InventoryApiModels.cs | 57 ++ .../Resgrid.Web.Services.xml | 15 + .../Controllers/ChecklistReportsController.cs | 2 +- .../User/Controllers/GroupsController.cs | 5 +- .../User/Controllers/InventoryController.cs | 455 +++------ .../Controllers/RecordsInventoryController.cs | 141 ++- .../User/Controllers/SecurityController.cs | 4 +- .../Areas/User/Controllers/UnitsController.cs | 5 +- .../User/Controllers/WorkOrdersController.cs | 2 +- .../Inventory/InventoryWorkspaceView.cs | 36 + .../User/Models/Records/RecordsViewModels.cs | 9 + .../Models/Security/RecordsPermissionRow.cs | 2 + .../User/Views/Inventory/Workspace.cshtml | 155 ++++ .../User/Views/Personnel/ViewPerson.cshtml | 2 + .../User/Views/RecordsInventory/Edit.cshtml | 59 +- .../Areas/User/Views/Reports/Index.cshtml | 3 +- .../Areas/User/Views/Units/EditUnit.cshtml | 2 + .../Areas/User/Views/WorkOrders/Index.cshtml | 4 +- .../Areas/User/Views/Workflows/Edit.cshtml | 2 +- .../Areas/User/Views/Workflows/New.cshtml | 2 +- .../internal/inventory/inventory-modern.js | 67 ++ .../Logic/ReportDeliveryLogic.cs | 2 +- 154 files changed, 10621 insertions(+), 521 deletions(-) create mode 100644 Core/Resgrid.Model/Inventories/InventoryContracts.cs create mode 100644 Core/Resgrid.Model/Inventories/InventoryModels.cs create mode 100644 Core/Resgrid.Model/Inventories/InventoryPermissionCatalog.cs create mode 100644 Core/Resgrid.Model/Inventories/InventoryQuery.cs create mode 100644 Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs create mode 100644 Core/Resgrid.Model/Repositories/IInventoryStore.cs create mode 100644 Core/Resgrid.Model/Services/IInventoryModernizationService.cs create mode 100644 Core/Resgrid.Services/InventoryAuthorizationService.cs create mode 100644 Core/Resgrid.Services/InventoryCatalog.cs create mode 100644 Core/Resgrid.Services/InventoryChecklistAssets.cs create mode 100644 Core/Resgrid.Services/InventoryGdprExport.cs create mode 100644 Core/Resgrid.Services/InventoryHolderRetention.cs create mode 100644 Core/Resgrid.Services/InventoryIssuance.cs create mode 100644 Core/Resgrid.Services/InventoryLegacyMigration.cs create mode 100644 Core/Resgrid.Services/InventoryModernizationService.cs create mode 100644 Core/Resgrid.Services/InventoryPosting.cs create mode 100644 Core/Resgrid.Services/InventoryQueries.cs create mode 100644 Core/Resgrid.Services/InventoryReferences.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0199_FenceLegacyInventoryWrites.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0199_FenceLegacyInventoryWritesPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistPr505Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryApiTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryGdprTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ReadinessProBillingClientTests.cs create mode 100644 Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/InventoryWorkspaceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/inventory-modern.test.cjs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Inventory/InventoryApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-modern.js diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index aaef61f01..c57e1e43d 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -677,4 +677,5 @@ يتطلب حفظ أدلة الجاهزية بلاغاً ويغطي الثلاثين يوماً السابقة له؛ تواريخ التغطية المخصصة غير مدعومة. نوع الهدف المحطة / المجموعة + قوائم التحقق معطلة لهذا القسم. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index eab17b90a..6ad6d747b 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -677,4 +677,5 @@ Die Erfassung von Bereitschaftsnachweisen erfordert einen Einsatz und umfasst die 30 Tage davor; eigene Zeiträume werden nicht unterstützt. Zieltyp Wache / Gruppe + Checklisten sind für diese Abteilung deaktiviert. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 68f0c9af3..9621352ce 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -677,4 +677,5 @@ Η καταγραφή τεκμηρίων ετοιμότητας απαιτεί συμβάν και καλύπτει τις προηγούμενες 30 ημέρες· προσαρμοσμένες ημερομηνίες δεν υποστηρίζονται. Τύπος στόχου Σταθμός / ομάδα + Οι λίστες ελέγχου είναι απενεργοποιημένες για αυτό το τμήμα. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index c4e399d6a..590f1350b 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -677,4 +677,5 @@ Readiness evidence capture requires a call and uses the 30 days before that call; custom coverage dates are not supported. Target type Station / group + Checklists are disabled for this department. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index f49829e0a..46c81bcc1 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -677,4 +677,5 @@ La captura de evidencias de preparación requiere un incidente y abarca los 30 días anteriores; no se admiten fechas de cobertura personalizadas. Tipo de objetivo Estación / grupo + Las listas de verificación están deshabilitadas para este departamento. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index 48b474147..f82a65642 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -677,4 +677,5 @@ La capture des éléments de préparation exige une intervention et couvre les 30 jours précédents ; les dates de couverture personnalisées ne sont pas prises en charge. Type de cible Caserne / groupe + Les listes de contrôle sont désactivées pour ce département. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index 4da3f6498..d5936258e 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -677,4 +677,5 @@ L’acquisizione delle evidenze di prontezza richiede un intervento e copre i 30 giorni precedenti; non sono supportate date personalizzate. Tipo di destinatario Stazione / gruppo + Le liste di controllo sono disabilitate per questo reparto. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 88dcfd555..0d635ffda 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -677,4 +677,5 @@ Utrwalenie dowodów gotowości wymaga zdarzenia i obejmuje poprzednie 30 dni; własne daty zakresu nie są obsługiwane. Typ celu Stacja / grupa + Listy kontrolne są wyłączone dla tego działu. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index c4e399d6a..590f1350b 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -677,4 +677,5 @@ Readiness evidence capture requires a call and uses the 30 days before that call; custom coverage dates are not supported. Target type Station / group + Checklists are disabled for this department. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index da140c9c3..fd9f460d0 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -677,4 +677,5 @@ Insamling av beredskapsunderlag kräver en insats och omfattar de föregående 30 dagarna; egna täckningsdatum stöds inte. Måltyp Station / grupp + Checklistor är inaktiverade för den här avdelningen. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index 2f8f17157..c456c3628 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -677,4 +677,5 @@ Збереження доказів готовності потребує події та охоплює попередні 30 днів; довільні дати періоду не підтримуються. Тип цілі Станція / група + Контрольні списки вимкнено для цього підрозділу. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx index c7ff6f421..8e79463d9 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx @@ -33,4 +33,319 @@ وحدة القياس عرض السجل عرض إدخال المخزون + + الإجراءات + + + إضافة صنف + + + أرشفة + + + تفاصيل الأصل + + + معرّف الأصل + + + الأصول + + + وسم الأصل + + + إقرار الشاهد + + + الرمز الشريطي + + + الفئات + + + قوائم التحقق + + + الرمز + + + معرّف الأصل الحاوي + + + الموقع الافتراضي + + + الوصف + + + التفاصيل + + + تاريخ الإرجاع المتوقع + + + تاريخ انتهاء الصلاحية + + + موقع المصدر + + + السجل + + + اختر شخصًا واحدًا أو وحدة واحدة لاستلام العهدة. + + + تهيئة المخزون + + + هيّئ مساحة المخزون باستيراد الأصناف والأرصدة والسجل الحالي. + + + المخزون + + + نشط + + + مادة خاضعة للرقابة + + + صنف ضمن طقم + + + عمليات الصرف + + + بانتظار الإرجاع + + + مُرجع + + + مُرجع جزئيًا + + + مفقود + + + مستهلك + + + صرف + + + الأصناف + + + الأطقم + + + الموقع + + + المواقع + + + نوع الموقع + + + منشأة + + + محطة + + + وحدة + + + أفراد + + + حاوية + + + خارجي + + + معرّف الدُفعة + + + الدُفعات + + + الحد الأدنى للمخزون + + + تُعرض الصفحة الأولى فقط من الخيارات المتاحة. + + + حركة مخزون + + + رصيد افتتاحي مستورد + + + استلام + + + استهلاك + + + تحويل + + + صرف + + + إرجاع + + + تسوية + + + جرد + + + شطب + + + تغيير الحالة + + + أدخل كمية موجبة. يتطلب الاستلام موقع وجهة، ويتطلب الاستهلاك والشطب موقع مصدر. تتطلب التسوية موقع مصدر أو موقع وجهة. يتطلب التحويل موقعين مختلفين. + + + الاسم + + + أصل جديد + + + صنف جديد + + + التالي + + + لا + + + لا توجد سجلات مخزون لعرضها. + + + ملاحظة + + + الرصيد المتاح + + + العنصر الأعلى + + + الشخص + + + تجهيزات الأفراد + + + تسجيل الحركة + + + السابق + + + تحقّق من هويتك لعرض المخزون المحمي. + + + إعادة حساب أرصدة المخزون + + + المرجع + + + نقطة إعادة الطلب + + + تاريخ انتهاء الصلاحية مطلوب + + + تتبع الدُفعات + + + إرجاع + + + حفظ + + + الرقم التسلسلي + + + الحالة + + + قيد الخدمة + + + مصروف + + + قيد الإصلاح + + + تالف + + + مفقود + + + مستهلك + + + خارج الخدمة + + + موقع الوجهة + + + حسب الكمية + + + حسب الرقم التسلسلي + + + طريقة التتبع + + + التحويلات + + + تعذّر إكمال هذا الإجراء على المخزون. + + + تكلفة الوحدة + + + تجهيزات الوحدة + + + إلغاء القفل + + + التصديق بصفة شاهد + + + بانتظار شاهد مخوّل آخر غير منفّذ العملية. معرّف الطلب: + + + معرّف طلب الشهادة + + + أوامر العمل + + + نعم + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx index 87a0ffa4c..6cae42f83 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx @@ -157,5 +157,320 @@ Inventareintrag anzeigen + + Aktionen + + + Artikel hinzufügen + + + Archivieren + + + Gerätedetails + + + Geräte-ID + + + Einzelgeräte + + + Inventarnummer + + + Zeugenbestätigung + + + Strichcode + + + Kategorien + + + Checklisten + + + Code + + + ID des Behältergeräts + + + Standardlagerort + + + Beschreibung + + + Details + + + Voraussichtliches Rückgabedatum + + + Verfallsdatum + + + Quelllagerort + + + Verlauf + + + Wählen Sie eine Person oder eine Einheit als Empfänger des Inventars aus. + + + Inventar initialisieren + + + Initialisieren Sie den Inventarbereich durch Import vorhandener Artikel, Bestände und Verlaufsdaten. + + + Inventar + + + Aktiv + + + Kontrollierte Substanz + + + Set-Artikel + + + Ausgaben + + + Noch ausstehend + + + Zurückgegeben + + + Teilweise zurückgegeben + + + Verloren + + + Verbraucht + + + Ausgeben + + + Artikel + + + Sets + + + Lagerort + + + Lagerorte + + + Lagerorttyp + + + Einrichtung + + + Wache + + + Einheit + + + Personal + + + Behälter + + + Extern + + + Chargen-ID + + + Chargen + + + Mindestbestand + + + Es wird nur die erste Seite der verfügbaren Auswahlmöglichkeiten angezeigt. + + + Bestandsbewegung + + + Anfangsbestand importiert + + + Einlagern + + + Verbrauchen + + + Umlagern + + + Ausgeben + + + Zurückgeben + + + Korrigieren + + + Zählen + + + Abschreiben + + + Status ändern + + + Geben Sie eine positive Bewegungsmenge ein. Zugänge benötigen einen Zielort, Verbrauch und Abschreibungen einen Quellort. Korrekturen benötigen entweder einen Quell- oder einen Zielort. Umlagerungen benötigen zwei verschiedene Orte. + + + Name + + + Neues Gerät + + + Neuer Artikel + + + Weiter + + + Nein + + + Keine Inventareinträge vorhanden. + + + Notiz + + + Bestand + + + Übergeordnetes Element + + + Person + + + Persönliche Ausrüstung + + + Bewegung buchen + + + Zurück + + + Bestätigen Sie Ihre Identität, um geschütztes Inventar anzuzeigen. + + + Bestände neu berechnen + + + Referenz + + + Meldebestand + + + Verfallsdatum erforderlich + + + Chargen erfassen + + + Zurückgeben + + + Speichern + + + Seriennummer + + + Status + + + Im Einsatz + + + Ausgegeben + + + In Reparatur + + + Beschädigt + + + Verloren + + + Verbraucht + + + Ausgemustert + + + Ziellagerort + + + Nach Menge + + + Nach Seriennummer + + + Erfassungsart + + + Umlagerungen + + + Diese Inventaraktion konnte nicht abgeschlossen werden. + + + Stückkosten + + + Einheitsausrüstung + + + Entsperren + + + Als Zeuge bestätigen + + + Bestätigung durch eine andere berechtigte Person ausstehend. Anfrage-ID: + + + ID der Zeugenanfrage + + + Arbeitsaufträge + + + Ja + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx index 81eb14fd7..b9ca86794 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx @@ -216,5 +216,320 @@ Προβολή Καταχώρισης Αποθέματος + + Ενέργειες + + + Προσθήκη είδους + + + Αρχειοθέτηση + + + Λεπτομέρειες παγίου + + + Αναγνωριστικό παγίου + + + Πάγια + + + Ετικέτα παγίου + + + Βεβαίωση μάρτυρα + + + Γραμμωτός κώδικας + + + Κατηγορίες + + + Λίστες ελέγχου + + + Κωδικός + + + Αναγνωριστικό παγίου περιέκτη + + + Προεπιλεγμένη τοποθεσία + + + Περιγραφή + + + Λεπτομέρειες + + + Αναμενόμενη ημερομηνία επιστροφής + + + Ημερομηνία λήξης + + + Τοποθεσία προέλευσης + + + Ιστορικό + + + Επιλέξτε ένα άτομο ή μία μονάδα που θα παραλάβει το υλικό. + + + Αρχικοποίηση αποθεμάτων + + + Αρχικοποιήστε τον χώρο αποθεμάτων εισάγοντας τα υπάρχοντα είδη, υπόλοιπα και το ιστορικό. + + + Αποθέματα + + + Ενεργό + + + Ελεγχόμενη ουσία + + + Είδος σετ + + + Χορηγήσεις + + + Εκκρεμεί επιστροφή + + + Επιστράφηκε + + + Επιστράφηκε μερικώς + + + Απολεσθέν + + + Καταναλωμένο + + + Χορήγηση + + + Είδη + + + Σετ + + + Τοποθεσία + + + Τοποθεσίες + + + Τύπος τοποθεσίας + + + Εγκατάσταση + + + Σταθμός + + + Μονάδα + + + Προσωπικό + + + Περιέκτης + + + Εξωτερική + + + Αναγνωριστικό παρτίδας + + + Παρτίδες + + + Ελάχιστο απόθεμα + + + Εμφανίζεται μόνο η πρώτη σελίδα των διαθέσιμων επιλογών. + + + Κίνηση αποθέματος + + + Εισαγωγή αρχικού υπολοίπου + + + Παραλαβή + + + Κατανάλωση + + + Μεταφορά + + + Χορήγηση + + + Επιστροφή + + + Προσαρμογή + + + Καταμέτρηση + + + Διαγραφή + + + Αλλαγή κατάστασης + + + Εισαγάγετε θετική ποσότητα. Οι παραλαβές απαιτούν προορισμό, ενώ η κατανάλωση και οι διαγραφές απαιτούν προέλευση. Οι προσαρμογές απαιτούν είτε προέλευση είτε προορισμό. Οι μεταφορές απαιτούν δύο διαφορετικές τοποθεσίες. + + + Όνομα + + + Νέο πάγιο + + + Νέο είδος + + + Επόμενη + + + Όχι + + + Δεν υπάρχουν εγγραφές αποθεμάτων προς εμφάνιση. + + + Σημείωση + + + Διαθέσιμο απόθεμα + + + Γονικό στοιχείο + + + Άτομο + + + Ατομικός εξοπλισμός + + + Καταχώριση κίνησης + + + Προηγούμενη + + + Επαληθεύστε την ταυτότητά σας για να δείτε προστατευμένα αποθέματα. + + + Επανυπολογισμός υπολοίπων + + + Αναφορά + + + Όριο αναπαραγγελίας + + + Απαιτείται ημερομηνία λήξης + + + Παρακολούθηση παρτίδων + + + Επιστροφή + + + Αποθήκευση + + + Σειριακός αριθμός + + + Κατάσταση + + + Σε χρήση + + + Χορηγημένο + + + Σε επισκευή + + + Κατεστραμμένο + + + Απολεσθέν + + + Καταναλωμένο + + + Αποσυρμένο + + + Τοποθεσία προορισμού + + + Ανά ποσότητα + + + Ανά σειριακό αριθμό + + + Τρόπος παρακολούθησης + + + Μεταφορές + + + Δεν ήταν δυνατή η ολοκλήρωση αυτής της ενέργειας αποθεμάτων. + + + Κόστος μονάδας + + + Εξοπλισμός μονάδας + + + Ξεκλείδωμα + + + Βεβαίωση ως μάρτυρας + + + Αναμονή για διαφορετικό εξουσιοδοτημένο μάρτυρα. Αναγνωριστικό αιτήματος: + + + Αναγνωριστικό αιτήματος μάρτυρα + + + Εντολές εργασίας + + + Ναι + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx index 2487990f5..f5df0ed4a 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx @@ -216,5 +216,320 @@ View Inventory Entry + + Actions + + + Add item + + + Archive + + + Asset details + + + Asset ID + + + Assets + + + Asset tag + + + Witness attestation + + + Barcode + + + Categories + + + Checklists + + + Code + + + Container asset ID + + + Default location + + + Description + + + Details + + + Expected return date + + + Expiration date + + + Source location + + + History + + + Select one person or one unit to receive the inventory. + + + Initialize inventory + + + Initialize the inventory workspace by importing existing inventory, balances, and history. + + + Inventory + + + Active + + + Controlled substance + + + Kit item + + + Issuances + + + Outstanding + + + Returned + + + Partially returned + + + Lost + + + Consumed + + + Issue + + + Items + + + Kits + + + Location + + + Locations + + + Location type + + + Facility + + + Station + + + Unit + + + Personnel + + + Container + + + External + + + Lot ID + + + Lots + + + Minimum stock + + + Only the first page of available choices is shown. + + + Movement + + + Opening balance imported + + + Receive + + + Consume + + + Transfer + + + Issue + + + Return + + + Adjust + + + Count + + + Write off + + + Change status + + + Enter a positive quantity to move. Receipts require a destination; consumption and write-offs require a source. Adjustments require either a source or a destination. Transfers require two different locations. + + + Name + + + New asset + + + New item + + + Next + + + No + + + No inventory records to display. + + + Note + + + On hand + + + Parent + + + Person + + + Personnel gear + + + Record movement + + + Previous + + + Verify your identity to view protected inventory. + + + Rebuild stock balances + + + Reference + + + Reorder point + + + Expiration required + + + Track lots + + + Return + + + Save + + + Serial number + + + Status + + + In service + + + Issued + + + Out for repair + + + Damaged + + + Lost + + + Consumed + + + Retired + + + Destination location + + + By quantity + + + By serial number + + + Tracking mode + + + Transfers + + + Unable to complete this inventory action. + + + Unit cost + + + Unit equipment + + + Unlock + + + Attest as witness + + + Awaiting an independent authorized witness. Request ID: + + + Witness request ID + + + Work orders + + + Yes + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx index d1d7195c5..7ed029974 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx @@ -157,5 +157,320 @@ Ver entrada de inventario + + Acciones + + + Añadir artículo + + + Archivar + + + Detalles del activo + + + ID del activo + + + Activos + + + Etiqueta del activo + + + Declaración del testigo + + + Código de barras + + + Categorías + + + Listas de verificación + + + Código + + + ID del activo contenedor + + + Ubicación predeterminada + + + Descripción + + + Detalles + + + Fecha prevista de devolución + + + Fecha de caducidad + + + Ubicación de origen + + + Historial + + + Seleccione una persona o una unidad para recibir el inventario. + + + Inicializar inventario + + + Inicialice el espacio de inventario importando los artículos, saldos e historial existentes. + + + Inventario + + + Activo + + + Sustancia controlada + + + Artículo de kit + + + Entregas + + + Pendiente de devolución + + + Devuelto + + + Devuelto parcialmente + + + Perdido + + + Consumido + + + Entregar + + + Artículos + + + Kits + + + Ubicación + + + Ubicaciones + + + Tipo de ubicación + + + Instalación + + + Estación + + + Unidad + + + Personal + + + Contenedor + + + Externa + + + ID del lote + + + Lotes + + + Existencias mínimas + + + Solo se muestra la primera página de opciones disponibles. + + + Movimiento + + + Saldo inicial importado + + + Recibir + + + Consumir + + + Trasladar + + + Entregar + + + Devolver + + + Ajustar + + + Contar + + + Dar de baja + + + Cambiar estado + + + Introduzca una cantidad positiva. Las recepciones requieren un destino; el consumo y las bajas requieren un origen. Los ajustes requieren un origen o un destino. Los traslados requieren dos ubicaciones diferentes. + + + Nombre + + + Nuevo activo + + + Nuevo artículo + + + Siguiente + + + No + + + No hay registros de inventario para mostrar. + + + Nota + + + Existencias + + + Elemento superior + + + Persona + + + Equipo del personal + + + Registrar movimiento + + + Anterior + + + Verifique su identidad para ver el inventario protegido. + + + Reconstruir saldos de existencias + + + Referencia + + + Punto de reposición + + + Caducidad obligatoria + + + Seguimiento por lote + + + Devolver + + + Guardar + + + Número de serie + + + Estado + + + En servicio + + + Entregado + + + En reparación + + + Dañado + + + Perdido + + + Consumido + + + Retirado + + + Ubicación de destino + + + Por cantidad + + + Por número de serie + + + Modo de seguimiento + + + Traslados + + + No se pudo completar esta acción de inventario. + + + Coste unitario + + + Equipo de la unidad + + + Desbloquear + + + Atestiguar + + + Pendiente de un testigo autorizado distinto del responsable. ID de solicitud: + + + ID de solicitud de testigo + + + Órdenes de trabajo + + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx index 9aa9b25b4..754fc3884 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx @@ -157,5 +157,320 @@ Voir l'entrée d'inventaire + + Actions + + + Ajouter un article + + + Archiver + + + Détails de l’équipement + + + ID de l’équipement + + + Équipements + + + Étiquette d’inventaire + + + Attestation du témoin + + + Code-barres + + + Catégories + + + Listes de contrôle + + + Code + + + ID de l’équipement contenant + + + Emplacement par défaut + + + Description + + + Détails + + + Date de retour prévue + + + Date de péremption + + + Emplacement d’origine + + + Historique + + + Sélectionnez une personne ou une unité destinataire du matériel. + + + Initialiser l’inventaire + + + Initialisez l’espace d’inventaire en important les articles, les soldes et l’historique existants. + + + Inventaire + + + Actif + + + Substance contrôlée + + + Article de kit + + + Dotations + + + À retourner + + + Retourné + + + Partiellement retourné + + + Perdu + + + Consommé + + + Attribuer + + + Articles + + + Kits + + + Emplacement + + + Emplacements + + + Type d’emplacement + + + Établissement + + + Caserne + + + Unité + + + Personnel + + + Contenant + + + Externe + + + ID du lot + + + Lots + + + Stock minimum + + + Seule la première page des choix disponibles est affichée. + + + Mouvement + + + Solde initial importé + + + Réceptionner + + + Consommer + + + Transférer + + + Attribuer + + + Retourner + + + Ajuster + + + Compter + + + Sortir du stock + + + Changer l’état + + + Saisissez une quantité positive. Les réceptions nécessitent une destination ; les consommations et les sorties définitives nécessitent une origine. Les ajustements nécessitent une origine ou une destination. Les transferts nécessitent deux emplacements différents. + + + Nom + + + Nouvel équipement + + + Nouvel article + + + Suivant + + + Non + + + Aucun enregistrement d’inventaire à afficher. + + + Note + + + Stock disponible + + + Élément parent + + + Personne + + + Équipement du personnel + + + Enregistrer le mouvement + + + Précédent + + + Vérifiez votre identité pour consulter l’inventaire protégé. + + + Recalculer les soldes de stock + + + Référence + + + Seuil de réapprovisionnement + + + Date de péremption obligatoire + + + Suivi des lots + + + Retourner + + + Enregistrer + + + Numéro de série + + + État + + + En service + + + Attribué + + + En réparation + + + Endommagé + + + Perdu + + + Consommé + + + Retiré du service + + + Emplacement de destination + + + Par quantité + + + Par numéro de série + + + Mode de suivi + + + Transferts + + + Impossible d’effectuer cette action sur l’inventaire. + + + Coût unitaire + + + Équipement de l’unité + + + Déverrouiller + + + Attester comme témoin + + + En attente d’un témoin habilité distinct de l’opérateur. ID de la demande : + + + ID de demande de témoin + + + Ordres de travail + + + Oui + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx index 29a8066bb..d8cb7d6cc 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx @@ -157,5 +157,320 @@ Visualizza voce di inventario + + Azioni + + + Aggiungi articolo + + + Archivia + + + Dettagli del bene + + + ID del bene + + + Beni + + + Etichetta del bene + + + Attestazione del testimone + + + Codice a barre + + + Categorie + + + Liste di controllo + + + Codice + + + ID del bene contenitore + + + Ubicazione predefinita + + + Descrizione + + + Dettagli + + + Data di restituzione prevista + + + Data di scadenza + + + Ubicazione di origine + + + Cronologia + + + Seleziona una persona o un’unità a cui assegnare il materiale. + + + Inizializza inventario + + + Inizializza l’area inventario importando articoli, giacenze e cronologia esistenti. + + + Inventario + + + Attivo + + + Sostanza controllata + + + Articolo del kit + + + Assegnazioni + + + Da restituire + + + Restituito + + + Restituito parzialmente + + + Smarrito + + + Consumato + + + Assegna + + + Articoli + + + Kit + + + Ubicazione + + + Ubicazioni + + + Tipo di ubicazione + + + Struttura + + + Stazione + + + Unità + + + Personale + + + Contenitore + + + Esterna + + + ID del lotto + + + Lotti + + + Scorta minima + + + Viene mostrata solo la prima pagina delle opzioni disponibili. + + + Movimento + + + Giacenza iniziale importata + + + Ricevi + + + Consuma + + + Trasferisci + + + Assegna + + + Restituisci + + + Rettifica + + + Conta + + + Dismetti + + + Cambia stato + + + Inserisci una quantità positiva. Le ricezioni richiedono una destinazione; i consumi e le dismissioni richiedono un’origine. Le rettifiche richiedono un’origine o una destinazione. I trasferimenti richiedono due ubicazioni diverse. + + + Nome + + + Nuovo bene + + + Nuovo articolo + + + Successivo + + + No + + + Nessun record di inventario da visualizzare. + + + Nota + + + Giacenze + + + Elemento superiore + + + Persona + + + Dotazione del personale + + + Registra movimento + + + Precedente + + + Verifica la tua identità per visualizzare l’inventario protetto. + + + Ricalcola le giacenze + + + Riferimento + + + Punto di riordino + + + Scadenza obbligatoria + + + Tracciamento dei lotti + + + Restituisci + + + Salva + + + Numero di serie + + + Stato + + + In servizio + + + Assegnato + + + In riparazione + + + Danneggiato + + + Smarrito + + + Consumato + + + Dismesso + + + Ubicazione di destinazione + + + Per quantità + + + Per numero di serie + + + Modalità di tracciamento + + + Trasferimenti + + + Impossibile completare questa operazione di inventario. + + + Costo unitario + + + Attrezzatura dell’unità + + + Sblocca + + + Attesta come testimone + + + In attesa di un testimone autorizzato diverso dall’operatore. ID richiesta: + + + ID della richiesta di testimone + + + Ordini di lavoro + + + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx index 2fa89b58b..e087ebe2b 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx @@ -157,5 +157,320 @@ Zobacz wpis inwentarza + + Działania + + + Dodaj artykuł + + + Archiwizuj + + + Szczegóły egzemplarza + + + Identyfikator egzemplarza + + + Egzemplarze sprzętu + + + Numer inwentarzowy + + + Oświadczenie świadka + + + Kod kreskowy + + + Kategorie + + + Listy kontrolne + + + Kod + + + Identyfikator pojemnika + + + Lokalizacja domyślna + + + Opis + + + Szczegóły + + + Planowana data zwrotu + + + Data ważności + + + Lokalizacja źródłowa + + + Historia + + + Wybierz jedną osobę lub jedną jednostkę jako odbiorcę wyposażenia. + + + Zainicjuj inwentarz + + + Zainicjuj obszar inwentarza, importując istniejące artykuły, stany magazynowe i historię. + + + Inwentarz + + + Aktywny + + + Substancja kontrolowana + + + Artykuł zestawu + + + Wydania + + + Do zwrotu + + + Zwrócony + + + Częściowo zwrócony + + + Zagubiony + + + Zużyty + + + Wydaj + + + Artykuły + + + Zestawy + + + Lokalizacja + + + Lokalizacje + + + Typ lokalizacji + + + Obiekt + + + Strażnica + + + Jednostka + + + Personel + + + Pojemnik + + + Zewnętrzna + + + Identyfikator partii + + + Partie + + + Zapas minimalny + + + Wyświetlana jest tylko pierwsza strona dostępnych opcji. + + + Ruch magazynowy + + + Zaimportowany stan początkowy + + + Przyjmij + + + Zużyj + + + Przesuń + + + Wydaj + + + Zwróć + + + Skoryguj + + + Przelicz + + + Zlikwiduj + + + Zmień stan + + + Wprowadź dodatnią ilość. Przyjęcia wymagają lokalizacji docelowej, a zużycie i likwidacja — źródłowej. Korekty wymagają lokalizacji źródłowej albo docelowej. Przesunięcia wymagają dwóch różnych lokalizacji. + + + Nazwa + + + Nowy egzemplarz + + + Nowy artykuł + + + Następna + + + Nie + + + Brak rekordów inwentarza do wyświetlenia. + + + Notatka + + + Stan magazynowy + + + Element nadrzędny + + + Osoba + + + Wyposażenie osobiste + + + Zarejestruj ruch + + + Poprzednia + + + Potwierdź tożsamość, aby wyświetlić chroniony inwentarz. + + + Przelicz stany magazynowe + + + Odwołanie + + + Próg ponownego zamówienia + + + Wymagana data ważności + + + Ewidencja partii + + + Zwróć + + + Zapisz + + + Numer seryjny + + + Stan + + + W użytkowaniu + + + Wydany + + + W naprawie + + + Uszkodzony + + + Zagubiony + + + Zużyty + + + Wycofany + + + Lokalizacja docelowa + + + Według ilości + + + Według numeru seryjnego + + + Sposób ewidencji + + + Przesunięcia + + + Nie można wykonać tej operacji na inwentarzu. + + + Koszt jednostkowy + + + Wyposażenie jednostki + + + Odblokuj + + + Potwierdź jako świadek + + + Oczekiwanie na inną uprawnioną osobę jako świadka. Identyfikator wniosku: + + + Identyfikator wniosku o świadka + + + Zlecenia pracy + + + Tak + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx index 498d90fa6..ee6772b8e 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx @@ -157,5 +157,320 @@ Visa lagerpost + + Åtgärder + + + Lägg till artikel + + + Arkivera + + + Utrustningsdetaljer + + + Utrustnings-ID + + + Utrustningsenheter + + + Inventarienummer + + + Vittnesintyg + + + Streckkod + + + Kategorier + + + Checklistor + + + Kod + + + Behållarens utrustnings-ID + + + Standardlagerplats + + + Beskrivning + + + Detaljer + + + Förväntat återlämningsdatum + + + Utgångsdatum + + + Avsändande lagerplats + + + Historik + + + Välj en person eller en enhet som ska ta emot utrustningen. + + + Initiera inventarier + + + Initiera inventarieområdet genom att importera befintliga artiklar, lagersaldon och historik. + + + Inventarier + + + Aktiv + + + Kontrollerad substans + + + Satsartikel + + + Utlämningar + + + Ej återlämnad + + + Återlämnad + + + Delvis återlämnad + + + Förlorad + + + Förbrukad + + + Lämna ut + + + Artiklar + + + Satser + + + Lagerplats + + + Lagerplatser + + + Typ av lagerplats + + + Anläggning + + + Station + + + Enhet + + + Personal + + + Behållare + + + Extern + + + Parti-ID + + + Partier + + + Minimilager + + + Endast den första sidan med tillgängliga alternativ visas. + + + Lagertransaktion + + + Ingående saldo importerat + + + Ta emot + + + Förbruka + + + Flytta + + + Lämna ut + + + Återlämna + + + Justera + + + Inventera + + + Utrangera + + + Ändra status + + + Ange en positiv mängd. Inleveranser kräver en mottagande plats; förbrukning och utrangering kräver en avsändande plats. Justeringar kräver antingen en avsändande eller en mottagande plats. Förflyttningar kräver två olika platser. + + + Namn + + + Ny utrustningsenhet + + + Ny artikel + + + Nästa + + + Nej + + + Det finns inga inventarieposter att visa. + + + Anteckning + + + Lagersaldo + + + Överordnat objekt + + + Person + + + Personlig utrustning + + + Registrera transaktion + + + Föregående + + + Verifiera din identitet för att visa skyddade inventarier. + + + Beräkna om lagersaldon + + + Referens + + + Beställningspunkt + + + Utgångsdatum krävs + + + Spåra partier + + + Återlämna + + + Spara + + + Serienummer + + + Status + + + I bruk + + + Utlämnad + + + På reparation + + + Skadad + + + Förlorad + + + Förbrukad + + + Tagen ur bruk + + + Mottagande lagerplats + + + Efter mängd + + + Efter serienummer + + + Spårningsmetod + + + Förflyttningar + + + Det gick inte att slutföra inventarieåtgärden. + + + Styckkostnad + + + Enhetens utrustning + + + Lås upp + + + Intyga som vittne + + + Inväntar ett annat behörigt vittne. Begärans ID: + + + ID för vittnesbegäran + + + Arbetsorder + + + Ja + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx index 7ef52a19e..1bf44b00e 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx @@ -157,5 +157,320 @@ Переглянути запис інвентарю + + Дії + + + Додати позицію + + + Архівувати + + + Відомості про одиницю майна + + + Ідентифікатор одиниці майна + + + Одиниці майна + + + Інвентарний номер + + + Засвідчення свідка + + + Штрихкод + + + Категорії + + + Контрольні списки + + + Код + + + Ідентифікатор майна-контейнера + + + Місце зберігання за замовчуванням + + + Опис + + + Відомості + + + Очікувана дата повернення + + + Дата закінчення строку придатності + + + Місце відправлення + + + Історія + + + Виберіть одну особу або один підрозділ як отримувача майна. + + + Ініціалізувати інвентар + + + Ініціалізуйте робочу область інвентаря, імпортувавши наявну номенклатуру, залишки та історію. + + + Інвентар + + + Активна + + + Контрольована речовина + + + Позиція комплекту + + + Видачі + + + Очікує повернення + + + Повернуто + + + Частково повернуто + + + Втрачено + + + Використано + + + Видати + + + Номенклатура + + + Комплекти + + + Місце зберігання + + + Місця зберігання + + + Тип місця зберігання + + + Об’єкт + + + Станція + + + Підрозділ + + + Персонал + + + Контейнер + + + Зовнішнє + + + Ідентифікатор партії + + + Партії + + + Мінімальний запас + + + Показано лише першу сторінку доступних варіантів. + + + Рух запасів + + + Початковий залишок імпортовано + + + Прийняти + + + Використати + + + Перемістити + + + Видати + + + Повернути + + + Скоригувати + + + Провести інвентаризацію + + + Списати + + + Змінити стан + + + Укажіть додатну кількість. Надходження потребують місця призначення, а використання та списання — місця відправлення. Коригування потребують місця відправлення або призначення. Переміщення потребують двох різних місць. + + + Назва + + + Нова одиниця майна + + + Нова позиція + + + Наступна + + + Ні + + + Немає записів інвентаря для відображення. + + + Примітка + + + Залишки + + + Батьківський елемент + + + Особа + + + Особисте спорядження + + + Зареєструвати рух + + + Попередня + + + Підтвердьте свою особу, щоб переглянути захищений інвентар. + + + Перерахувати залишки + + + Посилання + + + Поріг повторного замовлення + + + Строк придатності обов’язковий + + + Облік за партіями + + + Повернути + + + Зберегти + + + Серійний номер + + + Стан + + + В експлуатації + + + Видано + + + На ремонті + + + Пошкоджено + + + Втрачено + + + Використано + + + Виведено з експлуатації + + + Місце призначення + + + За кількістю + + + За серійним номером + + + Спосіб обліку + + + Переміщення + + + Не вдалося виконати цю дію з інвентарем. + + + Вартість одиниці + + + Оснащення підрозділу + + + Розблокувати + + + Засвідчити як свідок + + + Очікується засвідчення іншою уповноваженою особою. Ідентифікатор запиту: + + + Ідентифікатор запиту на свідка + + + Наряди на роботи + + + Так + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx index f657f8450..bd6798388 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx @@ -392,4 +392,22 @@ من يمكنه نقل مسودة سجل إلى مؤلف آخر، مثلاً عندما يكون المؤلف الأصلي غير متاح. إدارة بيانات الوقاية من يستطيع إنشاء وتعديل المباني وبرامج التفتيش ومجموعات القوانين وصنابير الإطفاء والتصاريح وأنشطة الحد من مخاطر المجتمع. تتطلب قراءة بيانات الوقاية الوصول إلى السجلات فقط؛ أما التحقيقات فتخضع لصلاحية السجلات المقيدة وعضوية القضية. + + صرف المخزون وإرجاعه + + + يحدد من يمكنه صرف المخزون وإرجاعه. يُسمح افتراضيًا لمسؤولي الإدارة. + + + إدارة المواد الخاضعة للرقابة + + + يحدد من يمكنه تسجيل معاملات المواد الخاضعة للرقابة والشهادة عليها. يُسمح افتراضيًا لمسؤولي الإدارة. يجب أن يشهد على كل معاملة شخص مخوّل آخر غير منفّذها. + + + تحويل المخزون + + + يحدد من يمكنه تحويل المخزون بين المواقع. يُسمح افتراضيًا لمسؤولي الإدارة. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx index 4f0529969..9e94ff742 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx @@ -994,4 +994,22 @@ Wer einen Berichtsentwurf einem anderen Verfasser zuweisen darf, etwa wenn der ursprüngliche Verfasser nicht verfügbar ist. Präventionsdaten verwalten Wer Objekte, Prüfprogramme und Vorschriftensätze, Hydranten, Genehmigungen und Aktivitäten zur Risikominderung anlegen und ändern darf. Das Lesen von Präventionsdaten erfordert nur Zugriff auf Berichte; Ermittlungen unterliegen der Berechtigung für eingeschränkte Berichte und der Fallmitgliedschaft. + + Inventar ausgeben und zurücknehmen + + + Legt fest, wer Inventar ausgeben und zurücknehmen darf. Standardmäßig sind Abteilungsadministratoren berechtigt. + + + Kontrollierte Substanzen verwalten + + + Legt fest, wer Buchungen kontrollierter Substanzen erfassen und bezeugen darf. Standardmäßig sind Abteilungsadministratoren berechtigt. Jede Buchung muss von einer anderen berechtigten Person bezeugt werden. + + + Inventar umlagern + + + Legt fest, wer Inventar zwischen Lagerorten umlagern darf. Standardmäßig sind Abteilungsadministratoren berechtigt. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx index 468966014..262a1be8d 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx @@ -439,6 +439,24 @@ Ποιος μπορεί να μεταφέρει ένα πρόχειρο αναφοράς σε άλλον συντάκτη, για παράδειγμα όταν ο αρχικός συντάκτης δεν είναι διαθέσιμος. Διαχείριση δεδομένων πρόληψης Ποιος μπορεί να δημιουργεί και να αλλάζει κτίρια, προγράμματα επιθεώρησης και σύνολα κωδίκων, πυροσβεστικούς κρουνούς, άδειες και δράσεις μείωσης κινδύνου στην κοινότητα. Η ανάγνωση δεδομένων πρόληψης απαιτεί μόνο πρόσβαση στα Αρχεία· οι έρευνες διέπονται από το δικαίωμα περιορισμένων αρχείων και τη συμμετοχή στην υπόθεση. + + Χορήγηση και επιστροφή αποθεμάτων + + + Καθορίζει ποιος μπορεί να χορηγεί και να επιστρέφει αποθέματα. Από προεπιλογή, επιτρέπεται στους διαχειριστές του τμήματος. + + + Διαχείριση ελεγχόμενων ουσιών + + + Καθορίζει ποιος μπορεί να καταχωρίζει και να βεβαιώνει συναλλαγές ελεγχόμενων ουσιών. Από προεπιλογή, επιτρέπεται στους διαχειριστές του τμήματος. Κάθε συναλλαγή πρέπει να βεβαιώνεται από διαφορετικό εξουσιοδοτημένο άτομο. + + + Μεταφορά αποθεμάτων + + + Καθορίζει ποιος μπορεί να μεταφέρει αποθέματα μεταξύ τοποθεσιών. Από προεπιλογή, επιτρέπεται στους διαχειριστές του τμήματος. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index 04c8e0712..ad69dafd4 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -439,6 +439,24 @@ Who can move a draft record to a different author, for example when the original author is unavailable. Manage Prevention Data Who can create and change occupancies, inspection programs and code sets, hydrants, permits and community risk reduction activities. Reading prevention data needs only Records access; investigations are governed by the restricted-records permission plus case membership. + + Issue and return inventory + + + Controls who can issue and return inventory. Defaults to department administrators. + + + Manage controlled substances + + + Controls who can record and witness controlled-substance transactions. Defaults to department administrators. A different authorized person must witness each transaction. + + + Transfer inventory + + + Controls who can transfer inventory between locations. Defaults to department administrators. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx index 138829d2d..197ed75c9 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx @@ -398,6 +398,24 @@ Quién puede trasladar un borrador de registro a otro autor, por ejemplo cuando el autor original no está disponible. Gestionar datos de prevención Quién puede crear y modificar ocupaciones, programas de inspección y conjuntos de códigos, hidrantes, permisos y actividades de reducción de riesgos comunitarios. Leer datos de prevención solo requiere acceso a Registros; las investigaciones se rigen por el permiso de registros restringidos y la pertenencia al caso. + + Entregar y devolver inventario + + + Controla quién puede entregar y devolver inventario. Por defecto, se permite a los administradores del departamento. + + + Gestionar sustancias controladas + + + Controla quién puede registrar y atestiguar transacciones de sustancias controladas. Por defecto, se permite a los administradores del departamento. Cada transacción debe ser atestiguada por otra persona autorizada. + + + Trasladar inventario + + + Controla quién puede trasladar inventario entre ubicaciones. Por defecto, se permite a los administradores del departamento. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx index 1e2951780..a56786c30 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx @@ -994,4 +994,22 @@ Qui peut transférer un brouillon de rapport à un autre auteur, par exemple lorsque l'auteur d'origine est indisponible. Gérer les données de prévention Qui peut créer et modifier les occupations, les programmes d'inspection et les recueils de codes, les bornes d'incendie, les permis et les activités de réduction des risques communautaires. La lecture des données de prévention ne demande que l'accès aux dossiers ; les enquêtes relèvent de la permission des dossiers restreints et de l'appartenance au dossier. + + Attribuer et retourner l’inventaire + + + Définit qui peut attribuer et retourner l’inventaire. Par défaut, cette autorisation est réservée aux administrateurs du département. + + + Gérer les substances contrôlées + + + Définit qui peut enregistrer et attester les transactions de substances contrôlées. Par défaut, cette autorisation est réservée aux administrateurs du département. Chaque transaction doit être attestée par une autre personne habilitée. + + + Transférer l’inventaire + + + Définit qui peut transférer l’inventaire entre emplacements. Par défaut, cette autorisation est réservée aux administrateurs du département. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx index fd594503a..77341f294 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx @@ -994,4 +994,22 @@ Chi può trasferire una bozza di rapporto a un altro autore, ad esempio quando l'autore originale non è disponibile. Gestire i dati di prevenzione Chi può creare e modificare occupazioni, programmi di ispezione e raccolte di norme, idranti, permessi e attività di riduzione del rischio comunitario. La lettura dei dati di prevenzione richiede solo l'accesso ai Registri; le indagini sono regolate dal permesso sui registri riservati e dall'appartenenza al caso. + + Assegnare e restituire inventario + + + Definisce chi può assegnare e restituire l’inventario. Per impostazione predefinita, sono autorizzati gli amministratori del dipartimento. + + + Gestire sostanze controllate + + + Definisce chi può registrare e attestare transazioni di sostanze controllate. Per impostazione predefinita, sono autorizzati gli amministratori del dipartimento. Ogni transazione deve essere attestata da un’altra persona autorizzata. + + + Trasferire inventario + + + Definisce chi può trasferire l’inventario tra ubicazioni. Per impostazione predefinita, sono autorizzati gli amministratori del dipartimento. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx index c67159703..6dbd6596b 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx @@ -994,4 +994,22 @@ Kto może przekazać wersję roboczą raportu innemu autorowi, np. gdy pierwotny autor jest niedostępny. Zarządzanie danymi prewencji Kto może tworzyć i zmieniać obiekty, programy kontroli i zbiory przepisów, hydranty, zezwolenia oraz działania na rzecz ograniczania ryzyka w społeczności. Odczyt danych prewencji wymaga jedynie dostępu do Rejestrów; dochodzenia podlegają uprawnieniu do rejestrów zastrzeżonych i członkostwu w sprawie. + + Wydawanie i zwracanie inwentarza + + + Określa, kto może wydawać i zwracać inwentarz. Domyślnie uprawnieni są administratorzy departamentu. + + + Zarządzanie substancjami kontrolowanymi + + + Określa, kto może rejestrować i poświadczać transakcje dotyczące substancji kontrolowanych. Domyślnie uprawnieni są administratorzy departamentu. Każdą transakcję musi poświadczyć inna uprawniona osoba. + + + Przesuwanie inwentarza + + + Określa, kto może przesuwać inwentarz między lokalizacjami. Domyślnie uprawnieni są administratorzy departamentu. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx index 21db8cac9..55c1e1abe 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx @@ -994,4 +994,22 @@ Vem som får flytta ett rapportutkast till en annan författare, till exempel när den ursprungliga författaren inte är tillgänglig. Hantera förebyggande data Vem som får skapa och ändra objekt, tillsynsprogram och regelverk, brandposter, tillstånd och aktiviteter för att minska risker i samhället. Att läsa förebyggande data kräver bara åtkomst till Register; utredningar styrs av behörigheten för begränsade register och medlemskap i ärendet. + + Lämna ut och återlämna inventarier + + + Styr vem som får lämna ut och återlämna inventarier. Som standard har avdelningsadministratörer behörighet. + + + Hantera kontrollerade substanser + + + Styr vem som får registrera och bevittna transaktioner med kontrollerade substanser. Som standard har avdelningsadministratörer behörighet. Varje transaktion måste bevittnas av en annan behörig person. + + + Flytta inventarier + + + Styr vem som får flytta inventarier mellan lagerplatser. Som standard har avdelningsadministratörer behörighet. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx index d45459eff..9e9034dd4 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx @@ -994,4 +994,22 @@ Хто може передати чернетку звіту іншому автору, наприклад коли початковий автор недоступний. Керування даними профілактики Хто може створювати та змінювати об'єкти, програми перевірок і збірки норм, гідранти, дозволи та заходи зі зниження ризиків у громаді. Для читання даних профілактики достатньо доступу до Записів; розслідування регулюються дозволом на обмежені записи та членством у справі. + + Видача та повернення інвентаря + + + Визначає, хто може видавати та повертати інвентар. За замовчуванням це дозволено адміністраторам департаменту. + + + Керування контрольованими речовинами + + + Визначає, хто може реєструвати та засвідчувати операції з контрольованими речовинами. За замовчуванням це дозволено адміністраторам департаменту. Кожну операцію має засвідчити інша уповноважена особа. + + + Переміщення інвентаря + + + Визначає, хто може переміщувати інвентар між місцями зберігання. За замовчуванням це дозволено адміністраторам департаменту. + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index d2cc57db5..d16c46b4a 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -220,6 +220,7 @@ public enum AuditLogTypes ChecklistOccurrenceMissed, ChecklistOccurrenceSkipped, ChecklistReminderSettingsUpdated, - WorkOrderChanged + WorkOrderChanged, + InventoryChanged } } diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs index 8f09a26ce..31e51a6d9 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs @@ -15,7 +15,8 @@ public static class ChecklistWorkflowPayload (int)WorkflowTriggerEventType.ChecklistCompleted, (int)WorkflowTriggerEventType.ChecklistFailed, (int)WorkflowTriggerEventType.ChecklistMissed, (int)WorkflowTriggerEventType.WorkOrderCreated, (int)WorkflowTriggerEventType.WorkOrderStatusChanged, (int)WorkflowTriggerEventType.WorkOrderAssigned, - (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped + (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped, + 22, 58, 59, 60, 64, 66 }); private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" }; private static bool IsStructuralTarget(int type, string target) => @@ -30,7 +31,8 @@ private static void Timing(JObject source, JObject target) } public static string Routing(string payloadJson, string aggregateId) { - var source = JObject.Parse(payloadJson ?? "{}"); + var source = Resgrid.Model.Inventories.InventoryWorkflowPayload.Parse(payloadJson); + if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(source)) return Resgrid.Model.Inventories.InventoryWorkflowPayload.Routing(source); if (source["WorkOrderId"] != null) return Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Routing(source); var safe = new JObject(); foreach (var name in Identifiers) @@ -48,12 +50,14 @@ public static string Routing(string payloadJson, string aggregateId) return safe.ToString(Newtonsoft.Json.Formatting.None); } public static bool IsChecklist(int trigger) => Triggers.Contains(trigger); - public static bool IsReadinessProducer(string producer) => producer == "Checklists" || producer == "WorkOrders"; + public static readonly IReadOnlyList ReadinessProducers = Array.AsReadOnly(new[] { "Checklists", "WorkOrders", "Inventory" }); + public static bool IsReadinessProducer(string producer) => ReadinessProducers.Contains(producer); public static async Task ProjectAsync(int departmentId, object value, IProtectedProjectionService protection, bool wrapped = false) { if (protection == null) throw new InvalidOperationException("Checklist workflow protection is unavailable."); var source = value as JObject ?? (value == null ? new JObject() : JObject.FromObject(value)); var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source; + if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(payload)) return await Resgrid.Model.Inventories.InventoryWorkflowPayload.ProjectAsync(departmentId, source, protection, wrapped); if (payload["WorkOrderId"] != null) return await Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); var safe = new JObject(); foreach (var name in Identifiers) diff --git a/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs index bb379bfaa..416d09f04 100644 --- a/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs +++ b/Core/Resgrid.Model/Checklists/ReadinessHistoryFields.cs @@ -7,7 +7,7 @@ namespace Resgrid.Model.Checklists public static class ReadinessHistoryFields { public const int CatalogVersion = 16; - public static readonly int[] AuditTypes = Enum.GetValues().Where(x => (x.ToString().StartsWith("Checklist", StringComparison.Ordinal) || x.ToString().StartsWith("WorkOrder", StringComparison.Ordinal))).Select(x => (int)x).ToArray(); + public static readonly int[] AuditTypes = Enum.GetValues().Where(x => (x.ToString().StartsWith("Checklist", StringComparison.Ordinal) || x.ToString().StartsWith("WorkOrder", StringComparison.Ordinal) || x.ToString().StartsWith("Inventory", StringComparison.Ordinal))).Select(x => (int)x).ToArray(); public static bool IsChecklistAudit(int type) => AuditTypes.Contains(type); public static readonly IReadOnlyDictionary Get, Action Set)> Audits = new Dictionary, Action)> { ["auditlogs.data"] = (x => x.Data, (x, v) => x.Data = v) }; diff --git a/Core/Resgrid.Model/Inventories/InventoryContracts.cs b/Core/Resgrid.Model/Inventories/InventoryContracts.cs new file mode 100644 index 000000000..a502e688e --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryContracts.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Inventories +{ + public sealed class InventoryActor { public int DepartmentId { get; set; } public string UserId { get; set; } public string GrantToken { get; set; } } + public sealed class InventoryException : Exception { public int StatusCode { get; } public string Code { get; } public InventoryException(int status, string code) : base(code) { StatusCode = status; Code = code; } } + public sealed class InventoryItemInput + { + public string Id { get; set; } + public int Revision { get; set; } + public string CategoryId { get; set; } + public InventoryTrackingMode TrackingMode { get; set; } + public bool IsKit { get; set; } + public bool RequiresLotTracking { get; set; } + public bool RequiresExpiration { get; set; } + public bool IsControlledSubstance { get; set; } + public bool IsActive { get; set; } = true; + public InventoryItemContent Details { get; set; } = new(); + } + public sealed class InventoryLocationInput { public string Id { get; set; } public int Revision { get; set; } public InventoryLocationType Type { get; set; } public int? GroupId { get; set; } public int? UnitId { get; set; } public string UserId { get; set; } public string ContainerAssetId { get; set; } public string ParentLocationId { get; set; } public bool IsDefault { get; set; } public string Name { get; set; } } + public sealed class InventoryAssetInput { public string Id { get; set; } public string RequestId { get; set; } public string ItemId { get; set; } public string LocationId { get; set; } public string LotId { get; set; } public DateTime? ExpiresOn { get; set; } public InventoryAssetContent Details { get; set; } = new(); } + public sealed class InventoryPosting + { + public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string FromLocationId { get; set; } + public string ToLocationId { get; set; } + public decimal Quantity { get; set; } + public InventoryTransactionType Type { get; set; } + public InventoryAssetStatus? Status { get; set; } + public int? ExpectedAssetRevision { get; set; } + public InventoryReferenceType ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string ReversesTransactionId { get; set; } + public string IssuanceId { get; set; } + public string Note { get; set; } + public decimal? UnitCost { get; set; } + } + public sealed class InventoryCommand { public string RequestId { get; set; } public List Lines { get; set; } = new(); } + public sealed class InventoryResult { public string OperationId { get; set; } public bool AwaitingWitness { get; set; } public List TransactionIds { get; set; } = new(); public List OutboxIds { get; set; } = new(); public string TransferId { get; set; } public string IssuanceId { get; set; } public string AssetId { get; set; } public List IssuanceIds { get; set; } = new(); } + public sealed class InventoryIssueInput { public string RequestId { get; set; } public string ItemId { get; set; } public string AssetId { get; set; } public string LotId { get; set; } public string FromLocationId { get; set; } public decimal Quantity { get; set; } public string UserId { get; set; } public int? UnitId { get; set; } public DateTime? ExpectedReturnOn { get; set; } public InventoryReferenceType ReferenceType { get; set; } public string ReferenceId { get; set; } public string Note { get; set; } } + public sealed class InventoryReturnInput { public string RequestId { get; set; } public string IssuanceId { get; set; } public int Revision { get; set; } public string ToLocationId { get; set; } public decimal Quantity { get; set; } public InventoryAssetStatus Condition { get; set; } public string Note { get; set; } } + public sealed class InventoryEquipment { public InventoryAsset Asset { get; set; } public InventoryStock Stock { get; set; } public string ItemName { get; set; } public int? UnitId { get; set; } public int? GroupId { get; set; } public string UserId { get; set; } public List Issuances { get; set; } = new(); } + public sealed class InventoryPage { public List Items { get; set; } = new(); public bool HasMore { get; set; } } + public sealed class InventoryOperationContent { public string Fingerprint { get; set; } public InventoryResult Result { get; set; } public InventoryCommand PendingCommand { get; set; } public string PendingKind { get; set; } public List PendingIssues { get; set; } public InventoryReturnInput PendingReturn { get; set; } public string PerformerId { get; set; } public string WitnessId { get; set; } public DateTime? WitnessedOn { get; set; } public string Attestation { get; set; } } + public sealed class InventoryKitInput { public string Id { get; set; } public int Revision { get; set; } public string Name { get; set; } public List Lines { get; set; } = new(); } + public sealed class InventoryKitLine { public string ItemId { get; set; } public decimal Quantity { get; set; } } + public sealed class InventoryKitIssueInput { public string KitId { get; set; } public string RequestId { get; set; } public List Lines { get; set; } = new(); } + public sealed class InventoryMigrationResult { public bool AlreadyMigrated { get; set; } public int Items { get; set; } public int Transactions { get; set; } public List Warnings { get; set; } = new(); } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryModels.cs b/Core/Resgrid.Model/Inventories/InventoryModels.cs new file mode 100644 index 000000000..810234801 --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryModels.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Inventories +{ + public enum InventoryTrackingMode { Bulk = 0, Serialized = 1 } + public enum InventoryLocationType { Facility = 0, Station = 1, Unit = 2, Personnel = 3, Container = 4, External = 5 } + public enum InventoryTransactionType { Migrated = 0, Receive = 1, Consume = 2, Transfer = 3, Issue = 4, Return = 5, Adjust = 6, Count = 7, WriteOff = 8, StatusChange = 9 } + public enum InventoryAssetStatus { InService = 0, Issued = 1, OutForRepair = 2, Damaged = 3, Lost = 4, Consumed = 5, Retired = 6 } + public enum InventoryReferenceType { None = 0, LegacyLog = 1, RmsRecord = 2, Call = 3, Transfer = 4, Issuance = 5, PurchaseOrder = 6, Count = 7, Legacy = 8, WorkOrder = 9, Deployment = 10 } + public enum InventoryIssuanceStatus { Outstanding = 0, Returned = 1, PartiallyReturned = 2, Lost = 3, Consumed = 4 } + + /// Stable GUID public identities also support checklist/work-order soft references. User-authored data is stored in cataloged Content. + public abstract class InventoryRow : IEntity + { + public string Id { get; set; } = Guid.NewGuid().ToString("D"); + public int DepartmentId { get; set; } + public int Revision { get; set; } = 1; + public DateTime CreatedOn { get; set; } + public DateTime? ModifiedOn { get; set; } + public string CreatedBy { get; set; } + public string Content { get; set; } + public bool IsProtected { get; set; } + [NotMapped, JsonIgnore] public object IdValue { get => Id; set => Id = (string)value; } + [NotMapped, JsonIgnore] public string TableName => InventoryTables.All[GetType()]; + [NotMapped, JsonIgnore] public string IdName => "Id"; + [NotMapped, JsonIgnore] public int IdType => 1; + [NotMapped, JsonIgnore] public IEnumerable IgnoredProperties => new[] { "IdValue", "TableName", "IdName", "IdType", "IgnoredProperties" }; + } + public abstract class InventoryMutableRow : InventoryRow, IChangeTracked { public bool IsDeleted { get; set; } } + public sealed class InventoryCategory : InventoryMutableRow { public string ParentCategoryId { get; set; } } + public sealed class InventoryItem : InventoryMutableRow + { + public string CategoryId { get; set; } + public int TrackingMode { get; set; } + public bool IsKit { get; set; } + public bool RequiresLotTracking { get; set; } + public bool RequiresExpiration { get; set; } + public bool IsControlledSubstance { get; set; } + public bool IsActive { get; set; } = true; + public int? LegacyInventoryTypeId { get; set; } + } + public sealed class InventoryLocation : InventoryMutableRow + { + public int LocationType { get; set; } + public int? GroupId { get; set; } + public int? UnitId { get; set; } + public string UserId { get; set; } + public string ContainerAssetId { get; set; } + public string ParentLocationId { get; set; } + public bool IsDefault { get; set; } + } + public sealed class InventoryLot : InventoryMutableRow { public string ItemId { get; set; } public DateTime? ExpiresOn { get; set; } public DateTime ReceivedOn { get; set; } } + public sealed class InventoryStock : InventoryMutableRow + { + public string ItemId { get; set; } + public string LocationId { get; set; } + public string LotId { get; set; } + public decimal Quantity { get; set; } + } + public sealed class InventoryAsset : InventoryMutableRow + { + public string ItemId { get; set; } + public string LotId { get; set; } + public int Status { get; set; } + public string CurrentLocationId { get; set; } + public DateTime? ExpiresOn { get; set; } + public DateTime? AcquiredOn { get; set; } + } + /// EntryId is the bigint database primary key; Id is the stable GUID exposed to integration consumers. Append-only. + public sealed class InventoryTransaction : InventoryRow + { + public long EntryId { get; set; } + public string OperationId { get; set; } + public int LineNumber { get; set; } + public int TransactionType { get; set; } + public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string FromLocationId { get; set; } + public string ToLocationId { get; set; } + public decimal Quantity { get; set; } + public decimal? FromQuantityBefore { get; set; } + public decimal? FromQuantityAfter { get; set; } + public decimal? ToQuantityBefore { get; set; } + public decimal? ToQuantityAfter { get; set; } + public int? OldStatus { get; set; } + public int? NewStatus { get; set; } + public int ReferenceType { get; set; } + public string ReferenceId { get; set; } + public string ReversesTransactionId { get; set; } + public string IssuanceId { get; set; } + public int? LegacyInventoryId { get; set; } + public DateTime OccurredOn { get; set; } + } + public sealed class InventoryOperation : InventoryRow { public int State { get; set; } public string RequestId { get; set; } public string WitnessUserId { get; set; } } + public sealed class InventoryTransfer : InventoryMutableRow { public string FromLocationId { get; set; } public string ToLocationId { get; set; } public int Status { get; set; } public string OperationId { get; set; } } + public sealed class InventoryTransferItem : InventoryRow { public string TransferId { get; set; } public string TransactionId { get; set; } public string ItemId { get; set; } public string AssetId { get; set; } public string LotId { get; set; } public decimal Quantity { get; set; } } + public sealed class InventoryIssuance : InventoryMutableRow + { + public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public decimal Quantity { get; set; } + public decimal ReturnedQuantity { get; set; } + public string IssuedToUserId { get; set; } + public int? IssuedToUnitId { get; set; } + public string LocationId { get; set; } + public string ReturnedToLocationId { get; set; } + public DateTime IssuedOn { get; set; } + public DateTime? ExpectedReturnOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public int Status { get; set; } + public int ReferenceType { get; set; } + public string ReferenceId { get; set; } + } + public sealed class InventoryKit : InventoryMutableRow { } + public sealed class InventoryKitItem : InventoryMutableRow { public string KitId { get; set; } public string ItemId { get; set; } public decimal Quantity { get; set; } } + public sealed class InventoryItemContent + { + public string Name { get; set; } + public string Description { get; set; } + public string Code { get; set; } + public string Barcode { get; set; } + public string UnitOfMeasure { get; set; } + public string DeaSchedule { get; set; } + public int? DefaultExpirationDays { get; set; } + public decimal? MinLevel { get; set; } + public decimal? MaxLevel { get; set; } + public decimal? ReorderPoint { get; set; } + public decimal? ReorderQuantity { get; set; } + public decimal? DefaultUnitCost { get; set; } + } + public sealed class InventoryLabel { public string Name { get; set; } public string Note { get; set; } } + public sealed class InventoryLotContent { public string LotNumber { get; set; } public decimal? UnitCost { get; set; } public string VendorId { get; set; } } + public sealed class InventoryAssetContent { public string SerialNumber { get; set; } public string AssetTag { get; set; } public string Barcode { get; set; } public decimal? AcquisitionCost { get; set; } public DateTime? WarrantyExpiresOn { get; set; } } + public static class InventoryTables + { + public const int CatalogVersion = 19; + public static readonly IReadOnlyDictionary All = new Dictionary + { + [typeof(InventoryCategory)] = "InventoryCategories", [typeof(InventoryItem)] = "InventoryItems", [typeof(InventoryLocation)] = "InventoryLocations", + [typeof(InventoryLot)] = "InventoryLots", [typeof(InventoryStock)] = "InventoryStocks", [typeof(InventoryAsset)] = "InventoryAssets", [typeof(InventoryTransaction)] = "InventoryTransactions", + [typeof(InventoryOperation)] = "InventoryOperations", [typeof(InventoryTransfer)] = "InventoryTransfers", [typeof(InventoryTransferItem)] = "InventoryTransferItems", + [typeof(InventoryIssuance)] = "InventoryIssuances", [typeof(InventoryKit)] = "InventoryKits", [typeof(InventoryKitItem)] = "InventoryKitItems" + }; + public static IReadOnlyDictionary Get, Action Set)> Fields() where T : InventoryRow => + new Dictionary, Action)> { [All[typeof(T)].ToLowerInvariant() + ".content"] = (x => x.Content, (x, v) => x.Content = v) }; + } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryPermissionCatalog.cs b/Core/Resgrid.Model/Inventories/InventoryPermissionCatalog.cs new file mode 100644 index 000000000..facd95873 --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryPermissionCatalog.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +namespace Resgrid.Model +{ + public static class InventoryPermissionCatalog + { + // The service inherits AdjustInventory for absent transfer/issue rows; the editor resolves those rows using that same fallback. + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.TransferInventory, PermissionActions.DepartmentAdminsOnly, true, "TransferInventory"), + new RecordPermissionDescriptor(PermissionTypes.IssueInventory, PermissionActions.DepartmentAdminsOnly, true, "IssueInventory"), + new RecordPermissionDescriptor(PermissionTypes.ManageControlledSubstances, PermissionActions.DepartmentAdminsOnly, false, "ManageControlledSubstances", false) + }; + } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryQuery.cs b/Core/Resgrid.Model/Inventories/InventoryQuery.cs new file mode 100644 index 000000000..4a2f7df6e --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryQuery.cs @@ -0,0 +1,12 @@ +namespace Resgrid.Model.Inventories +{ + /// Structural filters applied before paging; authored content is never queried outside its protected read boundary. + public sealed class InventoryQuery + { + public string ItemId { get; set; } + public string LocationId { get; set; } + public string AssetId { get; set; } + public string IssuedToUserId { get; set; } + public string KitId { get; set; } + } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs new file mode 100644 index 000000000..3beae500e --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs @@ -0,0 +1,122 @@ +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.Services; + +namespace Resgrid.Model.Inventories +{ + /// Reviewed inventory routing and quantities; personnel and authored content never cross the Workflow boundary. + public static class InventoryWorkflowPayload + { + public const int CatalogVersion = 19; + public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 64, 66 }); + public static readonly (string Variable, string Property)[] Variables = + { + ("transaction_id", "TransactionId"), ("item_id", "ItemId"), ("asset_id", "AssetId"), ("lot_id", "LotId"), + ("transfer_id", "TransferId"), ("issuance_id", "IssuanceId"), ("transaction_type", "TransactionType"), ("quantity", "Quantity"), + ("from_location_id", "FromLocationId"), ("to_location_id", "ToLocationId"), + ("from_quantity_before", "FromQuantityBefore"), ("from_quantity_after", "FromQuantityAfter"), + ("to_quantity_before", "ToQuantityBefore"), ("to_quantity_after", "ToQuantityAfter"), + ("previous_status", "OldStatus"), ("status", "NewStatus"), ("reference_type", "ReferenceType"), ("reference_id", "ReferenceId"), + ("reverses_transaction_id", "ReversesTransactionId"), ("occurred_on", "OccurredOn"), ("item_name", "ItemName") + }; + private static readonly string[] GuidFields = { "TransactionId", "ItemId", "AssetId", "LotId", "TransferId", "IssuanceId", "FromLocationId", "ToLocationId", "ReversesTransactionId" }; + private static readonly string[] WithheldFields = { "ItemName", "Note", "SerialNumber", "WitnessUserId" }; + + public static bool IsInventory(JObject payload) => payload?["InventoryEvent"]?.Type == JTokenType.Boolean && payload["InventoryEvent"].Value(); + public static bool IsInventory(int trigger) => Triggers.Contains(trigger); + public static JObject Parse(string json) + { + using var reader = new JsonTextReader(new System.IO.StringReader(json ?? "{}")) { FloatParseHandling = FloatParseHandling.Decimal }; + return JObject.Load(reader); + } + + private static bool Identifier(JToken token, out string value) + { + value = null; + if (token?.Type != JTokenType.String && token?.Type != JTokenType.Guid) return false; + if (!Guid.TryParse(token.Value(), out var id) || id == Guid.Empty) return false; + value = id.ToString("D"); + return true; + } + + private static void CopyGuid(JObject source, JObject target, string name) + { + if (Identifier(source[name], out var value)) target[name] = value; + } + + private static void CopyInteger(JObject source, JObject target, string name, long maximum = int.MaxValue) + { + if (source[name]?.Type == JTokenType.Integer && long.TryParse(source[name].ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= maximum) + target[name] = value; + } + + private static void CopyTimestamp(JObject source, JObject target, string name) + { + var token = source[name]; + if (token?.Type != JTokenType.Date && token?.Type != JTokenType.String) return; + var text = token.Type == JTokenType.Date ? JsonConvert.SerializeObject(token).Trim('"') : token.Value(); + if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) target[name] = date.UtcDateTime.ToString("O", CultureInfo.InvariantCulture); + } + + public static string Routing(JObject payload) + { + payload ??= new JObject(); + var safe = new JObject { ["InventoryEvent"] = true }; + foreach (var name in GuidFields) CopyGuid(payload, safe, name); + foreach (var name in new[] { "TransactionType", "OldStatus", "NewStatus", "ReferenceType" }) CopyInteger(payload, safe, name); + foreach (var name in new[] { "Quantity", "FromQuantityBefore", "FromQuantityAfter", "ToQuantityBefore", "ToQuantityAfter" }) + { + var token = payload[name]; + if ((token?.Type == JTokenType.Integer || token?.Type == JTokenType.Float) && decimal.TryParse(token.ToString(Formatting.None), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) safe[name] = value; + } + var reference = payload["ReferenceId"]; + if (Identifier(reference, out var referenceId)) safe["ReferenceId"] = referenceId; + else if (reference?.Type == JTokenType.String && reference.Value().Length <= 128 && long.TryParse(reference.Value(), NumberStyles.None, CultureInfo.InvariantCulture, out var numeric) && numeric > 0) + safe["ReferenceId"] = numeric.ToString(CultureInfo.InvariantCulture); + CopyTimestamp(payload, safe, "OccurredOn"); + foreach (var name in WithheldFields) safe[name] = ProtectedDataEnvelope.RedactionValue; + safe["is_redacted"] = true; + safe["redacted_fields"] = new JArray(WithheldFields); + var version = payload["catalog_version"]; + safe["catalog_version"] = version?.Type == JTokenType.Integer && int.TryParse(version.ToString(), out var previous) ? Math.Max(CatalogVersion, previous) : CatalogVersion; + return safe.ToString(Formatting.None); + } + + private static JObject Envelope(JObject source) + { + var safe = new JObject(); + foreach (var name in new[] { "EventId", "AggregateId", "CorrelationId", "CausationId" }) CopyGuid(source, safe, name); + foreach (var name in new[] { "DepartmentId", "SchemaVersion", "TriggerEventType", "AggregateVersion" }) CopyInteger(source, safe, name); + CopyInteger(source, safe, "Sequence", long.MaxValue); + var eventName = source["EventName"]; + if (eventName?.Type == JTokenType.String && Enum.TryParse(eventName.Value(), out var trigger) && IsInventory((int)trigger) && Enum.IsDefined(typeof(WorkflowTriggerEventType), trigger)) safe["EventName"] = trigger.ToString(); + var aggregate = source["AggregateType"]?.Type == JTokenType.String ? source["AggregateType"].Value() : null; + if (aggregate is "InventoryTransaction" or "InventoryItem" or "InventoryAsset" or "InventoryTransfer" or "InventoryIssuance") safe["AggregateType"] = aggregate; + var origin = source["OriginClient"]; + if (origin?.Type == JTokenType.String && Enum.TryParse(origin.Value(), out var client) && Enum.IsDefined(typeof(RmsOriginClient), client)) safe["OriginClient"] = client.ToString(); + if (source["IsReplay"]?.Type == JTokenType.Boolean) safe["IsReplay"] = source["IsReplay"].DeepClone(); + CopyTimestamp(source, safe, "OccurredOn"); + return safe; + } + + public static async Task ProjectAsync(int departmentId, JObject source, IProtectedProjectionService protection, bool wrapped = false) + { + if (protection == null) throw new InvalidOperationException("Inventory workflow protection is unavailable."); + source ??= new JObject(); + var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source; + var projected = await protection.BuildSafeWorkflowPayloadAsync(departmentId, Parse(Routing(payload))) + ?? throw new InvalidOperationException("Inventory workflow projection failed."); + // Reapply the whitelist so replay, a policy change, or an unknown property cannot restore content. + var safe = Parse(Routing(Parse(projected))); + if (!wrapped) return safe.ToString(Formatting.None); + var envelope = Envelope(source); + envelope["Payload"] = safe; + return envelope.ToString(Formatting.None); + } + } +} diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index 726ede0b7..da794ac18 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -153,7 +153,14 @@ public enum PermissionTypes ManageChecklists = 112, ViewChecklistResults = 113, ManageWorkOrders = 114, - ViewAllWorkOrders = 115 + ViewAllWorkOrders = 115, + + /// Transfer inventory between department locations. Defaults to the inventory adjustment permission. + TransferInventory = 47, + /// Issue and return department equipment. Defaults to the inventory adjustment permission. + IssueInventory = 48, + /// Record controlled-substance inventory transactions. Defaults to department administrators. + ManageControlledSubstances = 49 } } diff --git a/Core/Resgrid.Model/Repositories/IInventoryStore.cs b/Core/Resgrid.Model/Repositories/IInventoryStore.cs new file mode 100644 index 000000000..e7e2ac450 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IInventoryStore.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; + +namespace Resgrid.Model.Repositories +{ + public interface IInventoryStore + { + Task LockDepartmentAsync(int departmentId); + Task GetAsync(int departmentId, string id) where T : InventoryRow; + Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow; + Task> QueryAsync(int departmentId, InventoryQuery filter, int skip = 0) where T : InventoryRow; + Task> RelatedAsync(int departmentId, string column, string id) where T : InventoryRow; + Task InsertAsync(T row) where T : InventoryRow; + Task UpdateAsync(T row, int expectedRevision) where T : InventoryRow; + Task RequestAsync(int departmentId, string requestId); + Task ApplyStockDeltaAsync(int departmentId, string itemId, string locationId, string lotId, decimal delta, string userId); + Task LegacyItemAsync(int departmentId, int typeId); + Task LegacyTransactionAsync(int departmentId, int inventoryId); + Task RebuildStocksAsync(int departmentId); + Task HasLegacyMigrationAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IChecklistsService.cs b/Core/Resgrid.Model/Services/IChecklistsService.cs index 030506a4a..666f19862 100644 --- a/Core/Resgrid.Model/Services/IChecklistsService.cs +++ b/Core/Resgrid.Model/Services/IChecklistsService.cs @@ -39,7 +39,7 @@ public interface IChecklistsService Task GetFileAsync(ChecklistActor actor, string id); Task DeleteFileAsync(ChecklistActor actor, string id); Task DeleteFileAtRevisionAsync(ChecklistActor actor, string id, int revision); - Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0); + Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false); Task GetScheduleAsync(ChecklistActor actor, string id); Task SaveScheduleAsync(ChecklistActor actor, ChecklistScheduleInput input); Task DisableScheduleAsync(ChecklistActor actor, string id, int revision); diff --git a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs new file mode 100644 index 000000000..3ac1da372 --- /dev/null +++ b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; + +namespace Resgrid.Model.Services +{ + public interface IInventoryMigrationService { Task MigrateLegacyAsync(InventoryActor actor); Task IsMigratedAsync(int departmentId); } + public interface IInventoryCatalogService + { + Task SaveItemAsync(InventoryActor actor, InventoryItemInput input); + Task SaveCategoryAsync(InventoryActor actor, string id, int revision, string name, string parentId); + Task SaveLocationAsync(InventoryActor actor, InventoryLocationInput input); + Task SaveLotAsync(InventoryActor actor, InventoryLot lot, InventoryLotContent details); + Task ArchiveAsync(InventoryActor actor, string id, int revision) where T : InventoryMutableRow; + Task GetAsync(InventoryActor actor, string id) where T : InventoryRow; + Task> ListAsync(InventoryActor actor, int page = 0) where T : InventoryRow; + Task> QueryAsync(InventoryActor actor, InventoryQuery filter, int page = 0) where T : InventoryRow; + } + public interface IInventoryStockService + { + Task PostTransactionAsync(InventoryActor actor, InventoryCommand command, CancellationToken ct = default); + /// The caller owns the active transaction and dispatches returned OutboxIds only after its commit. + Task PostWithinTransactionAsync(InventoryActor actor, InventoryCommand command, CancellationToken ct = default); + Task WitnessAsync(InventoryActor actor, string requestId, string attestation); + Task RebuildStocksAsync(InventoryActor actor); + Task> GetByReferenceAsync(InventoryActor actor, InventoryReferenceType type, string id); + } + public interface IInventoryTransferService { Task CreateAndCompleteTransferAsync(InventoryActor actor, InventoryCommand command); } + public interface IInventoryIssuanceService + { + Task CreateAssetAsync(InventoryActor actor, InventoryAssetInput input); + Task IssueAsync(InventoryActor actor, InventoryIssueInput input); + Task ReturnAsync(InventoryActor actor, InventoryReturnInput input); + Task ChangeAssetStatusAsync(InventoryActor actor, InventoryCommand command); + Task SaveKitAsync(InventoryActor actor, InventoryKitInput input); + Task IssueKitAsync(InventoryActor actor, InventoryKitIssueInput input); + Task> GetUnitEquipmentAsync(InventoryActor actor, int unitId); + Task> GetIssuableAsync(InventoryActor actor, string itemId = null, string locationId = null); + } + public interface IInventoryAuthorizationService + { + Task RequireAsync(InventoryActor actor, bool write = false, PermissionTypes? permission = null, int? groupId = null); + Task CanLocationAsync(InventoryActor actor, InventoryLocation location); + Task ValidateHolderAsync(InventoryActor actor, InventoryLocation location); + Task IsEnabledAsync(int departmentId); + } +} diff --git a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs index 1ceab0edd..2e360baf2 100644 --- a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Resgrid.Model.Inventories; namespace Resgrid.Model.Services { @@ -17,6 +18,8 @@ public class RmsInventoryUsage public string RecordId { get; set; } public int? LegacyLogId { get; set; } public int InventoryId { get; set; } + public string TransactionId { get; set; } + public string ItemId { get; set; } public decimal Quantity { get; set; } public string Note { get; set; } public string ItemName { get; set; } @@ -37,7 +40,8 @@ public class RmsInventoryUsage /// public interface IRmsInventoryUsageAdapter { - Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default); + Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default, string grantToken = null); + Task ConsumeModernAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, InventoryCommand command, CancellationToken cancellationToken = default); Task> GetUsageForRecordAsync(int departmentId, string recordId); Task> GetUsageForLegacyLogAsync(int departmentId, int logId); diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs index 6fe39650c..a9ecaed81 100644 --- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -61,11 +61,11 @@ public sealed class WorkOrderReadScope public bool All { get; set; } public int? GroupId { get; set; } public int[] RoleIds { get; set; } = Array.Empty(); - public bool Allows(WorkOrder row) => All || row.CreatedBy == UserId || row.AssignedToUserId == UserId || GroupId.HasValue && row.TargetGroupId == GroupId || row.AssignedToRoleId.HasValue && Array.IndexOf(RoleIds, row.AssignedToRoleId.Value) >= 0; + public bool Allows(WorkOrder row) => All || row.CreatedBy == UserId || row.AssignedToUserId == UserId || GroupId.HasValue && row.TargetGroupId == GroupId || row.AssignedToRoleId.HasValue && Array.IndexOf(RoleIds ?? Array.Empty(), row.AssignedToRoleId.Value) >= 0; } public sealed class WorkOrderInput { - public string RequestId { get; set; } = Guid.NewGuid().ToString("D"); + public string RequestId { get; set; } public int Revision { get; set; } public WorkOrderType Type { get; set; } public WorkOrderPriority Priority { get; set; } = WorkOrderPriority.Normal; diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs index 209d4a20d..13cdb3e25 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -10,13 +10,15 @@ namespace Resgrid.Model.WorkOrders public static class WorkOrderWorkflowPayload { public static readonly (string Variable, string Property)[] Variables = { ("id", "WorkOrderId"), ("revision", "Revision"), ("status", "Status"), ("priority", "Priority"), ("unit_id", "TargetUnitId"), ("group_id", "TargetGroupId"), ("asset_id", "InventoryAssetId"), ("role_id", "AssignedToRoleId"), ("due_on", "DueOn"), ("title", "Title") }; + public static bool IsWorkOrder(int trigger) => trigger is (int)WorkflowTriggerEventType.WorkOrderCreated or (int)WorkflowTriggerEventType.WorkOrderStatusChanged or (int)WorkflowTriggerEventType.WorkOrderAssigned; public static string Routing(JObject payload) { var safe = new JObject(); foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId" }) if (payload[field]?.Type == JTokenType.Integer && payload[field].Value() >= 0 && payload[field].Value() <= int.MaxValue) safe[field] = payload[field].DeepClone(); if (payload["InventoryAssetId"]?.Type == JTokenType.String && Guid.TryParseExact(payload["InventoryAssetId"].Value(), "D", out var asset)) safe["InventoryAssetId"] = asset.ToString("D"); - if ((payload["DueOn"]?.Type == JTokenType.String || payload["DueOn"]?.Type == JTokenType.Date) && DateTimeOffset.TryParse(payload["DueOn"].ToString(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var due)) safe["DueOn"] = due.UtcDateTime.ToString("O"); + if (payload["DueOn"]?.Type == JTokenType.Date) safe["DueOn"] = ((DateTimeOffset)payload["DueOn"]).UtcDateTime.ToString("O"); + else if (payload["DueOn"]?.Type == JTokenType.String && DateTimeOffset.TryParse(payload["DueOn"].Value(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var due)) safe["DueOn"] = due.UtcDateTime.ToString("O"); safe["Title"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray("Title"); safe["catalog_version"] = WorkOrderTables.CatalogVersion; return safe.ToString(Formatting.None); } diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 50f2e38a1..002d4e656 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -390,7 +390,7 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: foreach (var pair in WorkOrders.WorkOrderWorkflowPayload.Variables) - list.Add(new TemplateVariableDescriptor("work_order." + pair.Variable, pair.Property, pair.Variable is "asset_id" or "due_on" or "title" ? "string" : "int", false)); + list.Add(new TemplateVariableDescriptor("work_order." + pair.Variable, "Work order " + pair.Variable.Replace('_', ' ') + (pair.Variable == "title" ? "; always REDACTED. Do not compare or render this value." : ""), pair.Variable is "asset_id" or "due_on" or "title" ? "string" : "int", false)); list.Add(new TemplateVariableDescriptor("work_order.url", "Authenticated work-order link", "string", false)); list.Add(new TemplateVariableDescriptor("protection.is_redacted", "Sensitive work-order fields are withheld", "bool", false)); list.Add(new TemplateVariableDescriptor("protection.redacted_fields", "Withheld fields", "array", false)); @@ -754,17 +754,49 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.InventoryAdjusted: list.AddRange(new[] { - new TemplateVariableDescriptor("inventory.id", "Inventory record ID", "int", false), - new TemplateVariableDescriptor("inventory.type_name", "Inventory type name", "string", false), - new TemplateVariableDescriptor("inventory.type_description", "Inventory type description", "string", false), - new TemplateVariableDescriptor("inventory.unit_of_measure", "Unit of measure", "string", false), - new TemplateVariableDescriptor("inventory.batch", "Batch identifier", "string", false), - new TemplateVariableDescriptor("inventory.note", "Note", "string", false), - new TemplateVariableDescriptor("inventory.location", "Storage location", "string", false), - new TemplateVariableDescriptor("inventory.amount", "Current amount", "double", false), - new TemplateVariableDescriptor("inventory.previous_amount", "Previous amount before adjustment", "double", false), - new TemplateVariableDescriptor("inventory.timestamp", "Adjustment timestamp", "datetime", false), - new TemplateVariableDescriptor("inventory.group_id", "Group ID", "int", false), + new TemplateVariableDescriptor("inventory.id", "Deprecated alias: transaction GUID for modern events, integer inventory ID for historical events; use inventory.transaction_id", "string", false), + new TemplateVariableDescriptor("inventory.type_name", "Deprecated item name alias; REDACTED for modern events", "string", false), + new TemplateVariableDescriptor("inventory.type_description", "Legacy type description; REDACTED for modern events", "string", false), + new TemplateVariableDescriptor("inventory.unit_of_measure", "Legacy unit of measure; empty for modern events", "string", false), + new TemplateVariableDescriptor("inventory.batch", "Legacy batch identifier; REDACTED for modern events", "string", false), + new TemplateVariableDescriptor("inventory.note", "Legacy note; REDACTED for modern events", "string", false), + new TemplateVariableDescriptor("inventory.location", "Deprecated location alias: destination GUID when present, otherwise source GUID", "string", false), + new TemplateVariableDescriptor("inventory.amount", "Deprecated balance alias: destination after quantity when present, otherwise source after quantity; use the explicit from/to quantity variables", "decimal", false), + new TemplateVariableDescriptor("inventory.previous_amount", "Deprecated balance alias: before quantity for the same location as inventory.amount", "decimal", false), + new TemplateVariableDescriptor("inventory.timestamp", "Deprecated alias for inventory.occurred_on", "datetime", false), + new TemplateVariableDescriptor("inventory.group_id", "Legacy group ID; zero for modern events", "int", false), + }); + goto case WorkflowTriggerEventType.InventoryTransferCompleted; + case WorkflowTriggerEventType.InventoryTransferCompleted: + case WorkflowTriggerEventType.InventoryIssued: + case WorkflowTriggerEventType.InventoryReturned: + case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.ControlledSubstanceRecorded: + foreach (var pair in Inventories.InventoryWorkflowPayload.Variables) + { + var type = pair.Variable switch + { + "transaction_type" or "previous_status" or "status" or "reference_type" => "int", + "quantity" or "from_quantity_before" or "from_quantity_after" or "to_quantity_before" or "to_quantity_after" => "decimal", + "occurred_on" => "datetime", + _ => "string" + }; + list.Add(new TemplateVariableDescriptor("inventory." + pair.Variable, pair.Property + (pair.Variable == "item_name" ? "; always REDACTED" : string.Empty), type, false)); + } + list.AddRange(new[] + { + new TemplateVariableDescriptor("event.id", "Stable event ID across delivery attempts", "string", false), + new TemplateVariableDescriptor("event.name", "Inventory trigger name", "string", false), + new TemplateVariableDescriptor("event.schema_version", "Inventory payload schema version", "int", false), + new TemplateVariableDescriptor("event.occurred_on", "When the event occurred (UTC)", "datetime", false), + new TemplateVariableDescriptor("event.correlation_id", "Inventory operation correlation ID", "string", false), + new TemplateVariableDescriptor("event.causation_id", "ID of the event that caused this event", "string", false), + new TemplateVariableDescriptor("event.sequence", "Sequence within the inventory aggregate", "int", false), + new TemplateVariableDescriptor("event.is_replay", "Whether this delivery is a retry", "bool", false), + new TemplateVariableDescriptor("event.origin_client", "Originating Resgrid client", "string", false), + new TemplateVariableDescriptor("protection.is_redacted", "Inventory personnel and authored content are always withheld", "bool", false), + new TemplateVariableDescriptor("protection.redacted_fields", "Withheld inventory fields", "array", false), + new TemplateVariableDescriptor("protection.catalog_version", "Inventory protection catalog version", "int", false), }); break; diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 302f8783c..a8cc0f3c2 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -170,7 +170,14 @@ public enum WorkflowTriggerEventType RecordPermitExpiring = 163, WorkOrderCreated = 70, WorkOrderStatusChanged = 71, - WorkOrderAssigned = 72 + WorkOrderAssigned = 72, + + // Inventory modernization: persisted registry allocations; InventoryAdjusted retains value 22. + InventoryTransferCompleted = 58, + InventoryIssued = 59, + InventoryReturned = 60, + InventoryAssetStatusChanged = 64, + ControlledSubstanceRecorded = 66 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 6210399d2..403b71c52 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -66,7 +66,7 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => AdpTableBinding.Direct("AuditLogs", "AuditLogId", true, "DepartmentId", new[] { Text("AuditLogs", "Data") }) with { Discriminator = new AdpRowDiscriminator("LogType", Resgrid.Model.Checklists.ReadinessHistoryFields.AuditTypes) }, AdpTableBinding.Direct("DomainEventOutbox", "DomainEventOutboxId", true, "DepartmentId", new[] { Text("DomainEventOutbox", "PayloadJson"), Text("DomainEventOutbox", "LastError") }) - with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Texts: new[] { "Checklists", "WorkOrders" }) }, + with { Discriminator = new AdpRowDiscriminator("ProducerSubsystem", Texts: new[] { "Checklists", "WorkOrders", "Inventory" }) }, AdpTableBinding.Direct("WorkflowRuns", "WorkflowRunId", false, "DepartmentId", new[] { Text("WorkflowRuns", "InputPayload"), Text("WorkflowRuns", "ErrorMessage") }) with { Discriminator = new AdpRowDiscriminator("TriggerEventType", Resgrid.Model.Checklists.ChecklistWorkflowPayload.Triggers) }, AdpTableBinding.ViaParent("WorkflowRunLogs", "WorkflowRunLogId", false, "WorkflowRunId", "WorkflowRuns", "WorkflowRunId", new[] { Text("WorkflowRunLogs", "RenderedOutput"), Text("WorkflowRunLogs", "ActionResult"), Text("WorkflowRunLogs", "ErrorMessage") }) @@ -507,6 +507,9 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => }; bindings.AddRange(Resgrid.Model.WorkOrders.WorkOrderTables.All.Values.Select(table => AdpTableBinding.Direct(table, "Id", true, "DepartmentId", table == "WorkOrderFiles" ? new[] { Text(table, "Content"), Binary(table, "Data") } : new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); + // Inventory transactions use their stable GUID public Id for field AAD, independently of the bigint EntryId ledger key. + bindings.AddRange(Resgrid.Model.Inventories.InventoryTables.All.Values.Select(table => + AdpTableBinding.Direct(table, "Id", false, "DepartmentId", new[] { Text(table, "Content") }) with { ProtectedMarkerColumn = "IsProtected" })); return bindings.Concat(Resgrid.Model.Checklists.ChecklistTables.All.Values.Select(table => AdpTableBinding.Direct(table, "Id", false, "DepartmentId", table switch { diff --git a/Core/Resgrid.Services/ChecklistMobile.cs b/Core/Resgrid.Services/ChecklistMobile.cs index 1bbf3edd7..aea9a9ca7 100644 --- a/Core/Resgrid.Services/ChecklistMobile.cs +++ b/Core/Resgrid.Services/ChecklistMobile.cs @@ -112,7 +112,7 @@ public async Task> MobileHistoryAsync(ChecklistActor { await RevealAsync(actor, row); var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId)); - result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name }); + result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(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 BuildInventoryDataAsync(string userId, int departmentId) + { + if (_inventoryStore == null) throw new InvalidOperationException("Inventory export storage is unavailable."); + if (_checklistProtection == null) throw new InvalidOperationException("Inventory export protection is unavailable."); + async Task> Relevant(Func include) where T : InventoryRow + { + var result = new List(); + for (var skip = 0; skip <= 100000; skip += 500) + { + var rows = await _inventoryStore.ListAsync(departmentId, skip); + foreach (var row in rows.Take(500).Where(x => x.DepartmentId == departmentId && include(x))) result.Add(await _checklistProtection.Value.ForDisplayAsync(departmentId, row, InventoryTables.Fields())); + if (rows.Count <= 500) return result; + } + throw new InvalidOperationException("Inventory export exceeds the supported department size."); + } + var locations = await Relevant(x => x.UserId == userId || x.CreatedBy == userId); + var locationIds = locations.Select(x => x.Id).ToHashSet(); + var issuances = await Relevant(x => x.IssuedToUserId == userId || x.CreatedBy == userId); + var issuanceIds = issuances.Select(x => x.Id).ToHashSet(); + var operations = await Relevant(x => x.CreatedBy == userId || x.WitnessUserId == userId); + return new { Locations = locations, Issuances = issuances, + Assets = await Relevant(x => x.CreatedBy == userId || locationIds.Contains(x.CurrentLocationId)), + Transactions = await Relevant(x => x.CreatedBy == userId || issuanceIds.Contains(x.IssuanceId) || locationIds.Contains(x.FromLocationId) || locationIds.Contains(x.ToLocationId)), + Operations = operations.Where(x => x.CreatedBy == userId), + // A witness's participation does not grant export access to the performer's command or other actors' data. + // Keep structural facts and declare withheld content without parsing or decrypting the receipt. + WitnessedOperations = operations.Where(x => x.CreatedBy != userId && x.WitnessUserId == userId) + .Select(x => new { x.Id, x.DepartmentId, x.RequestId, x.State, x.WitnessUserId, x.ModifiedOn, + Content = string.IsNullOrEmpty(x.Content) ? null : ProtectedDataEnvelope.RedactionValue }) }; + } + } +} diff --git a/Core/Resgrid.Services/InventoryHolderRetention.cs b/Core/Resgrid.Services/InventoryHolderRetention.cs new file mode 100644 index 000000000..9d5af9ffc --- /dev/null +++ b/Core/Resgrid.Services/InventoryHolderRetention.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +namespace Resgrid.Services +{ + /// Retain holder identities used by inventory evidence; an authorized department purge removes the complete subtree. + internal static class InventoryHolderRetention + { + public static async Task DeleteAsync(IInventoryStore store, IUnitOfWork uow, int department, int id, bool unit, Func> remove, CancellationToken ct) + { + if (store == null) return await remove(); + if (uow == null) throw new InvalidOperationException("Inventory holder deletion requires a transaction."); + var owns = uow.Transaction == null; + try + { + await uow.CreateOrGetConnectionAsync(ct); await store.LockDepartmentAsync(department); + for (var skip = 0; ; skip += 500) + { + var rows = await store.ListAsync(department, skip); + if (rows.Take(500).Any(l => unit ? l.UnitId == id : l.GroupId == id)) throw new InventoryException(409, "HolderHistoryRetained"); + if (rows.Count <= 500) break; + if (skip >= 100000) throw new InventoryException(409, "InventoryTooLarge"); + } + var result = await remove(); if (owns) uow.CommitChanges(); return result; + } + catch { if (owns) uow.DiscardChanges(); throw; } + } + } +} diff --git a/Core/Resgrid.Services/InventoryIssuance.cs b/Core/Resgrid.Services/InventoryIssuance.cs new file mode 100644 index 000000000..a32ef4598 --- /dev/null +++ b/Core/Resgrid.Services/InventoryIssuance.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +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 async Task CreateAssetAsync(InventoryActor actor, InventoryAssetInput input) + { + var result = await TransactionAsync(actor, async events => + { + if (input?.Details == null) throw new InventoryException(400, "AssetRequired"); Text(input.Details.SerialNumber); + await LocationAsync(actor, input.LocationId, true); + if (await RequiresWitnessAsync(actor, new[] { input.ItemId })) await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); + return await OperationAsync(actor, input.RequestId, new { Kind = "Asset", input.Id, input.ItemId, input.LocationId, input.LotId, input.ExpiresOn, input.Details }, async (operation, pending) => + { + var item = await GetAsync(actor, input.ItemId); + if (item.TrackingMode != (int)InventoryTrackingMode.Serialized || item.IsDeleted || !item.IsActive || input.Details.AcquisitionCost < 0) throw new InventoryException(400, "InvalidAsset"); + if (item.RequiresExpiration && !input.ExpiresOn.HasValue) throw new InventoryException(400, "ExpiryRequired"); + foreach (var other in await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id)) + if (string.Equals(Decode(await RevealAsync(actor, other)).SerialNumber, input.Details.SerialNumber, StringComparison.OrdinalIgnoreCase)) throw new InventoryException(409, "DuplicateSerial"); + var asset = New(actor); if (input.Id != null) { Id(input.Id); asset.Id = input.Id; } + asset.ItemId = item.Id; asset.LotId = input.LotId; asset.ExpiresOn = input.ExpiresOn?.ToUniversalTime(); asset.AcquiredOn = Now; asset.Status = (int)InventoryAssetStatus.InService; + asset.Content = JsonConvert.SerializeObject(input.Details); await SaveAsync(actor, asset); + var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = item.Id, AssetId = asset.Id, LotId = input.LotId, ToLocationId = input.LocationId, Quantity = 1, Type = InventoryTransactionType.Receive, UnitCost = input.Details.AcquisitionCost } } }; + await ValidateCommandAsync(actor, command); + if (item.IsControlledSubstance) return await AwaitWitnessAsync(actor, operation, "Receive", command, assetId: asset.Id); + return await ReceiveAssetAsync(actor, operation, command, pending); + }, events); + }); + return await GetAsync(actor, result.AssetId); + } + private async Task ReceiveAssetAsync(InventoryActor actor, InventoryOperation operation, InventoryCommand command, List events, + string performer = null, string witness = null, string attestation = null) + { + if (command.Lines.Count != 1 || command.Lines[0].Type != InventoryTransactionType.Receive || command.Lines[0].AssetId == null) throw new InventoryException(400, "InvalidAsset"); + var line = command.Lines[0]; var result = await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); result.AssetId = line.AssetId; + if ((await _store.GetAsync(actor.DepartmentId, line.ItemId)).IsKit) + { + var location = New(actor); location.CreatedBy = performer ?? actor.UserId; + location.LocationType = (int)InventoryLocationType.Container; location.ContainerAssetId = line.AssetId; + location.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Container" }); await SaveAsync(actor, location); + } + return result; + } + public Task IssueAsync(InventoryActor actor, InventoryIssueInput input) => TransactionAsync(actor, async events => + { + if (input == null) throw new InventoryException(400, "IssuanceRequired"); await ValidateIssueAccessAsync(actor, input); + return await OperationAsync(actor, input.RequestId, new { Kind = "Issue", Input = input }, async (op, pending) => + { + await ValidateIssueAsync(actor, input); + if (await RequiresWitnessAsync(actor, new[] { input.ItemId })) return await AwaitWitnessAsync(actor, op, "Issue", issues: new() { input }); + return await IssueLinesAsync(actor, op, new[] { input }, pending); + }, events); + }); + private async Task ValidateIssueAccessAsync(InventoryActor actor, InventoryIssueInput input) + { + if (input == null) throw new InventoryException(400, "IssuanceRequired"); + Quantity(input.Quantity); if ((input.UnitId.HasValue ? 1 : 0) + (input.UserId != null ? 1 : 0) != 1) throw new InventoryException(400, "HolderRequired"); + if (input.FromLocationId == null) throw new InventoryException(400, "InvalidMovement"); + await RequireCommandAccessAsync(actor, new InventoryCommand { RequestId = input.RequestId, Lines = new() { IssuePosting(input) } }); + var holder = New(actor); holder.LocationType = input.UnitId.HasValue ? (int)InventoryLocationType.Unit : (int)InventoryLocationType.Personnel; holder.UnitId = input.UnitId; holder.UserId = input.UserId; + await _auth.ValidateHolderAsync(actor, holder); + var groupId = input.UnitId.HasValue ? (await _units.GetUnitByIdAsync(input.UnitId.Value))?.StationGroupId : (await _groups.GetGroupForUserAsync(input.UserId, actor.DepartmentId))?.DepartmentGroupId; + await _auth.RequireAsync(actor, true, PermissionTypes.IssueInventory, groupId); + var existing = (await AllAsync(actor.DepartmentId)).SingleOrDefault(l => !l.IsDeleted && l.LocationType == holder.LocationType && l.UnitId == input.UnitId && l.UserId == input.UserId); + if (existing != null) await LocationAsync(actor, existing.Id, true, PermissionTypes.IssueInventory); + } + private async Task ValidateIssueAsync(InventoryActor actor, InventoryIssueInput input) + { + await ValidateIssueAccessAsync(actor, input); + if (input.Note?.Length > 16000) throw new InventoryException(400, "InvalidText"); + if (input.ExpectedReturnOn.HasValue && input.ExpectedReturnOn.Value.ToUniversalTime() <= Now) throw new InventoryException(400, "ReturnDateInPast"); + await ValidatePostingItemAsync(actor, IssuePosting(input)); + if (input.AssetId != null) + { + var asset = await GetAsync(actor, input.AssetId); + if (asset.IsDeleted || asset.ItemId != input.ItemId || asset.LotId != input.LotId) throw new InventoryException(409, "AssetConflict"); + if ((await _store.RelatedAsync(actor.DepartmentId, "AssetId", input.AssetId)).Any(x => x.Status is 0 or 2)) throw new InventoryException(409, "ReturnAssetFirst"); + if (asset.CurrentLocationId != input.FromLocationId) throw new InventoryException(409, "AssetLocationConflict"); + if (asset.Status != (int)InventoryAssetStatus.InService || asset.ExpiresOn <= Now) throw new InventoryException(409, "AssetNotAvailable"); + } + } + private static InventoryPosting IssuePosting(InventoryIssueInput input, string locationId = null, string issuanceId = null) => new InventoryPosting + { + ItemId = input.ItemId, AssetId = input.AssetId, LotId = input.LotId, FromLocationId = input.FromLocationId, ToLocationId = locationId, + Quantity = input.Quantity, Type = InventoryTransactionType.Issue, IssuanceId = issuanceId, ReferenceType = input.ReferenceType, ReferenceId = input.ReferenceId, Note = input.Note + }; + private async Task IssueLinesAsync(InventoryActor actor, InventoryOperation operation, IEnumerable inputs, List events, + string performer = null, string witness = null, string attestation = null) + { + var command = new InventoryCommand { RequestId = operation.RequestId }; var issuances = new List(); + foreach (var input in inputs) + { + await ValidateIssueAsync(actor, input); var location = await HolderLocationAsync(actor, input.UnitId, input.UserId); + var issuance = New(actor); issuance.CreatedBy = performer ?? actor.UserId; issuance.ItemId = input.ItemId; issuance.AssetId = input.AssetId; issuance.LotId = input.LotId; + issuance.Quantity = input.Quantity; issuance.IssuedToUserId = input.UserId; issuance.IssuedToUnitId = input.UnitId; issuance.LocationId = location.Id; + issuance.IssuedOn = Now; issuance.ExpectedReturnOn = input.ExpectedReturnOn?.ToUniversalTime(); issuance.Status = 0; + issuance.ReferenceType = (int)input.ReferenceType; issuance.ReferenceId = input.ReferenceId; issuance.Content = JsonConvert.SerializeObject(new { input.Note, IssuedByUserId = performer ?? actor.UserId }); + // Keep these identities in memory until every line has validated. + issuances.Add(issuance); + command.Lines.Add(IssuePosting(input, location.Id, issuance.Id)); + } + await ValidateCommandAsync(actor, command, true); + // The ledger has an optional issuance FK, so allocate the issuance before posting and allow this exact new identity in MoveAssetAsync. + foreach (var issuance in issuances) await SaveAsync(actor, issuance); + var result = await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); + result.IssuanceIds = issuances.Select(i => i.Id).ToList(); result.IssuanceId = result.IssuanceIds.First(); + foreach (var id in result.TransactionIds) await EventAsync(await _store.GetAsync(actor.DepartmentId, id), WorkflowTriggerEventType.InventoryIssued, events); + result.OutboxIds = events.ToList(); return result; + } + public Task ReturnAsync(InventoryActor actor, InventoryReturnInput input) => TransactionAsync(actor, async events => + { + await RequireReturnAccessAsync(actor, input); + return await OperationAsync(actor, input.RequestId, new { Kind = "Return", Input = input }, (operation, pending) => ReturnLinesAsync(actor, operation, input, pending), events); + }); + private static InventoryCommand ReturnCommand(InventoryReturnInput input, InventoryIssuance issuance) => new InventoryCommand + { + RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = issuance.ItemId, AssetId = issuance.AssetId, LotId = issuance.LotId, FromLocationId = issuance.LocationId, + ToLocationId = input.ToLocationId, Type = InventoryTransactionType.Return, Quantity = input.Quantity, Status = input.Condition, IssuanceId = issuance.Id, + ReferenceType = (InventoryReferenceType)issuance.ReferenceType, ReferenceId = issuance.ReferenceId, Note = input.Note } } + }; + private async Task RequireReturnAccessAsync(InventoryActor actor, InventoryReturnInput input) + { + if (input == null) throw new InventoryException(400, "IssuanceRequired"); Quantity(input.Quantity); Id(input.IssuanceId); + if (input.ToLocationId == null) throw new InventoryException(400, "InvalidMovement"); + var issuance = await _store.GetAsync(actor.DepartmentId, input.IssuanceId); if (issuance == null) throw new InventoryException(404, "Unavailable"); + await RequireCommandAccessAsync(actor, ReturnCommand(input, issuance)); + } + private async Task ReturnLinesAsync(InventoryActor actor, InventoryOperation operation, InventoryReturnInput input, List events, + string performer = null, string witness = null, string attestation = null) + { + await RequireReturnAccessAsync(actor, input); var issuance = await GetAsync(actor, input.IssuanceId); + if (issuance.Revision != input.Revision || issuance.Status is not (0 or 2) || input.Quantity > issuance.Quantity - issuance.ReturnedQuantity) throw new InventoryException(409, "ReturnConflict"); + if (input.Condition is not (InventoryAssetStatus.InService or InventoryAssetStatus.Damaged or InventoryAssetStatus.OutForRepair)) throw new InventoryException(400, "InvalidReturnCondition"); + var command = ReturnCommand(input, issuance); await ValidateCommandAsync(actor, command, true); + if (witness == null && await RequiresWitnessAsync(actor, new[] { issuance.ItemId })) + { + var pending = await AwaitWitnessAsync(actor, operation, "Return", returned: input); pending.IssuanceId = issuance.Id; return pending; + } + var result = await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); + issuance.ReturnedQuantity += input.Quantity; issuance.ReturnedToLocationId = input.ToLocationId; + issuance.Status = (int)(issuance.ReturnedQuantity == issuance.Quantity ? InventoryIssuanceStatus.Returned : InventoryIssuanceStatus.PartiallyReturned); + if (issuance.Status == (int)InventoryIssuanceStatus.Returned) issuance.ReturnedOn = Now; + await SaveAsync(actor, issuance, false); result.IssuanceId = issuance.Id; + await EventAsync(await _store.GetAsync(actor.DepartmentId, result.TransactionIds[0]), WorkflowTriggerEventType.InventoryReturned, events); + result.OutboxIds = events.ToList(); return result; + } + public Task SaveKitAsync(InventoryActor actor, InventoryKitInput input) => TransactionAsync(actor, async events => + { + await _auth.RequireAsync(actor, true); if (input?.Lines == null || input.Lines.Count is < 1 or > 100) throw new InventoryException(400, "InvalidKit"); Text(input.Name); + if (input.Lines.Select(l => l.ItemId).Distinct().Count() != input.Lines.Count) throw new InventoryException(400, "DuplicateKitItem"); + var kit = input.Id == null ? New(actor) : await GetAsync(actor, input.Id); + if (input.Id != null && kit.Revision != input.Revision) throw new InventoryException(409, "RevisionConflict"); + kit.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = input.Name }); await SaveAsync(actor, kit, input.Id == null); + foreach (var old in await _store.RelatedAsync(actor.DepartmentId, "KitId", kit.Id)) { old.IsDeleted = true; await SaveAsync(actor, old, false); } + foreach (var line in input.Lines) + { + Quantity(line.Quantity); var item = await GetAsync(actor, line.ItemId); if (item.IsDeleted || !item.IsActive || item.TrackingMode == 1 && decimal.Truncate(line.Quantity) != line.Quantity) throw new InventoryException(400, "InvalidKitItem"); + var row = New(actor); row.KitId = kit.Id; row.ItemId = item.Id; row.Quantity = line.Quantity; await SaveAsync(actor, row); + } + await AuditAsync(actor, kit, "InventoryKitSaved"); return await RevealAsync(actor, kit); + }); + public Task IssueKitAsync(InventoryActor actor, InventoryKitIssueInput input) => TransactionAsync(actor, async events => + { + if (input?.Lines == null || input.Lines.Count is < 1 or > 100) throw new InventoryException(400, "InvalidKit"); + foreach (var line in input.Lines) await ValidateIssueAccessAsync(actor, line); + return await OperationAsync(actor, input.RequestId, new { Kind = "KitIssue", input.KitId, input.Lines }, async (op, pending) => + { + var kit = await GetAsync(actor, input.KitId); if (kit.IsDeleted) throw new InventoryException(404, "Unavailable"); + var expected = (await _store.RelatedAsync(actor.DepartmentId, "KitId", kit.Id)).Where(x => !x.IsDeleted).ToDictionary(x => x.ItemId, x => x.Quantity); + var actual = input.Lines.GroupBy(x => x.ItemId).ToDictionary(x => x.Key, x => x.Sum(l => l.Quantity)); + if (expected.Count != actual.Count || expected.Any(x => !actual.TryGetValue(x.Key, out var quantity) || quantity != x.Value) || input.Lines.Select(l => (l.UnitId, l.UserId)).Distinct().Count() != 1) throw new InventoryException(400, "KitContentsMismatch"); + foreach (var line in input.Lines) await ValidateIssueAsync(actor, line); + if (await RequiresWitnessAsync(actor, input.Lines.Select(l => l.ItemId))) return await AwaitWitnessAsync(actor, op, "KitIssue", issues: input.Lines); + return await IssueLinesAsync(actor, op, input.Lines, pending); + }, events); + }); + private async Task EquipmentAsync(InventoryActor actor, InventoryAsset asset, InventoryStock stock = null) + { + var locationId = asset?.CurrentLocationId ?? stock.LocationId; var location = await LocationAsync(actor, locationId); var holder = await EffectiveLocationAsync(actor.DepartmentId, location); + var item = await GetAsync(actor, asset?.ItemId ?? stock.ItemId); var result = new InventoryEquipment { Asset = asset == null ? null : await RevealAsync(actor, asset), Stock = stock, ItemName = Decode(item).Name, UnitId = holder.UnitId, GroupId = holder.GroupId, UserId = holder.UserId }; + if (holder.UnitId.HasValue) result.GroupId = (await _units.GetUnitByIdAsync(holder.UnitId.Value))?.StationGroupId; + foreach (var issuance in await _store.RelatedAsync(actor.DepartmentId, asset != null ? "AssetId" : "ItemId", asset?.Id ?? stock.ItemId)) + if (issuance.Status is 0 or 2 && (asset != null || issuance.LocationId == stock.LocationId && issuance.LotId == stock.LotId)) result.Issuances.Add(await RevealAsync(actor, issuance)); + return result; + } + public async Task> GetUnitEquipmentAsync(InventoryActor actor, int unitId) + { + await _auth.RequireAsync(actor); var result = new List(); + foreach (var asset in (await AllAsync(actor.DepartmentId)).Where(a => !a.IsDeleted && a.CurrentLocationId != null && a.Status is not (4 or 5 or 6))) + { + try { var equipment = await EquipmentAsync(actor, asset); if (equipment.UnitId == unitId) result.Add(equipment); } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { } + } + foreach (var stock in (await AllAsync(actor.DepartmentId)).Where(s => s.Quantity > 0)) + { + try { var equipment = await EquipmentAsync(actor, null, stock); if (equipment.UnitId == unitId) result.Add(equipment); } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { } + } + return result; + } + public async Task> GetIssuableAsync(InventoryActor actor, string itemId = null, string locationId = null) + { + await _auth.RequireAsync(actor); var result = new List(); + foreach (var asset in (await AllAsync(actor.DepartmentId)).Where(a => !a.IsDeleted && a.Status == 0 && a.CurrentLocationId != null && (!a.ExpiresOn.HasValue || a.ExpiresOn > Now) && (itemId == null || itemId == a.ItemId) && (locationId == null || locationId == a.CurrentLocationId))) + { + try { var item = await GetAsync(actor, asset.ItemId); if (item.IsActive && !item.IsDeleted) result.Add(await EquipmentAsync(actor, asset)); } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { } + } + foreach (var stock in (await AllAsync(actor.DepartmentId)).Where(s => s.Quantity > 0 && (itemId == null || itemId == s.ItemId) && (locationId == null || locationId == s.LocationId))) + { + try { var item = await GetAsync(actor, stock.ItemId); var lot = stock.LotId == null ? null : await GetAsync(actor, stock.LotId); if (item.IsActive && !item.IsDeleted && (lot == null || !lot.IsDeleted && (!lot.ExpiresOn.HasValue || lot.ExpiresOn > Now))) result.Add(await EquipmentAsync(actor, null, stock)); } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { } + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryLegacyMigration.cs b/Core/Resgrid.Services/InventoryLegacyMigration.cs new file mode 100644 index 000000000..5819b972b --- /dev/null +++ b/Core/Resgrid.Services/InventoryLegacyMigration.cs @@ -0,0 +1,164 @@ +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; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + public Task MigrateLegacyAsync(InventoryActor actor) => TransactionAsync(actor, async events => + { + // No source group is supplied: a group-locked adjustment permission cannot authorize department cutover. + await _auth.RequireAsync(actor, true); + if (await _store.HasLegacyMigrationAsync(actor.DepartmentId)) return new InventoryMigrationResult { AlreadyMigrated = true }; + if (_legacyInventory == null || _legacyTypes == null) throw new InventoryException(409, "LegacyInventoryUnavailable"); + if ((await _store.ListAsync(actor.DepartmentId)).Count != 0 + || (await _store.ListAsync(actor.DepartmentId)).Count != 0 + || (await _store.ListAsync(actor.DepartmentId)).Count != 0) + throw new InventoryException(409, "LegacyMigrationHasExistingInventory"); + + var result = new InventoryMigrationResult(); + var types = (await _legacyTypes.GetAllByDepartmentIdAsync(actor.DepartmentId) ?? Enumerable.Empty()).ToList(); + var entries = (await _legacyInventory.GetAllInventoriesByDepartmentIdAsync(actor.DepartmentId) ?? Enumerable.Empty()).ToList(); + if (types.Any(t => t == null || t.DepartmentId != actor.DepartmentId || t.InventoryTypeId <= 0) + || types.GroupBy(t => t.InventoryTypeId).Any(g => g.Count() != 1)) throw new InventoryException(409, "LegacyInventoryTypesInvalid"); + if (entries.Any(t => t == null || t.DepartmentId != actor.DepartmentId || t.InventoryId <= 0) + || entries.GroupBy(t => t.InventoryId).Any(g => g.Count() != 1)) throw new InventoryException(409, "LegacyInventoryEntriesInvalid"); + var typeIds = types.Select(t => t.InventoryTypeId).ToHashSet(); + var holders = new Dictionary(StringComparer.Ordinal); + var amounts = new Dictionary(); + var sourceHolders = new Dictionary(); + var balances = new Dictionary<(int TypeId, string Holder), decimal>(); + + // Validate the complete source before inserting protected rows. Invalid holders are not silently reassigned. + foreach (var source in entries.OrderBy(t => t.TimeStamp).ThenBy(t => t.InventoryId)) + { + if (!typeIds.Contains(source.TypeId)) throw LegacyMigrationError("LegacyInventoryTypeMissing", source.InventoryId); + var amount = LegacyQuantity(source.Amount, source.InventoryId); + if (source.GroupId < 0 || source.UnitId <= 0) throw LegacyMigrationError("LegacyInventoryHolderInvalid", source.InventoryId); + if (source.GroupId > 0) + await ValidateLegacyHolderAsync(actor, NewLegacyLocation(actor, InventoryLocationType.Station, source.GroupId, null), source.InventoryId); + var holder = source.UnitId.HasValue ? "unit:" + source.UnitId.Value.ToString(CultureInfo.InvariantCulture) + : source.GroupId > 0 ? "station:" + source.GroupId.ToString(CultureInfo.InvariantCulture) : "unassigned"; + if (!holders.ContainsKey(holder)) + { + var location = source.UnitId.HasValue ? NewLegacyLocation(actor, InventoryLocationType.Unit, null, source.UnitId) + : source.GroupId > 0 ? NewLegacyLocation(actor, InventoryLocationType.Station, source.GroupId, null) + : NewLegacyLocation(actor, InventoryLocationType.Facility, null, null); + await ValidateLegacyHolderAsync(actor, location, source.InventoryId); + holders.Add(holder, location); + } + if (source.UnitId.HasValue && source.GroupId > 0) + { + var unit = await _units.GetUnitByIdAsync(source.UnitId.Value); + if (unit?.StationGroupId != source.GroupId) result.Warnings.Add("LegacyUnitLocationTakesPrecedence:" + source.InventoryId.ToString(CultureInfo.InvariantCulture)); + } + if (holder == "unassigned") result.Warnings.Add("LegacyUnassignedLocation:" + source.InventoryId.ToString(CultureInfo.InvariantCulture)); + amounts.Add(source.InventoryId, amount); sourceHolders.Add(source.InventoryId, holder); + var key = (source.TypeId, holder); + balances.TryGetValue(key, out var prior); + var next = prior + amount; + if (Math.Abs(next) >= 1000000000000000000m) throw LegacyMigrationError("LegacyInventoryBalanceOverflow", source.InventoryId); + balances[key] = next; + } + if (entries.Any(t => !string.IsNullOrEmpty(t.Batch))) result.Warnings.Add("LegacyBatchesPreservedAsSourceMetadata"); + if (balances.Values.Any(q => q < 0)) result.Warnings.Add("LegacyNegativeBalancesPreserved"); + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var duplicateNames = types.GroupBy(t => t.Type?.Trim() ?? "", StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var itemMap = new Dictionary(); + foreach (var source in types.OrderBy(t => t.InventoryTypeId)) + { + var original = source.Type?.Trim(); + var name = string.IsNullOrWhiteSpace(original) ? "Legacy item " + source.InventoryTypeId.ToString(CultureInfo.InvariantCulture) : original; + var renamed = string.IsNullOrWhiteSpace(original) || name.Length > 250 || duplicateNames.Contains(original ?? ""); + if (name.Length > 250) name = name.Substring(0, 250); + var stem = name; var suffixNumber = 0; + if (renamed || names.Contains(name)) + { + do + { + var suffix = " [legacy " + source.InventoryTypeId.ToString(CultureInfo.InvariantCulture) + (suffixNumber == 0 ? "" : "-" + suffixNumber.ToString(CultureInfo.InvariantCulture)) + "]"; + name = stem.Substring(0, Math.Min(stem.Length, 250 - suffix.Length)) + suffix; suffixNumber++; + } while (names.Contains(name)); + result.Warnings.Add("LegacyItemNamePreservedWithIdentifier:" + source.InventoryTypeId.ToString(CultureInfo.InvariantCulture)); + } + names.Add(name); + var item = New(actor); item.LegacyInventoryTypeId = source.InventoryTypeId; item.TrackingMode = (int)InventoryTrackingMode.Bulk; + var content = JObject.FromObject(new InventoryItemContent { Name = name, Description = source.Description, + UnitOfMeasure = string.IsNullOrWhiteSpace(source.UnitOfMesasure) ? "unit" : source.UnitOfMesasure, + DefaultExpirationDays = source.ExpiresDays > 0 ? source.ExpiresDays : null }); + content["LegacySource"] = JObject.FromObject(new { source.InventoryTypeId, source.Type, source.Description, source.UnitOfMesasure, source.ExpiresDays }); + item.Content = content.ToString(Formatting.None); await SaveAsync(actor, item); itemMap.Add(source.InventoryTypeId, item); + result.Items++; + } + + // An explicit default is also created for an empty department, ready for subsequent receipts. + var locations = await AllAsync(actor.DepartmentId); + if (!holders.ContainsKey("unassigned")) holders.Add("unassigned", NewLegacyLocation(actor, InventoryLocationType.Facility, null, null)); + foreach (var holder in holders.Keys.ToArray()) + { + var proposed = holders[holder]; + var existing = locations.Where(l => !l.IsDeleted && (holder == "unassigned" ? l.IsDefault + : l.LocationType == proposed.LocationType && l.UnitId == proposed.UnitId && l.GroupId == proposed.GroupId && l.UserId == null && l.ContainerAssetId == null)).ToList(); + if (existing.Count > 1) throw new InventoryException(409, "LegacyInventoryLocationsAmbiguous"); + if (existing.Count == 1) + { + var location = existing[0]; + if (location.ParentLocationId != null || holder == "unassigned" && location.LocationType != (int)InventoryLocationType.Facility) throw new InventoryException(409, "LegacyInventoryLocationsAmbiguous"); + await _auth.ValidateHolderAsync(actor, location); holders[holder] = location; + } + else await SaveAsync(actor, proposed); + } + + balances.Clear(); + foreach (var source in entries.OrderBy(t => t.TimeStamp).ThenBy(t => t.InventoryId)) + { + var amount = amounts[source.InventoryId]; var holder = sourceHolders[source.InventoryId]; var location = holders[holder]; + var key = (source.TypeId, holder); balances.TryGetValue(key, out var before); var after = before + amount; balances[key] = after; + var row = New(actor); row.CreatedBy = source.AddedByUserId; row.CreatedOn = source.TimeStamp; row.OccurredOn = source.TimeStamp; + row.TransactionType = (int)InventoryTransactionType.Migrated; row.ItemId = itemMap[source.TypeId].Id; row.LegacyInventoryId = source.InventoryId; + row.ReferenceType = (int)InventoryReferenceType.Legacy; row.ReferenceId = source.InventoryId.ToString(CultureInfo.InvariantCulture); row.Quantity = Math.Abs(amount); + if (amount < 0) { row.FromLocationId = location.Id; row.FromQuantityBefore = before; row.FromQuantityAfter = after; } + else { row.ToLocationId = location.Id; row.ToQuantityBefore = before; row.ToQuantityAfter = after; } + row.Content = JsonConvert.SerializeObject(new { SchemaVersion = 1, LegacySource = new { source.InventoryId, source.TypeId, source.DepartmentId, + source.GroupId, source.UnitId, source.Location, source.Batch, source.Note, source.Amount, source.AddedByUserId, source.TimeStamp } }); + await SaveAsync(actor, row); result.Transactions++; + } + await _store.RebuildStocksAsync(actor.DepartmentId); + var marker = New(actor); marker.RequestId = "00000000-0000-0000-0000-000000000001"; marker.State = 2; + marker.Content = JsonConvert.SerializeObject(result); + await AuditAsync(actor, marker, "InventoryLegacyMigrationCompleted"); + await SaveAsync(actor, marker); + return result; + }, allowMigration: true); + + private InventoryLocation NewLegacyLocation(InventoryActor actor, InventoryLocationType type, int? groupId, int? unitId) + { + var row = New(actor); row.LocationType = (int)type; row.GroupId = groupId; row.UnitId = unitId; + row.IsDefault = type == InventoryLocationType.Facility; + row.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = type == InventoryLocationType.Facility ? "Unassigned" : type.ToString() }); + return row; + } + private async Task ValidateLegacyHolderAsync(InventoryActor actor, InventoryLocation location, int inventoryId) + { + try { await _auth.ValidateHolderAsync(actor, location); } + catch (InventoryException ex) when (ex.StatusCode == 400 || ex.StatusCode == 404) { throw LegacyMigrationError("LegacyInventoryHolderInvalid", inventoryId); } + } + private static decimal LegacyQuantity(double value, int inventoryId) + { + // Round-trip text preserves the source decimal precision; casting double can silently discard significant digits. + if (!double.IsFinite(value) || !decimal.TryParse(value.ToString("R", CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var quantity) + || (double)quantity != value || quantity <= -1000000000000000000m || quantity >= 1000000000000000000m || decimal.Round(quantity, 6) != quantity) + throw LegacyMigrationError("LegacyInventoryQuantityRequiresReview", inventoryId); + return quantity; + } + private static InventoryException LegacyMigrationError(string code, int inventoryId) => new(409, code + ":" + inventoryId.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/Core/Resgrid.Services/InventoryModernizationService.cs b/Core/Resgrid.Services/InventoryModernizationService.cs new file mode 100644 index 000000000..ab70a7a96 --- /dev/null +++ b/Core/Resgrid.Services/InventoryModernizationService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService : IInventoryCatalogService, IInventoryStockService, IInventoryTransferService, IInventoryIssuanceService, IInventoryMigrationService, IChecklistAssetSource, IChecklistHistoricalAssetSource + { + private readonly IInventoryStore _store; + private readonly IInventoryAuthorizationService _auth; + private readonly IUnitOfWork _uow; + private readonly IProtectedReadService _read; + private readonly IProtectedWriteService _write; + private readonly IDomainEventOutboxService _outbox; + private readonly IAuditLogsRepository _audit; + private readonly IUnitsService _units; + private readonly IDepartmentGroupsService _groups; + private readonly TimeProvider _clock; + private readonly IInventoryRepository _legacyInventory; + private readonly IInventoryTypesRepository _legacyTypes; + private readonly IWorkOrderRepository _workOrders; + private readonly Lazy _workOrderAuthorization; + public InventoryModernizationService(IInventoryStore store, IInventoryAuthorizationService auth, IUnitOfWork uow, IProtectedReadService read, + IProtectedWriteService write, IDomainEventOutboxService outbox, IAuditLogsRepository audit, IUnitsService units, IDepartmentGroupsService groups, TimeProvider clock = null, + IInventoryRepository legacyInventory = null, IInventoryTypesRepository legacyTypes = null, + IWorkOrderRepository workOrders = null, Lazy workOrderAuthorization = null) + { _store = store; _auth = auth; _uow = uow; _read = read; _write = write; _outbox = outbox; _audit = audit; _units = units; _groups = groups; _clock = clock ?? TimeProvider.System; _legacyInventory = legacyInventory; _legacyTypes = legacyTypes; _workOrders = workOrders; _workOrderAuthorization = workOrderAuthorization; } + public Task IsMigratedAsync(int departmentId) => _store.HasLegacyMigrationAsync(departmentId); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + private static void Id(string id) { if (!Guid.TryParseExact(id, "D", out var value) || value == Guid.Empty) throw new InventoryException(400, "InvalidIdentifier"); } + private static void Text(string text, int max = 250) { if (string.IsNullOrWhiteSpace(text) || text.Length > max || text == ProtectedDataEnvelope.RedactionValue) throw new InventoryException(400, "InvalidText"); } + private static void Quantity(decimal quantity, bool zero = false) { if (quantity < 0 || !zero && quantity == 0 || quantity > 100000000m || decimal.Round(quantity, 6) != quantity) throw new InventoryException(400, "InvalidQuantity"); } + private static string Fingerprint(object input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input)))); + private static T Decode(InventoryRow row) where T : new() => string.IsNullOrEmpty(row.Content) ? new T() : JsonConvert.DeserializeObject(row.Content) ?? new T(); + private T New(InventoryActor actor) where T : InventoryRow, new() => new() { DepartmentId = actor.DepartmentId, CreatedBy = actor.UserId, CreatedOn = Now, ModifiedOn = Now }; + private async Task RevealAsync(InventoryActor actor, T row) where T : InventoryRow + { + if (row?.DepartmentId != actor.DepartmentId) throw new InventoryException(404, "Unavailable"); + var plain = !string.IsNullOrEmpty(row.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content); + var result = await _read.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, new[] { (row, row.Id) }, InventoryTables.Fields(), actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && plain || ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new InventoryException(403, "ProtectedDataRequired"); + return row; + } + private async Task SaveAsync(InventoryActor actor, T row, bool insert = true) where T : InventoryRow + { + row.ModifiedOn = Now; + var result = await _write.PrepareRecordsEntityWriteAsync(actor.DepartmentId, row, (T)null, row.Id, InventoryTables.Fields(), () => row.IsProtected = true, actor.GrantToken, actor.UserId, false); + if (result?.Success != true || result.IsProtected && !string.IsNullOrEmpty(row.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(row.Content)) throw new InventoryException(403, "ProtectedDataRequired"); + if (insert) await _store.InsertAsync(row); + else { var revision = row.Revision++; await _store.UpdateAsync(row, revision); } + } + private async Task TransactionAsync(InventoryActor actor, Func, Task> work, bool allowMigration = false) + { + await _auth.RequireAsync(actor); // Location-aware mutation checks happen inside the operation. + if (_uow.Transaction != null) throw new InvalidOperationException("Inventory commands own their transaction; use the explicit joined posting contract."); + var events = new List(); T result; + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _store.LockDepartmentAsync(actor.DepartmentId); + if (!allowMigration && !await _store.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + if (!await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + var preflight = await _write.PreflightWriteAsync(actor.DepartmentId, actor.GrantToken, actor.UserId, false); + if (preflight?.Success != true) throw new InventoryException(403, "ProtectedDataRequired"); + result = await work(events); _uow.CommitChanges(); + } + catch { _uow.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events); return result; + } + private async Task AuditAsync(InventoryActor actor, InventoryRow row, string action) + { + var entry = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, + ObjectId = row.Id, LogType = (int)AuditLogTypes.InventoryChanged, Message = action, LoggedOn = Now, Successful = true, ServerName = Environment.MachineName }, CancellationToken.None); + entry.Data = JsonConvert.SerializeObject(new { row.Id, row.Revision, Entity = row.TableName, Action = action }); + var result = await _write.PrepareRecordsEntityWriteAsync(actor.DepartmentId, entry, null, entry.AuditLogId.ToString(System.Globalization.CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, actor.GrantToken, actor.UserId, false); + if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(entry.Data)) throw new InventoryException(403, "ProtectedDataRequired"); + await _audit.UpdateAsync(entry, CancellationToken.None); + } + private async Task> AllAsync(int departmentId) where T : InventoryRow + { + var all = new List(); + for (var skip = 0; ; skip += 500) + { + var page = await _store.ListAsync(departmentId, skip); all.AddRange(page.Take(500)); if (page.Count <= 500) return all; + if (skip >= 100000) throw new InventoryException(409, "InventoryTooLarge"); + } + } + private async Task EffectiveLocationAsync(int departmentId, InventoryLocation location, HashSet seen = null, bool historical = false) + { + seen ??= new HashSet(); + if (location?.DepartmentId != departmentId || !seen.Add(location.Id) || seen.Count > 32) throw new InventoryException(409, "InvalidLocationHierarchy"); + if (location.ContainerAssetId != null) + { + var asset = await _store.GetAsync(departmentId, location.ContainerAssetId); + if (asset == null || !historical && (asset.IsDeleted || asset.Status is 4 or 5 or 6) || asset.CurrentLocationId == null) throw new InventoryException(409, "LocationUnavailable"); + return await EffectiveLocationAsync(departmentId, await _store.GetAsync(departmentId, asset.CurrentLocationId), seen, historical); + } + if (location.ParentLocationId != null) return await EffectiveLocationAsync(departmentId, await _store.GetAsync(departmentId, location.ParentLocationId), seen, historical); + return location; + } + private async Task LocationAsync(InventoryActor actor, string id, bool write = false, PermissionTypes? permission = null, bool historical = false) + { + Id(id); var location = await _store.GetAsync(actor.DepartmentId, id); + if (location == null || location.IsDeleted && !historical) throw new InventoryException(404, "LocationUnavailable"); + var effective = await EffectiveLocationAsync(actor.DepartmentId, location, historical: historical); + if (!await _auth.CanLocationAsync(actor, effective)) throw new InventoryException(404, "LocationUnavailable"); + var groupId = effective.GroupId; + if (effective.UnitId.HasValue) groupId = (await _units.GetUnitByIdAsync(effective.UnitId.Value))?.StationGroupId; + if (effective.UserId != null) groupId = (await _groups.GetGroupForUserAsync(effective.UserId, actor.DepartmentId))?.DepartmentGroupId; + await _auth.RequireAsync(actor, write, permission, groupId); return location; + } + public async Task GetAsync(InventoryActor actor, string id) where T : InventoryRow + { + await _auth.RequireAsync(actor); Id(id); var row = await _store.GetAsync(actor.DepartmentId, id); + if (row == null) throw new InventoryException(404, "Unavailable"); + await AuthorizeRowAsync(actor, row); return await RevealAsync(actor, row); + } + private async Task AuthorizeRowAsync(InventoryActor actor, InventoryRow row) + { + if (row is InventoryTransferItem detail) + { + var transfer = await _store.GetAsync(actor.DepartmentId, detail.TransferId); + if (transfer == null) throw new InventoryException(404, "Unavailable"); + await AuthorizeRowAsync(actor, transfer); + } + var ids = row switch + { + InventoryLocation l => new[] { l.Id }, InventoryAsset a => new[] { a.CurrentLocationId }, InventoryStock s => new[] { s.LocationId }, + InventoryTransaction t => new[] { t.FromLocationId, t.ToLocationId }, InventoryIssuance i => new[] { i.LocationId }, + InventoryTransfer t => new[] { t.FromLocationId, t.ToLocationId }, _ => Array.Empty() + }; + foreach (var id in ids.Where(x => x != null).Distinct()) await LocationAsync(actor, id, historical: row is InventoryTransaction or InventoryTransfer or InventoryIssuance); + if (row is InventoryOperation op && op.CreatedBy != actor.UserId) await _auth.RequireAsync(actor, false, PermissionTypes.ManageControlledSubstances); + } + public async Task> ListAsync(InventoryActor actor, int page = 0) where T : InventoryRow + { + await _auth.RequireAsync(actor); if (page < 0 || page > 10000) throw new InventoryException(400, "InvalidPage"); + var rows = await _store.ListAsync(actor.DepartmentId, page * 500); var result = new InventoryPage { HasMore = rows.Count > 500 }; + foreach (var row in rows.Take(500)) + { + if (row is InventoryMutableRow mutable && mutable.IsDeleted) continue; + try { await AuthorizeRowAsync(actor, row); } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { continue; } + result.Items.Add(await RevealAsync(actor, row)); + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryPosting.cs b/Core/Resgrid.Services/InventoryPosting.cs new file mode 100644 index 000000000..36a1cd231 --- /dev/null +++ b/Core/Resgrid.Services/InventoryPosting.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + private async Task OperationAsync(InventoryActor actor, string requestId, object input, Func, Task> work, List events) + { + Id(requestId); if (requestId == "00000000-0000-0000-0000-000000000001") throw new InventoryException(400, "ReservedRequestId"); + var fingerprint = Fingerprint(input); var existing = await _store.RequestAsync(actor.DepartmentId, requestId); + if (existing != null) + { + if (existing.CreatedBy != actor.UserId) throw new InventoryException(409, "RequestConflict"); + var receipt = Decode(await RevealAsync(actor, existing)); + if (receipt.Fingerprint != fingerprint) throw new InventoryException(409, "RequestConflict"); + return receipt.Result ?? throw new InventoryException(409, "RequestInProgress"); + } + var operation = New(actor); operation.RequestId = requestId; operation.State = 1; + // Insert the operation first so ledger FK constraints can join it. Content has no unsaved caller payload. + operation.Content = JsonConvert.SerializeObject(new InventoryOperationContent { Fingerprint = fingerprint }); await SaveAsync(actor, operation); + var result = await work(operation, events); result.OperationId = operation.Id; result.OutboxIds = events.ToList(); + operation.State = result.AwaitingWitness ? 1 : 2; + var content = Decode(await RevealAsync(actor, operation)); content.Fingerprint = fingerprint; content.Result = result; + operation.Content = JsonConvert.SerializeObject(content); await SaveAsync(actor, operation, false); return result; + } + private async Task ValidateCommandAsync(InventoryActor actor, InventoryCommand command, bool issuance = false, bool joined = false) + { + await _auth.RequireAsync(actor); + if (command?.Lines == null || command.Lines.Count is < 1 or > 100) throw new InventoryException(400, "InvalidLines"); Id(command.RequestId); + foreach (var line in command.Lines) + { + if (line == null || !Enum.IsDefined(line.Type) || line.Type is InventoryTransactionType.Migrated or InventoryTransactionType.Count || !issuance && line.Type is InventoryTransactionType.Issue or InventoryTransactionType.Return) + throw new InventoryException(400, "InvalidTransactionType"); + Quantity(line.Quantity, line.Type == InventoryTransactionType.StatusChange); + if (line.Type == InventoryTransactionType.StatusChange && (line.Quantity != 0 || line.AssetId == null || !line.Status.HasValue || !Enum.IsDefined(line.Status.Value))) throw new InventoryException(400, "InvalidAssetStatus"); + if (line.Type != InventoryTransactionType.StatusChange && line.Status.HasValue && !issuance) throw new InventoryException(400, "InvalidAssetStatus"); + if (!issuance && line.IssuanceId != null) throw new InventoryException(400, "InvalidIssuance"); + if (line.Note?.Length > 16000 || line.UnitCost < 0) throw new InventoryException(400, "InvalidText"); + if (line.FromLocationId == line.ToLocationId && line.Type != InventoryTransactionType.StatusChange) throw new InventoryException(400, "DistinctLocationsRequired"); + var from = line.FromLocationId != null; var to = line.ToLocationId != null; + if (line.Type == InventoryTransactionType.Receive && (from || !to) + || line.Type is InventoryTransactionType.Consume or InventoryTransactionType.WriteOff && (!from || to) + || line.Type is InventoryTransactionType.Transfer or InventoryTransactionType.Issue or InventoryTransactionType.Return && (!from || !to) + || line.Type == InventoryTransactionType.Adjust && from == to) throw new InventoryException(400, "InvalidMovement"); + var permission = line.Type == InventoryTransactionType.Transfer ? PermissionTypes.TransferInventory : line.Type is InventoryTransactionType.Issue or InventoryTransactionType.Return ? PermissionTypes.IssueInventory : PermissionTypes.AdjustInventory; + if (from) await LocationAsync(actor, line.FromLocationId, true, permission); + if (to) await LocationAsync(actor, line.ToLocationId, true, permission); + if (!from && !to) await _auth.RequireAsync(actor, true, permission); + await ValidatePostingItemAsync(actor, line, joined); + } + } + private async Task ValidatePostingItemAsync(InventoryActor actor, InventoryPosting line, bool joined = false) + { + var item = await GetAsync(actor, line.ItemId); + if (item.IsDeleted || !item.IsActive) throw new InventoryException(409, "ItemUnavailable"); + if (item.IsControlledSubstance) await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); + if (!Enum.IsDefined(line.ReferenceType) || line.ReferenceId?.Length > 128 || (line.ReferenceType == InventoryReferenceType.None) != (line.ReferenceId == null)) throw new InventoryException(400, "InvalidReference"); + if (line.ReferenceId != null && !Guid.TryParseExact(line.ReferenceId, "D", out _) && (!long.TryParse(line.ReferenceId, out var numeric) || numeric <= 0)) throw new InventoryException(400, "InvalidReference"); + await ValidateReferenceAsync(actor, line, joined); + if (line.LotId != null) + { + var lot = await GetAsync(actor, line.LotId); + if (lot.ItemId != item.Id || lot.IsDeleted || item.RequiresExpiration && !lot.ExpiresOn.HasValue) throw new InventoryException(400, "LotMismatch"); + if (line.Type is InventoryTransactionType.Consume or InventoryTransactionType.Issue && lot.ExpiresOn <= Now) throw new InventoryException(409, "LotExpired"); + } + else if (item.RequiresLotTracking || item.RequiresExpiration && item.TrackingMode == (int)InventoryTrackingMode.Bulk) throw new InventoryException(400, "LotRequired"); + if (item.TrackingMode == (int)InventoryTrackingMode.Serialized) + { + if (line.Type == InventoryTransactionType.Adjust) throw new InventoryException(400, "SerializedAdjustmentUnsupported"); + if (line.AssetId == null || line.Type != InventoryTransactionType.StatusChange && line.Quantity != 1) throw new InventoryException(400, "SerializedQuantity"); + } + else if (line.AssetId != null || line.Type == InventoryTransactionType.StatusChange) throw new InventoryException(400, "AssetMismatch"); + } + // Recheck the original principal without using another person's protected-data grant. + private async Task RequireCommandAccessAsync(InventoryActor actor, InventoryCommand command, bool joined = false) + { + await _auth.RequireAsync(actor); + if (command?.Lines == null || command.Lines.Count is < 1 or > 100 || command.Lines.Any(l => l == null)) throw new InventoryException(400, "InvalidLines"); + foreach (var line in command.Lines) + { + var permission = line.Type == InventoryTransactionType.Transfer ? PermissionTypes.TransferInventory : line.Type is InventoryTransactionType.Issue or InventoryTransactionType.Return ? PermissionTypes.IssueInventory : PermissionTypes.AdjustInventory; + if (line.FromLocationId != null) await LocationAsync(actor, line.FromLocationId, true, permission); + if (line.ToLocationId != null) await LocationAsync(actor, line.ToLocationId, true, permission); + if (line.FromLocationId == null && line.ToLocationId == null) await _auth.RequireAsync(actor, true, permission); + if (line.AssetId != null) + { + Id(line.AssetId); var asset = await _store.GetAsync(actor.DepartmentId, line.AssetId); + if (asset == null) throw new InventoryException(404, "Unavailable"); + if (asset.CurrentLocationId != null) await LocationAsync(actor, asset.CurrentLocationId, true, permission); + } + Id(line.ItemId); var item = await _store.GetAsync(actor.DepartmentId, line.ItemId); + if (item == null) throw new InventoryException(404, "Unavailable"); + if (item.IsControlledSubstance) await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); + await ValidateReferenceAsync(actor, line, joined, requireOpen: false); + } + } + private async Task RequiresWitnessAsync(InventoryActor actor, IEnumerable itemIds) + { + var controlled = false; + foreach (var id in itemIds.Distinct()) + { + Id(id); var item = await _store.GetAsync(actor.DepartmentId, id); + if (item == null) throw new InventoryException(404, "Unavailable"); + controlled |= item.IsControlledSubstance; + } + return controlled; + } + private async Task AwaitWitnessAsync(InventoryActor actor, InventoryOperation operation, string kind, InventoryCommand command = null, + List issues = null, InventoryReturnInput returned = null, string assetId = null) + { + operation.Content = JsonConvert.SerializeObject(new InventoryOperationContent { PendingKind = kind, PendingCommand = command, PendingIssues = issues, + PendingReturn = returned, PerformerId = actor.UserId }); + await SaveAsync(actor, operation, false); await AuditAsync(actor, operation, "InventoryWitnessRequested"); + return new InventoryResult { OperationId = operation.Id, AwaitingWitness = true, AssetId = assetId }; + } + public Task PostTransactionAsync(InventoryActor actor, InventoryCommand command, CancellationToken ct = default) => TransactionAsync(actor, async events => + { + ct.ThrowIfCancellationRequested(); await RequireCommandAccessAsync(actor, command); + if (command.Lines.Any(l => l.Type == InventoryTransactionType.Transfer)) throw new InventoryException(400, "TransferCommandRequired"); + return await OperationAsync(actor, command.RequestId, new { Kind = "Post", command.Lines }, async (operation, pending) => + { + await ValidateCommandAsync(actor, command); + if (await RequiresWitnessAsync(actor, command.Lines.Select(l => l.ItemId))) return await AwaitWitnessAsync(actor, operation, "Post", command); + return await PostLinesAsync(actor, operation, command, pending); + }, events); + }); + public async Task PostWithinTransactionAsync(InventoryActor actor, InventoryCommand command, CancellationToken ct = default) + { + if (_uow.Transaction == null) throw new InvalidOperationException("An owning transaction is required."); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + ct.ThrowIfCancellationRequested(); await _store.LockDepartmentAsync(actor.DepartmentId); + if (!await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + var preflight = await _write.PreflightWriteAsync(actor.DepartmentId, actor.GrantToken, actor.UserId, false); + if (preflight?.Success != true) throw new InventoryException(403, "ProtectedDataRequired"); + await RequireCommandAccessAsync(actor, command, joined: true); + if (command.Lines.Any(l => l.Type == InventoryTransactionType.Transfer)) throw new InventoryException(400, "TransferCommandRequired"); + foreach (var line in command.Lines) if ((await _store.GetAsync(actor.DepartmentId, line.ItemId)).IsControlledSubstance) throw new InventoryException(409, "IndependentWitnessRequired"); + var events = new List(); + return await OperationAsync(actor, command.RequestId, new { Kind = "Post", command.Lines }, async (op, pending) => + { + await ValidateCommandAsync(actor, command, joined: true); + return await PostLinesAsync(actor, op, command, pending); + }, events); + } + public Task WitnessAsync(InventoryActor actor, string requestId, string attestation) => TransactionAsync(actor, async events => + { + await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); Text(attestation, 4000); Id(requestId); + var operation = await _store.RequestAsync(actor.DepartmentId, requestId); if (operation == null) throw new InventoryException(404, "Unavailable"); + var receipt = Decode(await RevealAsync(actor, operation)); + if (operation.State == 2 && receipt.WitnessId == actor.UserId && receipt.Attestation == attestation) + { + if (receipt.Result == null) throw new InventoryException(409, "RequestInProgress"); + foreach (var id in receipt.Result.TransactionIds) + { + var transaction = await _store.GetAsync(actor.DepartmentId, id); + if (transaction == null || transaction.OperationId != operation.Id) throw new InventoryException(404, "Unavailable"); + await AuthorizeRowAsync(actor, transaction); + } + return receipt.Result; + } + if (operation.State != 1 || string.IsNullOrWhiteSpace(receipt.PerformerId) || receipt.PerformerId == actor.UserId || receipt.PerformerId != operation.CreatedBy) throw new InventoryException(409, "IndependentWitnessRequired"); + var performer = new InventoryActor { DepartmentId = actor.DepartmentId, UserId = receipt.PerformerId }; + await _auth.RequireAsync(performer, true, PermissionTypes.ManageControlledSubstances); + InventoryResult result; + switch (receipt.PendingKind ?? "Post") + { + case "Post": + case "Transfer": + case "Receive": + await RequireCommandAccessAsync(performer, receipt.PendingCommand); + await RequireCommandAccessAsync(actor, receipt.PendingCommand); + await ValidateCommandAsync(actor, receipt.PendingCommand); + if (receipt.PendingKind == "Transfer") result = await CompleteTransferAsync(actor, operation, receipt.PendingCommand, events, performer.UserId, actor.UserId, attestation); + else if (receipt.PendingKind == "Receive") result = await ReceiveAssetAsync(actor, operation, receipt.PendingCommand, events, performer.UserId, actor.UserId, attestation); + else result = await PostLinesAsync(actor, operation, receipt.PendingCommand, events, performer.UserId, actor.UserId, attestation); + break; + case "Issue": + case "KitIssue": + if (receipt.PendingIssues == null || receipt.PendingIssues.Count is < 1 or > 100) throw new InventoryException(409, "IndependentWitnessRequired"); + foreach (var input in receipt.PendingIssues) await ValidateIssueAccessAsync(performer, input); + result = await IssueLinesAsync(actor, operation, receipt.PendingIssues, events, performer.UserId, actor.UserId, attestation); + break; + case "Return": + if (receipt.PendingReturn == null) throw new InventoryException(409, "IndependentWitnessRequired"); + await RequireReturnAccessAsync(performer, receipt.PendingReturn); + result = await ReturnLinesAsync(actor, operation, receipt.PendingReturn, events, performer.UserId, actor.UserId, attestation); + break; + default: throw new InventoryException(409, "IndependentWitnessRequired"); + } + result.OutboxIds = events.ToList(); + // Clearing the protected pending request and all movement effects commit atomically. + receipt.PendingCommand = null; receipt.PendingIssues = null; receipt.PendingReturn = null; receipt.PendingKind = null; + receipt.WitnessId = actor.UserId; receipt.WitnessedOn = Now; receipt.Attestation = attestation; receipt.Result = result; + operation.State = 2; operation.WitnessUserId = actor.UserId; operation.Content = JsonConvert.SerializeObject(receipt); await SaveAsync(actor, operation, false); return result; + }); + private async Task PostLinesAsync(InventoryActor actor, InventoryOperation operation, InventoryCommand command, List events, string performer = null, string witness = null, string attestation = null) + { + var result = new InventoryResult { OperationId = operation.Id }; + for (var index = 0; index < command.Lines.Count; index++) + { + var line = command.Lines[index]; var item = await GetAsync(actor, line.ItemId); + var transaction = New(actor); transaction.OperationId = operation.Id; transaction.LineNumber = index; + transaction.ItemId = item.Id; transaction.AssetId = line.AssetId; transaction.LotId = line.LotId; transaction.Quantity = line.Quantity; + transaction.FromLocationId = line.FromLocationId; transaction.ToLocationId = line.ToLocationId; transaction.TransactionType = (int)line.Type; + transaction.ReferenceType = (int)line.ReferenceType; transaction.ReferenceId = line.ReferenceId; transaction.OccurredOn = Now; + transaction.ReversesTransactionId = line.ReversesTransactionId; transaction.IssuanceId = line.IssuanceId; + if (line.ReversesTransactionId != null) + { + var original = await GetAsync(actor, line.ReversesTransactionId); + if (original.ReversesTransactionId != null || original.ItemId != item.Id || original.AssetId != line.AssetId || original.LotId != line.LotId || original.Quantity != line.Quantity + || original.FromLocationId != line.ToLocationId || original.ToLocationId != line.FromLocationId || original.TransactionType is 0 or 4 or 5 or 9 + || (await _store.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", original.Id)).Count > 0) throw new InventoryException(409, "InvalidReversal"); + } + if (item.TrackingMode == (int)InventoryTrackingMode.Bulk) + { + if (line.FromLocationId != null) + { + var stock = await _store.ApplyStockDeltaAsync(actor.DepartmentId, item.Id, line.FromLocationId, line.LotId, -line.Quantity, actor.UserId); + if (stock.Quantity < 0) throw new InventoryException(409, "InsufficientStock"); + transaction.FromQuantityAfter = stock.Quantity; transaction.FromQuantityBefore = stock.Quantity + line.Quantity; + } + if (line.ToLocationId != null) + { + var stock = await _store.ApplyStockDeltaAsync(actor.DepartmentId, item.Id, line.ToLocationId, line.LotId, line.Quantity, actor.UserId); + transaction.ToQuantityAfter = stock.Quantity; transaction.ToQuantityBefore = stock.Quantity - line.Quantity; + } + } + else await MoveAssetAsync(actor, line, transaction); + var cost = line.UnitCost ?? (line.LotId == null ? null : Decode(await GetAsync(actor, line.LotId)).UnitCost) + ?? Decode(item).DefaultUnitCost; + transaction.CreatedBy = performer ?? actor.UserId; + var serial = line.AssetId == null ? null : Decode(await GetAsync(actor, line.AssetId)).SerialNumber; + transaction.Content = JsonConvert.SerializeObject(new { line.Note, ItemName = Decode(item).Name, SerialNumber = serial, UnitCost = cost, TotalCost = cost * line.Quantity, PerformerId = performer ?? actor.UserId, WitnessUserId = witness, WitnessedOn = witness == null ? (DateTime?)null : Now, Attestation = attestation }); + await SaveAsync(actor, transaction); await AuditAsync(actor, transaction, "InventoryTransactionPosted"); result.TransactionIds.Add(transaction.Id); + await EventAsync(transaction, WorkflowTriggerEventType.InventoryAdjusted, events); + if (transaction.OldStatus != transaction.NewStatus) await EventAsync(transaction, WorkflowTriggerEventType.InventoryAssetStatusChanged, events); + if (item.IsControlledSubstance) { if (witness == null) throw new InventoryException(409, "IndependentWitnessRequired"); await EventAsync(transaction, WorkflowTriggerEventType.ControlledSubstanceRecorded, events); } + } + result.OutboxIds = events.ToList(); return result; + } + private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, InventoryTransaction transaction) + { + var asset = await GetAsync(actor, line.AssetId); + if (asset.ItemId != line.ItemId || asset.LotId != line.LotId || asset.IsDeleted || line.ExpectedAssetRevision.HasValue && asset.Revision != line.ExpectedAssetRevision) throw new InventoryException(409, "AssetConflict"); + // Terminal holders remain for audit access, never as stock available for another disposal or implicit recovery. + if (asset.Status is 4 or 5 or 6 && (line.Type != InventoryTransactionType.StatusChange || line.Status is InventoryAssetStatus.InService or InventoryAssetStatus.Issued or InventoryAssetStatus.OutForRepair or InventoryAssetStatus.Damaged)) + throw new InventoryException(409, "AssetNotAvailable"); + if (asset.CurrentLocationId == null && line.Type != InventoryTransactionType.Receive) throw new InventoryException(409, "AssetNotAvailable"); + if (line.Type != InventoryTransactionType.Receive && asset.CurrentLocationId != line.FromLocationId) throw new InventoryException(409, "AssetLocationConflict"); + if (line.Type == InventoryTransactionType.Receive && asset.CurrentLocationId != null) throw new InventoryException(409, "AssetAlreadyReceived"); + if (line.Type is InventoryTransactionType.Issue or InventoryTransactionType.Transfer && (asset.Status != (int)InventoryAssetStatus.InService || asset.ExpiresOn <= Now)) throw new InventoryException(409, "AssetNotAvailable"); + if (line.Type == InventoryTransactionType.Consume && asset.ExpiresOn <= Now) throw new InventoryException(409, "AssetNotAvailable"); + var issuance = (await _store.RelatedAsync(actor.DepartmentId, "AssetId", asset.Id)).SingleOrDefault(x => x.Status is 0 or 2); + if (issuance != null && line.Type is not (InventoryTransactionType.Return or InventoryTransactionType.StatusChange) && !(line.Type == InventoryTransactionType.Issue && issuance.Id == line.IssuanceId)) throw new InventoryException(409, "ReturnAssetFirst"); + var status = line.Type switch { InventoryTransactionType.Issue => InventoryAssetStatus.Issued, InventoryTransactionType.Consume => InventoryAssetStatus.Consumed, + InventoryTransactionType.WriteOff => InventoryAssetStatus.Lost, InventoryTransactionType.Return or InventoryTransactionType.StatusChange => line.Status ?? InventoryAssetStatus.InService, _ => (InventoryAssetStatus)asset.Status }; + if (line.Type == InventoryTransactionType.StatusChange) + { + if (asset.Status == (int)status) throw new InventoryException(409, "StatusUnchanged"); + if (status == InventoryAssetStatus.Issued && issuance == null || status == InventoryAssetStatus.InService && issuance != null) throw new InventoryException(409, "IssuanceStatusConflict"); + transaction.ToLocationId = asset.CurrentLocationId; + if (issuance != null && status is InventoryAssetStatus.Lost or InventoryAssetStatus.Consumed or InventoryAssetStatus.Retired) + { issuance.Status = (int)(status == InventoryAssetStatus.Consumed ? InventoryIssuanceStatus.Consumed : InventoryIssuanceStatus.Lost); issuance.ReturnedOn = Now; await SaveAsync(actor, issuance, false); } + } + else + { + if (line.ToLocationId != null) + { + var target = await _store.GetAsync(actor.DepartmentId, line.ToLocationId); var visited = new HashSet(); + while (target != null) { if (!visited.Add(target.Id) || target.ContainerAssetId == asset.Id || visited.Count > 32) throw new InventoryException(400, "InvalidLocationHierarchy"); target = target.ContainerAssetId != null ? await _store.GetAsync(actor.DepartmentId, (await _store.GetAsync(actor.DepartmentId, target.ContainerAssetId))?.CurrentLocationId) : target.ParentLocationId == null ? null : await _store.GetAsync(actor.DepartmentId, target.ParentLocationId); } + } + // Retain the last holder for access control and audit after terminal disposal. + asset.CurrentLocationId = line.ToLocationId ?? asset.CurrentLocationId; + } + transaction.OldStatus = asset.Status; transaction.NewStatus = (int)status; asset.Status = (int)status; await SaveAsync(actor, asset, false); + } + private async Task EventAsync(InventoryTransaction transaction, WorkflowTriggerEventType trigger, List events, string transferId = null) + { + var entry = await _outbox.EnqueueAsync(transaction.DepartmentId, "Inventory", new DomainEventEnvelope + { + EventName = trigger.ToString(), SchemaVersion = 1, AggregateType = transferId != null ? "InventoryTransfer" : transaction.AssetId != null ? "InventoryAsset" : "InventoryItem", + AggregateId = transferId ?? transaction.AssetId ?? transaction.ItemId, Trigger = trigger, CorrelationId = transaction.OperationId, OccurredOn = transaction.OccurredOn, + Payload = new { InventoryEvent = true, TransactionId = transaction.Id, transaction.ItemId, transaction.AssetId, transaction.LotId, TransferId = transferId, transaction.IssuanceId, transaction.TransactionType, transaction.Quantity, + transaction.FromLocationId, transaction.ToLocationId, transaction.FromQuantityBefore, transaction.FromQuantityAfter, transaction.ToQuantityBefore, transaction.ToQuantityAfter, + transaction.OldStatus, transaction.NewStatus, transaction.ReferenceType, transaction.ReferenceId, transaction.OccurredOn, transaction.ReversesTransactionId } + }); events.Add(entry.DomainEventOutboxId); + } + public Task CreateAndCompleteTransferAsync(InventoryActor actor, InventoryCommand command) => TransactionAsync(actor, async events => + { + await RequireCommandAccessAsync(actor, command); if (command.Lines.Any(l => l.Type != InventoryTransactionType.Transfer)) throw new InventoryException(400, "TransferLinesRequired"); + if (command.Lines.Select(l => l.FromLocationId).Distinct().Count() != 1 || command.Lines.Select(l => l.ToLocationId).Distinct().Count() != 1) throw new InventoryException(400, "TransferLocationsRequired"); + return await OperationAsync(actor, command.RequestId, new { Kind = "Transfer", command.Lines }, async (op, pending) => + { + await ValidateCommandAsync(actor, command); + if (await RequiresWitnessAsync(actor, command.Lines.Select(l => l.ItemId))) return await AwaitWitnessAsync(actor, op, "Transfer", command); + return await CompleteTransferAsync(actor, op, command, pending); + }, events); + }); + private async Task CompleteTransferAsync(InventoryActor actor, InventoryOperation operation, InventoryCommand command, List events, + string performer = null, string witness = null, string attestation = null) + { + if (command.Lines.Any(l => l.Type != InventoryTransactionType.Transfer) || command.Lines.Select(l => l.FromLocationId).Distinct().Count() != 1 || command.Lines.Select(l => l.ToLocationId).Distinct().Count() != 1) throw new InventoryException(400, "TransferLinesRequired"); + var transfer = New(actor); transfer.CreatedBy = performer ?? actor.UserId; transfer.FromLocationId = command.Lines[0].FromLocationId; + transfer.ToLocationId = command.Lines[0].ToLocationId; transfer.Status = 2; transfer.OperationId = operation.Id; await SaveAsync(actor, transfer); + var result = await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); result.TransferId = transfer.Id; + for (var i = 0; i < command.Lines.Count; i++) + { + var line = command.Lines[i]; var detail = New(actor); detail.CreatedBy = performer ?? actor.UserId; + detail.TransferId = transfer.Id; detail.TransactionId = result.TransactionIds[i]; detail.ItemId = line.ItemId; detail.AssetId = line.AssetId; detail.LotId = line.LotId; detail.Quantity = line.Quantity; await SaveAsync(actor, detail); + } + await EventAsync(await _store.GetAsync(actor.DepartmentId, result.TransactionIds[0]), WorkflowTriggerEventType.InventoryTransferCompleted, events, transfer.Id); + result.OutboxIds = events.ToList(); return result; + } + public Task ChangeAssetStatusAsync(InventoryActor actor, InventoryCommand command) + { + if (command?.Lines == null || command.Lines.Any(x => x.Type != InventoryTransactionType.StatusChange)) throw new InventoryException(400, "StatusLinesRequired"); + return PostTransactionAsync(actor, command); + } + } +} diff --git a/Core/Resgrid.Services/InventoryQueries.cs b/Core/Resgrid.Services/InventoryQueries.cs new file mode 100644 index 000000000..74e56d491 --- /dev/null +++ b/Core/Resgrid.Services/InventoryQueries.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + public async Task> QueryAsync(InventoryActor actor, InventoryQuery filter, int page = 0) where T : InventoryRow + { + await _auth.RequireAsync(actor); + if (page < 0 || page > 10000) throw new InventoryException(400, "InvalidPage"); + filter ??= new InventoryQuery(); + foreach (var id in new[] { filter.ItemId, filter.LocationId, filter.AssetId, filter.KitId }.Where(x => x != null)) Id(id); + if (filter.IssuedToUserId?.Length > 128) throw new InventoryException(400, "InvalidIdentifier"); + var rows = await _store.QueryAsync(actor.DepartmentId, filter, page * 500); + var result = new InventoryPage { HasMore = rows.Count > 500 }; + foreach (var row in rows.Take(500)) + { + try { await AuthorizeRowAsync(actor, row); } + catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { continue; } + result.Items.Add(await RevealAsync(actor, row)); + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryReferences.cs b/Core/Resgrid.Services/InventoryReferences.cs new file mode 100644 index 000000000..a796336ef --- /dev/null +++ b/Core/Resgrid.Services/InventoryReferences.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + private async Task ValidateReferenceAsync(InventoryActor actor, InventoryPosting line, bool joined, bool requireOpen = true) + { + if (line.ReferenceType == InventoryReferenceType.None) return; + // The Records adapter owns lifecycle, record authorization and row-version checks in this same UoW. + // Ordinary inventory endpoints cannot manufacture a Records backlink. + if (line.ReferenceType == InventoryReferenceType.RmsRecord && joined && Guid.TryParseExact(line.ReferenceId, "D", out _)) return; + if (line.ReferenceType == InventoryReferenceType.WorkOrder) + { + if (_workOrders == null || _workOrderAuthorization == null || !int.TryParse(line.ReferenceId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0) + throw new InventoryException(404, "ReferenceUnavailable"); + var order = await _workOrders.GetAsync(actor.DepartmentId, id, false); + var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; + if (order == null || order.IsDeleted || !await _workOrderAuthorization.Value.CanContributeAsync(principal, order)) throw new InventoryException(404, "ReferenceUnavailable"); + if (requireOpen && order.Status >= (int)WorkOrderStatus.Completed && line.Type != InventoryTransactionType.Return) throw new InventoryException(409, "ReferenceClosed"); + return; + } + // Future modules must add an authorized adapter; accepting a syntactically valid foreign ID is insufficient. + throw new InventoryException(400, "ReferenceUnsupported"); + } + } +} diff --git a/Core/Resgrid.Services/InventoryService.cs b/Core/Resgrid.Services/InventoryService.cs index fd9daab96..f9e68a59f 100644 --- a/Core/Resgrid.Services/InventoryService.cs +++ b/Core/Resgrid.Services/InventoryService.cs @@ -14,13 +14,19 @@ public class InventoryService: IInventoryService private readonly IInventoryRepository _inventoryRepository; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IUnitsService _unitsService; + private readonly IInventoryStore _modern; - public InventoryService(IInventoryTypesRepository inventoryTypesRepository, IInventoryRepository inventoryRepository, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService) + public InventoryService(IInventoryTypesRepository inventoryTypesRepository, IInventoryRepository inventoryRepository, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, IInventoryStore modern = null) { _inventoryTypesRepository = inventoryTypesRepository; _inventoryRepository = inventoryRepository; _departmentGroupsService = departmentGroupsService; _unitsService = unitsService; + _modern = modern; + } + private async Task GuardLegacyWriteAsync(int departmentId) + { + if (_modern != null && await _modern.HasLegacyMigrationAsync(departmentId)) throw new Model.Inventories.InventoryException(409, "LegacyInventoryReadOnly"); } public async Task GetTypeByIdAsync(int typeId) @@ -30,6 +36,7 @@ public async Task GetTypeByIdAsync(int typeId) public async Task SaveTypeAsync(InventoryType type, CancellationToken cancellationToken = default(CancellationToken)) { + await GuardLegacyWriteAsync(type.DepartmentId); return await _inventoryTypesRepository.SaveOrUpdateAsync(type, cancellationToken); } @@ -48,12 +55,14 @@ public async Task GetInventoryByIdAsync(int inventoryId) public async Task SaveInventoryAsync(Inventory inventory, CancellationToken cancellationToken = default(CancellationToken)) { + await GuardLegacyWriteAsync(inventory.DepartmentId); return await _inventoryRepository.SaveOrUpdateAsync(inventory, cancellationToken); } public async Task DeleteTypeAsync(int typeId, CancellationToken cancellationToken = default(CancellationToken)) { var type = await GetTypeByIdAsync(typeId); + await GuardLegacyWriteAsync(type.DepartmentId); var inventories = await _inventoryRepository.GetInventoryByTypeIdAsync(typeId); foreach (var inventory in inventories) @@ -111,6 +120,8 @@ public async Task> GetConsolidatedInventoryForDepartment(int dep public async Task DeleteInventoriesByGroupIdAsync(int groupId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) { + // Migrated legacy rows remain immutable historical evidence when a holder is removed. + if (_modern != null && await _modern.HasLegacyMigrationAsync(departmentId)) return true; return await _inventoryRepository.DeleteInventoriesByGroupIdAsync(groupId, departmentId, cancellationToken); } } diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index d312f72c2..5bd815468 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -700,9 +700,12 @@ void Prevention(string table, string column, ProtectedFieldClassification classi ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, ChecklistContentCatalogVersion)); foreach (var table in Resgrid.Model.WorkOrders.WorkOrderTables.All.Values) list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 18)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.WorkOrders.WorkOrderTables.CatalogVersion)); list.Add(new ProtectedFieldDefinition("workorderfiles.data", OperationalFamily, "WorkOrderFiles", "Data", ProtectedFieldStorageKind.Binary, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, 18)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.WorkOrders.WorkOrderTables.CatalogVersion)); + foreach (var table in Resgrid.Model.Inventories.InventoryTables.All.Values) + list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.Inventories.InventoryTables.CatalogVersion)); return list; } } diff --git a/Core/Resgrid.Services/ReadinessProBillingService.cs b/Core/Resgrid.Services/ReadinessProBillingService.cs index a2ecdc814..096ad601d 100644 --- a/Core/Resgrid.Services/ReadinessProBillingService.cs +++ b/Core/Resgrid.Services/ReadinessProBillingService.cs @@ -2,7 +2,6 @@ using System.Net; using System.Threading.Tasks; using RestSharp; -using RestSharp.Serializers.NewtonsoftJson; using Resgrid.Model; using Resgrid.Model.Services; @@ -11,19 +10,23 @@ namespace Resgrid.Services /// Dedicated monthly billing API. Checkout is never an entitlement and cannot use a PTT quantity endpoint. public sealed class ReadinessProBillingService : IReadinessProBillingService { + private readonly Func _client; + public ReadinessProBillingService(Func client) { _client = client ?? throw new ArgumentNullException(nameof(client)); } private async Task CallAsync(string action, int departmentId, bool post) { if (departmentId <= 0 || string.IsNullOrWhiteSpace(Config.SystemBehaviorConfig.BillingApiBaseUrl) || string.IsNullOrWhiteSpace(Config.ApiConfig.BackendInternalApikey)) return default; try { - using var client = new RestClient(new RestClientOptions(Config.SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromSeconds(10) }, configureSerialization: s => s.UseNewtonsoftJson()); + var client = _client(); var request = new RestRequest("/api/ReadinessProBilling/" + action, post ? Method.Post : Method.Get); request.AddHeader("X-API-Key", Config.ApiConfig.BackendInternalApikey); if (post) request.AddJsonBody(new { DepartmentId = departmentId }); else request.AddQueryParameter("departmentId", departmentId.ToString(System.Globalization.CultureInfo.InvariantCulture)); var response = await client.ExecuteAsync(request); - return response.IsSuccessful && response.StatusCode == HttpStatusCode.OK ? response.Data : default; + if (response.IsSuccessful && response.StatusCode == HttpStatusCode.OK) return response.Data; + Resgrid.Framework.Logging.LogError($"Readiness billing {action} failed for department {departmentId}: HTTP {(int)response.StatusCode}, transport {response.ResponseStatus}, exception {response.ErrorException?.GetType().FullName}."); + return default; } - catch { return default; } + catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Readiness billing {action} failed for department {departmentId}: {ex.GetType().FullName}."); return default; } } public Task GetAsync(int departmentId) => CallAsync("Status", departmentId, false); public Task BeginCheckoutAsync(int departmentId) => CallAsync("Checkout", departmentId, true); diff --git a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs index c9ae91cc2..f4c4d57b5 100644 --- a/Core/Resgrid.Services/Records/DomainEventOutboxService.cs +++ b/Core/Resgrid.Services/Records/DomainEventOutboxService.cs @@ -160,7 +160,7 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella var original = entry.PayloadJson; var oldError = entry.LastError; var source = ProtectedDataEnvelope.HasEnvelopePrefix(original) ? entry.ReadinessRoutingJson : original; if (string.IsNullOrEmpty(source)) throw new InvalidOperationException("Checklist event routing metadata is unavailable."); - dispatchPayload = await ChecklistWorkflowPayload.ProjectAsync(entry.DepartmentId, JObject.Parse(source), _protection?.Value); + dispatchPayload = await ChecklistWorkflowPayload.ProjectAsync(entry.DepartmentId, Resgrid.Model.Inventories.InventoryWorkflowPayload.Parse(source), _protection?.Value); await ProtectHistoryAsync(entry, cancellationToken); if ((entry.PayloadJson != original || entry.LastError != oldError) && !await _outboxRepository.ReplaceChecklistPayloadAsync(entry, entry.PayloadJson, cancellationToken)) throw new InvalidOperationException("Checklist outbox lease changed before projection was saved."); @@ -192,7 +192,7 @@ private async Task DispatchOneAsync(DomainEventOutboxEntry entry, Cancella } catch (Exception ex) { - var error = ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem) ? "Checklist subscriber delivery failed." : ex.Message; + var error = ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem) ? "Readiness subscriber delivery failed." : ex.Message; if (ChecklistWorkflowPayload.IsReadinessProducer(entry.ProducerSubsystem)) { entry.LastError = error; await ProtectHistoryAsync(entry, cancellationToken); error = entry.LastError; diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs index 51b32133d..be3c4d809 100644 --- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -62,7 +62,7 @@ public ReadinessPacketEvidenceAdapter(IChecklistsService checklists = null, IRea public Task IsAvailableAsync(int departmentId) => _checklists == null || _access == null || _pdf == null ? Task.FromResult(false) : _access.CanUseChecklistsAsync(departmentId); public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { - if (!await IsAvailableAsync(request.DepartmentId)) return RecordEvidenceCapture.Unavailable(ChecklistReportDocuments.Text("Checklists are disabled for this department.")); + if (!await IsAvailableAsync(request.DepartmentId)) return RecordEvidenceCapture.Unavailable(ChecklistReportDocuments.Text("ChecklistsDisabled")); if (_grant == null || _grant.IsWorkloadCaller || _grant.UserId != request.CapturedByUserId) throw new UnauthorizedAccessException(); if (!request.CallId.HasValue || request.CoverageStart.HasValue || request.CoverageEnd.HasValue) throw new ArgumentException(ChecklistReportDocuments.Text("PacketCaptureWindow")); var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = request.DepartmentId, UserId = request.CapturedByUserId, GrantToken = _grant.GrantToken }; diff --git a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs index 2cb01a11a..9a8c455d8 100644 --- a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Model; +using Resgrid.Model.Inventories; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -32,19 +33,36 @@ public class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter private readonly IUnitsService _units; private readonly IUnitOfWork _unit; private readonly IRmsAccessAuditsRepository _audits; + private readonly IInventoryStore _modernStore; + private readonly IInventoryStockService _modernStock; + private readonly IInventoryCatalogService _modernCatalog; + private readonly IDomainEventOutboxService _outbox; public RmsInventoryUsageAdapter(IRmsExternalReferencesRepository references, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidents, - IInventoryService inventory, IRecordsAuthorizationService authorization, IDepartmentGroupsService groups, IUnitsService units, IUnitOfWork unit, IRmsAccessAuditsRepository audits) + IInventoryService inventory, IRecordsAuthorizationService authorization, IDepartmentGroupsService groups, IUnitsService units, IUnitOfWork unit, IRmsAccessAuditsRepository audits, + IInventoryStore modernStore = null, IInventoryStockService modernStock = null, IInventoryCatalogService modernCatalog = null, IDomainEventOutboxService outbox = null) { _references = references; _records = records; _incidents = incidents; _inventory = inventory; _authorization = authorization; _groups = groups; _units = units; _unit = unit; _audits = audits; + _modernStore = modernStore; _modernStock = modernStock; _modernCatalog = modernCatalog; _outbox = outbox; } - public async Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default) + public async Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default, string grantToken = null) { ValidateQuantity(quantity, note); if (kind is not (RmsRecordKind.Operational or RmsRecordKind.IncidentReport)) throw new ArgumentException("Choose an operational or incident record."); + if (_modernStore != null && await _modernStore.HasLegacyMigrationAsync(departmentId)) + { + var actor = new InventoryActor { DepartmentId = departmentId, UserId = userId, GrantToken = grantToken }; + var item = await _modernStore.LegacyItemAsync(departmentId, typeId); + if (item == null || item.DepartmentId != departmentId || item.IsDeleted) throw new InvalidOperationException("The migrated inventory item is unavailable."); + if ((await _groups.GetGroupByIdAsync(groupId, true))?.DepartmentId != departmentId || unitId.HasValue && (await _units.GetUnitByIdAsync(unitId.Value))?.DepartmentId != departmentId) throw new UnauthorizedAccessException(); + var location = await LegacyLocationAsync(actor, groupId, unitId); + var key = SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { departmentId, recordId, kind, expectedRowVersion }))); + var requestId = new Guid(key.Take(16).ToArray()).ToString("D"); + return await ConsumeModernAsync(actor, recordId, kind, expectedRowVersion, new InventoryCommand { RequestId = requestId, Lines = new List { new InventoryPosting { ItemId = item.Id, FromLocationId = location.Id, Quantity = quantity, Type = InventoryTransactionType.Consume, Note = note } } }, cancellationToken); + } _unit.CreateOrGetConnection(); try { @@ -61,7 +79,101 @@ public async Task ConsumeAsync(int departmentId, string userI catch { _unit.DiscardChanges(); throw; } } - private async Task GuardAsync(int department, string user, string recordId, RmsRecordKind kind, long? expected, CancellationToken ct) + public async Task ConsumeModernAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, InventoryCommand command, CancellationToken cancellationToken = default) + { + if (_modernStore == null || _modernStock == null || _modernCatalog == null || _outbox == null) throw new InvalidOperationException("Modern inventory integration is unavailable."); + if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new UnauthorizedAccessException(); + if (kind is not (RmsRecordKind.Operational or RmsRecordKind.IncidentReport)) throw new ArgumentException("Choose an operational or incident record."); + if (!Guid.TryParseExact(recordId, "D", out var recordGuid) || recordGuid == Guid.Empty || expectedRowVersion < 1) throw new ArgumentException("A current Record identity and version are required."); + if (command?.Lines == null || command.Lines.Count != 1 || command.Lines[0]?.Type != InventoryTransactionType.Consume || !Guid.TryParseExact(command.RequestId, "D", out var requestId) || requestId == Guid.Empty) throw new ArgumentException("One consumption line and a stable request GUID are required."); + var input = command.Lines[0]; ValidateQuantity(input.Quantity, input.Note); + if (input.ToLocationId != null || input.Status.HasValue || input.ReversesTransactionId != null || input.IssuanceId != null || input.UnitCost.HasValue) throw new ArgumentException("Record usage accepts a consumption from one source location."); + // Copy the caller's command before assigning the Records provenance. Grants never enter durable request fingerprints. + var posting = new InventoryCommand + { + RequestId = requestId.ToString("D"), + Lines = new List { new InventoryPosting { ItemId = input.ItemId, AssetId = input.AssetId, LotId = input.LotId, FromLocationId = input.FromLocationId, + Quantity = input.Quantity, Type = InventoryTransactionType.Consume, Note = input.Note, ExpectedAssetRevision = input.ExpectedAssetRevision, ReferenceType = InventoryReferenceType.RmsRecord, ReferenceId = recordGuid.ToString("D") } } + }; + recordId = recordGuid.ToString("D"); + var fingerprint = Checksum(JsonConvert.SerializeObject(new { actor.DepartmentId, actor.UserId, recordId, kind, expectedRowVersion, posting.Lines })); + if (_unit.Transaction != null) throw new InvalidOperationException("Record inventory consumption owns its transaction."); + RmsInventoryUsage usage; var events = new List(); + try + { + await _unit.CreateOrGetConnectionAsync(cancellationToken); await _modernStore.LockDepartmentAsync(actor.DepartmentId); + var existing = await _references.GetByIdAsync(posting.RequestId); + if (existing != null) + { + if (existing.DepartmentId != actor.DepartmentId || existing.RecordId != recordId || existing.RecordKind != (int)kind || existing.SemanticRole != SemanticRole || existing.SourceSubsystem != SourceSubsystem || existing.SourceEntityType != "InventoryTransaction" || existing.CapturedByUserId != actor.UserId || existing.DeletedOn.HasValue) + throw new InventoryException(409, "RequestConflict"); + usage = FromReference(existing); + var snapshot = JsonConvert.DeserializeObject(existing.SnapshotJson); + if (snapshot?.RequestFingerprint != fingerprint) throw new InventoryException(409, "RequestConflict"); + await GuardAsync(actor.DepartmentId, actor.UserId, recordId, kind, null, cancellationToken, false); + await AuthorizeModernSourceAsync(actor, posting.Lines[0].FromLocationId); + await _modernCatalog.GetAsync(actor, posting.Lines[0].ItemId); + await _modernCatalog.GetAsync(actor, usage.TransactionId); + _unit.CommitChanges(); + return usage; + } + await GuardAsync(actor.DepartmentId, actor.UserId, recordId, kind, expectedRowVersion, cancellationToken); + await AuthorizeModernSourceAsync(actor, posting.Lines[0].FromLocationId); + var item = await _modernCatalog.GetAsync(actor, posting.Lines[0].ItemId); + var result = await _modernStock.PostWithinTransactionAsync(actor, posting, cancellationToken); + if (result == null || result.AwaitingWitness || result.TransactionIds?.Count != 1) throw new InvalidOperationException("Inventory consumption did not produce one committed ledger entry."); + var transaction = await _modernStore.GetAsync(actor.DepartmentId, result.TransactionIds[0]); + if (transaction?.DepartmentId != actor.DepartmentId || transaction.ItemId != item.Id || transaction.AssetId != input.AssetId || transaction.LotId != input.LotId || transaction.FromLocationId != input.FromLocationId || transaction.ToLocationId != null || transaction.ReferenceType != (int)InventoryReferenceType.RmsRecord || transaction.ReferenceId != recordId || transaction.TransactionType != (int)InventoryTransactionType.Consume || transaction.Quantity != input.Quantity) + throw new InvalidOperationException("Inventory consumption provenance did not match the Record."); + usage = await WriteModernReferenceAsync(actor, recordId, kind, posting.RequestId, fingerprint, transaction, cancellationToken); + events.AddRange(result.OutboxIds ?? new List()); + _unit.CommitChanges(); + } + catch { _unit.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events, cancellationToken); + return usage; + } + + private async Task LegacyLocationAsync(InventoryActor actor, int groupId, int? unitId) + { + if (_modernCatalog == null) throw new InvalidOperationException("Modern inventory integration is unavailable."); + InventoryLocation result = null; + for (var page = 0; page <= 10000; page++) + { + var locations = await _modernCatalog.ListAsync(actor, page); + foreach (var location in locations.Items.Where(l => !l.IsDeleted && l.ParentLocationId == null && (unitId.HasValue + ? l.LocationType == (int)InventoryLocationType.Unit && l.UnitId == unitId + : l.LocationType == (int)InventoryLocationType.Station && l.GroupId == groupId))) + { + if (result != null) throw new InvalidOperationException("Choose an explicit inventory location; the legacy holder is ambiguous."); + result = location; + } + if (!locations.HasMore) return result ?? throw new InvalidOperationException("The migrated inventory holder location is unavailable."); + } + throw new InvalidOperationException("The inventory location list exceeds the supported size."); + } + + private async Task AuthorizeModernSourceAsync(InventoryActor actor, string locationId) + { + var seen = new HashSet(StringComparer.Ordinal); var location = await _modernCatalog.GetAsync(actor, locationId); + while (true) + { + if (location?.DepartmentId != actor.DepartmentId || location.IsDeleted || !seen.Add(location.Id) || seen.Count > 32) throw new UnauthorizedAccessException(); + if (location.ContainerAssetId != null) + { + var asset = await _modernCatalog.GetAsync(actor, location.ContainerAssetId); + location = await _modernCatalog.GetAsync(actor, asset.CurrentLocationId); continue; + } + if (location.ParentLocationId != null) { location = await _modernCatalog.GetAsync(actor, location.ParentLocationId); continue; } + var groupId = location.GroupId; + if (location.UnitId.HasValue) groupId = (await _units.GetUnitByIdAsync(location.UnitId.Value))?.StationGroupId; + if (location.UserId != null) groupId = (await _groups.GetGroupForUserAsync(location.UserId, actor.DepartmentId))?.DepartmentGroupId; + if (!await _authorization.CanUseSourceInventoryAsync(actor.UserId, actor.DepartmentId, groupId)) throw new UnauthorizedAccessException(); + return; + } + } + + private async Task GuardAsync(int department, string user, string recordId, RmsRecordKind kind, long? expected, CancellationToken ct, bool bump = true) { if (!await _authorization.CanUserViewRecordAsync(user, recordId, department) || !await _authorization.HasPermissionAsync(user, department, PermissionTypes.CreateRecord) || !await _authorization.HasPermissionAsync(user, department, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); string author, owner, amendment; int state; long version; @@ -80,6 +192,7 @@ private async Task GuardAsync(int department, string user, string recordId, RmsR if (RmsLifecycle.IsTerminal((RmsRecordState)state) || !(RmsLifecycle.IsEditable((RmsRecordState)state) || amendment != null)) throw new InvalidOperationException("Record inventory usage through a draft or amendment."); if (author != user && owner != user && !await _authorization.IsDepartmentAdminAsync(user, department) && !(amendment != null && await _authorization.HasPermissionAsync(user, department, PermissionTypes.AmendRecords))) throw new UnauthorizedAccessException(); if (expected.HasValue && expected.Value != version) throw new RecordConcurrencyException(recordId, expected.Value, version); + if (!bump) return; var bumped = kind == RmsRecordKind.Operational ? await _records.TryBumpRowVersionAsync(department, recordId, version, ct) : await _incidents.TryBumpRowVersionAsync(department, recordId, version, ct); if (!bumped) throw new RecordConcurrencyException(recordId, version, version + 1); } @@ -159,6 +272,34 @@ private async Task WriteReferenceAsync(int departmentId, stri return FromReference(reference); } + private async Task WriteModernReferenceAsync(InventoryActor actor, string recordId, RmsRecordKind kind, string requestId, string fingerprint, InventoryTransaction transaction, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var source = new { TransactionId = transaction.Id, transaction.EntryId, transaction.OperationId, transaction.LineNumber, transaction.ItemId, transaction.AssetId, transaction.LotId, + transaction.FromLocationId, transaction.ToLocationId, transaction.Quantity, transaction.FromQuantityBefore, transaction.FromQuantityAfter, transaction.ToQuantityBefore, + transaction.ToQuantityAfter, transaction.OldStatus, transaction.NewStatus, transaction.ReferenceType, transaction.ReferenceId, transaction.OccurredOn }; + // External-reference snapshots are not a protected-content store. Persist reviewed routing only; + // details remain in InventoryTransaction.Content and require that consumer's own current grant. + var snapshot = JsonConvert.SerializeObject(new UsageSnapshot + { + SchemaVersion = 2, TransactionId = transaction.Id, ItemId = transaction.ItemId, Quantity = transaction.Quantity, + ItemName = ProtectedDataEnvelope.RedactionValue, Note = ProtectedDataEnvelope.RedactionValue, UnitOfMeasure = ProtectedDataEnvelope.RedactionValue, + RequestFingerprint = fingerprint, Source = source, SourceChecksum = Checksum(JsonConvert.SerializeObject(source)) + }); + var reference = new RmsExternalReference + { + RmsExternalReferenceId = requestId, ProtectionId = Guid.NewGuid().ToString("D"), DepartmentId = actor.DepartmentId, RecordId = recordId, RecordKind = (int)kind, + SourceSubsystem = SourceSubsystem, SourceEntityType = "InventoryTransaction", SourceEntityId = transaction.Id, IdentifierScheme = IdentifierScheme, + SemanticRole = SemanticRole, SourceVersion = "2", CapturedByUserId = actor.UserId, CapturedOn = now, Checksum = Checksum(snapshot), SnapshotJson = snapshot, + CreatedOn = now, ModifiedOn = now, RowVersion = 1 + }; + await _references.InsertAsync(reference, cancellationToken, true); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = actor.DepartmentId, RecordId = recordId, ActorUserId = actor.UserId, + Action = (int)RmsAccessAuditAction.Change, Successful = true, OccurredOn = now, Purpose = "Inventory usage recorded", + DetailJson = JsonConvert.SerializeObject(new { reference.RmsExternalReferenceId, TransactionId = transaction.Id, transaction.ItemId, reference.Checksum }) }, cancellationToken, true); + return FromReference(reference); + } + private static RmsInventoryUsage FromReference(RmsExternalReference reference) { if (string.IsNullOrWhiteSpace(reference.Checksum) || Checksum(reference.SnapshotJson ?? "") != reference.Checksum) throw new InvalidOperationException("Inventory usage failed its integrity check."); @@ -172,6 +313,20 @@ private static RmsInventoryUsage FromReference(RmsExternalReference reference) throw new InvalidOperationException("The inventory usage snapshot is unreadable."); } + if (snapshot.SchemaVersion == 2 || reference.SourceEntityType == "InventoryTransaction") + { + if (snapshot.SchemaVersion != 2 || reference.SourceEntityType != "InventoryTransaction" || !Guid.TryParseExact(reference.SourceEntityId, "D", out var sourceId) || sourceId == Guid.Empty + || !Guid.TryParseExact(snapshot.TransactionId, "D", out var transactionId) || sourceId != transactionId || !Guid.TryParseExact(snapshot.ItemId, "D", out var itemId) || itemId == Guid.Empty || snapshot.Quantity <= 0) + throw new InvalidOperationException("The modern inventory usage source identity is invalid."); + return new RmsInventoryUsage + { + ReferenceId = reference.RmsExternalReferenceId, ReferenceChecksum = reference.Checksum, Source = RmsInventoryUsage.SourceRecord, RecordId = reference.RecordId, + TransactionId = transactionId.ToString("D"), ItemId = itemId.ToString("D"), Quantity = snapshot.Quantity, + // This grant-free read path cannot expose copies of modern inventory content, even from malformed stored snapshots. + Note = ProtectedDataEnvelope.RedactionValue, ItemName = ProtectedDataEnvelope.RedactionValue, UnitOfMeasure = ProtectedDataEnvelope.RedactionValue, + SourceChecksum = snapshot.SourceChecksum, CapturedByUserId = reference.CapturedByUserId, CapturedOn = reference.CapturedOn + }; + } if (!int.TryParse(reference.SourceEntityId, out var inventoryId) || inventoryId != snapshot.InventoryId || snapshot.Quantity <= 0) throw new InvalidOperationException("The inventory usage source identity is invalid."); @@ -198,6 +353,14 @@ private static string Checksum(string text) private sealed class UsageSnapshot { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int SchemaVersion { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string TransactionId { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string ItemId { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public string RequestFingerprint { get; set; } public int InventoryId { get; set; } public decimal Quantity { get; set; } public string Note { get; set; } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index a02e3ea9c..9e07fc358 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -15,10 +15,20 @@ public class ServicesModule : Module protected override void Load(ContainerBuilder builder) { + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().As().As().As() + .As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); + builder.Register(_ => new RestClient(new RestClientOptions(SystemBehaviorConfig.BillingApiBaseUrl) { Timeout = TimeSpan.FromSeconds(10) }, + configureSerialization: serializer => serializer.UseNewtonsoftJson())).Named("readiness-billing-client").SingleInstance(); + builder.RegisterType().As() + .WithParameter((parameter, _) => parameter.ParameterType == typeof(Func), (_, context) => + { + var scope = context.Resolve(); + return (Func)(() => scope.ResolveNamed("readiness-billing-client")); + }).InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 8f765400e..3628870a4 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -31,6 +31,8 @@ public class UnitsService : IUnitsService private readonly IDepartmentGroupsService _departmentGroupsService; private readonly ILimitsService _limitsService; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IInventoryStore _inventoryStore; + private readonly Resgrid.Model.Repositories.Queries.IUnitOfWork _inventoryUnitOfWork; // Lazy: defers the protected-write graph (broker client) until a state save actually needs it. private readonly Lazy _protectedWriteService; @@ -45,9 +47,10 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository, IUnitActiveRolesRepository unitActiveRolesRepository, IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService, - Lazy protectedWriteService, Lazy recordsCutoverService) + Lazy protectedWriteService, Lazy recordsCutoverService, IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null) { _recordsCutoverService = recordsCutoverService; + _inventoryStore = inventoryStore; _inventoryUnitOfWork = inventoryUnitOfWork; _unitsRepository = unitsRepository; _unitStatesRepository = unitStatesRepository; _unitLogsRepository = unitLogsRepository; @@ -192,6 +195,8 @@ public async Task GetUnitByIdAsync(int unitId) if (unit != null) { + return await InventoryHolderRetention.DeleteAsync(_inventoryStore, _inventoryUnitOfWork, unit.DepartmentId, unitId, true, async () => + { var states = await _unitStatesRepository.GetAllStatesByUnitIdAsync(unitId); if (states != null && states.Any()) @@ -210,6 +215,7 @@ public async Task GetUnitByIdAsync(int unitId) SendUnitVisibilityRefresh(unit.DepartmentId); return true; + }, cancellationToken); } return false; diff --git a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs index 3b455ecf5..2bf765855 100644 --- a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs +++ b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs @@ -22,39 +22,52 @@ public sealed class WorkOrderAuthorizationService : IWorkOrderAuthorizationServi public WorkOrderAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IPersonnelRolesService roles, IPermissionsService permissions, IUnitsService units, IAuthorizationService resources, IChecklistAssignmentService assignments, IChecklistAssetSource assets = null) { _departments = departments; _groups = groups; _roles = roles; _permissions = permissions; _units = units; _resources = resources; _assignments = assignments; _assets = assets; } - public async Task RequireMemberAsync(ChecklistActor actor) + public async Task RequireMemberAsync(ChecklistActor actor) => await MemberAsync(actor); + private async Task MemberAsync(ChecklistActor actor) { if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new WorkOrderException(403, "MembershipRequired"); var m = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); if (m?.DepartmentId != actor.DepartmentId || m.IsDeleted || m.IsDisabled == true) throw new WorkOrderException(403, "MembershipRequired"); + return m; } - private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type, int? groupId) + private sealed class ActorContext { - await RequireMemberAsync(actor); - var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); + public ChecklistActor Actor; + public bool Admin; + public DepartmentGroup Group; + public List Roles; + public readonly Dictionary Permissions = new(); + } + private async Task ContextAsync(ChecklistActor actor) + { + var member = await MemberAsync(actor); 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 permission = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, type); + return new ActorContext { Actor = actor, Admin = member.IsAdmin == true || department?.ManagingUserId == actor.UserId, + Group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId), Roles = await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId) }; + } + private async Task AllowedAsync(ActorContext context, PermissionTypes type, int? groupId) + { + var actor = context.Actor; var admin = context.Admin; var group = context.Group; + if (!context.Permissions.TryGetValue(type, out var permission)) + context.Permissions[type] = permission = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, type); var fallback = type == PermissionTypes.ManageWorkOrders ? PermissionActions.DepartmentAdminsOnly : PermissionActions.DepartmentAndGroupAdmins; - if (!RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)fallback, permission?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true, await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId))) return false; + if (!RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)fallback, permission?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true, context.Roles)) return false; return admin || !(permission?.LockToGroup ?? type == PermissionTypes.ViewAllWorkOrders) || groupId.HasValue && groupId == group?.DepartmentGroupId; } - public Task CanManageAsync(ChecklistActor actor, int? groupId) => AllowedAsync(actor, PermissionTypes.ManageWorkOrders, groupId); + public async Task CanManageAsync(ChecklistActor actor, int? groupId) => await AllowedAsync(await ContextAsync(actor), PermissionTypes.ManageWorkOrders, groupId); public async Task ScopeAsync(ChecklistActor actor) { - await RequireMemberAsync(actor); - var group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId); - var all = await AllowedAsync(actor, PermissionTypes.ViewAllWorkOrders, null) || await CanManageAsync(actor, null); - var groupAllowed = group != null && (await AllowedAsync(actor, PermissionTypes.ViewAllWorkOrders, group.DepartmentGroupId) || await CanManageAsync(actor, group.DepartmentGroupId)); + var context = await ContextAsync(actor); var group = context.Group; + var all = await AllowedAsync(context, PermissionTypes.ViewAllWorkOrders, null) || await AllowedAsync(context, PermissionTypes.ManageWorkOrders, null); + var groupAllowed = group != null && (await AllowedAsync(context, PermissionTypes.ViewAllWorkOrders, group.DepartmentGroupId) || await AllowedAsync(context, PermissionTypes.ManageWorkOrders, group.DepartmentGroupId)); return new WorkOrderReadScope { UserId = actor.UserId, All = all, GroupId = groupAllowed ? group.DepartmentGroupId : null, - RoleIds = (await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId)).Where(r => r.DepartmentId == actor.DepartmentId).Select(r => r.PersonnelRoleId).ToArray() }; + RoleIds = context.Roles.Where(r => r.DepartmentId == actor.DepartmentId).Select(r => r.PersonnelRoleId).ToArray() }; } public async Task CanContributeAsync(ChecklistActor actor, WorkOrder row) { - await RequireMemberAsync(actor); + var context = await ContextAsync(actor); if (row?.DepartmentId != actor.DepartmentId) return false; - if (await CanManageAsync(actor, row.TargetGroupId)) return true; + if (await AllowedAsync(context, PermissionTypes.ManageWorkOrders, row.TargetGroupId)) return true; if (row.AssignedToUserId != null) return row.AssignedToUserId == actor.UserId; return row.AssignedToRoleId.HasValue && (await _assignments.MembersAsync(actor.DepartmentId, 2, row.AssignedToRoleId.Value.ToString())).Contains(actor.UserId); } @@ -76,10 +89,14 @@ public async Task ValidateTargetAsync(ChecklistActor actor, WorkOrderInput input } if (!string.IsNullOrEmpty(input.InventoryAssetId)) { - if (!Guid.TryParseExact(input.InventoryAssetId, "D", out _) || _assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) throw new WorkOrderException(404, "TargetUnavailable"); - var asset = await _assets.GetAsync(actor, input.InventoryAssetId); - if (asset?.DepartmentId != actor.DepartmentId || asset.Id != input.InventoryAssetId || input.TargetUnitId.HasValue && input.TargetUnitId != asset.UnitId || input.TargetGroupId.HasValue && input.TargetGroupId != asset.GroupId) throw new WorkOrderException(404, "TargetUnavailable"); - input.TargetUnitId = asset.UnitId; input.TargetGroupId = asset.GroupId; + try + { + if (!Guid.TryParseExact(input.InventoryAssetId, "D", out _) || _assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) throw new WorkOrderException(404, "TargetUnavailable"); + var asset = await _assets.GetAsync(actor, input.InventoryAssetId); + if (asset?.DepartmentId != actor.DepartmentId || asset.Id != input.InventoryAssetId || input.TargetUnitId.HasValue && input.TargetUnitId != asset.UnitId || input.TargetGroupId.HasValue && input.TargetGroupId != asset.GroupId) throw new WorkOrderException(404, "TargetUnavailable"); + input.TargetUnitId = asset.UnitId; input.TargetGroupId = asset.GroupId; + } + catch (ChecklistException ex) { throw new WorkOrderException(ex.StatusCode, ex.Message); } } } public async Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, string userId, int? roleId) @@ -92,16 +109,20 @@ public async Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, s } public async Task ChoicesAsync(ChecklistActor actor) { - await RequireMemberAsync(actor); var result = new WorkOrderChoices(); + var context = await ContextAsync(actor); var result = new WorkOrderChoices(); foreach (var c in await _assignments.ChoicesAsync(actor)) { var choice = new WorkOrderChoice { Id = c.Id, Name = c.Name }; if (c.Type == 1 && await _resources.CanUserViewPersonAsync(actor.UserId, c.Id, actor.DepartmentId)) result.Users.Add(choice); if (c.Type == 2) result.Roles.Add(choice); - if (c.Type == 3 && ((await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId))?.DepartmentGroupId.ToString() == c.Id || await CanManageAsync(actor, int.Parse(c.Id)))) result.Groups.Add(choice); + if (c.Type == 3 && (context.Group?.DepartmentGroupId.ToString() == c.Id || await AllowedAsync(context, PermissionTypes.ManageWorkOrders, int.Parse(c.Id)))) result.Groups.Add(choice); if (c.Type == 4 && await _resources.CanUserViewUnitAsync(actor.UserId, int.Parse(c.Id))) result.Units.Add(choice); } - if (_assets != null && await _assets.IsAvailableAsync(actor.DepartmentId)) result.Assets = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => new WorkOrderChoice { Id = a.Id, Name = a.Name }).ToList(); + try + { + if (_assets != null && await _assets.IsAvailableAsync(actor.DepartmentId)) result.Assets = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => new WorkOrderChoice { Id = a.Id, Name = a.Name }).ToList(); + } + catch (ChecklistException ex) { throw new WorkOrderException(ex.StatusCode, ex.Message); } return result; } public async Task> RecipientsAsync(int departmentId, WorkOrder row) diff --git a/Core/Resgrid.Services/WorkOrderFiles.cs b/Core/Resgrid.Services/WorkOrderFiles.cs index 528f3b6f3..cbacdbb56 100644 --- a/Core/Resgrid.Services/WorkOrderFiles.cs +++ b/Core/Resgrid.Services/WorkOrderFiles.cs @@ -51,10 +51,10 @@ private async Task FileWriteOrderAsync(ChecklistActor actor, int id, public async Task GetFileAsync(ChecklistActor actor, int id) { await _authorization.RequireMemberAsync(actor); var metadata = await _store.GetAsync(actor.DepartmentId, id, false); - if (metadata?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); + if (metadata?.WorkOrderId == null || metadata.WithdrawnOn.HasValue) throw new WorkOrderException(404, "Unavailable"); await ReadOrderAsync(actor, metadata.WorkOrderId.Value); var file = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); - if (file.ScanState != (int)RmsAttachmentScanState.Clean) throw new WorkOrderException(404, "Unavailable"); + if (file.ScanState != (int)RmsAttachmentScanState.Clean || file.WithdrawnOn.HasValue) throw new WorkOrderException(404, "Unavailable"); var enveloped = ProtectedReadService.IsBinaryEnveloped(file.Data); var result = await _read.Value.ResolveRecordsBinaryForReadAsync(actor.DepartmentId, "workorderfiles.data", Key(file), file.Data, bytes => file.Data = bytes, actor.GrantToken, actor.UserId); if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && !enveloped || file.Data == null) throw new WorkOrderException(403, "ProtectedDataRequired"); diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index b2f9e7651..663e09159 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -51,7 +51,7 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) var department = await _departments.GetDepartmentByIdAsync(entry.DepartmentId, true); var number = await _settings.GetTextToCallNumberForDepartmentAsync(entry.DepartmentId); var current = await _orders.GetAsync(entry.DepartmentId, id); - if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !(await RecipientsAsync(entry.DepartmentId, current)).Contains(user)) + if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !await IsRecipientAsync(entry.DepartmentId, current, user)) { await FinishAsync(notice, 3); continue; } CultureInfo culture; try { culture = CultureInfo.GetCultureInfo(profile.Language ?? "en"); if (!SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) culture = CultureInfo.GetCultureInfo("en"); } @@ -62,9 +62,26 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) number, department, Strings.GetString("NotificationTitle", culture), profile); await FinishAsync(notice, handedOff ? 2 : 3); } - catch { await FinishAsync(notice, 0); throw new InvalidOperationException("Work-order notification handoff failed."); } + catch (Exception ex) + { + // Provider errors can contain content or credentials; only routing and exception types leave this boundary. + Resgrid.Framework.Logging.LogError($"Work-order notification handoff failed for department {entry.DepartmentId}, order {id}: {ex.GetType().FullName}."); + try { await FinishAsync(notice, 0); } + catch (Exception releaseEx) { Resgrid.Framework.Logging.LogError($"Work-order notification lease release failed for department {entry.DepartmentId}, order {id}: {releaseEx.GetType().FullName}."); } + throw new InvalidOperationException("Work-order notification handoff failed."); + } } } + private async Task IsRecipientAsync(int departmentId, WorkOrder row, string user) + { + var member = await _departments.GetDepartmentMemberAsync(user, departmentId, true); + if (member?.DepartmentId != departmentId || member.IsDeleted || member.IsDisabled == true) return false; + if (row.CreatedBy == user) return true; + var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = user }; + if (row.Status is 0 or 1 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) return true; + if (row.AssignedToUserId != null) return row.AssignedToUserId == user; + return row.AssignedToRoleId.HasValue && (await _authorization.ScopeAsync(actor)).RoleIds.Contains(row.AssignedToRoleId.Value); + } private async Task> RecipientsAsync(int departmentId, WorkOrder row) { var result = new System.Collections.Generic.HashSet(await _authorization.RecipientsAsync(departmentId, row)); diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index 0527449b6..f8c4ca024 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -150,7 +150,8 @@ await TransactionAsync(actor, async events => if (manage && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); var document = Decode(row.Content); if (row.StartedOn.HasValue && (document.Fields.SafetyCritical && !input.Content.SafetyCritical || document.Fields.HazardousWork && !input.Content.HazardousWork)) throw new WorkOrderException(409, "SafetyRequirements"); - if (!manage && (input.Content.ApprovedCost.HasValue || input.Content.Resolution != document.Fields.Resolution || input.Content.VerificationEvidence != document.Fields.VerificationEvidence)) throw new WorkOrderException(403, "PermissionRequired"); + if (!manage && (input.Content.ApprovedCost.HasValue && input.Content.ApprovedCost != document.Fields.ApprovedCost || input.Content.Resolution != document.Fields.Resolution || input.Content.VerificationEvidence != document.Fields.VerificationEvidence)) throw new WorkOrderException(403, "PermissionRequired"); + if (!manage) input.Content.ApprovedCost = document.Fields.ApprovedCost; document.Fields = input.Content; row.Content = JsonConvert.SerializeObject(document); Apply(row, input); await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events); return true; }); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index d3c6a63e7..791521e3e 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using Resgrid.Model; +using Resgrid.Model.Inventories; using Scriban.Runtime; namespace Resgrid.Services @@ -214,19 +216,12 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve break; case WorkflowTriggerEventType.InventoryAdjusted: - var inv = new ScriptObject(); - inv["id"] = 601; - inv["type_name"] = "SCBA Cylinder"; - inv["type_description"] = "Self-contained breathing apparatus cylinder"; - inv["unit_of_measure"] = "unit"; - inv["batch"] = "2024-BATCH-01"; - inv["note"] = "Monthly inventory check"; - inv["location"] = "Apparatus Bay A, Shelf 3"; - inv["amount"] = 12.0; - inv["previous_amount"] = 14.0; - inv["timestamp"] = DateTime.Now; - inv["group_id"] = 1; - obj["inventory"] = inv; + case WorkflowTriggerEventType.InventoryTransferCompleted: + case WorkflowTriggerEventType.InventoryIssued: + case WorkflowTriggerEventType.InventoryReturned: + case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.ControlledSubstanceRecorded: + AddInventorySamples(obj, eventType); break; case WorkflowTriggerEventType.CertificationExpiring: @@ -558,9 +553,74 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve } /// - /// Records (RMS) triggers 100-112: a bounded snapshot matching RecordEventVariables, RecordVariables and - /// RecordChangeVariables in WorkflowTemplateVariableCatalog. The state pair follows the trigger. + /// Inventory previews use the same variable mapping and permanently withheld content as dispatched events. /// + private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventType eventType) + { + const string transactionId = "11111111-1111-1111-1111-111111111111"; + const string itemId = "22222222-2222-2222-2222-222222222222"; + const string assetId = "33333333-3333-3333-3333-333333333333"; + const string lotId = "44444444-4444-4444-4444-444444444444"; + const string transferId = "55555555-5555-5555-5555-555555555555"; + const string issuanceId = "66666666-6666-6666-6666-666666666666"; + const string sourceId = "77777777-7777-7777-7777-777777777777"; + const string destinationId = "88888888-8888-8888-8888-888888888888"; + var occurred = new DateTime(2026, 9, 9, 8, 0, 0, DateTimeKind.Utc); + var serialized = eventType is WorkflowTriggerEventType.InventoryIssued or WorkflowTriggerEventType.InventoryReturned or WorkflowTriggerEventType.InventoryAssetStatusChanged; + var statusChange = eventType == WorkflowTriggerEventType.InventoryAssetStatusChanged; + var transactionType = eventType switch + { + WorkflowTriggerEventType.InventoryTransferCompleted => InventoryTransactionType.Transfer, + WorkflowTriggerEventType.InventoryIssued => InventoryTransactionType.Issue, + WorkflowTriggerEventType.InventoryReturned => InventoryTransactionType.Return, + WorkflowTriggerEventType.InventoryAssetStatusChanged => InventoryTransactionType.StatusChange, + WorkflowTriggerEventType.ControlledSubstanceRecorded => InventoryTransactionType.Consume, + _ => InventoryTransactionType.Adjust + }; + var referenceType = eventType switch + { + WorkflowTriggerEventType.InventoryTransferCompleted => InventoryReferenceType.Transfer, + WorkflowTriggerEventType.InventoryIssued => InventoryReferenceType.Deployment, + WorkflowTriggerEventType.InventoryReturned => InventoryReferenceType.Issuance, + WorkflowTriggerEventType.InventoryAssetStatusChanged => InventoryReferenceType.WorkOrder, + WorkflowTriggerEventType.ControlledSubstanceRecorded => InventoryReferenceType.RmsRecord, + _ => InventoryReferenceType.None + }; + var quantity = statusChange ? 0m : serialized ? 1m : 2m; + var payload = new Dictionary + { + ["TransactionId"] = transactionId, ["ItemId"] = itemId, ["AssetId"] = serialized ? assetId : null, ["LotId"] = serialized ? null : lotId, + ["TransferId"] = eventType == WorkflowTriggerEventType.InventoryTransferCompleted ? transferId : null, + ["IssuanceId"] = eventType is WorkflowTriggerEventType.InventoryIssued or WorkflowTriggerEventType.InventoryReturned ? issuanceId : null, + ["TransactionType"] = (int)transactionType, ["Quantity"] = quantity, ["FromLocationId"] = sourceId, + ["ToLocationId"] = statusChange ? sourceId : transactionType is InventoryTransactionType.Transfer or InventoryTransactionType.Issue or InventoryTransactionType.Return ? destinationId : null, + ["FromQuantityBefore"] = serialized ? null : (decimal?)14m, ["FromQuantityAfter"] = serialized ? null : (decimal?)(14m - quantity), + ["ToQuantityBefore"] = transactionType == InventoryTransactionType.Transfer ? (decimal?)3m : null, + ["ToQuantityAfter"] = transactionType == InventoryTransactionType.Transfer ? (decimal?)(3m + quantity) : null, + ["OldStatus"] = !serialized ? null : (int?)(eventType == WorkflowTriggerEventType.InventoryReturned ? InventoryAssetStatus.Issued : InventoryAssetStatus.InService), + ["NewStatus"] = !serialized ? null : (int?)(eventType == WorkflowTriggerEventType.InventoryIssued ? InventoryAssetStatus.Issued : statusChange ? InventoryAssetStatus.OutForRepair : InventoryAssetStatus.InService), + ["ReferenceType"] = (int)referenceType, + ["ReferenceId"] = referenceType switch { InventoryReferenceType.None => null, InventoryReferenceType.Transfer => transferId, InventoryReferenceType.Issuance => issuanceId, InventoryReferenceType.WorkOrder => "123", _ => "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" }, + ["ReversesTransactionId"] = null, ["OccurredOn"] = occurred, ["ItemName"] = ProtectedDataEnvelope.RedactionValue + }; + var inventory = new ScriptObject(); + foreach (var pair in InventoryWorkflowPayload.Variables) inventory[pair.Variable] = payload.TryGetValue(pair.Property, out var value) ? value : null; + if (eventType == WorkflowTriggerEventType.InventoryAdjusted) + { + inventory["id"] = transactionId; inventory["type_name"] = ProtectedDataEnvelope.RedactionValue; inventory["type_description"] = ProtectedDataEnvelope.RedactionValue; + inventory["unit_of_measure"] = string.Empty; inventory["batch"] = ProtectedDataEnvelope.RedactionValue; inventory["note"] = ProtectedDataEnvelope.RedactionValue; + inventory["location"] = sourceId; inventory["amount"] = 12m; inventory["previous_amount"] = 14m; inventory["timestamp"] = occurred; inventory["group_id"] = 0; + } + obj["inventory"] = inventory; + obj["event"] = new ScriptObject + { + ["id"] = "99999999-9999-9999-9999-999999999999", ["name"] = eventType.ToString(), ["schema_version"] = 1, ["occurred_on"] = occurred, + ["correlation_id"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ["causation_id"] = string.Empty, ["sequence"] = 1L, ["is_replay"] = false, ["origin_client"] = "Web" + }; + obj["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = new ScriptArray { "ItemName", "Note", "SerialNumber", "WitnessUserId" }, ["catalog_version"] = InventoryWorkflowPayload.CatalogVersion }; + } + + /// Records snapshots match the event, record and change catalogs; state pairs follow the trigger. private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType eventType) { var previousState = "Draft"; diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index a5ca8fda5..d809f7509 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -306,7 +306,7 @@ public async Task ExecuteWorkflowAsync( if (checklist) { if (string.IsNullOrEmpty(existingRunId)) throw new InvalidOperationException("Checklist workflows require a durable event run."); - eventPayloadJson = await ChecklistWorkflowPayload.ProjectAsync(departmentId, JObject.Parse(eventPayloadJson), _protectedProjection?.Value, wrapped: true); + eventPayloadJson = await ChecklistWorkflowPayload.ProjectAsync(departmentId, Resgrid.Model.Inventories.InventoryWorkflowPayload.Parse(eventPayloadJson), _protectedProjection?.Value, wrapped: true); var persistedInput = new WorkflowRun { InputPayload = eventPayloadJson }; await History.ProtectAsync(departmentId, existingRunId, persistedInput, ReadinessHistoryFields.Runs, cancellationToken); if (!await _runRepository.TryStartChecklistRunAsync(existingRunId, workflowId, departmentId, attemptNumber, persistedInput.InputPayload)) diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index a2c45fa25..95209d457 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -251,7 +251,19 @@ public async Task BuildContextAsync( break; } case WorkflowTriggerEventType.InventoryAdjusted: + case WorkflowTriggerEventType.InventoryTransferCompleted: + case WorkflowTriggerEventType.InventoryIssued: + case WorkflowTriggerEventType.InventoryReturned: + case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.ControlledSubstanceRecorded: { + var modern = string.IsNullOrWhiteSpace(eventPayloadJson) ? null : JsonConvert.DeserializeObject(eventPayloadJson, new JsonSerializerSettings { FloatParseHandling = FloatParseHandling.Decimal }); + if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(modern?.Payload) || eventType != WorkflowTriggerEventType.InventoryAdjusted) + { + MapModernInventoryVariables(scriptObject, modern, eventType == WorkflowTriggerEventType.InventoryAdjusted); + break; + } + // Existing InventoryAdjusted templates can still render historical top-level legacy events. var evt = TryDeserialize(eventPayloadJson); if (evt?.Inventory != null) { @@ -409,7 +421,7 @@ public async Task BuildContextAsync( foreach (var pair in Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Variables) order[pair.Variable] = ToScriptValue(payload[pair.Property]); order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/Detail/{payload["WorkOrderId"]?.Value()}"; scriptObject["work_order"] = order; - scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = 18 }; + scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.CatalogVersion }; break; } case WorkflowTriggerEventType.ChecklistCompleted: @@ -1147,6 +1159,42 @@ private static void MapTrainingVariables(ScriptObject obj, Training training) obj["training"] = t; } + private static void MapModernInventoryVariables(ScriptObject obj, RecordsWorkflowEvent evt, bool legacyAliases) + { + if (evt?.SchemaVersion != 1 || !Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(evt.Payload)) + throw new InvalidOperationException("The inventory workflow payload has an unsupported schema."); + using var reader = new Newtonsoft.Json.JsonTextReader(new System.IO.StringReader(Resgrid.Model.Inventories.InventoryWorkflowPayload.Routing(evt.Payload))) { FloatParseHandling = Newtonsoft.Json.FloatParseHandling.Decimal }; + var payload = JObject.Load(reader); + var inventory = new ScriptObject(); + foreach (var pair in Resgrid.Model.Inventories.InventoryWorkflowPayload.Variables) inventory[pair.Variable] = ToScriptValue(payload[pair.Property]); + if (legacyAliases) + { + inventory["id"] = ToScriptValue(payload["TransactionId"]); + inventory["type_name"] = ProtectedDataEnvelope.RedactionValue; + inventory["type_description"] = ProtectedDataEnvelope.RedactionValue; + inventory["unit_of_measure"] = string.Empty; + inventory["batch"] = ProtectedDataEnvelope.RedactionValue; + inventory["note"] = ProtectedDataEnvelope.RedactionValue; + inventory["location"] = ToScriptValue(payload["ToLocationId"] ?? payload["FromLocationId"]); + // Deprecated single-location aliases prefer the destination; transfers expose both sides in the modern variables. + inventory["amount"] = ToScriptValue(payload["ToQuantityAfter"] ?? payload["FromQuantityAfter"]); + inventory["previous_amount"] = ToScriptValue(payload["ToQuantityAfter"] != null ? payload["ToQuantityBefore"] : payload["FromQuantityBefore"]); + inventory["timestamp"] = ToScriptValue(payload["OccurredOn"]) ?? evt.OccurredOn; + inventory["group_id"] = 0; + } + obj["inventory"] = inventory; + obj["event"] = new ScriptObject + { + ["id"] = evt.EventId ?? string.Empty, ["name"] = evt.EventName ?? string.Empty, ["schema_version"] = evt.SchemaVersion, + ["occurred_on"] = evt.OccurredOn, ["correlation_id"] = evt.CorrelationId ?? string.Empty, ["causation_id"] = evt.CausationId ?? string.Empty, + ["sequence"] = evt.Sequence, ["is_replay"] = evt.IsReplay, ["origin_client"] = evt.OriginClient ?? RmsOriginClient.System.ToString() + }; + obj["protection"] = new ScriptObject + { + ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(payload["redacted_fields"]), ["catalog_version"] = ToScriptValue(payload["catalog_version"]) + }; + } + private static void MapInventoryVariables(ScriptObject obj, Inventory inventory, double previousAmount) { var i = new ScriptObject(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs new file mode 100644 index 000000000..63d2a7dc9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs @@ -0,0 +1,179 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(198)] + public class M0198_AddInventoryModernization : Migration + { + private static string N(string value) => value; + private static readonly string[] Tables = { "InventoryCategories", "InventoryItems", "InventoryLocations", "InventoryLots", "InventoryStocks", "InventoryAssets", "InventoryTransactions", "InventoryOperations", "InventoryTransfers", "InventoryTransferItems", "InventoryIssuances", "InventoryKits", "InventoryKitItems" }; + private static readonly (string Table, string Column, string Parent)[] Links = + { + ("InventoryCategories", "ParentCategoryId", "InventoryCategories"), ("InventoryItems", "CategoryId", "InventoryCategories"), + ("InventoryLocations", "ContainerAssetId", "InventoryAssets"), ("InventoryLocations", "ParentLocationId", "InventoryLocations"), + ("InventoryLots", "ItemId", "InventoryItems"), ("InventoryStocks", "ItemId", "InventoryItems"), ("InventoryStocks", "LocationId", "InventoryLocations"), + ("InventoryAssets", "ItemId", "InventoryItems"), ("InventoryAssets", "CurrentLocationId", "InventoryLocations"), + ("InventoryTransactions", "OperationId", "InventoryOperations"), ("InventoryTransactions", "ItemId", "InventoryItems"), + ("InventoryTransactions", "FromLocationId", "InventoryLocations"), ("InventoryTransactions", "ToLocationId", "InventoryLocations"), + ("InventoryTransactions", "ReversesTransactionId", "InventoryTransactions"), ("InventoryTransactions", "IssuanceId", "InventoryIssuances"), + ("InventoryTransfers", "FromLocationId", "InventoryLocations"), ("InventoryTransfers", "ToLocationId", "InventoryLocations"), ("InventoryTransfers", "OperationId", "InventoryOperations"), + ("InventoryTransferItems", "TransferId", "InventoryTransfers"), ("InventoryTransferItems", "TransactionId", "InventoryTransactions"), ("InventoryTransferItems", "ItemId", "InventoryItems"), + ("InventoryIssuances", "ItemId", "InventoryItems"), ("InventoryIssuances", "LocationId", "InventoryLocations"), ("InventoryIssuances", "ReturnedToLocationId", "InventoryLocations"), + ("InventoryKitItems", "KitId", "InventoryKits"), ("InventoryKitItems", "ItemId", "InventoryItems") + }; + private static readonly string[] LotChildren = { "InventoryStocks", "InventoryAssets", "InventoryTransactions", "InventoryTransferItems", "InventoryIssuances" }; + private static readonly string[] AssetChildren = { "InventoryTransactions", "InventoryTransferItems", "InventoryIssuances" }; + + public override void Up() + { + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable(); + if (name == "InventoryTransactions") table.WithColumn(N("EntryId")).AsInt64().PrimaryKey().Identity(); + else table.PrimaryKey(); + table.WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("ModifiedOn")).AsDateTime2().Nullable() + .WithColumn(N("CreatedBy")).AsString(128).Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + if (name != "InventoryTransactions" && name != "InventoryOperations" && name != "InventoryTransferItems") + table.WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "InventoryCategories": table.WithColumn(N("ParentCategoryId")).AsString(36).Nullable(); break; + case "InventoryItems": + table.WithColumn(N("CategoryId")).AsString(36).Nullable().WithColumn(N("TrackingMode")).AsInt32().NotNullable() + .WithColumn(N("IsKit")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RequiresLotTracking")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RequiresExpiration")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("IsControlledSubstance")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("IsActive")).AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn(N("LegacyInventoryTypeId")).AsInt32().Nullable(); break; + case "InventoryLocations": + table.WithColumn(N("LocationType")).AsInt32().NotNullable().WithColumn(N("GroupId")).AsInt32().Nullable() + .WithColumn(N("UnitId")).AsInt32().Nullable().WithColumn(N("UserId")).AsString(128).Nullable() + .WithColumn(N("ContainerAssetId")).AsString(36).Nullable().WithColumn(N("ParentLocationId")).AsString(36).Nullable() + .WithColumn(N("IsDefault")).AsBoolean().NotNullable().WithDefaultValue(false); break; + case "InventoryLots": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("ExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("ReceivedOn")).AsDateTime2().NotNullable(); break; + case "InventoryStocks": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("LocationId")).AsString(36).NotNullable() + .WithColumn(N("LotId")).AsString(36).Nullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + case "InventoryAssets": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("CurrentLocationId")).AsString(36).Nullable() + .WithColumn(N("ExpiresOn")).AsDateTime2().Nullable().WithColumn(N("AcquiredOn")).AsDateTime2().Nullable(); break; + case "InventoryTransactions": + table.WithColumn(N("OperationId")).AsString(36).Nullable().WithColumn(N("LineNumber")).AsInt32().NotNullable() + .WithColumn(N("TransactionType")).AsInt32().NotNullable().WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("AssetId")).AsString(36).Nullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("FromLocationId")).AsString(36).Nullable().WithColumn(N("ToLocationId")).AsString(36).Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("FromQuantityBefore")).AsDecimal(24, 6).Nullable().WithColumn(N("FromQuantityAfter")).AsDecimal(24, 6).Nullable() + .WithColumn(N("ToQuantityBefore")).AsDecimal(24, 6).Nullable().WithColumn(N("ToQuantityAfter")).AsDecimal(24, 6).Nullable() + .WithColumn(N("OldStatus")).AsInt32().Nullable().WithColumn(N("NewStatus")).AsInt32().Nullable() + .WithColumn(N("ReferenceType")).AsInt32().NotNullable().WithColumn(N("ReferenceId")).AsString(128).Nullable() + .WithColumn(N("ReversesTransactionId")).AsString(36).Nullable().WithColumn(N("IssuanceId")).AsString(36).Nullable() + .WithColumn(N("LegacyInventoryId")).AsInt32().Nullable().WithColumn(N("OccurredOn")).AsDateTime2().NotNullable(); break; + case "InventoryOperations": table.WithColumn(N("State")).AsInt32().NotNullable().WithColumn(N("RequestId")).AsString(36).NotNullable().WithColumn(N("WitnessUserId")).AsString(128).Nullable(); break; + case "InventoryTransfers": + table.WithColumn(N("FromLocationId")).AsString(36).NotNullable().WithColumn(N("ToLocationId")).AsString(36).NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("OperationId")).AsString(36).NotNullable(); break; + case "InventoryTransferItems": + table.WithColumn(N("TransferId")).AsString(36).NotNullable().WithColumn(N("TransactionId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + case "InventoryIssuances": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("AssetId")).AsString(36).Nullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable().WithColumn(N("ReturnedQuantity")).AsDecimal(24, 6).NotNullable().WithDefaultValue(0) + .WithColumn(N("IssuedToUserId")).AsString(128).Nullable().WithColumn(N("IssuedToUnitId")).AsInt32().Nullable() + .WithColumn(N("LocationId")).AsString(36).NotNullable().WithColumn(N("ReturnedToLocationId")).AsString(36).Nullable() + .WithColumn(N("IssuedOn")).AsDateTime2().NotNullable().WithColumn(N("ExpectedReturnOn")).AsDateTime2().Nullable().WithColumn(N("ReturnedOn")).AsDateTime2().Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("ReferenceType")).AsInt32().NotNullable().WithColumn(N("ReferenceId")).AsString(128).Nullable(); break; + case "InventoryKitItems": + table.WithColumn(N("KitId")).AsString(36).NotNullable().WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + } + Index(name, "TenantId", true, "DepartmentId", "Id"); + Check(name, "Revision", "Revision >= 1"); + Create.ForeignKey(N("FK_" + name + "_Department")).FromTable(N(name)).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + } + Index("InventoryTransactions", "PublicId", true, "Id"); + Index("InventoryLots", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + Index("InventoryAssets", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + foreach (var link in Links) + Create.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).FromTable(N(link.Table)).ForeignColumns(N("DepartmentId"), N(link.Column)).ToTable(N(link.Parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + foreach (var child in LotChildren) + Create.ForeignKey(N("FK_" + child + "_ItemLot")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")).ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + foreach (var child in AssetChildren) + Create.ForeignKey(N("FK_" + child + "_ItemAsset")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")).ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); + Holder("InventoryLocations", "UnitId", "Units", "UnitId"); + Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); + Holder("InventoryIssuances", "IssuedToUnitId", "Units", "UnitId"); + Holder("InventoryIssuances", "IssuedToUserId", "AspNetUsers", "Id"); + Check("InventoryItems", "TrackingMode", "TrackingMode IN (0,1)"); + Check("InventoryAssets", "Status", "Status BETWEEN 0 AND 6"); + Check("InventoryTransactions", "TypeQuantity", "TransactionType BETWEEN 0 AND 9 AND Quantity >= 0 AND (Quantity > 0 OR TransactionType IN (0,9))"); + Check("InventoryTransactions", "Line", "LineNumber >= 0"); + Check("InventoryTransfers", "Locations", "FromLocationId <> ToLocationId"); + Check("InventoryTransferItems", "Quantity", "Quantity > 0"); + Check("InventoryKitItems", "Quantity", "Quantity > 0"); + Check("InventoryCategories", "Parent", "ParentCategoryId IS NULL OR ParentCategoryId <> Id"); + Check("InventoryLocations", "Parent", "ParentLocationId IS NULL OR ParentLocationId <> Id"); + Check("InventoryLocations", "Holder", "(LocationType IN (0,5) AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 1 AND GroupId IS NOT NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 2 AND GroupId IS NULL AND UnitId IS NOT NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 3 AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NOT NULL AND ContainerAssetId IS NULL) OR (LocationType = 4 AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NOT NULL)"); + Check("InventoryIssuances", "Holder", "(IssuedToUserId IS NOT NULL AND IssuedToUnitId IS NULL) OR (IssuedToUserId IS NULL AND IssuedToUnitId IS NOT NULL)"); + Check("InventoryIssuances", "Quantity", "Quantity > 0 AND ReturnedQuantity >= 0 AND ReturnedQuantity <= Quantity"); + Check("InventoryIssuances", "Status", "Status BETWEEN 0 AND 4"); + Index("InventoryOperations", "Request", true, "DepartmentId", "RequestId"); + Index("InventoryTransactions", "History", false, "DepartmentId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "ItemHistory", false, "DepartmentId", "ItemId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "AssetHistory", false, "DepartmentId", "AssetId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "Reference", false, "DepartmentId", "ReferenceType", "ReferenceId", "EntryId"); + Index("InventoryAssets", "LocationStatus", false, "DepartmentId", "CurrentLocationId", "Status"); + Index("InventoryLocations", "Parent", false, "DepartmentId", "ParentLocationId"); + Index("InventoryLots", "Expiration", false, "DepartmentId", "ExpiresOn", "ItemId"); + Index("InventoryIssuances", "UnitHistory", false, "DepartmentId", "IssuedToUnitId", "IssuedOn", "ReturnedOn"); + Index("InventoryIssuances", "PersonStatus", false, "DepartmentId", "IssuedToUserId", "Status", "ExpectedReturnOn"); + Index("InventoryIssuances", "AssetHistory", false, "DepartmentId", "AssetId", "IssuedOn"); + Index("InventoryTransferItems", "Transfer", false, "DepartmentId", "TransferId"); + Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); + SpecialIndexes(); + } + private void Holder(string table, string column, string parent, string key) + { + // Existing unit tracking provides a tenant key; older minimal databases still get the typed FK. + if (!Schema.Table(N(parent)).Exists()) return; + if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) + Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); + else Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumn(N(column)).ToTable(N(parent)).PrimaryColumn(N(key)); + } + private void Index(string table, string suffix, bool unique, params string[] columns) + { + var index = Create.Index(N((unique ? "UX_" : "IX_") + table + "_" + suffix)).OnTable(N(table)).OnColumn(N(columns[0])).Ascending(); + for (var i = 1; i < columns.Length; i++) index = index.OnColumn(N(columns[i])).Ascending(); + if (unique) index.WithOptions().Unique(); + } + private void Check(string table, string suffix, string expression) => Execute.Sql("ALTER TABLE " + N(table) + " ADD CONSTRAINT " + N("CK_" + table + "_" + suffix) + " CHECK (" + expression + ");"); + private void SpecialIndexes() + { + Execute.Sql("ALTER TABLE InventoryStocks ADD LotKey AS ISNULL(LotId, '00000000-0000-0000-0000-000000000000') PERSISTED; CREATE UNIQUE INDEX UX_InventoryStocks_Balance ON InventoryStocks(DepartmentId,ItemId,LocationId,LotKey) WHERE IsDeleted = 0;"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryItems_Legacy ON InventoryItems(DepartmentId,LegacyInventoryTypeId) WHERE LegacyInventoryTypeId IS NOT NULL; CREATE UNIQUE INDEX UX_InventoryTransactions_Legacy ON InventoryTransactions(DepartmentId,LegacyInventoryId) WHERE LegacyInventoryId IS NOT NULL; CREATE UNIQUE INDEX UX_InventoryTransactions_OperationLine ON InventoryTransactions(DepartmentId,OperationId,LineNumber) WHERE OperationId IS NOT NULL;"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryLocations_Default ON InventoryLocations(DepartmentId) WHERE IsDefault = 1 AND IsDeleted = 0; CREATE UNIQUE INDEX UX_InventoryKitItems_Item ON InventoryKitItems(DepartmentId,KitId,ItemId) WHERE IsDeleted = 0; CREATE UNIQUE INDEX UX_InventoryIssuances_OutstandingAsset ON InventoryIssuances(DepartmentId,AssetId) WHERE AssetId IS NOT NULL AND IsDeleted = 0 AND Status IN (0,2);"); + foreach (var column in new[] { "GroupId", "UnitId", "UserId", "ContainerAssetId" }) + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryLocations_" + column + " ON InventoryLocations(DepartmentId," + column + ") WHERE " + column + " IS NOT NULL AND IsDeleted = 0;"); + } + public override void Down() + { + // An operator must use the authorized retention path; rollback must never erase ledger/equipment evidence. + Execute.Sql("IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + " THROW 51000, 'Inventory data must be exported and removed through the authorized retention process before rollback.', 1;"); + foreach (var child in AssetChildren) Delete.ForeignKey(N("FK_" + child + "_ItemAsset")).OnTable(N(child)); + foreach (var child in LotChildren) Delete.ForeignKey(N("FK_" + child + "_ItemLot")).OnTable(N(child)); + foreach (var link in Links) Delete.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).OnTable(N(link.Table)); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0199_FenceLegacyInventoryWrites.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0199_FenceLegacyInventoryWrites.cs new file mode 100644 index 000000000..bd94dd93c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0199_FenceLegacyInventoryWrites.cs @@ -0,0 +1,48 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(199)] + public class M0199_FenceLegacyInventoryWrites : Migration + { + private static readonly string[] LegacyTables = { "Inventories", "InventoryTypes" }; + public override void Up() + { + if (!Schema.Table("InventoryOperations").Exists() || !Schema.Table("Departments").Exists()) return; + foreach (var table in LegacyTables) + { + if (!Schema.Table(table).Exists()) continue; + // AFTER triggers preserve the legacy statement's normal identity/FK behavior. Throwing rolls back every row. + // Match InventoryStore.LockDepartmentAsync, then use a locking marker read even under row-versioned isolation. + Execute.Sql($@"CREATE TRIGGER [dbo].[TR_{table}_InventoryModernizationFence] +ON [dbo].[{table}] +AFTER INSERT, UPDATE, DELETE +AS +BEGIN + SET NOCOUNT ON; + DECLARE @departmentId int, @lockedDepartmentId int; + DECLARE affectedDepartments CURSOR LOCAL FAST_FORWARD FOR + SELECT DepartmentId FROM (SELECT DepartmentId FROM inserted UNION SELECT DepartmentId FROM deleted) AS affected + ORDER BY DepartmentId; + OPEN affectedDepartments; + FETCH NEXT FROM affectedDepartments INTO @departmentId; + WHILE @@FETCH_STATUS = 0 + BEGIN + SELECT @lockedDepartmentId = DepartmentId FROM [dbo].[Departments] WITH (UPDLOCK,HOLDLOCK) + WHERE DepartmentId = @departmentId; + IF EXISTS (SELECT 1 FROM [dbo].[InventoryOperations] WITH (UPDLOCK,HOLDLOCK) + WHERE DepartmentId = @departmentId AND RequestId = '00000000-0000-0000-0000-000000000001' AND State = 2) + THROW 51000, 'Legacy inventory writes are disabled after inventory modernization. Use the modern inventory command.', 1; + FETCH NEXT FROM affectedDepartments INTO @departmentId; + END; + CLOSE affectedDepartments; + DEALLOCATE affectedDepartments; +END;"); + } + } + public override void Down() + { + foreach (var table in LegacyTables) Execute.Sql($"DROP TRIGGER IF EXISTS [dbo].[TR_{table}_InventoryModernizationFence];"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs new file mode 100644 index 000000000..01a093f9a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs @@ -0,0 +1,179 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(198)] + public class M0198_AddInventoryModernizationPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static readonly string[] Tables = { "InventoryCategories", "InventoryItems", "InventoryLocations", "InventoryLots", "InventoryStocks", "InventoryAssets", "InventoryTransactions", "InventoryOperations", "InventoryTransfers", "InventoryTransferItems", "InventoryIssuances", "InventoryKits", "InventoryKitItems" }; + private static readonly (string Table, string Column, string Parent)[] Links = + { + ("InventoryCategories", "ParentCategoryId", "InventoryCategories"), ("InventoryItems", "CategoryId", "InventoryCategories"), + ("InventoryLocations", "ContainerAssetId", "InventoryAssets"), ("InventoryLocations", "ParentLocationId", "InventoryLocations"), + ("InventoryLots", "ItemId", "InventoryItems"), ("InventoryStocks", "ItemId", "InventoryItems"), ("InventoryStocks", "LocationId", "InventoryLocations"), + ("InventoryAssets", "ItemId", "InventoryItems"), ("InventoryAssets", "CurrentLocationId", "InventoryLocations"), + ("InventoryTransactions", "OperationId", "InventoryOperations"), ("InventoryTransactions", "ItemId", "InventoryItems"), + ("InventoryTransactions", "FromLocationId", "InventoryLocations"), ("InventoryTransactions", "ToLocationId", "InventoryLocations"), + ("InventoryTransactions", "ReversesTransactionId", "InventoryTransactions"), ("InventoryTransactions", "IssuanceId", "InventoryIssuances"), + ("InventoryTransfers", "FromLocationId", "InventoryLocations"), ("InventoryTransfers", "ToLocationId", "InventoryLocations"), ("InventoryTransfers", "OperationId", "InventoryOperations"), + ("InventoryTransferItems", "TransferId", "InventoryTransfers"), ("InventoryTransferItems", "TransactionId", "InventoryTransactions"), ("InventoryTransferItems", "ItemId", "InventoryItems"), + ("InventoryIssuances", "ItemId", "InventoryItems"), ("InventoryIssuances", "LocationId", "InventoryLocations"), ("InventoryIssuances", "ReturnedToLocationId", "InventoryLocations"), + ("InventoryKitItems", "KitId", "InventoryKits"), ("InventoryKitItems", "ItemId", "InventoryItems") + }; + private static readonly string[] LotChildren = { "InventoryStocks", "InventoryAssets", "InventoryTransactions", "InventoryTransferItems", "InventoryIssuances" }; + private static readonly string[] AssetChildren = { "InventoryTransactions", "InventoryTransferItems", "InventoryIssuances" }; + + public override void Up() + { + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable(); + if (name == "InventoryTransactions") table.WithColumn(N("EntryId")).AsInt64().PrimaryKey().Identity(); + else table.PrimaryKey(); + table.WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("ModifiedOn")).AsDateTime2().Nullable() + .WithColumn(N("CreatedBy")).AsString(128).Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + if (name != "InventoryTransactions" && name != "InventoryOperations" && name != "InventoryTransferItems") + table.WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "InventoryCategories": table.WithColumn(N("ParentCategoryId")).AsString(36).Nullable(); break; + case "InventoryItems": + table.WithColumn(N("CategoryId")).AsString(36).Nullable().WithColumn(N("TrackingMode")).AsInt32().NotNullable() + .WithColumn(N("IsKit")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RequiresLotTracking")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RequiresExpiration")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("IsControlledSubstance")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("IsActive")).AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn(N("LegacyInventoryTypeId")).AsInt32().Nullable(); break; + case "InventoryLocations": + table.WithColumn(N("LocationType")).AsInt32().NotNullable().WithColumn(N("GroupId")).AsInt32().Nullable() + .WithColumn(N("UnitId")).AsInt32().Nullable().WithColumn(N("UserId")).AsString(128).Nullable() + .WithColumn(N("ContainerAssetId")).AsString(36).Nullable().WithColumn(N("ParentLocationId")).AsString(36).Nullable() + .WithColumn(N("IsDefault")).AsBoolean().NotNullable().WithDefaultValue(false); break; + case "InventoryLots": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("ExpiresOn")).AsDateTime2().Nullable() + .WithColumn(N("ReceivedOn")).AsDateTime2().NotNullable(); break; + case "InventoryStocks": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("LocationId")).AsString(36).NotNullable() + .WithColumn(N("LotId")).AsString(36).Nullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + case "InventoryAssets": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("CurrentLocationId")).AsString(36).Nullable() + .WithColumn(N("ExpiresOn")).AsDateTime2().Nullable().WithColumn(N("AcquiredOn")).AsDateTime2().Nullable(); break; + case "InventoryTransactions": + table.WithColumn(N("OperationId")).AsString(36).Nullable().WithColumn(N("LineNumber")).AsInt32().NotNullable() + .WithColumn(N("TransactionType")).AsInt32().NotNullable().WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("AssetId")).AsString(36).Nullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("FromLocationId")).AsString(36).Nullable().WithColumn(N("ToLocationId")).AsString(36).Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("FromQuantityBefore")).AsDecimal(24, 6).Nullable().WithColumn(N("FromQuantityAfter")).AsDecimal(24, 6).Nullable() + .WithColumn(N("ToQuantityBefore")).AsDecimal(24, 6).Nullable().WithColumn(N("ToQuantityAfter")).AsDecimal(24, 6).Nullable() + .WithColumn(N("OldStatus")).AsInt32().Nullable().WithColumn(N("NewStatus")).AsInt32().Nullable() + .WithColumn(N("ReferenceType")).AsInt32().NotNullable().WithColumn(N("ReferenceId")).AsString(128).Nullable() + .WithColumn(N("ReversesTransactionId")).AsString(36).Nullable().WithColumn(N("IssuanceId")).AsString(36).Nullable() + .WithColumn(N("LegacyInventoryId")).AsInt32().Nullable().WithColumn(N("OccurredOn")).AsDateTime2().NotNullable(); break; + case "InventoryOperations": table.WithColumn(N("State")).AsInt32().NotNullable().WithColumn(N("RequestId")).AsString(36).NotNullable().WithColumn(N("WitnessUserId")).AsString(128).Nullable(); break; + case "InventoryTransfers": + table.WithColumn(N("FromLocationId")).AsString(36).NotNullable().WithColumn(N("ToLocationId")).AsString(36).NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("OperationId")).AsString(36).NotNullable(); break; + case "InventoryTransferItems": + table.WithColumn(N("TransferId")).AsString(36).NotNullable().WithColumn(N("TransactionId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + case "InventoryIssuances": + table.WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("AssetId")).AsString(36).Nullable().WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable().WithColumn(N("ReturnedQuantity")).AsDecimal(24, 6).NotNullable().WithDefaultValue(0) + .WithColumn(N("IssuedToUserId")).AsString(128).Nullable().WithColumn(N("IssuedToUnitId")).AsInt32().Nullable() + .WithColumn(N("LocationId")).AsString(36).NotNullable().WithColumn(N("ReturnedToLocationId")).AsString(36).Nullable() + .WithColumn(N("IssuedOn")).AsDateTime2().NotNullable().WithColumn(N("ExpectedReturnOn")).AsDateTime2().Nullable().WithColumn(N("ReturnedOn")).AsDateTime2().Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithColumn(N("ReferenceType")).AsInt32().NotNullable().WithColumn(N("ReferenceId")).AsString(128).Nullable(); break; + case "InventoryKitItems": + table.WithColumn(N("KitId")).AsString(36).NotNullable().WithColumn(N("ItemId")).AsString(36).NotNullable().WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable(); break; + } + Index(name, "TenantId", true, "DepartmentId", "Id"); + Check(name, "Revision", "Revision >= 1"); + Create.ForeignKey(N("FK_" + name + "_Department")).FromTable(N(name)).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + } + Index("InventoryTransactions", "PublicId", true, "Id"); + Index("InventoryLots", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + Index("InventoryAssets", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + foreach (var link in Links) + Create.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).FromTable(N(link.Table)).ForeignColumns(N("DepartmentId"), N(link.Column)).ToTable(N(link.Parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + foreach (var child in LotChildren) + Create.ForeignKey(N("FK_" + child + "_ItemLot")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")).ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + foreach (var child in AssetChildren) + Create.ForeignKey(N("FK_" + child + "_ItemAsset")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")).ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); + Holder("InventoryLocations", "UnitId", "Units", "UnitId"); + Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); + Holder("InventoryIssuances", "IssuedToUnitId", "Units", "UnitId"); + Holder("InventoryIssuances", "IssuedToUserId", "AspNetUsers", "Id"); + Check("InventoryItems", "TrackingMode", "TrackingMode IN (0,1)"); + Check("InventoryAssets", "Status", "Status BETWEEN 0 AND 6"); + Check("InventoryTransactions", "TypeQuantity", "TransactionType BETWEEN 0 AND 9 AND Quantity >= 0 AND (Quantity > 0 OR TransactionType IN (0,9))"); + Check("InventoryTransactions", "Line", "LineNumber >= 0"); + Check("InventoryTransfers", "Locations", "FromLocationId <> ToLocationId"); + Check("InventoryTransferItems", "Quantity", "Quantity > 0"); + Check("InventoryKitItems", "Quantity", "Quantity > 0"); + Check("InventoryCategories", "Parent", "ParentCategoryId IS NULL OR ParentCategoryId <> Id"); + Check("InventoryLocations", "Parent", "ParentLocationId IS NULL OR ParentLocationId <> Id"); + Check("InventoryLocations", "Holder", "(LocationType IN (0,5) AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 1 AND GroupId IS NOT NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 2 AND GroupId IS NULL AND UnitId IS NOT NULL AND UserId IS NULL AND ContainerAssetId IS NULL) OR (LocationType = 3 AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NOT NULL AND ContainerAssetId IS NULL) OR (LocationType = 4 AND GroupId IS NULL AND UnitId IS NULL AND UserId IS NULL AND ContainerAssetId IS NOT NULL)"); + Check("InventoryIssuances", "Holder", "(IssuedToUserId IS NOT NULL AND IssuedToUnitId IS NULL) OR (IssuedToUserId IS NULL AND IssuedToUnitId IS NOT NULL)"); + Check("InventoryIssuances", "Quantity", "Quantity > 0 AND ReturnedQuantity >= 0 AND ReturnedQuantity <= Quantity"); + Check("InventoryIssuances", "Status", "Status BETWEEN 0 AND 4"); + Index("InventoryOperations", "Request", true, "DepartmentId", "RequestId"); + Index("InventoryTransactions", "History", false, "DepartmentId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "ItemHistory", false, "DepartmentId", "ItemId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "AssetHistory", false, "DepartmentId", "AssetId", "OccurredOn", "EntryId"); + Index("InventoryTransactions", "Reference", false, "DepartmentId", "ReferenceType", "ReferenceId", "EntryId"); + Index("InventoryAssets", "LocationStatus", false, "DepartmentId", "CurrentLocationId", "Status"); + Index("InventoryLocations", "Parent", false, "DepartmentId", "ParentLocationId"); + Index("InventoryLots", "Expiration", false, "DepartmentId", "ExpiresOn", "ItemId"); + Index("InventoryIssuances", "UnitHistory", false, "DepartmentId", "IssuedToUnitId", "IssuedOn", "ReturnedOn"); + Index("InventoryIssuances", "PersonStatus", false, "DepartmentId", "IssuedToUserId", "Status", "ExpectedReturnOn"); + Index("InventoryIssuances", "AssetHistory", false, "DepartmentId", "AssetId", "IssuedOn"); + Index("InventoryTransferItems", "Transfer", false, "DepartmentId", "TransferId"); + Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); + SpecialIndexes(); + } + private void Holder(string table, string column, string parent, string key) + { + // Existing unit tracking provides a tenant key; older minimal databases still get the typed FK. + if (!Schema.Table(N(parent)).Exists()) return; + if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) + Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); + else Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumn(N(column)).ToTable(N(parent)).PrimaryColumn(N(key)); + } + private void Index(string table, string suffix, bool unique, params string[] columns) + { + var index = Create.Index(N((unique ? "UX_" : "IX_") + table + "_" + suffix)).OnTable(N(table)).OnColumn(N(columns[0])).Ascending(); + for (var i = 1; i < columns.Length; i++) index = index.OnColumn(N(columns[i])).Ascending(); + if (unique) index.WithOptions().Unique(); + } + private void Check(string table, string suffix, string expression) => Execute.Sql("ALTER TABLE " + N(table) + " ADD CONSTRAINT " + N("CK_" + table + "_" + suffix) + " CHECK (" + expression + ");"); + private void SpecialIndexes() + { + Execute.Sql("CREATE UNIQUE INDEX ux_inventorystocks_balance ON inventorystocks(departmentid,itemid,locationid,(COALESCE(lotid, '00000000-0000-0000-0000-000000000000'))) WHERE isdeleted = false;"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventoryitems_legacy ON inventoryitems(departmentid,legacyinventorytypeid) WHERE legacyinventorytypeid IS NOT NULL; CREATE UNIQUE INDEX ux_inventorytransactions_legacy ON inventorytransactions(departmentid,legacyinventoryid) WHERE legacyinventoryid IS NOT NULL; CREATE UNIQUE INDEX ux_inventorytransactions_operationline ON inventorytransactions(departmentid,operationid,linenumber) WHERE operationid IS NOT NULL;"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventorylocations_default ON inventorylocations(departmentid) WHERE isdefault = true AND isdeleted = false; CREATE UNIQUE INDEX ux_inventorykititems_item ON inventorykititems(departmentid,kitid,itemid) WHERE isdeleted = false; CREATE UNIQUE INDEX ux_inventoryissuances_outstandingasset ON inventoryissuances(departmentid,assetid) WHERE assetid IS NOT NULL AND isdeleted = false AND status IN (0,2);"); + foreach (var column in new[] { "groupid", "unitid", "userid", "containerassetid" }) + Execute.Sql("CREATE UNIQUE INDEX ux_inventorylocations_" + column + " ON inventorylocations(departmentid," + column + ") WHERE " + column + " IS NOT NULL AND isdeleted = false;"); + } + public override void Down() + { + // An operator must use the authorized retention path; rollback must never erase ledger/equipment evidence. + Execute.Sql("DO $guard$ BEGIN IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + " THEN RAISE EXCEPTION 'Inventory data must be exported and removed through the authorized retention process before rollback.'; END IF; END $guard$;"); + foreach (var child in AssetChildren) Delete.ForeignKey(N("FK_" + child + "_ItemAsset")).OnTable(N(child)); + foreach (var child in LotChildren) Delete.ForeignKey(N("FK_" + child + "_ItemLot")).OnTable(N(child)); + foreach (var link in Links) Delete.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).OnTable(N(link.Table)); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0199_FenceLegacyInventoryWritesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0199_FenceLegacyInventoryWritesPg.cs new file mode 100644 index 000000000..61af8d75c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0199_FenceLegacyInventoryWritesPg.cs @@ -0,0 +1,55 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(199)] + public class M0199_FenceLegacyInventoryWritesPg : Migration + { + private static readonly string[] LegacyTables = { "inventories", "inventorytypes" }; + public override void Up() + { + if (!Schema.Table("inventoryoperations").Exists() || !Schema.Table("departments").Exists()) return; + // VOLATILE supplies a fresh Read Committed snapshot for the marker query after a lock wait. + // An older Repeatable Read snapshot cannot prove absence of a committed marker, so refuse that writer. + Execute.Sql(@"CREATE FUNCTION public.resgrid_inventory_legacy_write_fence() RETURNS trigger +LANGUAGE plpgsql VOLATILE AS $inventory_fence$ +DECLARE + old_department integer; + new_department integer; + affected_department integer; +BEGIN + IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN + RAISE EXCEPTION 'Legacy inventory mutations require read committed isolation for the modernization fence.' USING ERRCODE = '55000'; + END IF; + IF TG_OP <> 'INSERT' THEN old_department := OLD.departmentid; END IF; + IF TG_OP <> 'DELETE' THEN new_department := NEW.departmentid; END IF; + FOR affected_department IN + SELECT DISTINCT holder FROM unnest(ARRAY[old_department,new_department]) AS changed(holder) + WHERE holder IS NOT NULL ORDER BY holder + LOOP + PERFORM departmentid FROM public.departments WHERE departmentid = affected_department FOR UPDATE; + IF EXISTS (SELECT 1 FROM public.inventoryoperations WHERE departmentid = affected_department + AND requestid = '00000000-0000-0000-0000-000000000001' AND state = 2) THEN + RAISE EXCEPTION 'Legacy inventory writes are disabled after inventory modernization. Use the modern inventory command.' USING ERRCODE = '55000'; + END IF; + END LOOP; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$inventory_fence$;"); + foreach (var table in LegacyTables) + { + if (!Schema.Table(table).Exists()) continue; + Execute.Sql($@"CREATE TRIGGER tr_{table}_inventorymodernizationfence +BEFORE INSERT OR UPDATE OR DELETE ON public.{table} +FOR EACH ROW EXECUTE FUNCTION public.resgrid_inventory_legacy_write_fence();"); + } + } + public override void Down() + { + foreach (var table in LegacyTables) + if (Schema.Table(table).Exists()) Execute.Sql($"DROP TRIGGER IF EXISTS tr_{table}_inventorymodernizationfence ON public.{table};"); + Execute.Sql("DROP FUNCTION IF EXISTS public.resgrid_inventory_legacy_write_fence();"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index e4f426128..1db4e9a2e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -8,6 +8,7 @@ using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; namespace Resgrid.Repositories.DataRepository { @@ -25,23 +26,27 @@ async Task Exists(string table) var result = await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT CASE WHEN to_regclass(@Name) IS NULL THEN 0 ELSE 1 END" : "SELECT CASE WHEN OBJECT_ID(@Name,'U') IS NOT NULL THEN 1 WHEN HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION')=1 THEN 0 ELSE -1 END", new { Name = (pg ? "public." + table.ToLowerInvariant() : "dbo." + table) }, transaction, cancellationToken: ct)); if (result < 0) throw new InvalidOperationException("Cannot verify the department cleanup schema."); return result == 1; } - if (!await Exists("ChecklistDefinitions")) return; + var hasChecklists = await Exists("ChecklistDefinitions"); if (await Exists("ChecklistAccessFence")) await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT id FROM checklistaccessfence WHERE id=1 FOR UPDATE" : "SELECT Id FROM ChecklistAccessFence WITH (UPDLOCK,HOLDLOCK) WHERE Id=1", transaction: transaction, cancellationToken: ct)); - await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT departmentid FROM departments WHERE departmentid=@DepartmentId FOR UPDATE" : "SELECT DepartmentId FROM Departments WITH (UPDLOCK,HOLDLOCK) WHERE DepartmentId=@DepartmentId", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)); + var lockedDepartment = await connection.ExecuteScalarAsync(new CommandDefinition(pg ? "SELECT departmentid FROM departments WHERE departmentid=@DepartmentId FOR UPDATE" : "SELECT DepartmentId FROM Departments WITH (UPDLOCK,HOLDLOCK) WHERE DepartmentId=@DepartmentId", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)); + if (lockedDepartment != departmentId) throw new InvalidOperationException("Department cleanup could not lock the requested department."); // RMS uses the same department lock for legal holds. Preserve source readiness evidence // conservatively when any active hold exists, even if its content is encrypted. if (await Exists("RmsRecordLegalHolds") && await connection.ExecuteScalarAsync(new CommandDefinition($"SELECT COUNT(*) FROM {Q("RmsRecordLegalHolds")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ReleasedOn")} IS NULL", new { DepartmentId = departmentId }, transaction, cancellationToken: ct)) > 0) throw new InvalidOperationException("Department readiness evidence is retained under an active legal hold."); - var triggers = ChecklistWorkflowPayload.Triggers.ToArray(); + await InventoryDepartmentCleanup.DeleteWithinTransactionAsync(connection, transaction, departmentId, type, ct); + if (!hasChecklists) return; + var triggers = ChecklistWorkflowPayload.Triggers.Except(InventoryWorkflowPayload.Triggers).ToArray(); var triggerPredicate = Q("TriggerEventType") + (pg ? "=ANY(@Triggers)" : " IN @Triggers"); var auditPredicate = Q("LogType") + (pg ? "=ANY(@AuditTypes)" : " IN @AuditTypes"); - var parameters = new { DepartmentId = departmentId, Triggers = triggers, AuditTypes = ReadinessHistoryFields.AuditTypes }; + var producerPredicate = Q("ProducerSubsystem") + (pg ? "=ANY(@Producers)" : " IN @Producers"); + var parameters = new { DepartmentId = departmentId, Producers = ChecklistWorkflowPayload.ReadinessProducers.Where(p => p != "Inventory").ToArray(), Triggers = triggers, AuditTypes = ReadinessHistoryFields.AuditTypes.Except(InventoryDepartmentCleanup.AuditTypes).ToArray() }; if (await Exists("WorkflowRuns")) { if (await Exists("WorkflowRunLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate})", parameters, transaction, cancellationToken: ct)); await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate}", parameters, transaction, cancellationToken: ct)); } - if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ProducerSubsystem")} IN ('Checklists','WorkOrders')", parameters, transaction, cancellationToken: ct)); + if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {producerPredicate}", parameters, transaction, cancellationToken: ct)); if (await Exists("AuditLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {auditPredicate}", parameters, transaction, cancellationToken: ct)); foreach (var table in new[] { "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs index 0f1cef853..36eeabd3f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistRepository.cs @@ -71,7 +71,14 @@ public async Task WriteAsync(T row, bool insert, CancellationToken ct = defau var columns = Columns(); var sql = insert ? $"INSERT INTO {Tbl(Table())} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" : $"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Where(c => c != "Id" && c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id"; - if (await ExecuteAsync(sql, row, ct) != 1) throw new InvalidOperationException("Checklist row could not be saved."); + try + { + if (await ExecuteAsync(sql, row, ct) != 1) throw new InvalidOperationException("Checklist row could not be saved."); + } + catch (Microsoft.Data.SqlClient.SqlException ex) when (insert && typeof(T) == typeof(ChecklistCompletion) && ex.Number is 2601 or 2627) + { throw new ChecklistException(409, "Run identifier is already in use."); } + catch (Npgsql.PostgresException ex) when (insert && typeof(T) == typeof(ChecklistCompletion) && ex.SqlState == "23505") + { throw new ChecklistException(409, "Run identifier is already in use."); } } public async Task ReplaceAnswersAsync(int departmentId, string completionId, IEnumerable items, CancellationToken ct = default) { diff --git a/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs new file mode 100644 index 000000000..084374534 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using CommandDefinition = Dapper.CommandDefinition; + +namespace Resgrid.Repositories.DataRepository +{ + /// Inventory subtree of authorized department deletion. The caller holds the department lock + /// and checks legal holds before invoking this helper; it never commits or grants independent purge access. + internal static class InventoryDepartmentCleanup + { + internal static readonly int[] AuditTypes = Enum.GetValues() + .Where(t => t.ToString().StartsWith("Inventory", StringComparison.Ordinal)).Select(t => (int)t).ToArray(); + + internal static async Task DeleteWithinTransactionAsync(DbConnection connection, DbTransaction transaction, int departmentId, DatabaseTypes type, CancellationToken ct = default) + { + if (transaction == null || transaction.Connection != connection || departmentId <= 0) throw new InvalidOperationException("Inventory department cleanup requires the caller's scoped transaction."); + var pg = type == DatabaseTypes.Postgres; + string Q(string value) => pg ? value.ToLowerInvariant() : "[" + value + "]"; + async Task Exists(string table) + { + var result = await connection.ExecuteScalarAsync(new CommandDefinition(pg + ? "SELECT CASE WHEN to_regclass(@Name) IS NULL THEN 0 ELSE 1 END" + : "SELECT CASE WHEN OBJECT_ID(@Name,'U') IS NOT NULL THEN 1 WHEN HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION')=1 THEN 0 ELSE -1 END", + new { Name = pg ? "public." + table.ToLowerInvariant() : "dbo." + table }, transaction, cancellationToken: ct)); + if (result < 0) throw new InvalidOperationException("Cannot verify the inventory department cleanup schema."); + return result == 1; + } + var parameters = new { DepartmentId = departmentId, Triggers = InventoryWorkflowPayload.Triggers.ToArray(), AuditTypes }; + var triggerPredicate = Q("TriggerEventType") + (pg ? "=ANY(@Triggers)" : " IN @Triggers"); + if (await Exists("WorkflowRuns")) + { + if (await Exists("WorkflowRunLogs")) + await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate})", parameters, transaction, cancellationToken: ct)); + await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@DepartmentId AND {triggerPredicate}", parameters, transaction, cancellationToken: ct)); + } + if (await Exists("DomainEventOutbox")) + await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("ProducerSubsystem")}='Inventory'", parameters, transaction, cancellationToken: ct)); + if (await Exists("AuditLogs") && AuditTypes.Length > 0) + await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {Q("LogType")}{(pg ? "=ANY(@AuditTypes)" : " IN @AuditTypes")}", parameters, transaction, cancellationToken: ct)); + + var present = new HashSet(StringComparer.Ordinal); + foreach (var table in InventoryTables.All.Values) if (await Exists(table)) present.Add(table); + async Task Delete(string table) + { + if (present.Contains(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); + } + foreach (var table in new[] { "InventoryTransferItems", "InventoryTransactions", "InventoryIssuances", "InventoryTransfers", "InventoryStocks", "InventoryKitItems" }) await Delete(table); + // Break only the nullable asset -> location edge; changing container holder columns would violate its CHECK. + // Protected Content and immutable historical transactions are never decoded or rewritten. + if (present.Contains("InventoryAssets")) + await connection.ExecuteAsync(new CommandDefinition($"UPDATE {Q("InventoryAssets")} SET {Q("CurrentLocationId")}=NULL WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); + foreach (var table in new[] { "InventoryLocations", "InventoryAssets", "InventoryLots", "InventoryItems", "InventoryCategories", "InventoryKits" }) await Delete(table); + // Keep the migration receipt/fence until every modern row is gone. Legacy deletion follows in the caller. + await Delete("InventoryOperations"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs new file mode 100644 index 000000000..7a82818e6 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Tenant-scoped inventory persistence. The service owns the transaction spanning ledger, stock, audit and outbox writes. + public sealed class InventoryStore : RmsRepositoryBase, IInventoryStore + { + private const int PageSize = 501; + private const int MaximumRelatedRows = 5000; + private const string LegacyMigrationRequestId = "00000000-0000-0000-0000-000000000001"; + private static readonly HashSet RelationshipColumns = new(StringComparer.Ordinal) + { + "ParentCategoryId", "CategoryId", "ContainerAssetId", "ParentLocationId", "ItemId", "LocationId", "LotId", "CurrentLocationId", + "OperationId", "AssetId", "FromLocationId", "ToLocationId", "ReferenceId", "ReversesTransactionId", "IssuanceId", "TransferId", + "TransactionId", "ReturnedToLocationId", "KitId", "RequestId", "IssuedToUserId", "UserId" + }; + + public InventoryStore(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) + : base(connection, config, uow, queries) { } + + private static string Table() where T : InventoryRow => InventoryTables.All[typeof(T)]; + private static PropertyInfo[] Properties() where T : InventoryRow => typeof(T).GetProperties() + .Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute))).ToArray(); + private static string[] Columns() where T : InventoryRow => Properties().Select(p => p.Name).ToArray(); + private static DynamicParameters Parameters(T row) where T : InventoryRow + { + var parameters = new DynamicParameters(); + foreach (var property in Properties()) + { + var value = property.GetValue(row); + parameters.Add(property.Name, value is DateTime date ? DatabaseTimestamp(date) : value); + } + return parameters; + } + private void Transaction() + { + if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Inventory writes require an existing transaction."); + } + + public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); + + public Task GetAsync(int departmentId, string id) where T : InventoryRow => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", + new { DepartmentId = departmentId, Id = id }); + + public async Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow + { + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId ORDER BY {Col("Id")} {Paging()}", + new { DepartmentId = departmentId, Skip = skip, Take = PageSize })).ToList(); + } + + public async Task> RelatedAsync(int departmentId, string column, string id) where T : InventoryRow + { + // Callers can choose only reviewed, persisted relationship columns, never SQL expressions or content fields. + if (column == null || !RelationshipColumns.Contains(column) || !Properties().Any(p => p.Name == column && p.PropertyType == typeof(string))) + throw new ArgumentException("Invalid inventory relationship column.", nameof(column)); + var rows = (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col(column)}={P}RelatedId ORDER BY {Col("Id")} {Paging()}", + new { DepartmentId = departmentId, RelatedId = id, Skip = 0, Take = MaximumRelatedRows + 1 })).ToList(); + if (rows.Count > MaximumRelatedRows) throw new InvalidOperationException("The inventory relationship exceeds the supported operation size."); + return rows; + } + + public async Task> QueryAsync(int departmentId, InventoryQuery filter, int skip = 0) where T : InventoryRow + { + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + filter ??= new InventoryQuery(); + var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId" }; + var parameters = new DynamicParameters(new { DepartmentId = departmentId, Skip = skip, Take = PageSize }); + void Equal(string column, string value) + { + if (value == null) return; + if (!Properties().Any(p => p.Name == column)) throw new ArgumentException("Unsupported inventory filter."); + conditions.Add($"{Col(column)}={P}{column}"); parameters.Add(column, value); + } + Equal("ItemId", filter.ItemId); Equal("AssetId", filter.AssetId); Equal("IssuedToUserId", filter.IssuedToUserId); Equal("KitId", filter.KitId); + if (typeof(T) == typeof(InventoryTransaction) && filter.LocationId != null) + { conditions.Add($"({Col("FromLocationId")}={P}LocationId OR {Col("ToLocationId")}={P}LocationId)"); parameters.Add("LocationId", filter.LocationId); } + else Equal("LocationId", filter.LocationId); + if (typeof(InventoryMutableRow).IsAssignableFrom(typeof(T))) conditions.Add($"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}"); + var order = typeof(T) == typeof(InventoryTransaction) ? $"{Col("EntryId")} DESC" : Col("Id"); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {string.Join(" AND ", conditions)} ORDER BY {order} {Paging()}", parameters)).ToList(); + } + + public async Task InsertAsync(T row) where T : InventoryRow + { + Transaction(); + if (row == null || row.DepartmentId <= 0 || !Guid.TryParse(row.Id, out var id) || id == Guid.Empty) throw new ArgumentException("A tenant and stable inventory identity are required.", nameof(row)); + var columns = Columns().Where(c => c != nameof(InventoryTransaction.EntryId)).ToArray(); + var values = string.Join(",", columns.Select(c => P + c)); + if (row is InventoryTransaction transaction) + { + if (transaction.EntryId != 0) throw new InvalidOperationException("Inventory ledger identities are allocated by the database."); + transaction.EntryId = await ScalarAsync($"INSERT INTO {Tbl(Table())} ({Cols(columns)}) {(IsPostgres ? "" : "OUTPUT INSERTED.[EntryId]")} VALUES ({values}) {(IsPostgres ? "RETURNING entryid" : "")}", Parameters(row)); + } + else if (await ExecuteAsync($"INSERT INTO {Tbl(Table())} ({Cols(columns)}) VALUES ({values})", Parameters(row)) != 1) + throw new InvalidOperationException("The inventory row could not be inserted."); + } + + public async Task UpdateAsync(T row, int expectedRevision) where T : InventoryRow + { + Transaction(); + if (row == null) throw new ArgumentNullException(nameof(row)); + if (row is InventoryTransaction or InventoryTransferItem) throw new InvalidOperationException("Inventory ledger and transfer evidence are immutable."); + if (expectedRevision < 1 || expectedRevision == int.MaxValue || row.Revision != expectedRevision + 1) throw new ArgumentException("Inventory updates must advance the expected revision once.", nameof(expectedRevision)); + var columns = Columns().Where(c => c is not "Id" and not "DepartmentId" and not "CreatedOn" and not "CreatedBy").ToArray(); + var parameters = Parameters(row); parameters.Add("ExpectedRevision", expectedRevision); + if (await ExecuteAsync($"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id AND {Col("Revision")}={P}ExpectedRevision", parameters) != 1) + throw new InvalidOperationException("The inventory row changed or is unavailable."); + } + + public Task RequestAsync(int departmentId, string requestId) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("InventoryOperations")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RequestId")}={P}RequestId", new { DepartmentId = departmentId, RequestId = requestId }); + + public async Task ApplyStockDeltaAsync(int departmentId, string itemId, string locationId, string lotId, decimal delta, string userId) + { + Transaction(); + await LockDepartmentAsync(departmentId); + var now = DateTime.UtcNow; + var parameters = new { DepartmentId = departmentId, ItemId = itemId, LocationId = locationId, LotId = lotId, Delta = delta, Now = DatabaseTimestamp(now) }; + var columns = Columns(); + var output = IsPostgres ? "" : "OUTPUT " + string.Join(",", columns.Select(c => "INSERTED." + Col(c))); + var stock = await QueryFirstOrDefaultAsync($"UPDATE {Tbl("InventoryStocks")} SET {Col("Quantity")}={Col("Quantity")}+{P}Delta,{Col("Revision")}={Col("Revision")}+1,{Col("ModifiedOn")}={P}Now,{Col("IsDeleted")}={(IsPostgres ? "false" : "0")} {output} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ItemId")}={P}ItemId AND {Col("LocationId")}={P}LocationId AND ({Col("LotId")}={P}LotId OR ({Col("LotId")} IS NULL AND {P}LotId IS NULL)) {(IsPostgres ? "RETURNING " + Cols(columns) : "")}", parameters); + if (stock != null) return stock; + // The department lock covers the missing-row race as well as every multi-location transfer. + stock = new InventoryStock { DepartmentId = departmentId, ItemId = itemId, LocationId = locationId, LotId = lotId, Quantity = delta, CreatedOn = now, ModifiedOn = now, CreatedBy = userId }; + await InsertAsync(stock); + return stock; + } + + public Task LegacyItemAsync(int departmentId, int typeId) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("InventoryItems")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("LegacyInventoryTypeId")}={P}TypeId", new { DepartmentId = departmentId, TypeId = typeId }); + + public Task LegacyTransactionAsync(int departmentId, int inventoryId) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("InventoryTransactions")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("LegacyInventoryId")}={P}InventoryId", new { DepartmentId = departmentId, InventoryId = inventoryId }); + + public async Task RebuildStocksAsync(int departmentId) + { + Transaction(); + await LockDepartmentAsync(departmentId); + var parameters = new { DepartmentId = departmentId, Now = DatabaseTimestamp(DateTime.UtcNow) }; + await ExecuteAsync($"DELETE FROM {Tbl("InventoryStocks")} WHERE {Col("DepartmentId")}={P}DepartmentId", parameters); + var columns = Cols("Id", "DepartmentId", "Revision", "CreatedOn", "ModifiedOn", "CreatedBy", "Content", "IsProtected", "IsDeleted", "ItemId", "LocationId", "LotId", "Quantity"); + var id = IsPostgres ? "gen_random_uuid()::text" : "CONVERT(varchar(36),NEWID())"; + var disabled = IsPostgres ? "false" : "0"; + string LedgerSide(string location, bool subtract) => $"SELECT t.{Col("ItemId")},t.{Col(location)} AS {Col("LocationId")},t.{Col("LotId")},{(subtract ? "-" : "")}t.{Col("Quantity")} AS {Col("Quantity")} FROM {Tbl("InventoryTransactions")} t JOIN {Tbl("InventoryItems")} i ON i.{Col("DepartmentId")}=t.{Col("DepartmentId")} AND i.{Col("Id")}=t.{Col("ItemId")} WHERE t.{Col("DepartmentId")}={P}DepartmentId AND i.{Col("TrackingMode")}={(int)InventoryTrackingMode.Bulk} AND t.{Col(location)} IS NOT NULL"; + await ExecuteAsync($"INSERT INTO {Tbl("InventoryStocks")} ({columns}) SELECT {id},{P}DepartmentId,1,{P}Now,{P}Now,NULL,NULL,{disabled},{disabled},s.{Col("ItemId")},s.{Col("LocationId")},s.{Col("LotId")},SUM(s.{Col("Quantity")}) FROM ({LedgerSide("FromLocationId", true)} UNION ALL {LedgerSide("ToLocationId", false)}) s GROUP BY s.{Col("ItemId")},s.{Col("LocationId")},s.{Col("LotId")}", parameters); + } + + public async Task HasLegacyMigrationAsync(int departmentId) => await ScalarAsync( + $"SELECT COUNT(*) FROM {Tbl("InventoryOperations")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RequestId")}={P}RequestId AND {Col("State")}=2", new { DepartmentId = departmentId, RequestId = LegacyMigrationRequestId }) > 0; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 6fde3fc10..470eaea16 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -83,6 +83,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/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index c1b7adc5d..1c436989e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -89,6 +89,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/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index a8f3a1b6d..6d1b5a943 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -98,6 +98,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/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 83ef07911..86a3e9e6a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -82,6 +82,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/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs index 77ac046d8..e31c8a10f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ReadinessProBillingRepository.cs @@ -32,16 +32,16 @@ public async Task SaveAsync(ReadinessProBillingAccount account) if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Readiness billing writes require the department transaction."); var columns = typeof(ReadinessProBillingAccount).GetProperties().Select(p => p.Name).ToArray(); var existing = await GetAsync(account.DepartmentId); - await ExecuteAsync(existing == null ? $"INSERT INTO {Tbl("ReadinessProBillingAccounts")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" - : $"UPDATE {Tbl("ReadinessProBillingAccounts")} SET {string.Join(",", columns.Where(c => c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId", account, default); + if (await ExecuteAsync(existing == null ? $"INSERT INTO {Tbl("ReadinessProBillingAccounts")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("ReadinessProBillingAccounts")} SET {string.Join(",", columns.Where(c => c != "DepartmentId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId", account, default) != 1) throw new InvalidOperationException("Readiness billing account could not be saved."); } public async Task> PaymentsAsync(int departmentId, string planAddonId) => (await QueryAsync($"SELECT * FROM {Tbl("PaymentAddons")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PlanAddonId")}={P}PlanAddonId", new { DepartmentId = departmentId, PlanAddonId = planAddonId }, default)).ToList(); public async Task SavePaymentAsync(PaymentAddon payment, bool insert) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Readiness billing writes require the department transaction."); var columns = typeof(PaymentAddon).GetProperties().Where(p => p.CanWrite && !payment.IgnoredProperties.Contains(p.Name)).Select(p => p.Name).ToArray(); - await ExecuteAsync(insert ? $"INSERT INTO {Tbl("PaymentAddons")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" - : $"UPDATE {Tbl("PaymentAddons")} SET {string.Join(",", columns.Where(c => c != "DepartmentId" && c != "PaymentAddonId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PaymentAddonId")}={P}PaymentAddonId AND {Col("PlanAddonId")}={P}PlanAddonId", payment, default); + if (await ExecuteAsync(insert ? $"INSERT INTO {Tbl("PaymentAddons")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})" + : $"UPDATE {Tbl("PaymentAddons")} SET {string.Join(",", columns.Where(c => c != "DepartmentId" && c != "PaymentAddonId").Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PaymentAddonId")}={P}PaymentAddonId AND {Col("PlanAddonId")}={P}PlanAddonId", payment, default) != 1) throw new InvalidOperationException("Readiness billing payment could not be saved."); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index a05e961b6..7ce470598 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -682,13 +682,13 @@ public class DomainEventOutboxRepository : RmsRepositoryBase InitializeChecklistPayloadAsync(DomainEventOutboxEntry entry, CancellationToken cancellationToken = default) { if (UnitOfWork.Transaction == null) throw new InvalidOperationException("Checklist event initialization requires the producer transaction."); - return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("EventId")} = {P}EventId AND {Col("ProducerSubsystem")} IN ('Checklists','WorkOrders') AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} IS NULL AND {Col("PayloadJson")} = '{{}}'", - new { Id = entry.DomainEventOutboxId, entry.DepartmentId, entry.EventId, Payload = entry.PayloadJson, Pending = (int)DomainEventOutboxState.Pending }, cancellationToken) == 1; + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("EventId")} = {P}EventId AND {InList("ProducerSubsystem", "Producers")} AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} IS NULL AND {Col("PayloadJson")} = '{{}}'", + new { Id = entry.DomainEventOutboxId, entry.DepartmentId, entry.EventId, Payload = entry.PayloadJson, Producers = InListValue(Resgrid.Model.Checklists.ChecklistWorkflowPayload.ReadinessProducers), Pending = (int)DomainEventOutboxState.Pending }, cancellationToken) == 1; } public async Task ReplaceChecklistPayloadAsync(DomainEventOutboxEntry entry, string safePayload, CancellationToken cancellationToken = default) { - return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload, {Col("LastError")} = {P}LastError WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("ProducerSubsystem")} IN ('Checklists','WorkOrders') AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} = {P}Owner AND {Col("Attempts")} = {P}Attempts AND {Col("LeaseExpiresOn")} > {P}Now", - new { Id = entry.DomainEventOutboxId, entry.DepartmentId, Payload = safePayload, entry.LastError, Pending = (int)DomainEventOutboxState.Pending, Owner = entry.LeaseOwner, entry.Attempts, Now = DateTime.UtcNow }, cancellationToken) == 1; + return await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Payload, {Col("LastError")} = {P}LastError WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {InList("ProducerSubsystem", "Producers")} AND {Col("State")} = {P}Pending AND {Col("LeaseOwner")} = {P}Owner AND {Col("Attempts")} = {P}Attempts AND {Col("LeaseExpiresOn")} > {P}Now", + new { Id = entry.DomainEventOutboxId, entry.DepartmentId, Payload = safePayload, entry.LastError, Producers = InListValue(Resgrid.Model.Checklists.ChecklistWorkflowPayload.ReadinessProducers), Pending = (int)DomainEventOutboxState.Pending, Owner = entry.LeaseOwner, entry.Attempts, Now = DateTime.UtcNow }, cancellationToken) == 1; } public DomainEventOutboxRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs index 5a4b4eb54..f66432cc6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -43,7 +43,7 @@ public async Task> ListAsync(int departmentId, WorkOrderReadScop { if (scope == null || string.IsNullOrWhiteSpace(scope.UserId) || filter.Page < 0 || filter.Page > 10000) throw new ArgumentException("Invalid work-order scope."); var parameters = new DynamicParameters(new { DepartmentId = departmentId, UserId = scope.UserId, AllowedGroup = scope.GroupId, Status = (int?)filter.Status, Priority = (int?)filter.Priority, UnitId = filter.UnitId, GroupId = filter.GroupId, AssetId = filter.AssetId, Skip = filter.Page * 50, Take = 51 }); - parameters.Add("Roles", InListValue(scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); + parameters.Add("Roles", InListValue(scope.RoleIds == null || scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); var own = $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId" }; if (!scope.All) conditions.Add(own); diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 55c1f8618..32d22d581 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -86,5 +86,10 @@ "ChecklistFailed": 68, "WorkOrderCreated": 70, "WorkOrderStatusChanged": 71, - "WorkOrderAssigned": 72 + "WorkOrderAssigned": 72, + "InventoryTransferCompleted": 58, + "InventoryIssued": 59, + "InventoryReturned": 60, + "InventoryAssetStatusChanged": 64, + "ControlledSubstanceRecorded": 66 } diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index c01a2399f..445535766 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -37,8 +37,11 @@ public void Permission_types_50_to_67_are_the_registry_names() // 68 is Unified Search's ManageSearchIndex; RMS-5 took 69 from the pool released on 2026-08-27. Enum.IsDefined(typeof(PermissionTypes), 68).Should().BeFalse("68 is reserved for Unified Search's ManageSearchIndex, which is not authored yet"); - // 40-49 belong to other pending plans; RMS must not have taken any of them. - foreach (var value in Enumerable.Range(40, 10)) + // Inventory consumed its 47-49 reservation in P1-M1/M2; 40-46 remain unauthored. + ((int)PermissionTypes.TransferInventory).Should().Be(47); + ((int)PermissionTypes.IssueInventory).Should().Be(48); + ((int)PermissionTypes.ManageControlledSubstances).Should().Be(49); + foreach (var value in Enumerable.Range(40, 7)) Enum.IsDefined(typeof(PermissionTypes), value).Should().BeFalse($"PermissionTypes {value} is reserved for another plan"); } @@ -93,7 +96,13 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.WorkOrderCreated).Should().Be(70); ((int)WorkflowTriggerEventType.WorkOrderStatusChanged).Should().Be(71); ((int)WorkflowTriggerEventType.WorkOrderAssigned).Should().Be(72); - foreach (var value in Enumerable.Range(52, 48).Except(new[] { 67, 68, 69, 70, 71, 72 })) + ((int)WorkflowTriggerEventType.InventoryAdjusted).Should().Be(22); + ((int)WorkflowTriggerEventType.InventoryTransferCompleted).Should().Be(58); + ((int)WorkflowTriggerEventType.InventoryIssued).Should().Be(59); + ((int)WorkflowTriggerEventType.InventoryReturned).Should().Be(60); + ((int)WorkflowTriggerEventType.InventoryAssetStatusChanged).Should().Be(64); + ((int)WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(66); + foreach (var value in Enumerable.Range(52, 48).Except(new[] { 58, 59, 60, 64, 66, 67, 68, 69, 70, 71, 72 })) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs index 69108bc13..8c9434452 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistEventDeliveryTests.cs @@ -62,7 +62,7 @@ public async Task Projection_precedes_persistence_and_is_rechecked_after_enrollm (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); var payload = JObject.Parse(delivered.PayloadJson); payload["Score"].Value().Should().Be("REDACTED"); payload["Passed"].Value().Should().Be("REDACTED"); payload["TargetId"].Value().Should().Be("REDACTED"); - payload["is_redacted"].Value().Should().BeTrue(); payload["catalog_version"].Value().Should().Be(18); + payload["is_redacted"].Value().Should().BeTrue(); payload["catalog_version"].Value().Should().Be(new ProtectedFieldCatalog().Version); entry.PayloadJson.Should().StartWith("rgdp:").And.NotContain("87.25").And.NotContain("person-42"); _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().Contain("87.25").And.NotContain("person-42"); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs index 8c06610c4..1c59c59dd 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs @@ -45,10 +45,11 @@ public async Task Checklist_export_pages_all_owned_data_and_masks_plaintext_duri _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, - new Lazy(() => protection), reminders.Object, EmptyWorkOrders()); + new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory()); var files = await RunExportAsync(); var json = files["checklists.json"]; - json.Should().NotContain("CANARY").And.NotContain("23.5").And.NotContain("rgdp:").And.Contain("REDACTED"); + json.Should().NotContain("CANARY").And.NotContain("rgdp:").And.Contain("REDACTED"); var exported = JObject.Parse(json); + exported["Completions"].Select(c => c["Completion"]["Score"].Value()).Should().OnlyContain(score => score == null); exported["Completions"].Should().HaveCount(26); store.ChildQueries.Should().Be(9, "children are paged across all selected parents, not queried per completion"); json.Should().NotContain("AQID", "evidence blobs are excluded from JSON exports"); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs index 805f12b20..f83a95ed6 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504BoundaryTests.cs @@ -81,7 +81,7 @@ public async Task Schedule_page_encodes_untrusted_names_timezones_and_all_route_ { const string attack = "\">(); - checklists.Setup(s => s.SchedulesAsync(It.IsAny(), attack, 1)).ReturnsAsync(new List + checklists.Setup(s => s.SchedulesAsync(It.IsAny(), attack, 1, false)).ReturnsAsync(new List { new ChecklistScheduleView { Schedule = new ChecklistSchedule { Id = attack, TimeZoneId = attack, Frequency = 2 }, Content = new ChecklistScheduleContent { Name = attack } } }); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs index c07fed7be..e24cbf97a 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs @@ -83,7 +83,7 @@ public void Readiness_trigger_membership_cannot_be_replaced_through_the_public_c var collection = (IList)ChecklistWorkflowPayload.Triggers; Action change = () => collection[0] = 999; change.Should().Throw(); - ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165); + ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 22, 58, 59, 60, 64, 66); ChecklistWorkflowPayload.IsChecklist(67).Should().BeTrue(); ChecklistWorkflowPayload.IsChecklist(999).Should().BeFalse(); } @@ -123,7 +123,7 @@ public async Task Checklist_witness_only_export_contains_own_witness_facts_witho _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, - new Lazy(() => protection), reminders.Object, EmptyWorkOrders()); + new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory()); var json = (await RunExportAsync())["checklists.json"]; var exported = JObject.Parse(json); json.Should().NotContain("CANARY").And.NotContain("AQID"); exported["Witnesses"].Should().HaveCount(105); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504ServiceTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504ServiceTests.cs index b1cc7a383..fc4d92eaa 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504ServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504ServiceTests.cs @@ -73,7 +73,8 @@ public async Task Cache_failure_after_commit_still_publishes_audit_and_never_rol events.Setup(e => e.SendMessage(It.IsAny())).Callback(() => committed.Should().BeTrue()); var service = new FeatureToggleService(flags.Object, null, null, null, null, cache.Object, events.Object, null, null, unit.Object, Mock.Of()); Func save = () => service.SaveFlagAsync(new FeatureFlag { FlagKey = "Review.Test" }, "actor"); - if (failCache) await save.Should().ThrowAsync(); else await save(); + await save.Should().NotThrowAsync(); + cache.Verify(c => c.RemoveAsync(It.IsAny()), Times.Exactly(3)); unit.Verify(u => u.CommitChanges(), Times.Once); unit.Verify(u => u.DiscardChanges(), Times.Never); events.Verify(e => e.SendMessage(It.Is(a => a.UserId == "actor" && a.Type == AuditLogTypes.FeatureFlagChanged)), Times.Once); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr505Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr505Tests.cs new file mode 100644 index 000000000..23d8a2454 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistPr505Tests.cs @@ -0,0 +1,108 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [TestCase(""), TestCase("null"), TestCase("{}")] + public async Task Desktop_and_mobile_history_fall_back_to_the_target_ID_for_empty_snapshots(string content) + { + var setup = await Start(); + var completion = await _store.GetAsync(77, setup.Run); + var occurrence = await _store.GetAsync(77, completion.OccurrenceId); + occurrence.Content = content; await _store.WriteAsync(occurrence, false); + (await _service.HistoryAsync(_actor, completion.ParentId)).Single().TargetName.Should().Be(completion.TargetId); + (await _service.MobileHistoryAsync(_actor, new ChecklistMobileQuery { DefinitionId = completion.ParentId })).Single().TargetName.Should().Be(completion.TargetId); + } + + [Test] + public async Task Schedule_lookahead_keeps_fifty_row_page_offsets() + { + var setup = await Scheduled(); + var original = await _store.GetAsync(77, setup.Input.Id); + for (var i = 0; i < 51; i++) + { + var row = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original)); + row.Id = Guid.NewGuid().ToString(); await _store.WriteAsync(row, true); + } + var first = await _service.SchedulesAsync(_actor, original.ParentId, 0, true); + var next = await _service.SchedulesAsync(_actor, original.ParentId, 1, true); + first.Should().HaveCount(51); next.Should().HaveCount(2); + first.Take(50).Concat(next).Select(v => v.Schedule.Id).Should().HaveCount(52).And.OnlyHaveUniqueItems(); + } + + [TestCase("th-TH"), TestCase("ar-SA")] + public async Task Compliance_trend_dates_keep_the_Gregorian_year(string culture) + { + await SeedReportMonth(); var report = await _service.GetComplianceSummaryAsync(_actor, Month()); + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + ChecklistReportDocuments.Compliance(report).Should().Contain("2026-08-01"); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + [Test, NonParallelizable] + public async Task Failed_post_commit_cache_eviction_does_not_skip_other_keys_or_report_the_committed_command_failed() + { + var oldEnabled = FeatureFlagsConfig.FeatureFlagsEnabled; var oldCache = SystemBehaviorConfig.CacheEnabled; + try + { + FeatureFlagsConfig.FeatureFlagsEnabled = true; SystemBehaviorConfig.CacheEnabled = true; + var flag = new FeatureFlag { FeatureFlagId = 19, FlagKey = FeatureFlagKeys.ChecklistsSystem, IsEnabledGlobally = true }; + var flags = new Mock(); flags.Setup(r => r.GetAllAsync()).ReturnsAsync(new[] { flag }); + flags.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), false)).ReturnsAsync((FeatureFlag row, CancellationToken ct, bool first) => row); + var cache = new Mock(); var attempts = 0; + cache.Setup(c => c.RemoveAsync(It.IsAny())).Returns(() => ++attempts == 1 ? Task.FromException(new InvalidOperationException("Synthetic cache failure")) : Task.FromResult(true)); + var events = new Mock(); + var service = new FeatureToggleService(flags.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), cache.Object, events.Object, Mock.Of(), Mock.Of(), _uow.Object, Mock.Of()); + await service.SetGlobalEnabledAsync(flag.FlagKey, false, "author"); + attempts.Should().Be(3); events.Invocations.Should().ContainSingle(i => i.Method.Name == "SendMessage"); + _uow.Verify(u => u.CommitChanges(), Times.Once); _uow.Verify(u => u.DiscardChanges(), Times.Never); + } + finally { FeatureFlagsConfig.FeatureFlagsEnabled = oldEnabled; SystemBehaviorConfig.CacheEnabled = oldCache; } + } + } + + public partial class ChecklistDatabaseTests + { + [Test] + public async Task A_completion_ID_owned_by_another_department_returns_conflict_without_reading_its_data() + { + var connections = Connections(); using var uow = new Resgrid.Repositories.DataRepository.Transactions.UnitOfWork(connections); var store = Repository(connections, uow); + await uow.CreateOrGetConnectionAsync(); + async Task Completion(int department) + { + var definition = Row(); definition.DepartmentId = department; await store.WriteAsync(definition, true); + var version = Row(definition.Id); version.DepartmentId = department; version.Version = 1; await store.WriteAsync(version, true); + var occurrence = Row(definition.Id); occurrence.DepartmentId = department; occurrence.VersionId = version.Id; occurrence.CompletionId = Guid.NewGuid().ToString(); occurrence.TargetId = "77"; await store.WriteAsync(occurrence, true); + var completion = Row(definition.Id); completion.DepartmentId = department; completion.VersionId = version.Id; completion.OccurrenceId = occurrence.Id; completion.TargetId = "77"; + return completion; + } + var existing = await Completion(77); await store.WriteAsync(existing, true); + var foreign = await Completion(88); foreign.Id = existing.Id; uow.CommitChanges(); + (await store.GetAsync(88, existing.Id)).Should().BeNull(); + await uow.CreateOrGetConnectionAsync(); + await FluentActions.Awaiting(() => store.WriteAsync(foreign, true)).Should().ThrowAsync().Where(e => e.StatusCode == 409); + uow.DiscardChanges(); + (await store.GetAsync(77, existing.Id)).Content.Should().Be(existing.Content); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs index 0c75809f5..beb752ac5 100644 --- a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs +++ b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs @@ -114,7 +114,7 @@ public void SetUp() _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), - new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), Mock.Of())), checklistReminders.Object, EmptyWorkOrders()); + new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), Mock.Of())), checklistReminders.Object, EmptyWorkOrders(), EmptyInventory()); } private async Task> RunExportAsync() diff --git a/Tests/Resgrid.Tests/Services/InventoryApiTests.cs b/Tests/Resgrid.Tests/Services/InventoryApiTests.cs new file mode 100644 index 000000000..a7e3070be --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryApiTests.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using ApiClaims = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; +using ApiController = Resgrid.Web.Services.Controllers.v4.InventoryController; + +namespace Resgrid.Tests.Services +{ + /// Real MVC binding, authentication and action filters around mocked domain boundaries. + [TestFixture, NonParallelizable] + public sealed class InventoryApiTests + { + private const string Route = "/api/v4/Inventory/"; + private const string Canary = "SYNTHETIC-INVENTORY-API-PHI-CANARY"; + private const string ItemId = "11111111-1111-1111-1111-111111111111"; + private const string LocationId = "22222222-2222-2222-2222-222222222222"; + private const string LotId = "33333333-3333-3333-3333-333333333333"; + private Mock _catalog; + private Mock _stock; + private Mock _transfers; + private Mock _issuance; + private Mock _migration; + private Mock _authorization; + private List<(InventoryActor Actor, InventoryCommand Command)> _posts; + + [SetUp] + public void SetUp() + { + _catalog = new(); _stock = new(); _transfers = new(); _issuance = new(); _migration = new(); _authorization = new(); _posts = new(); + _catalog.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _stock.Setup(s => s.PostTransactionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryCommand command, CancellationToken ct) => + { + _posts.Add((Copy(actor), Copy(command))); return new InventoryResult { OperationId = Guid.NewGuid().ToString("D"), TransactionIds = new() { Guid.NewGuid().ToString("D") } }; + }); + _authorization.Setup(s => s.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + _migration.Setup(s => s.IsMigratedAsync(77)).ReturnsAsync(true); + } + + [Test] + public async Task Http_routes_require_authentication_and_source_tenant_member_and_grant_only_from_request_context() + { + await WithServer(async client => + { + (await client.GetAsync(Route + "GetAll")).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "synthetic-header-grant"); + var response = await client.GetAsync(Route + "GetAll?departmentId=88&userId=body-user&grantToken=body-grant"); + await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + _catalog.Verify(s => s.ListAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == "synthetic-header-grant"), 0), Times.Once); + var requestId = Guid.NewGuid().ToString("D"); + response = await client.PostAsync(Route + "PostTransaction", Json(new { RequestId = requestId, DepartmentId = 88, UserId = "body-user", GrantToken = "body-grant", + Actor = new { DepartmentId = 88, UserId = "body-user", GrantToken = "body-grant" }, + Lines = new[] { new { ItemId, ToLocationId = LocationId, Quantity = 2.125001m, Type = (int)InventoryTransactionType.Receive, Note = Canary } } })); + await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var captured = _posts.Single(); captured.Actor.DepartmentId.Should().Be(77); captured.Actor.UserId.Should().Be("manager"); captured.Actor.GrantToken.Should().Be("synthetic-header-grant"); + captured.Command.RequestId.Should().Be(requestId); captured.Command.Lines.Single().Quantity.Should().Be(2.125001m); + (await response.Content.ReadAsStringAsync()).Should().NotContain("synthetic-header-grant").And.NotContain(Canary); + }); + } + + [Test] + public async Task Omitted_request_ids_and_invalid_identifier_shapes_do_not_invoke_mutating_services() + { + await WithServer(async client => + { + SignIn(client); + foreach (var action in new[] { "PostTransaction", "CreateTransfer", "CreateAsset", "Issue", "Return", "StatusChange", "IssueKit", "Witness" }) + { + var response = await client.PostAsync(Route + action, Json(new { ItemId, ToLocationId = LocationId, Quantity = 1, Details = new { SerialNumber = "Synthetic serial" }, Attestation = "Count verified", + Lines = new[] { new { ItemId, ToLocationId = LocationId, Quantity = 1, Type = 1 } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, action + ": " + await response.Content.ReadAsStringAsync()); + } + foreach (var requestId in new[] { Guid.Empty.ToString("D"), "not-a-request-id" }) + { + var response = await client.PostAsync(Route + "PostTransaction", Json(new { RequestId = requestId, Lines = new[] { new { ItemId, ToLocationId = LocationId, Quantity = 1, Type = 1 } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + _posts.Should().BeEmpty(); _transfers.Invocations.Should().BeEmpty(); _issuance.Invocations.Should().BeEmpty(); _stock.Invocations.Should().BeEmpty(); + }); + } + + [Test] + public async Task Update_compatibility_route_posts_an_explicit_directional_delta_and_carries_lot_and_asset_revision() + { + await WithServer(async client => + { + SignIn(client); var requestId = Guid.NewGuid().ToString("D"); var assetId = Guid.NewGuid().ToString("D"); + var response = await client.PutAsync(Route + "UpdateItem", Json(new { RequestId = requestId, ItemId, AssetId = assetId, LotId, + FromLocationId = LocationId, Quantity = 1.125001m, ExpectedAssetRevision = 7, Note = Canary, Amount = 999m, InventoryId = 45 })); + await Success(response); var command = _posts.Single().Command; command.RequestId.Should().Be(requestId); var line = command.Lines.Single(); + line.Type.Should().Be(InventoryTransactionType.Adjust); line.Quantity.Should().Be(1.125001m); line.FromLocationId.Should().Be(LocationId); line.ToLocationId.Should().BeNull(); + line.ItemId.Should().Be(ItemId); line.AssetId.Should().Be(assetId); line.LotId.Should().Be(LotId); line.ExpectedAssetRevision.Should().Be(7); + response = await client.PutAsync(Route + "UpdateItem", Json(new { RequestId = Guid.NewGuid().ToString("D"), ItemId, ToLocationId = LocationId, Quantity = 2m })); + await Success(response); _posts.Last().Command.Lines.Single().FromLocationId.Should().BeNull(); _posts.Last().Command.Lines.Single().ToLocationId.Should().Be(LocationId); + foreach (var invalid in new object[] { + new { RequestId = Guid.NewGuid().ToString("D"), ItemId, Quantity = 9m, Amount = 9m }, + new { RequestId = Guid.NewGuid().ToString("D"), ItemId, FromLocationId = LocationId, ToLocationId = LotId, Quantity = 9m }, + new { RequestId = Guid.NewGuid().ToString("D"), ItemId, ToLocationId = LocationId, Quantity = -9m } }) + { + response = await client.PutAsync(Route + "UpdateItem", Json(invalid)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("ExplicitAdjustmentRequired"); + } + _posts.Should().HaveCount(2); + }); + } + + [Test] + public async Task Lot_input_cannot_overpost_identity_tenant_protection_markers_or_raw_content() + { + InventoryActor capturedActor = null; InventoryLot capturedLot = null; InventoryLotContent capturedDetails = null; + _catalog.Setup(s => s.SaveLotAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryLot lot, InventoryLotContent details) => { capturedActor = Copy(actor); capturedLot = Copy(lot); capturedDetails = Copy(details); return lot; }); + await WithServer(async client => + { + SignIn(client); var forgedId = Guid.NewGuid().ToString("D"); var expires = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var response = await client.PostAsync(Route + "CreateLot", Json(new { Id = forgedId, DepartmentId = 88, Revision = 500, IsDeleted = true, IsProtected = true, + CreatedBy = "other-member", CreatedOn = new DateTime(2000, 1, 1), ReceivedOn = new DateTime(2000, 1, 1), Content = Canary, ItemId, ExpiresOn = expires, + Details = new { LotNumber = "Synthetic lot 2027", UnitCost = 1.125001m } })); + await Success(response); capturedActor.DepartmentId.Should().Be(77); capturedLot.Id.Should().NotBe(forgedId); Guid.TryParseExact(capturedLot.Id, "D", out _).Should().BeTrue(); + capturedLot.DepartmentId.Should().Be(0); capturedLot.Revision.Should().Be(1); capturedLot.IsDeleted.Should().BeFalse(); capturedLot.IsProtected.Should().BeFalse(); + capturedLot.Content.Should().BeNull(); capturedLot.CreatedBy.Should().BeNull(); capturedLot.CreatedOn.Should().Be(default(DateTime)); capturedLot.ReceivedOn.Should().Be(default(DateTime)); + capturedLot.ItemId.Should().Be(ItemId); capturedLot.ExpiresOn.Should().Be(expires); capturedDetails.LotNumber.Should().Be("Synthetic lot 2027"); capturedDetails.UnitCost.Should().Be(1.125001m); + }); + } + + [Test] + public async Task Protected_and_conflict_failures_return_safe_problem_codes_without_cacheable_or_raw_errors() + { + _catalog.Setup(s => s.GetAsync(It.IsAny(), ItemId)).ThrowsAsync(new InventoryException(403, "ProtectedDataRequired")); + await WithServer(async client => + { + SignIn(client); var response = await client.GetAsync(Route + "GetItem?itemId=" + ItemId); var text = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); response.Headers.CacheControl.NoStore.Should().BeTrue(); text.Should().Contain("protected_data_required").And.Contain("ProtectedDataRequired"); + var problem = JObject.Parse(text); (problem["IsRedacted"] ?? problem["isRedacted"]).Value().Should().BeTrue(); + _catalog.Setup(s => s.GetAsync(It.IsAny(), ItemId)).ThrowsAsync(new InvalidOperationException(Canary)); + response = await client.GetAsync(Route + "GetItem?itemId=" + ItemId); text = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); response.Headers.CacheControl.NoStore.Should().BeTrue(); text.Should().Contain("OperationUnavailable").And.NotContain(Canary).And.NotContain("InvalidOperationException"); + response = await client.PostAsync(Route + "PostTransaction", Json(new { RequestId = Guid.NewGuid().ToString("D"), Lines = new[] { new { ItemId, Quantity = Canary, Type = 1 } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); _posts.Should().BeEmpty(); + }); + } + + [Test] + public async Task Low_stock_uses_authorized_stock_pages_and_reports_the_visibility_scope() + { + _catalog.Setup(s => s.ListAsync(It.IsAny(), 2)).ReturnsAsync(new InventoryPage { HasMore = true, + Items = new() { new InventoryItem { Id = ItemId, DepartmentId = 77, Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = "Synthetic gloves", UnitOfMeasure = "pair", ReorderPoint = 5m }) } } }); + _catalog.Setup(s => s.ListAsync(It.IsAny(), 0)).ReturnsAsync(new InventoryPage { HasMore = true, + Items = new() { new InventoryStock { ItemId = ItemId, LocationId = LocationId, Quantity = 1.125m } } }); + _catalog.Setup(s => s.ListAsync(It.IsAny(), 1)).ReturnsAsync(new InventoryPage { + Items = new() { new InventoryStock { ItemId = ItemId, LocationId = LotId, Quantity = 2m } } }); + await WithServer(async client => + { + SignIn(client); var response = await client.GetAsync(Route + "GetLowStockItems?page=2"); await Success(response); + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); var row = json["Data"]["Items"].Single(); + row.Value("VisibleQuantity").Should().Be(3.125m); row.Value("QuantityScope").Should().Be("AuthorizedLocations"); + json.Value("HasMore").Should().BeTrue(); + _catalog.Verify(s => s.ListAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), 1), Times.Once); + }); + } + + private static StringContent Json(object value) => new(JsonConvert.SerializeObject(value), Encoding.UTF8, "application/json"); + private static T Copy(T value) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + private static void SignIn(HttpClient client) => client.DefaultRequestHeaders.Add("Test-Member", "manager"); + private static async Task Success(HttpResponseMessage response) => response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + private async Task WithServer(Func test) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); builder.Services.AddApiVersioning(); + const string scheme = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + builder.Services.AddAuthentication(scheme).AddScheme(scheme, _ => { }); builder.Services.AddAuthorization(); + builder.Services.AddControllers().AddApplicationPart(typeof(ApiController).Assembly).AddNewtonsoftJson(o => o.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver()); + builder.Services.AddSingleton(_catalog.Object); builder.Services.AddSingleton(_stock.Object); builder.Services.AddSingleton(_transfers.Object); + builder.Services.AddSingleton(_issuance.Object); builder.Services.AddSingleton(_migration.Object); builder.Services.AddSingleton(_authorization.Object); + await using var app = builder.Build(); var previous = ApiClaims._httpContextAccessor; ApiClaims._httpContextAccessor = app.Services.GetRequiredService(); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); + try { await app.StartAsync(); using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; await test(client); } + finally { await app.StopAsync(); ApiClaims._httpContextAccessor = previous; } + } + private sealed class TestAuthentication : AuthenticationHandler + { + public TestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("Test-Member", out var user)) return Task.FromResult(AuthenticateResult.NoResult()); + var claims = new[] { new Claim(ClaimTypes.PrimarySid, user.ToString()), new Claim(ClaimTypes.PrimaryGroupSid, "77") }; + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name))); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/InventoryAuthorizationTests.cs new file mode 100644 index 000000000..93f1895e6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryAuthorizationTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class InventoryAuthorizationTests + { + private InventoryActor _actor; + private DepartmentMember _member; + private Mock _departments; + private Mock _groups; + private Mock _units; + private Mock _resources; + private Mock _permissions; + private Mock _roles; + private Mock _settings; + private InventoryAuthorizationService _service; + [SetUp] + public void Setup() + { + _actor = new InventoryActor { DepartmentId = 77, UserId = "member" }; + _member = new DepartmentMember { DepartmentId = 77, UserId = _actor.UserId }; + _departments = new Mock(); + _departments.Setup(d => d.GetDepartmentMemberAsync(_actor.UserId, 77, true)).ReturnsAsync(() => _member); + _departments.Setup(d => d.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77, ManagingUserId = "another-user" }); + _groups = new Mock(); + _groups.Setup(g => g.GetGroupForUserAsync(_actor.UserId, 77)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 101 }); + _units = new Mock(); _resources = new Mock(); + _permissions = new Mock(); _roles = new Mock(); + _roles.Setup(r => r.GetRolesForUserAsync(_actor.UserId, 77)).ReturnsAsync(new List()); + _settings = new Mock(); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(77, true)).ReturnsAsync(new DepartmentModuleSettings()); + _service = new InventoryAuthorizationService(_departments.Object, _groups.Object, _units.Object, _resources.Object, _permissions.Object, _roles.Object, _settings.Object); + } + private void Allow(PermissionTypes type, bool groupLocked = false) => _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(77, type)) + .ReturnsAsync(new Permission { DepartmentId = 77, PermissionType = (int)type, Action = (int)PermissionActions.Everyone, LockToGroup = groupLocked }); + private static async Task Denied(Func action, string code, int status = 403) + { + var error = (await action.Should().ThrowAsync()).Which; + error.Code.Should().Be(code); error.StatusCode.Should().Be(status); + } + [TestCase("missing")] + [TestCase("foreign")] + [TestCase("deleted")] + [TestCase("disabled")] + public async Task Current_membership_is_required_even_for_reads(string state) + { + if (state == "missing") _member = null; + else if (state == "foreign") _member.DepartmentId = 78; + else if (state == "deleted") _member.IsDeleted = true; + else _member.IsDisabled = true; + await Denied(() => _service.RequireAsync(_actor), "MembershipRequired"); + _departments.Verify(d => d.GetDepartmentMemberAsync(_actor.UserId, 77, true), Times.Once); + } + [Test] + public async Task Revoked_membership_is_rechecked_on_the_next_operation() + { + await _service.RequireAsync(_actor); + _member.IsDisabled = true; + await Denied(() => _service.RequireAsync(_actor), "MembershipRequired"); + _departments.Verify(d => d.GetDepartmentMemberAsync(_actor.UserId, 77, true), Times.Exactly(2)); + } + [Test] + public async Task Module_suspension_blocks_mutation_and_keeps_historical_read_authorization() + { + Allow(PermissionTypes.AdjustInventory); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(77, true)).ReturnsAsync(new DepartmentModuleSettings { InventoryDisabled = true }); + await _service.RequireAsync(_actor); + await Denied(() => _service.RequireAsync(_actor, true), "InventoryDisabled", 409); + (await _service.IsEnabledAsync(77)).Should().BeFalse(); + _settings.Verify(s => s.GetDepartmentModuleSettingsAsync(77, true), Times.Exactly(2)); + } + [TestCase(PermissionTypes.TransferInventory)] + [TestCase(PermissionTypes.IssueInventory)] + public async Task Missing_transfer_and_issue_rules_inherit_adjust_permission(PermissionTypes type) + { + Allow(PermissionTypes.AdjustInventory); + await _service.RequireAsync(_actor, true, type); + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.AdjustInventory), Times.Once); + } + [TestCase(PermissionTypes.TransferInventory)] + [TestCase(PermissionTypes.IssueInventory)] + public async Task Explicit_transfer_or_issue_rule_overrides_permissive_adjust_rule(PermissionTypes type) + { + Allow(PermissionTypes.AdjustInventory); + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(77, type)).ReturnsAsync(new Permission { DepartmentId = 77, PermissionType = (int)type, Action = (int)PermissionActions.DepartmentAdminsOnly }); + await Denied(() => _service.RequireAsync(_actor, true, type), "PermissionRequired"); + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.AdjustInventory), Times.Never); + } + [Test] + public async Task Controlled_operations_do_not_inherit_adjust_access_and_default_to_department_administrators() + { + Allow(PermissionTypes.AdjustInventory); + await Denied(() => _service.RequireAsync(_actor, true, PermissionTypes.ManageControlledSubstances), "PermissionRequired"); + _member.IsAdmin = true; + await _service.RequireAsync(_actor, true, PermissionTypes.ManageControlledSubstances); + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.AdjustInventory), Times.Never); + } + [TestCase(null)] + [TestCase(102)] + public async Task Group_locked_fallback_rejects_missing_or_other_group(int? targetGroup) + { + Allow(PermissionTypes.AdjustInventory, true); + await Denied(() => _service.RequireAsync(_actor, true, PermissionTypes.TransferInventory, targetGroup), "PermissionRequired"); + await _service.RequireAsync(_actor, true, PermissionTypes.TransferInventory, 101); + _member.IsAdmin = true; + await _service.RequireAsync(_actor, true, PermissionTypes.TransferInventory, targetGroup); + } + [Test] + public async Task Explicit_selected_role_can_issue_without_adjust_permission() + { + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.IssueInventory)).ReturnsAsync(new Permission + { DepartmentId = 77, PermissionType = (int)PermissionTypes.IssueInventory, Action = (int)PermissionActions.DepartmentAdminsAndSelectRoles, Data = "12,13" }); + _roles.Setup(r => r.GetRolesForUserAsync(_actor.UserId, 77)).ReturnsAsync(new List { new() { DepartmentId = 77, PersonnelRoleId = 13 } }); + await _service.RequireAsync(_actor, true, PermissionTypes.IssueInventory); + _permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.AdjustInventory), Times.Never); + } + [TestCase(InventoryLocationType.Unit)] + [TestCase(InventoryLocationType.Station)] + [TestCase(InventoryLocationType.Personnel)] + public async Task Holder_identity_must_belong_to_the_current_department(InventoryLocationType type) + { + var location = new InventoryLocation { DepartmentId = 77, LocationType = (int)type }; + if (type == InventoryLocationType.Unit) + { + location.UnitId = 501; _units.Setup(u => u.GetUnitByIdAsync(501)).ReturnsAsync(new Unit { DepartmentId = 78, UnitId = 501 }); + _resources.Setup(r => r.CanUserViewUnitAsync(_actor.UserId, 501)).ReturnsAsync(true); + } + else if (type == InventoryLocationType.Station) + { + location.GroupId = 501; _groups.Setup(g => g.GetGroupByIdAsync(501, true)).ReturnsAsync(new DepartmentGroup { DepartmentId = 78, DepartmentGroupId = 501 }); + } + else + { + location.UserId = "foreign-member"; _departments.Setup(d => d.GetDepartmentMemberAsync(location.UserId, 77, true)).ReturnsAsync(new DepartmentMember { DepartmentId = 78, UserId = location.UserId }); + _resources.Setup(r => r.CanUserViewPersonAsync(_actor.UserId, location.UserId, 77)).ReturnsAsync(true); + } + await Denied(() => _service.ValidateHolderAsync(_actor, location), "LocationUnavailable", 404); + } + [Test] + public async Task Same_department_holder_still_requires_resource_visibility() + { + var location = new InventoryLocation { DepartmentId = 77, LocationType = (int)InventoryLocationType.Unit, UnitId = 501 }; + _units.Setup(u => u.GetUnitByIdAsync(501)).ReturnsAsync(new Unit { DepartmentId = 77, UnitId = 501 }); + await Denied(() => _service.ValidateHolderAsync(_actor, location), "LocationUnavailable", 404); + _resources.Setup(r => r.CanUserViewUnitAsync(_actor.UserId, 501)).ReturnsAsync(true); + await _service.ValidateHolderAsync(_actor, location); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs new file mode 100644 index 000000000..ac69188b5 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public partial class InventoryDatabaseTests + { + private const string DatabasePrefix = "inventory_verification_"; + private const string MigrationRequest = "00000000-0000-0000-0000-000000000001"; + private readonly DatabaseTypes _type; + private DatabaseTypes _previous; + private string _master, _connection, _database; + private bool _created, _configured; + private ServiceProvider _runner; + public InventoryDatabaseTests(DatabaseTypes type) { _type = type; } + private string Q(string name) => _type == DatabaseTypes.Postgres ? name.ToLowerInvariant() : "[" + name + "]"; + private DbConnection Connect(string connection) => _type == DatabaseTypes.Postgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + private SqlConfiguration Configuration() => _type == DatabaseTypes.Postgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + private IConnectionProvider Connections() + { + var provider = new Mock(); provider.Setup(p => p.Create()).Returns(() => Connect(_connection)); return provider.Object; + } + private InventoryStore Store(IUnitOfWork uow) => new(Connections(), Configuration(), uow, Mock.Of()); + private static T NewRow(int department = 77) where T : InventoryRow, new() => new() + { DepartmentId = department, CreatedBy = "inventory-test-author", CreatedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow, Content = "{}" }; + + [OneTimeSetUp] + public async Task CreateOnlyAnIsolatedInventoryDatabase() + { + var configured = Environment.GetEnvironmentVariable(_type == DatabaseTypes.Postgres ? "RESGRID_CHECKLIST_POSTGRES_TEST_CONNECTION" : "RESGRID_CHECKLIST_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(configured)) Assert.Ignore("Set the checklist test connection for " + _type + " to enable disposable inventory database tests."); + // A configured but inaccessible server is a failing fixture, never an environment skip. + // Refuse application database names; the connection is used only to create a unique disposable database. + if (_type == DatabaseTypes.Postgres) + { + var builder = new NpgsqlConnectionStringBuilder(configured); + if (!string.IsNullOrEmpty(builder.Database) && builder.Database != "postgres" && builder.Database != "template1") throw new InvalidOperationException("Inventory tests require a PostgreSQL administrative database connection."); + builder.Database = "postgres"; builder.IncludeErrorDetail = false; _master = builder.ConnectionString; + } + else + { + var builder = new SqlConnectionStringBuilder(configured); + if (!string.IsNullOrEmpty(builder.InitialCatalog) && !string.Equals(builder.InitialCatalog, "master", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Inventory tests require a SQL Server master connection."); + builder.InitialCatalog = "master"; _master = builder.ConnectionString; + } + _previous = DataConfig.DatabaseType; _configured = true; DataConfig.DatabaseType = _type; + _database = DatabasePrefix + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) { await master.ExecuteAsync("CREATE DATABASE " + _database); _created = true; } + _connection = _type == DatabaseTypes.Postgres ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + await using (var db = Connect(_connection)) + { + var text = _type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; + var identity = _type == DatabaseTypes.Postgres ? "integer GENERATED BY DEFAULT AS IDENTITY" : "int IDENTITY(1,1)"; + var date = _type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + await db.ExecuteAsync($@"CREATE TABLE {Q("Departments")} ({Q("DepartmentId")} int PRIMARY KEY); +INSERT INTO {Q("Departments")} VALUES(77),(88); +CREATE TABLE {Q("DepartmentGroups")} ({Q("DepartmentGroupId")} int PRIMARY KEY, {Q("DepartmentId")} int NOT NULL); +INSERT INTO {Q("DepartmentGroups")} VALUES(123,77),(223,88); +CREATE TABLE {Q("Units")} ({Q("UnitId")} int PRIMARY KEY, {Q("DepartmentId")} int NOT NULL); +ALTER TABLE {Q("Units")} ADD CONSTRAINT {Q("UQ_Units_DepartmentId_UnitId")} UNIQUE ({Q("DepartmentId")},{Q("UnitId")}); +INSERT INTO {Q("Units")} VALUES(12,77),(13,77),(22,88); +CREATE TABLE {Q("AspNetUsers")} ({Q("Id")} {text}(128) PRIMARY KEY); +INSERT INTO {Q("AspNetUsers")} VALUES('inventory-test-author'),('inventory-test-other'); +CREATE TABLE {Q("InventoryTypes")} ({Q("InventoryTypeId")} {identity} PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("Type")} {text}(250) NOT NULL); +CREATE TABLE {Q("Inventories")} ({Q("InventoryId")} {identity} PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("TypeId")} int NOT NULL, {Q("Amount")} decimal(24,6) NOT NULL); +CREATE TABLE {Q("RmsRecordLegalHolds")} ({Q("Id")} {text}(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("ReleasedOn")} {date} NULL); +CREATE TABLE {Q("WorkflowRuns")} ({Q("WorkflowRunId")} {text}(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("TriggerEventType")} int NOT NULL, {Q("InputPayload")} {text}(250)); +CREATE TABLE {Q("WorkflowRunLogs")} ({Q("WorkflowRunLogId")} {text}(36) PRIMARY KEY, {Q("WorkflowRunId")} {text}(36) NOT NULL REFERENCES {Q("WorkflowRuns")}({Q("WorkflowRunId")}), {Q("RenderedOutput")} {text}(250)); +CREATE TABLE {Q("DomainEventOutbox")} ({Q("Id")} {text}(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("ProducerSubsystem")} {text}(64) NOT NULL, {Q("Payload")} {text}(250)); +CREATE TABLE {Q("AuditLogs")} ({Q("Id")} {text}(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL, {Q("LogType")} int NOT NULL, {Q("Content")} {text}(250));"); + } + var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] + { + _type == DatabaseTypes.Postgres ? new M0198_AddInventoryModernizationPg() : new M0198_AddInventoryModernization(), + _type == DatabaseTypes.Postgres ? new M0199_FenceLegacyInventoryWritesPg() : new M0199_FenceLegacyInventoryWrites() + }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => + { + if (_type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); r.WithGlobalConnectionString(_connection); + }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + + [SetUp] + public async Task ClearOnlyThisFixturesInventoryRows() + { + await using var db = Connect(_connection); + foreach (var table in new[] { "WorkflowRunLogs", "WorkflowRuns", "DomainEventOutbox", "AuditLogs", "RmsRecordLegalHolds" }) + await db.ExecuteAsync("DELETE FROM " + Q(table)); + foreach (var table in new[] { "InventoryTransferItems", "InventoryTransactions", "InventoryIssuances", "InventoryTransfers", "InventoryStocks", "InventoryKitItems" }) + await db.ExecuteAsync("DELETE FROM " + Q(table)); + await db.ExecuteAsync($"UPDATE {Q("InventoryAssets")} SET {Q("CurrentLocationId")}=NULL"); + foreach (var table in new[] { "InventoryLocations", "InventoryAssets", "InventoryLots", "InventoryItems", "InventoryCategories", "InventoryKits", "InventoryOperations", "Inventories", "InventoryTypes" }) + await db.ExecuteAsync("DELETE FROM " + Q(table)); + } + + [OneTimeTearDown] + public async Task RemoveOnlyThisFixturesDatabase() + { + _runner?.Dispose(); if (_configured) DataConfig.DatabaseType = _previous; + if (!_created) return; + if (!_database.StartsWith(DatabasePrefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(DatabasePrefix.Length), "N", out _)) throw new InvalidOperationException("Unexpected inventory fixture database name."); + if (_type == DatabaseTypes.Postgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(_type == DatabaseTypes.Postgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + + private async Task WriteAsync(Func work, int department = 77) + { + using var uow = new UnitOfWork(Connections()); var store = Store(uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(department); + try { await work(store); uow.CommitChanges(); } catch { uow.DiscardChanges(); throw; } + } + private async Task RejectAsync(Func work) + { + using var uow = new UnitOfWork(Connections()); var store = Store(uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + try { await FluentActions.Awaiting(() => work(store)).Should().ThrowAsync(); } finally { uow.DiscardChanges(); } + } + private async Task<(InventoryItem Item, InventoryLocation Location)> SeedAsync(int department = 77) + { + var item = NewRow(department); var location = NewRow(department); + await WriteAsync(async store => { await store.InsertAsync(item); await store.InsertAsync(location); }, department); return (item, location); + } + private static InventoryTransaction Posting(InventoryItem item, InventoryLocation from, InventoryLocation to, decimal quantity, InventoryTransactionType type = InventoryTransactionType.Receive) + { + var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.FromLocationId = from?.Id; row.ToLocationId = to?.Id; + row.Quantity = quantity; row.TransactionType = (int)type; row.OccurredOn = DateTime.UtcNow; return row; + } + private async Task MarkMigratedAsync(int department = 77) + { + var marker = NewRow(department); marker.RequestId = MigrationRequest; marker.State = 2; + await WriteAsync(store => store.InsertAsync(marker), department); + } + private async Task InsertLegacyTypeAsync(DbConnection db, int department) + { + return await db.ExecuteScalarAsync($"INSERT INTO {Q("InventoryTypes")} ({Q("DepartmentId")},{Q("Type")}) VALUES(@department,'synthetic legacy type') {(_type == DatabaseTypes.Postgres ? "RETURNING inventorytypeid" : "; SELECT CAST(SCOPE_IDENTITY() AS int);")}", new { department }); + } + private Task LegacyCountAsync(DbConnection db, string table, int department) => db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q(table)} WHERE {Q("DepartmentId")}=@department", new { department }); + + private async Task SeedCleanupEvidenceAsync(int department = 77) + { + await using var db = Connect(_connection); + var legacyType = await InsertLegacyTypeAsync(db, department); + await db.ExecuteAsync($"INSERT INTO {Q("Inventories")} ({Q("DepartmentId")},{Q("TypeId")},{Q("Amount")}) VALUES(@department,@legacyType,1)", new { department, legacyType }); + T Protected() where T : InventoryRow, new() + { + var row = NewRow(department); row.IsProtected = true; row.Content = "SYNTHETIC-OPAQUE-PROTECTED-EVIDENCE"; return row; + } + var category = Protected(); var subcategory = Protected(); subcategory.ParentCategoryId = category.Id; + var item = Protected(); item.CategoryId = subcategory.Id; + var facility = Protected(); var child = Protected(); child.ParentLocationId = facility.Id; + var lot = Protected(); lot.ItemId = item.Id; lot.ReceivedOn = DateTime.UtcNow; + var containerAsset = Protected(); containerAsset.ItemId = item.Id; containerAsset.LotId = lot.Id; containerAsset.CurrentLocationId = child.Id; + var container = Protected(); container.LocationType = (int)InventoryLocationType.Container; container.ContainerAssetId = containerAsset.Id; + var asset = Protected(); asset.ItemId = item.Id; asset.LotId = lot.Id; asset.CurrentLocationId = container.Id; + var stock = Protected(); stock.ItemId = item.Id; stock.LocationId = container.Id; stock.LotId = lot.Id; stock.Quantity = 0.123456m; + var operation = Protected(); operation.RequestId = MigrationRequest; operation.State = 2; + var issuance = Protected(); issuance.ItemId = item.Id; issuance.AssetId = asset.Id; issuance.LotId = lot.Id; + issuance.LocationId = container.Id; issuance.IssuedToUserId = "inventory-test-author"; issuance.Quantity = 1; issuance.IssuedOn = DateTime.UtcNow; + var transaction = Protected(); transaction.ItemId = item.Id; transaction.AssetId = asset.Id; transaction.LotId = lot.Id; + transaction.OperationId = operation.Id; transaction.TransactionType = (int)InventoryTransactionType.Transfer; transaction.FromLocationId = child.Id; + transaction.ToLocationId = container.Id; transaction.Quantity = 1; transaction.OccurredOn = DateTime.UtcNow; transaction.IssuanceId = issuance.Id; + var reversal = Protected(); reversal.ItemId = item.Id; reversal.TransactionType = (int)InventoryTransactionType.Return; + reversal.Quantity = 1; reversal.OccurredOn = DateTime.UtcNow; reversal.ReversesTransactionId = transaction.Id; reversal.FromLocationId = container.Id; reversal.ToLocationId = child.Id; + var transfer = Protected(); transfer.OperationId = operation.Id; transfer.FromLocationId = child.Id; transfer.ToLocationId = container.Id; + var transferItem = Protected(); transferItem.TransferId = transfer.Id; transferItem.TransactionId = transaction.Id; + transferItem.ItemId = item.Id; transferItem.AssetId = asset.Id; transferItem.LotId = lot.Id; transferItem.Quantity = 1; + var kit = Protected(); var kitItem = Protected(); kitItem.KitId = kit.Id; kitItem.ItemId = item.Id; kitItem.Quantity = 1; + await WriteAsync(async store => + { + await store.InsertAsync(category); await store.InsertAsync(subcategory); await store.InsertAsync(item); + await store.InsertAsync(facility); await store.InsertAsync(child); await store.InsertAsync(lot); await store.InsertAsync(containerAsset); + await store.InsertAsync(container); await store.InsertAsync(asset); await store.InsertAsync(stock); await store.InsertAsync(operation); + await store.InsertAsync(issuance); await store.InsertAsync(transaction); await store.InsertAsync(reversal); + await store.InsertAsync(transfer); await store.InsertAsync(transferItem); await store.InsertAsync(kit); await store.InsertAsync(kitItem); + }, department); + foreach (var trigger in InventoryWorkflowPayload.Triggers.Concat(new[] { 0, 67, 70 })) + { + var run = Guid.NewGuid().ToString("D"); var log = Guid.NewGuid().ToString("D"); var outbox = Guid.NewGuid().ToString("D"); + var producer = trigger == 0 ? "Records" : trigger == 67 ? "Checklists" : trigger == 70 ? "WorkOrders" : "Inventory"; + await db.ExecuteAsync($@"INSERT INTO {Q("WorkflowRuns")} VALUES(@run,@department,@trigger,'SYNTHETIC-OPAQUE-WORKFLOW'); +INSERT INTO {Q("WorkflowRunLogs")} VALUES(@log,@run,'SYNTHETIC-OPAQUE-LOG'); +INSERT INTO {Q("DomainEventOutbox")} VALUES(@outbox,@department,@producer,'SYNTHETIC-OPAQUE-EVENT');", new { run, log, outbox, department, trigger, producer }); + } + await db.ExecuteAsync($"INSERT INTO {Q("AuditLogs")} VALUES(@inventory,@department,@inventoryType,'SYNTHETIC-INVENTORY-AUDIT'),(@other,@department,0,'SYNTHETIC-OTHER-AUDIT')", + new { inventory = Guid.NewGuid().ToString("D"), other = Guid.NewGuid().ToString("D"), department, inventoryType = (int)AuditLogTypes.InventoryChanged }); + } + + private async Task> CleanupSnapshotAsync(DbConnection db, int? department = null) + { + var snapshot = new Dictionary(); + foreach (var table in InventoryTables.All.Values.Concat(new[] { "WorkflowRuns", "WorkflowRunLogs", "DomainEventOutbox", "AuditLogs", "RmsRecordLegalHolds", "Inventories", "InventoryTypes" })) + { + var scope = !department.HasValue ? "" : table == "WorkflowRunLogs" + ? $" WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@department)" + : $" WHERE {Q("DepartmentId")}=@department"; + snapshot[table] = JsonConvert.SerializeObject(await db.QueryAsync($"SELECT * FROM {Q(table)}{scope} ORDER BY 1,2", new { department })); + } + return snapshot; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs new file mode 100644 index 000000000..1edd0f82c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs @@ -0,0 +1,368 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Inventories; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class InventoryDatabaseTests + { + [Test, Order(0)] + public void Empty_schema_and_writer_fences_can_reverse_and_reapply() + { + var runner = _runner.GetRequiredService(); runner.MigrateDown(0); runner.MigrateUp(); runner.MigrateUp(); + } + + [Test] + public async Task Schema_covers_every_persisted_model_column_and_six_decimal_quantities() + { + await using var db = Connect(_connection); + InventoryTables.All.Should().HaveCount(13); + foreach (var binding in InventoryTables.All) + { + var actual = (await db.QueryAsync("SELECT LOWER(column_name) FROM information_schema.columns WHERE LOWER(table_name)=@table", new { table = binding.Value.ToLowerInvariant() })).ToHashSet(); + var expected = binding.Key.GetProperties().Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute))).Select(p => p.Name.ToLowerInvariant()); + actual.Should().Contain(expected, binding.Value + " must store every mapped property"); + foreach (var column in binding.Key.GetProperties().Where(p => p.PropertyType == typeof(decimal) || p.PropertyType == typeof(decimal?))) + { + var query = " FROM information_schema.columns WHERE LOWER(table_name)=@table AND LOWER(column_name)=@column"; + var parameters = new { table = binding.Value.ToLowerInvariant(), column = column.Name.ToLowerInvariant() }; + (await db.ExecuteScalarAsync("SELECT numeric_precision" + query, parameters)).Should().Be(24); + (await db.ExecuteScalarAsync("SELECT numeric_scale" + query, parameters)).Should().Be(6); + } + } + } + + [Test] + public async Task Ledger_allocates_bigint_identity_keeps_guid_and_round_trips_six_decimals() + { + var seed = await SeedAsync(); var row = Posting(seed.Item, null, seed.Location, 0.123456m); + row.ToQuantityBefore = 10.111111m; row.ToQuantityAfter = 10.234567m; + row.Content = new string('x', 18000); await WriteAsync(store => store.InsertAsync(row)); + row.EntryId.Should().BeGreaterThan(0); Guid.TryParseExact(row.Id, "D", out _).Should().BeTrue(); + using var uow = new UnitOfWork(Connections()); var store = Store(uow); + var saved = await store.GetAsync(77, row.Id); + saved.EntryId.Should().Be(row.EntryId); saved.Quantity.Should().Be(0.123456m); saved.ToQuantityAfter.Should().Be(10.234567m); saved.Content.Should().HaveLength(18000); + (await store.GetAsync(88, row.Id)).Should().BeNull(); + await uow.CreateOrGetConnectionAsync(); row.Revision++; + await FluentActions.Awaiting(() => store.UpdateAsync(row, 1)).Should().ThrowAsync(); uow.DiscardChanges(); + } + + [Test] + public async Task Transaction_queries_filter_before_paging_and_order_by_ledger_identity() + { + var seed = await SeedAsync(); var unrelated = await SeedAsync(); var foreign = await SeedAsync(88); + var asset = NewRow(); asset.ItemId = seed.Item.Id; asset.CurrentLocationId = seed.Location.Id; + var matches = Enumerable.Range(0, 502).Select(i => + { + var row = Posting(seed.Item, i % 2 == 0 ? null : seed.Location, i % 2 == 0 ? seed.Location : null, 1, + i % 2 == 0 ? InventoryTransactionType.Receive : InventoryTransactionType.Consume); + row.AssetId = asset.Id; row.OccurredOn = DateTime.UtcNow.AddMinutes(-i); return row; + }).ToList(); + await WriteAsync(async store => + { + await store.InsertAsync(asset); + foreach (var row in matches) await store.InsertAsync(row); + for (var i = 0; i < 501; i++) await store.InsertAsync(Posting(unrelated.Item, null, unrelated.Location, 1)); + }); + await WriteAsync(store => store.InsertAsync(Posting(foreign.Item, null, foreign.Location, 1)), 88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.QueryAsync(77, new InventoryQuery())).Should().HaveCount(501).And.OnlyContain(t => t.ItemId == unrelated.Item.Id); + var filter = new InventoryQuery { ItemId = seed.Item.Id, AssetId = asset.Id, LocationId = seed.Location.Id }; + var first = await repository.QueryAsync(77, filter); + var second = await repository.QueryAsync(77, filter, 500); + first.Select(t => t.Id).Should().Equal(matches.AsEnumerable().Reverse().Take(501).Select(t => t.Id)); + second.Select(t => t.Id).Should().Equal(matches.AsEnumerable().Reverse().Skip(500).Select(t => t.Id)); + first.Should().Contain(t => t.FromLocationId == seed.Location.Id).And.Contain(t => t.ToLocationId == seed.Location.Id); + (await repository.QueryAsync(88, filter)).Should().BeEmpty(); + } + + [Test] + public async Task Typed_stock_asset_kit_and_person_filters_preserve_tenant_and_archive_boundaries() + { + var seed = await SeedAsync(); var other = await SeedAsync(); var foreign = await SeedAsync(88); + var stock = NewRow(); stock.ItemId = seed.Item.Id; stock.LocationId = seed.Location.Id; stock.Quantity = 3; + var otherStock = NewRow(); otherStock.ItemId = seed.Item.Id; otherStock.LocationId = other.Location.Id; otherStock.Quantity = 4; + var asset = NewRow(); asset.ItemId = seed.Item.Id; asset.CurrentLocationId = seed.Location.Id; + var archivedAsset = NewRow(); archivedAsset.ItemId = seed.Item.Id; archivedAsset.CurrentLocationId = seed.Location.Id; archivedAsset.IsDeleted = true; + var kit = NewRow(); var otherKit = NewRow(); + var line = NewRow(); line.KitId = kit.Id; line.ItemId = seed.Item.Id; line.Quantity = 1; + var archivedLine = NewRow(); archivedLine.KitId = kit.Id; archivedLine.ItemId = other.Item.Id; archivedLine.Quantity = 1; archivedLine.IsDeleted = true; + var otherLine = NewRow(); otherLine.KitId = otherKit.Id; otherLine.ItemId = seed.Item.Id; otherLine.Quantity = 1; + var issuance = NewRow(); issuance.ItemId = seed.Item.Id; issuance.LocationId = seed.Location.Id; + issuance.IssuedToUserId = "inventory-test-author"; issuance.IssuedOn = DateTime.UtcNow; issuance.Quantity = 1; + var unitIssuance = NewRow(); unitIssuance.ItemId = seed.Item.Id; unitIssuance.LocationId = seed.Location.Id; + unitIssuance.IssuedToUnitId = 12; unitIssuance.IssuedOn = DateTime.UtcNow; unitIssuance.Quantity = 1; + await WriteAsync(async store => + { + await store.InsertAsync(stock); await store.InsertAsync(otherStock); await store.InsertAsync(asset); await store.InsertAsync(archivedAsset); + await store.InsertAsync(kit); await store.InsertAsync(otherKit); await store.InsertAsync(line); await store.InsertAsync(archivedLine); await store.InsertAsync(otherLine); + await store.InsertAsync(issuance); await store.InsertAsync(unitIssuance); + }); + var foreignIssuance = NewRow(88); foreignIssuance.ItemId = foreign.Item.Id; foreignIssuance.LocationId = foreign.Location.Id; + foreignIssuance.IssuedToUserId = "inventory-test-author"; foreignIssuance.IssuedOn = DateTime.UtcNow; foreignIssuance.Quantity = 1; + await WriteAsync(store => store.InsertAsync(foreignIssuance), 88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.QueryAsync(77, new InventoryQuery { ItemId = seed.Item.Id, LocationId = seed.Location.Id })).Select(r => r.Id).Should().Equal(stock.Id); + (await repository.QueryAsync(77, new InventoryQuery { ItemId = seed.Item.Id })).Select(r => r.Id).Should().Equal(asset.Id); + (await repository.QueryAsync(77, new InventoryQuery { KitId = kit.Id })).Select(r => r.Id).Should().Equal(line.Id); + (await repository.QueryAsync(77, new InventoryQuery { KitId = otherKit.Id, ItemId = seed.Item.Id })).Select(r => r.Id).Should().Equal(otherLine.Id); + (await repository.QueryAsync(77, new InventoryQuery { IssuedToUserId = "inventory-test-author" })).Select(r => r.Id).Should().Equal(issuance.Id); + (await repository.QueryAsync(88, new InventoryQuery { KitId = kit.Id })).Should().BeEmpty(); + } + + [Test] + public async Task Unsupported_query_filters_are_rejected_and_filter_values_cannot_become_sql() + { + var seed = await SeedAsync(); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + await FluentActions.Awaiting(() => repository.QueryAsync(77, new InventoryQuery { ItemId = seed.Item.Id })).Should().ThrowAsync(); + await FluentActions.Awaiting(() => repository.QueryAsync(77, new InventoryQuery { AssetId = Guid.NewGuid().ToString("D") })).Should().ThrowAsync(); + await FluentActions.Awaiting(() => repository.QueryAsync(77, new InventoryQuery(), -1)).Should().ThrowAsync(); + (await repository.QueryAsync(77, new InventoryQuery { ItemId = "' OR 1=1; DELETE FROM InventoryItems;--" })).Should().BeEmpty(); + (await repository.GetAsync(77, seed.Item.Id)).Should().NotBeNull(); + } + + [Test] + public async Task Inventory_foreign_keys_reject_cross_tenant_locations_and_mismatched_item_lots_and_assets() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); var other = NewRow(); + var lot = NewRow(); lot.ItemId = seed.Item.Id; lot.ReceivedOn = DateTime.UtcNow; + var asset = NewRow(); asset.ItemId = seed.Item.Id; asset.CurrentLocationId = seed.Location.Id; asset.LotId = lot.Id; + await WriteAsync(async store => { await store.InsertAsync(other); await store.InsertAsync(lot); await store.InsertAsync(asset); }); + await RejectAsync(store => { var row = NewRow(); row.ItemId = seed.Item.Id; row.LocationId = foreign.Location.Id; row.Quantity = 1; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = NewRow(); row.ItemId = other.Id; row.LocationId = seed.Location.Id; row.LotId = lot.Id; row.Quantity = 1; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = Posting(other, null, seed.Location, 1); row.AssetId = asset.Id; return store.InsertAsync(row); }); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("InventoryItems")} WHERE {Q("Id")}=@Id", new { seed.Item.Id })).Should().ThrowAsync(); + } + + [Test] + public async Task Typed_holder_checks_and_tenant_unit_foreign_key_reject_invalid_locations() + { + await RejectAsync(store => { var row = NewRow(); row.LocationType = (int)InventoryLocationType.Unit; row.UnitId = 12; row.GroupId = 123; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = NewRow(); row.LocationType = (int)InventoryLocationType.Unit; row.UnitId = 22; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = NewRow(); row.LocationType = (int)InventoryLocationType.Personnel; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = NewRow(); row.ParentLocationId = row.Id; return store.InsertAsync(row); }); + var unit = NewRow(); unit.LocationType = (int)InventoryLocationType.Unit; unit.UnitId = 12; + await WriteAsync(store => store.InsertAsync(unit)); + await RejectAsync(store => { var duplicate = NewRow(); duplicate.LocationType = unit.LocationType; duplicate.UnitId = unit.UnitId; return store.InsertAsync(duplicate); }); + } + + [Test] + public async Task Container_links_and_issuance_holder_constraints_preserve_tenant_boundaries() + { + var seed = await SeedAsync(); + var asset = NewRow(); asset.ItemId = seed.Item.Id; asset.CurrentLocationId = seed.Location.Id; + await WriteAsync(store => store.InsertAsync(asset)); + var container = NewRow(); container.LocationType = (int)InventoryLocationType.Container; container.ContainerAssetId = asset.Id; + await WriteAsync(store => store.InsertAsync(container)); + await RejectAsync(store => { var row = NewRow(88); row.LocationType = (int)InventoryLocationType.Container; row.ContainerAssetId = asset.Id; return store.InsertAsync(row); }); + await RejectAsync(store => { var row = NewRow(); row.ItemId = seed.Item.Id; row.AssetId = asset.Id; row.LocationId = seed.Location.Id; row.Quantity = 1; row.IssuedOn = DateTime.UtcNow; row.IssuedToUnitId = 12; row.IssuedToUserId = "inventory-test-author"; return store.InsertAsync(row); }); + var issuance = NewRow(); issuance.ItemId = seed.Item.Id; issuance.AssetId = asset.Id; issuance.LocationId = seed.Location.Id; issuance.Quantity = 1; issuance.IssuedOn = DateTime.UtcNow; issuance.IssuedToUnitId = 12; + await WriteAsync(store => store.InsertAsync(issuance)); + await RejectAsync(store => { var duplicate = NewRow(); duplicate.ItemId = seed.Item.Id; duplicate.AssetId = asset.Id; duplicate.LocationId = seed.Location.Id; duplicate.Quantity = 1; duplicate.IssuedOn = DateTime.UtcNow; duplicate.IssuedToUnitId = 13; return store.InsertAsync(duplicate); }); + } + + [Test] + public async Task Concurrent_missing_stock_upserts_keep_one_exact_balance_and_rollback_restores_it() + { + var seed = await SeedAsync(); + async Task Add(decimal delta) => await WriteAsync(async store => { await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, delta, "inventory-test-author"); }); + await Task.WhenAll(Add(0.123456m), Add(1.111111m)); + using var uow = new UnitOfWork(Connections()); var store = Store(uow); + var balance = (await store.RelatedAsync(77, "ItemId", seed.Item.Id)).Single(); balance.Quantity.Should().Be(1.234567m); + await uow.CreateOrGetConnectionAsync(); await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, -1m, "inventory-test-author"); + var discarded = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); await store.InsertAsync(discarded); uow.DiscardChanges(); + (await store.GetAsync(77, discarded.Id)).Should().BeNull(); + (await store.RelatedAsync(77, "ItemId", seed.Item.Id)).Single().Quantity.Should().Be(1.234567m); + } + + [Test] + public async Task Stock_rebuild_expands_transfer_legs_preserves_lots_and_excludes_serialized_assets() + { + var seed = await SeedAsync(); var destination = NewRow(); var lot = NewRow(); lot.ItemId = seed.Item.Id; lot.ReceivedOn = DateTime.UtcNow; + var serialized = NewRow(); serialized.TrackingMode = (int)InventoryTrackingMode.Serialized; + await WriteAsync(async store => + { + await store.InsertAsync(destination); await store.InsertAsync(lot); await store.InsertAsync(serialized); + var received = Posting(seed.Item, null, seed.Location, 10.123456m); received.LotId = lot.Id; await store.InsertAsync(received); + var consumed = Posting(seed.Item, seed.Location, null, 0.123456m, InventoryTransactionType.Consume); consumed.LotId = lot.Id; await store.InsertAsync(consumed); + var transferred = Posting(seed.Item, seed.Location, destination, 3m, InventoryTransactionType.Transfer); transferred.LotId = lot.Id; await store.InsertAsync(transferred); + var asset = NewRow(); asset.ItemId = serialized.Id; asset.CurrentLocationId = seed.Location.Id; await store.InsertAsync(asset); + var assetReceipt = Posting(serialized, null, seed.Location, 1); assetReceipt.AssetId = asset.Id; await store.InsertAsync(assetReceipt); + await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, lot.Id, 999m, "inventory-test-author"); + await store.RebuildStocksAsync(77); + }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + var stocks = await repository.ListAsync(77); stocks.Should().HaveCount(2); stocks.Sum(s => s.Quantity).Should().Be(10m); + stocks.Single(s => s.LocationId == seed.Location.Id).Quantity.Should().Be(7m); stocks.Single(s => s.LocationId == destination.Id).Quantity.Should().Be(3m); + stocks.Should().OnlyContain(s => s.ItemId == seed.Item.Id && s.LotId == lot.Id); + } + + [Test] + public async Task Request_operation_line_and_legacy_id_uniqueness_survive_retries() + { + var seed = await SeedAsync(); var op = NewRow(); op.RequestId = Guid.NewGuid().ToString("D"); + var legacyItem = NewRow(); legacyItem.LegacyInventoryTypeId = 456; + var transaction = Posting(seed.Item, null, seed.Location, 1); transaction.OperationId = op.Id; transaction.LineNumber = 0; transaction.LegacyInventoryId = 789; + await WriteAsync(async store => { await store.InsertAsync(op); await store.InsertAsync(legacyItem); await store.InsertAsync(transaction); }); + await RejectAsync(store => { var duplicate = NewRow(); duplicate.RequestId = op.RequestId; return store.InsertAsync(duplicate); }); + await RejectAsync(store => { var duplicate = Posting(seed.Item, null, seed.Location, 1); duplicate.OperationId = op.Id; return store.InsertAsync(duplicate); }); + await RejectAsync(store => { var duplicate = NewRow(); duplicate.LegacyInventoryTypeId = 456; return store.InsertAsync(duplicate); }); + await RejectAsync(store => { var duplicate = Posting(seed.Item, null, seed.Location, 1); duplicate.LegacyInventoryId = 789; return store.InsertAsync(duplicate); }); + await WriteAsync(store => { var other = NewRow(88); other.RequestId = op.RequestId; return store.InsertAsync(other); }, 88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.LegacyItemAsync(77, 456)).Id.Should().Be(legacyItem.Id); (await repository.LegacyTransactionAsync(77, 789)).Id.Should().Be(transaction.Id); + (await repository.LegacyTransactionAsync(88, 789)).Should().BeNull(); + } + + [TestCase("Inventories"), TestCase("InventoryTypes")] + public async Task Cutover_blocks_multirow_mutations_and_both_department_move_directions_but_preserves_reads(string table) + { + await using var db = Connect(_connection); var type77 = await InsertLegacyTypeAsync(db, 77); var type88 = await InsertLegacyTypeAsync(db, 88); + await db.ExecuteAsync($"INSERT INTO {Q("Inventories")} ({Q("DepartmentId")},{Q("TypeId")},{Q("Amount")}) VALUES(77,@type77,1),(88,@type88,2)", new { type77, type88 }); + var changedColumn = Q(table == "Inventories" ? "Amount" : "Type"); + await db.ExecuteAsync($"UPDATE {Q(table)} SET {changedColumn}={changedColumn} WHERE {Q("DepartmentId")}=77"); + await MarkMigratedAsync(); + await FluentActions.Awaiting(() => db.ExecuteAsync($"UPDATE {Q(table)} SET {changedColumn}={changedColumn} WHERE {Q("DepartmentId")} IN (77,88)")).Should().ThrowAsync(); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")} IN (77,88)")).Should().ThrowAsync(); + await FluentActions.Awaiting(() => db.ExecuteAsync($"UPDATE {Q(table)} SET {Q("DepartmentId")}=88 WHERE {Q("DepartmentId")}=77")).Should().ThrowAsync(); + await FluentActions.Awaiting(() => db.ExecuteAsync($"UPDATE {Q(table)} SET {Q("DepartmentId")}=77 WHERE {Q("DepartmentId")}=88")).Should().ThrowAsync(); + var insert = table == "Inventories" ? $"INSERT INTO {Q(table)} ({Q("DepartmentId")},{Q("TypeId")},{Q("Amount")}) VALUES(88,@type88,3),(77,@type77,3)" + : $"INSERT INTO {Q(table)} ({Q("DepartmentId")},{Q("Type")}) VALUES(88,'synthetic'),(77,'synthetic')"; + await FluentActions.Awaiting(() => db.ExecuteAsync(insert, new { type77, type88 })).Should().ThrowAsync(); + (await LegacyCountAsync(db, table, 77)).Should().Be(1); (await LegacyCountAsync(db, table, 88)).Should().Be(1); + await db.ExecuteAsync($"UPDATE {Q(table)} SET {changedColumn}={changedColumn} WHERE {Q("DepartmentId")}=88"); + // Teardown removes the department's modern marker first, without disabling any trigger globally. + await db.ExecuteAsync($"DELETE FROM {Q("InventoryOperations")} WHERE {Q("DepartmentId")}=77"); + await db.ExecuteAsync($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=77"); + (await LegacyCountAsync(db, table, 77)).Should().Be(0); (await LegacyCountAsync(db, table, 88)).Should().Be(1); + } + + [Test] + public async Task Legacy_writer_waits_for_cutover_department_lock_then_observes_committed_marker() + { + await using var legacy = Connect(_connection); await legacy.OpenAsync(); + using var cutover = new UnitOfWork(Connections()); var store = Store(cutover); + await cutover.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + var marker = NewRow(); marker.RequestId = MigrationRequest; marker.State = 2; await store.InsertAsync(marker); + var pending = legacy.ExecuteAsync($"INSERT INTO {Q("InventoryTypes")} ({Q("DepartmentId")},{Q("Type")}) VALUES(77,'concurrent synthetic')", commandTimeout: 10); + try + { + await Task.WhenAny(pending, Task.Delay(150)); pending.IsCompleted.Should().BeFalse("the migration owns the shared department lock"); + cutover.CommitChanges(); await FluentActions.Awaiting(async () => { await pending; }).Should().ThrowAsync(); + } + finally { if (cutover.Transaction != null) cutover.DiscardChanges(); } + (await LegacyCountAsync(legacy, "InventoryTypes", 77)).Should().Be(0); + } + + [Test] + public async Task Legacy_commit_before_cutover_is_visible_after_the_shared_department_lock() + { + await using var legacy = Connect(_connection); await legacy.OpenAsync(); await using var transaction = await legacy.BeginTransactionAsync(); + await legacy.ExecuteAsync($"INSERT INTO {Q("InventoryTypes")} ({Q("DepartmentId")},{Q("Type")}) VALUES(77,'pre-cutover synthetic')", transaction: transaction); + using var cutover = new UnitOfWork(Connections()); var store = Store(cutover); await cutover.CreateOrGetConnectionAsync(); + var pending = store.LockDepartmentAsync(77); + await Task.WhenAny(pending, Task.Delay(150)); pending.IsCompleted.Should().BeFalse("the legacy mutation holds the same department lock until commit"); + await transaction.CommitAsync(); await pending; + (await cutover.Connection.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("InventoryTypes")} WHERE {Q("DepartmentId")}=77", transaction: cutover.Transaction)).Should().Be(1); + cutover.DiscardChanges(); + } + + [Test] + public async Task Legacy_repeatable_read_uses_sql_locking_or_postgres_explicit_fail_closed_policy() + { + await using var db = Connect(_connection); await db.OpenAsync(); await using var transaction = await db.BeginTransactionAsync(IsolationLevel.RepeatableRead); + Func insert = async () => { await db.ExecuteAsync($"INSERT INTO {Q("InventoryTypes")} ({Q("DepartmentId")},{Q("Type")}) VALUES(77,'isolation synthetic')", transaction: transaction); }; + if (_type == DatabaseTypes.Postgres) await insert.Should().ThrowAsync(); else await insert(); + await transaction.RollbackAsync(); + } + + [Test] + public async Task Populated_migration_refuses_rollback_without_erasing_inventory_evidence() + { + var seed = await SeedAsync(); var runner = _runner.GetRequiredService(); + try { FluentActions.Invoking(() => runner.MigrateDown(0)).Should().Throw(); } + finally { _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); } + using var uow = new UnitOfWork(Connections()); (await Store(uow).GetAsync(77, seed.Item.Id)).Should().NotBeNull(); + } + + [Test] + public async Task Department_cleanup_refuses_active_legal_holds_without_changing_inventory_or_shared_evidence() + { + await SeedCleanupEvidenceAsync(); await SeedCleanupEvidenceAsync(88); + await using var db = Connect(_connection); await db.OpenAsync(); + await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHolds")} VALUES(@id,77,NULL)", new { id = Guid.NewGuid().ToString("D") }); + var before = await CleanupSnapshotAsync(db); + await using (var held = await db.BeginTransactionAsync()) + { + await FluentActions.Awaiting(() => ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, held, 77, _type)) + .Should().ThrowAsync().WithMessage("*legal hold*"); + await held.CommitAsync(); // Refusal must precede every mutation, even if the caller commits. + } + (await CleanupSnapshotAsync(db)).Should().BeEquivalentTo(before); + await FluentActions.Awaiting(() => InsertLegacyTypeAsync(db, 77)).Should().ThrowAsync(); + } + + [Test] + public async Task Department_cleanup_rollback_restores_every_inventory_row_history_and_legacy_writer_fence() + { + await SeedCleanupEvidenceAsync(); await SeedCleanupEvidenceAsync(88); + await using var db = Connect(_connection); await db.OpenAsync(); + var before = await CleanupSnapshotAsync(db); + await using (var rollback = await db.BeginTransactionAsync()) + { + await ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, rollback, 77, _type); + foreach (var table in InventoryTables.All.Values) + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q(table)} WHERE {Q("DepartmentId")}=77", transaction: rollback)).Should().Be(0, table); + await rollback.RollbackAsync(); + } + (await CleanupSnapshotAsync(db)).Should().BeEquivalentTo(before); + await FluentActions.Awaiting(() => InsertLegacyTypeAsync(db, 77)).Should().ThrowAsync(); + } + + [Test] + public async Task Department_cleanup_purges_inventory_child_first_and_preserves_other_producers_and_departments() + { + await SeedCleanupEvidenceAsync(); await SeedCleanupEvidenceAsync(88); + await using var db = Connect(_connection); await db.OpenAsync(); + await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHolds")} VALUES(@released,77,@now),(@foreign,88,NULL)", + new { released = Guid.NewGuid().ToString("D"), foreign = Guid.NewGuid().ToString("D"), now = DateTime.UtcNow }); + var foreignBefore = await CleanupSnapshotAsync(db, 88); + foreach (var table in InventoryTables.All.Values) + (await LegacyCountAsync(db, table, 77)).Should().BePositive("the test must exercise deletion of " + table); + await using (var commit = await db.BeginTransactionAsync()) + { + // No ChecklistDefinitions table exists in this fixture: Inventory cleanup must still run. + await ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, commit, 77, _type); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("Inventories")} WHERE {Q("DepartmentId")}=77", transaction: commit)).Should().Be(1); + // Legacy data is retained until its separate parent-deletion stage, in the same transaction. + await db.ExecuteAsync($"DELETE FROM {Q("Inventories")} WHERE {Q("DepartmentId")}=77; DELETE FROM {Q("InventoryTypes")} WHERE {Q("DepartmentId")}=77", transaction: commit); + await commit.CommitAsync(); + } + foreach (var table in InventoryTables.All.Values.Concat(new[] { "Inventories", "InventoryTypes" })) + (await LegacyCountAsync(db, table, 77)).Should().Be(0, table); + (await CleanupSnapshotAsync(db, 88)).Should().BeEquivalentTo(foreignBefore); + (await db.QueryAsync($"SELECT {Q("TriggerEventType")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=77")) + .Should().BeEquivalentTo(new[] { 0, 67, 70 }); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("WorkflowRunLogs")} WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=77)")).Should().Be(3); + (await db.QueryAsync($"SELECT {Q("ProducerSubsystem")} FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=77")) + .Should().BeEquivalentTo(new[] { "Records", "Checklists", "WorkOrders" }); + (await db.QueryAsync($"SELECT {Q("LogType")} FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=77")).Should().BeEquivalentTo(new[] { 0 }); + (await LegacyCountAsync(db, "RmsRecordLegalHolds", 77)).Should().Be(1, "retention records belong to the caller's separate retention policy"); + await FluentActions.Awaiting(() => InsertLegacyTypeAsync(db, 88)).Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs new file mode 100644 index 000000000..ec425b6ba --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class GdprExportProtectedDataTests + { + private static IInventoryStore EmptyInventory() + { + var store = new Mock(); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + return store.Object; + } + + private void UseInventoryExport(IInventoryStore store, bool enforced = false, bool policyFailure = false) + { + var policy = new Mock(); + if (policyFailure) policy.Setup(p => p.IsProtectionEnforcedAsync(DeptId)).ThrowsAsync(new InvalidOperationException("Synthetic policy failure")); + else policy.Setup(p => p.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(enforced); + var reminders = new Mock(); + reminders.SetReturnsDefault(Task.FromResult(new List())); + _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, + _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, + _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, + _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), + new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), policy.Object)), + reminders.Object, EmptyWorkOrders(), store); + } + + private static T InventoryExportRow(string creator = UserId, int department = DeptId, string content = "SUBJECT-EVIDENCE") where T : InventoryRow, new() + => new() { DepartmentId = department, CreatedBy = creator, Content = content }; + + private static void InventoryExportPages(IInventoryStore store, params T[] rows) where T : InventoryRow + => Mock.Get(store).Setup(s => s.ListAsync(DeptId, It.IsAny())) + .ReturnsAsync((int department, int skip) => rows.Skip(skip).Take(501).ToList()); + + [Test] + public async Task Inventory_export_includes_subject_relationships_and_excludes_unrelated_or_foreign_candidates() + { + var store = EmptyInventory(); + var location = InventoryExportRow("another-user"); location.UserId = UserId; + var authoredLocation = InventoryExportRow(); + InventoryExportPages(store, location, authoredLocation, + InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + var issuance = InventoryExportRow("another-user"); issuance.IssuedToUserId = UserId; + InventoryExportPages(store, issuance, InventoryExportRow(), + InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + var heldAsset = InventoryExportRow("another-user"); heldAsset.CurrentLocationId = location.Id; + InventoryExportPages(store, heldAsset, InventoryExportRow(), + InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + var issuedTransaction = InventoryExportRow("another-user"); issuedTransaction.IssuanceId = issuance.Id; + var holderTransaction = InventoryExportRow("another-user"); holderTransaction.ToLocationId = location.Id; + InventoryExportPages(store, issuedTransaction, holderTransaction, InventoryExportRow(), + InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + InventoryExportPages(store, InventoryExportRow(), InventoryExportRow("another-user", content: "UNRELATED-CANARY"), + InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + UseInventoryExport(store); + var files = await RunExportAsync(); var json = files["inventory.json"]; var data = JObject.Parse(json); + json.Should().Contain("SUBJECT-EVIDENCE").And.NotContain("UNRELATED-CANARY").And.NotContain("FOREIGN-CANARY"); + data["Locations"].Should().HaveCount(2); data["Issuances"].Should().HaveCount(2); data["Assets"].Should().HaveCount(2); + data["Transactions"].Should().HaveCount(3); data["Operations"].Should().HaveCount(1); data["WitnessedOperations"].Should().BeEmpty(); + } + + [TestCase(false, false), TestCase(false, true), TestCase(true, false), TestCase(true, true)] + public async Task Inventory_witness_export_contains_only_participation_facts_and_declares_receipt_content_withheld(bool enforced, bool encrypted) + { + var store = EmptyInventory(); + var content = encrypted ? "rgdp:1:19:SYNTHETIC-CIPHERTEXT-CANARY" : "{\"PendingCommand\":{\"Note\":\"PERFORMER-CANARY\"},\"Attestation\":\"ATTESTATION-CANARY\"}"; + var witness = InventoryExportRow("PERFORMER-IDENTITY-CANARY", content: content); + witness.WitnessUserId = UserId; witness.State = 2; witness.RequestId = Guid.NewGuid().ToString("D"); witness.ModifiedOn = DateTime.UtcNow; + var unrelated = InventoryExportRow("another-user", content: "UNRELATED-CANARY"); unrelated.WitnessUserId = "another-witness"; + InventoryExportPages(store, witness, unrelated); UseInventoryExport(store, enforced); + var files = await RunExportAsync(); var json = files["inventory.json"]; var data = JObject.Parse(json); + json.Should().NotContain("CANARY").And.NotContain("rgdp:"); data["Operations"].Should().BeEmpty(); data["WitnessedOperations"].Should().HaveCount(1); + var exported = (JObject)data["WitnessedOperations"][0]; + exported.Properties().Select(p => p.Name).Should().BeEquivalentTo(new[] { "Id", "DepartmentId", "RequestId", "State", "WitnessUserId", "ModifiedOn", "Content" }); + exported["Id"].Value().Should().Be(witness.Id); exported["WitnessUserId"].Value().Should().Be(UserId); + exported["Content"].Value().Should().Be(ProtectedDataEnvelope.RedactionValue); + JObject.Parse(files["withheld.json"])["entries"]["inventory.json"]["fields"].Values().Should().Contain("WitnessedOperations[].Content"); + witness.Content.Should().Be(content, "background masking must not mutate a persisted receipt"); + } + + [TestCase(false, false, false), TestCase(false, true, false), TestCase(true, false, false), TestCase(false, false, true)] + public async Task Inventory_export_masks_envelopes_enrollment_plaintext_and_policy_failures_without_mutating_rows(bool enforced, bool encrypted, bool policyFailure) + { + var store = EmptyInventory(); var content = encrypted ? "rgdp:1:19:SYNTHETIC-CIPHERTEXT-CANARY" : "SYNTHETIC-PLAIN-CANARY"; + var location = InventoryExportRow(content: content); var issuance = InventoryExportRow(content: content); + var asset = InventoryExportRow(content: content); var transaction = InventoryExportRow(content: content); + var operation = InventoryExportRow(content: content); + InventoryExportPages(store, location); InventoryExportPages(store, issuance); InventoryExportPages(store, asset); + InventoryExportPages(store, transaction); InventoryExportPages(store, operation); UseInventoryExport(store, enforced, policyFailure); + var files = await RunExportAsync(); var json = files["inventory.json"]; var data = JObject.Parse(json); + var masked = enforced || encrypted || policyFailure; + foreach (var property in new[] { "Locations", "Issuances", "Assets", "Transactions", "Operations" }) + data[property][0]["Content"].Value().Should().Be(masked ? ProtectedDataEnvelope.RedactionValue : content); + if (masked) + { + json.Should().NotContain("CANARY").And.NotContain("rgdp:"); + JObject.Parse(files["withheld.json"])["entries"]["inventory.json"]["valuesWithheld"].Value().Should().Be(5); + } + else files.Should().NotContainKey("withheld.json"); + new InventoryRow[] { location, issuance, asset, transaction, operation }.Should().OnlyContain(x => x.Content == content); + } + + [Test] + public async Task Inventory_export_pages_every_row_family_and_includes_witnesses_after_unrelated_pages_without_duplicates() + { + var store = EmptyInventory(); + T[] Rows() where T : InventoryRow, new() => Enumerable.Range(0, 1002) + .Select(i => InventoryExportRow(i >= 999 ? UserId : "another-user")).ToArray(); + var locations = Rows(); var issuances = Rows(); var assets = Rows(); + var transactions = Rows(); var operations = Rows(); + operations[1001].CreatedBy = "another-user"; operations[1001].WitnessUserId = UserId; + InventoryExportPages(store, locations); InventoryExportPages(store, issuances); InventoryExportPages(store, assets); + InventoryExportPages(store, transactions); InventoryExportPages(store, operations); UseInventoryExport(store); + var data = JObject.Parse((await RunExportAsync())["inventory.json"]); + foreach (var property in new[] { "Locations", "Issuances", "Assets", "Transactions" }) + { + data[property].Should().HaveCount(3); data[property].Select(x => x["Id"].Value()).Should().OnlyHaveUniqueItems(); + } + data["Operations"].Should().HaveCount(2); data["WitnessedOperations"].Should().HaveCount(1); + data["WitnessedOperations"][0]["Id"].Value().Should().Be(operations[1001].Id); + void VerifyPages() where T : InventoryRow + { + foreach (var skip in new[] { 0, 500, 1000 }) Mock.Get(store).Verify(s => s.ListAsync(DeptId, skip), Times.Once); + } + VerifyPages(); VerifyPages(); VerifyPages(); VerifyPages(); VerifyPages(); + } + + [Test] + public async Task Inventory_export_fails_instead_of_publishing_a_partial_archive_when_the_page_limit_is_exceeded() + { + var store = EmptyInventory(); var unrelated = InventoryExportRow("another-user"); + Mock.Get(store).Setup(s => s.ListAsync(DeptId, It.IsAny())) + .ReturnsAsync(Enumerable.Repeat(unrelated, 501).ToList()); + UseInventoryExport(store); await _service.ProcessPendingRequestsAsync(System.Threading.CancellationToken.None); + _request.Status.Should().Be((int)GdprExportStatus.Failed); _request.ExportData.Should().BeNull(); _request.DownloadToken.Should().BeNull(); + _request.ErrorMessage.Should().Be("Inventory export exceeds the supported department size."); + Mock.Get(store).Verify(s => s.ListAsync(DeptId, 100000), Times.Once); + Mock.Get(store).Verify(s => s.ListAsync(DeptId, 100500), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs b/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs new file mode 100644 index 000000000..d422ed067 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class InventoryHolderRetentionTests + { + private static Mock UnitOfWork(bool joined = false) + { + var work = new Mock(); + work.SetupGet(u => u.Transaction).Returns(joined ? new Mock().Object : null); + work.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync((DbConnection)null); + return work; + } + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task Group_inventory_history_stops_all_association_cleanup(bool archived, bool nextPage) + { + var location = new InventoryLocation { DepartmentId = 77, LocationType = (int)InventoryLocationType.Station, GroupId = 101, IsDeleted = archived }; + var store = new Mock(); var work = UnitOfWork(); + store.Setup(s => s.ListAsync(77, 0)).ReturnsAsync(nextPage + ? Enumerable.Range(0, 500).Select(_ => new InventoryLocation { DepartmentId = 77, GroupId = 102 }).Append(location).ToList() + : new List { location }); + store.Setup(s => s.ListAsync(77, 500)).ReturnsAsync(new List { location }); + var resources = new Mock(); + resources.Setup(a => a.CanUserEditDepartmentGroupAsync("manager", 101)).ReturnsAsync(true); + var calls = new Mock(MockBehavior.Strict); + var logs = new Mock(MockBehavior.Strict); + var units = new Mock(MockBehavior.Strict); + var shifts = new Mock(MockBehavior.Strict); + var inventory = new Mock(MockBehavior.Strict); + var groups = new Mock(MockBehavior.Strict); + var service = DeleteService(resources.Object, calls.Object, logs.Object, units.Object, shifts.Object, inventory.Object, groups.Object, store.Object, work.Object); + + var error = (await ((Func)(async () => await service.DeleteGroupAsync(101, 77, "manager"))).Should().ThrowAsync()).Which; + error.Code.Should().Be("HolderHistoryRetained"); error.StatusCode.Should().Be(409); + calls.VerifyNoOtherCalls(); logs.VerifyNoOtherCalls(); units.VerifyNoOtherCalls(); shifts.VerifyNoOtherCalls(); inventory.VerifyNoOtherCalls(); groups.VerifyNoOtherCalls(); + store.Verify(s => s.LockDepartmentAsync(77), Times.Once); + store.Verify(s => s.ListAsync(77, 500), nextPage ? Times.Once() : Times.Never()); + work.Verify(u => u.DiscardChanges(), Times.Once); work.Verify(u => u.CommitChanges(), Times.Never); + } + [Test] + public async Task Group_without_inventory_references_cleans_associations_only_after_locking() + { + var order = new List(); + var store = new Mock(); var work = UnitOfWork(); + store.Setup(s => s.LockDepartmentAsync(77)).Callback(() => order.Add("lock")).Returns(Task.CompletedTask); + store.Setup(s => s.ListAsync(77, 0)).Callback(() => order.Add("inventory-check")).ReturnsAsync(new List()); + var resources = new Mock(); resources.Setup(a => a.CanUserEditDepartmentGroupAsync("manager", 101)).ReturnsAsync(true); + var calls = new Mock(); var logs = new Mock(); var units = new Mock(); + var shifts = new Mock(); var inventory = new Mock(); var groups = new Mock(); + calls.Setup(c => c.ClearGroupForDispatchesAsync(101, It.IsAny())).Callback(() => order.Add("cleanup")).ReturnsAsync(true); + var service = DeleteService(resources.Object, calls.Object, logs.Object, units.Object, shifts.Object, inventory.Object, groups.Object, store.Object, work.Object); + + (await service.DeleteGroupAsync(101, 77, "manager")).Should().Be(DeleteGroupResults.NoFailure); + order.Should().Equal("lock", "inventory-check", "cleanup"); + groups.Verify(g => g.DeleteGroupMembersByGroupIdAsync(101, 77, It.IsAny()), Times.Once); + groups.Verify(g => g.DeleteGroupByIdAsync(101, It.IsAny()), Times.Once); + work.Verify(u => u.CommitChanges(), Times.Once); work.Verify(u => u.DiscardChanges(), Times.Never); + } + [TestCase(false)] + [TestCase(true)] + public async Task Unit_history_blocks_state_deletion_and_respects_transaction_ownership(bool joined) + { + var store = new Mock(); var work = UnitOfWork(joined); + store.Setup(s => s.ListAsync(77, 0)).ReturnsAsync(new List + { new() { DepartmentId = 77, LocationType = (int)InventoryLocationType.Unit, UnitId = 501, IsDeleted = true } }); + var units = new Mock(MockBehavior.Strict); + units.Setup(u => u.GetByIdAsync(501)).ReturnsAsync(new Unit { UnitId = 501, DepartmentId = 77 }); + var states = new Mock(MockBehavior.Strict); + var activeRoles = new Mock(MockBehavior.Strict); + var limits = new Mock(MockBehavior.Strict); + var events = new Mock(MockBehavior.Strict); + var service = new UnitsService(units.Object, states.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + events.Object, Mock.Of(), new Lazy>(() => Mock.Of>()), + Mock.Of(), new Lazy(() => Mock.Of()), + activeRoles.Object, Mock.Of(), limits.Object, Mock.Of(), + new Lazy(() => Mock.Of()), new Lazy(() => Mock.Of()), store.Object, work.Object); + + var error = (await ((Func)(async () => await service.DeleteUnitAsync(501))).Should().ThrowAsync()).Which; + error.Code.Should().Be("HolderHistoryRetained"); error.StatusCode.Should().Be(409); + units.Verify(u => u.GetByIdAsync(501), Times.Once); units.VerifyNoOtherCalls(); + states.VerifyNoOtherCalls(); activeRoles.VerifyNoOtherCalls(); limits.VerifyNoOtherCalls(); events.VerifyNoOtherCalls(); + store.Verify(s => s.LockDepartmentAsync(77), Times.Once); + work.Verify(u => u.DiscardChanges(), joined ? Times.Never() : Times.Once()); + work.Verify(u => u.CommitChanges(), Times.Never); + } + private static DeleteService DeleteService(IAuthorizationService resources, ICallsService calls, IWorkLogsService logs, IUnitsService units, + IShiftsService shifts, IInventoryService inventory, IDepartmentGroupsService groups, IInventoryStore store, IUnitOfWork work) => new DeleteService( + resources, Mock.Of(), calls, Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), groups, logs, Mock.Of(), Mock.Of(), Mock.Of(), shifts, + units, Mock.Of(), Mock.Of(), inventory, Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), store, work); + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs new file mode 100644 index 000000000..7208db618 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs @@ -0,0 +1,860 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public sealed class InventoryModernizationTests + { + private const int Department = 77; + private const string Canary = "PII-PHI-CANARY inventory narrative"; + private readonly InventoryActor _actor = new() { DepartmentId = Department, UserId = "manager", GrantToken = "synthetic-manager-grant" }; + private Store _store; + private InventoryModernizationService _service; + private Mock _auth; + private Mock _read; + private Mock _write; + private Mock _uow; + private Mock _outbox; + private List _events; + private List> _dispatches; + private List _audits; + private HashSet _deniedLocations; + private DbTransaction _transaction; + private int _eventsBefore; + private int _auditsBefore; + private long _outboxSequence; + private TestClock _clock; + private Mock _legacyInventory; + private Mock _legacyTypes; + private Mock _workOrders; + private Mock _workOrderAuth; + + [SetUp] + public void SetUp() + { + _store = new Store(); _events = new(); _dispatches = new(); _audits = new(); _deniedLocations = new(); + _transaction = null; _outboxSequence = 0; _clock = new TestClock(); + _auth = new Mock(); + _auth.Setup(x => x.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _auth.Setup(x => x.CanLocationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor a, InventoryLocation l) => l.DepartmentId == a.DepartmentId && !_deniedLocations.Contains(l.Id)); + _auth.Setup(x => x.ValidateHolderAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + _auth.Setup(x => x.IsEnabledAsync(Department)).ReturnsAsync(true); + _read = new Mock(); _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + _write = new Mock(); _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + var units = new Mock(); + units.Setup(x => x.GetUnitByIdAsync(It.IsAny())).ReturnsAsync((int id) => new Unit { UnitId = id, DepartmentId = Department, StationGroupId = 10 }); + var groups = new Mock(); + groups.Setup(x => x.GetGroupByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((int id, bool b) => new DepartmentGroup { DepartmentGroupId = id, DepartmentId = Department }); + groups.Setup(x => x.GetGroupForUserAsync(It.IsAny(), Department)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = Department }); + var audit = new Mock(); + audit.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((AuditLog a, CancellationToken c, bool f) => { _transaction.Should().NotBeNull(); a.AuditLogId = _audits.Count + 1; _audits.Add(a); return a; }); + _outbox = new Mock(); + _outbox.Setup(x => x.EnqueueAsync(Department, "Inventory", It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string p, DomainEventEnvelope e, CancellationToken c) => + { + _transaction.Should().NotBeNull("the event must be persisted alongside its inventory movement"); + _events.Add(Copy(e)); return new DomainEventOutboxEntry { DomainEventOutboxId = ++_outboxSequence }; + }); + _outbox.Setup(x => x.DispatchAfterCommitAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable ids, CancellationToken c) => { _transaction.Should().BeNull("dispatch is allowed only after the owner commits"); var batch = ids.ToList(); _dispatches.Add(batch); return batch.Count; }); + _uow = new Mock(); _uow.SetupGet(x => x.Transaction).Returns(() => _transaction); + _uow.Setup(x => x.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync(() => { Begin(); return (DbConnection)null; }); + _uow.Setup(x => x.CommitChanges()).Callback(() => { _store.Commit(); _transaction = null; }); + _uow.Setup(x => x.DiscardChanges()).Callback(Rollback); + _legacyInventory = new(); _legacyTypes = new(); + _legacyInventory.Setup(x => x.GetAllInventoriesByDepartmentIdAsync(Department)).ReturnsAsync(Array.Empty()); + _legacyTypes.Setup(x => x.GetAllByDepartmentIdAsync(Department)).ReturnsAsync(Array.Empty()); + _workOrders = new(); _workOrderAuth = new(); + _service = new InventoryModernizationService(_store, _auth.Object, _uow.Object, _read.Object, _write.Object, + _outbox.Object, audit.Object, units.Object, groups.Object, _clock, _legacyInventory.Object, _legacyTypes.Object, + _workOrders.Object, new Lazy(() => _workOrderAuth.Object)); + } + + [Test] + public async Task Expiring_bulk_items_require_lot_tracking_and_a_dated_lot_for_receipts() + { + var input = new InventoryItemInput { TrackingMode = InventoryTrackingMode.Bulk, RequiresExpiration = true, + Details = new InventoryItemContent { Name = "Synthetic expiring supplies", UnitOfMeasure = "each" } }; + await Fails(() => _service.SaveItemAsync(_actor, input), "ExpiryRequiresLotTracking", 400); + _store.All().Should().BeEmpty(); + input.RequiresLotTracking = true; var item = await _service.SaveItemAsync(_actor, input); var location = Location(); + await Fails(() => _service.PostTransactionAsync(_actor, Receive(item, location, 1)), "LotRequired", 400); + await Fails(() => _service.SaveLotAsync(_actor, new InventoryLot { ItemId = item.Id }, new InventoryLotContent { LotNumber = "synthetic lot" }), "InvalidLot", 400); + var lot = await _service.SaveLotAsync(_actor, new InventoryLot { ItemId = item.Id, ExpiresOn = _clock.Utc.AddDays(10) }, new InventoryLotContent { LotNumber = "synthetic lot" }); + var receipt = Receive(item, location, 1); receipt.Lines[0].LotId = lot.Id; + await _service.PostTransactionAsync(_actor, receipt); + _store.All().Should().ContainSingle(t => t.LotId == lot.Id); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Expiration_tracking_cannot_change_after_an_item_has_ledger_history(bool initiallyRequired) + { + var input = new InventoryItemInput { TrackingMode = InventoryTrackingMode.Bulk, RequiresExpiration = initiallyRequired, RequiresLotTracking = true, + Details = new InventoryItemContent { Name = "Synthetic lot supplies", UnitOfMeasure = "each" } }; + var item = await _service.SaveItemAsync(_actor, input); var location = Location(); + var lot = await _service.SaveLotAsync(_actor, new InventoryLot { ItemId = item.Id, ExpiresOn = _clock.Utc.AddDays(10) }, new InventoryLotContent { LotNumber = "synthetic lot" }); + var receipt = Receive(item, location, 1); receipt.Lines[0].LotId = lot.Id; await _service.PostTransactionAsync(_actor, receipt); + input.Id = item.Id; input.Revision = item.Revision; input.RequiresExpiration = !initiallyRequired; + await Fails(() => _service.SaveItemAsync(_actor, input), "ItemTrackingLocked", 409); + (await _service.GetAsync(_actor, item.Id)).RequiresExpiration.Should().Be(initiallyRequired); + } + + [TestCase(InventoryAssetStatus.Retired)] + [TestCase(InventoryAssetStatus.Lost)] + public async Task Terminal_bag_containers_do_not_block_inventory_locations_or_asset_lists(InventoryAssetStatus status) + { + var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var equipmentItem = Item(InventoryTrackingMode.Serialized); var location = Location(); + var bag = await CreateAsset(bagItem, location, "synthetic-bag"); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); + var containedAsset = await CreateAsset(equipmentItem, container, "synthetic-contained"); + var availableAsset = await CreateAsset(equipmentItem, location, "synthetic-available"); + await _service.ChangeAssetStatusAsync(_actor, Status(bagItem, bag, location, status)); + + var locations = await _service.ListAsync(_actor); + locations.Items.Should().Contain(l => l.Id == location.Id).And.NotContain(l => l.Id == container.Id); + var assets = await _service.ListAsync(_actor); + assets.Items.Should().Contain(a => a.Id == availableAsset.Id).And.NotContain(a => a.Id == containedAsset.Id); + (await _service.QueryAsync(_actor, new InventoryQuery { ItemId = equipmentItem.Id })).Items.Select(a => a.Id).Should().Equal(availableAsset.Id); + _store.All().Should().Contain(l => l.Id == container.Id, "the container identity remains as historical evidence"); + } + + [Test] + public async Task Structural_queries_filter_before_paging_and_authorize_each_returned_holder() + { + var item = Item(); var unrelated = Item(); var visible = Location(); var denied = Location(); _deniedLocations.Add(denied.Id); + for (var i = 1; i <= 502; i++) _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, + ToLocationId = visible.Id, EntryId = i, Quantity = 1, Content = "{}", OccurredOn = _clock.Utc }); + for (var i = 0; i < 501; i++) _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = unrelated.Id, + ToLocationId = visible.Id, EntryId = 2000 + i, Quantity = 1, Content = "{}", OccurredOn = _clock.Utc }); + var hidden = _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, ToLocationId = denied.Id, + EntryId = 503, Quantity = 1, Content = "{\"Note\":\"denied-content-canary\"}", OccurredOn = _clock.Utc }); + _store.Seed(new InventoryTransaction { DepartmentId = 88, ItemId = item.Id, ToLocationId = visible.Id, EntryId = 9000, Quantity = 1, Content = "{}" }); + _read.Invocations.Clear(); + var first = await _service.QueryAsync(_actor, new InventoryQuery { ItemId = item.Id }); + var second = await _service.QueryAsync(_actor, new InventoryQuery { ItemId = item.Id }, 1); + first.HasMore.Should().BeTrue(); second.HasMore.Should().BeFalse(); + var returned = first.Items.Concat(second.Items).ToList(); + returned.Should().HaveCount(502).And.OnlyContain(t => t.DepartmentId == Department && t.ItemId == item.Id && t.ToLocationId == visible.Id); + returned.Select(t => t.EntryId).Should().Equal(Enumerable.Range(1, 502).Reverse().Select(i => (long)i)); + returned.Should().NotContain(t => t.Id == hidden.Id); + _read.Invocations.Count(i => i.Method.IsGenericMethod && i.Method.GetGenericArguments().Contains(typeof(InventoryTransaction))).Should().Be(502, + "a holder-denied or foreign row must not reach protected-content resolution"); + await Fails(() => _service.QueryAsync(_actor, new InventoryQuery { ItemId = "invalid-filter" }), "InvalidIdentifier", 400); + } + + [Test] + public async Task Serialized_outbound_adjustments_cannot_leave_current_stock_divergent_from_the_ledger() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + var adjustment = Move(item, location, null, 1, InventoryTransactionType.Adjust); adjustment.AssetId = asset.Id; + var eventsBefore = _events.Count; var operationsBefore = _store.All().Count(); + await Fails(() => _service.PostTransactionAsync(_actor, Command(adjustment)), "SerializedAdjustmentUnsupported", 400); + _store.All().Should().HaveCount(1); _store.All().Should().HaveCount(operationsBefore); _events.Should().HaveCount(eventsBefore); + var unchanged = _store.All().Single(); unchanged.CurrentLocationId.Should().Be(location.Id); unchanged.Status.Should().Be((int)InventoryAssetStatus.InService); unchanged.Revision.Should().Be(asset.Revision); + } + + [TestCase(InventoryTransactionType.Consume)] + [TestCase(InventoryTransactionType.WriteOff)] + public async Task Serialized_disposal_retries_return_the_receipt_but_new_requests_cannot_dispose_the_same_asset_again(InventoryTransactionType type) + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + var disposal = Move(item, location, null, 1, type); disposal.AssetId = asset.Id; var command = Command(disposal); + var first = await _service.PostTransactionAsync(_actor, command); var eventsBefore = _events.Count; + (await _service.PostTransactionAsync(_actor, Copy(command))).TransactionIds.Should().Equal(first.TransactionIds); + command.RequestId = Guid.NewGuid().ToString("D"); + await Fails(() => _service.PostTransactionAsync(_actor, command), "AssetNotAvailable", 409); + _store.All().Should().HaveCount(2); _store.All().Should().HaveCount(2); _events.Should().HaveCount(eventsBefore); + _store.All().Single().Status.Should().Be((int)(type == InventoryTransactionType.Consume ? InventoryAssetStatus.Consumed : InventoryAssetStatus.Lost)); + } + + [TestCase(InventoryAssetStatus.Lost)] + [TestCase(InventoryAssetStatus.Consumed)] + [TestCase(InventoryAssetStatus.Retired)] + public async Task Terminal_asset_status_cannot_implicitly_restore_stock_or_allow_new_consumption(InventoryAssetStatus terminal) + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + await _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, terminal)); asset = _store.All().Single(); + var eventsBefore = _events.Count; + foreach (var status in new[] { InventoryAssetStatus.InService, InventoryAssetStatus.Issued, InventoryAssetStatus.OutForRepair, InventoryAssetStatus.Damaged }) + await Fails(() => _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, status)), "AssetNotAvailable", 409); + var consumption = Move(item, location, null, 1, InventoryTransactionType.Consume); consumption.AssetId = asset.Id; + await Fails(() => _service.PostTransactionAsync(_actor, Command(consumption)), "AssetNotAvailable", 409); + _store.All().Single().Status.Should().Be((int)terminal); _store.All().Should().HaveCount(2); + _store.All().Should().HaveCount(2); _events.Should().HaveCount(eventsBefore); + } + + [Test] + public async Task Expired_serialized_assets_cannot_be_consumed_but_can_be_written_off() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); + var asset = await _service.CreateAssetAsync(_actor, new InventoryAssetInput { RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, + LocationId = location.Id, ExpiresOn = _clock.Utc.AddDays(-1), Details = new InventoryAssetContent { SerialNumber = "synthetic-expired-asset" } }); + var consumption = Move(item, location, null, 1, InventoryTransactionType.Consume); consumption.AssetId = asset.Id; + await Fails(() => _service.PostTransactionAsync(_actor, Command(consumption)), "AssetNotAvailable", 409); + _store.All().Should().HaveCount(1); _store.All().Single().Status.Should().Be((int)InventoryAssetStatus.InService); + consumption.Type = InventoryTransactionType.WriteOff; await _service.PostTransactionAsync(_actor, Command(consumption)); + _store.All().Single().Status.Should().Be((int)InventoryAssetStatus.Lost); + } + + [Test] + public async Task Public_posting_cannot_attach_a_caller_supplied_issuance_backlink() + { + var item = Item(); var other = Item(); var location = Location(); + var issuance = _store.Seed(new InventoryIssuance { DepartmentId = Department, ItemId = other.Id, LocationId = location.Id, Quantity = 1, IssuedToUserId = "member" }); + var command = Receive(item, location, 2); command.Lines[0].IssuanceId = issuance.Id; + await Fails(() => _service.PostTransactionAsync(_actor, command), "InvalidIssuance", 400); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Single().ItemId.Should().Be(other.Id); + } + + [Test] + public async Task Retry_returns_original_receipt_without_stock_or_event_duplication_and_changed_retry_conflicts() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 2.125001m); + var first = await _service.PostTransactionAsync(_actor, command); + var retry = await _service.PostTransactionAsync(_actor, Copy(command)); + retry.OperationId.Should().Be(first.OperationId); retry.TransactionIds.Should().Equal(first.TransactionIds); + Stock(item, location).Should().Be(2.125001m); _store.All().Should().HaveCount(1); + _store.All().Should().HaveCount(1); _events.Should().HaveCount(1); + command.Lines[0].Quantity = 3; + await Fails(() => _service.PostTransactionAsync(_actor, command), "RequestConflict", 409); + Stock(item, location).Should().Be(2.125001m); _events.Should().HaveCount(1); + } + + [Test] + public async Task Missing_or_reserved_request_identifier_fails_before_mutation() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 1); + command.RequestId = null; await Fails(() => _service.PostTransactionAsync(_actor, command), "InvalidIdentifier", 400); + command.RequestId = Guid.Empty.ToString("D"); await Fails(() => _service.PostTransactionAsync(_actor, command), "InvalidIdentifier", 400); + command.RequestId = "00000000-0000-0000-0000-000000000001"; + await Fails(() => _service.PostTransactionAsync(_actor, command), "ReservedRequestId", 400); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Insufficient_second_line_rolls_back_prior_stock_ledger_audit_and_outbox() + { + var first = Item(); var second = Item(); var location = Location(); SeedStock(first, location, 5); SeedStock(second, location, 1); + var command = Command(Move(first, location, null, 2, InventoryTransactionType.Consume), Move(second, location, null, 2, InventoryTransactionType.Consume)); + await Fails(() => _service.PostTransactionAsync(_actor, command), "InsufficientStock", 409); + Stock(first, location).Should().Be(5); Stock(second, location).Should().Be(1); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _events.Should().BeEmpty(); _audits.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + _uow.Verify(x => x.CommitChanges(), Times.Never); + } + + [Test] + public async Task Transfer_commits_both_legs_and_one_completion_event_and_retries_atomically() + { + var first = Item(); var second = Item(); var from = Location(); var to = Location(InventoryLocationType.Unit, 101); + SeedStock(first, from, 5); SeedStock(second, from, 7); + var command = Command(Move(first, from, to, 2.25m, InventoryTransactionType.Transfer), Move(second, from, to, 3, InventoryTransactionType.Transfer)); + var result = await _service.CreateAndCompleteTransferAsync(_actor, command); + (await _service.CreateAndCompleteTransferAsync(_actor, Copy(command))).TransferId.Should().Be(result.TransferId); + Stock(first, from).Should().Be(2.75m); Stock(first, to).Should().Be(2.25m); Stock(second, from).Should().Be(4); Stock(second, to).Should().Be(3); + _store.All().Single().Status.Should().Be(2); _store.All().Should().HaveCount(2); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryAdjusted).Should().Be(2); + var completion = _events.Single(e => e.Trigger == WorkflowTriggerEventType.InventoryTransferCompleted); + completion.AggregateId.Should().Be(result.TransferId); completion.SchemaVersion.Should().Be(1); + JObject.FromObject(completion.Payload).Value("InventoryEvent").Should().BeTrue(); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary); + _dispatches.SelectMany(x => x).Should().HaveCount(3); + } + + [Test] + public async Task Transfer_with_unavailable_later_item_creates_no_partial_transfer() + { + var first = Item(); var second = Item(); var from = Location(); var to = Location(); + SeedStock(first, from, 5); SeedStock(second, from, 1); + await Fails(() => _service.CreateAndCompleteTransferAsync(_actor, + Command(Move(first, from, to, 2, InventoryTransactionType.Transfer), Move(second, from, to, 3, InventoryTransactionType.Transfer))), "InsufficientStock", 409); + Stock(first, from).Should().Be(5); Stock(first, to).Should().Be(0); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Serialized_asset_can_be_issued_to_person_returned_damaged_and_then_repaired() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + var issue = Issue(item, location, 1, userId: "member", assetId: asset.Id); + var result = await _service.IssueAsync(_actor, issue); + (await _service.IssueAsync(_actor, Copy(issue))).IssuanceId.Should().Be(result.IssuanceId); + var issuance = _store.All().Single(); issuance.IssuedToUserId.Should().Be("member"); + _store.All().Single().Status.Should().Be((int)InventoryAssetStatus.Issued); + (await _service.GetIssuableAsync(_actor)).Should().BeEmpty(); + await Fails(() => _service.IssueAsync(_actor, Issue(item, location, 1, unitId: 101, assetId: asset.Id)), "ReturnAssetFirst", 409); + var returned = await _service.ReturnAsync(_actor, new InventoryReturnInput { RequestId = Guid.NewGuid().ToString("D"), IssuanceId = issuance.Id, + Revision = issuance.Revision, ToLocationId = location.Id, Quantity = 1, Condition = InventoryAssetStatus.Damaged, Note = Canary }); + returned.IssuanceId.Should().Be(issuance.Id); _store.All().Single().Status.Should().Be((int)InventoryIssuanceStatus.Returned); + asset = _store.All().Single(); asset.Status.Should().Be((int)InventoryAssetStatus.Damaged); asset.CurrentLocationId.Should().Be(location.Id); + await _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, InventoryAssetStatus.InService)); + (await _service.GetIssuableAsync(_actor)).Single().Asset.Id.Should().Be(asset.Id); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryIssued).Should().Be(1); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryReturned).Should().Be(1); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryAssetStatusChanged).Should().Be(3); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("serial-canary"); + } + + [Test] + public async Task Bulk_unit_issue_supports_partial_returns_and_preserves_original_quantity() + { + var item = Item(); var source = Location(); SeedStock(item, source, 10); + var issued = await _service.IssueAsync(_actor, Issue(item, source, 6.125001m, unitId: 101)); + var issuance = _store.All().Single(); var target = _store.All().Single(l => l.UnitId == 101); + (await _service.GetUnitEquipmentAsync(_actor, 101)).Single().Issuances.Single().Id.Should().Be(issued.IssuanceId); + var input = new InventoryReturnInput { RequestId = Guid.NewGuid().ToString("D"), IssuanceId = issuance.Id, Revision = issuance.Revision, + Quantity = 2.125001m, ToLocationId = source.Id, Condition = InventoryAssetStatus.InService }; + await _service.ReturnAsync(_actor, input); await _service.ReturnAsync(_actor, Copy(input)); + issuance = _store.All().Single(); issuance.Quantity.Should().Be(6.125001m); issuance.ReturnedQuantity.Should().Be(2.125001m); + issuance.Status.Should().Be((int)InventoryIssuanceStatus.PartiallyReturned); issuance.ReturnedOn.Should().BeNull(); + Stock(item, source).Should().Be(6); Stock(item, target).Should().Be(4); + input.RequestId = Guid.NewGuid().ToString("D"); input.Revision = issuance.Revision; input.Quantity = 4.000001m; + await Fails(() => _service.ReturnAsync(_actor, input), "ReturnConflict", 409); + input.Quantity = 4; await _service.ReturnAsync(_actor, input); + _store.All().Single().ReturnedOn.Should().NotBeNull(); Stock(item, source).Should().Be(10); Stock(item, target).Should().Be(0); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryReturned).Should().Be(2); + } + + [Test] + public async Task Serialized_status_uses_optimistic_revision_and_terminal_status_closes_issuance() + { + var item = Item(InventoryTrackingMode.Serialized); var source = Location(); var asset = await CreateAsset(item, source); + await _service.IssueAsync(_actor, Issue(item, source, 1, unitId: 101, assetId: asset.Id)); + var issued = _store.All().Single(); var location = _store.All().Single(l => l.Id == issued.CurrentLocationId); + await Fails(() => _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, InventoryAssetStatus.Lost)), "AssetConflict", 409); + await _service.ChangeAssetStatusAsync(_actor, Status(item, issued, location, InventoryAssetStatus.Lost)); + _store.All().Single().Status.Should().Be((int)InventoryIssuanceStatus.Lost); + (await _service.GetUnitEquipmentAsync(_actor, 101)).Should().BeEmpty(); + (await _service.RoutingAsync(Department, asset.Id)).Should().BeNull(); + } + + [Test] + public async Task Joined_posting_enqueues_inside_owner_transaction_and_dispatches_only_after_owner_commit() + { + var item = Item(); var location = Location(); SeedStock(item, location, 3); + var command = Command(Move(item, location, null, 1.125m, InventoryTransactionType.Consume)); + command.Lines[0].ReferenceType = InventoryReferenceType.RmsRecord; command.Lines[0].ReferenceId = Guid.NewGuid().ToString("D"); + await _uow.Object.CreateOrGetConnectionAsync(); + var result = await _service.PostWithinTransactionAsync(_actor, command); + Stock(item, location).Should().Be(1.875m); result.OutboxIds.Should().HaveCount(1); _dispatches.Should().BeEmpty(); + _uow.Verify(x => x.CommitChanges(), Times.Never); _uow.Verify(x => x.CreateOrGetConnectionAsync(It.IsAny()), Times.Once); + _store.All().Single().ReferenceId.Should().Be(command.Lines[0].ReferenceId); + _uow.Object.CommitChanges(); await _outbox.Object.DispatchAfterCommitAsync(result.OutboxIds); + _dispatches.Single().Should().Equal(result.OutboxIds); + } + + [Test] + public async Task Joined_retry_preserves_original_dispatch_ids_after_item_lifecycle_changes() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 2); + command.Lines[0].ReferenceType = InventoryReferenceType.RmsRecord; command.Lines[0].ReferenceId = Guid.NewGuid().ToString("D"); + await _uow.Object.CreateOrGetConnectionAsync(); var first = await _service.PostWithinTransactionAsync(_actor, command); _uow.Object.CommitChanges(); + item.IsActive = false; _store.Seed(item); + await _uow.Object.CreateOrGetConnectionAsync(); var replay = await _service.PostWithinTransactionAsync(_actor, Copy(command)); _uow.Object.CommitChanges(); + replay.TransactionIds.Should().Equal(first.TransactionIds); replay.OutboxIds.Should().Equal(first.OutboxIds).And.NotBeEmpty(); + _store.All().Should().HaveCount(1); _store.All().Should().HaveCount(1); Stock(item, location).Should().Be(2); + await _outbox.Object.DispatchAfterCommitAsync(replay.OutboxIds); _dispatches.Single().Should().Equal(first.OutboxIds); + command.RequestId = Guid.NewGuid().ToString("D"); await _uow.Object.CreateOrGetConnectionAsync(); + await Fails(() => _service.PostWithinTransactionAsync(_actor, command), "ItemUnavailable", 409); _uow.Object.DiscardChanges(); + _store.All().Should().HaveCount(1); _store.All().Should().HaveCount(1); Stock(item, location).Should().Be(2); + } + + [Test] + public async Task Joined_new_request_validates_every_line_before_mutating_stock_or_ledger() + { + var item = Item(); var location = Location(); + var command = Command(Move(item, null, location, 2, InventoryTransactionType.Receive), Move(item, location, null, 1, InventoryTransactionType.Receive)); + await _uow.Object.CreateOrGetConnectionAsync(); + await Fails(() => _service.PostWithinTransactionAsync(_actor, command), "InvalidMovement", 400); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _uow.Object.DiscardChanges(); _store.All().Should().BeEmpty(); + } + + [Test] + public async Task Joined_controlled_post_requires_the_independent_witness_path() + { + var item = Item(controlled: true); var location = Location(); + await _uow.Object.CreateOrGetConnectionAsync(); + await Fails(() => _service.PostWithinTransactionAsync(_actor, Receive(item, location, 2)), "IndependentWitnessRequired", 409); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _uow.Object.DiscardChanges(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Work_order_closure_preserves_authorized_retries_but_blocks_new_movements(bool joined) + { + var order = new WorkOrder { Id = 31, DepartmentId = Department, Status = (int)WorkOrderStatus.InProgress }; + _workOrders.Setup(s => s.GetAsync(Department, order.Id, false)).ReturnsAsync(() => order); + _workOrderAuth.Setup(a => a.CanContributeAsync(It.IsAny(), order)).ReturnsAsync(true); + var item = Item(); var location = Location(); var command = Receive(item, location, 2); + command.Lines[0].ReferenceType = InventoryReferenceType.WorkOrder; command.Lines[0].ReferenceId = order.Id.ToString(); + async Task Post(InventoryCommand value) + { + if (!joined) return await _service.PostTransactionAsync(_actor, value); + await _uow.Object.CreateOrGetConnectionAsync(); + try { var result = await _service.PostWithinTransactionAsync(_actor, value); _uow.Object.CommitChanges(); return result; } + catch { _uow.Object.DiscardChanges(); throw; } + } + var first = await Post(command); order.Status = (int)WorkOrderStatus.Closed; + var replay = await Post(Copy(command)); replay.TransactionIds.Should().Equal(first.TransactionIds); replay.OutboxIds.Should().Equal(first.OutboxIds); + var newRequest = Copy(command); newRequest.RequestId = Guid.NewGuid().ToString("D"); await Fails(() => Post(newRequest), "ReferenceClosed", 409); + _workOrderAuth.Setup(a => a.CanContributeAsync(It.IsAny(), order)).ReturnsAsync(false); + await Fails(() => Post(Copy(command)), "ReferenceUnavailable", 404); + Stock(item, location).Should().Be(2); _store.All().Should().HaveCount(1); _store.All().Should().HaveCount(1); _events.Should().HaveCount(1); + } + + [Test] + public async Task Owner_rollback_removes_joined_posting_and_outer_commands_refuse_nested_ownership() + { + var item = Item(); var location = Location(); SeedStock(item, location, 3); var command = Command(Move(item, location, null, 1, InventoryTransactionType.Consume)); + await ((Func)(() => _service.PostWithinTransactionAsync(_actor, command))).Should().ThrowAsync(); + await _uow.Object.CreateOrGetConnectionAsync(); + await ((Func)(() => _service.PostTransactionAsync(_actor, command))).Should().ThrowAsync(); + _transaction.Should().NotBeNull(); await _service.PostWithinTransactionAsync(_actor, command); _uow.Object.DiscardChanges(); + Stock(item, location).Should().Be(3); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _events.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + } + + [Test] + public async Task Controlled_post_waits_for_independent_attestation_and_retry_never_duplicates_movement() + { + var item = Item(controlled: true); var location = Location(); var command = Receive(item, location, 5); + var pending = await _service.PostTransactionAsync(_actor, command); pending.AwaitingWitness.Should().BeTrue(); + Stock(item, location).Should().Be(0); _events.Should().BeEmpty(); _store.All().Should().BeEmpty(); + await Fails(() => _service.WitnessAsync(_actor, command.RequestId, "Count independently verified"), "IndependentWitnessRequired", 409); + var witness = new InventoryActor { DepartmentId = Department, UserId = "witness", GrantToken = "synthetic-witness-grant" }; + var result = await _service.WitnessAsync(witness, command.RequestId, "Count independently verified"); + (await _service.WitnessAsync(witness, command.RequestId, "Count independently verified")).TransactionIds.Should().Equal(result.TransactionIds); + result.AwaitingWitness.Should().BeFalse(); Stock(item, location).Should().Be(5); + var transaction = _store.All().Single(); transaction.CreatedBy.Should().Be(_actor.UserId); + var content = JObject.Parse(transaction.Content); content.Value("WitnessUserId").Should().Be(witness.UserId); + content.Value("PerformerId").Should().Be(_actor.UserId); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(1); + _auth.Verify(x => x.RequireAsync(It.Is(a => a.UserId == _actor.UserId && a.GrantToken == null), true, PermissionTypes.ManageControlledSubstances, null), Times.AtLeastOnce); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("synthetic-witness-grant").And.NotContain("Count independently verified"); + } + + [Test] + public async Task Revoked_performer_permission_blocks_pending_controlled_movement() + { + var item = Item(controlled: true); var location = Location(); var command = Receive(item, location, 2); + await _service.PostTransactionAsync(_actor, command); + _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == "manager" && a.GrantToken == null), true, PermissionTypes.ManageControlledSubstances, null)) + .ThrowsAsync(new InventoryException(403, "PermissionDenied")); + await Fails(() => _service.WitnessAsync(new InventoryActor { DepartmentId = Department, UserId = "witness" }, command.RequestId, "Verified"), "PermissionDenied", 403); + _store.All().Single().State.Should().Be(1); Stock(item, location).Should().Be(0); _events.Should().BeEmpty(); + } + + [Test] + public async Task Denied_protection_preflight_read_or_ledger_encryption_never_commits_partial_data() + { + var item = Item(); var location = Location(); SeedStock(item, location, 4); var command = Command(Move(item, location, null, 1, InventoryTransactionType.Consume)); + _write.SetReturnsDefault(Task.FromResult(new ProtectedWriteResult { Success = false })); + await Fails(() => _service.PostTransactionAsync(_actor, command), "ProtectedDataRequired", 403); + _write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { IsProtected = true, RedactedFields = new() { "inventoryitems.content" } })); + await Fails(() => _service.PostTransactionAsync(_actor, command), "ProtectedDataRequired", 403); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + _write.Setup(x => x.PrepareRecordsEntityWriteAsync(Department, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny Get, Action Set)>>(), It.IsAny(), + It.IsAny(), It.IsAny(), false, It.IsAny())).ReturnsAsync(new ProtectedWriteResult { Success = false }); + await Fails(() => _service.PostTransactionAsync(_actor, command), "ProtectedDataRequired", 403); + Stock(item, location).Should().Be(4); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _events.Should().BeEmpty(); _audits.Should().BeEmpty(); _dispatches.Should().BeEmpty(); _uow.Verify(x => x.CommitChanges(), Times.Never); + } + + [Test] + public async Task Cross_department_and_holder_scope_are_rechecked_for_reads_writes_and_receipt_retries() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 2); await _service.PostTransactionAsync(_actor, command); + await Fails(() => _service.GetAsync(new InventoryActor { DepartmentId = 78, UserId = "manager" }, item.Id), "Unavailable", 404); + _deniedLocations.Add(location.Id); + await Fails(() => _service.PostTransactionAsync(_actor, command), "LocationUnavailable", 404); + (await _service.ListAsync(_actor)).Items.Should().BeEmpty(); + Stock(item, location).Should().Be(2); _events.Should().HaveCount(1); + } + + [Test] + public async Task Migration_module_and_foreign_reference_guards_block_new_mutations() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 2); + _store.Migrated = false; await Fails(() => _service.PostTransactionAsync(_actor, command), "MigrationRequired", 409); + _store.Migrated = true; _auth.Setup(x => x.IsEnabledAsync(Department)).ReturnsAsync(false); + await Fails(() => _service.PostTransactionAsync(_actor, command), "InventoryDisabled", 409); + _auth.Setup(x => x.IsEnabledAsync(Department)).ReturnsAsync(true); + command.Lines[0].ReferenceType = InventoryReferenceType.RmsRecord; command.Lines[0].ReferenceId = Guid.NewGuid().ToString("D"); + await Fails(() => _service.PostTransactionAsync(_actor, command), "ReferenceUnsupported", 400); + command.Lines[0].ReferenceType = InventoryReferenceType.WorkOrder; command.Lines[0].ReferenceId = "25"; + await Fails(() => _service.PostTransactionAsync(_actor, command), "ReferenceUnavailable", 404); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Catalog_cannot_change_tracking_after_posting_or_move_existing_location_to_another_holder() + { + var input = new InventoryItemInput { Details = new InventoryItemContent { Name = "Synthetic medical consumable", UnitOfMeasure = "each" } }; + var item = await _service.SaveItemAsync(_actor, input); var location = Location(); await _service.PostTransactionAsync(_actor, Receive(item, location, 1)); + input.Id = item.Id; input.Revision = item.Revision; input.TrackingMode = InventoryTrackingMode.Serialized; + await Fails(() => _service.SaveItemAsync(_actor, input), "ItemTrackingLocked", 409); + await Fails(() => _service.SaveLocationAsync(_actor, new InventoryLocationInput { Id = location.Id, Revision = location.Revision, + Name = "Attempted reassignment", Type = InventoryLocationType.Unit, UnitId = 101 }), "LocationHolderImmutable", 409); + _store.All().Single().TrackingMode.Should().Be((int)InventoryTrackingMode.Bulk); + } + + [Test] + public async Task Lot_mismatch_expired_issue_and_precision_overflow_are_rejected_without_rewriting_stock() + { + var item = Item(); item.RequiresLotTracking = true; _store.Seed(item); var other = Item(); var location = Location(); + var lot = _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = other.Id, ExpiresOn = _clock.Utc.AddDays(-1), Content = "{}" }); + var command = Receive(item, location, 1); command.Lines[0].LotId = lot.Id; + await Fails(() => _service.PostTransactionAsync(_actor, command), "LotMismatch", 400); + lot.ItemId = item.Id; _store.Seed(lot); command = Command(Move(item, location, null, 1, InventoryTransactionType.Consume)); command.Lines[0].LotId = lot.Id; + await Fails(() => _service.PostTransactionAsync(_actor, command), "LotExpired", 409); + command = Receive(item, location, 0.0000001m); command.Lines[0].LotId = lot.Id; + await Fails(() => _service.PostTransactionAsync(_actor, command), "InvalidQuantity", 400); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + } + + [Test] + public async Task Kit_shortage_rolls_back_every_component_and_success_issues_exact_bill_of_materials() + { + var first = Item(); var second = Item(); var location = Location(); SeedStock(first, location, 5); SeedStock(second, location, 1); + var kit = await _service.SaveKitAsync(_actor, new InventoryKitInput { Name = "Synthetic medical kit", Lines = new() { + new InventoryKitLine { ItemId = first.Id, Quantity = 2 }, new InventoryKitLine { ItemId = second.Id, Quantity = 2 } } }); + var input = new InventoryKitIssueInput { RequestId = Guid.NewGuid().ToString("D"), KitId = kit.Id, + Lines = new() { Issue(first, location, 2, unitId: 101), Issue(second, location, 2, unitId: 101) } }; + await Fails(() => _service.IssueKitAsync(_actor, input), "InsufficientStock", 409); + Stock(first, location).Should().Be(5); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + SeedStock(second, location, 4); var result = await _service.IssueKitAsync(_actor, input); + result.IssuanceIds.Should().HaveCount(2); Stock(first, location).Should().Be(3); Stock(second, location).Should().Be(2); + (await _service.IssueKitAsync(_actor, Copy(input))).IssuanceIds.Should().Equal(result.IssuanceIds); + } + + [Test] + public async Task Checklist_routing_and_reminder_delivery_follow_current_holder_without_protected_reads() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(InventoryLocationType.Unit, 101); var asset = await CreateAsset(item, location); + _read.Invocations.Clear(); var routing = await _service.RoutingAsync(Department, asset.Id); + routing.UnitId.Should().Be(101); routing.GroupId.Should().Be(10); routing.Name.Should().BeNull(); + (await _service.CanReceiveReminderAsync(Department, "member", asset.Id)).Should().BeTrue(); _read.Invocations.Should().BeEmpty(); + var attended = await _service.GetAsync(ChecklistActor(), asset.Id); attended.Name.Should().Contain("serial-canary"); + _deniedLocations.Add(location.Id); (await _service.GetAsync(ChecklistActor(), asset.Id)).Should().BeNull(); + (await _service.CanReceiveReminderAsync(Department, "member", asset.Id)).Should().BeFalse(); + (await _service.RoutingAsync(78, asset.Id)).Should().BeNull(); + } + + [Test] + public async Task Checklist_history_uses_ledger_at_call_and_preserves_label_when_current_asset_moves_or_is_retired() + { + var item = Item(InventoryTrackingMode.Serialized); var first = Location(InventoryLocationType.Unit, 101); var second = Location(InventoryLocationType.Unit, 102); + var asset = await CreateAsset(item, first); var received = _store.All().Single(); + _clock.Advance(TimeSpan.FromMinutes(10)); var call = _clock.Utc; _clock.Advance(TimeSpan.FromMinutes(10)); + await _service.CreateAndCompleteTransferAsync(_actor, Command(AssetMove(item, asset, first, second))); var departure = _clock.Utc; + asset = _store.All().Single(); _clock.Advance(TimeSpan.FromMinutes(10)); + await _service.ChangeAssetStatusAsync(_actor, Status(item, asset, second, InventoryAssetStatus.Retired)); + item.Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = "Current label must not rewrite history", UnitOfMeasure = "each" }); _store.Seed(item); + _auth.Setup(x => x.IsEnabledAsync(Department)).ReturnsAsync(false); + var snapshots = await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 101 }, false); + var snapshot = snapshots.Single(); snapshot.AssetId.Should().Be(asset.Id); snapshot.UnitId.Should().Be(101); snapshot.SourceId.Should().Be(received.Id); + snapshot.SourceVersion.Should().Be(received.EntryId.ToString()); snapshot.IssuedUtc.Should().Be(received.OccurredOn); snapshot.ReturnedUtc.Should().Be(departure); + snapshot.Name.Should().Contain("serial-canary").And.NotContain("Current label"); + (await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 102 }, false)).Should().BeEmpty(); + (await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 101 }, true)).Should().BeNull(); + } + + [Test] + public async Task Checklist_container_history_tracks_bag_movement_while_child_provenance_keeps_its_own_label() + { + var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var childItem = Item(InventoryTrackingMode.Serialized); + var first = Location(InventoryLocationType.Unit, 101); var second = Location(InventoryLocationType.Unit, 102); + var bag = await CreateAsset(bagItem, first, "bag-serial"); var bagLocation = _store.All().Single(l => l.ContainerAssetId == bag.Id); + var child = await CreateAsset(childItem, bagLocation, "child-serial"); + _clock.Advance(TimeSpan.FromMinutes(10)); await _service.CreateAndCompleteTransferAsync(_actor, Command(AssetMove(bagItem, bag, first, second))); + var transfer = _store.All().Last(); _clock.Advance(TimeSpan.FromMinutes(10)); var call = _clock.Utc; + var snapshot = (await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 102 }, false)).Single(s => s.AssetId == child.Id); + snapshot.SourceId.Should().Be(transfer.Id); snapshot.SourceVersion.Should().Be(transfer.EntryId.ToString()); snapshot.Name.Should().Contain("child-serial").And.NotContain("bag-serial"); + (await _service.RoutingAsync(Department, child.Id)).UnitId.Should().Be(102); + } + + [Test] + public async Task Checklist_historical_snapshot_stops_at_later_terminal_status() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(InventoryLocationType.Unit, 101); var asset = await CreateAsset(item, location); + _clock.Advance(TimeSpan.FromMinutes(5)); var call = _clock.Utc; _clock.Advance(TimeSpan.FromMinutes(5)); + await _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, InventoryAssetStatus.Lost)); + var snapshot = (await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 101 }, false)).Single(); + snapshot.ReturnedUtc.Should().Be(_clock.Utc, "the asset ceased to be present when it was marked lost"); + } + + [Test] + public async Task Controlled_transfer_waits_for_independent_witness_before_committing_both_legs_and_transfer_event() + { + var item = Item(controlled: true); var from = Location(); var to = Location(InventoryLocationType.Unit, 101); SeedStock(item, from, 5); + var command = Command(Move(item, from, to, 2.125001m, InventoryTransactionType.Transfer)); + var pending = await _service.CreateAndCompleteTransferAsync(_actor, command); pending.AwaitingWitness.Should().BeTrue(); + (await _service.CreateAndCompleteTransferAsync(_actor, Copy(command))).OperationId.Should().Be(pending.OperationId); + Stock(item, from).Should().Be(5); Stock(item, to).Should().Be(0); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + var completed = await Witness(command.RequestId); completed.AwaitingWitness.Should().BeFalse(); completed.TransferId.Should().NotBeNullOrEmpty(); + Stock(item, from).Should().Be(2.874999m); Stock(item, to).Should().Be(2.125001m); + _store.All().Should().HaveCount(1); _store.All().Should().HaveCount(1); + var eventCount = _events.Count; (await Witness(command.RequestId)).TransferId.Should().Be(completed.TransferId); _events.Should().HaveCount(eventCount); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryTransferCompleted).Should().Be(1); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(1); + _store.All().Single().CreatedBy.Should().Be(_actor.UserId); + } + + [TestCase(true), TestCase(false)] + public async Task Controlled_issue_and_partial_return_each_require_witness_without_premature_issuance_changes(bool toUnit) + { + var item = Item(controlled: true); var source = Location(); SeedStock(item, source, 10); + var issue = Issue(item, source, 5, unitId: toUnit ? 101 : null, userId: toUnit ? null : "member"); + (await _service.IssueAsync(_actor, issue)).AwaitingWitness.Should().BeTrue(); _store.All().Should().BeEmpty(); Stock(item, source).Should().Be(10); _events.Should().BeEmpty(); + var completed = await Witness(issue.RequestId); var issuance = _store.All().Single(); completed.IssuanceId.Should().Be(issuance.Id); + issuance.CreatedBy.Should().Be(_actor.UserId); issuance.IssuedToUnitId.Should().Be(issue.UnitId); issuance.IssuedToUserId.Should().Be(issue.UserId); + Stock(item, source).Should().Be(5); var returned = new InventoryReturnInput { RequestId = Guid.NewGuid().ToString("D"), IssuanceId = issuance.Id, + Revision = issuance.Revision, ToLocationId = source.Id, Quantity = 2.125001m, Condition = InventoryAssetStatus.InService, Note = Canary }; + var priorEvents = _events.Count; (await _service.ReturnAsync(_actor, returned)).AwaitingWitness.Should().BeTrue(); + _store.All().Single().ReturnedQuantity.Should().Be(0); Stock(item, source).Should().Be(5); _events.Should().HaveCount(priorEvents); + await Witness(returned.RequestId); await Witness(returned.RequestId); + issuance = _store.All().Single(); issuance.ReturnedQuantity.Should().Be(2.125001m); issuance.Status.Should().Be((int)InventoryIssuanceStatus.PartiallyReturned); + Stock(item, source).Should().Be(7.125001m); _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryIssued).Should().Be(1); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryReturned).Should().Be(1); _events.Count(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(2); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("synthetic-witness-grant"); + } + + [Test] + public async Task Controlled_kit_witness_commits_controlled_and_ordinary_components_as_one_operation() + { + var medicine = Item(controlled: true); var supplies = Item(); var source = Location(); SeedStock(medicine, source, 5); SeedStock(supplies, source, 10); + var kit = await _service.SaveKitAsync(_actor, new InventoryKitInput { Name = "Synthetic controlled kit", Lines = new() { + new InventoryKitLine { ItemId = medicine.Id, Quantity = 2 }, new InventoryKitLine { ItemId = supplies.Id, Quantity = 3 } } }); + var input = new InventoryKitIssueInput { RequestId = Guid.NewGuid().ToString("D"), KitId = kit.Id, + Lines = new() { Issue(medicine, source, 2, unitId: 101), Issue(supplies, source, 3, unitId: 101) } }; + var pending = await _service.IssueKitAsync(_actor, input); pending.AwaitingWitness.Should().BeTrue(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + (await _service.IssueKitAsync(_actor, Copy(input))).OperationId.Should().Be(pending.OperationId); + var completed = await Witness(input.RequestId); completed.IssuanceIds.Should().HaveCount(2); _store.All().Select(t => t.OperationId).Distinct().Should().ContainSingle(); + Stock(medicine, source).Should().Be(3); Stock(supplies, source).Should().Be(7); _store.All().Should().OnlyContain(i => i.CreatedBy == _actor.UserId); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryIssued).Should().Be(2); _events.Count(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(1); + } + + [Test] + public async Task Controlled_serialized_receipt_allocates_identity_but_is_not_available_until_witness_completes_receipt() + { + var item = Item(InventoryTrackingMode.Serialized, controlled: true); var location = Location(); + var input = new InventoryAssetInput { RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, LocationId = location.Id, Details = new InventoryAssetContent { SerialNumber = "Synthetic controlled serial" } }; + var pending = await _service.CreateAssetAsync(_actor, input); pending.CurrentLocationId.Should().BeNull(); + (await _service.CreateAssetAsync(_actor, Copy(input))).Id.Should().Be(pending.Id); _store.All().Should().HaveCount(1); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); (await _service.GetIssuableAsync(_actor)).Should().BeEmpty(); (await _service.RoutingAsync(Department, pending.Id)).Should().BeNull(); + var completed = await Witness(input.RequestId); completed.AssetId.Should().Be(pending.Id); (await Witness(input.RequestId)).TransactionIds.Should().Equal(completed.TransactionIds); + (await _service.CreateAssetAsync(_actor, Copy(input))).CurrentLocationId.Should().Be(location.Id); _store.All().Should().HaveCount(1); + (await _service.GetIssuableAsync(_actor)).Single().Asset.Id.Should().Be(pending.Id); _events.Count(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(1); + } + + [Test] + public async Task Controlled_transfer_witness_rechecks_available_stock_and_preserves_pending_receipt_on_failure() + { + var item = Item(controlled: true); var from = Location(); var to = Location(); SeedStock(item, from, 5); + var command = Command(Move(item, from, to, 4, InventoryTransactionType.Transfer)); await _service.CreateAndCompleteTransferAsync(_actor, command); + SeedStock(item, from, 3); await Fails(() => Witness(command.RequestId), "InsufficientStock", 409); + Stock(item, from).Should().Be(3); Stock(item, to).Should().Be(0); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Single().State.Should().Be(1); + SeedStock(item, from, 5); (await Witness(command.RequestId)).TransferId.Should().NotBeNullOrEmpty(); Stock(item, from).Should().Be(1); + } + + [Test] + public async Task Legacy_migration_preserves_signed_six_decimal_balances_unit_precedence_and_attended_encrypted_source_copies() + { + _store.Migrated = false; + var type = new InventoryType { InventoryTypeId = 1, DepartmentId = Department, Type = "Synthetic old stock", Description = Canary, UnitOfMesasure = "each" }; + var sources = new[] { + Legacy(1, 5.125001, 10), Legacy(2, -2.000001, 10), Legacy(3, 3.125001, 11, 101), Legacy(4, -0.125001, 11, 101), Legacy(5, -1.25, 0) }; + var original = JsonConvert.SerializeObject(sources); + _legacyTypes.Setup(x => x.GetAllByDepartmentIdAsync(Department)).ReturnsAsync(new[] { type }); + _legacyInventory.Setup(x => x.GetAllInventoriesByDepartmentIdAsync(Department)).ReturnsAsync(sources); + var key = RandomNumberGenerator.GetBytes(32); var crypto = new ProtectedFieldCryptoService(); + try + { + EncryptWrites(key, crypto); EncryptWrites(key, crypto); EncryptWrites(key, crypto); EncryptWrites(key, crypto); + var migrated = await _service.MigrateLegacyAsync(_actor); migrated.Items.Should().Be(1); migrated.Transactions.Should().Be(5); + migrated.Warnings.Should().Contain("LegacyUnitLocationTakesPrecedence:3").And.Contain("LegacyNegativeBalancesPreserved"); + var locations = _store.All().ToList(); var item = _store.All().Single(); item.LegacyInventoryTypeId.Should().Be(1); + Stock(item, locations.Single(l => l.GroupId == 10)).Should().Be(3.125000m); Stock(item, locations.Single(l => l.UnitId == 101)).Should().Be(3.000000m); + Stock(item, locations.Single(l => l.IsDefault)).Should().Be(-1.25m); locations.Single(l => l.UnitId == 101).GroupId.Should().BeNull(); + _store.All().Should().OnlyContain(t => t.IsProtected && t.Content.StartsWith("rgdp:") && !t.Content.Contains(Canary)); + var consumption = _store.All().Single(t => t.LegacyInventoryId == 2); consumption.Quantity.Should().Be(2.000001m); consumption.ToLocationId.Should().BeNull(); + var decrypted = JObject.Parse(crypto.DecryptText(key, consumption.Content, Department, "inventorytransactions.content", consumption.Id)); + decrypted["LegacySource"].Value("Amount").Should().Be(-2.000001); decrypted["LegacySource"].Value("Note").Should().Be(Canary); + JsonConvert.SerializeObject(sources).Should().Be(original); _events.Should().BeEmpty(); + (await _service.MigrateLegacyAsync(_actor)).AlreadyMigrated.Should().BeTrue(); _store.All().Should().HaveCount(5); + _store.All().Single().RequestId.Should().Be("00000000-0000-0000-0000-000000000001"); + } + finally { CryptographicOperations.ZeroMemory(key); } + } + + [Test] + public async Task Legacy_migration_refuses_unrepresentable_quantities_and_rolls_back_when_protected_copy_fails() + { + _store.Migrated = false; _legacyTypes.Setup(x => x.GetAllByDepartmentIdAsync(Department)).ReturnsAsync(new[] { new InventoryType { InventoryTypeId = 1, DepartmentId = Department, Type = "Synthetic legacy", UnitOfMesasure = "each" } }); + _legacyInventory.Setup(x => x.GetAllInventoriesByDepartmentIdAsync(Department)).ReturnsAsync(new[] { Legacy(1, 0.1234567, 10) }); + await Fails(() => _service.MigrateLegacyAsync(_actor), "LegacyInventoryQuantityRequiresReview:1", 409); + _store.All().Should().BeEmpty(); + _legacyInventory.Setup(x => x.GetAllInventoriesByDepartmentIdAsync(Department)).ReturnsAsync(new[] { Legacy(1, 1.125001, 10) }); + _write.Setup(x => x.PrepareRecordsEntityWriteAsync(Department, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny Get, Action Set)>>(), It.IsAny(), + It.IsAny(), It.IsAny(), false, It.IsAny())).ReturnsAsync(ProtectedWriteResult.Blocked("synthetic_broker_unavailable")); + await Fails(() => _service.MigrateLegacyAsync(_actor), "ProtectedDataRequired", 403); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + (await _service.IsMigratedAsync(Department)).Should().BeFalse(); _events.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + } + + private Task Witness(string requestId) => _service.WitnessAsync(new InventoryActor { DepartmentId = Department, UserId = "witness", GrantToken = "synthetic-witness-grant" }, requestId, "Synthetic independent count verified"); + private Inventory Legacy(int id, double quantity, int group, int? unit = null) => new() { InventoryId = id, DepartmentId = Department, TypeId = 1, GroupId = group, UnitId = unit, Amount = quantity, + AddedByUserId = "legacy-author", TimeStamp = _clock.Utc.AddDays(-5).AddMinutes(id), Note = Canary, Batch = "Synthetic legacy batch", Location = "Synthetic shelf" }; + private void EncryptWrites(byte[] key, ProtectedFieldCryptoService crypto) where T : InventoryRow + { + _write.Setup(x => x.PrepareRecordsEntityWriteAsync(Department, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny Get, Action Set)>>(), It.IsAny(), It.IsAny(), It.IsAny(), false, It.IsAny())) + .ReturnsAsync((int d, T row, T previous, string id, IReadOnlyDictionary Get, Action Set)> fields, Action mark, string grant, string user, bool workload, CancellationToken ct) => + { + grant.Should().Be(_actor.GrantToken); user.Should().Be(_actor.UserId); workload.Should().BeFalse(); + foreach (var field in fields) field.Value.Set(row, crypto.EncryptText(key, 1, field.Value.Get(row), d, field.Key, id)); + mark?.Invoke(); return ProtectedWriteResult.Allowed(true, true); + }); + } + private static T Copy(T value) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + private static async Task Fails(Func action, string code, int status) + { + var error = (await action.Should().ThrowAsync()).Which; error.Code.Should().Be(code); error.StatusCode.Should().Be(status); + } + private ChecklistActor ChecklistActor() => new() { DepartmentId = _actor.DepartmentId, UserId = _actor.UserId, GrantToken = _actor.GrantToken }; + private InventoryItem Item(InventoryTrackingMode tracking = InventoryTrackingMode.Bulk, bool controlled = false, bool kit = false) => _store.Seed(new InventoryItem { + DepartmentId = Department, CreatedBy = _actor.UserId, CreatedOn = _clock.Utc, TrackingMode = (int)tracking, IsControlledSubstance = controlled, IsKit = kit, + Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = "Synthetic equipment " + Guid.NewGuid().ToString("N"), UnitOfMeasure = "each", Description = Canary, DefaultUnitCost = 2.25m }) }); + private InventoryLocation Location(InventoryLocationType type = InventoryLocationType.Facility, int? unitId = null) => _store.Seed(new InventoryLocation { + DepartmentId = Department, LocationType = (int)type, UnitId = unitId, CreatedBy = _actor.UserId, CreatedOn = _clock.Utc, Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Synthetic storage" }) }); + private void SeedStock(InventoryItem item, InventoryLocation location, decimal quantity) + { + var stock = _store.All().SingleOrDefault(s => s.ItemId == item.Id && s.LocationId == location.Id && s.LotId == null) + ?? new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id }; + stock.Quantity = quantity; _store.Seed(stock); + } + private decimal Stock(InventoryItem item, InventoryLocation location) => _store.All().SingleOrDefault(s => s.ItemId == item.Id && s.LocationId == location.Id && s.LotId == null)?.Quantity ?? 0; + private static InventoryCommand Command(params InventoryPosting[] lines) => new() { RequestId = Guid.NewGuid().ToString("D"), Lines = lines.ToList() }; + private static InventoryPosting Move(InventoryItem item, InventoryLocation from, InventoryLocation to, decimal quantity, InventoryTransactionType type) => new() { + ItemId = item.Id, FromLocationId = from?.Id, ToLocationId = to?.Id, Quantity = quantity, Type = type, Note = Canary }; + private static InventoryCommand Receive(InventoryItem item, InventoryLocation location, decimal quantity) => Command(Move(item, null, location, quantity, InventoryTransactionType.Receive)); + private static InventoryPosting AssetMove(InventoryItem item, InventoryAsset asset, InventoryLocation from, InventoryLocation to) => new() { + ItemId = item.Id, AssetId = asset.Id, FromLocationId = from.Id, ToLocationId = to.Id, Quantity = 1, Type = InventoryTransactionType.Transfer }; + private static InventoryCommand Status(InventoryItem item, InventoryAsset asset, InventoryLocation location, InventoryAssetStatus status) => Command(new InventoryPosting { + ItemId = item.Id, AssetId = asset.Id, FromLocationId = location.Id, Quantity = 0, Type = InventoryTransactionType.StatusChange, Status = status, ExpectedAssetRevision = asset.Revision }); + private static InventoryIssueInput Issue(InventoryItem item, InventoryLocation location, decimal quantity, int? unitId = null, string userId = null, string assetId = null) => new() { + RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, FromLocationId = location.Id, Quantity = quantity, UnitId = unitId, UserId = userId, AssetId = assetId, Note = Canary }; + private Task CreateAsset(InventoryItem item, InventoryLocation location, string serial = "serial-canary") => _service.CreateAssetAsync(_actor, new InventoryAssetInput { + RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, LocationId = location.Id, Details = new InventoryAssetContent { SerialNumber = serial, AcquisitionCost = 25m } }); + private void Begin() + { + _transaction.Should().BeNull(); _store.Begin(); _transaction = new Mock().Object; _eventsBefore = _events.Count; _auditsBefore = _audits.Count; + } + private void Rollback() + { + _store.Rollback(); _events.RemoveRange(_eventsBefore, _events.Count - _eventsBefore); _audits.RemoveRange(_auditsBefore, _audits.Count - _auditsBefore); _transaction = null; + } + private sealed class TestClock : TimeProvider + { + public DateTime Utc { get; private set; } = new(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc); + public override DateTimeOffset GetUtcNow() => new(Utc); + public void Advance(TimeSpan by) => Utc += by; + } + + /// Detached reads and rollback snapshots exercise transaction ownership; this is not a substitute for dialect database tests. + private sealed class Store : IInventoryStore + { + private Dictionary> _rows = new(); + private Dictionary> _before; + private long _entrySequence; + public bool Migrated { get; set; } = true; + public IEnumerable All() where T : InventoryRow => _rows.TryGetValue(typeof(T), out var rows) ? rows.Cast().Select(Copy).ToList() : Enumerable.Empty(); + public T Seed(T row) where T : InventoryRow + { + if (!_rows.TryGetValue(typeof(T), out var rows)) _rows[typeof(T)] = rows = new(); + var index = rows.FindIndex(r => r.DepartmentId == row.DepartmentId && r.Id == row.Id); + if (index < 0) rows.Add(Copy(row)); else rows[index] = Copy(row); return Copy(row); + } + public void Begin() => _before = _rows.ToDictionary(p => p.Key, p => p.Value.Select(v => (InventoryRow)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(v), p.Key)).ToList()); + public void Commit() => _before = null; + public void Rollback() { if (_before != null) _rows = _before; _before = null; } + private void RequireTransaction() { if (_before == null) throw new InvalidOperationException("Mutation requires an owning transaction."); } + public Task LockDepartmentAsync(int departmentId) { RequireTransaction(); return Task.CompletedTask; } + public Task GetAsync(int departmentId, string id) where T : InventoryRow => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.Id == id)); + public Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow => Task.FromResult(All().Where(r => r.DepartmentId == departmentId).OrderBy(r => r.CreatedOn).ThenBy(r => r.Id).Skip(skip).Take(501).ToList()); + public Task> QueryAsync(int departmentId, InventoryQuery filter, int skip = 0) where T : InventoryRow + { + var rows = All().Where(r => r.DepartmentId == departmentId && (r is not InventoryMutableRow mutable || !mutable.IsDeleted)); + foreach (var field in new[] { ("ItemId", filter.ItemId), ("AssetId", filter.AssetId), ("IssuedToUserId", filter.IssuedToUserId), ("KitId", filter.KitId) }.Where(x => x.Item2 != null)) + rows = rows.Where(r => (string)typeof(T).GetProperty(field.Item1).GetValue(r) == field.Item2); + if (filter.LocationId != null) rows = rows.Where(r => r is InventoryTransaction t ? t.FromLocationId == filter.LocationId || t.ToLocationId == filter.LocationId : (string)typeof(T).GetProperty("LocationId").GetValue(r) == filter.LocationId); + return Task.FromResult((typeof(T) == typeof(InventoryTransaction) ? rows.OrderByDescending(r => ((InventoryTransaction)(InventoryRow)r).EntryId) : rows.OrderBy(r => r.Id)).Skip(skip).Take(501).ToList()); + } + public Task> RelatedAsync(int departmentId, string column, string id) where T : InventoryRow => Task.FromResult(All().Where(r => r.DepartmentId == departmentId && (string)typeof(T).GetProperty(column).GetValue(r) == id).ToList()); + public Task InsertAsync(T row) where T : InventoryRow + { + RequireTransaction(); if (All().Any(r => r.DepartmentId == row.DepartmentId && r.Id == row.Id)) throw new InvalidOperationException("Duplicate inventory identity."); + if (row is InventoryOperation op && All().Any(r => r.DepartmentId == op.DepartmentId && r.RequestId == op.RequestId)) throw new InventoryException(409, "RequestConflict"); + if (row is InventoryTransaction transaction) transaction.EntryId = ++_entrySequence; + Seed(row); return Task.CompletedTask; + } + public Task UpdateAsync(T row, int expectedRevision) where T : InventoryRow + { + RequireTransaction(); if (row is InventoryTransaction or InventoryTransferItem) throw new InvalidOperationException("Ledger identities are immutable."); + var stored = All().SingleOrDefault(r => r.DepartmentId == row.DepartmentId && r.Id == row.Id); + if (stored == null || stored.Revision != expectedRevision || row.Revision != expectedRevision + 1) throw new InventoryException(409, "RevisionConflict"); + Seed(row); return Task.CompletedTask; + } + public Task RequestAsync(int departmentId, string requestId) => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.RequestId == requestId)); + public Task ApplyStockDeltaAsync(int departmentId, string itemId, string locationId, string lotId, decimal delta, string userId) + { + RequireTransaction(); var stock = All().SingleOrDefault(r => r.DepartmentId == departmentId && r.ItemId == itemId && r.LocationId == locationId && r.LotId == lotId) + ?? new InventoryStock { DepartmentId = departmentId, ItemId = itemId, LocationId = locationId, LotId = lotId, CreatedBy = userId }; + stock.Quantity += delta; stock.Revision++; return Task.FromResult(Seed(stock)); + } + public Task LegacyItemAsync(int departmentId, int typeId) => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.LegacyInventoryTypeId == typeId)); + public Task LegacyTransactionAsync(int departmentId, int inventoryId) => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.LegacyInventoryId == inventoryId)); + public async Task RebuildStocksAsync(int departmentId) + { + RequireTransaction(); if (_rows.TryGetValue(typeof(InventoryStock), out var stocks)) stocks.RemoveAll(r => r.DepartmentId == departmentId); + foreach (var transaction in All().Where(t => t.DepartmentId == departmentId && t.AssetId == null).OrderBy(t => t.EntryId)) + { + if (transaction.FromLocationId != null) await ApplyStockDeltaAsync(departmentId, transaction.ItemId, transaction.FromLocationId, transaction.LotId, -transaction.Quantity, transaction.CreatedBy); + if (transaction.ToLocationId != null) await ApplyStockDeltaAsync(departmentId, transaction.ItemId, transaction.ToLocationId, transaction.LotId, transaction.Quantity, transaction.CreatedBy); + } + } + public Task HasLegacyMigrationAsync(int departmentId) => Task.FromResult(departmentId == Department && (Migrated || All().Any(o => o.DepartmentId == departmentId && o.RequestId == "00000000-0000-0000-0000-000000000001" && o.State == 2))); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs new file mode 100644 index 000000000..e968bf6ce --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Events; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Bus; +using Resgrid.Services; +using Resgrid.Services.Records; +using Resgrid.Tests.Rms; +using Scriban; +using Scriban.Runtime; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public sealed class InventoryWorkflowTests + { + private const string Canary = "SYNTHETIC-INVENTORY-PHI-CANARY"; + private const string TransactionId = "11111111-1111-1111-1111-111111111111"; + private const string ItemId = "22222222-2222-2222-2222-222222222222"; + private const string AssetId = "33333333-3333-3333-3333-333333333333"; + private const string SourceId = "44444444-4444-4444-4444-444444444444"; + private const string DestinationId = "55555555-5555-5555-5555-555555555555"; + private const string CorrelationId = "66666666-6666-6666-6666-666666666666"; + private FakeRmsStore _store; + private EventAggregator _bus; + private Mock _policy; + private ProtectedProjectionService _projection; + private DomainEventOutboxService _outbox; + private ReadinessHistoryTestProtection _history; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); _bus = new EventAggregator(); _policy = new Mock(); + _projection = new ProtectedProjectionService(_policy.Object, new ProtectedFieldCatalog()); _history = new ReadinessHistoryTestProtection(_policy); + _outbox = new DomainEventOutboxService(_store.OutboxRepo.Object, _bus, new Lazy(() => _projection), _history.Lazy); + _store.OutboxRepo.Setup(s => s.InitializeChecklistPayloadAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _store.OutboxRepo.Setup(s => s.ReplaceChecklistPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DomainEventOutboxEntry e, string p, CancellationToken c) => { e.PayloadJson = p; return true; }); + } + [TearDown] public void TearDown() => _history.Dispose(); + + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(66)] + public async Task All_inventory_events_render_safe_quantities_ids_and_metadata_after_wrapped_projection(int trigger) + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var wrapped = Wrapped(trigger); var projected = await ChecklistWorkflowPayload.ProjectAsync(42, wrapped, _projection, true); + projected.Should().NotContain(Canary).And.NotContain("rgdp:").And.NotContain("\"UserId\":").And.NotContain("NestedSecret"); + var json = JObject.Parse(projected); json.Value("AggregateId").Should().Be(AssetId); json.Value("CorrelationId").Should().Be(CorrelationId); + var context = (ScriptObject)await Builder().BuildContextAsync(42, (WorkflowTriggerEventType)trigger, projected, CancellationToken.None); + var template = Template.Parse("{{ inventory.transaction_id }}|{{ inventory.item_id }}|{{ inventory.quantity + 1 }}|{{ inventory.from_quantity_after }}|{{ inventory.item_name }}|{{ event.name }}|{{ event.sequence }}|{{ event.is_replay }}|{{ protection.is_redacted }}"); + template.HasErrors.Should().BeFalse(); + template.Render(context).Should().Be(TransactionId + "|" + ItemId + "|3.125001|5.874999|REDACTED|" + (WorkflowTriggerEventType)trigger + "|7|true|true"); + var inventory = (ScriptObject)context["inventory"]; inventory["quantity"].Should().BeOfType(); + inventory["from_location_id"].Should().Be(SourceId); inventory["to_location_id"].Should().Be(DestinationId); + ((ScriptObject)context["event"])["id"].Should().Be(wrapped.Value("EventId")); + Convert.ToInt32(((ScriptObject)context["protection"])["catalog_version"]).Should().BeGreaterThanOrEqualTo(19); + var sample = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData((WorkflowTriggerEventType)trigger); + foreach (var descriptor in WorkflowTemplateVariableCatalog.GetVariableCatalog((WorkflowTriggerEventType)trigger) + .Where(d => d.Name.StartsWith("inventory.") || d.Name.StartsWith("event.") || d.Name.StartsWith("protection."))) + { + var path = descriptor.Name.Split('.'); ((ScriptObject)context[path[0]]).ContainsKey(path[1]).Should().BeTrue("runtime advertises " + descriptor.Name); + ((ScriptObject)sample[path[0]]).ContainsKey(path[1]).Should().BeTrue("preview advertises " + descriptor.Name); + } + Template.Parse("{{ inventory.quantity + 1 }}|{{ event.name }}|{{ protection.is_redacted }}").Render(sample).Should().EndWith("|" + (WorkflowTriggerEventType)trigger + "|true"); + } + + [Test] + public async Task Large_stock_balances_preserve_all_six_fractional_digits_through_projection_and_template_arithmetic() + { + var wrapped = Wrapped(22); const decimal before = 1234567890123456.123456m; const decimal after = 1234567890123454.123456m; + wrapped["Payload"]["FromQuantityBefore"] = before; wrapped["Payload"]["FromQuantityAfter"] = after; + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var projected = await InventoryWorkflowPayload.ProjectAsync(42, wrapped, _projection, true); + var context = (ScriptObject)await Builder().BuildContextAsync(42, WorkflowTriggerEventType.InventoryAdjusted, projected, CancellationToken.None); + var inventory = (ScriptObject)context["inventory"]; inventory["from_quantity_before"].Should().Be(before); inventory["from_quantity_after"].Should().Be(after); + Template.Parse("{{ (inventory.from_quantity_before - inventory.from_quantity_after) == 2 }}").Render(context).Should().Be("true"); + } + + [Test] + public async Task Invalid_scalar_types_and_policy_reintroduced_authored_fields_cannot_escape_allowlist() + { + var payload = Payload(); payload["Quantity"] = Canary; payload["ItemId"] = Canary; payload["OldStatus"] = Canary; + payload["ReferenceId"] = "person-" + Canary; payload["OccurredOn"] = Canary; + var protection = new Mock(); + protection.Setup(p => p.BuildSafeWorkflowPayloadAsync(42, It.IsAny())).ReturnsAsync((int d, object value) => + { + var returned = JObject.FromObject(value); returned["Note"] = Canary; returned["SerialNumber"] = Canary; returned["WitnessUserId"] = Canary; + returned["UserId"] = Canary; returned["NestedSecret"] = new JObject { ["Value"] = Canary }; return returned.ToString(); + }); + var safe = JObject.Parse(await InventoryWorkflowPayload.ProjectAsync(42, payload, protection.Object)); + safe.ToString().Should().NotContain(Canary).And.NotContain("NestedSecret"); + foreach (var name in new[] { "Quantity", "ItemId", "OldStatus", "ReferenceId", "OccurredOn", "UserId" }) safe.Property(name).Should().BeNull(name + " is not a valid routing value"); + safe.Value("Note").Should().Be("REDACTED"); safe.Value("SerialNumber").Should().Be("REDACTED"); + safe.Value("WitnessUserId").Should().Be("REDACTED"); + } + + [Test] + public async Task Reprojection_after_enrollment_or_deactivation_never_restores_authored_content() + { + var safe = await InventoryWorkflowPayload.ProjectAsync(42, Wrapped(22), _projection, true); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + safe = await InventoryWorkflowPayload.ProjectAsync(42, JObject.Parse(safe), _projection, true); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(false); + safe = await InventoryWorkflowPayload.ProjectAsync(42, JObject.Parse(safe), _projection, true); + safe.Should().NotContain(Canary); var payload = (JObject)JObject.Parse(safe)["Payload"]; + payload.Value("Quantity").Should().Be(2.125001m); payload.Value("ItemName").Should().Be("REDACTED"); + var context = await Builder().BuildContextAsync(42, WorkflowTriggerEventType.InventoryAdjusted, safe, CancellationToken.None); + Template.Parse("{{ inventory.id }}|{{ inventory.amount }}|{{ inventory.previous_amount }}|{{ inventory.type_name }}|{{ inventory.note }}").Render(context) + .Should().Be(TransactionId + "|3.125001|1.0|REDACTED|REDACTED"); + } + + [Test] + public async Task Legacy_adjustment_still_renders_original_legacy_aliases() + { + var legacy = new InventoryAdjustedEvent { PreviousAmount = 12.5, Inventory = new Inventory { InventoryId = 15, Amount = 10.25, + Batch = "Synthetic lot", Note = "Synthetic note", Location = "Synthetic store", Type = new InventoryType { Type = "Synthetic gloves", UnitOfMesasure = "pair" } } }; + var context = await Builder().BuildContextAsync(42, WorkflowTriggerEventType.InventoryAdjusted, JsonConvert.SerializeObject(legacy), CancellationToken.None); + Template.Parse("{{ inventory.id }}|{{ inventory.amount }}|{{ inventory.previous_amount }}|{{ inventory.type_name }}|{{ inventory.unit_of_measure }}").Render(context) + .Should().Be("15|10.25|12.5|Synthetic gloves|pair"); + } + + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(66)] + public async Task Inventory_outbox_protects_history_and_replays_only_structural_payload_without_broker_decryption(int trigger) + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var entry = await _outbox.EnqueueAsync(42, "Inventory", Event(trigger)); + entry.PayloadJson.Should().StartWith("rgdp:").And.NotContain("2.125001"); + entry.ReadinessRoutingJson.Should().NotContain(Canary).And.Contain(TransactionId); + _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson).Should().NotContain(Canary).And.Contain("2.125001"); + DomainEventDispatchedEvent delivered = null; + _bus.AddAsyncListener(e => { delivered = e; return Task.CompletedTask; }); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + delivered.ProducerSubsystem.Should().Be("Inventory"); delivered.AggregateId.Should().Be(AssetId); delivered.CorrelationId.Should().Be(CorrelationId); + delivered.PayloadJson.Should().NotContain(Canary).And.NotContain("rgdp:"); JObject.Parse(delivered.PayloadJson).Value("Quantity").Should().Be(2.125001m); + var context = await Builder().BuildContextAsync(42, (WorkflowTriggerEventType)trigger, JsonConvert.SerializeObject(RecordsWorkflowEvent.From(delivered)), CancellationToken.None); + Template.Parse("{{ event.id }}|{{ inventory.transaction_id }}|{{ inventory.item_name }}").Render(context).Should().Be(entry.EventId + "|" + TransactionId + "|REDACTED"); + _history.Broker.Invocations.Should().OnlyContain(i => i.Method.Name == "EncryptAsync"); + } + + [Test] + public async Task Unknown_protection_policy_prevents_inventory_outbox_initialization() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ThrowsAsync(new InvalidOperationException("policy unavailable")); + await ((Func)(() => _outbox.EnqueueAsync(42, "Inventory", Event(22)))).Should().ThrowAsync(); + _store.OutboxRepo.Verify(s => s.InitializeChecklistPayloadAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(66)] + public async Task Queue_rejection_retries_existing_inventory_workflow_run_with_same_event_identity(int trigger) + { + var workflows = new Mock(); var runs = new Mock(); var queue = new Mock(); + var subscriptions = new Mock(); subscriptions.Setup(s => s.GetCurrentPlanForDepartmentAsync(42, It.IsAny())).ReturnsAsync(new Plan { PlanId = 999999 }); + var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString("D"), DepartmentId = 42, TriggerEventType = trigger }; + workflows.Setup(s => s.GetAllActiveByDepartmentAndEventTypeAsync(42, trigger)).ReturnsAsync(new[] { workflow }); + WorkflowRun stored = null; + runs.Setup(s => s.GetByWorkflowsAndEventAsync(42, It.IsAny>(), It.IsAny())).ReturnsAsync(() => stored == null ? new List() : new List { stored }); + runs.Setup(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((WorkflowRun run, CancellationToken c, bool f) => stored = run); + var attempts = new List(); queue.Setup(s => s.EnqueueWorkflow(It.IsAny())).ReturnsAsync((WorkflowQueueItem q) => { attempts.Add(q); return attempts.Count > 1; }); + _ = new WorkflowEventProvider(_bus, queue.Object, workflows.Object, runs.Object, Mock.Of(), subscriptions.Object, _projection, _history.Lazy); + var entry = await _outbox.EnqueueAsync(42, "Inventory", Event(trigger)); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(0); stored.Should().NotBeNull(); entry.DispatchedOn.Should().BeNull(); + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + (await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId })).Should().Be(1); + attempts.Should().HaveCount(2); attempts[1].WorkflowRunId.Should().Be(attempts[0].WorkflowRunId); + var queued = JObject.Parse(attempts[1].EventPayloadJson); queued.Value("EventId").Should().Be(entry.EventId); queued.Value("IsReplay").Should().BeTrue(); + queued.Value("AggregateId").Should().Be(AssetId); attempts[1].EventPayloadJson.Should().NotContain(Canary).And.NotContain("rgdp:"); + runs.Verify(s => s.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(66)] + public async Task Duplicate_consumer_claim_does_not_execute_completed_inventory_run(int trigger) + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var workflows = new Mock(); var runs = new Mock(); var context = new Mock(); + var workflow = new Workflow { WorkflowId = Guid.NewGuid().ToString("D"), DepartmentId = 42, TriggerEventType = trigger }; + var run = new WorkflowRun { WorkflowRunId = Guid.NewGuid().ToString("D"), WorkflowId = workflow.WorkflowId, DepartmentId = 42, TriggerEventType = trigger, Status = (int)WorkflowRunStatus.Completed }; + workflows.Setup(s => s.GetByIdAsync(workflow.WorkflowId)).ReturnsAsync(workflow); runs.Setup(s => s.GetByIdAsync(run.WorkflowRunId)).ReturnsAsync(run); + string claimed = null; + runs.Setup(s => s.TryStartChecklistRunAsync(run.WorkflowRunId, workflow.WorkflowId, 42, 1, It.IsAny())) + .ReturnsAsync((string r, string w, int d, int a, string p) => { claimed = p; return false; }); + var service = new WorkflowService(workflows.Object, Mock.Of(), Mock.Of(), runs.Object, + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + context.Object, Mock.Of(), Mock.Of(), new Lazy(() => _projection), _history.Lazy); + (await service.ExecuteWorkflowAsync(workflow.WorkflowId, Wrapped(trigger).ToString(), 42, string.Empty, existingRunId: run.WorkflowRunId)).Should().BeSameAs(run); + claimed.Should().StartWith("rgdp:"); _history.Decrypt(42, "workflowruns.inputpayload", run.WorkflowRunId, claimed).Should().NotContain(Canary).And.Contain(TransactionId); + context.Verify(s => s.BuildContextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private static WorkflowTemplateContextBuilder Builder() => new(Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); + private static JObject Payload() => JObject.FromObject(new { InventoryEvent = true, TransactionId, ItemId, AssetId, FromLocationId = SourceId, ToLocationId = DestinationId, + Quantity = 2.125001m, FromQuantityBefore = 8m, FromQuantityAfter = 5.874999m, ToQuantityBefore = 1m, ToQuantityAfter = 3.125001m, + TransactionType = 3, OldStatus = 0, NewStatus = 1, ReferenceType = 9, ReferenceId = "123", OccurredOn = new DateTime(2026, 9, 9, 8, 0, 0, DateTimeKind.Utc), + ItemName = Canary, Note = Canary, SerialNumber = Canary, WitnessUserId = Canary, UserId = Canary, NestedSecret = new { Value = Canary } }); + private static JObject Wrapped(int trigger) => new() { ["DepartmentId"] = 42, ["EventId"] = "77777777-7777-7777-7777-777777777777", + ["EventName"] = ((WorkflowTriggerEventType)trigger).ToString(), ["TriggerEventType"] = trigger, ["SchemaVersion"] = 1, ["AggregateType"] = "InventoryAsset", ["AggregateId"] = AssetId, + ["CorrelationId"] = CorrelationId, ["Sequence"] = 7L, ["IsReplay"] = true, ["OriginClient"] = "Api", ["OccurredOn"] = new DateTime(2026, 9, 9, 8, 0, 0, DateTimeKind.Utc), + ["Payload"] = Payload(), ["UnreviewedEnvelopeText"] = Canary }; + private static DomainEventEnvelope Event(int trigger) => new() { EventName = ((WorkflowTriggerEventType)trigger).ToString(), SchemaVersion = 1, + AggregateType = "InventoryAsset", AggregateId = AssetId, Trigger = (WorkflowTriggerEventType)trigger, CorrelationId = CorrelationId, Payload = Payload() }; + } +} diff --git a/Tests/Resgrid.Tests/Services/ReadinessProBillingClientTests.cs b/Tests/Resgrid.Tests/Services/ReadinessProBillingClientTests.cs new file mode 100644 index 000000000..36cf9710f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ReadinessProBillingClientTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Services; +using Resgrid.Services; +using RestSharp; +using RestSharp.Serializers.NewtonsoftJson; + +namespace Resgrid.Tests.Services +{ + [TestFixture, NonParallelizable] + public class ReadinessProBillingClientTests + { + [Test] + public async Task Billing_client_is_lazy_when_unconfigured_and_shared_across_service_scopes() + { + var oldUrl = SystemBehaviorConfig.BillingApiBaseUrl; var oldKey = ApiConfig.BackendInternalApikey; + try + { + SystemBehaviorConfig.BillingApiBaseUrl = ""; ApiConfig.BackendInternalApikey = "synthetic-key"; + var builder = new ContainerBuilder(); builder.RegisterModule(); + using var container = builder.Build(Autofac.Builder.ContainerBuildOptions.IgnoreStartableComponents); using var first = container.BeginLifetimeScope(); using var second = container.BeginLifetimeScope(); + (await first.Resolve().GetAsync(77)).Should().BeNull(); + SystemBehaviorConfig.BillingApiBaseUrl = "https://billing.invalid"; + first.ResolveNamed("readiness-billing-client").Should().BeSameAs(second.ResolveNamed("readiness-billing-client")); + } + finally { SystemBehaviorConfig.BillingApiBaseUrl = oldUrl; ApiConfig.BackendInternalApikey = oldKey; } + } + + [Test] + public async Task Consecutive_billing_calls_keep_the_shared_transport_usable_and_preserve_failure_results() + { + var oldUrl = SystemBehaviorConfig.BillingApiBaseUrl; var oldKey = ApiConfig.BackendInternalApikey; + try + { + SystemBehaviorConfig.BillingApiBaseUrl = "https://billing.invalid"; ApiConfig.BackendInternalApikey = "synthetic-key"; + var handler = new BillingHandler(); + using var client = new RestClient(new RestClientOptions(SystemBehaviorConfig.BillingApiBaseUrl) { ConfigureMessageHandler = _ => handler }, configureSerialization: s => s.UseNewtonsoftJson()); + var service = new ReadinessProBillingService(() => client); + (await service.GetAsync(77)).Provider.Should().Be("Stripe"); + (await service.CancelRenewalAsync(77)).Should().BeTrue(); + handler.Fail = true; + (await service.GetAsync(77)).Should().BeNull(); (await service.CancelRenewalAsync(77)).Should().BeFalse(); + handler.Calls.Should().Be(4); + } + finally { SystemBehaviorConfig.BillingApiBaseUrl = oldUrl; ApiConfig.BackendInternalApikey = oldKey; } + } + + private sealed class BillingHandler : HttpMessageHandler + { + public int Calls; + public bool Fail; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; request.Headers.GetValues("X-API-Key").Should().ContainSingle().Which.Should().Be("synthetic-key"); + return Task.FromResult(new HttpResponseMessage(Fail ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK) + { + Content = new StringContent(request.Method == HttpMethod.Post ? "true" : "{\"Provider\":\"Stripe\"}", System.Text.Encoding.UTF8, "application/json") + }); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs new file mode 100644 index 000000000..7aff741d6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public sealed class RmsInventoryModernUsageTests + { + private const int Department = 77; + private const string RecordId = "11111111-1111-1111-1111-111111111111"; + private const string ItemId = "22222222-2222-2222-2222-222222222222"; + private const string LocationId = "33333333-3333-3333-3333-333333333333"; + private const string Canary = "SYNTHETIC-RECORD-INVENTORY-PHI-CANARY"; + private readonly InventoryActor _actor = new() { DepartmentId = Department, UserId = "author", GrantToken = "synthetic-current-grant" }; + private Mock _referencesRepo; + private Mock _records; + private Mock _incidents; + private Mock _auditRepo; + private Mock _modernStore; + private Mock _stock; + private Mock _catalog; + private Mock _legacy; + private Mock _authorization; + private Mock _uow; + private Mock _outbox; + private RmsInventoryUsageAdapter _adapter; + private RmsOperationalRecord _record; + private RmsIncidentReport _incident; + private List _references; + private List _audits; + private List _ledger; + private List<(InventoryActor Actor, InventoryCommand Command)> _posts; + private List> _dispatches; + private DbTransaction _transaction; + private Action _rollback; + private decimal _balance; + private bool _migrated; + + [SetUp] + public void SetUp() + { + _references = new(); _audits = new(); _ledger = new(); _posts = new(); _dispatches = new(); _balance = 10; _transaction = null; _migrated = true; + _record = new RmsOperationalRecord { RmsOperationalRecordId = RecordId, DepartmentId = Department, AuthorUserId = "author", State = (int)RmsRecordState.Draft, RowVersion = 1 }; + _incident = new RmsIncidentReport { RmsIncidentReportId = RecordId, DepartmentId = Department, AuthorUserId = "author", State = (int)RmsRecordState.Draft, RowVersion = 1 }; + _records = new(); _incidents = new(); _referencesRepo = new(); _auditRepo = new(); _authorization = new(); _modernStore = new(); _stock = new(); _catalog = new(); _legacy = new(); _uow = new(); _outbox = new(); + _records.Setup(r => r.GetByIdForDepartmentAsync(Department, RecordId)).ReturnsAsync(() => Copy(_record)); + _incidents.Setup(r => r.GetByIdForDepartmentAsync(Department, RecordId)).ReturnsAsync(() => Copy(_incident)); + _records.Setup(r => r.TryBumpRowVersionAsync(Department, RecordId, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, long version, CancellationToken ct) => + { _transaction.Should().NotBeNull(); if (_record.RowVersion != version) return false; _record.RowVersion++; return true; }); + _incidents.Setup(r => r.TryBumpRowVersionAsync(Department, RecordId, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, long version, CancellationToken ct) => + { _transaction.Should().NotBeNull(); if (_incident.RowVersion != version) return false; _incident.RowVersion++; return true; }); + _authorization.Setup(a => a.CanUserViewRecordAsync("author", RecordId, Department)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync("author", Department, It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.CanUseSourceInventoryAsync("author", Department, It.IsAny())).ReturnsAsync(true); + _referencesRepo.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => Copy(_references.SingleOrDefault(r => r.RmsExternalReferenceId == id.ToString()))); + _referencesRepo.Setup(r => r.GetForRecordAsync(Department, RecordId)).ReturnsAsync(() => _references.Select(Copy).ToList()); + _referencesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExternalReference r, CancellationToken c, bool f) => + { _transaction.Should().NotBeNull(); _references.Add(Copy(r)); return r; }); + _auditRepo.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsAccessAudit a, CancellationToken c, bool f) => + { _transaction.Should().NotBeNull(); _audits.Add(Copy(a)); return a; }); + var item = new InventoryItem { DepartmentId = Department, Id = ItemId, LegacyInventoryTypeId = 1, + Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = Canary, UnitOfMeasure = Canary }) }; + var location = new InventoryLocation { Id = LocationId, DepartmentId = Department, LocationType = (int)InventoryLocationType.Station, GroupId = 7 }; + _catalog.Setup(c => c.GetAsync(It.IsAny(), ItemId)).ReturnsAsync(() => Copy(item)); + _catalog.Setup(c => c.GetAsync(It.IsAny(), LocationId)).ReturnsAsync(() => Copy(location)); + _catalog.Setup(c => c.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, string id) => Copy(_ledger.SingleOrDefault(t => t.DepartmentId == a.DepartmentId && t.Id == id))); + _catalog.Setup(c => c.ListAsync(It.IsAny(), 0)).ReturnsAsync(new InventoryPage { Items = new() { location } }); + _modernStore.Setup(s => s.HasLegacyMigrationAsync(Department)).ReturnsAsync(() => _migrated); + _modernStore.Setup(s => s.LockDepartmentAsync(Department)).Returns(() => { _transaction.Should().NotBeNull(); return Task.CompletedTask; }); + _modernStore.Setup(s => s.LegacyItemAsync(Department, 1)).ReturnsAsync(() => Copy(item)); + _modernStore.Setup(s => s.GetAsync(Department, It.IsAny())).ReturnsAsync((int d, string id) => Copy(_ledger.SingleOrDefault(t => t.Id == id && t.DepartmentId == d))); + _stock.Setup(s => s.PostWithinTransactionAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor actor, InventoryCommand command, CancellationToken ct) => + { + _transaction.Should().NotBeNull("Records must own the stock and reference transaction"); _posts.Add((Copy(actor), Copy(command))); + var line = command.Lines.Single(); var before = _balance; _balance -= line.Quantity; + var entry = new InventoryTransaction { DepartmentId = actor.DepartmentId, EntryId = _ledger.Count + 1, OperationId = Guid.NewGuid().ToString("D"), + ItemId = line.ItemId, AssetId = line.AssetId, LotId = line.LotId, FromLocationId = line.FromLocationId, ToLocationId = line.ToLocationId, + Quantity = line.Quantity, FromQuantityBefore = before, FromQuantityAfter = _balance, ReferenceType = (int)line.ReferenceType, ReferenceId = line.ReferenceId, + TransactionType = (int)line.Type, OccurredOn = DateTime.UtcNow, Content = JsonConvert.SerializeObject(new { Note = Canary }) }; + _ledger.Add(entry); return new InventoryResult { TransactionIds = new() { entry.Id }, OutboxIds = new() { 101 } }; + }); + _uow.SetupGet(u => u.Transaction).Returns(() => _transaction); + _uow.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync(() => { Begin(); return (DbConnection)null; }); + _uow.Setup(u => u.CreateOrGetConnection()).Returns(() => { Begin(); return (DbConnection)null; }); + _uow.Setup(u => u.CommitChanges()).Callback(() => { _transaction.Should().NotBeNull(); _transaction = null; _rollback = null; }); + _uow.Setup(u => u.DiscardChanges()).Callback(() => { _rollback?.Invoke(); _transaction = null; _rollback = null; }); + _outbox.Setup(o => o.DispatchAfterCommitAsync(It.IsAny>(), It.IsAny())).ReturnsAsync((IEnumerable ids, CancellationToken ct) => + { _transaction.Should().BeNull(); var values = ids.ToList(); _dispatches.Add(values); return values.Count; }); + var groups = new Mock(); groups.Setup(g => g.GetGroupByIdAsync(7, true)).ReturnsAsync(new DepartmentGroup { DepartmentId = Department, DepartmentGroupId = 7 }); + _legacy.Setup(i => i.GetTypeByIdAsync(1)).ReturnsAsync(new InventoryType { InventoryTypeId = 1, DepartmentId = Department, Type = "Synthetic foam", UnitOfMesasure = "litres" }); + _legacy.Setup(i => i.SaveInventoryAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Inventory row, CancellationToken ct) => { row.InventoryId = 901; return row; }); + _adapter = new RmsInventoryUsageAdapter(_referencesRepo.Object, _records.Object, _incidents.Object, _legacy.Object, _authorization.Object, + groups.Object, Mock.Of(), _uow.Object, _auditRepo.Object, _modernStore.Object, _stock.Object, _catalog.Object, _outbox.Object); + } + + [TestCase(RmsRecordKind.Operational), TestCase(RmsRecordKind.IncidentReport)] + public async Task Modern_consumption_joins_stock_to_parent_version_clamps_provenance_and_dispatches_after_commit(RmsRecordKind kind) + { + var command = Command(); command.Lines[0].ReferenceType = InventoryReferenceType.WorkOrder; command.Lines[0].ReferenceId = "999"; + var result = await _adapter.ConsumeModernAsync(_actor, RecordId, kind, 1, command); + var captured = _posts.Single(); captured.Actor.DepartmentId.Should().Be(Department); captured.Actor.UserId.Should().Be("author"); captured.Actor.GrantToken.Should().Be(_actor.GrantToken); + captured.Command.RequestId.Should().Be(command.RequestId); var posting = captured.Command.Lines.Single(); posting.ReferenceType.Should().Be(InventoryReferenceType.RmsRecord); posting.ReferenceId.Should().Be(RecordId); + command.Lines[0].ReferenceType.Should().Be(InventoryReferenceType.WorkOrder, "the adapter must copy the caller's mutable command"); command.Lines[0].ReferenceId.Should().Be("999"); + _balance.Should().Be(7.874999m); (kind == RmsRecordKind.Operational ? _record.RowVersion : _incident.RowVersion).Should().Be(2); + result.TransactionId.Should().Be(_ledger.Single().Id); result.ItemId.Should().Be(ItemId); result.InventoryId.Should().Be(0); + var reference = _references.Single(); reference.RmsExternalReferenceId.Should().Be(command.RequestId); reference.SourceEntityType.Should().Be("InventoryTransaction"); reference.SourceEntityId.Should().Be(result.TransactionId); + JObject.Parse(reference.SnapshotJson).Value("SchemaVersion").Should().Be(2); reference.SnapshotJson.Should().NotContain(Canary).And.NotContain(_actor.GrantToken); + result.ItemName.Should().Be("REDACTED"); result.Note.Should().Be("REDACTED"); result.SourceChecksum.Should().HaveLength(64); _audits.Single().DetailJson.Should().NotContain(Canary); + _dispatches.Single().Should().Equal(101); _stock.Verify(s => s.PostTransactionAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _legacy.Verify(i => i.SaveInventoryAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Receipt_retry_rechecks_current_grant_and_source_without_rebumping_parent_or_consuming_twice() + { + var command = Command(); var first = await _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command); + var renewed = new InventoryActor { DepartmentId = Department, UserId = "author", GrantToken = "synthetic-renewed-grant" }; + var retry = await _adapter.ConsumeModernAsync(renewed, RecordId, RmsRecordKind.Operational, 1, Copy(command)); + retry.TransactionId.Should().Be(first.TransactionId); _record.RowVersion.Should().Be(2); _balance.Should().Be(7.874999m); + _posts.Should().HaveCount(1); _references.Should().HaveCount(1); _audits.Should().HaveCount(1); _dispatches.Should().HaveCount(1); + _records.Verify(r => r.TryBumpRowVersionAsync(Department, RecordId, 1, It.IsAny()), Times.Once); + _catalog.Verify(c => c.GetAsync(It.Is(a => a.GrantToken == "synthetic-renewed-grant"), first.TransactionId), Times.Once); + command.Lines[0].Quantity = 3; + var error = (await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command))).Should().ThrowAsync()).Which; + error.Code.Should().Be("RequestConflict"); _balance.Should().Be(7.874999m); _record.RowVersion.Should().Be(2); + } + + [Test] + public async Task Audit_failure_rolls_back_parent_stock_ledger_and_every_new_reference() + { + _auditRepo.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Synthetic audit unavailable")); + await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, Command()))).Should().ThrowAsync(); + _record.RowVersion.Should().Be(1); _balance.Should().Be(10); _ledger.Should().BeEmpty(); _references.Should().BeEmpty(); _audits.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + _uow.Verify(u => u.CommitChanges(), Times.Never); _uow.Verify(u => u.DiscardChanges(), Times.Once); + } + + [Test] + public async Task Source_authorization_and_closed_parent_block_consumption_and_replayed_receipts() + { + var command = Command(); _authorization.Setup(a => a.CanUseSourceInventoryAsync("author", Department, 7)).ReturnsAsync(false); + await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command))).Should().ThrowAsync(); + _record.RowVersion.Should().Be(1); _posts.Should().BeEmpty(); + _authorization.Setup(a => a.CanUseSourceInventoryAsync("author", Department, 7)).ReturnsAsync(true); + await _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command); _record.State = (int)RmsRecordState.Finalized; + await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command))).Should().ThrowAsync(); + _posts.Should().HaveCount(1); _balance.Should().Be(7.874999m); + } + + [Test] + public async Task Legacy_input_translates_after_cutover_with_deterministic_request_and_preserves_pre_cutover_behavior() + { + _migrated = false; + var legacy = await _adapter.ConsumeAsync(Department, "author", RecordId, RmsRecordKind.Operational, 1, 1, 7, null, 2.5m, "Synthetic usage"); + legacy.InventoryId.Should().Be(901); legacy.ItemName.Should().Be("Synthetic foam"); _posts.Should().BeEmpty(); + _legacy.Verify(i => i.SaveInventoryAsync(It.Is(r => r.Amount == -2.5 && r.DepartmentId == Department), It.IsAny()), Times.Once); + _migrated = true; + var modern = await _adapter.ConsumeAsync(Department, "author", RecordId, RmsRecordKind.Operational, 2, 1, 7, null, 1.125001m, Canary, grantToken: _actor.GrantToken); + var retry = await _adapter.ConsumeAsync(Department, "author", RecordId, RmsRecordKind.Operational, 2, 1, 7, null, 1.125001m, Canary, grantToken: "new-grant"); + modern.TransactionId.Should().NotBeNullOrEmpty(); retry.TransactionId.Should().Be(modern.TransactionId); _posts.Should().HaveCount(1); + _posts.Single().Actor.GrantToken.Should().Be(_actor.GrantToken); _record.RowVersion.Should().Be(3); + (await _adapter.GetUsageForRecordAsync(Department, RecordId)).Should().HaveCount(2); + } + + [Test] + public async Task Protected_source_denial_or_incomplete_witness_result_rolls_back_parent_and_dispatches_nothing() + { + _catalog.Setup(c => c.GetAsync(It.IsAny(), ItemId)).ThrowsAsync(new InventoryException(403, "ProtectedDataRequired")); + await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, Command()))).Should().ThrowAsync(); + _record.RowVersion.Should().Be(1); _posts.Should().BeEmpty(); + _catalog.Setup(c => c.GetAsync(It.IsAny(), ItemId)).ReturnsAsync(new InventoryItem { Id = ItemId, DepartmentId = Department }); + _stock.Setup(s => s.PostWithinTransactionAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryResult { AwaitingWitness = true }); + await ((Func)(() => _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, Command()))).Should().ThrowAsync(); + _record.RowVersion.Should().Be(1); _references.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + } + + private static T Copy(T value) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + private static InventoryCommand Command() => new() { RequestId = Guid.NewGuid().ToString("D"), Lines = new() { new InventoryPosting { + ItemId = ItemId, FromLocationId = LocationId, Type = InventoryTransactionType.Consume, Quantity = 2.125001m, Note = Canary } } }; + private void Begin() + { + _transaction.Should().BeNull(); _transaction = new Mock().Object; + var references = _references.Select(Copy).ToList(); var audits = _audits.Select(Copy).ToList(); var ledger = _ledger.Select(Copy).ToList(); + var record = Copy(_record); var incident = Copy(_incident); var balance = _balance; + _rollback = () => { _references = references; _audits = audits; _ledger = ledger; _record = record; _incident = incident; _balance = balance; }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs new file mode 100644 index 000000000..a76dc0d9c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class WorkOrderAuthorizationTests + { + [TestCase(false, 403, "ProtectedDataRequired")] + [TestCase(true, 403, "ProtectedDataRequired")] + [TestCase(false, 403, "MembershipRequired")] + [TestCase(true, 403, "MembershipRequired")] + public async Task Inventory_asset_access_failures_use_the_work_order_error_contract(bool validateTarget, int statusCode, string code) + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "manager" }; + var assetId = Guid.NewGuid().ToString("D"); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true)) + .ReturnsAsync(new DepartmentMember { DepartmentId = actor.DepartmentId, UserId = actor.UserId }); + var assignments = new Mock(); + assignments.Setup(a => a.ChoicesAsync(actor)).ReturnsAsync(new List()); + var assets = new Mock(); + assets.Setup(a => a.IsAvailableAsync(actor.DepartmentId)).ReturnsAsync(true); + assets.Setup(a => a.GetAsync(actor, assetId)).ThrowsAsync(new ChecklistException(statusCode, code)); + assets.Setup(a => a.ListAsync(actor)).ThrowsAsync(new ChecklistException(statusCode, code)); + var service = new WorkOrderAuthorizationService(departments.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), assignments.Object, assets.Object); + + Func action = validateTarget + ? () => service.ValidateTargetAsync(actor, new WorkOrderInput { InventoryAssetId = assetId }) + : async () => { await service.ChoicesAsync(actor); }; + var error = (await action.Should().ThrowAsync()).Which; + error.StatusCode.Should().Be(statusCode); + error.Code.Should().Be(code); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs index e30756a00..d736df7eb 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs @@ -169,6 +169,12 @@ string Type(Type t) => t == typeof(int) ? "int" : t == typeof(long) ? "bigint" : (await store.PaymentsAsync(77, account.PlanAddonId)).Should().ContainSingle(); (await store.PaymentsAsync(88, account.PlanAddonId)).Should().BeEmpty(); payment.IsCancelled = true; await uow.CreateOrGetConnectionAsync(); await store.SavePaymentAsync(payment, false); uow.DiscardChanges(); (await store.PaymentsAsync(77, account.PlanAddonId)).Single().IsCancelled.Should().BeFalse(); + var originalId = payment.PaymentAddonId; + payment.PaymentAddonId = Guid.NewGuid().ToString(); await uow.CreateOrGetConnectionAsync(); + await FluentActions.Awaiting(() => store.SavePaymentAsync(payment, false)).Should().ThrowAsync(); uow.DiscardChanges(); + payment.PaymentAddonId = originalId; payment.PlanAddonId = Guid.NewGuid().ToString(); await uow.CreateOrGetConnectionAsync(); + await FluentActions.Awaiting(() => store.SavePaymentAsync(payment, false)).Should().ThrowAsync(); uow.DiscardChanges(); + (await store.PaymentsAsync(77, account.PlanAddonId)).Single().IsCancelled.Should().BeFalse(); } [Test, Order(1000)] public void Populated_migration_refuses_destructive_rollback() diff --git a/Tests/Resgrid.Tests/Services/WorkOrderEvidenceTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderEvidenceTests.cs index 5eac33bc7..5372df030 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderEvidenceTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderEvidenceTests.cs @@ -38,10 +38,12 @@ public async Task Evidence_requires_clean_scan_preserves_withdrawn_bytes_and_che _scanner.Setup(s=>s.ScanAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult {State=RmsAttachmentScanState.Clean}); await _service.AddFileAsync(_actor,id,1,"synthetic.pdf","application/pdf",bytes);row=await _service.GetAsync(_actor,id); var file=row.Files.Single();(await _service.GetFileAsync(_actor,file.Id)).Data.Should().Equal(bytes); - await _service.WithdrawFileAsync(_actor,id,file.Id,row.Order.Revision,"Superseded"); - (await _service.GetFileAsync(_actor,file.Id)).Data.Should().Equal(bytes); var stored=await _store.GetAsync(77,file.Id);stored.Data[0]=0;await _store.WriteAsync(stored); await FluentActions.Awaiting(()=>_service.GetFileAsync(_actor,file.Id)).Should().ThrowAsync().Where(e=>e.Code=="IntegrityFailed"); + stored.Data=bytes;await _store.WriteAsync(stored); + await _service.WithdrawFileAsync(_actor,id,file.Id,row.Order.Revision,"Superseded"); + await FluentActions.Awaiting(()=>_service.GetFileAsync(_actor,file.Id)).Should().ThrowAsync().Where(e=>e.StatusCode==404); + (await _store.GetAsync(77,file.Id)).Data.Should().Equal(bytes); } } } diff --git a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs index 04837a39f..f6cadf689 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs @@ -43,7 +43,7 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), - new Lazy(()=>new ReadinessHistoryProtectionService(Mock.Of(),policy.Object)),reminders.Object,store.Object); + new Lazy(()=>new ReadinessHistoryProtectionService(Mock.Of(),policy.Object)),reminders.Object,store.Object,EmptyInventory()); var files = await RunExportAsync(); files["workorders.json"].Should().Contain("REDACTED").And.NotContain("CANARY").And.NotContain("AQID"); } diff --git a/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs index e7a440112..5e4c9771c 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs @@ -20,22 +20,24 @@ namespace Resgrid.Tests.Services [TestFixture] public sealed class WorkOrderNotificationTests { - [TestCase(false),TestCase(true)] - public async Task Current_role_members_or_triage_managers_receive_localized_metadata_only_once(bool triage) + [TestCase(false, false),TestCase(true, false),TestCase(false, true),TestCase(true, true)] + public async Task Current_role_members_or_triage_managers_receive_localized_metadata_only_once(bool triage, bool revoked) { var store=new Mock(); var auth=new Mock(); var access=new Mock();access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(true); var uow=new Mock();uow.Setup(u=>u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync((DbConnection)null); var communication=new Mock();communication.SetReturnsDefault(Task.FromResult(true)); var departments=new Mock();departments.Setup(d=>d.GetDepartmentByIdAsync(77,true)).ReturnsAsync(new Department {DepartmentId=77}); + departments.Setup(d=>d.GetDepartmentMemberAsync(It.IsAny(),77,true)).ReturnsAsync((string user,int department,bool fresh)=>new DepartmentMember {DepartmentId=department,UserId=user,IsDisabled=revoked && user!="requester"}); departments.Setup(d=>d.GetAllMembersForDepartmentUnlimitedAsync(77,true)).ReturnsAsync(new List { new DepartmentMember {DepartmentId=77,UserId="requester"},new DepartmentMember {DepartmentId=77,UserId="manager"}, new DepartmentMember {DepartmentId=77,UserId="tech1"},new DepartmentMember {DepartmentId=77,UserId="tech2"} }); auth.Setup(a=>a.CanManageAsync(It.IsAny(),It.IsAny())).ReturnsAsync((ChecklistActor a,int? g)=>a.UserId=="manager"); auth.Setup(a=>a.RecipientsAsync(77,It.IsAny())).ReturnsAsync(triage ? new List() : new List{"tech1","tech2"}); + auth.Setup(a=>a.ScopeAsync(It.IsAny())).ReturnsAsync((ChecklistActor a)=>new WorkOrderReadScope {UserId=a.UserId,RoleIds=a.UserId.StartsWith("tech")?new[]{3}:Array.Empty()}); var profiles=new Mock();profiles.Setup(p=>p.GetProfileByUserIdAsync(It.IsAny(), false)).ReturnsAsync(new UserProfile {Language="fr"}); - var row=new WorkOrder {Id=19,DepartmentId=77,CreatedBy="requester",Status=triage?0:2,Content="SYNTHETIC-PHI-CANARY"}; + var row=new WorkOrder {Id=19,DepartmentId=77,CreatedBy="requester",Status=triage?0:2,AssignedToRoleId=triage?null:3,Content="SYNTHETIC-PHI-CANARY"}; store.Setup(s=>s.GetAsync(77,19,true)).ReturnsAsync(row); var states=new Dictionary(); store.Setup(s=>s.ClaimNotificationAsync(It.IsAny(),It.IsAny())).ReturnsAsync((WorkOrderNotification n,DateTime now)=>states.TryGetValue(n.UserId,out var state)?state:1); @@ -43,8 +45,10 @@ public async Task Current_role_members_or_triage_managers_receive_localized_meta var service=new WorkOrderNotificationService(store.Object,auth.Object,access.Object,uow.Object,communication.Object,departments.Object,Mock.Of(),profiles.Object); var entry=new DomainEventOutboxEntry {DepartmentId=77,ProducerSubsystem="WorkOrders",AggregateId="19",EventId=Guid.NewGuid().ToString(),TriggerEventType=triage?70:72}; await service.DispatchAsync(entry); await service.DispatchAsync(entry); + departments.Verify(d=>d.GetAllMembersForDepartmentUnlimitedAsync(77,true),Times.Exactly(2)); + auth.Verify(a=>a.RecipientsAsync(77,It.IsAny()),Times.Exactly(2)); var sends=communication.Invocations.Where(i=>i.Method.Name=="SendNotificationAsync").ToList(); - sends.Select(i=>(string)i.Arguments[0]).Should().BeEquivalentTo(triage?new[]{"requester","manager"}:new[]{"requester","tech1","tech2"}); + sends.Select(i=>(string)i.Arguments[0]).Should().BeEquivalentTo(revoked?new[]{"requester"}:triage?new[]{"requester","manager"}:new[]{"requester","tech1","tech2"}); foreach(var send in sends) ((string)send.Arguments[2]).Should().Contain("nécessite votre attention").And.NotContain("CANARY").And.NotContain("grant"); access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); entry.EventId=Guid.NewGuid().ToString();await service.DispatchAsync(entry); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs index 0a85ad1f7..54d7c0395 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs @@ -54,7 +54,7 @@ public void Setup() _uow.Setup(u => u.DiscardChanges()).Callback(() => _store.Rollback()); _service = new WorkOrdersService(_store, _auth.Object, _access.Object, _uow.Object, audit.Object, _outbox.Object, new Lazy(() => _read.Object), new Lazy(() => _write.Object), _scanner.Object); } - private static WorkOrderInput Input(bool safety = false) => new WorkOrderInput { Content = new WorkOrderContent { Title = "Synthetic equipment repair", Description = "PII-PHI-CANARY narrative", SafetyCritical = safety } }; + private static WorkOrderInput Input(bool safety = false) => new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D"), Content = new WorkOrderContent { Title = "Synthetic equipment repair", Description = "PII-PHI-CANARY narrative", SafetyCritical = safety } }; private async Task Transition(int id, WorkOrderStatus status, ChecklistActor actor = null, string reason = null, string evidence = null) { actor ??= _actor; var current = await _service.GetAsync(actor, id); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs new file mode 100644 index 000000000..8200d7f73 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderP2M1Tests + { + [TestCase(null), TestCase(""), TestCase("not-a-guid")] + public async Task Creation_requires_a_caller_owned_retry_identifier(string requestId) + { + var input = Input(); input.RequestId = requestId; + await FluentActions.Awaiting(() => _service.CreateAsync(_actor, input)).Should().ThrowAsync().Where(e => e.StatusCode == 400); + _store.All().Should().BeEmpty(); + JsonConvert.DeserializeObject("{}").RequestId.Should().BeNull(); + } + + [TestCase(null), TestCase(125)] + public async Task Requester_edits_preserve_the_existing_approved_cost(decimal? submittedCost) + { + var input = Input(); input.Content.ApprovedCost = 125; + var detail = await _service.CreateAsync(_actor, input); + _auth.Setup(a => a.CanManageAsync(_actor, It.IsAny())).ReturnsAsync(false); + detail.Input.Content.ApprovedCost = submittedCost; detail.Input.Content.Title = "Updated title"; + await _service.UpdateAsync(_actor, detail.Order.Id, detail.Input); + var updated = await _service.GetAsync(_actor, detail.Order.Id); + updated.Input.Content.ApprovedCost.Should().Be(125); + updated.Input.Content.Title.Should().Be("Updated title"); + updated.Input.Content.ApprovedCost = 999; + await FluentActions.Awaiting(() => _service.UpdateAsync(_actor, detail.Order.Id, updated.Input)).Should().ThrowAsync().Where(e => e.StatusCode == 403); + } + + [TestCase("fr-FR"), TestCase("ar-SA"), TestCase("th-TH")] + public void Workflow_dates_keep_the_same_UTC_instant_for_typed_and_string_values(string culture) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + var expected = new DateTime(2026, 9, 3, 12, 34, 56, DateTimeKind.Utc); + foreach (var value in new JToken[] { new JValue(expected), new JValue(new DateTimeOffset(expected).ToOffset(TimeSpan.FromHours(2))), new JValue("2026-09-03T14:34:56+02:00") }) + { + var result = JObject.Parse(WorkOrderWorkflowPayload.Routing(new JObject { ["DueOn"] = value })); + ((DateTimeOffset)result["DueOn"]).UtcDateTime.Should().Be(expected); + } + } + finally { CultureInfo.CurrentCulture = previous; } + } + } + + [TestFixture] + public class WorkOrderAuthorizationContextTests + { + [TestCase(PermissionActions.DepartmentAdminsOnly, false, false, false, false)] + [TestCase(PermissionActions.DepartmentAdminsOnly, true, false, false, true)] + [TestCase(PermissionActions.DepartmentAndGroupAdmins, false, true, false, true)] + [TestCase(PermissionActions.DepartmentAndGroupAdmins, false, false, true, false)] + [TestCase(PermissionActions.DepartmentAdminsAndSelectRoles, false, false, true, true)] + [TestCase(PermissionActions.DepartmentAdminsAndSelectRoles, false, true, false, false)] + [TestCase(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, false, true, false, true)] + [TestCase(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, false, false, true, true)] + [TestCase(PermissionActions.Everyone, false, false, false, true)] + [TestCase((PermissionActions)999, false, true, true, false)] + public async Task Scope_and_choices_preserve_permission_roles_group_locks_and_fresh_revocation(PermissionActions action, bool admin, bool groupAdmin, bool hasRole, bool allowed) + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "member" }; + var member = new DepartmentMember { DepartmentId = 77, UserId = actor.UserId, IsAdmin = admin }; + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync(actor.UserId, 77, true)).ReturnsAsync(member); + departments.Setup(d => d.GetDepartmentByIdAsync(77, true)).ReturnsAsync(new Department { DepartmentId = 77 }); + var groups = new Mock(); + groups.Setup(g => g.GetGroupForUserAsync(actor.UserId, 77)).ReturnsAsync(new DepartmentGroup { DepartmentId = 77, DepartmentGroupId = 10, + Members = new List { new DepartmentGroupMember { UserId = actor.UserId, IsAdmin = groupAdmin } } }); + var roles = new Mock(); + roles.Setup(r => r.GetRolesForUserAsync(actor.UserId, 77)).ReturnsAsync(hasRole ? new List { new PersonnelRole { DepartmentId = 77, PersonnelRoleId = 3 } } : new List()); + var permissions = new Mock(); + permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(77, It.IsAny())).ReturnsAsync(new Permission { Action = (int)action, Data = "3", LockToGroup = true }); + var assignments = new Mock(); + assignments.Setup(a => a.ChoicesAsync(actor)).ReturnsAsync(new List { new() { Type = 3, Id = "10", Name = "Own group" }, new() { Type = 3, Id = "20", Name = "Other group" } }); + var service = new WorkOrderAuthorizationService(departments.Object, groups.Object, roles.Object, permissions.Object, Mock.Of(), Mock.Of(), assignments.Object); + var scope = await service.ScopeAsync(actor); + departments.Verify(d => d.GetDepartmentMemberAsync(actor.UserId, 77, true), Times.Once); + departments.Verify(d => d.GetDepartmentByIdAsync(77, true), Times.Once); + groups.Verify(g => g.GetGroupForUserAsync(actor.UserId, 77), Times.Once); + roles.Verify(r => r.GetRolesForUserAsync(actor.UserId, 77), Times.Once); + permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.ViewAllWorkOrders), Times.Once); + permissions.Verify(p => p.GetPermissionByDepartmentTypeAsync(77, PermissionTypes.ManageWorkOrders), Times.AtMostOnce); + scope.All.Should().Be(admin); scope.GroupId.Should().Be(allowed ? 10 : null); + (await service.CanManageAsync(actor, 10)).Should().Be(allowed); + (await service.CanManageAsync(actor, 20)).Should().Be(admin); + (await service.ChoicesAsync(actor)).Groups.Should().HaveCount(admin ? 2 : 1); + member.IsDisabled = true; + await FluentActions.Awaiting(() => service.ScopeAsync(actor)).Should().ThrowAsync().Where(e => e.Code == "MembershipRequired"); + await FluentActions.Awaiting(() => service.ChoicesAsync(actor)).Should().ThrowAsync().Where(e => e.Code == "MembershipRequired"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/InventoryWorkspaceTests.cs b/Tests/Resgrid.Tests/Web/User/InventoryWorkspaceTests.cs new file mode 100644 index 000000000..a3b47fb90 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/InventoryWorkspaceTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Inventory; +using Resgrid.Web.Helpers; +using InventoryStrings = Resgrid.Localization.Areas.User.Inventory.Inventory; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture, NonParallelizable] + public sealed class InventoryWorkspaceTests + { + private const int DepartmentId = 77; + private const string UserId = "inventory-member"; + private const string ItemId = "11111111-1111-1111-1111-111111111111"; + private const string LocationId = "22222222-2222-2222-2222-222222222222"; + private const string AssetId = "33333333-3333-3333-3333-333333333333"; + private Mock _catalog; + private Mock _authorization; + private Mock _migration; + private Mock _protection; + private InventoryController _controller; + private DefaultHttpContext _http; + private IHttpContextAccessor _previousAccessor; + + [SetUp] + public void SetUp() + { + _catalog = new(); _authorization = new(); _migration = new(); _protection = new(); + _authorization.Setup(s => s.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + _authorization.Setup(s => s.CanLocationAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _migration.Setup(s => s.IsMigratedAsync(DepartmentId)).ReturnsAsync(true); + Empty(); Empty(); Empty(); Empty(); Empty(); + Empty(); Empty(); Empty(); Empty(); Empty(); + _catalog.Setup(s => s.GetAsync(It.IsAny(), ItemId)).ReturnsAsync(new InventoryItem { Id = ItemId }); + _catalog.Setup(s => s.GetAsync(It.IsAny(), LocationId)).ReturnsAsync(new InventoryLocation { Id = LocationId }); + _catalog.Setup(s => s.GetAsync(It.IsAny(), AssetId)).ReturnsAsync(new InventoryAsset { Id = AssetId, ItemId = ItemId }); + var units = new Mock(); units.Setup(s => s.GetUnitsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + var groups = new Mock(); groups.Setup(s => s.GetAllGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + groups.Setup(s => s.GetGroupForUserAsync(UserId, DepartmentId)).ReturnsAsync(new DepartmentGroup { DepartmentId = DepartmentId, DepartmentGroupId = 9 }); + var departments = new Mock(); departments.Setup(s => s.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + var strings = new Mock>(); strings.Setup(s => s[It.IsAny()]).Returns((string key) => new LocalizedString(key, key)); + _http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, UserId), new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) }, "Test")) }; + _http.Request.Method = "GET"; + var accessor = new HttpContextAccessor { HttpContext = _http }; _previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; ClaimsAuthorizationHelper._httpContextAccessor = accessor; + _controller = new InventoryController(_catalog.Object, Mock.Of(), Mock.Of(), Mock.Of(), + _migration.Object, _authorization.Object, new HttpProtectedGrantContext(accessor), _protection.Object, units.Object, groups.Object, departments.Object, strings.Object) + { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + + private void Empty() where T : InventoryRow + { + _catalog.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _catalog.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + } + private static InventoryWorkspaceView Model(IActionResult result) => (InventoryWorkspaceView)((ViewResult)result).Model; + private ActionExecutingContext Context(Dictionary arguments = null) => new(new ActionContext(_http, new RouteData(), new ActionDescriptor()), new List(), arguments ?? new(), _controller); + + [TestCase(PermissionTypes.IssueInventory)] + [TestCase(PermissionTypes.TransferInventory)] + [TestCase(PermissionTypes.ManageControlledSubstances)] + public async Task Specialized_permissions_expose_their_action_without_general_adjust_access(PermissionTypes granted) + { + _authorization.Setup(s => s.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((InventoryActor actor, bool write, PermissionTypes? permission, int? group) => !write || permission == granted ? Task.CompletedTask : Task.FromException(new InventoryException(403, "PermissionRequired"))); + var view = Model(await _controller.Index()); + view.CanWrite.Should().BeFalse(); view.CanIssue.Should().Be(granted == PermissionTypes.IssueInventory); + view.CanTransfer.Should().Be(granted == PermissionTypes.TransferInventory); view.CanWitness.Should().Be(granted == PermissionTypes.ManageControlledSubstances); + _authorization.Verify(s => s.RequireAsync(It.Is(a => a.DepartmentId == DepartmentId && a.UserId == UserId), true, granted, 9), Times.Once); + } + + [Test] + public async Task Protected_GET_keeps_structural_selection_for_attended_unlock_without_exposing_rows() + { + _protection.Setup(s => s.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(true); + var context = Context(new() { ["tab"] = "History", ["page"] = 3, ["id"] = AssetId, ["unitId"] = 42, ["userId"] = "holder", ["itemId"] = ItemId, ["locationId"] = LocationId }); + var executed = new ActionExecutedContext(context, new List(), _controller) { Exception = new InventoryException(409, "ProtectedDataRequired") }; + await _controller.OnActionExecutionAsync(context, () => Task.FromResult(executed)); + executed.ExceptionHandled.Should().BeTrue(); var view = Model(executed.Result); + view.Locked.Should().BeTrue(); view.Tab.Should().Be("History"); view.Page.Should().Be(3); view.Id.Should().Be(AssetId); + view.UnitId.Should().Be(42); view.UserId.Should().Be("holder"); view.ItemId.Should().Be(ItemId); view.LocationId.Should().Be(LocationId); + view.Rows.Should().BeEmpty(); view.Items.Should().BeEmpty(); _http.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + } + + [TestCase("OnHand")] + [TestCase("History")] + public async Task Reopen_keeps_request_grant_expiry_and_passes_item_location_filters_before_paging(string tab) + { + _http.Request.Method = "POST"; _http.Request.ContentType = "application/x-www-form-urlencoded"; + _http.Request.QueryString = new QueryString("?grantToken=forged&userId=other&departmentId=88"); + _http.Request.Form = new FormCollection(new Dictionary { [HttpProtectedGrantContext.FormFieldName] = "synthetic-form-grant", [HttpProtectedGrantContext.ExpiresOnFormFieldName] = "2030-01-01T00:00:00Z" }); + _protection.Setup(s => s.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(true); + var context = Context(); var executed = new ActionExecutedContext(context, new List(), _controller); + await _controller.OnActionExecutionAsync(context, async () => { executed.Result = await _controller.Reopen(tab, page: 2, itemId: ItemId, locationId: LocationId); return executed; }); + var view = Model(executed.Result); view.ItemId.Should().Be(ItemId); view.LocationId.Should().Be(LocationId); view.Page.Should().Be(2); + ((string)_controller.ViewBag.ProtectedGrant).Should().Be("synthetic-form-grant"); ((DateTime?)_controller.ViewBag.GrantExpiresOn).Should().Be(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + if (tab == "OnHand") _catalog.Verify(s => s.QueryAsync(It.Is(a => a.DepartmentId == DepartmentId && a.UserId == UserId && a.GrantToken == "synthetic-form-grant"), It.Is(q => q.ItemId == ItemId && q.LocationId == LocationId), 2), Times.Once); + else _catalog.Verify(s => s.QueryAsync(It.Is(a => a.DepartmentId == DepartmentId && a.UserId == UserId && a.GrantToken == "synthetic-form-grant"), It.Is(q => q.ItemId == ItemId && q.LocationId == LocationId), 2), Times.Once); + } + + [Test] + public async Task Asset_history_and_personnel_gear_pass_subject_filter_before_paging() + { + var transaction = new InventoryTransaction { AssetId = AssetId, ItemId = ItemId, EntryId = 900 }; + _catalog.Setup(s => s.QueryAsync(It.IsAny(), It.Is(q => q.AssetId == AssetId), 4)).ReturnsAsync(new InventoryPage { Items = new() { transaction }, HasMore = true }); + var asset = Model(await _controller.Index("AssetDetail", page: 4, id: AssetId)); + asset.Rows.Should().Contain(transaction); asset.HasMore.Should().BeTrue(); + await _controller.Index("PersonnelGear", page: 3, userId: "holder"); + _catalog.Verify(s => s.QueryAsync(It.IsAny(), It.Is(q => q.IssuedToUserId == "holder"), 3), Times.Once); + _catalog.Verify(s => s.ListAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Kits_load_complete_BOMs_for_each_visible_kit_instead_of_a_global_first_page() + { + var first = new InventoryKit(); var second = new InventoryKit(); + _catalog.Setup(s => s.ListAsync(It.IsAny(), 0)).ReturnsAsync(new InventoryPage { Items = new() { first, second } }); + var firstLine = new InventoryKitItem { KitId = first.Id, ItemId = ItemId, Quantity = 1 }; + var otherLine = new InventoryKitItem { KitId = second.Id, ItemId = ItemId, Quantity = 3 }; + _catalog.Setup(s => s.QueryAsync(It.IsAny(), It.Is(q => q.KitId == first.Id), 0)).ReturnsAsync(new InventoryPage { Items = new() { firstLine } }); + _catalog.Setup(s => s.QueryAsync(It.IsAny(), It.Is(q => q.KitId == second.Id), 0)).ReturnsAsync(new InventoryPage { Items = new() { otherLine } }); + var view = Model(await _controller.Index("Kits")); + view.KitContents.Should().BeEquivalentTo(new[] { firstLine, otherLine }); + view.Items.Select(item => item.Id).Should().Contain(ItemId); + _catalog.Verify(s => s.GetAsync(It.IsAny(), ItemId), Times.Once); + _catalog.Verify(s => s.ListAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Incomplete_kit_contents_fail_closed_before_an_edit_form_can_replace_the_BOM() + { + var kit = new InventoryKit(); + _catalog.Setup(s => s.ListAsync(It.IsAny(), 0)).ReturnsAsync(new InventoryPage { Items = new() { kit } }); + _catalog.Setup(s => s.QueryAsync(It.IsAny(), It.Is(q => q.KitId == kit.Id), 0)).ReturnsAsync(new InventoryPage { Items = new() { new InventoryKitItem { KitId = kit.Id, ItemId = ItemId, Quantity = 1 } }, HasMore = true }); + Func open = async () => await _controller.Index("Kits"); + (await open.Should().ThrowAsync()).Which.Code.Should().Be("InventoryTooLarge"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs index cf1117ae6..caf5a7651 100644 --- a/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs @@ -26,6 +26,7 @@ public class SecurityControllerTests private const string UserId = "audit-admin"; private Mock _departmentsService; private Mock _auditService; + private Mock _permissionsService; private SecurityController _controller; [SetUp] @@ -33,6 +34,7 @@ public void SetUp() { _departmentsService = new Mock(); _auditService = new Mock(); + _permissionsService = new Mock(); var httpContext = new DefaultHttpContext { @@ -51,7 +53,7 @@ public void SetUp() _controller = new SecurityController( _departmentsService.Object, _auditService.Object, - Mock.Of(), + _permissionsService.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -148,6 +150,35 @@ public async Task GetAuditLogsList_ProvidesChronologicalSortKeyAndDecisionColumn entries.Single(x => x.AuditLogId == 3).TimestampSort.Should().BeNull(); } + [TestCase(PermissionTypes.TransferInventory)] + [TestCase(PermissionTypes.IssueInventory)] + public async Task GetRolesForPermission_MissingInventoryRule_DisplaysInheritedAdjustmentRoles(PermissionTypes permission) + { + _permissionsService.Setup(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, permission)).ReturnsAsync((Permission)null); + _permissionsService.Setup(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory)) + .ReturnsAsync(new Permission { DepartmentId = DepartmentId, PermissionType = (int)PermissionTypes.AdjustInventory, Data = "3,7" }); + + var result = await _controller.GetRolesForPermission((int)permission); + + result.Should().BeOfType().Subject.Value.Should().Be("3,7"); + _permissionsService.Verify(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory), Times.Once); + } + + [TestCase(PermissionTypes.TransferInventory)] + [TestCase(PermissionTypes.IssueInventory)] + public async Task GetRolesForPermission_ExplicitEmptyInventoryRule_DoesNotInheritAdjustmentRoles(PermissionTypes permission) + { + _permissionsService.Setup(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, permission)) + .ReturnsAsync(new Permission { DepartmentId = DepartmentId, PermissionType = (int)permission, Data = "" }); + _permissionsService.Setup(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory)) + .ReturnsAsync(new Permission { DepartmentId = DepartmentId, PermissionType = (int)PermissionTypes.AdjustInventory, Data = "3,7" }); + + var result = await _controller.GetRolesForPermission((int)permission); + + result.Should().BeOfType().Subject.Value.Should().Be(""); + _permissionsService.Verify(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory), Times.Never); + } + [Test] public async Task ViewAudit_ReturnsCompleteAuditEntryAndFriendlyTypeName() { diff --git a/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs b/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs new file mode 100644 index 000000000..5fc2f0920 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs @@ -0,0 +1,153 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +// A deliberately small DOM adapter executes the shipped script without a browser/package dependency. +// It models only selectors and native form operations used by the inventory page. +class Element { + constructor(tag, properties = {}) { + this.tagName = tag.toLowerCase(); this.children = []; this.parentElement = null; + this.dataset = {}; this.listeners = {}; this.className = ''; this.id = ''; this.name = ''; + this.value = ''; this.type = ''; this.method = 'post'; this.textContent = ''; this.hidden = false; + this.submissions = []; Object.assign(this, properties); + this.classList = { contains: name => this.className.split(/\s+/).includes(name) }; + } + appendChild(child) { child.parentElement = this; this.children.push(child); return child; } + get firstElementChild() { return this.children[0]; } + matches(selector) { + const attribute = selector.match(/\[([^\]$=]+)(\$?=)?(?:"([^"]*)")?\]/); + const head = attribute ? selector.replace(attribute[0], '') : selector; + const [tag, className] = head.split('.'); + if (tag && tag !== this.tagName) return false; + if (className && !this.classList.contains(className)) return false; + if (!attribute) return true; + const value = attribute[1] === 'for' ? this.htmlFor : this[attribute[1]]; + return attribute[2] === '$=' ? String(value || '').endsWith(attribute[3]) : attribute[2] === '=' ? value === attribute[3] : !!value; + } + querySelectorAll(selector) { + const matches = [], selectors = selector.split(',').map(value => value.trim()); + for (const child of this.children) { + if (selectors.some(value => child.matches(value))) matches.push(child); + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + closest(selector) { return this.matches(selector) ? this : this.parentElement?.closest(selector) || null; } + addEventListener(name, listener) { (this.listeners[name] ||= []).push(listener); } + async emit(name, target = this) { + const event = { target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + for (const listener of this.listeners[name] || []) await listener(event); + } + remove() { this.parentElement.children.splice(this.parentElement.children.indexOf(this), 1); this.parentElement = null; } + cloneNode(deep) { + const copy = new Element(this.tagName); + for (const key of ['id', 'name', 'value', 'type', 'className', 'htmlFor', 'disabled']) copy[key] = this[key]; + if (deep) this.children.forEach(child => copy.appendChild(child.cloneNode(true))); + return copy; + } + submit() { this.submissions.push(Object.fromEntries(new FormData(this))); } + replaceChildren() { this.children = []; } +} +class FormData { + constructor(form) { this.values = form.querySelectorAll('[name]').map(field => [field.name, field.value]); } + [Symbol.iterator]() { return this.values[Symbol.iterator](); } +} +const field = (form, name, value, tag = 'input') => form.appendChild(new Element(tag, { name, value, type: 'hidden' })); +function kitLine(index, item) { + const row = new Element('div', { className: 'inventory-kit-line' }); + for (const [name, value, type] of [['ItemId', item, 'text'], ['Quantity', '1', 'number']]) { + const id = 'kit-' + index + '-' + name; + row.appendChild(new Element('label', { htmlFor: id })); + field(row, 'Lines[' + index + '].' + name, value, name === 'ItemId' ? 'select' : 'input').id = id; + row.children.at(-1).type = type; + } + row.appendChild(new Element('button', { className: 'inventory-remove-line' })); + return row; +} +function page({ response = { ok: true, json: async () => ({ success: true }) }, createAsset = false, denied = false, kit = false, issuedAssets = null } = {}) { + const document = new Element('document'); + document.createElement = tag => new Element(tag); + document.getElementById = id => document.querySelectorAll('[id]').find(element => element.id === id); + const settings = document.appendChild(new Element('script', { id: 'inventory-settings', textContent: JSON.stringify({ protectedData: true, grant: 'synthetic-grant', expiry: '2030-01-01T00:00:00Z', index: '/User/Inventory', failed: 'Unable to complete', witness: 'Awaiting witness' }) })); + const message = document.appendChild(new Element('p', { id: 'inventory-message', hidden: true })); + const reopen = document.appendChild(new Element('form', { id: 'inventory-page', className: 'inventory-navigation' })); + field(reopen, 'itemId', 'item-filter'); field(reopen, 'locationId', 'location-filter'); + const getForm = document.appendChild(new Element('form', { method: 'get', className: 'inventory-navigation' })); + const command = document.appendChild(new Element('form', { action: '/User/Inventory/Post', className: 'inventory-command' + (kit ? ' inventory-kit' : '') + (issuedAssets ? ' inventory-kit-issue' : '') })); + field(command, '__RequestVerificationToken', 'synthetic-csrf'); field(command, 'RequestId', 'synthetic-request'); field(command, 'Note', 'unsaved note'); + if (createAsset) command.dataset.inventoryCreateAsset = 'true'; + if (issuedAssets) issuedAssets.forEach((id, index) => field(command, 'Lines[' + index + '].AssetId', id, 'select')); + let lines; + if (kit) { + lines = command.appendChild(new Element('div', { className: 'inventory-kit-lines' })); lines.appendChild(kitLine(0, 'item-a')); lines.appendChild(kitLine(1, 'item-b')); + command.appendChild(new Element('button', { className: 'inventory-add-line' })); + } + const requests = [], bound = []; + const window = { resgridAdpReveal: { bindForm(form) { bound.push(form); form.addEventListener('submit', event => { if (denied) event.preventDefault(); }); } } }; + vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-modern.js'), 'utf8'), { + document, window, FormData, $: callback => callback(), fetch: async (url, options) => { requests.push({ url, ...options, values: Object.fromEntries(options.body) }); return typeof response === 'function' ? response() : response; } + }); + return { settings, message, reopen, getForm, command, requests, bound, lines }; +} + +test('grant and expiry reach POST forms only, then leave the bootstrap JSON', () => { + const ui = page(); + assert.equal(ui.command.querySelector('[name="__ResgridProtectedGrant"]').value, 'synthetic-grant'); + assert.equal(ui.command.querySelector('[name="__ResgridProtectedGrantExpiresOn"]').value, '2030-01-01T00:00:00Z'); + assert.equal(ui.getForm.querySelector('[name="__ResgridProtectedGrant"]'), null); + assert.equal(ui.bound.includes(ui.getForm), false); + assert.equal(ui.settings.textContent, ''); +}); +test('ADP interception prevents the command from reaching the server', async () => { + const ui = page({ denied: true }); await ui.command.emit('submit'); + assert.equal(ui.requests.length, 0); assert.equal(ui.reopen.submissions.length, 0); +}); +test('success reopens by POST retaining grant, expiry and structural filters', async () => { + const ui = page(); await ui.command.emit('submit'); + assert.equal(ui.requests[0].method, 'POST'); assert.equal(ui.requests[0].cache, 'no-store'); + assert.equal(ui.requests[0].values.__RequestVerificationToken, 'synthetic-csrf'); + assert.equal(ui.reopen.submissions.length, 1); + assert.deepEqual(ui.reopen.submissions[0], { itemId: 'item-filter', locationId: 'location-filter', __ResgridProtectedGrant: 'synthetic-grant', __ResgridProtectedGrantExpiresOn: '2030-01-01T00:00:00Z' }); +}); +test('failed commands retain entered values and do not reload', async () => { + const ui = page({ response: { ok: false, json: async () => ({ message: 'Revision conflict' }) } }); await ui.command.emit('submit'); + assert.equal(ui.message.textContent, 'Revision conflict'); assert.equal(ui.message.hidden, false); + assert.equal(ui.command.querySelector('[name="Note"]').value, 'unsaved note'); assert.equal(ui.reopen.submissions.length, 0); +}); +test('controlled asset receive and ordinary pending commands display their stable witness request', async () => { + for (const options of [{ createAsset: true, response: { ok: true, json: async () => ({ Id: 'asset-id', CurrentLocationId: null }) } }, { response: { ok: true, json: async () => ({ awaitingWitness: true }) } }]) { + const ui = page(options); await ui.command.emit('submit'); + assert.equal(ui.message.textContent, 'Awaiting witness synthetic-request'); assert.equal(ui.reopen.submissions.length, 0); + } +}); +test('an outstanding command suppresses double submission and waits for its receipt', async () => { + let complete; const pending = new Promise(resolve => { complete = resolve; }); + const ui = page({ response: () => pending }); + const first = ui.command.emit('submit'); await Promise.resolve(); await Promise.resolve(); + await ui.command.emit('submit'); + assert.equal(ui.requests.length, 1); assert.equal(ui.reopen.submissions.length, 0); + complete({ ok: true, json: async () => ({ success: true }) }); await first; + assert.equal(ui.reopen.submissions.length, 1); +}); +test('serialized kits reject missing or duplicate asset selections before posting', async () => { + for (const issuedAssets of [['asset-a', 'asset-a'], ['asset-a', '']]) { + const ui = page({ issuedAssets }); await ui.command.emit('submit'); assert.equal(ui.requests.length, 0); assert.equal(ui.message.hidden, false); + } + const valid = page({ issuedAssets: ['asset-a', 'asset-b'] }); await valid.command.emit('submit'); assert.equal(valid.requests.length, 1); +}); +test('kit removal and addition keep contiguous binding indices and unique accessible IDs', async () => { + const ui = page({ kit: true }); + await ui.command.emit('click', ui.lines.children[0].querySelector('.inventory-remove-line')); + assert.equal(ui.lines.children[0].querySelector('[name="Lines[0].ItemId"]').value, 'item-b'); + assert.equal(ui.lines.children[0].querySelector('.inventory-remove-line').disabled, true); + await ui.command.emit('click', ui.command.querySelector('.inventory-add-line')); + await ui.command.emit('click', ui.command.querySelector('.inventory-add-line')); + assert.deepEqual(ui.lines.querySelectorAll('[name]').map(input => input.name), ['Lines[0].ItemId', 'Lines[0].Quantity', 'Lines[1].ItemId', 'Lines[1].Quantity', 'Lines[2].ItemId', 'Lines[2].Quantity']); + const ids = ui.lines.querySelectorAll('[name]').map(input => input.id); assert.equal(new Set(ids).size, ids.length); + assert.deepEqual(ui.lines.querySelectorAll('label[for]').map(label => label.htmlFor), ids); + assert.equal(ui.lines.children[1].querySelector('[name="Lines[1].Quantity"]').value, '1'); + assert.equal(ui.lines.children[1].querySelector('[name="Lines[1].ItemId"]').value, ''); +}); diff --git a/Tests/Resgrid.Tests/Web/work-orders.test.cjs b/Tests/Resgrid.Tests/Web/work-orders.test.cjs index 8e3b45631..207086cae 100644 --- a/Tests/Resgrid.Tests/Web/work-orders.test.cjs +++ b/Tests/Resgrid.Tests/Web/work-orders.test.cjs @@ -16,7 +16,7 @@ const { chromium } = launch.playwright(); window.$=f=>f();window.requests=[];window.reopened=[]; window.fail=true;window.allowed=false; window.resgridAdpReveal={bindForm:form=>form.addEventListener('submit',e=>{if(!window.allowed)e.preventDefault();})}; - window.fetch=async(url,options)=>{window.requests.push(Object.fromEntries(options.body));return window.fail ? {ok:false,json:async()=>({message:'Conflit',code:'Conflict'})}:{ok:true,json:async()=>({id:123})};}; + window.fetch=async(url,options)=>{window.requests.push(Object.fromEntries(options.body));return window.fail ? {ok:false,json:async()=>({message:window.errorMessage || 'Conflit',code:'Conflict'})}:{ok:true,json:async()=>({id:123})};}; HTMLFormElement.prototype.submit=function(){window.reopened.push(Object.fromEntries(new FormData(this)));}; }); await page.addScriptTag({path:path.resolve(__dirname,'../../../Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js')}); @@ -27,6 +27,11 @@ const { chromium } = launch.playwright(); assert.equal(await page.locator('textarea').inputValue(),'private draft'); assert.equal(await page.locator('.work-order-error').textContent(),'Conflit'); assert.equal(await page.evaluate(()=>requests[0].__ResgridProtectedGrant),'synthetic-grant'); + await page.evaluate(()=>window.errorMessage=''); + await page.click('button[type=submit]'); + assert.equal(await page.locator('.work-order-error img, .work-order-error script').count(),0); + assert.equal(await page.evaluate(()=>window.xss),undefined); + assert.match(await page.locator('.work-order-error').textContent(),/^window.fail=false); await page.click('button[type=submit]'); await page.waitForFunction(()=>reopened.length===1); assert.equal(await page.evaluate(()=>reopened[0].destination),'Detail'); diff --git a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs index 2dd40760f..44d89166b 100644 --- a/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/InventoryToolProvider.cs @@ -41,14 +41,15 @@ private void RegisterGetInventoryTool(McpServer server) var schema = SchemaBuilder.BuildObjectSchema( new Dictionary { - ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["page"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Zero-based catalog page, default 0. Request the next page while HasMore is true." } }, new[] { "accessToken" } ); server.AddTool( toolName, - "Retrieves all inventory items for the department", + "Retrieves one page of department inventory catalog items. Follow HasMore with page + 1. This client does not carry Protected Data Grants.", schema, async (arguments) => { @@ -60,11 +61,12 @@ private void RegisterGetInventoryTool(McpServer server) { return CreateErrorResponse("Access token is required"); } + if (args.Page < 0 || args.Page > 10000) return CreateErrorResponse("Page must be between 0 and 10000"); _logger.LogInformation("Retrieving inventory"); var result = await _apiClient.GetAsync( - "/api/v4/Inventory/GetAll", + $"/api/v4/Inventory/GetAll?page={args.Page}", args.AccessToken ); @@ -72,7 +74,7 @@ private void RegisterGetInventoryTool(McpServer server) } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving inventory"); + _logger.LogError("Error retrieving inventory ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("Failed to retrieve inventory. Please try again later."); } } @@ -88,7 +90,7 @@ private void RegisterGetInventoryItemTool(McpServer server) new Dictionary { ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, - ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Inventory item ID" } + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Inventory item GUID returned by get_inventory" } }, new[] { "accessToken", "itemId" } ); @@ -107,11 +109,12 @@ private void RegisterGetInventoryItemTool(McpServer server) { return CreateErrorResponse("Access token is required"); } + if (!ValidId(args.ItemId)) return CreateErrorResponse("A valid inventory item GUID is required"); _logger.LogInformation("Retrieving inventory item {ItemId}", args.ItemId); var result = await _apiClient.GetAsync( - $"/api/v4/Inventory/GetItem?itemId={args.ItemId}", + $"/api/v4/Inventory/GetItem?itemId={Uri.EscapeDataString(args.ItemId)}", args.AccessToken ); @@ -119,7 +122,7 @@ private void RegisterGetInventoryItemTool(McpServer server) } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving inventory item"); + _logger.LogError("Error retrieving inventory item ({ExceptionType})", ex.GetType().Name); return CreateErrorResponse("Failed to retrieve inventory item. Please try again later."); } } @@ -135,16 +138,25 @@ private void RegisterUpdateInventoryTool(McpServer server) new Dictionary { ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, - ["itemId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Inventory item ID" }, - ["quantity"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "New quantity" }, + ["itemId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Inventory item GUID returned by get_inventory" }, + ["requestId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Caller-supplied request GUID. Reuse this same GUID and unchanged values for every retry of this adjustment." }, + ["fromLocationId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Source location GUID to subtract quantity. Supply exactly one of fromLocationId or toLocationId." }, + ["toLocationId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Destination location GUID to add quantity. Supply exactly one of fromLocationId or toLocationId." }, + ["lotId"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional inventory lot GUID; required when the item tracks lots" }, + ["quantity"] = new SchemaBuilder.PropertySchema { Type = "number", Description = "Positive quantity delta, up to 100000000 with at most six decimal places; never an absolute balance" }, ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the update" } }, - new[] { "accessToken", "itemId", "quantity" } + new[] { "accessToken", "itemId", "requestId", "quantity" } ); + schema["oneOf"] = new[] + { + new Dictionary { ["required"] = new[] { "fromLocationId" } }, + new Dictionary { ["required"] = new[] { "toLocationId" } } + }; server.AddTool( toolName, - "Updates the quantity of an inventory item", + "Records an inventory quantity adjustment at an explicit location. Supply a positive delta, one direction, and a stable request GUID. Protected writes require the Inventory app; this client cannot carry a Protected Data Grant.", schema, async (arguments) => { @@ -156,12 +168,22 @@ private void RegisterUpdateInventoryTool(McpServer server) { return CreateErrorResponse("Access token is required"); } + if (!ValidId(args.ItemId) || !ValidId(args.RequestId)) return CreateErrorResponse("Valid item and request GUIDs are required"); + if ((args.FromLocationId == null) == (args.ToLocationId == null) + || args.FromLocationId != null && !ValidId(args.FromLocationId) || args.ToLocationId != null && !ValidId(args.ToLocationId) + || args.LotId != null && !ValidId(args.LotId)) return CreateErrorResponse("Supply exactly one valid source or destination location GUID and a valid optional lot GUID"); + if (args.Quantity <= 0 || args.Quantity > 100000000m || decimal.Round(args.Quantity, 6) != args.Quantity) return CreateErrorResponse("Quantity must be a positive delta up to 100000000 with at most six decimal places"); + if (args.Note?.Length > 16000) return CreateErrorResponse("The adjustment note cannot exceed 16000 characters"); _logger.LogInformation("Updating inventory item {ItemId}", args.ItemId); var updateData = new { itemId = args.ItemId, + requestId = args.RequestId, + fromLocationId = args.FromLocationId, + toLocationId = args.ToLocationId, + lotId = args.LotId, quantity = args.Quantity, note = args.Note }; @@ -172,12 +194,12 @@ private void RegisterUpdateInventoryTool(McpServer server) args.AccessToken ); - return new { success = true, data = result, message = "Inventory updated successfully" }; + return new { success = true, data = result, message = "Inventory adjustment request accepted" }; } catch (Exception ex) { - _logger.LogError(ex, "Error updating inventory"); - return CreateErrorResponse("Failed to update inventory. Please try again later."); + _logger.LogError("Error updating inventory ({ExceptionType})", ex.GetType().Name); + return CreateErrorResponse("The inventory adjustment could not be confirmed. Retry with the same requestId and unchanged values. Protected data requires the Inventory app."); } } ); @@ -191,14 +213,15 @@ private void RegisterLowStockItemsTool(McpServer server) var schema = SchemaBuilder.BuildObjectSchema( new Dictionary { - ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" } + ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, + ["page"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Zero-based catalog page, default 0. Continue while HasMore is true, even when a page has no low-stock items." } }, new[] { "accessToken" } ); server.AddTool( toolName, - "Retrieves all inventory items that are low in stock", + "Retrieves a catalog page of bulk items at or below their reorder point using stock locations this caller can view. Follow HasMore with page + 1.", schema, async (arguments) => { @@ -210,25 +233,27 @@ private void RegisterLowStockItemsTool(McpServer server) { return CreateErrorResponse("Access token is required"); } + if (args.Page < 0 || args.Page > 10000) return CreateErrorResponse("Page must be between 0 and 10000"); _logger.LogInformation("Retrieving low stock items"); var result = await _apiClient.GetAsync( - "/api/v4/Inventory/GetLowStockItems", + $"/api/v4/Inventory/GetLowStockItems?page={args.Page}", args.AccessToken ); return new { success = true, data = result }; } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving low stock items"); - return CreateErrorResponse("Failed to retrieve low stock items. Please try again later."); - } + catch (Exception ex) + { + _logger.LogError("Error retrieving low stock items ({ExceptionType})", ex.GetType().Name); + return CreateErrorResponse("Failed to retrieve low stock items. Please try again later."); + } } ); } + private static bool ValidId(string value) => Guid.TryParseExact(value, "D", out var id) && id != Guid.Empty; private static object CreateErrorResponse(string errorMessage) => new { success = false, error = errorMessage }; @@ -236,6 +261,8 @@ private sealed class TokenArgs { [JsonProperty("accessToken")] public string AccessToken { get; set; } + [JsonProperty("page")] + public int Page { get; set; } } private sealed class ItemIdArgs @@ -244,7 +271,7 @@ private sealed class ItemIdArgs public string AccessToken { get; set; } [JsonProperty("itemId")] - public int ItemId { get; set; } + public string ItemId { get; set; } } private sealed class UpdateInventoryArgs @@ -253,10 +280,19 @@ private sealed class UpdateInventoryArgs public string AccessToken { get; set; } [JsonProperty("itemId")] - public int ItemId { get; set; } + public string ItemId { get; set; } + + [JsonProperty("requestId")] + public string RequestId { get; set; } + [JsonProperty("fromLocationId")] + public string FromLocationId { get; set; } + [JsonProperty("toLocationId")] + public string ToLocationId { get; set; } + [JsonProperty("lotId")] + public string LotId { get; set; } [JsonProperty("quantity")] - public int Quantity { get; set; } + public decimal Quantity { get; set; } [JsonProperty("note")] public string Note { get; set; } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs index b2a22fa0d..b9d5df38f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistManagementController.cs @@ -48,7 +48,7 @@ public async Task DeleteChecklist([FromBody] ChecklistCommandInpu { Required(input); await Checklists.RetireAsync(Actor, input.Id, input.Revision, true); return Reply(true); } [HttpGet("GetSchedules"), Authorize(Policy = ResgridResources.Checklist_Update)] public async Task GetSchedules(string definitionId, int page = 0) - { var rows = await Checklists.SchedulesAsync(Actor, definitionId, page); var more = rows.Count == 50 && page < 10000 && (await Checklists.SchedulesAsync(Actor, definitionId, page + 1)).Count > 0; return Reply(rows.Select(ScheduleData).ToList(), rows.Count, more); } + { var rows = await Checklists.SchedulesAsync(Actor, definitionId, page, includeNext: true); var more = rows.Count > 50 && page < 10000; var data = rows.Take(50).Select(ScheduleData).ToList(); return Reply(data, data.Count, more); } [HttpGet("GetSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] public async Task GetSchedule(string id) => Reply(ScheduleData(await Checklists.GetScheduleAsync(Actor, id))); [HttpPost("NewSchedule"), Authorize(Policy = ResgridResources.Checklist_Update)] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs new file mode 100644 index 000000000..99f91b97e --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Inventory; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + [Route("api/v{VersionId:apiVersion}/[controller]"), ApiVersion("4.0"), ApiExplorerSettings(GroupName = "v4"), Authorize] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + public sealed class InventoryController : V4AuthenticatedApiControllerbase, IAsyncActionFilter, IOrderedFilter + { + public int Order => -3000; // Sanitize model-binding errors before ApiController's automatic response. + private readonly IInventoryCatalogService _catalog; + private readonly IInventoryStockService _stock; + private readonly IInventoryTransferService _transfers; + private readonly IInventoryIssuanceService _issuance; + private readonly IInventoryMigrationService _migration; + private readonly IInventoryAuthorizationService _authorization; + private readonly IStringLocalizer _strings; + public InventoryController(IInventoryCatalogService catalog, IInventoryStockService stock, IInventoryTransferService transfers, IInventoryIssuanceService issuance, + IInventoryMigrationService migration, IInventoryAuthorizationService authorization, IStringLocalizer strings) + { _catalog = catalog; _stock = stock; _transfers = transfers; _issuance = issuance; _migration = migration; _authorization = authorization; _strings = strings; } + private InventoryActor Actor => new InventoryActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[DataProtectionController.GrantHeader].ToString() }; + private OkObjectResult Reply(T value, int count = 1, bool more = false, int page = 0) + { + var response = new InventoryApiResult { Data = value, Status = ResponseHelper.Success, PageSize = count, HasMore = more, Page = page }; + ResponseHelper.PopulateV4ResponseData(response); return Ok(response); + } + private static T Required(T input) where T : class => input ?? throw new InventoryException(400, "InvalidInput"); + private static void RequireRequestId(string requestId) + { + if (!Guid.TryParseExact(requestId, "D", out var id) || id == Guid.Empty) throw new InventoryException(400, "RequestIdRequired"); + } + private static InventoryCommand Command(InventoryCommand input) { Required(input); RequireRequestId(input.RequestId); return input; } + [NonAction] + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!context.ModelState.IsValid) { context.Result = Failure(new InventoryException(400, "InvalidInput")); return; } + var executed = await next(); + if (executed.Exception == null || executed.Exception is OperationCanceledException) return; + var failure = executed.Exception switch + { + InventoryException inventory => inventory, + UnauthorizedAccessException => new InventoryException(403, "PermissionRequired"), + ArgumentException or JsonException => new InventoryException(400, "InvalidInput"), + InvalidOperationException => new InventoryException(409, "OperationUnavailable"), + _ => new InventoryException(500, "OperationFailed") + }; + if (failure.StatusCode == 500) Resgrid.Framework.Logging.LogError($"Inventory API failed: {executed.Exception.GetType().FullName}."); + executed.ExceptionHandled = true; executed.Result = Failure(failure); + } + private ObjectResult Failure(InventoryException exception) + { + var protectedData = exception.Code == "ProtectedDataRequired"; + var problem = new ProblemDetails { Status = exception.StatusCode, Type = protectedData ? "protected_data_required" : "inventory_" + exception.Code, Title = _strings["UnableToComplete"] }; + problem.Extensions["code"] = exception.Code; problem.Extensions["IsRedacted"] = protectedData; + return new ObjectResult(problem) { StatusCode = exception.StatusCode }; + } + private async Task Page(int page) where T : InventoryRow + { var result = await _catalog.ListAsync(Actor, page); return Reply(result, result.Items.Count, result.HasMore, page); } + private async Task Query(InventoryQuery filter, int page) where T : InventoryRow + { var result = await _catalog.QueryAsync(Actor, filter, page); return Reply(result, result.Items.Count, result.HasMore, page); } + private async Task Archive(InventoryArchiveInput input) where T : InventoryMutableRow + { Required(input); await _catalog.ArchiveAsync(Actor, input.Id, input.Revision); return Reply(new { input.Id, Archived = true }); } + + /// One page of catalog items. Continue with page+1 while HasMore is true. + [HttpGet("GetAll"), HttpGet("GetItems")] + public Task GetAll(int page = 0) => Page(page); + [HttpGet("GetItem")] + public async Task GetItem(string itemId) => Reply(await _catalog.GetAsync(Actor, itemId)); + [HttpPost("SaveItem")] + public async Task SaveItem([FromBody] InventoryItemInput input) => Reply(await _catalog.SaveItemAsync(Actor, Required(input))); + [HttpPost("ArchiveItem"), Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public Task ArchiveItem([FromBody] InventoryArchiveInput input) => Archive(input); + [HttpGet("GetCategories")] + public Task GetCategories(int page = 0) => Page(page); + [HttpGet("GetCategory")] + public async Task GetCategory(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpPost("SaveCategory")] + public async Task SaveCategory([FromBody] InventoryCategoryInput input) + { Required(input); return Reply(await _catalog.SaveCategoryAsync(Actor, input.Id, input.Revision, input.Name, input.ParentCategoryId)); } + [HttpPost("ArchiveCategory"), Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public Task ArchiveCategory([FromBody] InventoryArchiveInput input) => Archive(input); + [HttpGet("GetLocations")] + public Task GetLocations(int page = 0) => Page(page); + [HttpGet("GetLocation")] + public async Task GetLocation(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpPost("SaveLocation")] + public async Task SaveLocation([FromBody] InventoryLocationInput input) => Reply(await _catalog.SaveLocationAsync(Actor, Required(input))); + [HttpPost("ArchiveLocation"), Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public Task ArchiveLocation([FromBody] InventoryArchiveInput input) => Archive(input); + [HttpGet("GetLots")] + public Task GetLots(int page = 0) => Page(page); + [HttpGet("GetLot")] + public async Task GetLot(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpPost("CreateLot")] + public async Task CreateLot([FromBody] InventoryCreateLotInput input) + { Required(input); return Reply(await _catalog.SaveLotAsync(Actor, new InventoryLot { ItemId = input.ItemId, ExpiresOn = input.ExpiresOn }, Required(input.Details))); } + [HttpGet("GetStocks")] + public Task GetStocks(int page = 0, string itemId = null, string locationId = null) => Query(new InventoryQuery { ItemId = itemId, LocationId = locationId }, page); + [HttpGet("GetTransactions")] + public Task GetTransactions(int page = 0, string itemId = null, string locationId = null, string assetId = null) => Query(new InventoryQuery { ItemId = itemId, LocationId = locationId, AssetId = assetId }, page); + [HttpGet("GetTransaction")] + public async Task GetTransaction(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpGet("GetHistory")] + public async Task GetHistory(InventoryReferenceType referenceType, string referenceId) + { + if (!Enum.IsDefined(referenceType) || referenceType == InventoryReferenceType.None || string.IsNullOrWhiteSpace(referenceId) || referenceId.Length > 128) throw new InventoryException(400, "InvalidReference"); + var result = await _stock.GetByReferenceAsync(Actor, referenceType, referenceId); return Reply(result, result.Count); + } + [HttpGet("GetTransfers")] + public Task GetTransfers(int page = 0) => Page(page); + [HttpGet("GetTransfer")] + public async Task GetTransfer(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpGet("GetTransferItems")] + public async Task GetTransferItems(int page = 0) + { + var actor = Actor; var rows = await _catalog.ListAsync(actor, page); + var result = new InventoryPage { HasMore = rows.HasMore }; + foreach (var row in rows.Items) + { + try { await _catalog.GetAsync(actor, row.TransferId); result.Items.Add(row); } + catch (InventoryException exception) when (exception.StatusCode is 403 or 404) { } + } + return Reply(result, result.Items.Count, result.HasMore, page); + } + [HttpGet("GetAssets")] + public Task GetAssets(int page = 0) => Page(page); + [HttpGet("GetAsset")] + public async Task GetAsset(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpGet("GetIssuances")] + public Task GetIssuances(int page = 0, string itemId = null, string userId = null) => Query(new InventoryQuery { ItemId = itemId, IssuedToUserId = userId }, page); + [HttpGet("GetIssuance")] + public async Task GetIssuance(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpGet("GetKits")] + public Task GetKits(int page = 0) => Page(page); + [HttpGet("GetKit")] + public async Task GetKit(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpGet("GetKitItems")] + public Task GetKitItems(int page = 0) => Page(page); + [HttpPost("SaveKit")] + public async Task SaveKit([FromBody] InventoryKitInput input) => Reply(await _issuance.SaveKitAsync(Actor, Required(input))); + [HttpPost("ArchiveKit"), Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public Task ArchiveKit([FromBody] InventoryArchiveInput input) => Archive(input); + + [HttpGet("GetMigrationStatus")] + public async Task GetMigrationStatus() + { var actor = Actor; await _authorization.RequireAsync(actor); return Reply(new { Migrated = await _migration.IsMigratedAsync(actor.DepartmentId) }); } + [HttpPost("Migrate")] + public async Task Migrate() => Reply(await _migration.MigrateLegacyAsync(Actor)); + [HttpPost("PostTransaction")] + public async Task PostTransaction([FromBody] InventoryCommand input, CancellationToken cancellationToken) => Reply(await _stock.PostTransactionAsync(Actor, Command(input), cancellationToken)); + [HttpPost("CreateTransfer")] + public async Task CreateTransfer([FromBody] InventoryCommand input) => Reply(await _transfers.CreateAndCompleteTransferAsync(Actor, Command(input))); + [HttpPost("CreateAsset")] + public async Task CreateAsset([FromBody] InventoryAssetInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _issuance.CreateAssetAsync(Actor, input)); } + [HttpPost("Issue")] + public async Task Issue([FromBody] InventoryIssueInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _issuance.IssueAsync(Actor, input)); } + [HttpPost("Return")] + public async Task Return([FromBody] InventoryReturnInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _issuance.ReturnAsync(Actor, input)); } + [HttpPost("StatusChange")] + public async Task StatusChange([FromBody] InventoryCommand input) => Reply(await _issuance.ChangeAssetStatusAsync(Actor, Command(input))); + [HttpPost("IssueKit")] + public async Task IssueKit([FromBody] InventoryKitIssueInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _issuance.IssueKitAsync(Actor, input)); } + [HttpPost("Witness")] + public async Task Witness([FromBody] InventoryWitnessInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _stock.WitnessAsync(Actor, input.RequestId, input.Attestation)); } + [HttpPost("RebuildStocks")] + public async Task RebuildStocks() { await _stock.RebuildStocksAsync(Actor); return Reply(new { Rebuilt = true }); } + [HttpGet("GetUnitEquipment")] + public async Task GetUnitEquipment(int unitId) + { if (unitId <= 0) throw new InventoryException(400, "InvalidIdentifier"); var result = await _issuance.GetUnitEquipmentAsync(Actor, unitId); return Reply(result, result.Count); } + [HttpGet("GetIssuable")] + public async Task GetIssuable(string itemId = null, string locationId = null) + { var result = await _issuance.GetIssuableAsync(Actor, itemId, locationId); return Reply(result, result.Count); } + + /// Adjustment compatibility route: provide a positive delta and exactly one explicit source/destination location. + [HttpPut("UpdateItem")] + public async Task UpdateItem([FromBody] InventoryAdjustmentInput input, CancellationToken cancellationToken) + { + Required(input); RequireRequestId(input.RequestId); + if ((input.FromLocationId == null) == (input.ToLocationId == null) || input.Quantity <= 0) throw new InventoryException(400, "ExplicitAdjustmentRequired"); + var command = new InventoryCommand { RequestId = input.RequestId, Lines = new List { new InventoryPosting { ItemId = input.ItemId, AssetId = input.AssetId, LotId = input.LotId, + FromLocationId = input.FromLocationId, ToLocationId = input.ToLocationId, Quantity = input.Quantity, Type = InventoryTransactionType.Adjust, ExpectedAssetRevision = input.ExpectedAssetRevision, Note = input.Note } } }; + return Reply(await _stock.PostTransactionAsync(Actor, command, cancellationToken)); + } + /// Bulk items at/below their reorder point, using only stock locations this caller can view. Page is the catalog page. + [HttpGet("GetLowStockItems")] + public async Task GetLowStockItems(int page = 0) + { + var actor = Actor; var items = await _catalog.ListAsync(actor, page); + var totals = items.Items.Where(i => !i.IsDeleted && i.IsActive && i.TrackingMode == (int)InventoryTrackingMode.Bulk).ToDictionary(i => i.Id, _ => 0m); + for (var stockPage = 0; ; stockPage++) + { + var stocks = await _catalog.ListAsync(actor, stockPage); + foreach (var stock in stocks.Items) if (!stock.IsDeleted && totals.ContainsKey(stock.ItemId)) totals[stock.ItemId] += stock.Quantity; + if (!stocks.HasMore) break; + if (stockPage >= 200) throw new InventoryException(409, "InventoryTooLarge"); + } + var result = new InventoryPage { HasMore = items.HasMore }; + foreach (var item in items.Items.Where(i => totals.ContainsKey(i.Id))) + { + var details = JsonConvert.DeserializeObject(item.Content ?? "{}") ?? new InventoryItemContent(); + var threshold = details.ReorderPoint ?? details.MinLevel; + if (threshold.HasValue && totals[item.Id] <= threshold.Value) result.Items.Add(new InventoryLowStockItem { Item = item, VisibleQuantity = totals[item.Id], ReorderPoint = threshold.Value }); + } + return Reply(result, result.Items.Count, result.HasMore, page); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs index 803e1cc8e..6dd7cdc68 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs @@ -15,6 +15,7 @@ public class RecordInventoryController : V4AuthenticatedApiControllerbase { public class ConsumeInput { + public Resgrid.Model.Inventories.InventoryCommand Command { get; set; } public string RecordId {get;set;} public RmsRecordKind Kind {get;set;} public long ExpectedRowVersion {get;set;} @@ -47,7 +48,10 @@ public async Task Consume(ConsumeInput input,CancellationToken ca if (!await Allowed(input.RecordId)) return NotFound(); try { - var usage=await _usage.ConsumeAsync(DepartmentId,UserId,input.RecordId,input.Kind,input.ExpectedRowVersion,input.TypeId,input.GroupId,input.UnitId,input.Quantity,input.Note,cancellationToken); + var grant = Request.Headers[DataProtectionController.GrantHeader].ToString(); + var usage=input.Command != null + ? await _usage.ConsumeModernAsync(new Resgrid.Model.Inventories.InventoryActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = grant }, input.RecordId, input.Kind, input.ExpectedRowVersion, input.Command, cancellationToken) + : await _usage.ConsumeAsync(DepartmentId,UserId,input.RecordId,input.Kind,input.ExpectedRowVersion,input.TypeId,input.GroupId,input.UnitId,input.Quantity,input.Note,cancellationToken,grant); string evidenceId=null; try { evidenceId=(await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest {DepartmentId=DepartmentId,RecordId=input.RecordId,RecordKind=input.Kind,Kind=RmsEvidenceKind.InventoryUsage,CapturedByUserId=UserId,CaptureReason="Officer recorded inventory consumption",OriginClient=RmsOriginClient.Api},true,cancellationToken)).RmsEvidenceArtifactId; } catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException) { } @@ -55,6 +59,7 @@ public async Task Consume(ConsumeInput input,CancellationToken ca return StatusCode(201,new {usage,evidenceId,evidenceCaptureRequired=evidenceId==null}); } catch(RecordConcurrencyException) {return Conflict(new {error="The draft changed. Reload its version and recorded usage before retrying."});} + catch(Resgrid.Model.Inventories.InventoryException ex) { return StatusCode(ex.StatusCode, new { code=ex.Code, error="Inventory consumption could not be completed.", type=ex.Code=="ProtectedDataRequired" ? "protected_data_required" : "inventory_error" }); } catch(UnauthorizedAccessException) {return Forbid();} catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) {return BadRequest(new {error=ex.Message});} } diff --git a/Web/Resgrid.Web.Services/Models/v4/Inventory/InventoryApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Inventory/InventoryApiModels.cs new file mode 100644 index 000000000..f37264e87 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Inventory/InventoryApiModels.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Resgrid.Model.Inventories; + +namespace Resgrid.Web.Services.Models.v4.Inventory +{ + public sealed class InventoryApiResult : StandardApiResponseV4Base + { + public T Data { get; set; } + public bool HasMore { get; set; } + public int ContractVersion { get; set; } = 1; + } + public sealed class InventoryCategoryInput + { + public string Id { get; set; } + public int Revision { get; set; } + [Required, StringLength(250)] public string Name { get; set; } + public string ParentCategoryId { get; set; } + } + public sealed class InventoryArchiveInput + { + [Required] public string Id { get; set; } + [Range(1, int.MaxValue)] public int Revision { get; set; } + } + public sealed class InventoryCreateLotInput + { + [Required] public string ItemId { get; set; } + public DateTime? ExpiresOn { get; set; } + [Required] public InventoryLotContent Details { get; set; } + } + public sealed class InventoryWitnessInput + { + [Required] public string RequestId { get; set; } + [Required, StringLength(4000)] public string Attestation { get; set; } + } + /// A positive quantity delta with an explicit direction, never an absolute replacement balance. + public sealed class InventoryAdjustmentInput + { + [Required] public string RequestId { get; set; } + [Required] public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string FromLocationId { get; set; } + public string ToLocationId { get; set; } + public decimal Quantity { get; set; } + public int? ExpectedAssetRevision { get; set; } + [StringLength(16000)] public string Note { get; set; } + } + /// Bulk stock visible to this caller, summed across authorized locations and lots. + public sealed class InventoryLowStockItem + { + public InventoryItem Item { get; set; } + public decimal VisibleQuantity { get; set; } + public decimal ReorderPoint { get; set; } + public string QuantityScope { get; set; } = "AuthorizedLocations"; + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 2246f1684..dc906449b 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2339,6 +2339,15 @@ Closes all open on-demand tactical channels for a call. + + One page of catalog items. Continue with page+1 while HasMore is true. + + + Adjustment compatibility route: provide a positive delta and exactly one explicit source/destination location. + + + Bulk items at/below their reorder point, using only stock locations this caller can view. Page is the catalog page. + Mapping operations @@ -11699,6 +11708,12 @@ The current user's effective incident capabilities (raw flags value + granted names). + + A positive quantity delta with an explicit direction, never an absolute replacement balance. + + + Bulk stock visible to this caller, summed across authorized locations and lots. + GeoJSON FeatureCollection string ready for direct rnmapbox ShapeSource consumption diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs index f368b6c07..beaa1da28 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChecklistReportsController.cs @@ -21,7 +21,7 @@ public async Task ChecklistComplianceReport(ChecklistReportQuery ViewBag.MissedOnly = missedOnly; return View("ChecklistComplianceReport", report); } - catch (ChecklistException ex) { return StatusCode(ex.StatusCode, ChecklistReportDocuments.Text("The request could not be completed.")); } + catch (ChecklistException ex) { Resgrid.Framework.Logging.LogError($"Checklist compliance report failed for department {DepartmentId}: status {ex.StatusCode}."); return StatusCode(ex.StatusCode, ChecklistReportDocuments.Text("The request could not be completed.")); } } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs index c71c45883..54a53b62d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs @@ -360,9 +360,10 @@ public async Task DeleteGroup(DeleteGroupView model, Cancellation auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + try { await _deleteService.DeleteGroupAsync(group.DepartmentGroupId, DepartmentId, UserId, cancellationToken); } + catch (Resgrid.Model.Inventories.InventoryException ex) when (ex.Code == "HolderHistoryRetained") + { model.Group = group; model.Message = "This group is referenced by inventory history and must be retained."; return View(model); } _eventAggregator.SendMessage(auditEvent); - - await _deleteService.DeleteGroupAsync(group.DepartmentGroupId, DepartmentId, UserId, cancellationToken); } return RedirectToAction("Index", "Groups", new { Area = "User" }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs index f311fad4c..4fbb57087 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs @@ -1,347 +1,154 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Localization; using Resgrid.Model; +using Resgrid.Model.Inventories; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.Inventory; -using Microsoft.AspNetCore.Authorization; -using Resgrid.Model.Helpers; -using Resgrid.Providers.Claims; +using Resgrid.Web.Helpers; namespace Resgrid.Web.Areas.User.Controllers { - [Area("User")] - public class InventoryController : SecureBaseController + [WorkOrderFormCulture, Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] + public sealed class InventoryController : SecureBaseController { - #region Private Members and Constructors - private readonly IInventoryService _inventoryService; - private readonly IDepartmentGroupsService _departmentGroupsService; - private readonly IUnitsService _unitsService; - private readonly IDepartmentsService _departmentsService; - private readonly IUserProfileService _userProfileService; - private readonly IStringLocalizer _localizer; - private readonly IStringLocalizer _commonLocalizer; - - public InventoryController(IInventoryService inventoryService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, IDepartmentsService departmentsService, IUserProfileService userProfileService, - IStringLocalizer localizer, IStringLocalizer commonLocalizer) - { - _inventoryService = inventoryService; - _departmentGroupsService = departmentGroupsService; - _unitsService = unitsService; - _departmentsService = departmentsService; - _userProfileService = userProfileService; - _localizer = localizer; - _commonLocalizer = commonLocalizer; - } - #endregion Private Members and Constructors - - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task Index() - { - return View(); - } - - [Authorize(Policy = ResgridResources.Inventory_Update)] - public async Task ManageTypes() - { - return View(); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_Update)] - public async Task AddType() - { - var model = new AddTypeView(); - model.Type = new InventoryType(); - - return View(model); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_Create)] - public async Task Adjust() - { - var model = new AdjustView(); - model.Inventory = new Inventory(); - model.Types = await _inventoryService.GetAllTypesForDepartmentAsync(DepartmentId); - model.Stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); - - return View(model); - } - - [HttpPost] - [Authorize(Policy = ResgridResources.Inventory_Create)] - public async Task Adjust(AdjustView model) - { - if (model.Inventory.Amount == 0) - ModelState.AddModelError("Inventory.Amount", _localizer["AdjustmentAmountRequired"]); - - if (ModelState.IsValid) + private readonly IInventoryCatalogService _catalog; + private readonly IInventoryStockService _stock; + private readonly IInventoryTransferService _transfers; + private readonly IInventoryIssuanceService _issuance; + private readonly IInventoryMigrationService _migration; + private readonly IInventoryAuthorizationService _auth; + private readonly IProtectedGrantContext _grant; + private readonly IDepartmentDataProtectionService _protection; + private readonly IUnitsService _units; + private readonly IDepartmentGroupsService _groups; + private readonly IDepartmentsService _departments; + private readonly IStringLocalizer _strings; + public InventoryController(IInventoryCatalogService catalog, IInventoryStockService stock, IInventoryTransferService transfers, IInventoryIssuanceService issuance, + IInventoryMigrationService migration, IInventoryAuthorizationService auth, IProtectedGrantContext grant, IDepartmentDataProtectionService protection, + IUnitsService units, IDepartmentGroupsService groups, IDepartmentsService departments, IStringLocalizer strings) + { _catalog = catalog; _stock = stock; _transfers = transfers; _issuance = issuance; _migration = migration; _auth = auth; _grant = grant; _protection = protection; _units = units; _groups = groups; _departments = departments; _strings = strings; } + private InventoryActor Actor => new() { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + try { await _auth.RequireAsync(Actor); } catch (InventoryException ex) { context.Result = StatusCode(ex.StatusCode); return; } + ViewBag.ProtectionEnforced = await _protection.IsProtectionEnforcedAsync(DepartmentId); ViewBag.ProtectedGrant = _grant.GrantToken; ViewBag.GrantExpiresOn = HttpProtectedGrantContext.ReadExpiry(Request); + if (!ModelState.IsValid) { context.Result = BadRequest(new { message = _strings["UnableToComplete"].Value }); return; } + var executed = await next(); + if (executed.Exception is InventoryException error) { - model.Inventory.DepartmentId = DepartmentId; - model.Inventory.TimeStamp = DateTime.UtcNow; - model.Inventory.AddedByUserId = UserId; - - if (model.UnitId > 0) - model.Inventory.UnitId = model.UnitId; - - await _inventoryService.SaveInventoryAsync(model.Inventory); - - return RedirectToAction("Index"); + executed.ExceptionHandled = true; + if (HttpMethods.IsGet(Request.Method) && error.Code == "ProtectedDataRequired") executed.Result = View("Workspace", new InventoryWorkspaceView { Locked = true, + Tab = context.ActionArguments.TryGetValue("tab", out var tab) ? tab as string ?? "OnHand" : "OnHand", Id = context.ActionArguments.TryGetValue("id", out var id) ? id as string : null, + Page = context.ActionArguments.TryGetValue("page", out var page) && page is int pageNumber ? pageNumber : 0, + UnitId = context.ActionArguments.TryGetValue("unitId", out var unit) && unit is int unitNumber ? unitNumber : null, UserId = context.ActionArguments.TryGetValue("userId", out var person) ? person as string : null, + ItemId = context.ActionArguments.TryGetValue("itemId", out var item) ? item as string : null, LocationId = context.ActionArguments.TryGetValue("locationId", out var location) ? location as string : null }); + else executed.Result = StatusCode(error.StatusCode, new { message = _strings["UnableToComplete"].Value, code = error.Code }); } - - model.Types = await _inventoryService.GetAllTypesForDepartmentAsync(DepartmentId); - model.Stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); - - return View(model); } - - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task History() + private async Task PageAsync(InventoryWorkspaceView view) where T : InventoryRow + { var result = await _catalog.ListAsync(Actor, view.Page); view.Rows = result.Items.Cast().ToList(); view.HasMore = result.HasMore; } + private async Task QueryAsync(InventoryWorkspaceView view, InventoryQuery filter) where T : InventoryRow + { var result = await _catalog.QueryAsync(Actor, filter, view.Page); view.Rows = result.Items.Cast().ToList(); view.HasMore = result.HasMore; } + private async Task MayWriteAsync(PermissionTypes permission, int? groupId) { - return View(); + try { await _auth.RequireAsync(Actor, true, permission, groupId); return true; } + catch (InventoryException ex) when (ex.StatusCode is 403 or 409) { return false; } } - - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task ViewEntry(int inventoryId) - { - var model = new ViewEntryView(); - model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); - model.Inventory = await _inventoryService.GetInventoryByIdAsync(inventoryId); - - if (model.Inventory == null || model.Inventory.DepartmentId != DepartmentId) - return Unauthorized(); - - var profile = await _userProfileService.GetProfileByUserIdAsync(model.Inventory.AddedByUserId); - - if (profile != null) - model.Name = profile.FullName.AsFirstNameLastName; - else - model.Name = _commonLocalizer["Unknown"]; - - return View(model); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task DeleteType(int typeId) - { - var type = await _inventoryService.GetTypeByIdAsync(typeId); - - if (type == null) - return RedirectToAction("ManageTypes"); - - if (type.DepartmentId != DepartmentId) - return Unauthorized(); - - await _inventoryService.DeleteTypeAsync(typeId); - - return RedirectToAction("ManageTypes"); - } - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_Update)] - public async Task EditType(int typeId) - { - var type = await _inventoryService.GetTypeByIdAsync(typeId); - - if (type == null) - return RedirectToAction("ManageTypes"); - - if (type.DepartmentId != DepartmentId) - return Unauthorized(); - - var model = new EditTypeView(); - model.Type = type; - - return View(model); - } - - [HttpPost] - [Authorize(Policy = ResgridResources.Inventory_Update)] - public async Task EditType(EditTypeView model) - { - var type = await _inventoryService.GetTypeByIdAsync(model.Type.InventoryTypeId); - - if (type == null) - return RedirectToAction("ManageTypes"); - - if (type.DepartmentId != DepartmentId) - return Unauthorized(); - - type.Type = model.Type.Type; - type.Description = model.Type.Description; - type.ExpiresDays = model.Type.ExpiresDays; - type.UnitOfMesasure = model.Type.UnitOfMesasure; - - await _inventoryService.SaveTypeAsync(type); - - return RedirectToAction("ManageTypes"); - } - - [HttpPost] - [Authorize(Policy = ResgridResources.Inventory_Update)] - public async Task AddType(AddTypeView model) - { - if (ModelState.IsValid) - { - model.Type.DepartmentId = DepartmentId; - await _inventoryService.SaveTypeAsync(model.Type); - - return RedirectToAction("ManageTypes"); - } - - return View(model); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task GetTypesList() - { - List inventoryJson = new List(); - - var types = await _inventoryService.GetAllTypesForDepartmentAsync(DepartmentId); - - foreach (var type in types) - { - var typeJson = new InventoryTypeJson(); - typeJson.TypeId = type.InventoryTypeId; - typeJson.Name = type.Type; - - if (type.ExpiresDays > 0) - typeJson.ExpiresDays = $"{type.ExpiresDays} Days"; - else - typeJson.ExpiresDays = "No Expiry"; - - inventoryJson.Add(typeJson); - } - - return Json(inventoryJson); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task GetCombinedInventoryList() - { - List inventoryJson = new List(); - - var items = await _inventoryService.GetConsolidatedInventoryForDepartment(DepartmentId); - - foreach (var item in items) - { - var inventory = new InventorySummaryJson(); - inventory.Name = item.Type.Type; - inventory.Group = item.Group.Name; - - if (item.Unit != null) - inventory.Unit = item.Unit.Name; - else - inventory.Unit = _localizer["NoUnit"]; - - inventory.Count = item.Amount; - - inventoryJson.Add(inventory); - } - - return Json(inventoryJson); - } - - [HttpGet] - - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task GetInventoryList() - { - List inventoryJson = new List(); - - var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); - var items = await _inventoryService.GetAllTransactionsForDepartmentAsync(DepartmentId); - var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); - //var groups = await _departmentGroupsService.GetAllGroupsForDepartment(DepartmentId); - - foreach (var item in items) + public async Task Index(string tab = "OnHand", int page = 0, string id = null, int? unitId = null, string userId = null, string itemId = null, string locationId = null) + { + var view = new InventoryWorkspaceView { Tab = tab, Page = page, Id = id, UnitId = unitId, UserId = userId, ItemId = itemId, LocationId = locationId, Migrated = await _migration.IsMigratedAsync(DepartmentId) }; + var actorGroupId = (await _groups.GetGroupForUserAsync(UserId, DepartmentId))?.DepartmentGroupId; + view.CanWrite = await MayWriteAsync(PermissionTypes.AdjustInventory, actorGroupId); + view.CanTransfer = await MayWriteAsync(PermissionTypes.TransferInventory, actorGroupId); + view.CanIssue = await MayWriteAsync(PermissionTypes.IssueInventory, actorGroupId); + view.CanWitness = await MayWriteAsync(PermissionTypes.ManageControlledSubstances, actorGroupId); + if (!view.Migrated) return View("Workspace", view); + var items = await _catalog.ListAsync(Actor); view.Items = items.Items; + var locations = await _catalog.ListAsync(Actor); view.Locations = locations.Items; + var categories = await _catalog.ListAsync(Actor); view.Categories = categories.Items; + view.ChoicesHaveMore = items.HasMore || locations.HasMore || categories.HasMore; + var assets = await _catalog.ListAsync(Actor); view.Assets = assets.Items; + var lots = await _catalog.ListAsync(Actor); view.Lots = lots.Items; + view.ChoicesHaveMore |= assets.HasMore || lots.HasMore; + switch (tab) { - var inventory = new InventoryJson(); - inventory.InventoryId = item.InventoryId; - inventory.Type = item.Type.Type; - inventory.Amount = item.Amount; - inventory.Batch = item.Batch; - inventory.Timestamp = item.TimeStamp.FormatForDepartment(department); - - if (item.Unit != null) - inventory.Unit = item.Unit.Name; - else - inventory.Unit = _localizer["NoUnit"]; - - if (item.Group != null) - inventory.Group = item.Group.Name; - else - inventory.Group = _localizer["NoGroup"]; - - var name = names.FirstOrDefault(x => x.UserId == item.AddedByUserId); - - if (name != null) - inventory.UserName = name.Name; - else - inventory.UserName = _commonLocalizer["Unknown"]; - - - inventoryJson.Add(inventory); + case "OnHand": await QueryAsync(view, new InventoryQuery { ItemId = itemId, LocationId = locationId }); break; + case "Items": await PageAsync(view); break; + case "Categories": await PageAsync(view); break; + case "Locations": await PageAsync(view); break; + case "Lots": await PageAsync(view); break; + case "Assets": await PageAsync(view); break; + case "Issuances": await PageAsync(view); break; + case "Kits": + await PageAsync(view); + foreach (var kit in view.Rows) + { + var contents = await _catalog.QueryAsync(Actor, new InventoryQuery { KitId = kit.Id }); + if (contents.HasMore) throw new InventoryException(409, "InventoryTooLarge"); + view.KitContents.AddRange(contents.Items); + } + foreach (var component in view.KitContents.Select(x => x.ItemId).Distinct().Where(x => view.Items.All(i => i.Id != x))) view.Items.Add(await _catalog.GetAsync(Actor, component)); + break; + case "Transfers": await PageAsync(view); break; + case "History": await QueryAsync(view, new InventoryQuery { ItemId = itemId, LocationId = locationId }); break; + case "AssetDetail": await QueryAsync(view, new InventoryQuery { AssetId = id }); view.Rows.Insert(0, await _catalog.GetAsync(Actor, id)); break; + case "Transaction": view.Rows.Add(await _catalog.GetAsync(Actor, id)); break; + case "UnitEquipment": if (!unitId.HasValue) throw new InventoryException(400, "HolderRequired"); view.Rows.AddRange((await _issuance.GetUnitEquipmentAsync(Actor, unitId.Value)).Select(e => (InventoryRow)e.Asset ?? e.Stock)); break; + case "PersonnelGear": if (string.IsNullOrWhiteSpace(userId)) throw new InventoryException(400, "HolderRequired"); await QueryAsync(view, new InventoryQuery { IssuedToUserId = userId }); break; + default: throw new InventoryException(400, "InvalidPage"); } - - return Json(inventoryJson); - } - - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task ByUnit() - { - return View(); - } - - [HttpGet] - [Authorize(Policy = ResgridResources.Inventory_View)] - public async Task GetInventoryByUnitList() - { - List inventoryJson = new List(); - - var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); - var items = await _inventoryService.GetAllTransactionsForDepartmentAsync(DepartmentId); - var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); - - foreach (var item in items.Where(x => x.UnitId.HasValue && x.UnitId.Value > 0) - .OrderBy(x => x.Unit != null ? x.Unit.Name : string.Empty) - .ThenBy(x => x.Type != null ? x.Type.Type : string.Empty)) - { - var inventory = new InventoryJson(); - inventory.InventoryId = item.InventoryId; - inventory.Type = item.Type.Type; - inventory.Amount = item.Amount; - inventory.Batch = item.Batch; - inventory.Timestamp = item.TimeStamp.FormatForDepartment(department); - - if (item.Unit != null) - inventory.Unit = item.Unit.Name; - else - inventory.Unit = _localizer["NoUnit"]; - - if (item.Group != null) - inventory.Group = item.Group.Name; - else - inventory.Group = _localizer["NoGroup"]; - - var name = names.FirstOrDefault(x => x.UserId == item.AddedByUserId); - - if (name != null) - inventory.UserName = name.Name; - else - inventory.UserName = _commonLocalizer["Unknown"]; - - inventoryJson.Add(inventory); - } - - return Json(inventoryJson); + foreach (var unit in await _units.GetUnitsForDepartmentAsync(DepartmentId)) + if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = 2, UnitId = unit.UnitId })) view.Units.Add(new() { Id = unit.UnitId.ToString(), Name = unit.Name }); + foreach (var group in await _groups.GetAllGroupsForDepartmentAsync(DepartmentId)) + if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = 1, GroupId = group.DepartmentGroupId })) view.Groups.Add(new() { Id = group.DepartmentGroupId.ToString(), Name = group.Name }); + foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)) + if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = 3, UserId = person.UserId })) view.People.Add(new() { Id = person.UserId, Name = person.Name }); + return View("Workspace", view); + } + [HttpPost, ValidateAntiForgeryToken] public Task Reopen(string tab = "OnHand", int page = 0, string id = null, int? unitId = null, string userId = null, string itemId = null, string locationId = null) => Index(tab, page, id, unitId, userId, itemId, locationId); + [HttpPost, ValidateAntiForgeryToken] public async Task Initialize() => Json(await _migration.MigrateLegacyAsync(Actor)); + [HttpPost, ValidateAntiForgeryToken] public async Task SaveItem(InventoryItemInput input) => Json(await _catalog.SaveItemAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task SaveCategory(string id, int revision, string name, string parentId) => Json(await _catalog.SaveCategoryAsync(Actor, id, revision, name, parentId)); + [HttpPost, ValidateAntiForgeryToken] public async Task SaveLocation(InventoryLocationInput input) => Json(await _catalog.SaveLocationAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task CreateLot(string itemId, DateTime? expiresOn, InventoryLotContent details) => Json(await _catalog.SaveLotAsync(Actor, new InventoryLot { ItemId = itemId, ExpiresOn = expiresOn }, details)); + [HttpPost, ValidateAntiForgeryToken] public async Task Post(InventoryCommand command) => Json(await _stock.PostTransactionAsync(Actor, command)); + [HttpPost, ValidateAntiForgeryToken] public async Task Transfer(InventoryCommand command) => Json(await _transfers.CreateAndCompleteTransferAsync(Actor, command)); + [HttpPost, ValidateAntiForgeryToken] public async Task CreateAsset(InventoryAssetInput input) => Json(await _issuance.CreateAssetAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task Issue(InventoryIssueInput input) => Json(await _issuance.IssueAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task Return(InventoryReturnInput input) => Json(await _issuance.ReturnAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task Status(InventoryCommand command) => Json(await _issuance.ChangeAssetStatusAsync(Actor, command)); + [HttpPost, ValidateAntiForgeryToken] public async Task SaveKit(InventoryKitInput input) => Json(await _issuance.SaveKitAsync(Actor, input)); + [HttpPost, ValidateAntiForgeryToken] public async Task IssueKit(InventoryKitIssueInput input, string userId, int? unitId) + { if (input?.Lines == null) throw new InventoryException(400, "InvalidKit"); foreach (var line in input.Lines) { line.UserId = userId; line.UnitId = unitId; } return Json(await _issuance.IssueKitAsync(Actor, input)); } + [HttpPost, ValidateAntiForgeryToken] public async Task Witness(string requestId, string attestation) => Json(await _stock.WitnessAsync(Actor, requestId, attestation)); + [HttpPost, ValidateAntiForgeryToken] public async Task Rebuild() { await _stock.RebuildStocksAsync(Actor); return Json(new { success = true }); } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public async Task Archive(string kind, string id, int revision) + { + switch (kind) { case "Items": await _catalog.ArchiveAsync(Actor, id, revision); break; case "Categories": await _catalog.ArchiveAsync(Actor, id, revision); break; case "Locations": await _catalog.ArchiveAsync(Actor, id, revision); break; case "Kits": await _catalog.ArchiveAsync(Actor, id, revision); break; default: throw new InventoryException(400, "InvalidInput"); } + return Json(new { success = true }); + } + [HttpGet] public IActionResult ManageTypes() => RedirectToAction("Index", new { tab = "Items" }); + [HttpGet] public IActionResult AddType() => RedirectToAction("Index", new { tab = "Items" }); + [HttpGet] public IActionResult EditType(int typeId) => RedirectToAction("Index", new { tab = "Items" }); + [HttpGet] public IActionResult Adjust() => RedirectToAction("Index"); + [HttpGet] public IActionResult History() => RedirectToAction("Index", new { tab = "History" }); + [HttpGet] public IActionResult ByUnit(int unitId) => RedirectToAction("Index", new { tab = "UnitEquipment", unitId }); + [HttpGet] public IActionResult UnitEquipment(int unitId) => RedirectToAction("Index", new { tab = "UnitEquipment", unitId }); + [HttpGet] public IActionResult PersonnelGear(string userId) => RedirectToAction("Index", new { tab = "PersonnelGear", userId }); + [HttpGet] public IActionResult AssetDetail(string id) => RedirectToAction("Index", new { tab = "AssetDetail", id }); + [HttpGet] public async Task ViewEntry(int inventoryId) + { + for (var page = 0; page <= 200; page++) { var result = await _catalog.ListAsync(Actor, page); var row = result.Items.FirstOrDefault(t => t.LegacyInventoryId == inventoryId); if (row != null) return RedirectToAction("Index", new { tab = "Transaction", id = row.Id }); if (!result.HasMore) break; } + return NotFound(); } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs index db307e626..eb06934bc 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs @@ -6,15 +6,20 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; using Resgrid.Model; +using Resgrid.Model.Inventories; using Resgrid.Model.Services; using Resgrid.Providers.Claims; using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [Authorize(Policy = ResgridResources.Record_Create)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] public class RecordsInventoryController : SecureBaseController { private readonly IRmsInventoryUsageAdapter _usage; @@ -26,9 +31,19 @@ public class RecordsInventoryController : SecureBaseController private readonly IInventoryService _inventory; private readonly IDepartmentGroupsService _groups; private readonly IUnitsService _units; + private readonly IInventoryCatalogService _modernCatalog; + private readonly IInventoryMigrationService _migration; + private readonly IProtectedGrantContext _grant; + private readonly IDepartmentDataProtectionService _protection; + private readonly IStringLocalizer _strings; public RecordsInventoryController(IRmsInventoryUsageAdapter usage, IRecordsEvidenceService evidence, IRecordsAuthorizationService auth, IRecordsCutoverService cutover, - IRecordsService records, IIncidentReportsService incidents, IInventoryService inventory, IDepartmentGroupsService groups, IUnitsService units) - { _usage=usage; _evidence=evidence; _auth=auth; _cutover=cutover; _records=records; _incidents=incidents; _inventory=inventory; _groups=groups; _units=units; } + IRecordsService records, IIncidentReportsService incidents, IInventoryService inventory, IDepartmentGroupsService groups, IUnitsService units, + IInventoryCatalogService modernCatalog = null, IInventoryMigrationService migration = null, IProtectedGrantContext grant = null, + IDepartmentDataProtectionService protection = null, IStringLocalizer strings = null) + { _usage=usage; _evidence=evidence; _auth=auth; _cutover=cutover; _records=records; _incidents=incidents; _inventory=inventory; _groups=groups; _units=units; _modernCatalog=modernCatalog; _migration=migration; _grant=grant; _protection=protection; _strings=strings; } + private InventoryActor Actor => new() { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant?.GrantToken }; + private string UnableToComplete => _strings?["UnableToComplete"].Value ?? "Unable to complete this inventory action."; + private Task IsModernAsync() => _migration?.IsMigratedAsync(DepartmentId) ?? Task.FromResult(false); private async Task VersionAsync(string recordId, RmsRecordKind kind) { if (!(await _cutover.GetModuleStateAsync(DepartmentId)).RecordsUsable || !await _auth.CanUserViewRecordAsync(UserId,recordId,DepartmentId) || !await _auth.HasPermissionAsync(UserId,DepartmentId,PermissionTypes.ViewRestrictedRecords) || !await _auth.CanUseSourceInventoryAsync(UserId,DepartmentId,null)) return null; @@ -39,39 +54,131 @@ public RecordsInventoryController(IRmsInventoryUsageAdapter usage, IRecordsEvide [HttpGet] public async Task Edit(string recordId, RmsRecordKind kind=RmsRecordKind.Operational) { - Response.Headers.CacheControl="no-store"; - var version=await VersionAsync(recordId,kind); if(!version.HasValue) return NotFound(); - var model=new RecordInventoryView {RecordId=recordId,Kind=kind,RowVersion=version.Value,ErrorMessage=TempData["InventoryMessage"] as string,Usage=await _usage.GetUsageForRecordAsync(DepartmentId,recordId)}; - model.Types=(await _inventory.GetAllTypesForDepartmentAsync(DepartmentId)).Select(t=>new SelectListItem {Value=t.InventoryTypeId.ToString(),Text=t.Type+" ("+t.UnitOfMesasure+")"}).ToList(); - model.Groups=(await _groups.GetAllStationGroupsForDepartmentAsync(DepartmentId)).Select(g=>new SelectListItem {Value=g.DepartmentGroupId.ToString(),Text=g.Name}).ToList(); - model.Units=(await _units.GetUnitsForDepartmentAsync(DepartmentId)).Select(u=>new SelectListItem {Value=u.UnitId.ToString(),Text=u.Name}).ToList(); - if (!(await VersionAsync(recordId,kind)).HasValue) return NotFound(); - return View(model); + return await RenderAsync(new RecordInventoryView { RecordId = recordId, Kind = kind, ErrorMessage = TempData["InventoryMessage"] as string }); + } + [HttpPost, ValidateAntiForgeryToken] + public Task Reopen(string recordId, RmsRecordKind kind=RmsRecordKind.Operational) => Edit(recordId, kind); + private async Task RenderAsync(RecordInventoryView model, bool preserveVersion = false) + { + Response.Headers.CacheControl = "no-store"; + var version = await VersionAsync(model.RecordId, model.Kind); if (!version.HasValue) return NotFound(); + if (!preserveVersion) model.RowVersion = version.Value; + model.ModernInventory = await IsModernAsync(); + model.ProtectionEnforced = _protection != null && await _protection.IsProtectionEnforcedAsync(DepartmentId); + model.ProtectedGrant = _grant?.GrantToken; + model.ProtectedGrantExpiresOnUtc = HttpProtectedGrantContext.ReadExpiry(Request); + model.ProtectionRedacted = false; + model.Types = new(); model.Groups = new(); model.Units = new(); model.Locations = new(); model.Lots = new(); model.Assets = new(); model.Usage = new(); + ViewBag.InventoryUnavailable = false; + try + { + if (model.ProtectionEnforced && string.IsNullOrWhiteSpace(model.ProtectedGrant)) throw new InventoryException(403, "ProtectedDataRequired"); + if (model.ModernInventory) + { + if (_modernCatalog == null) throw new InventoryException(503, "InventoryUnavailable"); + var items = (await ChoicesAsync()).Where(x => !x.IsDeleted && x.IsActive).ToList(); + var names = items.ToDictionary(x => x.Id, x => Details(x).Name ?? x.Id); + model.Types = items.Select(x => new SelectListItem { Value = x.Id, Text = names[x.Id] + " (" + Details(x).UnitOfMeasure + ")" }).ToList(); + model.Locations = (await ChoicesAsync()).Where(x => !x.IsDeleted).Select(x => new SelectListItem { Value = x.Id, Text = Details(x).Name ?? x.Id }).ToList(); + model.Lots = (await ChoicesAsync()).Where(x => !x.IsDeleted && names.ContainsKey(x.ItemId)).Select(x => new SelectListItem { Value = x.Id, Text = names[x.ItemId] + " · " + (Details(x).LotNumber ?? x.Id) }).ToList(); + model.Assets = (await ChoicesAsync()).Where(x => !x.IsDeleted && x.Status == (int)InventoryAssetStatus.InService && names.ContainsKey(x.ItemId)).Select(x => new SelectListItem { Value = x.Id, Text = names[x.ItemId] + " · " + (Details(x).SerialNumber ?? x.Id) }).ToList(); + } + else + { + model.Types=(await _inventory.GetAllTypesForDepartmentAsync(DepartmentId)).Select(t=>new SelectListItem {Value=t.InventoryTypeId.ToString(),Text=t.Type+" ("+t.UnitOfMesasure+")"}).ToList(); + model.Groups=(await _groups.GetAllStationGroupsForDepartmentAsync(DepartmentId)).Select(g=>new SelectListItem {Value=g.DepartmentGroupId.ToString(),Text=g.Name}).ToList(); + model.Units=(await _units.GetUnitsForDepartmentAsync(DepartmentId)).Select(u=>new SelectListItem {Value=u.UnitId.ToString(),Text=u.Name}).ToList(); + } + model.Usage = await _usage.GetUsageForRecordAsync(DepartmentId, model.RecordId); + } + catch (InventoryException ex) + { + model.Types.Clear(); model.Locations.Clear(); model.Lots.Clear(); model.Assets.Clear(); model.Usage.Clear(); + if (ex.Code == "ProtectedDataRequired") + { + model.ProtectionEnforced = true; model.ProtectionRedacted = true; model.ProtectedGrant = null; model.ProtectedGrantExpiresOnUtc = null; + model.ErrorMessage = _strings?["ProtectedDataRequired"].Value ?? "Verify your identity to view protected inventory."; + } + else + { + ViewBag.InventoryUnavailable = true; + model.ErrorMessage = ex.Code == "InventoryChoiceLimitExceeded" ? "There are too many inventory choices to load this form. Ask an administrator to reduce the active inventory selection before recording usage." : UnableToComplete; + } + } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is JsonException) + { + ViewBag.InventoryUnavailable = true; model.ErrorMessage = UnableToComplete; + model.Types.Clear(); model.Locations.Clear(); model.Lots.Clear(); model.Assets.Clear(); model.Usage.Clear(); + } + if (!(await VersionAsync(model.RecordId, model.Kind)).HasValue) return NotFound(); + return View("Edit", model); + } + private async Task> ChoicesAsync() where T : InventoryRow + { + var choices = new List(); + for (var page = 0; page < 20; page++) + { + var result = await _modernCatalog.ListAsync(Actor, page); + if (result?.Items == null) throw new InventoryException(503, "InventoryUnavailable"); + choices.AddRange(result.Items); + if (choices.Count > 5000) throw new InventoryException(409, "InventoryChoiceLimitExceeded"); + if (!result.HasMore) return choices; + } + throw new InventoryException(409, "InventoryChoiceLimitExceeded"); } + private static T Details(InventoryRow row) where T : new() => string.IsNullOrWhiteSpace(row.Content) ? new T() : JsonConvert.DeserializeObject(row.Content) ?? new T(); [HttpPost] [ValidateAntiForgeryToken] public async Task Consume(RecordInventoryView model,CancellationToken cancellationToken) { + Response.Headers.CacheControl = "no-store"; if (!(await VersionAsync(model.RecordId,model.Kind)).HasValue) return NotFound(); + var modern = await IsModernAsync(); + if (!ModelState.IsValid) { model.ErrorMessage = UnableToComplete; return await RenderAsync(model, true); } + var saved = false; string message; try { - await _usage.ConsumeAsync(DepartmentId,UserId,model.RecordId,model.Kind,model.RowVersion,model.TypeId,model.GroupId,model.UnitId,model.Quantity,model.Note,cancellationToken); - try { await CaptureAsync(model.RecordId,model.Kind,cancellationToken); TempData["InventoryMessage"]="Usage saved and supporting evidence captured."; } - catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException) { TempData["InventoryMessage"]="The consumption was saved. Evidence could not be captured; use Refresh evidence after resolving access or draft changes. Do not enter the consumption again."; } + if (modern) + { + // The model creates a request ID for a new form only. Network submissions must explicitly carry it. + if (!Request.HasFormContentType || !Request.Form.TryGetValue(nameof(model.RequestId), out var supplied) || supplied.Count != 1 || !Guid.TryParseExact(supplied[0], "D", out var requestId) || requestId == Guid.Empty || model.RequestId != supplied[0]) + throw new InventoryException(400, "RequestIdRequired"); + await _usage.ConsumeModernAsync(Actor, model.RecordId, model.Kind, model.RowVersion, new InventoryCommand + { + RequestId = requestId.ToString("D"), + Lines = new List { new() { Type = InventoryTransactionType.Consume, ItemId = model.ItemId, FromLocationId = model.LocationId, LotId = model.LotId, AssetId = model.AssetId, Quantity = model.Quantity, Note = model.Note } } + }, cancellationToken); + } + else await _usage.ConsumeAsync(DepartmentId,UserId,model.RecordId,model.Kind,model.RowVersion,model.TypeId,model.GroupId,model.UnitId,model.Quantity,model.Note,cancellationToken,_grant?.GrantToken); + saved = true; + try { await CaptureAsync(model.RecordId,model.Kind,cancellationToken); message="Usage saved and supporting evidence captured."; } + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { message="The consumption was saved. Evidence could not be captured; use Refresh evidence after resolving access or draft changes. Do not enter the consumption again."; } } catch(UnauthorizedAccessException) { return Forbid(); } - catch(RecordConcurrencyException) { TempData["InventoryMessage"]="The draft changed. Check the recorded usage below before entering another consumption."; } - catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) { TempData["InventoryMessage"]=ex.Message; } + catch(RecordConcurrencyException) { message="The draft changed. Check the recorded usage below before entering another consumption."; } + catch(InventoryException ex) { message = ex.Code == "IndependentWitnessRequired" ? "Controlled-substance usage requires the independent witness process in Inventory. No consumption was recorded here." : UnableToComplete; } + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) { message=UnableToComplete; } + if (modern || _protection != null && await _protection.IsProtectionEnforcedAsync(DepartmentId)) + { + if (saved) { ModelState.Clear(); model = new RecordInventoryView { RecordId = model.RecordId, Kind = model.Kind }; } + model.ErrorMessage = message; return await RenderAsync(model, !saved); + } + TempData["InventoryMessage"] = message; return RedirectToAction("Edit",new {recordId=model.RecordId,kind=model.Kind}); } [HttpPost] [ValidateAntiForgeryToken] public async Task RefreshEvidence(string recordId,RmsRecordKind kind,CancellationToken cancellationToken) { + Response.Headers.CacheControl = "no-store"; if (!(await VersionAsync(recordId,kind)).HasValue) return NotFound(); - try { await CaptureAsync(recordId,kind,cancellationToken); TempData["InventoryMessage"]="Supporting evidence captured."; } + string message; + try { await CaptureAsync(recordId,kind,cancellationToken); message="Supporting evidence captured."; } catch(UnauthorizedAccessException) { return Forbid(); } - catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) { TempData["InventoryMessage"]=ex.Message; } + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is InventoryException) { message=UnableToComplete; } + if (await IsModernAsync() || _protection != null && await _protection.IsProtectionEnforcedAsync(DepartmentId)) + return await RenderAsync(new RecordInventoryView { RecordId = recordId, Kind = kind, ErrorMessage = message }); + TempData["InventoryMessage"] = message; return RedirectToAction("Edit",new {recordId,kind}); } private Task CaptureAsync(string id,RmsRecordKind kind,CancellationToken ct) => _evidence.CaptureAsync(new RecordEvidenceCaptureRequest {DepartmentId=DepartmentId,RecordId=id,RecordKind=kind,Kind=RmsEvidenceKind.InventoryUsage,CapturedByUserId=UserId,CaptureReason="Officer recorded inventory consumption",OriginClient=RmsOriginClient.Web},true,ct); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index 356c947f1..60a4e9036 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -482,7 +482,7 @@ SelectList AdpOptions(bool includeEveryone) // Rows come from RecordPermissionCatalog so this screen, ClaimsLogic.AddRecordClaims and the // activation-time row migration share one set of no-row defaults. A missing row preselects that // default, which for the Logs-parity types equals today's CreateLog/DeleteLog fall-through. - model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).ToList(); + model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).ToList(); var recordsState = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); model.RecordsFlagEnabled = recordsState != null && recordsState.FlagEnabled; model.RecordsActivated = recordsState != null && recordsState.RecordsUsable; @@ -768,6 +768,8 @@ public async Task SetPermissionData(int type, string data, bool? public async Task GetRolesForPermission(int type) { var before = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, (PermissionTypes)type); + if (before == null && (type == (int)PermissionTypes.TransferInventory || type == (int)PermissionTypes.IssueInventory)) + before = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory); if (before != null) return Json(before.Data); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index da3108443..8ffbb23b1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -923,10 +923,11 @@ public async Task DeleteUnit(int unitId, CancellationToken cancel auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + try { await _unitsService.DeleteUnitAsync(unitId, cancellationToken); } + catch (Resgrid.Model.Inventories.InventoryException ex) when (ex.Code == "HolderHistoryRetained") + { return Conflict(new { code = ex.Code, message = "This unit is referenced by inventory history and must be retained." }); } _eventAggregator.SendMessage(auditEvent); - await _unitsService.DeleteUnitAsync(unitId, cancellationToken); - return RedirectToAction("Index"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs index 96a817013..8acb0d59a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs @@ -54,7 +54,7 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context public async Task New() { if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); - return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput(), Choices = await _orders.ChoicesAsync(Actor), CanManage = await _authorization.CanManageAsync(Actor, null) }); + return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D") }, Choices = await _orders.ChoicesAsync(Actor), CanManage = await _authorization.CanManageAsync(Actor, null) }); } [HttpGet] public async Task Detail(int id) => View("Detail", new WorkOrderDetailView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor) }); diff --git a/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs new file mode 100644 index 000000000..dbe5afc42 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Resgrid.Model.Inventories; +namespace Resgrid.Web.Areas.User.Models.Inventory +{ + public sealed class InventoryChoice { public string Id { get; set; } public string Name { get; set; } } + public sealed class InventoryWorkspaceView + { + public string Tab { get; set; } = "OnHand"; + public int Page { get; set; } + public bool HasMore { get; set; } + public bool ChoicesHaveMore { get; set; } + public bool Migrated { get; set; } + public bool Locked { get; set; } + public bool CanWrite { get; set; } + public bool CanTransfer { get; set; } + public bool CanIssue { get; set; } + public bool CanWitness { get; set; } + public int? UnitId { get; set; } + public string UserId { get; set; } + public string Id { get; set; } + public string ItemId { get; set; } + public string LocationId { get; set; } + public List Rows { get; set; } = new(); + public List Items { get; set; } = new(); + public List Locations { get; set; } = new(); + public List Categories { get; set; } = new(); + public List Assets { get; set; } = new(); + public List Lots { get; set; } = new(); + public List KitContents { get; set; } = new(); + public List Units { get; set; } = new(); + public List People { get; set; } = new(); + public List Groups { get; set; } = new(); + public static T Details(InventoryRow row) where T : new() => string.IsNullOrEmpty(row?.Content) ? new T() : JsonConvert.DeserializeObject(row.Content) ?? new T(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index 290c2db7a..06ca36a2b 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -153,6 +153,15 @@ public class RecordParticipantEditRow public class RecordInventoryView : RecordsBaseView { + public bool ModernInventory { get; set; } + public string ItemId { get; set; } + public string LocationId { get; set; } + public string LotId { get; set; } + public string AssetId { get; set; } + public string RequestId { get; set; } = System.Guid.NewGuid().ToString("D"); + public List Locations { get; set; } = new(); + public List Lots { get; set; } = new(); + public List Assets { get; set; } = new(); public string RecordId { get; set; } public RmsRecordKind Kind { get; set; } public long RowVersion { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs index 3dde4a16d..41b46838b 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs @@ -43,6 +43,8 @@ public static List Build(IEnumerable permissio foreach (var descriptor in descriptors ?? RecordPermissionCatalog.All) { var row = existing.FirstOrDefault(p => p.PermissionType == (int)descriptor.Type); + if (row == null && descriptor.Type is PermissionTypes.TransferInventory or PermissionTypes.IssueInventory) + row = existing.FirstOrDefault(p => p.PermissionType == (int)PermissionTypes.AdjustInventory); var value = row != null ? row.Action : (int)descriptor.NoRowDefault; rows.Add(new RecordsPermissionRow diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml new file mode 100644 index 000000000..ec703e07e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml @@ -0,0 +1,155 @@ +@using System.Globalization +@using System.Linq +@using Microsoft.AspNetCore.Html +@using Microsoft.AspNetCore.Mvc.Rendering +@using Newtonsoft.Json +@using Resgrid.Model.Inventories +@using Resgrid.Web.Areas.User.Models +@using Resgrid.Web.Areas.User.Models.Inventory +@model InventoryWorkspaceView +@inject IStringLocalizer localizer +@functions { + IHtmlContent Field(string name, string label, object value = null, string type = "text", IEnumerable options = null) { + var wrap = new TagBuilder("div"); wrap.AddCssClass("form-group"); + var caption = new TagBuilder("label"); var identity = "inv-" + Guid.NewGuid().ToString("N"); caption.Attributes["for"] = identity; caption.InnerHtml.Append(localizer[label].Value); wrap.InnerHtml.AppendHtml(caption); + var element = new TagBuilder(options == null ? "input" : "select"); element.AddCssClass("form-control"); element.Attributes["name"] = name; element.Attributes["id"] = identity; + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + if (options == null) { element.Attributes["type"] = type; element.Attributes["value"] = text; element.Attributes["maxlength"] = "16000"; if (type == "number") element.Attributes["step"] = "0.000001"; } + else foreach (var option in options) { var item = new TagBuilder("option"); item.Attributes["value"] = option.Value ?? ""; if ((option.Value ?? "") == text) item.Attributes["selected"] = "selected"; item.InnerHtml.Append(option.Text); element.InnerHtml.AppendHtml(item); } + wrap.InnerHtml.AppendHtml(element); return wrap; + } + IEnumerable Choice(IEnumerable list) => new[] { new SelectListItem { Value = "", Text = "—" } }.Concat(list.Select(x => new SelectListItem { Value = x.Id, Text = x.Name })); + IEnumerable Codes(string prefix, params int[] values) => values.Select(x => new SelectListItem { Value = x.ToString(), Text = localizer[prefix + x].Value }); +} +@{ + ViewBag.Title = "Resgrid | " + localizer["Inventory"]; + var protectedData = ViewBag.ProtectionEnforced == true; + var itemOptions = Choice(Model.Items.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).Name })); + var locationOptions = Choice(Model.Locations.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).Name })); + var categoryOptions = Choice(Model.Categories.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).Name })); + var assetOptions = Choice(Model.Assets.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).SerialNumber + " · " + InventoryWorkspaceView.Details(x).AssetTag })); + var lotOptions = Choice(Model.Lots.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).LotNumber })); + string ItemName(string id) => Model.Items.FirstOrDefault(i => i.Id == id) is { } item ? InventoryWorkspaceView.Details(item).Name : id; + string LocationName(string id) => Model.Locations.FirstOrDefault(i => i.Id == id) is { } location ? InventoryWorkspaceView.Details(location).Name : id; +} +

@localizer["Inventory"]

+ @if (ViewBag.ProtectionEnforced == true) { @await Html.PartialAsync("_AdpRevealBanner", new AdpRevealView { BannerTitle = localizer["ProtectedDataRequired"].Value }) } + +
+ @Html.AntiForgeryToken() + @if (Model.Locked) { } +
+ @if (!Model.Locked && !Model.Migrated) { +

@localizer["InitializeDescription"]

+ @if (Model.CanWrite) {
@Html.AntiForgeryToken()
} + } + @if (!Model.Locked && Model.Migrated) { +

@localizer[Model.Tab]

+ @if (Model.ChoicesHaveMore) {

@localizer["MoreChoices"]

} + @if (Model.Tab is "OnHand" or "History") { +
+ @if (protectedData) { @Html.AntiForgeryToken() } + + @Field("itemId", "Items", Model.ItemId, options: itemOptions)@Field("locationId", "Location", Model.LocationId, options: locationOptions) + +
+ } + @if (Model.Tab == "OnHand") { +
@Html.AntiForgeryToken()@Field("unitId", "Unit", null, options: Choice(Model.Units))
+
@Html.AntiForgeryToken()@Field("userId", "Person", null, options: Choice(Model.People))
+ } +
+ @foreach (var row in Model.Rows) { + var title = row switch { InventoryItem i => InventoryWorkspaceView.Details(i).Name, InventoryAsset a => ItemName(a.ItemId) + " · " + InventoryWorkspaceView.Details(a).SerialNumber, InventoryStock s => ItemName(s.ItemId), InventoryIssuance i => ItemName(i.ItemId), InventoryLot l => InventoryWorkspaceView.Details(l).LotNumber, InventoryTransaction t => ItemName(t.ItemId) + " · " + localizer["Movement" + t.TransactionType].Value, _ => InventoryWorkspaceView.Details(row).Name ?? row.Id }; + var location = row switch { InventoryAsset a => LocationName(a.CurrentLocationId), InventoryStock s => LocationName(s.LocationId), InventoryIssuance i => LocationName(i.LocationId), InventoryTransaction t => LocationName(t.FromLocationId) + " → " + LocationName(t.ToLocationId), InventoryTransfer t => LocationName(t.FromLocationId) + " → " + LocationName(t.ToLocationId), _ => "" }; + var amount = row switch { InventoryStock s => s.Quantity.ToString(CultureInfo.CurrentCulture), InventoryIssuance i => (i.Quantity - i.ReturnedQuantity).ToString(CultureInfo.CurrentCulture), InventoryTransaction t => t.Quantity.ToString(CultureInfo.CurrentCulture), _ => "" }; + + } +
@localizer["Name"]@localizer["Location"]@localizer["Amount"]@localizer["Status"]@localizer["Actions"]
@title@row.Id@location@amount@(row is InventoryAsset asset ? localizer["Status" + asset.Status] : row is InventoryIssuance issued ? localizer["IssuanceStatus" + issued.Status] : "") + @if (row is InventoryAsset) {
@Html.AntiForgeryToken()
} + @if (row is InventoryTransaction txn) {
@localizer["Details"]

@(InventoryWorkspaceView.Details(txn).Note)

@localizer["Reference"]: @txn.ReferenceType / @txn.ReferenceId

} + @if (Model.CanWrite && Model.Tab is "Items" or "Categories" or "Locations" or "Kits") {
@Html.AntiForgeryToken()
} + @if (Model.CanIssue && row is InventoryIssuance outstanding && outstanding.Status is 0 or 2) { +
@localizer["Return"]
@Html.AntiForgeryToken()@Field("ToLocationId", "ToLocation", null, options: locationOptions)@Field("Quantity", "Amount", outstanding.Quantity - outstanding.ReturnedQuantity, "number")@Field("Condition", "Status", 0, options: Codes("Status", 0, 2, 3))@Field("Note", "Note")
+ } + @if (Model.CanWrite && row is InventoryCategory category) { +
@localizer["Edit"]
@Html.AntiForgeryToken()@Field("name", "Name", InventoryWorkspaceView.Details(category).Name)@Field("parentId", "Parent", category.ParentCategoryId, options: categoryOptions)
+ } + @if (Model.CanWrite && row is InventoryLocation place) { +
@localizer["Edit"]
@Html.AntiForgeryToken()@Field("Name", "Name", InventoryWorkspaceView.Details(place).Name)
+ } + @if (Model.CanWrite && row is InventoryKit editableKit) { +
@localizer["Edit"]
+ @Html.AntiForgeryToken()@Field("Name", "Name", InventoryWorkspaceView.Details(editableKit).Name) +
+ @{ var editLines = Model.KitContents.Where(k => k.KitId == editableKit.Id && !k.IsDeleted).ToList(); var editIndex = 0; } + @foreach (var kitLine in editLines.DefaultIfEmpty(new InventoryKitItem { Quantity = 1 })) { + var prefix = "Lines[" + editIndex++ + "]."; +
@Field(prefix + "ItemId", "Items", kitLine.ItemId, options: itemOptions)@Field(prefix + "Quantity", "Amount", kitLine.Quantity, "number")
+ } +
+ } + @if (Model.CanIssue && row is InventoryKit kit && Model.KitContents.Any(k => k.KitId == kit.Id && !k.IsDeleted)) { + var kitLines = Model.KitContents.Where(k => k.KitId == kit.Id && !k.IsDeleted).ToList(); + var issueCount = kitLines.Sum(k => Model.Items.FirstOrDefault(i => i.Id == k.ItemId)?.TrackingMode == (int)InventoryTrackingMode.Serialized ? k.Quantity : 1); + if (issueCount <= 100 && kitLines.All(k => Model.Items.Any(i => i.Id == k.ItemId))) { +
@localizer["Issue"]
@Html.AntiForgeryToken()@Field("userId", "Person", null, options: Choice(Model.People))@Field("unitId", "Unit", null, options: Choice(Model.Units)) + @{ var lineIndex = 0; } + @foreach (var kitLine in kitLines) { + var serialized = Model.Items.First(i => i.Id == kitLine.ItemId).TrackingMode == (int)InventoryTrackingMode.Serialized; + var quantity = serialized ? 1 : kitLine.Quantity; + foreach (var component in Enumerable.Range(0, serialized ? (int)kitLine.Quantity : 1)) { + var prefix = "Lines[" + lineIndex++ + "]."; +
@ItemName(kitLine.ItemId) @(serialized ? (component + 1).ToString(CultureInfo.CurrentCulture) : "")

@localizer["Amount"]: @quantity.ToString(CultureInfo.CurrentCulture)

+ @if (serialized) { @Field(prefix + "AssetId", "AssetId", null, options: Choice(Model.Assets.Where(a => a.ItemId == kitLine.ItemId && a.Status == (int)InventoryAssetStatus.InService && a.CurrentLocationId != null).Select(a => new InventoryChoice { Id = a.Id, Name = InventoryWorkspaceView.Details(a).SerialNumber + " · " + InventoryWorkspaceView.Details(a).AssetTag }))) } + @Field(prefix + "LotId", "LotId", null, options: Choice(Model.Lots.Where(l => l.ItemId == kitLine.ItemId).Select(l => new InventoryChoice { Id = l.Id, Name = InventoryWorkspaceView.Details(l).LotNumber })))@Field(prefix + "FromLocationId", "FromLocation", null, options: locationOptions)
+ } + } +
+ } else {

@localizer["UnableToComplete"]

} + } +
+ @if (Model.Rows.Count == 0) {

@localizer["NoRows"]

} + @foreach (var nextPage in new[] { Model.Page - 1, Model.Page + 1 }.Where(p => p >= 0 && (p < Model.Page || Model.HasMore))) {
@Html.AntiForgeryToken()
} + @if (Model.Tab == "AssetDetail") { +

@localizer["Checklists"] · @localizer["WorkOrders"]

+ } + @if (Model.CanWrite || Model.CanTransfer || Model.CanIssue || Model.CanWitness) { +
+ @if (Model.CanWrite && Model.Tab == "Items") { + foreach (var item in new[] { new InventoryItem() }.Concat(Model.Rows.OfType())) { + var details = InventoryWorkspaceView.Details(item); var isNew = !Model.Rows.Contains(item); +
@localizer[isNew ? "NewItem" : "Edit"] @details.Name
+ @Html.AntiForgeryToken() + @Field("Details.Name", "Name", details.Name)@Field("Details.Description", "Description", details.Description)@Field("Details.UnitOfMeasure", "UnitOfMeasure", details.UnitOfMeasure)@Field("Details.Code", "Code", details.Code)@Field("Details.Barcode", "Barcode", details.Barcode) + @Field("CategoryId", "Categories", item.CategoryId, options: categoryOptions)@Field("TrackingMode", "TrackingMode", item.TrackingMode, options: Codes("Tracking", 0, 1)) + @foreach (var flag in new[] { ("IsKit", item.IsKit), ("RequiresLotTracking", item.RequiresLotTracking), ("RequiresExpiration", item.RequiresExpiration), ("IsControlledSubstance", item.IsControlledSubstance), ("IsActive", item.IsActive) }) { @Field(flag.Item1, flag.Item1, flag.Item2.ToString().ToLowerInvariant(), options: new[] { new SelectListItem { Value = "false", Text = localizer["No"] }, new SelectListItem { Value = "true", Text = localizer["Yes"] } }) } + @Field("Details.MinLevel", "Minimum", details.MinLevel, "number")@Field("Details.ReorderPoint", "ReorderPoint", details.ReorderPoint, "number")@Field("Details.DefaultUnitCost", "UnitCost", details.DefaultUnitCost, "number") + +
+ } + } + @if (Model.CanWrite && Model.Tab == "Categories") {
@Html.AntiForgeryToken()@Field("name", "Name")@Field("parentId", "Parent", null, options: categoryOptions)
} + @if (Model.CanWrite && Model.Tab == "Locations") {
@Html.AntiForgeryToken()@Field("Name", "Name")@Field("Type", "LocationType", 0, options: Codes("LocationType", 0, 1, 2, 3, 4, 5))@Field("GroupId", "Station", null, options: Choice(Model.Groups))@Field("UnitId", "Unit", null, options: Choice(Model.Units))@Field("UserId", "Person", null, options: Choice(Model.People))@Field("ContainerAssetId", "ContainerAsset", null, options: assetOptions)@Field("ParentLocationId", "Parent", null, options: locationOptions)@Field("IsDefault", "DefaultLocation", "false", options: new[] { new SelectListItem { Value = "false", Text = localizer["No"] }, new SelectListItem { Value = "true", Text = localizer["Yes"] } })
} + @if (Model.CanWrite && Model.Tab == "Lots") {
@Html.AntiForgeryToken()@Field("itemId", "Items", null, options: itemOptions)@Field("details.LotNumber", "Batch")@Field("expiresOn", "Expiration", null, "date")@Field("details.UnitCost", "UnitCost", null, "number")
} + @if (Model.CanWrite && Model.Tab == "Assets") {
@Html.AntiForgeryToken()@Field("ItemId", "Items", null, options: itemOptions)@Field("LocationId", "Location", null, options: locationOptions)@Field("LotId", "LotId", null, options: lotOptions)@Field("Details.SerialNumber", "SerialNumber")@Field("Details.AssetTag", "AssetTag")@Field("Details.Barcode", "Barcode")@Field("ExpiresOn", "Expiration", null, "date")
} + @if ((Model.CanWrite && Model.Tab == "OnHand") || (Model.CanTransfer && Model.Tab == "Transfers")) {
@Html.AntiForgeryToken()@Field("Lines[0].Type", "Movement", Model.Tab == "Transfers" ? 3 : 1, options: Model.Tab == "Transfers" ? Codes("Movement", 3) : Codes("Movement", 1, 2, 6, 8))@Field("Lines[0].ItemId", "Items", null, options: itemOptions)@Field("Lines[0].AssetId", "AssetId", null, options: assetOptions)@Field("Lines[0].LotId", "LotId", null, options: lotOptions)@Field("Lines[0].FromLocationId", "FromLocation", null, options: locationOptions)@Field("Lines[0].ToLocationId", "ToLocation", null, options: locationOptions)@Field("Lines[0].Quantity", "Amount", null, "number")@Field("Lines[0].Note", "Note")

@localizer["MovementHelp"]

} + @if (Model.CanIssue && Model.Tab == "Issuances") {
@Html.AntiForgeryToken()@Field("ItemId", "Items", null, options: itemOptions)@Field("AssetId", "AssetId", null, options: assetOptions)@Field("LotId", "LotId", null, options: lotOptions)@Field("FromLocationId", "FromLocation", null, options: locationOptions)@Field("Quantity", "Amount", null, "number")@Field("UserId", "Person", null, options: Choice(Model.People))@Field("UnitId", "Unit", null, options: Choice(Model.Units))@Field("ExpectedReturnOn", "ExpectedReturn", null, "date")@Field("Note", "Note")

@localizer["HolderHelp"]

} + @if (Model.CanWrite && Model.Tab == "AssetDetail" && Model.Rows.OfType().FirstOrDefault() is { } current) {
@Html.AntiForgeryToken()@Field("Lines[0].Status", "Status", current.Status, options: Codes("Status", 0, 2, 3, 4, 5, 6))@Field("Lines[0].Note", "Note")
} + @if (Model.CanWrite && Model.Tab == "Kits") {
@Html.AntiForgeryToken()@Field("Name", "Name")
@Field("Lines[0].ItemId", "Items", null, options: itemOptions)@Field("Lines[0].Quantity", "Amount", 1, "number")
} + @if (Model.Tab == "History") { + if (Model.CanWitness) {
@Html.AntiForgeryToken()@Field("requestId", "WitnessRequest")@Field("attestation", "Attestation")
} + if (Model.CanWrite) {
@Html.AntiForgeryToken()
} + } + } + } +
+@section Scripts { + + +@if (ViewBag.ProtectionEnforced == true) { @await Html.PartialAsync("_AdpRevealScripts", new AdpRevealView { BannerTitle = localizer["ProtectedDataRequired"].Value, GrantExpiresOnUtc = (DateTime?)ViewBag.GrantExpiresOn, BindForms = new List { "form.inventory-navigation", "form.inventory-command" } }) } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml index eb5347892..0dd70a8e8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/ViewPerson.cshtml @@ -2,6 +2,7 @@ @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Personnel.ViewPersonView @inject IStringLocalizer localizer +@inject IStringLocalizer inventoryLocalizer @{ ViewBag.Title = "Resgrid | View Profile" + @localizer["ViewPersonHeader"]; } @@ -24,6 +25,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml index 95f10a9ea..149f920bf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml @@ -1,25 +1,78 @@ @using Resgrid.Model +@using Resgrid.Web.Areas.User.Models +@using Resgrid.Web.Helpers +@using Newtonsoft.Json @model Resgrid.Web.Areas.User.Models.Records.RecordInventoryView +@inject IStringLocalizer inventoryLocalizer @{ ViewBag.Title = "Resgrid | Record inventory usage"; var controller = Model.Kind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; }

Inventory usage

Enter supplies consumed on this report. Saving creates an inventory ledger adjustment and captures supporting evidence. Correct stock adjustments in Inventory; record corrections through an RMS amendment.

@if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @if (Model.ProtectionEnforced) { @await Html.PartialAsync("_AdpRevealBanner", new AdpRevealView { BannerTitle = inventoryLocalizer["ProtectedDataRequired"].Value }) } +
+ @Html.AntiForgeryToken() + + + @if (Model.ProtectionRedacted) { } +
+
+ @if (!Model.ProtectionRedacted && ViewBag.InventoryUnavailable != true) {

Recorded usage

- @foreach (var usage in Model.Usage) { } + @foreach (var usage in Model.Usage) { + var redact = Model.ProtectionEnforced || !string.IsNullOrEmpty(usage.TransactionId); + + + + + }
ItemQuantityNoteLedger entryRecorded at (UTC)
@usage.ItemName@usage.Quantity @usage.UnitOfMeasure@usage.Note@usage.InventoryId@usage.CapturedOn.ToString("u")
@(redact ? ProtectedDataEnvelope.RedactionValue : usage.ItemName) @if (!string.IsNullOrEmpty(usage.ItemId)) { @usage.ItemId }@usage.Quantity @(redact ? ProtectedDataEnvelope.RedactionValue : usage.UnitOfMeasure)@(redact ? ProtectedDataEnvelope.RedactionValue : usage.Note)@if (!string.IsNullOrEmpty(usage.TransactionId)) { @usage.TransactionId } else { @usage.InventoryId }@usage.CapturedOn.ToString("u")
-
+ @if (Model.ModernInventory) {

Usage retains the transaction identifiers and quantity. Open the ledger entry to view inventory details with your current protected-data access.

} + @Html.AntiForgeryToken() + + + + @if (Model.ModernInventory) { +
+
+
+
+ } else {
+ }
Return to report
-
+ @Html.AntiForgeryToken() + +
+ } +
+@section Scripts { + @if (Model.ProtectionEnforced) { + + @await Html.PartialAsync("_AdpRevealScripts", new AdpRevealView { + BannerTitle = inventoryLocalizer["ProtectedDataRequired"].Value, + GrantExpiresOnUtc = Model.ProtectedGrantExpiresOnUtc, + BindForms = new System.Collections.Generic.List { "#record-inventory-reopen", "#record-inventory-consume", "#record-inventory-evidence" } + }) + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index cf2352652..4d44e940b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -47,7 +47,8 @@ - @checklistLocalizer["ChecklistComplianceReport"]@checklistLocalizer["AuthorizedScope"]@checklistLocalizer["ReadinessPacketReport"] + @checklistLocalizer["ChecklistComplianceReport"]@checklistLocalizer["AuthorizedScope"] @localizer["ViewReport"] + @checklistLocalizer["ReadinessPacketReport"]@checklistLocalizer["AuthorizedScope"] @localizer["ViewReport"] @localizer["PersonnelStatusHistoryReportName"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml index d79116689..4ee1390fb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Units/EditUnit.cshtml @@ -1,5 +1,6 @@ @model Resgrid.Web.Areas.User.Models.Units.NewUnitView @inject IStringLocalizer localizer +@inject IStringLocalizer inventoryLocalizer @{ ViewBag.Title = "Resgrid | " + @localizer["EditUnitHeader"]; } @@ -15,6 +16,7 @@

@localizer["EditUnitHeader"]

+ @inventoryLocalizer["UnitEquipment"]
@if (Model.Orders.Items.Count == 0) {

@localizer["NoWorkOrders"]

} -@if (Model.Filter.Page > 0) {
@localizer["Previous"] } -@if (Model.Orders.HasMore) { @localizer["Next"] } +@if (Model.Filter.Page > 0) { @localizer["Previous"] } +@if (Model.Orders.HasMore) { @localizer["Next"] }
@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml index 143059ee3..f1914c8ef 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/Edit.cshtml @@ -6,7 +6,7 @@ ViewData["Title"] = "Resgrid | " + localizer["EditWorkflowHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var credentialsJson = (string)(ViewBag.CredentialsJson ?? "[]"); - var triggerEventTypeName = Model.TriggerEventType is 70 or 71 or 72 ? workOrderStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); + var triggerEventTypeName = Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder(Model.TriggerEventType) ? workOrderStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)Model.TriggerEventType) ? checklistStrings[((Resgrid.Model.WorkflowTriggerEventType)Model.TriggerEventType).ToString()].Value : (string)(ViewBag.TriggerEventTypeName ?? Model.TriggerEventType.ToString()); } @section Styles { diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml index 1afc05be2..c78f5bbb1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -11,7 +11,7 @@ .Cast() .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) - .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = (int)e is 70 or 71 or 72 ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) + .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder((int)e) ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) .ToList(); var noEventTypesAvailable = !eventTypes.Any(); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-modern.js b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-modern.js new file mode 100644 index 000000000..2a9a88b2c --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-modern.js @@ -0,0 +1,67 @@ +(function () { + 'use strict'; + const settings = JSON.parse(document.getElementById('inventory-settings').textContent); + const message = document.getElementById('inventory-message'); + function hidden(form, name, value) { + let field = form.querySelector('input[name="' + name + '"]'); + if (!field) { field = document.createElement('input'); field.type = 'hidden'; field.name = name; form.appendChild(field); } + field.value = value || ''; + } + document.querySelectorAll('form.inventory-navigation, form.inventory-command').forEach(form => { + if (form.method.toLowerCase() !== 'post') return; + if (settings.grant) { hidden(form, '__ResgridProtectedGrant', settings.grant); hidden(form, '__ResgridProtectedGrantExpiresOn', settings.expiry); } + }); + settings.grant = null; document.getElementById('inventory-settings').textContent = ''; + $(function () { + document.querySelectorAll('form.inventory-navigation, form.inventory-command').forEach(form => { + if (form.method.toLowerCase() === 'post' && settings.protectedData && window.resgridAdpReveal) window.resgridAdpReveal.bindForm(form); + }); + document.querySelectorAll('form.inventory-command').forEach(form => form.addEventListener('submit', async event => { + if (event.defaultPrevented) return; + event.preventDefault(); if (form.dataset.sending) return; form.dataset.sending = 'true'; + try { + if (form.classList.contains('inventory-kit-issue')) { + const assets = Array.from(form.querySelectorAll('select[name$=".AssetId"]')).map(field => field.value); + if (assets.some(value => !value) || new Set(assets).size !== assets.length) throw new Error(settings.failed); + } + const response = await fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', cache: 'no-store', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || settings.failed); + const pendingAsset = form.dataset.inventoryCreateAsset === 'true' && !(result.currentLocationId || result.CurrentLocationId); + if (result.awaitingWitness || result.AwaitingWitness || pendingAsset) { message.textContent = settings.witness + ' ' + form.querySelector('[name="RequestId"]').value; message.hidden = false; return; } + const page = document.getElementById('inventory-page'); + ['__ResgridProtectedGrant', '__ResgridProtectedGrantExpiresOn'].forEach(name => { const field = form.querySelector('[name="' + name + '"]'); if (field) hidden(page, name, field.value); }); + page.submit(); + } catch (error) { message.textContent = error.message || settings.failed; message.hidden = false; } + finally { delete form.dataset.sending; } + })); + }); + let fieldSequence = 0; + function refreshKitLines(lines) { + Array.from(lines.children).forEach((row, index) => { + row.querySelectorAll('[name]').forEach(field => { field.name = field.name.replace(/^Lines\[\d+\]/, 'Lines[' + index + ']'); }); + row.querySelector('.inventory-remove-line').disabled = lines.children.length === 1; + }); + } + document.querySelectorAll('form.inventory-kit').forEach(form => { + const lines = form.querySelector('.inventory-kit-lines'); + refreshKitLines(lines); + form.addEventListener('click', event => { + const add = event.target.closest('.inventory-add-line'), remove = event.target.closest('.inventory-remove-line'); + if (add && lines.children.length < 100) { + const row = lines.firstElementChild.cloneNode(true); + row.querySelectorAll('[name]').forEach(field => { + const label = row.querySelector('label[for="' + field.id + '"]'); + field.id = 'inventory-added-' + (++fieldSequence); + if (label) label.htmlFor = field.id; + field.value = field.type === 'number' ? '1' : ''; + }); + lines.appendChild(row); + } + if (remove && lines.children.length > 1) remove.closest('.inventory-kit-line').remove(); + refreshKitLines(lines); + }); + }); + window.resgridAdpPageRevealed = () => { if (window.resgridAdpReveal) window.resgridAdpReveal.submitWithGrant(document.getElementById('inventory-page')); }; + window.resgridAdpPageConcealed = () => { document.querySelector('.wrapper-content').replaceChildren(); window.location.replace(settings.index); }; +})(); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index 888909271..bbe3a4e58 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -43,7 +43,7 @@ public async Task> Process(ReportDeliveryQueueItem item) await _scheduledTasksService.CreateScheduleTaskLogAsync(item.ScheduledTask); return Tuple.Create(true, ""); } - catch (Exception) { Logging.LogError("Checklist scheduled report delivery failed."); return Tuple.Create(false, "Checklist scheduled report delivery failed."); } + catch (Exception ex) { Logging.LogError($"Checklist scheduled report delivery failed: {ex.GetType().FullName}."); return Tuple.Create(false, "Checklist scheduled report delivery failed."); } } bool success = true; string result = "";