From abd9033e6ddb68c7e2e0b048714eceb846554a29 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 9 Sep 2026 13:02:52 -0700 Subject: [PATCH] RG-T66 Inventory record usage and corrections --- .../Areas/User/Inventory/Inventory.ar.resx | 175 +++++++ .../Areas/User/Inventory/Inventory.de.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.el.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.en.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.es.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.fr.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.it.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.pl.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.sv.resx | 176 ++++++- .../Areas/User/Inventory/Inventory.uk.resx | 176 ++++++- .../Checklists/ChecklistWorkflowPayload.cs | 5 +- .../Inventories/InventoryContracts.cs | 8 +- .../Inventories/InventoryModels.cs | 14 +- .../Inventories/InventoryOperations.cs | 97 ++++ .../Inventories/InventoryPurchasing.cs | 60 +++ .../Inventories/InventoryQuery.cs | 10 + .../Inventories/InventoryWorkflowPayload.cs | 19 +- .../Inventories/RecordInventoryUsage.cs | 53 ++ Core/Resgrid.Model/ReportTypes.cs | 12 +- .../Repositories/IInventoryStore.cs | 7 + .../IInventoryModernizationService.cs | 4 + .../Services/IInventoryOperationsService.cs | 25 + .../Services/IInventoryPurchasingService.cs | 17 + .../IInventoryScheduledReportService.cs | 11 + .../Services/IRmsInventoryUsageAdapter.cs | 13 + .../WorkOrders/WorkOrderWorkflowPayload.cs | 8 +- .../WorkflowTemplateVariableCatalog.cs | 11 +- .../Resgrid.Model/WorkflowTriggerEventType.cs | 5 + .../Resgrid.Services/GdprDataExportService.cs | 4 +- .../InventoryAlertNotifications.cs | 94 ++++ Core/Resgrid.Services/InventoryAlerts.cs | 225 +++++++++ Core/Resgrid.Services/InventoryCatalog.cs | 22 +- .../InventoryChecklistAssets.cs | 28 +- Core/Resgrid.Services/InventoryCosting.cs | 143 ++++++ Core/Resgrid.Services/InventoryCounts.cs | 149 ++++++ Core/Resgrid.Services/InventoryGdprExport.cs | 9 +- Core/Resgrid.Services/InventoryIssuance.cs | 28 +- .../InventoryModernizationService.cs | 28 +- Core/Resgrid.Services/InventoryPosting.cs | 109 ++++- .../InventoryPurchaseReceipts.cs | 114 +++++ Core/Resgrid.Services/InventoryPurchasing.cs | 142 ++++++ Core/Resgrid.Services/InventoryQueries.cs | 29 ++ Core/Resgrid.Services/InventoryRecordUsage.cs | 59 +++ Core/Resgrid.Services/InventoryReferences.cs | 14 +- .../InventoryReportDocuments.cs | 92 ++++ Core/Resgrid.Services/InventoryReports.cs | 252 ++++++++++ .../InventoryScheduledReportService.cs | 140 ++++++ .../Resgrid.Services/ProtectedFieldCatalog.cs | 6 +- .../Evidence/RecordEvidenceAdapters.cs | 8 + .../Records/RecordInventoryUsageService.cs | 314 ++++++++++++ .../Records/RecordsEvidenceService.cs | 17 +- .../Records/RmsInventoryUsageAdapter.cs | 53 +- Core/Resgrid.Services/ServicesModule.cs | 4 +- Core/Resgrid.Services/UnitsService.cs | 2 + .../WorkflowSampleDataGenerator.cs | 25 +- .../WorkflowTemplateContextBuilder.cs | 5 + .../M0198_AddInventoryModernization.cs | 14 +- .../M0200_AddRecordInventoryUsage.cs | 79 +++ .../M0201_AddInventoryPurchasing.cs | 101 ++++ .../M0202_AddInventoryCountsAndAlerts.cs | 164 +++++++ .../M0198_AddInventoryModernizationPg.cs | 14 +- .../M0200_AddRecordInventoryUsagePg.cs | 79 +++ .../M0201_AddInventoryPurchasingPg.cs | 101 ++++ .../M0202_AddInventoryCountsAndAlertsPg.cs | 164 +++++++ .../InventoryDepartmentCleanup.cs | 4 +- .../InventoryStore.cs | 55 ++- .../Allocations/trigger-baseline.json | 7 +- .../Rms/RecordsEvidenceServiceTests.cs | 87 ++++ .../Rms/RmsIdentifierPinTests.cs | 7 +- .../Services/AdpSizingServiceTests.cs | 13 +- .../Services/ChecklistPr504SecurityTests.cs | 2 +- .../Services/InventoryApiTests.cs | 23 +- .../Services/InventoryDatabaseFixture.cs | 76 ++- .../Services/InventoryDatabaseTests.cs | 332 ++++++++++++- .../Services/InventoryGdprTests.cs | 68 ++- .../Services/InventoryM3PostingTests.cs | 216 +++++++++ .../InventoryM4CostRegressionsTests.cs | 171 +++++++ .../Services/InventoryM4HttpTests.cs | 317 ++++++++++++ .../Services/InventoryM4Tests.cs | 427 ++++++++++++++++ .../Services/InventoryM5DatabaseTests.cs | 324 +++++++++++++ .../Services/InventoryM5HttpTests.cs | 327 +++++++++++++ .../Services/InventoryM5NotificationTests.cs | 249 ++++++++++ .../Services/InventoryM5ReportTests.cs | 203 ++++++++ .../InventoryM5ScheduledReportTests.cs | 284 +++++++++++ .../Services/InventoryM5Tests.cs | 456 ++++++++++++++++++ .../Services/InventoryModernizationTests.cs | 33 +- .../Services/InventoryPr506Tests.cs | 144 ++++++ .../Services/InventoryWorkflowTests.cs | 165 ++++++- .../Services/ProtectedReadServiceTests.cs | 8 + .../Services/RecordInventoryM3HttpTests.cs | 278 +++++++++++ .../Services/RecordInventoryM3Tests.cs | 208 ++++++++ .../Services/RmsInventoryModernUsageTests.cs | 34 +- .../Services/WorkOrderPr505Tests.cs | 4 +- .../ProfileReportScheduleSecurityTests.cs | 198 ++++++++ .../Web/inventory-modern.test.cjs | 150 +++++- .../Controllers/v4/InventoryController.cs | 25 +- .../v4/InventoryOperationsController.cs | 60 +++ .../v4/InventoryPurchasingController.cs | 40 ++ .../v4/RecordInventoryController.cs | 56 ++- .../User/Controllers/InventoryController.cs | 31 +- .../InventoryOperationsController.cs | 151 ++++++ .../InventoryPurchasingController.cs | 120 +++++ .../User/Controllers/ProfileController.cs | 45 +- .../Controllers/RecordsInventoryController.cs | 73 ++- .../Areas/User/Controllers/UnitsController.cs | 5 +- .../Inventory/InventoryWorkspaceView.cs | 7 + .../User/Models/Records/RecordsViewModels.cs | 4 + .../User/Views/Inventory/Operations.cshtml | 115 +++++ .../User/Views/Inventory/Purchasing.cshtml | 205 ++++++++ .../User/Views/Inventory/Workspace.cshtml | 13 +- .../Areas/User/Views/Profile/Reporting.cshtml | 1 + .../User/Views/RecordsInventory/Edit.cshtml | 110 +++-- .../Areas/User/Views/Reports/Index.cshtml | 5 + .../Areas/User/Views/Units/Index.cshtml | 5 + .../Areas/User/Views/Workflows/Edit.cshtml | 3 +- .../Areas/User/Views/Workflows/New.cshtml | 3 +- .../internal/inventory/inventory-modern.js | 7 + .../inventory/inventory-operations.js | 18 + .../inventory/inventory-purchasing.js | 36 ++ .../profile/resgrid.profile.reporting.js | 26 +- .../Commands/InventoryAlertsCommand.cs | 14 + Workers/Resgrid.Workers.Console/Program.cs | 1 + .../Tasks/InventoryAlertsTask.cs | 22 + .../Logic/InventoryAlertsLogic.cs | 58 +++ .../Logic/ReportDeliveryLogic.cs | 15 + 125 files changed, 10852 insertions(+), 261 deletions(-) create mode 100644 Core/Resgrid.Model/Inventories/InventoryOperations.cs create mode 100644 Core/Resgrid.Model/Inventories/InventoryPurchasing.cs create mode 100644 Core/Resgrid.Model/Inventories/RecordInventoryUsage.cs create mode 100644 Core/Resgrid.Model/Services/IInventoryOperationsService.cs create mode 100644 Core/Resgrid.Model/Services/IInventoryPurchasingService.cs create mode 100644 Core/Resgrid.Model/Services/IInventoryScheduledReportService.cs create mode 100644 Core/Resgrid.Services/InventoryAlertNotifications.cs create mode 100644 Core/Resgrid.Services/InventoryAlerts.cs create mode 100644 Core/Resgrid.Services/InventoryCosting.cs create mode 100644 Core/Resgrid.Services/InventoryCounts.cs create mode 100644 Core/Resgrid.Services/InventoryPurchaseReceipts.cs create mode 100644 Core/Resgrid.Services/InventoryPurchasing.cs create mode 100644 Core/Resgrid.Services/InventoryRecordUsage.cs create mode 100644 Core/Resgrid.Services/InventoryReportDocuments.cs create mode 100644 Core/Resgrid.Services/InventoryReports.cs create mode 100644 Core/Resgrid.Services/InventoryScheduledReportService.cs create mode 100644 Core/Resgrid.Services/Records/RecordInventoryUsageService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0200_AddRecordInventoryUsage.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0201_AddInventoryPurchasing.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0202_AddInventoryCountsAndAlerts.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0200_AddRecordInventoryUsagePg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0201_AddInventoryPurchasingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0202_AddInventoryCountsAndAlertsPg.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM3PostingTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM4CostRegressionsTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM4HttpTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM4Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5DatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5NotificationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5ReportTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5ScheduledReportTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryM5Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/RecordInventoryM3HttpTests.cs create mode 100644 Tests/Resgrid.Tests/Services/RecordInventoryM3Tests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/InventoryPurchasingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/InventoryPurchasingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Inventory/Purchasing.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-operations.js create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js create mode 100644 Workers/Resgrid.Workers.Console/Commands/InventoryAlertsCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/InventoryAlertsTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/InventoryAlertsLogic.cs diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx index 8e79463d9..ecf8d6576 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx @@ -348,4 +348,179 @@ نعم + استخدام المخزون + يغيّر حفظ الاستخدام المخزون فورًا. تبقى هذه التغييرات إذا تم التخلي عن مسودة أو تعديل للتقرير. اعكس الاستخدام غير الصحيح صراحةً، ثم سجّل البديل. افتح تعديلًا قبل تغيير الاستخدام في تقرير مكتمل. + يتطلب استهلاك الأصناف الخاضعة للرقابة وعكسه شاهدًا مستقلاً في المخزون. أكمل هذا الإجراء أولاً، ثم اربط معرّف المعاملة الناتجة هنا. + الاستخدام المسجّل + نوع الاستخدام + مستخدم + مستهلك على مريض + متروك في موقع الحادث + تالف + قيد السجل + وقت التسجيل (UTC) + يعكس الاستخدام + تم العكس + عكس الاستخدام + يسجّل هذا الإجراء عكسًا كاملاً ويحفظ الاستخدام الأصلي في السجل التاريخي. لتصحيح كمية أو صنف، اعكس الاستخدام وسجّل البديل. إذا كان شاهد قد أكد العكس بالفعل في المخزون، فأدخل معرّف المعاملة لربطها دون تغيير المخزون مرة أخرى. + سبب التصحيح + معرّف معاملة عكس موجودة أكدها شاهد (اختياري) + تسجّل الكميات الموجبة الاستخدام، وتعكسه الكميات السالبة. افتح قيد السجل لعرض تفاصيل الحركة وفق صلاحيات وصولك الحالية إلى المخزون. + معرّف معاملة استهلاك موجودة (اختياري) + أدخل معاملة استهلاك مكتملة في المخزون لربط صنفها وكميتها وموقعها وملاحظتها. تُتجاهل حقول الاستهلاك الجديد أدناه، ولا يُستهلك المخزون مرة أخرى. حدد نوع الاستخدام أعلاه. + استهلاك جديد + استخدام وحدة التقرير أو محطته + اتركه فارغًا لاستخدام الوحدة المستجيبة في التقرير، مع استخدام المحطة كخيار بديل. حدد المصدر صراحةً إذا كان التقرير يتضمن عدة مواقع محتملة. + حفظ الاستخدام + العودة إلى التقرير + تحديث الأدلة من الاستخدام المسجّل + تم حفظ الاستخدام وتوثيق الأدلة الداعمة. + تم حفظ عملية المخزون. تعذّر توثيق الأدلة؛ استخدم «تحديث الأدلة» بعد حل مشكلات الوصول أو تغييرات المسودة. لا ترسل عملية المخزون مرة أخرى. + تغيّرت المسودة. تحقق من الاستخدام المسجّل قبل إرسال عملية مخزون أخرى. + أكمل إجراء الشاهد المستقل في المخزون، ثم اربط معرّف المعاملة الناتجة هنا. لم تسجّل هذه العملية أي حركة مخزون. + تم توثيق الأدلة الداعمة. + لم تُربط معاملة عكس في السجل بهذا التقرير. اربطها كتصحيح قبل اعتماد التقرير نهائيًا. + المشتريات + الموردون + أوامر الشراء + تقييم المخزون + يستخدم الموردون سجلات الشركات في «جهات الاتصال». أنشئ بيانات الشركة أو حدّثها في «جهات الاتصال»، ثم اربط الشركة هنا. + رقم الحساب لدى المورد + الشركة في جهات الاتصال + إضافة مورد + رقم أمر الشراء + المورد + العملة + عرض أمر الشراء + مسودة + تم الطلب + تم الاستلام جزئيًا + تم الاستلام + ملغى + الكمية المطلوبة + الكمية المستلمة + الكمية المتبقية + يؤدي وضع علامة «تم الطلب» على الأمر إلى قفل بنوده. يمنع إلغاء الأمر أي استلام إضافي ويحافظ على الكميات التي تم استلامها بالفعل. + وضع علامة «تم الطلب» + إلغاء الأمر + استلام الأصناف + استلم الطلب كاملًا أو جزءًا منه. أزل البنود غير المشمولة في هذه الشحنة. إذا كان وجود شاهد مستقل مطلوبًا، فلن تتغير أرصدة المخزون والكميات المستلمة إلا بعد موافقته في «المخزون». + استخدم صفًا مستقلًا لكل أصل له رقم تسلسلي، بكمية 1 ورقمه التسلسلي الخاص. أضف صفوفًا لاستلام أصول إضافية أو توزيع الكميات على مواقع أو دفعات مختلفة. + الكمية المراد استلامها + إزالة البند + عمليات الاستلام + أمر شراء جديد + يشمل التقييم المخزون والمواقع التي يحق لك عرضها فقط. تُعرض الإجماليات لكل عملة على حدة. تُحسب الصفوف ذات التكلفة غير المعروفة بشكل منفصل وتُستبعد من إجماليات القيمة المعروفة. + اعتبارًا من (UTC) + بعض أرصدة المخزون الظاهرة سالبة. راجعها قبل الاعتماد على هذا التقييم. + القيمة المعروفة + صفوف بلا تكلفة + العملة غير محددة + التكلفة غير معروفة + متوسط تكلفة الوحدة + المورد المفضل + استخدم عملة واحدة لكل صنف. يظل المخزون الحالي الذي لم تُحدد له عملة منفصلًا في التقييم؛ فتعيين عملة لا يحوّل التكاليف. + تم استلام أمر شراء للمخزون + الجرد والتنبيهات والتقارير + جرد المخزون + تنبيهات المخزون + تقارير المخزون + ابدأ بلقطة تضم ما يصل إلى 100 بند مخزون. سجّل كل كمية، ثم احفظ وأكمل الجرد. إذا تغيرت بيانات المخزون أو دليل الأصناف، فألغِ الجرد وابدأ جردًا جديدًا. سجّل المعدات ذات الأرقام التسلسلية التي تُكتشف لأول مرة من خلال «الاستلام». + جميع المواقع + بدء الجرد + إكمال الجرد + إلغاء الجرد + وقت اللقطة (UTC) + الكمية المتوقعة + الكمية المجرودة + الفرق + البنود التي تحتوي على فروق + مسودة + بانتظار الشاهد + مكتمل + ملغى + يجب أن يوافق شاهد مستقل على العملية المعلقة من سجل المخزون. ويجب أن تظل اللقطة مطابقة للحالة الحالية. + تُظهر التنبيهات انخفاض المخزون، والأصناف التي تنتهي صلاحيتها خلال 30 يومًا، والمخزون منتهي الصلاحية، والإرجاعات المتأخرة. حدّث التنبيهات مرة واحدة بعد إطلاق الميزة لتهيئة تنبيهات انخفاض المخزون، وكذلك بعد استيراد المخزون القديم. تُحدّثها التعديلات اللاحقة على المخزون والأصناف تلقائيًا. + تحديث التنبيهات + مخزون منخفض + تنتهي الصلاحية قريبًا + منتهي الصلاحية + إرجاع متأخر + مفتوح + تمت المعالجة + موعد الاستحقاق (UTC) + اختر تقريرًا وأي عوامل تصفية اختيارية. تستخدم التواريخ توقيت UTC ولا تشمل تاريخ النهاية. تعتمد تقارير اللقطة على المخزون الحالي. نزّل ملف PDF لعرضه أو طباعته. + من تاريخ (UTC، شامل) + إلى تاريخ (UTC، غير شامل) + تنزيل ملف PDF قابل للطباعة + جدولة إرسال التقارير + تنبيهات المخزون + المخزون يحتاج إلى مراجعة. سجّل الدخول للاطلاع على التنبيهات الحالية. + المخزون المتوفر + استخدام المخزون + انتهاء صلاحية المخزون + انخفاض المخزون + سجل نقل المخزون + سجل صرف المخزون + تقييم المخزون + سجل المواد الخاضعة للرقابة + الأيام المتبقية حتى انتهاء الصلاحية + الكمية الناقصة عن نقطة إعادة الطلب + كمية إعادة الطلب المقترحة + معرّف عملية الصرف + معرّف الشخص المستلم + معرّف الوحدة المستلمة + وقت الصرف (UTC) + الكمية المُعادة + الكمية التي لم تُعد بعد + وقت الإرجاع (UTC) + منفّذ العملية (معرّف المستخدم) + يعكس المعاملة + مصدر الاستخدام + معرّف السجل + معرّف البلاغ + معرّف النسخة المعدّلة + تصنيف إدارة مكافحة المخدرات الأمريكية (DEA) + رصيد المصدر قبل العملية + رصيد المصدر بعد العملية + رصيد الوجهة قبل العملية + رصيد الوجهة بعد العملية + الشاهد (معرّف المستخدم) + وقت تأكيد الشاهد (UTC) + يحتوي المخزون على بيانات محمية. افتح رابط التقرير الذي يتطلب تسجيل الدخول وتحقق من هويتك لعرض النتائج الحالية أو طباعتها. لا يتضمن ملف PDF المجدول هذا أي قيم للمخزون. + يتضمن هذا التقرير المخزون والمواقع التي يحق لك عرضها حاليًا فقط. تصف الكميات والقيم اللقطة المحددة، وتقتصر النتائج على 5,000 صف. + النطاق الزمني بتوقيت UTC (يشمل البداية ولا يشمل النهاية) + تظهر كل حركة مخزون مرة واحدة. تسجّل الكميات الموجبة الاستخدام الصادر، وتعكسه الكميات السالبة. يتطلب عرض تفاصيل السجل صلاحية الوصول إلى المصدر. تحتفظ الحركات الصادرة المستوردة بهويتها القديمة ولا تنشئ رابطًا بسجل عمليات أو مريض. + تُدرج الأصناف النشطة عندما يكون المخزون الظاهر المتتبع بالكميات أو الأصول ذات الأرقام التسلسلية عند نقطة إعادة الطلب أو دونها، مع استخدام الحد الأدنى للمخزون عند عدم تحديد تلك النقطة. تشمل الأصول ذات الأرقام التسلسلية الأصول قيد الخدمة والمصروفة والتالفة وقيد الإصلاح. تُستبعد المواقع المخفية. + تعرض الصفوف عمليات الصرف التي أُنشئت ضمن النطاق الزمني المحدد وحالة إرجاعها الحالية. لعرض الكميات التي لم تُعد من عمليات صرف أقدم، اختر تاريخ بداية أسبق. + تعرض الصفوف أرصدة المخزون الموجبة الحالية والأصول النشطة ذات الأرقام التسلسلية التي يقع تاريخ انتهاء صلاحيتها ضمن النطاق المحدد. يشمل النطاق الافتراضي المخزون المنتهي الصلاحية بالفعل والمخزون الذي تنتهي صلاحيته خلال الأيام الثلاثين القادمة. + توقيع منفّذ العملية + توقيع الشاهد + غير مسجّل + حركة مخزون دون رابط إلى سجل يمكن الوصول إليه + السجلات + حركة مخزون قديمة + أدخل تفاصيل جرد وكميات صحيحة. + اختر نطاقًا أصغر. يدعم الجرد ما يصل إلى 100 بند مخزون. + لا توجد بنود مخزون نشطة لجردها ضمن هذا النطاق. + هذا الجرد موجود بالفعل. افتحه من قائمة عمليات الجرد. + تغير الجرد أو لم تُسجّل جميع كمياته. أعد تحميله واحفظ جميع الكميات قبل إكماله. + تغير المخزون بعد التقاط هذه اللقطة. ألغِ هذا الجرد وابدأ جردًا جديدًا. + تتبع تسويات الجرد عملية الجرد المكتملة الخاصة بها. ابدأ جردًا جديدًا لتصحيح المخزون. + رحّل فروق الجرد بإكمال جرد المخزون. + اختر نطاق تواريخ صحيحًا بتوقيت UTC لا يتجاوز 366 يومًا. + اختر عوامل تصفية صحيحة للتقرير. + تغير التقرير أثناء إنشاء ملف PDF. أعد إنشاءه. + تعذر إنشاء التقرير. حاول مرة أخرى. + انخفاض مخزون المواد + اقتراب انتهاء صلاحية المخزون + اكتمال جرد المخزون + تأخر إعادة العهدة + هذا المورد مرتبط بسجل المخزون ويجب الاحتفاظ به. + تم تعديل المخزون + اكتمل نقل المخزون + تم صرف المخزون + تم إرجاع المخزون + تغيرت حالة أصل المخزون + تم تسجيل مادة خاضعة للرقابة diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx index 6cae42f83..7b8ed13c8 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx @@ -472,5 +472,179 @@ Ja + Bestandsverbrauch + Das Speichern des Verbrauchs ändert den Bestand sofort. Diese Änderungen bleiben erhalten, wenn ein Entwurf oder eine Berichtsänderung verworfen wird. Stornieren Sie falschen Verbrauch ausdrücklich und erfassen Sie anschließend den Ersatz. Öffnen Sie eine Berichtsänderung, bevor Sie den Verbrauch eines abgeschlossenen Berichts ändern. + Verbrauch und Stornierungen kontrollierter Artikel erfordern einen unabhängigen Zeugen in Inventar. Schließen Sie diesen Vorgang zuerst ab und verknüpfen Sie anschließend die entstandene Transaktions-ID hier. + Erfasster Verbrauch + Verwendungsart + Verwendet + Am Patienten verbraucht + Am Einsatzort belassen + Beschädigt + Buchung + Erfasst (UTC) + Storniert Verbrauch + Storniert + Verbrauch stornieren + Dies erfasst eine vollständige Stornierung und erhält den ursprünglichen Verbrauch im Verlauf. Um eine Menge oder einen Artikel zu korrigieren, stornieren Sie den Verbrauch und erfassen Sie den Ersatz. Wurde die Stornierung bereits in Inventar von einem Zeugen bestätigt, geben Sie ihre Transaktions-ID ein, um sie ohne erneute Bestandsänderung zu verknüpfen. + Korrekturgrund + ID einer vorhandenen, bezeugten Stornotransaktion (optional) + Positive Mengen erfassen Verbrauch; negative Mengen stornieren ihn. Öffnen Sie die Buchung, um Bewegungsdetails gemäß Ihren aktuellen Inventarzugriffsrechten anzuzeigen. + ID einer vorhandenen Verbrauchstransaktion (optional) + Geben Sie eine abgeschlossene Verbrauchstransaktion aus Inventar ein, um deren Artikel, Menge, Lagerort und Notiz zu verknüpfen. Die Felder für neuen Verbrauch unten werden ignoriert, und der Bestand wird nicht erneut verbraucht. Wählen Sie oben die Verwendungsart. + Neuer Verbrauch + Einheit oder Wache des Berichts verwenden + Leer lassen, um die Einsatzeinheit des Berichts zu verwenden, ersatzweise deren Wache. Wählen Sie die Quelle ausdrücklich, wenn der Bericht mehrere mögliche Lagerorte hat. + Verbrauch speichern + Zum Bericht zurück + Nachweise aus erfasstem Verbrauch aktualisieren + Verbrauch gespeichert und Nachweise erfasst. + Der Inventarvorgang wurde gespeichert. Die Nachweise konnten nicht erfasst werden. Verwenden Sie Nachweise aktualisieren, nachdem Zugriffsprobleme oder Entwurfsänderungen geklärt sind. Senden Sie den Inventarvorgang nicht erneut. + Der Entwurf wurde geändert. Prüfen Sie den erfassten Verbrauch, bevor Sie einen weiteren Inventarvorgang senden. + Schließen Sie das Verfahren mit einem unabhängigen Zeugen in Inventar ab und verknüpfen Sie hier die entstandene Transaktions-ID. Durch diese Aktion wurde keine Bestandsbewegung erfasst. + Nachweise erfasst. + Eine Stornobuchung wurde noch nicht mit diesem Bericht verknüpft. Verknüpfen Sie sie vor dem Abschließen als Korrektur. + Einkauf + Lieferanten + Bestellungen + Bestandsbewertung + Lieferanten verwenden Firmeneinträge aus dem Bereich „Kontakte“. Erstellen oder aktualisieren Sie dort die Firmendaten und verknüpfen Sie die Firma anschließend hier. + Kundennummer beim Lieferanten + Firma aus den Kontakten + Lieferanten hinzufügen + Bestellnummer + Lieferant + Währung + Bestellung anzeigen + Entwurf + Bestellt + Teilweise eingegangen + Eingegangen + Storniert + Bestellte Menge + Eingegangene Menge + Restmenge + Wenn eine Bestellung als bestellt markiert wird, werden ihre Positionen gesperrt. Das Stornieren verhindert weitere Wareneingänge; bereits eingegangene Mengen bleiben erhalten. + Als bestellt markieren + Bestellung stornieren + Artikel entgegennehmen + Erfassen Sie den Eingang der gesamten Bestellung oder einer Teillieferung. Entfernen Sie Positionen, die nicht zu dieser Lieferung gehören. Ist ein unabhängiger Zeuge erforderlich, ändern sich Bestand und eingegangene Mengen erst nach dessen Bestätigung in der Bestandsverwaltung. + Verwenden Sie für jeden Vermögensgegenstand mit Seriennummer eine eigene Zeile mit Menge 1 und seiner Seriennummer. Fügen Sie Zeilen hinzu, um weitere Gegenstände zu erfassen oder Mengen auf Lagerorte oder Chargen aufzuteilen. + Zu empfangende Menge + Position entfernen + Wareneingänge + Neue Bestellung + Die Bewertung umfasst nur Bestände und Lagerorte, die Sie einsehen dürfen. Summen werden für jede Währung getrennt ausgewiesen. Zeilen mit unbekannten Kosten werden separat gezählt und nicht in die Summen der bekannten Werte einbezogen. + Stand (UTC) + Einige sichtbare Lagerbestände sind negativ. Prüfen Sie diese, bevor Sie die Bewertung als Grundlage verwenden. + Bekannter Wert + Zeilen ohne Kostenangabe + Währung nicht angegeben + Kosten unbekannt + Durchschnittliche Stückkosten + Bevorzugter Lieferant + Verwenden Sie pro Artikel eine Währung. Bestehende Bestände ohne Währungsangabe bleiben bei der Bewertung getrennt; das Zuweisen einer Währung rechnet die Kosten nicht um. + Bestellung für den Lagerbestand eingegangen + Bestandszählungen, Warnungen und Berichte + Bestandszählungen + Inventarwarnungen + Inventarberichte + Erstellen Sie eine Momentaufnahme von bis zu 100 Bestandspositionen. Erfassen Sie jede Menge, speichern Sie die Zählung und schließen Sie sie dann ab. Wenn sich Bestands- oder Katalogdaten ändern, brechen Sie die Zählung ab und beginnen Sie eine neue. Erfassen Sie neu aufgefundene Ausrüstung mit Seriennummer über „Einlagern“. + Alle Lagerorte + Zählung starten + Zählung abschließen + Zählung abbrechen + Momentaufnahme vom (UTC) + Sollbestand + Gezählter Bestand + Abweichung + Positionen mit einer Abweichung + Entwurf + Wartet auf Zeugenbestätigung + Abgeschlossen + Abgebrochen + Ein unabhängiger Zeuge muss den ausstehenden Vorgang im Inventarverlauf bestätigen. Die Momentaufnahme muss weiterhin aktuell sein. + Warnungen zeigen niedrige Bestände, in den nächsten 30 Tagen ablaufendes Inventar, abgelaufenes Inventar und überfällige Rückgaben. Aktualisieren Sie die Warnungen einmal nach der Einführung, um Bestandswarnungen zu initialisieren, sowie nach dem Import von Altbeständen. Spätere Bestands- und Artikeländerungen aktualisieren sie automatisch. + Warnungen aktualisieren + Niedriger Bestand + Läuft bald ab + Abgelaufen + Überfällige Rückgabe + Offen + Erledigt + Fällig am (UTC) + Wählen Sie einen Bericht und bei Bedarf Filter aus. Datumsangaben verwenden UTC; das Enddatum ist ausgeschlossen. Momentaufnahmeberichte verwenden die aktuellen Bestände. Laden Sie die PDF-Datei herunter, um sie anzuzeigen oder zu drucken. + Startdatum (UTC, einschließlich) + Enddatum (UTC, ausschließlich) + Druckbare PDF herunterladen + Berichtsversand planen + Inventarwarnungen + Das Inventar erfordert Ihre Aufmerksamkeit. Melden Sie sich an, um die aktuellen Warnungen zu prüfen. + Aktueller Inventarbestand + Inventarverbrauch + Ablaufdaten des Inventars + Niedrige Inventarbestände + Umlagerungsverlauf des Inventars + Ausgabeverlauf des Inventars + Bestandsbewertung + Protokoll kontrollierter Substanzen + Tage bis zum Ablauf + Fehlmenge zum Meldebestand + Vorgeschlagene Nachbestellmenge + Ausgabe-ID + ID der empfangenden Person + ID der empfangenden Einheit + Ausgegeben am (UTC) + Zurückgegebene Menge + Ausstehende Menge + Zurückgegeben am (UTC) + Durchgeführt von (Benutzer-ID) + Storniert Transaktion + Verbrauchsquelle + Datensatz-ID + Einsatz-ID + ID der geänderten Revision + DEA-Einstufung + Quellbestand vorher + Quellbestand nachher + Zielbestand vorher + Zielbestand nachher + Zeuge (Benutzer-ID) + Bestätigt am (UTC) + Das Inventar enthält geschützte Daten. Öffnen Sie den Berichtslink mit Anmeldung und bestätigen Sie Ihre Identität, um aktuelle Ergebnisse anzuzeigen oder zu drucken. Diese planmäßig erstellte PDF-Datei enthält keine Inventarwerte. + Dieser Bericht enthält nur Inventar und Lagerorte, für die Sie aktuell eine Anzeigeberechtigung haben. Mengen und Werte beziehen sich auf die angegebene Momentaufnahme. Die Ergebnisse sind auf 5.000 Zeilen begrenzt. + UTC-Zeitraum (Start einschließlich, Ende ausschließlich) + Jede Bestandsbewegung erscheint einmal. Positive Mengen erfassen ausgehenden Verbrauch; negative Mengen machen ihn rückgängig. Für Datensatzdetails ist Zugriff auf die Quelle erforderlich. Importierte ausgehende Bewegungen behalten ihre ursprüngliche Kennung und stellen keine Verknüpfung mit einem Protokoll oder Patienten her. + Aktive Artikel werden aufgeführt, wenn der sichtbare Mengenbestand oder Bestand mit Seriennummer den Meldebestand erreicht oder unterschreitet. Ist kein Meldebestand festgelegt, gilt der Mindestbestand. Bestände mit Seriennummer umfassen einsatzbereite, ausgegebene, beschädigte und in Reparatur befindliche Ausrüstung. Ausgeblendete Lagerorte werden ausgeschlossen. + Die Zeilen zeigen Ausgaben, die im ausgewählten Zeitraum erstellt wurden, und ihren aktuellen Rückgabestatus. Für ausstehende Mengen älterer Ausgaben muss ein früheres Startdatum gewählt werden. + Die Zeilen zeigen aktuelle positive Bestände und aktive Bestände mit Seriennummer, deren Ablaufdatum im ausgewählten Zeitraum liegt. Standardmäßig werden bereits abgelaufenes Inventar und Inventar, das in den nächsten 30 Tagen abläuft, angezeigt. + Unterschrift der ausführenden Person + Unterschrift des Zeugen + Nicht erfasst + Inventarbewegung ohne zugängliche Datensatzverknüpfung + Datensätze + Übernommene Inventarbewegung + Geben Sie gültige Zählungsdaten und Mengen ein. + Wählen Sie einen kleineren Bereich aus. Zählungen unterstützen bis zu 100 Bestandspositionen. + In diesem Bereich gibt es keine aktiven Bestandspositionen zu zählen. + Diese Zählung existiert bereits. Öffnen Sie sie über die Liste der Zählungen. + Die Zählung wurde geändert oder enthält unvollständige Erfassungen. Laden Sie sie erneut und speichern Sie vor dem Abschluss alle Mengen. + Das Inventar hat sich seit dieser Momentaufnahme geändert. Brechen Sie diese Zählung ab und beginnen Sie eine neue. + Zählungskorrekturen gehören zur abgeschlossenen Zählung. Beginnen Sie eine neue Zählung, um den Bestand zu korrigieren. + Buchen Sie Zählungsabweichungen, indem Sie die Bestandszählung abschließen. + Wählen Sie einen gültigen UTC-Zeitraum von höchstens 366 Tagen. + Wählen Sie gültige Berichtsfilter aus. + Der Bericht hat sich während der PDF-Erstellung geändert. Erstellen Sie ihn erneut. + Der Bericht konnte nicht erstellt werden. Versuchen Sie es erneut. + Niedriger Lagerbestand + Inventar läuft ab + Inventurzählung abgeschlossen + Inventarrückgabe überfällig + Diese Ressource wird im Bestandsverlauf referenziert und muss erhalten bleiben. + Bestand angepasst + Bestandsübertragung abgeschlossen + Bestand ausgegeben + Bestand zurückgegeben + Status des Inventargegenstands geändert + Kontrollierte Substanz erfasst - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx index b9ca86794..535a9d5cd 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx @@ -531,5 +531,179 @@ Ναι + Χρήση αποθεμάτων + Η αποθήκευση της χρήσης αλλάζει αμέσως το απόθεμα. Οι αλλαγές παραμένουν αν εγκαταλειφθεί ένα προσχέδιο ή μια τροποποίηση αναφοράς. Αντιλογίστε ρητά τη λανθασμένη χρήση και καταγράψτε την αντικατάστασή της. Ανοίξτε τροποποίηση πριν αλλάξετε τη χρήση σε ολοκληρωμένη αναφορά. + Η κατανάλωση και οι αντιλογισμοί ελεγχόμενων ειδών απαιτούν ανεξάρτητο μάρτυρα στα Αποθέματα. Ολοκληρώστε πρώτα αυτή τη διαδικασία και συνδέστε εδώ το αναγνωριστικό της συναλλαγής που προέκυψε. + Καταγεγραμμένη χρήση + Τύπος χρήσης + Χρησιμοποιήθηκε + Καταναλώθηκε σε ασθενή + Αφέθηκε στον τόπο του συμβάντος + Κατεστραμμένο + Εγγραφή στο καθολικό + Καταγράφηκε (UTC) + Αντιλογίζει τη χρήση + Αντιλογίστηκε + Αντιλογισμός χρήσης + Αυτή η ενέργεια καταγράφει πλήρη αντιλογισμό και διατηρεί την αρχική χρήση στο ιστορικό. Για να διορθώσετε ποσότητα ή είδος, αντιλογίστε τη χρήση και καταγράψτε την αντικατάστασή της. Αν ο αντιλογισμός έχει ήδη επιβεβαιωθεί από μάρτυρα στα Αποθέματα, εισαγάγετε το αναγνωριστικό της συναλλαγής του για να τον συνδέσετε χωρίς νέα αλλαγή αποθέματος. + Αιτία διόρθωσης + Αναγνωριστικό υπάρχοντος αντιλογισμού με επιβεβαίωση μάρτυρα (προαιρετικό) + Οι θετικές ποσότητες καταγράφουν χρήση και οι αρνητικές την αντιλογίζουν. Ανοίξτε την εγγραφή στο καθολικό για τα στοιχεία κίνησης σύμφωνα με την τρέχουσα πρόσβασή σας στα αποθέματα. + Αναγνωριστικό υπάρχουσας συναλλαγής κατανάλωσης (προαιρετικό) + Εισαγάγετε μια ολοκληρωμένη συναλλαγή κατανάλωσης από τα Αποθέματα για να συνδέσετε το είδος, την ποσότητα, την τοποθεσία και τη σημείωσή της. Τα παρακάτω πεδία νέας κατανάλωσης αγνοούνται και το απόθεμα δεν καταναλώνεται ξανά. Επιλέξτε τον τύπο χρήσης παραπάνω. + Νέα κατανάλωση + Χρήση μονάδας ή σταθμού της αναφοράς + Αφήστε κενό για να χρησιμοποιηθεί η μονάδα απόκρισης της αναφοράς, με τον σταθμό ως εναλλακτική. Επιλέξτε ρητά πηγή όταν η αναφορά έχει πολλές πιθανές τοποθεσίες. + Αποθήκευση χρήσης + Επιστροφή στην αναφορά + Ανανέωση τεκμηρίων από καταγεγραμμένη χρήση + Η χρήση αποθηκεύτηκε και καταγράφηκαν τα υποστηρικτικά τεκμήρια. + Η ενέργεια αποθέματος αποθηκεύτηκε. Δεν ήταν δυνατή η καταγραφή τεκμηρίων. Χρησιμοποιήστε την Ανανέωση τεκμηρίων αφού επιλύσετε τα προβλήματα πρόσβασης ή τις αλλαγές του προσχεδίου. Μην υποβάλετε ξανά την ενέργεια αποθέματος. + Το προσχέδιο άλλαξε. Ελέγξτε την καταγεγραμμένη χρήση πριν υποβάλετε άλλη ενέργεια αποθέματος. + Ολοκληρώστε τη διαδικασία με ανεξάρτητο μάρτυρα στα Αποθέματα και συνδέστε εδώ το αναγνωριστικό της συναλλαγής που προέκυψε. Αυτή η ενέργεια δεν κατέγραψε καμία κίνηση αποθέματος. + Τα υποστηρικτικά τεκμήρια καταγράφηκαν. + Ένας αντιλογισμός στο καθολικό δεν έχει συνδεθεί με αυτή την αναφορά. Συνδέστε τον ως διόρθωση πριν την οριστικοποίηση. + Αγορές + Προμηθευτές + Εντολές αγοράς + Αποτίμηση αποθέματος + Οι προμηθευτές χρησιμοποιούν εγγραφές εταιρειών από τις Επαφές. Δημιουργήστε ή ενημερώστε τα στοιχεία της εταιρείας στις Επαφές και συνδέστε την εδώ. + Αριθμός λογαριασμού στον προμηθευτή + Εταιρεία στις Επαφές + Προσθήκη προμηθευτή + Αριθμός εντολής αγοράς + Προμηθευτής + Νόμισμα + Προβολή εντολής αγοράς + Πρόχειρη + Έχει παραγγελθεί + Έχει παραληφθεί εν μέρει + Έχει παραληφθεί + Ακυρωμένη + Παραγγελθείσα ποσότητα + Παραληφθείσα ποσότητα + Υπόλοιπη ποσότητα + Η σήμανση μιας εντολής ως παραγγελθείσας κλειδώνει τις γραμμές της. Η ακύρωση εμποδίζει περαιτέρω παραλαβές και διατηρεί τις ποσότητες που έχουν ήδη παραληφθεί. + Σήμανση ως παραγγελθείσα + Ακύρωση εντολής + Παραλαβή ειδών + Παραλάβετε ολόκληρη την εντολή ή μέρος της. Αφαιρέστε τις γραμμές που δεν περιλαμβάνονται σε αυτή την παράδοση. Αν απαιτείται ανεξάρτητος μάρτυρας, το απόθεμα και οι παραληφθείσες ποσότητες αλλάζουν μόνο μετά την έγκρισή του στην Αποθήκη. + Χρησιμοποιήστε μία γραμμή για κάθε πάγιο με σειριακό αριθμό, με ποσότητα 1 και τον δικό του σειριακό αριθμό. Προσθέστε γραμμές για να παραλάβετε περισσότερα πάγια ή να κατανείμετε ποσότητες σε διαφορετικές τοποθεσίες ή παρτίδες. + Ποσότητα προς παραλαβή + Αφαίρεση γραμμής + Παραλαβές + Νέα εντολή αγοράς + Η αποτίμηση καλύπτει μόνο το απόθεμα και τις τοποθεσίες που έχετε δικαίωμα να βλέπετε. Τα σύνολα εμφανίζονται ξεχωριστά για κάθε νόμισμα. Οι γραμμές με άγνωστο κόστος καταμετρώνται ξεχωριστά και εξαιρούνται από τα σύνολα γνωστής αξίας. + Κατά την (UTC) + Ορισμένα ορατά υπόλοιπα αποθέματος είναι αρνητικά. Ελέγξτε τα πριν βασιστείτε σε αυτή την αποτίμηση. + Γνωστή αξία + Γραμμές χωρίς κόστος + Δεν έχει οριστεί νόμισμα + Άγνωστο κόστος + Μέσο κόστος μονάδας + Προτιμώμενος προμηθευτής + Χρησιμοποιήστε ένα νόμισμα ανά είδος. Το υπάρχον απόθεμα χωρίς νόμισμα παραμένει ξεχωριστό στην αποτίμηση· η αντιστοίχιση νομίσματος δεν μετατρέπει τα κόστη. + Παραλήφθηκε εντολή αγοράς αποθέματος + Καταμετρήσεις, ειδοποιήσεις και αναφορές + Καταμετρήσεις αποθέματος + Ειδοποιήσεις αποθέματος + Αναφορές αποθέματος + Ξεκινήστε ένα στιγμιότυπο με έως 100 θέσεις αποθέματος. Καταγράψτε κάθε ποσότητα, αποθηκεύστε και έπειτα ολοκληρώστε την καταμέτρηση. Αν αλλάξουν το απόθεμα ή τα στοιχεία του καταλόγου, ακυρώστε και ξεκινήστε νέα καταμέτρηση. Καταγράψτε εξοπλισμό με σειριακό αριθμό που εντοπίζεται για πρώτη φορά μέσω της Παραλαβής. + Όλες οι τοποθεσίες + Έναρξη καταμέτρησης + Ολοκλήρωση καταμέτρησης + Ακύρωση καταμέτρησης + Χρόνος στιγμιοτύπου (UTC) + Αναμενόμενη ποσότητα + Καταμετρημένη ποσότητα + Απόκλιση + Γραμμές με απόκλιση + Προσχέδιο + Αναμονή μάρτυρα + Ολοκληρώθηκε + Ακυρώθηκε + Ένας ανεξάρτητος μάρτυρας πρέπει να εγκρίνει την εκκρεμή ενέργεια από το ιστορικό Αποθεμάτων. Το στιγμιότυπο πρέπει να εξακολουθεί να ανταποκρίνεται στην τρέχουσα κατάσταση. + Οι ειδοποιήσεις εμφανίζουν χαμηλό απόθεμα, λήξη εντός 30 ημερών, ληγμένο απόθεμα και εκπρόθεσμες επιστροφές. Κάντε μία ανανέωση μετά την ενεργοποίηση της λειτουργίας για να αρχικοποιηθούν οι ειδοποιήσεις χαμηλού αποθέματος, καθώς και μετά την εισαγωγή παλαιού αποθέματος. Οι επόμενες αλλαγές στο απόθεμα και στα είδη τις ενημερώνουν αυτόματα. + Ανανέωση ειδοποιήσεων + Χαμηλό απόθεμα + Λήγει σύντομα + Έχει λήξει + Εκπρόθεσμη επιστροφή + Ανοιχτή + Επιλύθηκε + Προθεσμία (UTC) + Επιλέξτε αναφορά και προαιρετικά φίλτρα. Οι ημερομηνίες χρησιμοποιούν UTC και δεν περιλαμβάνουν την ημερομηνία λήξης του διαστήματος. Οι αναφορές στιγμιοτύπου χρησιμοποιούν τα τρέχοντα αποθέματα. Κατεβάστε το PDF για προβολή ή εκτύπωση. + Από ημερομηνία (UTC, περιλαμβάνεται) + Έως ημερομηνία (UTC, δεν περιλαμβάνεται) + Λήψη εκτυπώσιμου PDF + Προγραμματισμός αποστολής αναφορών + Ειδοποιήσεις αποθέματος + Το απόθεμα χρειάζεται έλεγχο. Συνδεθείτε για να δείτε τις τρέχουσες ειδοποιήσεις. + Διαθέσιμο απόθεμα + Χρήση αποθέματος + Λήξη αποθέματος + Χαμηλό απόθεμα + Ιστορικό μεταφορών αποθέματος + Ιστορικό χορηγήσεων αποθέματος + Αποτίμηση αποθέματος + Μητρώο ελεγχόμενων ουσιών + Ημέρες μέχρι τη λήξη + Ποσότητα που υπολείπεται του σημείου αναπαραγγελίας + Προτεινόμενη ποσότητα αναπαραγγελίας + Αναγνωριστικό χορήγησης + Αναγνωριστικό παραλήπτη + Αναγνωριστικό μονάδας παραλήπτη + Χρόνος χορήγησης (UTC) + Επιστραφείσα ποσότητα + Ποσότητα που δεν έχει επιστραφεί + Χρόνος επιστροφής (UTC) + Εκτελέστηκε από (αναγνωριστικό χρήστη) + Αντιλογίζει τη συναλλαγή + Πηγή χρήσης + Αναγνωριστικό εγγραφής + Αναγνωριστικό κλήσης + Αναγνωριστικό τροποποιημένης αναθεώρησης + Κατηγορία ελέγχου DEA + Υπόλοιπο προέλευσης πριν + Υπόλοιπο προέλευσης μετά + Υπόλοιπο προορισμού πριν + Υπόλοιπο προορισμού μετά + Μάρτυρας (αναγνωριστικό χρήστη) + Χρόνος επιβεβαίωσης μάρτυρα (UTC) + Το απόθεμα περιέχει προστατευμένα δεδομένα. Ανοίξτε τον σύνδεσμο της αναφοράς που απαιτεί σύνδεση και επαληθεύστε την ταυτότητά σας για να δείτε ή να εκτυπώσετε τα τρέχοντα αποτελέσματα. Αυτό το προγραμματισμένο PDF δεν περιέχει τιμές αποθέματος. + Αυτή η αναφορά περιλαμβάνει μόνο το απόθεμα και τις τοποθεσίες που έχετε δικαίωμα να βλέπετε τώρα. Οι ποσότητες και οι αξίες περιγράφουν το συγκεκριμένο στιγμιότυπο. Τα αποτελέσματα περιορίζονται σε 5.000 γραμμές. + Διάστημα UTC (περιλαμβάνεται η αρχή, εξαιρείται το τέλος) + Κάθε κίνηση αποθέματος εμφανίζεται μία φορά. Οι θετικές ποσότητες καταγράφουν εξερχόμενη χρήση και οι αρνητικές την αντιλογίζουν. Τα στοιχεία της εγγραφής απαιτούν πρόσβαση στην πηγή. Οι εισαγόμενες εξερχόμενες κινήσεις διατηρούν την παλαιά ταυτότητά τους και δεν δημιουργούν σύνδεση με ημερολόγιο συμβάντων ή ασθενή. + Τα ενεργά είδη εμφανίζονται όταν το ορατό απόθεμα που παρακολουθείται κατά ποσότητα ή τα πάγια με σειριακό αριθμό βρίσκονται στο σημείο αναπαραγγελίας ή κάτω από αυτό. Αν δεν έχει οριστεί σημείο αναπαραγγελίας, χρησιμοποιείται το ελάχιστο απόθεμα. Στα πάγια με σειριακό αριθμό περιλαμβάνονται όσα είναι σε υπηρεσία, έχουν χορηγηθεί, έχουν υποστεί ζημιά ή βρίσκονται σε επισκευή. Οι κρυφές τοποθεσίες εξαιρούνται. + Οι γραμμές εμφανίζουν τις χορηγήσεις που δημιουργήθηκαν στο επιλεγμένο χρονικό διάστημα και την τρέχουσα κατάσταση επιστροφής τους. Για ποσότητες που δεν έχουν επιστραφεί από παλαιότερες χορηγήσεις, επιλέξτε προγενέστερη ημερομηνία έναρξης. + Οι γραμμές εμφανίζουν το τρέχον θετικό απόθεμα και τα ενεργά πάγια με σειριακό αριθμό των οποίων η ημερομηνία λήξης βρίσκεται στο επιλεγμένο διάστημα. Η προεπιλογή περιλαμβάνει το ήδη ληγμένο απόθεμα και όσα λήγουν τις επόμενες 30 ημέρες. + Υπογραφή εκτελούντος + Υπογραφή μάρτυρα + Δεν καταγράφηκε + Κίνηση αποθέματος χωρίς προσβάσιμο σύνδεσμο εγγραφής + Εγγραφές + Παλαιά κίνηση αποθέματος + Εισαγάγετε έγκυρα στοιχεία καταμέτρησης και ποσότητες. + Επιλέξτε μικρότερο εύρος. Οι καταμετρήσεις υποστηρίζουν έως 100 θέσεις αποθέματος. + Δεν υπάρχουν ενεργές θέσεις αποθέματος προς καταμέτρηση σε αυτό το εύρος. + Αυτή η καταμέτρηση υπάρχει ήδη. Ανοίξτε την από τη λίστα καταμετρήσεων. + Η καταμέτρηση άλλαξε ή οι παρατηρήσεις της είναι ελλιπείς. Φορτώστε την ξανά και αποθηκεύστε όλες τις ποσότητες πριν την ολοκλήρωση. + Το απόθεμα άλλαξε μετά από αυτό το στιγμιότυπο. Ακυρώστε αυτή την καταμέτρηση και ξεκινήστε νέα. + Οι προσαρμογές καταμέτρησης ανήκουν στην αντίστοιχη ολοκληρωμένη καταμέτρηση. Ξεκινήστε νέα καταμέτρηση για να διορθώσετε το απόθεμα. + Καταχωρίστε τις αποκλίσεις ολοκληρώνοντας την καταμέτρηση αποθέματος. + Επιλέξτε έγκυρο διάστημα ημερομηνιών UTC που δεν υπερβαίνει τις 366 ημέρες. + Επιλέξτε έγκυρα φίλτρα αναφοράς. + Η αναφορά άλλαξε κατά τη δημιουργία του PDF. Δημιουργήστε την ξανά. + Δεν ήταν δυνατή η δημιουργία της αναφοράς. Δοκιμάστε ξανά. + Χαμηλό απόθεμα + Λήξη αποθέματος σύντομα + Ολοκλήρωση απογραφής + Εκπρόθεσμη επιστροφή εξοπλισμού + Αυτός ο πόρος αναφέρεται στο ιστορικό αποθέματος και πρέπει να διατηρηθεί. + Το απόθεμα προσαρμόστηκε + Η μεταφορά αποθέματος ολοκληρώθηκε + Το απόθεμα χορηγήθηκε + Το απόθεμα επιστράφηκε + Η κατάσταση του εξοπλισμού άλλαξε + Η ελεγχόμενη ουσία καταγράφηκε - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx index f5df0ed4a..70991670a 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx @@ -531,5 +531,179 @@ Yes + Inventory usage + Saving usage changes inventory immediately. These stock changes remain if a draft or amendment is abandoned. Reverse incorrect usage explicitly, then record the replacement. Open an amendment before changing usage on a completed report. + Controlled-item consumption and reversals require an independent witness in Inventory. Complete that process first, then attach the resulting transaction ID here. + Recorded usage + Usage type + Used + Consumed on patient + Left at scene + Damaged + Ledger entry + Recorded at (UTC) + Reverses usage + Reversed + Reverse usage + This records a full reversal and keeps the original usage in the history. To correct an amount or item, reverse it and record the replacement. If the reversal was already witnessed in Inventory, enter its transaction ID to attach it without changing stock again. + Reason for correction + Existing witnessed reversal transaction ID (optional) + Positive quantities record usage; negative quantities reverse it. Open the ledger entry for the movement details under your current inventory access. + Existing consumption transaction ID (optional) + Enter a completed Inventory consumption transaction to attach its item, quantity, location and note. The new-consumption fields below are ignored, and stock is not consumed again. Select the usage type above. + New consumption + Use report unit or station + Leave blank to use the report's responding unit, with the station as fallback. Choose a source explicitly when the report has multiple possible locations. + Save usage + Return to report + Refresh evidence from recorded usage + Usage saved and supporting evidence captured. + The inventory action was saved. Evidence could not be captured; use Refresh evidence after resolving access or draft changes. Do not submit the inventory action again. + The draft changed. Check the recorded usage before submitting another inventory action. + Complete the independent witness process in Inventory, then attach the resulting transaction ID here. No stock movement was recorded by this action. + Supporting evidence captured. + A ledger reversal has not been linked to this report. Attach it as a correction before finalizing. + Purchasing + Suppliers + Purchase orders + Inventory valuation + Suppliers use company records from Contacts. Create or update company details in Contacts, then link the company here. + Supplier account number + Company in Contacts + Add supplier + Purchase order number + Supplier + Currency + View purchase order + Draft + Ordered + Partially received + Received + Cancelled + Quantity ordered + Quantity received + Remaining quantity + Marking an order as ordered locks its lines. Cancelling an order prevents further receipts and keeps quantities already received. + Mark as ordered + Cancel order + Receive items + Receive all or part of an order. Remove lines not included in this delivery. If an independent witness is required, stock and received quantities change only after approval in Inventory. + Use one row for each serialized asset, with quantity 1 and its own serial number. Add rows to receive more assets or split quantities across locations or lots. + Quantity to receive + Remove line + Receipts + New purchase order + Valuation covers only the inventory and locations you are authorized to view. Totals are separate for each currency. Rows with an unknown cost are counted separately and excluded from known value totals. + As of (UTC) + Some visible stock balances are negative. Review them before relying on this valuation. + Known value + Rows without a cost + Currency not specified + Unknown cost + Average unit cost + Preferred vendor + Use one currency per item. Existing inventory without a currency stays separate in valuation; assigning a currency does not convert costs. + Inventory purchase order received + Counts, alerts and reports + Stock counts + Inventory alerts + Inventory reports + Start a snapshot of up to 100 positions. Record every quantity, save, then complete. If stock or catalog data changes, cancel and start a fresh count. Record newly discovered serialized equipment through Receive. + All locations + Start count + Complete count + Cancel count + Snapshot at (UTC) + Expected + Counted + Variance + Lines with a variance + Draft + Awaiting witness + Completed + Cancelled + An independent witness must approve the pending operation from Inventory history. The snapshot must still be current. + Alerts show low stock, expiration within 30 days, expired inventory and overdue returns. Refresh once after rollout to initialize low-stock alerts, and after importing legacy inventory. Later stock and item edits update them automatically. + Refresh alerts + Low stock + Expiring soon + Expired + Overdue return + Open + Resolved + Due at (UTC) + Select a report and optional filters. Dates use UTC with an exclusive end date. Snapshot reports use current holdings. Download the PDF to view or print it. + From date (UTC, inclusive) + Until date (UTC, exclusive) + Download printable PDF + Schedule report delivery + Inventory alerts + Inventory needs attention. Sign in to review current alerts. + Inventory on hand + Inventory usage + Inventory expiration + Low inventory stock + Inventory transfer history + Inventory issuance history + Inventory valuation + Controlled substance log + Days until expiration + Quantity below reorder point + Suggested reorder quantity + Issuance ID + Recipient person ID + Recipient unit ID + Issued at (UTC) + Returned quantity + Outstanding quantity + Returned at (UTC) + Performed by (user ID) + Reverses transaction + Usage source + Record ID + Call ID + Amended revision ID + DEA schedule + Source balance before + Source balance after + Destination balance before + Destination balance after + Witness (user ID) + Witnessed at (UTC) + Inventory contains protected data. Open the authenticated report link and verify your identity to view or print current results. This scheduled PDF contains no inventory values. + This report includes only inventory and locations you are currently authorized to view. Quantities and values describe the stated snapshot; results are limited to 5,000 rows. + UTC range (inclusive start, exclusive end) + Each stock movement appears once. Positive quantities record outward usage; negative quantities reverse it. Record details require access to the source. Imported outward movements retain their legacy identity and do not establish a link to a Log or patient. + Active items are listed when visible bulk stock or serialized holdings are at or below the reorder point, falling back to minimum stock. Serialized holdings include in-service, issued, damaged and repair assets. Hidden locations are excluded. + Rows show issuances created within the selected date range and their current return status. Outstanding quantities on older issuances require an earlier start date. + Rows show current positive stock and active serialized holdings with an expiration date in the selected range. The default includes already expired inventory and inventory expiring in the next 30 days. + Performer signature + Witness signature + Not recorded + Inventory movement without an accessible Record link + Records + Legacy inventory movement + Enter valid count details and quantities. + Select a smaller scope. Counts support up to 100 positions. + There are no active inventory positions to count in this scope. + This count already exists. Open it from the counts list. + The count changed or has incomplete observations. Reload it and save all quantities before completion. + Inventory changed after this snapshot. Cancel this count and start a fresh one. + Count adjustments belong to their completed count. Start a new count to correct stock. + Post count variances by completing the stock count. + Select a valid UTC date range of no more than 366 days. + Select valid report filters. + The report changed while generating the PDF. Generate it again. + The report could not be generated. Try again. + Inventory low stock + Inventory expiring + Inventory count completed + Inventory return overdue + This resource is referenced by inventory history and must be retained. + Inventory adjusted + Inventory transfer completed + Inventory issued + Inventory returned + Inventory asset status changed + Controlled substance recorded - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx index 7ed029974..98ce8ab40 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx @@ -472,5 +472,179 @@ + Uso de inventario + Guardar el uso modifica el inventario de inmediato. Estos cambios en las existencias permanecen si se abandona un borrador o una enmienda. Revierta explícitamente el uso incorrecto y luego registre el reemplazo. Abra una enmienda antes de cambiar el uso de un informe finalizado. + El consumo y las reversiones de artículos controlados requieren un testigo independiente en Inventario. Complete primero ese proceso y luego vincule aquí el ID de la transacción resultante. + Uso registrado + Tipo de uso + Utilizado + Consumido en un paciente + Dejado en el lugar + Dañado + Asiento del libro de movimientos + Registrado (UTC) + Revierte el uso + Revertido + Revertir uso + Esto registra una reversión completa y conserva el uso original en el historial. Para corregir una cantidad o un artículo, reviértalo y registre el reemplazo. Si la reversión ya fue validada por un testigo en Inventario, ingrese su ID de transacción para vincularla sin modificar las existencias otra vez. + Motivo de la corrección + ID de reversión existente validada por un testigo (opcional) + Las cantidades positivas registran uso; las negativas lo revierten. Abra el asiento para ver los detalles del movimiento según sus permisos actuales de inventario. + ID de la transacción de consumo existente (opcional) + Ingrese una transacción de consumo completada en Inventario para vincular su artículo, cantidad, ubicación y nota. Los campos de nuevo consumo que aparecen abajo se ignoran y las existencias no se consumen otra vez. Seleccione el tipo de uso arriba. + Nuevo consumo + Usar unidad o estación del informe + Deje en blanco para usar la unidad que respondió según el informe, con la estación como alternativa. Elija un origen explícitamente si el informe tiene varias ubicaciones posibles. + Guardar uso + Volver al informe + Actualizar evidencia del uso registrado + Uso guardado y evidencia de respaldo capturada. + La acción de inventario se guardó. No se pudo capturar la evidencia; use Actualizar evidencia después de resolver los problemas de acceso o los cambios del borrador. No vuelva a enviar la acción de inventario. + El borrador cambió. Revise el uso registrado antes de enviar otra acción de inventario. + Complete el proceso de testigo independiente en Inventario y luego vincule aquí el ID de la transacción resultante. Esta acción no registró ningún movimiento de existencias. + Evidencia de respaldo capturada. + Hay una reversión en el libro de movimientos sin vincular a este informe. Vincúlela como corrección antes de finalizar. + Compras + Proveedores + Órdenes de compra + Valoración del inventario + Los proveedores utilizan los registros de empresas de Contactos. Cree o actualice los datos de la empresa en Contactos y luego vincúlela aquí. + Número de cuenta con el proveedor + Empresa en Contactos + Agregar proveedor + Número de orden de compra + Proveedor + Moneda + Ver orden de compra + Borrador + Ordenada + Recibida parcialmente + Recibida + Cancelada + Cantidad ordenada + Cantidad recibida + Cantidad pendiente + Al marcar una orden como ordenada, sus partidas quedan bloqueadas. Cancelar una orden impide nuevas recepciones y conserva las cantidades ya recibidas. + Marcar como ordenada + Cancelar orden + Recibir artículos + Reciba la orden completa o una parte. Quite las partidas que no se incluyen en esta entrega. Si se requiere un testigo independiente, las existencias y las cantidades recibidas solo cambiarán después de su aprobación en Inventario. + Use una fila para cada activo con número de serie, con cantidad 1 y su propio número de serie. Agregue filas para recibir más activos o distribuir cantidades entre ubicaciones o lotes. + Cantidad por recibir + Quitar partida + Recepciones + Nueva orden de compra + La valoración incluye únicamente el inventario y las ubicaciones que tiene autorización para ver. Los totales se presentan por separado para cada moneda. Las filas con costo desconocido se cuentan por separado y se excluyen de los totales de valor conocido. + Al (UTC) + Algunas existencias visibles son negativas. Revíselas antes de basarse en esta valoración. + Valor conocido + Filas sin costo + Moneda no especificada + Costo desconocido + Costo unitario promedio + Proveedor preferido + Use una sola moneda por artículo. El inventario existente sin moneda se mantiene separado en la valoración; asignar una moneda no convierte los costos. + Orden de compra de inventario recibida + Recuentos, alertas e informes + Recuentos de existencias + Alertas de inventario + Informes de inventario + Inicie un recuento del estado actual de hasta 100 posiciones de stock. Anote todas las cantidades, guarde y complete el recuento. Si cambian las existencias o los datos del catálogo, cancele e inicie un recuento nuevo. Registre los equipos con número de serie recién encontrados mediante Recibir artículos. + Todas las ubicaciones + Iniciar recuento + Completar recuento + Cancelar recuento + Estado capturado el (UTC) + Cantidad prevista + Cantidad contada + Diferencia + Líneas con diferencias + Borrador + Pendiente de testigo + Completado + Cancelado + Un testigo independiente debe aprobar la operación pendiente desde el historial de inventario. El estado capturado debe seguir vigente. + Las alertas muestran existencias bajas, artículos que caducan en los próximos 30 días, artículos caducados y devoluciones vencidas. Actualícelas una vez tras la puesta en marcha para inicializar las alertas de existencias bajas y después de importar el inventario anterior. Los cambios posteriores en las existencias y los artículos las actualizan automáticamente. + Actualizar alertas + Existencias bajas + Próxima caducidad + Caducado + Devolución vencida + Abierta + Resuelta + Vencimiento (UTC) + Seleccione un informe y los filtros que desee. Las fechas se expresan en UTC y la fecha final no se incluye. Los informes del estado actual utilizan las existencias actuales. Descargue el PDF para verlo o imprimirlo. + Fecha inicial (UTC, incluida) + Fecha final (UTC, no incluida) + Descargar PDF para imprimir + Programar el envío de informes + Alertas de inventario + El inventario requiere atención. Inicie sesión para revisar las alertas actuales. + Existencias de inventario + Uso de inventario + Caducidad del inventario + Existencias bajas de inventario + Historial de transferencias de inventario + Historial de entregas de inventario + Valoración del inventario + Registro de sustancias controladas + Días hasta la caducidad + Cantidad que falta para el punto de reposición + Cantidad de reposición sugerida + ID de entrega + ID de la persona destinataria + ID de la unidad destinataria + Entregado el (UTC) + Cantidad devuelta + Cantidad pendiente de devolución + Devuelto el (UTC) + Realizado por (ID de usuario) + Revierte la transacción + Origen del uso + ID del registro + ID de llamada + ID de la revisión modificada + Clasificación de la DEA + Saldo de origen anterior + Saldo de origen posterior + Saldo de destino anterior + Saldo de destino posterior + Testigo (ID de usuario) + Atestiguado el (UTC) + El inventario contiene datos protegidos. Abra el enlace del informe que requiere autenticación y verifique su identidad para ver o imprimir los resultados actuales. Este PDF programado no contiene valores del inventario. + Este informe solo incluye el inventario y las ubicaciones que tiene autorización para consultar en este momento. Las cantidades y los valores reflejan el estado capturado en el momento indicado; los resultados se limitan a 5.000 filas. + Intervalo UTC (inicio incluido, fin no incluido) + Cada movimiento de existencias aparece una sola vez. Las cantidades positivas registran usos de salida; las negativas los revierten. Los detalles del registro requieren acceso al origen. Los movimientos de salida importados conservan su identidad anterior y no establecen un vínculo con un registro de actividad ni con un paciente. + Se muestran los artículos activos cuando las existencias visibles controladas por cantidad o por número de serie están en el punto de reposición o por debajo de él; si no se ha definido ese punto, se utiliza el stock mínimo. Las existencias con número de serie incluyen equipos en servicio, entregados, dañados y en reparación. Se excluyen las ubicaciones ocultas. + Las filas muestran las entregas creadas en el intervalo de fechas seleccionado y su estado de devolución actual. Para consultar cantidades pendientes de entregas anteriores, seleccione una fecha inicial más antigua. + Las filas muestran las existencias actuales con cantidad positiva y los equipos activos con número de serie cuya fecha de caducidad está en el intervalo seleccionado. De forma predeterminada, se incluyen los artículos ya caducados y los que caducan en los próximos 30 días. + Firma de quien realizó la operación + Firma del testigo + No registrado + Movimiento de inventario sin vínculo accesible a un registro + Registros + Movimiento del inventario anterior + Introduzca datos y cantidades válidos para el recuento. + Seleccione un ámbito más pequeño. Los recuentos admiten hasta 100 posiciones de stock. + No hay posiciones de inventario activas que contar en este ámbito. + Este recuento ya existe. Ábralo desde la lista de recuentos. + El recuento ha cambiado o contiene observaciones incompletas. Vuelva a cargarlo y guarde todas las cantidades antes de completarlo. + El inventario ha cambiado desde que se capturó este estado. Cancele el recuento e inicie uno nuevo. + Los ajustes del recuento pertenecen al recuento completado. Inicie un recuento nuevo para corregir las existencias. + Complete el recuento de existencias para registrar las diferencias. + Seleccione un intervalo de fechas UTC válido de no más de 366 días. + Seleccione filtros válidos para el informe. + El informe cambió durante la generación del PDF. Genérelo de nuevo. + No se pudo generar el informe. Inténtelo de nuevo. + Existencias bajas + Inventario próximo a vencer + Recuento de inventario completado + Devolución de inventario atrasada + Este recurso está vinculado al historial de inventario y debe conservarse. + Inventario ajustado + Transferencia de inventario completada + Inventario entregado + Inventario devuelto + Estado del activo de inventario actualizado + Sustancia controlada registrada - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx index 754fc3884..cc15c2db5 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx @@ -472,5 +472,179 @@ Oui + Utilisation du stock + Enregistrer l'utilisation modifie immédiatement le stock. Ces changements persistent si un brouillon ou un amendement est abandonné. Annulez explicitement l'utilisation incorrecte, puis enregistrez son remplacement. Ouvrez un amendement avant de modifier l'utilisation dans un rapport finalisé. + La consommation et l'annulation d'articles réglementés nécessitent un témoin indépendant dans Inventaire. Terminez d'abord cette procédure, puis associez ici l'identifiant de la transaction obtenue. + Utilisation enregistrée + Type d'utilisation + Utilisé + Consommé pour un patient + Laissé sur les lieux + Endommagé + Écriture du registre + Enregistré (UTC) + Annule l'utilisation + Annulée + Annuler l'utilisation + Cette action enregistre une annulation complète et conserve l'utilisation d'origine dans l'historique. Pour corriger une quantité ou un article, annulez l'utilisation puis enregistrez son remplacement. Si l'annulation a déjà été validée par un témoin dans Inventaire, saisissez son identifiant de transaction pour l'associer sans modifier le stock à nouveau. + Motif de la correction + Identifiant d'annulation existante validée par un témoin (facultatif) + Les quantités positives enregistrent l'utilisation ; les quantités négatives l'annulent. Ouvrez l'écriture du registre pour consulter le mouvement selon vos droits d'accès actuels au stock. + Identifiant de la transaction de consommation existante (facultatif) + Saisissez une transaction de consommation terminée dans Inventaire pour associer son article, sa quantité, son emplacement et sa note. Les champs de nouvelle consommation ci-dessous sont ignorés et le stock n'est pas consommé une seconde fois. Sélectionnez le type d'utilisation ci-dessus. + Nouvelle consommation + Utiliser l'unité ou la caserne du rapport + Laissez vide pour utiliser l'unité intervenante du rapport, avec sa caserne comme solution de repli. Choisissez explicitement une source si le rapport présente plusieurs emplacements possibles. + Enregistrer l'utilisation + Revenir au rapport + Actualiser les justificatifs de l'utilisation enregistrée + Utilisation enregistrée et justificatifs capturés. + L'opération de stock a été enregistrée. Les justificatifs n'ont pas pu être capturés ; utilisez Actualiser les justificatifs après avoir résolu les problèmes d'accès ou les changements du brouillon. Ne soumettez pas à nouveau l'opération de stock. + Le brouillon a changé. Vérifiez l'utilisation enregistrée avant de soumettre une autre opération de stock. + Terminez la procédure avec un témoin indépendant dans Inventaire, puis associez ici l'identifiant de la transaction obtenue. Cette action n'a enregistré aucun mouvement de stock. + Justificatifs capturés. + Une écriture d'annulation du registre n'est pas liée à ce rapport. Associez-la en tant que correction avant de finaliser. + Achats + Fournisseurs + Bons de commande + Valorisation du stock + Les fournisseurs utilisent les fiches d’entreprise de Contacts. Créez ou mettez à jour les coordonnées de l’entreprise dans Contacts, puis associez-la ici. + Numéro de compte chez le fournisseur + Entreprise dans Contacts + Ajouter un fournisseur + Numéro du bon de commande + Fournisseur + Devise + Voir le bon de commande + Brouillon + Commandé + Partiellement réceptionné + Réceptionné + Annulé + Quantité commandée + Quantité réceptionnée + Quantité restante + Marquer une commande comme commandée verrouille ses lignes. L’annulation empêche toute nouvelle réception et conserve les quantités déjà réceptionnées. + Marquer comme commandé + Annuler la commande + Réceptionner les articles + Réceptionnez tout ou partie d’une commande. Retirez les lignes qui ne font pas partie de cette livraison. Si un témoin indépendant est requis, le stock et les quantités réceptionnées ne changent qu’après son approbation dans Inventaire. + Utilisez une ligne par équipement suivi par numéro de série, avec une quantité de 1 et son propre numéro de série. Ajoutez des lignes pour réceptionner d’autres équipements ou répartir les quantités entre plusieurs emplacements ou lots. + Quantité à réceptionner + Retirer la ligne + Réceptions + Nouveau bon de commande + La valorisation porte uniquement sur le stock et les emplacements que vous êtes autorisé à consulter. Les totaux sont séparés par devise. Les lignes dont le coût est inconnu sont comptées séparément et exclues des totaux de valeur connue. + Au (UTC) + Certains soldes de stock visibles sont négatifs. Vérifiez-les avant de vous appuyer sur cette valorisation. + Valeur connue + Lignes sans coût + Devise non précisée + Coût inconnu + Coût unitaire moyen + Fournisseur privilégié + Utilisez une seule devise par article. Le stock existant sans devise reste séparé dans la valorisation ; attribuer une devise ne convertit pas les coûts. + Bon de commande de stock réceptionné + Comptages, alertes et rapports + Comptages de stock + Alertes de stock + Rapports de stock + Relevez l’état actuel de 100 positions de stock au maximum. Saisissez toutes les quantités, enregistrez, puis terminez le comptage. Si le stock ou les données du catalogue changent, annulez et lancez un nouveau comptage. Enregistrez les équipements avec numéro de série nouvellement trouvés via Réceptionner les articles. + Tous les emplacements + Lancer le comptage + Terminer le comptage + Annuler le comptage + État relevé le (UTC) + Quantité attendue + Quantité comptée + Écart + Lignes présentant un écart + Brouillon + En attente d’un témoin + Terminé + Annulé + Un témoin indépendant doit approuver l’opération en attente depuis l’historique du stock. L’état relevé doit toujours être à jour. + Les alertes signalent les stocks bas, les péremptions dans les 30 prochains jours, les articles périmés et les retours en retard. Actualisez-les une fois après la mise en service pour initialiser les alertes de stock bas, ainsi qu’après l’importation de l’ancien inventaire. Les modifications ultérieures du stock et des articles les mettent à jour automatiquement. + Actualiser les alertes + Stock bas + Péremption prochaine + Périmé + Retour en retard + Ouverte + Résolue + Échéance (UTC) + Sélectionnez un rapport et, si nécessaire, des filtres. Les dates sont exprimées en UTC et la date de fin est exclue. Les rapports d’état utilisent le stock actuel. Téléchargez le PDF pour le consulter ou l’imprimer. + Date de début (UTC, incluse) + Date de fin (UTC, exclue) + Télécharger le PDF imprimable + Programmer l’envoi de rapports + Alertes de stock + Le stock nécessite votre attention. Connectez-vous pour consulter les alertes actuelles. + Stock disponible + Utilisation du stock + Péremption du stock + Articles en stock bas + Historique des transferts de stock + Historique des dotations + Valorisation du stock + Registre des substances contrôlées + Jours avant péremption + Quantité manquante pour atteindre le seuil de réapprovisionnement + Quantité de réapprovisionnement suggérée + ID de la dotation + ID de la personne destinataire + ID de l’unité destinataire + Attribué le (UTC) + Quantité retournée + Quantité restant à retourner + Retourné le (UTC) + Effectué par (ID utilisateur) + Annule le mouvement + Origine de l’utilisation + ID du dossier + ID de l’appel + ID de la révision modifiée + Classification DEA + Solde d’origine avant + Solde d’origine après + Solde de destination avant + Solde de destination après + Témoin (ID utilisateur) + Attesté le (UTC) + Le stock contient des données protégées. Ouvrez le lien du rapport nécessitant une authentification et vérifiez votre identité pour consulter ou imprimer les résultats actuels. Ce PDF programmé ne contient aucune valeur du stock. + Ce rapport inclut uniquement le stock et les emplacements que vous êtes actuellement autorisé à consulter. Les quantités et les valeurs correspondent à l’état relevé à la date indiquée ; les résultats sont limités à 5 000 lignes. + Intervalle UTC (début inclus, fin exclue) + Chaque mouvement de stock apparaît une seule fois. Les quantités positives enregistrent les sorties liées à une utilisation ; les quantités négatives les annulent. Les détails du dossier nécessitent un accès à la source. Les sorties importées conservent leur identité dans l’ancien système et ne créent aucun lien avec un journal d’activité ou un patient. + Les articles actifs sont affichés lorsque le stock visible suivi par quantité ou par numéro de série est inférieur ou égal au seuil de réapprovisionnement ; à défaut, le stock minimum est utilisé. Le stock suivi par numéro de série inclut les équipements en service, attribués, endommagés et en réparation. Les emplacements masqués sont exclus. + Les lignes présentent les dotations créées dans l’intervalle de dates sélectionné et leur état de retour actuel. Pour consulter les quantités restant à retourner sur des dotations plus anciennes, choisissez une date de début antérieure. + Les lignes présentent le stock actuel de quantité positive et les équipements actifs avec numéro de série dont la date de péremption se situe dans l’intervalle sélectionné. Par défaut, les articles déjà périmés et ceux qui périment dans les 30 prochains jours sont inclus. + Signature de l’auteur de l’opération + Signature du témoin + Non renseigné + Mouvement de stock sans lien accessible vers un dossier + Dossiers + Mouvement de l’ancien inventaire + Saisissez des informations et des quantités de comptage valides. + Sélectionnez un périmètre plus restreint. Les comptages acceptent jusqu’à 100 positions de stock. + Aucune position de stock active n’est à compter dans ce périmètre. + Ce comptage existe déjà. Ouvrez-le depuis la liste des comptages. + Le comptage a changé ou comporte des observations incomplètes. Rechargez-le et enregistrez toutes les quantités avant de le terminer. + Le stock a changé depuis le relevé de cet état. Annulez ce comptage et lancez-en un nouveau. + Les ajustements de comptage appartiennent au comptage terminé. Lancez un nouveau comptage pour corriger le stock. + Terminez le comptage de stock pour enregistrer les écarts. + Sélectionnez un intervalle de dates UTC valide de 366 jours au maximum. + Sélectionnez des filtres de rapport valides. + Le rapport a changé pendant la génération du PDF. Générez-le à nouveau. + Le rapport n’a pas pu être généré. Réessayez. + Stock faible + Expiration prochaine du stock + Inventaire terminé + Retour de matériel en retard + Cette ressource est référencée dans l’historique du stock et doit être conservée. + Stock ajusté + Transfert de stock terminé + Stock attribué + Stock retourné + Statut du bien en stock modifié + Substance contrôlée enregistrée - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx index d8cb7d6cc..8e5289d89 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx @@ -472,5 +472,179 @@ + Utilizzo delle scorte + Salvare l'utilizzo modifica subito le scorte. Queste modifiche rimangono se si abbandona una bozza o una modifica al rapporto. Stornare esplicitamente l'utilizzo errato, quindi registrare quello sostitutivo. Aprire una modifica prima di cambiare l'utilizzo in un rapporto finalizzato. + Il consumo e gli storni di articoli soggetti a controllo richiedono un testimone indipendente in Inventario. Completare prima questa procedura, quindi collegare qui l'ID della transazione risultante. + Utilizzo registrato + Tipo di utilizzo + Utilizzato + Consumato su un paziente + Lasciato sul posto + Danneggiato + Registrazione contabile + Registrato (UTC) + Storna l'utilizzo + Stornato + Storna utilizzo + Questa azione registra uno storno completo e conserva l'utilizzo originale nello storico. Per correggere una quantità o un articolo, stornare l'utilizzo e registrare quello sostitutivo. Se lo storno è già stato convalidato da un testimone in Inventario, inserire il suo ID di transazione per collegarlo senza modificare nuovamente le scorte. + Motivo della correzione + ID dello storno esistente convalidato da un testimone (facoltativo) + Le quantità positive registrano l'utilizzo; quelle negative lo stornano. Aprire la registrazione per visualizzare i dettagli del movimento in base agli attuali permessi di inventario. + ID della transazione di consumo esistente (facoltativo) + Inserire una transazione di consumo completata in Inventario per collegare articolo, quantità, posizione e nota. I campi di nuovo consumo sottostanti vengono ignorati e le scorte non vengono consumate di nuovo. Selezionare il tipo di utilizzo sopra. + Nuovo consumo + Usa unità o sede del rapporto + Lasciare vuoto per usare l'unità intervenuta indicata nel rapporto, con la sede come alternativa. Scegliere esplicitamente un'origine se il rapporto presenta più posizioni possibili. + Salva utilizzo + Torna al rapporto + Aggiorna documentazione dell'utilizzo registrato + Utilizzo salvato e documentazione di supporto acquisita. + L'operazione di inventario è stata salvata. Non è stato possibile acquisire la documentazione; usare Aggiorna documentazione dopo aver risolto i problemi di accesso o le modifiche alla bozza. Non inviare nuovamente l'operazione di inventario. + La bozza è cambiata. Controllare l'utilizzo registrato prima di inviare un'altra operazione di inventario. + Completare la procedura con un testimone indipendente in Inventario, quindi collegare qui l'ID della transazione risultante. Questa azione non ha registrato alcun movimento di magazzino. + Documentazione di supporto acquisita. + Uno storno nel registro non è stato collegato a questo rapporto. Collegarlo come correzione prima della finalizzazione. + Acquisti + Fornitori + Ordini di acquisto + Valutazione delle scorte + I fornitori utilizzano le schede aziendali di Contatti. Crea o aggiorna i dati dell’azienda in Contatti, quindi collegala qui. + Numero di conto presso il fornitore + Azienda in Contatti + Aggiungi fornitore + Numero dell’ordine di acquisto + Fornitore + Valuta + Visualizza ordine di acquisto + Bozza + Ordinato + Ricevuto parzialmente + Ricevuto + Annullato + Quantità ordinata + Quantità ricevuta + Quantità rimanente + Contrassegnare un ordine come ordinato ne blocca le righe. L’annullamento impedisce ulteriori ricezioni e mantiene le quantità già ricevute. + Contrassegna come ordinato + Annulla ordine + Ricevi articoli + Ricevi un ordine intero o una parte. Rimuovi le righe non incluse in questa consegna. Se è richiesto un testimone indipendente, le scorte e le quantità ricevute cambiano solo dopo la sua approvazione in Inventario. + Usa una riga per ogni bene con numero di serie, con quantità 1 e il suo numero di serie. Aggiungi righe per ricevere altri beni o suddividere le quantità tra ubicazioni o lotti. + Quantità da ricevere + Rimuovi riga + Ricezioni + Nuovo ordine di acquisto + La valutazione comprende solo le scorte e le ubicazioni che sei autorizzato a visualizzare. I totali sono separati per valuta. Le righe con costo sconosciuto vengono conteggiate separatamente ed escluse dai totali del valore noto. + Alla data (UTC) + Alcune giacenze visibili sono negative. Verificale prima di basarti su questa valutazione. + Valore noto + Righe senza costo + Valuta non specificata + Costo sconosciuto + Costo unitario medio + Fornitore preferito + Usa una sola valuta per articolo. Le scorte esistenti senza valuta rimangono separate nella valutazione; assegnare una valuta non converte i costi. + Ordine di acquisto per le scorte ricevuto + Conteggi, avvisi e report + Conteggi delle scorte + Avvisi dell’inventario + Report dell’inventario + Crea un’istantanea di un massimo di 100 posizioni di magazzino. Registra ogni quantità, salva e poi completa il conteggio. Se le scorte o i dati del catalogo cambiano, annulla il conteggio e avviane uno nuovo. Registra le nuove attrezzature con numero di serie rinvenute tramite Ricevi. + Tutte le ubicazioni + Avvia conteggio + Completa conteggio + Annulla conteggio + Istantanea del (UTC) + Quantità prevista + Quantità conteggiata + Scostamento + Righe con uno scostamento + Bozza + In attesa di attestazione + Completato + Annullato + Un testimone indipendente deve approvare l’operazione in sospeso dalla cronologia dell’inventario. L’istantanea deve essere ancora attuale. + Gli avvisi segnalano scorte basse, scadenze entro 30 giorni, scorte scadute e restituzioni in ritardo. Aggiorna gli avvisi una volta dopo l’introduzione della funzione per inizializzare quelli sulle scorte basse e dopo l’importazione dell’inventario preesistente. Le successive modifiche alle scorte e agli articoli li aggiornano automaticamente. + Aggiorna avvisi + Scorte basse + In scadenza + Scaduto + Restituzione in ritardo + Aperto + Risolto + Scadenza (UTC) + Seleziona un report ed eventuali filtri. Le date sono espresse in UTC e la data finale è esclusa. I report istantanei utilizzano le giacenze attuali. Scarica il PDF per visualizzarlo o stamparlo. + Data iniziale (UTC, inclusa) + Data finale (UTC, esclusa) + Scarica PDF stampabile + Programma l’invio dei report + Avvisi dell’inventario + L’inventario richiede attenzione. Accedi per esaminare gli avvisi attuali. + Giacenze dell’inventario + Utilizzo delle scorte + Scadenze dell’inventario + Scorte basse dell’inventario + Cronologia dei trasferimenti dell’inventario + Cronologia delle assegnazioni dell’inventario + Valutazione delle scorte + Registro delle sostanze controllate + Giorni alla scadenza + Quantità mancante al punto di riordino + Quantità di riordino suggerita + ID assegnazione + ID della persona destinataria + ID dell’unità destinataria + Assegnato il (UTC) + Quantità restituita + Quantità da restituire + Restituito il (UTC) + Eseguito da (ID utente) + Storna la transazione + Origine dell’utilizzo + ID del record + ID intervento + ID della revisione modificata + Classificazione DEA + Saldo di origine precedente + Saldo di origine successivo + Saldo di destinazione precedente + Saldo di destinazione successivo + Testimone (ID utente) + Attestato il (UTC) + L’inventario contiene dati protetti. Apri il link al report con autenticazione e verifica la tua identità per visualizzare o stampare i risultati attuali. Questo PDF programmato non contiene valori dell’inventario. + Questo report include solo le scorte e le ubicazioni che sei attualmente autorizzato a visualizzare. Quantità e valori si riferiscono all’istantanea indicata; i risultati sono limitati a 5.000 righe. + Intervallo UTC (inizio incluso, fine esclusa) + Ogni movimento di scorte compare una sola volta. Le quantità positive registrano l’utilizzo in uscita; quelle negative lo stornano. Per visualizzare i dettagli dei record è necessario l’accesso all’origine. I movimenti in uscita importati mantengono il proprio identificativo preesistente e non creano un collegamento a un registro o a un paziente. + Gli articoli attivi sono elencati quando le scorte visibili gestite per quantità o con numero di serie sono pari o inferiori al punto di riordino oppure, se non impostato, alla scorta minima. Le giacenze con numero di serie includono attrezzature in servizio, assegnate, danneggiate e in riparazione. Le ubicazioni nascoste sono escluse. + Le righe mostrano le assegnazioni create nell’intervallo di date selezionato e il relativo stato attuale di restituzione. Per le quantità ancora da restituire relative ad assegnazioni precedenti, seleziona una data iniziale anteriore. + Le righe mostrano le scorte positive attuali e le giacenze attive con numero di serie la cui scadenza rientra nell’intervallo selezionato. Per impostazione predefinita sono incluse le scorte già scadute e quelle in scadenza nei prossimi 30 giorni. + Firma dell’esecutore + Firma del testimone + Non registrato + Movimento di inventario senza collegamento accessibile a un record + Record + Movimento di inventario preesistente + Inserisci dettagli del conteggio e quantità validi. + Seleziona un ambito più ristretto. I conteggi supportano fino a 100 posizioni di magazzino. + In questo ambito non sono presenti posizioni di magazzino attive da conteggiare. + Questo conteggio esiste già. Aprilo dall’elenco dei conteggi. + Il conteggio è cambiato o contiene rilevazioni incomplete. Ricaricalo e salva tutte le quantità prima di completarlo. + L’inventario è cambiato dopo questa istantanea. Annulla questo conteggio e avviane uno nuovo. + Le rettifiche del conteggio appartengono al conteggio completato. Avvia un nuovo conteggio per correggere le scorte. + Registra gli scostamenti completando il conteggio delle scorte. + Seleziona un intervallo di date UTC valido di massimo 366 giorni. + Seleziona filtri validi per il report. + Il report è cambiato durante la generazione del PDF. Generalo di nuovo. + Impossibile generare il report. Riprova. + Scorte insufficienti + Inventario in scadenza + Conteggio inventario completato + Restituzione inventario scaduta + Questa risorsa è citata nello storico dell’inventario e deve essere conservata. + Inventario rettificato + Trasferimento di inventario completato + Inventario assegnato + Inventario restituito + Stato del bene inventariato modificato + Sostanza controllata registrata - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx index e087ebe2b..938310262 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx @@ -472,5 +472,179 @@ Tak + Zużycie zapasów + Zapisanie zużycia natychmiast zmienia stan zapasów. Zmiany te pozostają po porzuceniu wersji roboczej lub zmiany raportu. Jawnie cofnij nieprawidłowe zużycie, a następnie zapisz zastępujące je dane. Przed zmianą zużycia w ukończonym raporcie otwórz jego zmianę. + Zużycie i cofnięcia dotyczące artykułów kontrolowanych wymagają niezależnego świadka w Magazynie. Najpierw zakończ ten proces, a następnie powiąż tutaj identyfikator utworzonej transakcji. + Zapisane zużycie + Rodzaj wykorzystania + Wykorzystano + Zużyto u pacjenta + Pozostawiono na miejscu + Uszkodzono + Zapis w ewidencji + Zapisano (UTC) + Cofa zużycie + Cofnięto + Cofnij zużycie + Ta czynność zapisuje pełne cofnięcie i zachowuje pierwotne zużycie w historii. Aby poprawić ilość lub artykuł, cofnij zużycie i zapisz dane zastępcze. Jeśli cofnięcie zostało już potwierdzone przez świadka w Magazynie, podaj jego identyfikator transakcji, aby je powiązać bez ponownej zmiany zapasów. + Powód korekty + Identyfikator istniejącego cofnięcia potwierdzonego przez świadka (opcjonalnie) + Ilości dodatnie zapisują zużycie, a ujemne je cofają. Otwórz zapis w ewidencji, aby zobaczyć szczegóły ruchu zgodnie z aktualnymi uprawnieniami do magazynu. + Identyfikator istniejącej transakcji zużycia (opcjonalnie) + Podaj zakończoną transakcję zużycia z Magazynu, aby powiązać jej artykuł, ilość, lokalizację i notatkę. Poniższe pola nowego zużycia są ignorowane, a zapasy nie są zużywane ponownie. Wybierz rodzaj wykorzystania powyżej. + Nowe zużycie + Użyj jednostki lub stacji z raportu + Pozostaw puste, aby użyć jednostki interweniującej z raportu, a w razie potrzeby jej stacji. Wybierz źródło jawnie, jeśli raport ma kilka możliwych lokalizacji. + Zapisz zużycie + Wróć do raportu + Odśwież dokumentację zapisanego zużycia + Zużycie zapisano i utrwalono dokumentację potwierdzającą. + Operacja magazynowa została zapisana. Nie udało się utrwalić dokumentacji. Po rozwiązaniu problemów z dostępem lub zmianami wersji roboczej użyj opcji Odśwież dokumentację. Nie przesyłaj ponownie operacji magazynowej. + Wersja robocza uległa zmianie. Sprawdź zapisane zużycie przed przesłaniem kolejnej operacji magazynowej. + Zakończ proces z niezależnym świadkiem w Magazynie, a następnie powiąż tutaj identyfikator utworzonej transakcji. Ta czynność nie zarejestrowała żadnego ruchu magazynowego. + Utrwalono dokumentację potwierdzającą. + Cofnięcie w ewidencji nie zostało powiązane z tym raportem. Powiąż je jako korektę przed finalizacją. + Zakupy + Dostawcy + Zamówienia zakupu + Wycena zapasów + Dostawcy korzystają z wpisów firm w Kontaktach. Utwórz lub zaktualizuj dane firmy w Kontaktach, a następnie powiąż ją tutaj. + Numer konta u dostawcy + Firma z Kontaktów + Dodaj dostawcę + Numer zamówienia zakupu + Dostawca + Waluta + Wyświetl zamówienie zakupu + Wersja robocza + Zamówione + Częściowo odebrane + Odebrane + Anulowane + Zamówiona ilość + Odebrana ilość + Pozostała ilość + Oznaczenie zamówienia jako zamówionego blokuje jego pozycje. Anulowanie uniemożliwia kolejne przyjęcia i zachowuje ilości już odebrane. + Oznacz jako zamówione + Anuluj zamówienie + Przyjmij artykuły + Przyjmij całość lub część zamówienia. Usuń pozycje, których nie ma w tej dostawie. Jeśli wymagany jest niezależny świadek, stany magazynowe i odebrane ilości zmienią się dopiero po jego zatwierdzeniu w Magazynie. + Użyj osobnego wiersza dla każdego zasobu z numerem seryjnym, z ilością 1 i jego własnym numerem seryjnym. Dodaj wiersze, aby przyjąć więcej zasobów lub rozdzielić ilości między lokalizacje albo partie. + Ilość do przyjęcia + Usuń pozycję + Przyjęcia + Nowe zamówienie zakupu + Wycena obejmuje tylko zapasy i lokalizacje, do których masz uprawnienia do wglądu. Sumy są podawane oddzielnie dla każdej waluty. Wiersze z nieznanym kosztem są liczone osobno i pomijane w sumach znanej wartości. + Stan na (UTC) + Niektóre widoczne stany magazynowe są ujemne. Sprawdź je, zanim oprzesz się na tej wycenie. + Znana wartość + Wiersze bez kosztu + Nie określono waluty + Nieznany koszt + Średni koszt jednostkowy + Preferowany dostawca + Używaj jednej waluty dla każdego artykułu. Istniejące zapasy bez waluty pozostają osobno w wycenie; przypisanie waluty nie przelicza kosztów. + Odebrano zamówienie zakupu zapasów + Inwentaryzacje, alerty i raporty + Inwentaryzacje + Alerty inwentarza + Raporty inwentarza + Utwórz migawkę maksymalnie 100 pozycji magazynowych. Zapisz każdą ilość, zapisz inwentaryzację, a następnie ją zakończ. Jeśli zmienią się zapasy lub dane katalogowe, anuluj inwentaryzację i rozpocznij nową. Nowo odnaleziony sprzęt z numerem seryjnym zarejestruj za pomocą opcji Przyjmij. + Wszystkie lokalizacje + Rozpocznij inwentaryzację + Zakończ inwentaryzację + Anuluj inwentaryzację + Migawka z dnia (UTC) + Ilość oczekiwana + Ilość zliczona + Różnica + Pozycje z różnicą + Wersja robocza + Oczekuje na potwierdzenie świadka + Zakończona + Anulowana + Niezależny świadek musi zatwierdzić oczekującą operację w historii inwentarza. Migawka musi być nadal aktualna. + Alerty wskazują niskie stany magazynowe, zapasy tracące ważność w ciągu 30 dni, zapasy przeterminowane i zaległe zwroty. Odśwież alerty raz po wdrożeniu, aby zainicjować alerty niskiego stanu, oraz po zaimportowaniu wcześniejszego inwentarza. Późniejsze zmiany zapasów i artykułów aktualizują je automatycznie. + Odśwież alerty + Niski stan magazynowy + Wkrótce traci ważność + Przeterminowane + Zaległy zwrot + Otwarty + Rozwiązany + Termin (UTC) + Wybierz raport i opcjonalne filtry. Daty są podawane w UTC, a data końcowa jest wyłączona z zakresu. Raporty migawkowe korzystają z bieżących stanów. Pobierz plik PDF, aby go wyświetlić lub wydrukować. + Data początkowa (UTC, włącznie) + Data końcowa (UTC, wyłącznie) + Pobierz PDF do druku + Zaplanuj wysyłkę raportów + Alerty inwentarza + Inwentarz wymaga uwagi. Zaloguj się, aby sprawdzić aktualne alerty. + Bieżący stan inwentarza + Zużycie zapasów + Terminy ważności inwentarza + Niskie stany magazynowe + Historia przesunięć inwentarza + Historia wydań inwentarza + Wycena zapasów + Rejestr substancji kontrolowanych + Dni do upływu ważności + Brakująca ilość do progu ponownego zamówienia + Sugerowana ilość do ponownego zamówienia + Identyfikator wydania + Identyfikator osoby odbierającej + Identyfikator jednostki odbierającej + Wydano (UTC) + Zwrócona ilość + Ilość do zwrotu + Zwrócono (UTC) + Wykonawca (identyfikator użytkownika) + Odwraca transakcję + Źródło zużycia + Identyfikator rekordu + Identyfikator zdarzenia + Identyfikator zmienionej wersji + Klasyfikacja DEA + Stan źródłowy przed + Stan źródłowy po + Stan docelowy przed + Stan docelowy po + Świadek (identyfikator użytkownika) + Potwierdzono (UTC) + Inwentarz zawiera chronione dane. Otwórz link do raportu wymagający uwierzytelnienia i potwierdź swoją tożsamość, aby wyświetlić lub wydrukować bieżące wyniki. Ten zaplanowany plik PDF nie zawiera wartości inwentarza. + Ten raport obejmuje tylko inwentarz i lokalizacje, do których wyświetlania masz obecnie uprawnienia. Ilości i wartości odnoszą się do wskazanej migawki; wyniki są ograniczone do 5 000 wierszy. + Zakres UTC (początek włącznie, koniec wyłącznie) + Każdy ruch magazynowy pojawia się raz. Dodatnie ilości oznaczają zużycie wychodzące, a ujemne je odwracają. Szczegóły rekordu wymagają dostępu do źródła. Zaimportowane ruchy wychodzące zachowują swoje wcześniejsze identyfikatory i nie tworzą powiązania z dziennikiem ani pacjentem. + Aktywne artykuły są wyświetlane, gdy widoczne zapasy rozliczane ilościowo lub według numerów seryjnych są równe progowi ponownego zamówienia albo niższe od niego. Jeśli progu nie określono, używany jest stan minimalny. Zapasy z numerami seryjnymi obejmują sprzęt w użyciu, wydany, uszkodzony i w naprawie. Ukryte lokalizacje są pomijane. + Wiersze pokazują wydania utworzone w wybranym zakresie dat oraz ich bieżący status zwrotu. Aby uwzględnić niezwrócone ilości z wcześniejszych wydań, wybierz wcześniejszą datę początkową. + Wiersze pokazują bieżące dodatnie stany magazynowe oraz aktywne zapasy z numerami seryjnymi, których termin ważności mieści się w wybranym zakresie. Domyślnie uwzględniane są zapasy już przeterminowane i te, które stracą ważność w ciągu najbliższych 30 dni. + Podpis wykonawcy + Podpis świadka + Nie zarejestrowano + Ruch magazynowy bez dostępnego powiązania z rekordem + Rekordy + Wcześniejszy ruch magazynowy + Wprowadź prawidłowe dane inwentaryzacji i ilości. + Wybierz mniejszy zakres. Inwentaryzacja obsługuje maksymalnie 100 pozycji. + W tym zakresie nie ma aktywnych pozycji magazynowych do zliczenia. + Ta inwentaryzacja już istnieje. Otwórz ją z listy inwentaryzacji. + Inwentaryzacja uległa zmianie lub zawiera niepełne odczyty. Wczytaj ją ponownie i zapisz wszystkie ilości przed zakończeniem. + Inwentarz zmienił się od utworzenia tej migawki. Anuluj tę inwentaryzację i rozpocznij nową. + Korekty inwentaryzacyjne należą do zakończonej inwentaryzacji. Rozpocznij nową inwentaryzację, aby skorygować zapasy. + Zaksięguj różnice, kończąc inwentaryzację. + Wybierz prawidłowy zakres dat UTC nieprzekraczający 366 dni. + Wybierz prawidłowe filtry raportu. + Raport zmienił się podczas generowania pliku PDF. Wygeneruj go ponownie. + Nie udało się wygenerować raportu. Spróbuj ponownie. + Niski stan magazynowy + Zbliżający się termin ważności zapasów + Zakończono inwentaryzację + Zaległy zwrot wyposażenia + Ten zasób jest powiązany z historią magazynu i musi zostać zachowany. + Skorygowano stan magazynowy + Zakończono przesunięcie magazynowe + Wydano zapasy + Zwrócono zapasy + Zmieniono status składnika wyposażenia + Zarejestrowano substancję kontrolowaną - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx index ee6772b8e..20d2ce2f7 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx @@ -472,5 +472,179 @@ Ja + Lageranvändning + När användningen sparas ändras lagersaldot omedelbart. Ändringarna kvarstår om ett utkast eller en rapportändring överges. Återför felaktig användning uttryckligen och registrera sedan ersättningen. Öppna en rapportändring innan du ändrar användningen i en slutförd rapport. + Förbrukning och återföring av kontrollerade artiklar kräver ett oberoende vittne i Lager. Slutför först den processen och koppla sedan det skapade transaktions-ID:t här. + Registrerad användning + Användningstyp + Använt + Förbrukat på patient + Lämnat på plats + Skadat + Lagerpost + Registrerat (UTC) + Återför användning + Återfört + Återför användning + Detta registrerar en fullständig återföring och behåller den ursprungliga användningen i historiken. För att korrigera en mängd eller artikel återför du användningen och registrerar ersättningen. Om återföringen redan har bekräftats av ett vittne i Lager anger du dess transaktions-ID för att koppla den utan att ändra lagret igen. + Orsak till korrigering + ID för befintlig återföring bekräftad av vittne (valfritt) + Positiva mängder registrerar användning; negativa mängder återför den. Öppna lagerposten för rörelsedetaljer enligt dina aktuella lagerbehörigheter. + ID för befintlig förbrukningstransaktion (valfritt) + Ange en slutförd förbrukningstransaktion från Lager för att koppla dess artikel, mängd, plats och anteckning. Fälten för ny förbrukning nedan ignoreras och lagret förbrukas inte igen. Välj användningstyp ovan. + Ny förbrukning + Använd rapportens enhet eller station + Lämna tomt för att använda rapportens insatsenhet, med stationen som reserv. Välj en källa uttryckligen om rapporten har flera möjliga platser. + Spara användning + Tillbaka till rapporten + Uppdatera underlag från registrerad användning + Användningen har sparats och underlaget har registrerats. + Lageråtgärden har sparats. Underlaget kunde inte registreras. Använd Uppdatera underlag när åtkomstproblem eller ändringar i utkastet har lösts. Skicka inte lageråtgärden igen. + Utkastet har ändrats. Kontrollera den registrerade användningen innan du skickar en ny lageråtgärd. + Slutför processen med ett oberoende vittne i Lager och koppla sedan det skapade transaktions-ID:t här. Ingen lagerrörelse registrerades av denna åtgärd. + Underlaget har registrerats. + En återföring i lagerregistret har inte kopplats till denna rapport. Koppla den som en korrigering innan rapporten slutförs. + Inköp + Leverantörer + Inköpsordrar + Lagervärdering + Leverantörer använder företagsposter från Kontakter. Skapa eller uppdatera företagets uppgifter i Kontakter och koppla sedan företaget här. + Kundnummer hos leverantören + Företag i Kontakter + Lägg till leverantör + Inköpsordernummer + Leverantör + Valuta + Visa inköpsorder + Utkast + Beställd + Delvis mottagen + Mottagen + Avbruten + Beställd mängd + Mottagen mängd + Återstående mängd + När en order markeras som beställd låses orderraderna. Om ordern avbryts stoppas fler inleveranser, medan redan mottagna mängder behålls. + Markera som beställd + Avbryt order + Ta emot artiklar + Ta emot hela eller delar av en order. Ta bort rader som inte ingår i leveransen. Om ett oberoende vittne krävs ändras lagersaldon och mottagna mängder först efter godkännande i Lager. + Använd en rad per tillgång med serienummer, med antal 1 och ett eget serienummer. Lägg till rader för att ta emot fler tillgångar eller fördela mängder mellan platser eller partier. + Mängd att ta emot + Ta bort rad + Inleveranser + Ny inköpsorder + Värderingen omfattar endast lager och platser som du har behörighet att se. Summor redovisas separat för varje valuta. Rader med okänd kostnad räknas separat och ingår inte i summorna för känt värde. + Gäller per (UTC) + Vissa synliga lagersaldon är negativa. Kontrollera dem innan du använder värderingen som underlag. + Känt värde + Rader utan kostnad + Valuta saknas + Okänd kostnad + Genomsnittlig styckkostnad + Förvald leverantör + Använd en valuta per artikel. Befintligt lager utan valuta hålls separat i värderingen; att ange en valuta räknar inte om kostnaderna. + Inköpsorder för lager mottagen + Inventeringar, varningar och rapporter + Inventeringar + Lagervarningar + Lagerrapporter + Skapa en ögonblicksbild av upp till 100 lagerpositioner. Registrera varje antal, spara och slutför sedan inventeringen. Om lagret eller kataloguppgifterna ändras ska du avbryta inventeringen och starta en ny. Registrera nyfunnen utrustning med serienummer via Ta emot. + Alla lagerplatser + Starta inventering + Slutför inventering + Avbryt inventering + Ögonblicksbild från (UTC) + Förväntat antal + Räknat antal + Avvikelse + Rader med avvikelse + Utkast + Inväntar vittnesintyg + Slutförd + Avbruten + Ett oberoende vittne måste godkänna den väntande åtgärden i lagerhistoriken. Ögonblicksbilden måste fortfarande vara aktuell. + Varningar visar låga lagernivåer, lager som förfaller inom 30 dagar, utgånget lager och försenade återlämningar. Uppdatera varningarna en gång efter införandet för att initiera varningar om låga lagernivåer, och efter import av äldre inventarier. Senare ändringar av lager och artiklar uppdaterar dem automatiskt. + Uppdatera varningar + Låg lagernivå + Förfaller snart + Utgånget + Försenad återlämning + Öppen + Åtgärdad + Förfaller (UTC) + Välj en rapport och eventuella filter. Datumen anges i UTC och slutdatumet är exkluderat. Ögonblicksrapporter använder aktuella innehav. Ladda ned PDF-filen för att visa eller skriva ut den. + Startdatum (UTC, inkluderat) + Slutdatum (UTC, exkluderat) + Ladda ned utskrivbar PDF + Schemalägg rapportutskick + Lagervarningar + Lagret behöver ses över. Logga in för att granska aktuella varningar. + Aktuellt lagersaldo + Lageranvändning + Lagrets utgångsdatum + Låga lagernivåer + Historik över lageröverföringar + Historik över lagerutlämningar + Lagervärdering + Journal över kontrollerade substanser + Dagar till utgångsdatum + Antal under beställningspunkten + Föreslaget beställningsantal + Utlämnings-ID + Mottagande persons ID + Mottagande enhets ID + Utlämnat (UTC) + Återlämnat antal + Antal att återlämna + Återlämnat (UTC) + Utfört av (användar-ID) + Återför transaktion + Användningskälla + Post-ID + Larm-ID + ID för ändrad revision + DEA-klassificering + Avsändande saldo före + Avsändande saldo efter + Mottagande saldo före + Mottagande saldo efter + Vittne (användar-ID) + Intygat (UTC) + Lagret innehåller skyddade uppgifter. Öppna rapportlänken som kräver inloggning och verifiera din identitet för att visa eller skriva ut aktuella resultat. Denna schemalagda PDF innehåller inga lagervärden. + Den här rapporten innehåller endast inventarier och lagerplatser som du för närvarande har behörighet att visa. Antal och värden avser den angivna ögonblicksbilden. Resultaten är begränsade till 5 000 rader. + UTC-intervall (inkluderad start, exkluderat slut) + Varje lagerrörelse visas en gång. Positiva antal registrerar utgående användning och negativa antal återför den. Postuppgifter kräver åtkomst till källan. Importerade utgående rörelser behåller sina äldre identifierare och skapar ingen koppling till en logg eller patient. + Aktiva artiklar listas när synligt mängdhanterat lager eller innehav med serienummer ligger på eller under beställningspunkten. Om ingen beställningspunkt finns används miniminivån. Innehav med serienummer omfattar utrustning i drift, utlämnad, skadad och under reparation. Dolda lagerplatser utesluts. + Raderna visar utlämningar som skapats inom det valda datumintervallet och deras aktuella återlämningsstatus. För att se antal som återstår att lämna tillbaka från äldre utlämningar måste du välja ett tidigare startdatum. + Raderna visar aktuella positiva lagersaldon och aktiva innehav med serienummer vars utgångsdatum ligger inom det valda intervallet. Som standard ingår redan utgånget lager och lager som förfaller under de kommande 30 dagarna. + Utförarens underskrift + Vittnets underskrift + Inte registrerat + Lagerrörelse utan åtkomlig postlänk + Poster + Äldre lagerrörelse + Ange giltiga inventeringsuppgifter och antal. + Välj en mindre omfattning. Inventeringar stöder upp till 100 lagerpositioner. + Det finns inga aktiva lagerpositioner att inventera inom denna omfattning. + Den här inventeringen finns redan. Öppna den från inventeringslistan. + Inventeringen har ändrats eller innehåller ofullständiga observationer. Läs in den igen och spara alla antal innan du slutför den. + Lagret har ändrats sedan denna ögonblicksbild. Avbryt inventeringen och starta en ny. + Inventeringsjusteringar hör till den slutförda inventeringen. Starta en ny inventering för att korrigera lagret. + Bokför inventeringsavvikelser genom att slutföra inventeringen. + Välj ett giltigt UTC-datumintervall på högst 366 dagar. + Välj giltiga rapportfilter. + Rapporten ändrades medan PDF-filen skapades. Skapa den igen. + Det gick inte att skapa rapporten. Försök igen. + Lågt lagersaldo + Lager med nära utgångsdatum + Inventering slutförd + Försenad återlämning + Den här resursen refereras i lagerhistoriken och måste behållas. + Lager justerat + Lageröverföring slutförd + Lager utlämnat + Lager återlämnat + Status för inventariet ändrad + Kontrollerad substans registrerad - diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx index 1bf44b00e..f30c4e656 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx @@ -472,5 +472,179 @@ Так + Використання запасів + Збереження використання негайно змінює залишок запасів. Ці зміни зберігаються, якщо відмовитися від чернетки або зміни звіту. Явно сторнуйте помилкове використання, а потім зареєструйте заміну. Відкрийте зміну звіту, перш ніж змінювати використання в завершеному звіті. + Витрачання та сторнування контрольованих товарів потребують незалежного свідка в розділі «Запаси». Спочатку завершіть цю процедуру, а потім прив’яжіть тут ідентифікатор створеної транзакції. + Зареєстроване використання + Тип використання + Використано + Витрачено на пацієнта + Залишено на місці події + Пошкоджено + Запис у журналі руху + Зареєстровано (UTC) + Сторнує використання + Сторновано + Сторнувати використання + Ця дія реєструє повне сторнування та зберігає початкове використання в історії. Щоб виправити кількість або товар, сторнуйте використання й зареєструйте заміну. Якщо сторнування вже підтверджене свідком у розділі «Запаси», укажіть ідентифікатор його транзакції, щоб прив’язати його без повторної зміни запасів. + Причина коригування + Ідентифікатор наявного сторнування, підтвердженого свідком (необов’язково) + Додатні кількості реєструють використання, від’ємні — сторнують його. Відкрийте запис у журналі, щоб переглянути відомості про рух відповідно до поточних прав доступу до запасів. + Ідентифікатор наявної транзакції витрачання (необов’язково) + Укажіть завершену транзакцію витрачання з розділу «Запаси», щоб прив’язати її товар, кількість, місце та примітку. Поля нового витрачання нижче ігноруються, і запаси не списуються повторно. Виберіть тип використання вище. + Нове витрачання + Використати підрозділ або станцію зі звіту + Залиште порожнім, щоб використати залучений підрозділ зі звіту, а за потреби — його станцію. Явно виберіть джерело, якщо звіт має кілька можливих місць. + Зберегти використання + Повернутися до звіту + Оновити підтвердні матеріали зареєстрованого використання + Використання збережено, підтвердні матеріали зафіксовано. + Операцію із запасами збережено. Не вдалося зафіксувати підтвердні матеріали. Скористайтеся дією «Оновити підтвердні матеріали» після усунення проблем із доступом або змінами чернетки. Не надсилайте операцію із запасами повторно. + Чернетку змінено. Перевірте зареєстроване використання, перш ніж надсилати іншу операцію із запасами. + Завершіть процедуру з незалежним свідком у розділі «Запаси», а потім прив’яжіть тут ідентифікатор створеної транзакції. Ця дія не зареєструвала руху запасів. + Підтвердні матеріали зафіксовано. + Сторнування в журналі ще не прив’язане до цього звіту. Прив’яжіть його як коригування перед завершенням звіту. + Закупівлі + Постачальники + Замовлення на закупівлю + Оцінка запасів + Для постачальників використовуються записи компаній із розділу «Контакти». Створіть або оновіть дані компанії в розділі «Контакти», а потім пов’яжіть її тут. + Номер рахунку в постачальника + Компанія з розділу «Контакти» + Додати постачальника + Номер замовлення на закупівлю + Постачальник + Валюта + Переглянути замовлення на закупівлю + Чернетка + Замовлено + Частково отримано + Отримано + Скасовано + Замовлена кількість + Отримана кількість + Залишок до отримання + Позначення замовлення як замовленого блокує його позиції. Скасування унеможливлює подальші надходження та зберігає вже отримані кількості. + Позначити як замовлене + Скасувати замовлення + Прийняти товари + Прийміть усе замовлення або його частину. Видаліть позиції, яких немає в цій поставці. Якщо потрібен незалежний свідок, залишки запасів та отримані кількості зміняться лише після його підтвердження в розділі «Запаси». + Використовуйте окремий рядок для кожного активу із серійним номером, із кількістю 1 та його власним серійним номером. Додайте рядки, щоб прийняти більше активів або розподілити кількості між місцями зберігання чи партіями. + Кількість до приймання + Видалити позицію + Надходження + Нове замовлення на закупівлю + Оцінка охоплює лише запаси та місця зберігання, які ви маєте право переглядати. Підсумки подаються окремо для кожної валюти. Рядки з невідомою вартістю враховуються окремо та не включаються до підсумків відомої вартості. + Станом на (UTC) + Деякі видимі залишки запасів від’ємні. Перевірте їх, перш ніж покладатися на цю оцінку. + Відома вартість + Рядки без вартості + Валюту не вказано + Невідома вартість + Середня вартість одиниці + Бажаний постачальник + Використовуйте одну валюту для кожного товару. Наявні запаси без валюти залишаються окремо в оцінці; призначення валюти не перераховує вартість. + Отримано замовлення на закупівлю запасів + Інвентаризації, сповіщення та звіти + Інвентаризації запасів + Сповіщення про запаси + Звіти про запаси + Створіть знімок стану не більш ніж 100 позицій запасів. Укажіть кожну кількість, збережіть і завершіть інвентаризацію. Якщо запаси або дані каталогу зміняться, скасуйте її та почніть нову. Обладнання із серійними номерами, виявлене вперше, реєструйте через «Отримання». + Усі місця зберігання + Почати інвентаризацію + Завершити інвентаризацію + Скасувати інвентаризацію + Час знімка стану (UTC) + Очікувана кількість + Підрахована кількість + Розбіжність + Рядки з розбіжностями + Чернетка + Очікує на свідка + Завершено + Скасовано + Незалежний свідок має схвалити очікувану операцію в історії запасів. Знімок стану має залишатися актуальним. + Сповіщення показують низький рівень запасів, закінчення терміну придатності протягом 30 днів, прострочені запаси та затримані повернення. Оновіть їх один раз після запуску функції, щоб створити сповіщення про низький рівень запасів, а також після імпорту старих запасів. Подальші зміни запасів і товарів оновлюватимуть їх автоматично. + Оновити сповіщення + Низький рівень запасів + Термін придатності скоро закінчиться + Термін придатності закінчився + Повернення прострочене + Відкрите + Усунено + Кінцевий термін (UTC) + Виберіть звіт і, за потреби, фільтри. Дати вказуються в UTC; кінцева дата не включається в діапазон. Звіти зі знімком стану використовують поточні запаси. Завантажте PDF для перегляду або друку. + Від дати (UTC, включно) + До дати (UTC, не включно) + Завантажити PDF для друку + Запланувати надсилання звітів + Сповіщення про запаси + Запаси потребують уваги. Увійдіть, щоб переглянути поточні сповіщення. + Наявні запаси + Використання запасів + Терміни придатності запасів + Низький рівень запасів + Історія переміщень запасів + Історія видачі запасів + Оцінка запасів + Журнал контрольованих речовин + Днів до закінчення терміну придатності + Кількість, якої бракує до точки повторного замовлення + Рекомендована кількість для повторного замовлення + Ідентифікатор видачі + Ідентифікатор особи-одержувача + Ідентифікатор підрозділу-одержувача + Час видачі (UTC) + Повернена кількість + Неповернена кількість + Час повернення (UTC) + Виконавець (ідентифікатор користувача) + Сторнує операцію + Джерело використання + Ідентифікатор запису + Ідентифікатор виклику + Ідентифікатор зміненої редакції + Категорія контролю DEA + Залишок у джерелі до операції + Залишок у джерелі після операції + Залишок у місці призначення до операції + Залишок у місці призначення після операції + Свідок (ідентифікатор користувача) + Час підтвердження свідком (UTC) + Запаси містять захищені дані. Відкрийте посилання на звіт, яке вимагає входу, і підтвердьте свою особу, щоб переглянути або надрукувати поточні результати. Цей запланований PDF не містить значень запасів. + Цей звіт містить лише запаси та місця зберігання, які ви зараз маєте право переглядати. Кількості та вартості описують указаний знімок стану; результати обмежено 5 000 рядками. + Діапазон UTC (початок включається, кінець не включається) + Кожен рух запасів відображається один раз. Додатні кількості позначають вихідне використання, а від’ємні його сторнують. Для перегляду деталей запису потрібен доступ до джерела. Імпортовані вихідні рухи зберігають свою стару ідентичність і не створюють зв’язку з журналом або пацієнтом. + Активні товари відображаються, коли видимі запаси з кількісним обліком або активи із серійними номерами перебувають на рівні точки повторного замовлення чи нижче. Якщо її не задано, використовується мінімальний запас. До активів із серійними номерами належать активи в експлуатації, видані, пошкоджені та в ремонті. Приховані місця зберігання виключаються. + Рядки показують видачі, створені у вибраному діапазоні дат, і поточний стан їх повернення. Щоб побачити неповернені кількості за давнішими видачами, виберіть ранішу дату початку. + Рядки показують поточні додатні залишки та активні активи із серійними номерами, термін придатності яких закінчується у вибраному діапазоні. За замовчуванням включаються вже прострочені запаси та запаси, термін придатності яких закінчиться протягом наступних 30 днів. + Підпис виконавця + Підпис свідка + Не записано + Рух запасів без доступного посилання на запис + Записи + Рух запасів зі старої системи + Укажіть коректні дані інвентаризації та кількості. + Виберіть менший обсяг. Інвентаризація підтримує не більш ніж 100 позицій запасів. + У вибраному обсязі немає активних позицій запасів для інвентаризації. + Ця інвентаризація вже існує. Відкрийте її зі списку інвентаризацій. + Інвентаризація змінилася або містить неповні результати підрахунку. Завантажте її повторно та збережіть усі кількості перед завершенням. + Запаси змінилися після цього знімка стану. Скасуйте цю інвентаризацію та почніть нову. + Коригування інвентаризації належать відповідній завершеній інвентаризації. Почніть нову, щоб виправити запаси. + Проведіть розбіжності, завершивши інвентаризацію запасів. + Виберіть коректний діапазон дат UTC тривалістю не більш ніж 366 днів. + Виберіть коректні фільтри звіту. + Звіт змінився під час створення PDF. Створіть його ще раз. + Не вдалося створити звіт. Спробуйте ще раз. + Низький рівень запасів + Наближається закінчення терміну придатності запасів + Інвентаризацію завершено + Прострочене повернення майна + На цей ресурс є посилання в історії запасів, тому його потрібно зберегти. + Запаси скориговано + Переміщення запасів завершено + Запаси видано + Запаси повернуто + Стан інвентарного активу змінено + Контрольовану речовину зареєстровано - diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs index 31e51a6d9..e764ddb41 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs @@ -15,9 +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, - 22, 58, 59, 60, 64, 66 - }); + (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped + }.Concat(Resgrid.Model.Inventories.InventoryWorkflowPayload.Triggers).ToArray()); private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" }; private static bool IsStructuralTarget(int type, string target) => type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0 diff --git a/Core/Resgrid.Model/Inventories/InventoryContracts.cs b/Core/Resgrid.Model/Inventories/InventoryContracts.cs index a502e688e..9617f6821 100644 --- a/Core/Resgrid.Model/Inventories/InventoryContracts.cs +++ b/Core/Resgrid.Model/Inventories/InventoryContracts.cs @@ -35,16 +35,20 @@ public sealed class InventoryPosting public string ReferenceId { get; set; } public string ReversesTransactionId { get; set; } public string IssuanceId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string UsageId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public InventoryUsageType? UsageType { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string PurchaseOrderItemId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string CountItemId { 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 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 string PurchaseOrderId { get; set; } } 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 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 InventoryPurchaseReceiptInput PendingPurchaseReceipt { get; set; } public InventoryCountComplete PendingCount { 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(); } diff --git a/Core/Resgrid.Model/Inventories/InventoryModels.cs b/Core/Resgrid.Model/Inventories/InventoryModels.cs index 810234801..06ec1e87a 100644 --- a/Core/Resgrid.Model/Inventories/InventoryModels.cs +++ b/Core/Resgrid.Model/Inventories/InventoryModels.cs @@ -92,6 +92,8 @@ public sealed class InventoryTransaction : InventoryRow public string ReferenceId { get; set; } public string ReversesTransactionId { get; set; } public string IssuanceId { get; set; } + public string PurchaseOrderItemId { get; set; } + public string CountItemId { get; set; } public int? LegacyInventoryId { get; set; } public DateTime OccurredOn { get; set; } } @@ -132,6 +134,9 @@ public sealed class InventoryItemContent public decimal? ReorderPoint { get; set; } public decimal? ReorderQuantity { get; set; } public decimal? DefaultUnitCost { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public decimal? AverageUnitCost { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string CurrencyCode { get; set; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string PreferredVendorId { 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; } } @@ -139,12 +144,19 @@ public sealed class InventoryAssetContent { public string SerialNumber { get; se public static class InventoryTables { public const int CatalogVersion = 19; + public const int UsageCatalogVersion = 20; + public const int PurchasingCatalogVersion = 21; + public const int OperationsCatalogVersion = 22; 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" + [typeof(InventoryIssuance)] = "InventoryIssuances", [typeof(InventoryKit)] = "InventoryKits", [typeof(InventoryKitItem)] = "InventoryKitItems", + [typeof(RecordInventoryUsage)] = "RecordInventoryUsages", + [typeof(InventoryVendor)] = "InventoryVendors", [typeof(InventoryPurchaseOrder)] = "InventoryPurchaseOrders", [typeof(InventoryPurchaseOrderItem)] = "InventoryPurchaseOrderItems", + [typeof(InventoryCount)] = "InventoryCounts", [typeof(InventoryCountItem)] = "InventoryCountItems", + [typeof(InventoryAlert)] = "InventoryAlerts", [typeof(InventoryAlertDelivery)] = "InventoryAlertDeliveries" }; 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/InventoryOperations.cs b/Core/Resgrid.Model/Inventories/InventoryOperations.cs new file mode 100644 index 000000000..9aee10124 --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryOperations.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Inventories +{ + public enum InventoryCountStatus { Draft = 0, AwaitingWitness = 1, Completed = 2, Cancelled = 3 } + public enum InventoryAlertType { LowStock = 0, ExpiringSoon = 1, Expired = 2, OverdueReturn = 3 } + public sealed class InventoryCount : InventoryMutableRow + { + public string LocationId { get; set; } + public int Status { get; set; } + public string SnapshotFingerprint { get; set; } + public DateTime SnapshotOn { get; set; } + public DateTime? CompletedOn { get; set; } + public string OperationId { get; set; } + } + public sealed class InventoryCountItem : InventoryRow + { + public string CountId { get; set; } + public string ItemId { get; set; } + public string LocationId { get; set; } + public string LotId { get; set; } + public string AssetId { get; set; } + public decimal ExpectedQuantity { get; set; } + public decimal? CountedQuantity { get; set; } + public string TransactionId { get; set; } + } + public sealed class InventoryCountContent + { + public string Name { get; set; } + public string Note { get; set; } + public int VarianceLineCount { get; set; } + public List Totals { get; set; } = new(); + } + public sealed class InventoryCountItemContent + { + public string ItemName { get; set; } + public string UnitOfMeasure { get; set; } + public string SerialNumber { get; set; } + public string LotNumber { get; set; } + public decimal? UnitCost { get; set; } + public string CurrencyCode { get; set; } + } + public sealed class InventoryCountInput { public string Id { get; set; } public string LocationId { get; set; } public string Name { get; set; } public string Note { get; set; } } + public sealed class InventoryCountObservation { public string Id { get; set; } public decimal Quantity { get; set; } } + public sealed class InventoryCountUpdate { public string CountId { get; set; } public int Revision { get; set; } public List Lines { get; set; } = new(); } + public sealed class InventoryCountComplete { public string CountId { get; set; } public int Revision { get; set; } public string RequestId { get; set; } } + public sealed class InventoryCountDetail { public InventoryCount Count { get; set; } public List Lines { get; set; } = new(); } + /// Content is empty: unattended evaluation uses dates/statuses and attended low-stock projections only. + public sealed class InventoryAlert : InventoryRow + { + public int AlertType { get; set; } + public string DedupKey { get; set; } + public string ItemId { get; set; } + public string LocationId { get; set; } + public string LotId { get; set; } + public string AssetId { get; set; } + public string IssuanceId { get; set; } + public int Status { get; set; } + public DateTime OpenedOn { get; set; } + public DateTime? ResolvedOn { get; set; } + public DateTime? DueOn { get; set; } + public decimal? Quantity { get; set; } + } + public sealed class InventoryAlertDelivery : InventoryRow + { + public string AlertId { get; set; } + public string UserId { get; set; } + public int State { get; set; } + public DateTime NextAttemptOn { get; set; } + public DateTime? LeaseUntil { get; set; } + public string ClaimToken { get; set; } + public int AttemptCount { get; set; } + public DateTime? HandedOffOn { get; set; } + } + public enum InventoryReportKind { OnHand = 0, Usage = 1, Expiration = 2, LowStock = 3, TransferHistory = 4, Issuance = 5, Valuation = 6, ControlledSubstanceLog = 7 } + public sealed class InventoryReportInput + { + public InventoryReportKind Kind { get; set; } + public DateTime? FromUtc { get; set; } + public DateTime? UntilUtc { get; set; } + public string LocationId { get; set; } + public string ItemId { get; set; } + public string UserId { get; set; } + public int? UnitId { get; set; } + } + public sealed class InventoryReport + { + public InventoryReportKind Kind { get; set; } + public DateTime GeneratedOn { get; set; } + public DateTime? FromUtc { get; set; } + public DateTime? UntilUtc { get; set; } + public List Columns { get; set; } = new(); + public List> Rows { get; set; } = new(); + public List Totals { get; set; } = new(); + } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryPurchasing.cs b/Core/Resgrid.Model/Inventories/InventoryPurchasing.cs new file mode 100644 index 000000000..5c379e02b --- /dev/null +++ b/Core/Resgrid.Model/Inventories/InventoryPurchasing.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Inventories +{ + public enum InventoryPurchaseOrderStatus { Draft = 0, Ordered = 1, PartiallyReceived = 2, Received = 3, Cancelled = 4 } + public sealed class InventoryVendor : InventoryMutableRow { public string ContactId { get; set; } } + public sealed class InventoryVendorContent { public string AccountNumber { get; set; } public string Note { get; set; } } + public sealed class InventoryPurchaseOrder : InventoryMutableRow + { + public string VendorId { get; set; } + public int Status { get; set; } + public string CurrencyCode { get; set; } + public DateTime? OrderedOn { get; set; } + public DateTime? ReceivedOn { get; set; } + } + public sealed class InventoryPurchaseOrderContent { public string Number { get; set; } public string Note { get; set; } public string SupplierName { get; set; } } + public sealed class InventoryPurchaseOrderItem : InventoryMutableRow + { + public string PurchaseOrderId { get; set; } + public string ItemId { get; set; } + public int LineNumber { get; set; } + public decimal QuantityOrdered { get; set; } + public decimal QuantityReceived { get; set; } + } + public sealed class InventoryPurchaseOrderItemContent { public string ItemName { get; set; } public string UnitOfMeasure { get; set; } public decimal UnitCost { get; set; } public string Note { get; set; } } + public sealed class InventoryVendorInput { public string Id { get; set; } public int Revision { get; set; } public string ContactId { get; set; } public InventoryVendorContent Details { get; set; } = new(); } + public sealed class InventoryPurchaseOrderInput + { + public string Id { get; set; } + public int Revision { get; set; } + public string VendorId { get; set; } + public string CurrencyCode { get; set; } + public string Number { get; set; } + public string Note { get; set; } + public List Lines { get; set; } = new(); + } + public sealed class InventoryPurchaseOrderLineInput { public string Id { get; set; } public string ItemId { get; set; } public decimal QuantityOrdered { get; set; } public decimal UnitCost { get; set; } public string Note { get; set; } } + public sealed class InventoryPurchaseOrderChange { public string Id { get; set; } public int Revision { get; set; } public string RequestId { get; set; } } + public sealed class InventoryPurchaseReceiptInput { public string PurchaseOrderId { get; set; } public int Revision { get; set; } public string RequestId { get; set; } public List Lines { get; set; } = new(); } + public sealed class InventoryPurchaseReceiptLine + { + public string PurchaseOrderItemId { get; set; } + public string LocationId { get; set; } + public string LotId { get; set; } + public decimal Quantity { get; set; } + public InventoryPurchaseAssetInput Asset { get; set; } + } + public sealed class InventoryPurchaseAssetInput { public string SerialNumber { get; set; } public string AssetTag { get; set; } public string Barcode { get; set; } public DateTime? ExpiresOn { get; set; } } + public sealed class InventoryPurchaseOrderDetail { public InventoryPurchaseOrder Order { get; set; } public List Lines { get; set; } = new(); public List Receipts { get; set; } = new(); } + public sealed class InventoryVendorChoice { public string Id { get; set; } public string Name { get; set; } } + public sealed class InventoryValuationLine + { + public string ItemId { get; set; } public string AssetId { get; set; } public string LotId { get; set; } public string LocationId { get; set; } + public string ItemName { get; set; } public string CurrencyCode { get; set; } public decimal Quantity { get; set; } + public decimal? UnitCost { get; set; } public decimal? Value { get; set; } + } + public sealed class InventoryValuationTotal { public string CurrencyCode { get; set; } public decimal KnownValue { get; set; } public int UncostedRows { get; set; } } + public sealed class InventoryValuation { public List Lines { get; set; } = new(); public List Totals { get; set; } = new(); public bool HasNegativeStock { get; set; } public DateTime AsOfUtc { get; set; } } +} diff --git a/Core/Resgrid.Model/Inventories/InventoryQuery.cs b/Core/Resgrid.Model/Inventories/InventoryQuery.cs index 4a2f7df6e..f41646e23 100644 --- a/Core/Resgrid.Model/Inventories/InventoryQuery.cs +++ b/Core/Resgrid.Model/Inventories/InventoryQuery.cs @@ -1,5 +1,12 @@ namespace Resgrid.Model.Inventories { + public sealed class InventoryStockQuantity + { + public string ItemId { get; set; } + public string LocationId { get; set; } + public decimal Quantity { get; set; } + } + /// Structural filters applied before paging; authored content is never queried outside its protected read boundary. public sealed class InventoryQuery { @@ -8,5 +15,8 @@ public sealed class InventoryQuery public string AssetId { get; set; } public string IssuedToUserId { get; set; } public string KitId { get; set; } + public string SourceId { get; set; } + public int? SourceType { get; set; } + public int? RecordKind { get; set; } } } diff --git a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs index 3beae500e..c8cf6eb08 100644 --- a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs +++ b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs @@ -12,11 +12,16 @@ 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 const int CatalogVersion = 22; + public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166 }); public static readonly (string Variable, string Property)[] Variables = { ("transaction_id", "TransactionId"), ("item_id", "ItemId"), ("asset_id", "AssetId"), ("lot_id", "LotId"), + ("usage_id", "UsageId"), ("usage_type", "UsageType"), + ("count_id", "CountId"), ("count_item_id", "CountItemId"), ("variance_line_count", "VarianceLineCount"), ("variance_value", "VarianceValue"), + ("alert_id", "AlertId"), ("alert_type", "AlertType"), ("location_id", "LocationId"), ("due_on", "DueOn"), + ("purchase_order_id", "PurchaseOrderId"), ("purchase_order_item_id", "PurchaseOrderItemId"), ("vendor_id", "VendorId"), + ("receipt_id", "ReceiptId"), ("purchase_order_status", "PurchaseOrderStatus"), ("line_count", "LineCount"), ("currency_code", "CurrencyCode"), ("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"), @@ -24,8 +29,8 @@ public static readonly (string Variable, string Property)[] Variables = ("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" }; + private static readonly string[] GuidFields = { "TransactionId", "ItemId", "AssetId", "LotId", "TransferId", "IssuanceId", "FromLocationId", "ToLocationId", "ReversesTransactionId", "UsageId", "PurchaseOrderId", "PurchaseOrderItemId", "VendorId", "ReceiptId", "CountId", "CountItemId", "AlertId", "LocationId" }; + private static readonly string[] WithheldFields = { "ItemName", "Note", "SerialNumber", "WitnessUserId", "VarianceValue" }; public static bool IsInventory(JObject payload) => payload?["InventoryEvent"]?.Type == JTokenType.Boolean && payload["InventoryEvent"].Value(); public static bool IsInventory(int trigger) => Triggers.Contains(trigger); @@ -69,6 +74,10 @@ public static string Routing(JObject payload) 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); + CopyInteger(payload, safe, "UsageType", 3); + CopyInteger(payload, safe, "PurchaseOrderStatus", 4); CopyInteger(payload, safe, "LineCount", 100); + CopyInteger(payload, safe, "VarianceLineCount", 100); CopyInteger(payload, safe, "AlertType", 3); CopyTimestamp(payload, safe, "DueOn"); + if (payload["CurrencyCode"]?.Type == JTokenType.String && payload.Value("CurrencyCode") is { Length: 3 } currency && currency.All(c => c >= 'A' && c <= 'Z')) safe["CurrencyCode"] = currency; foreach (var name in new[] { "Quantity", "FromQuantityBefore", "FromQuantityAfter", "ToQuantityBefore", "ToQuantityAfter" }) { var token = payload[name]; @@ -96,7 +105,7 @@ private static JObject Envelope(JObject source) 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; + if (aggregate is "InventoryTransaction" or "InventoryItem" or "InventoryAsset" or "InventoryTransfer" or "InventoryIssuance" or "InventoryPurchaseOrder" or "InventoryCount" or "InventoryAlert") 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(); diff --git a/Core/Resgrid.Model/Inventories/RecordInventoryUsage.cs b/Core/Resgrid.Model/Inventories/RecordInventoryUsage.cs new file mode 100644 index 000000000..3bf1264b0 --- /dev/null +++ b/Core/Resgrid.Model/Inventories/RecordInventoryUsage.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace Resgrid.Model.Inventories +{ + public enum InventoryUsageSourceType { LegacyLog = 0, RmsRecord = 1 } + public enum InventoryUsageType { Used = 0, ConsumedOnPatient = 1, LeftAtScene = 2, Damaged = 3 } + + /// Immutable source-owned usage. Corrections are separate rows and ledger entries; Records freezes references in its revision evidence. + public sealed class RecordInventoryUsage : InventoryRow + { + public int SourceType { get; set; } + public string SourceId { get; set; } + public int? RecordKind { get; set; } + public int? CallId { get; set; } + /// The revision being amended when posted; null for an initial draft. Final revision membership is frozen by Records evidence. + public string RmsRevisionId { get; set; } + public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string SourceLocationId { get; set; } + public decimal Quantity { get; set; } + public int UsageType { get; set; } + public string TransactionId { get; set; } + public string ReversesUsageId { get; set; } + } + + public sealed class RecordInventoryUsageLine + { + public string ItemId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string LocationId { get; set; } + public decimal Quantity { get; set; } + public int? ExpectedAssetRevision { get; set; } + public InventoryUsageType UsageType { get; set; } + public string Note { get; set; } + /// Attach an already witnessed, unclaimed Inventory consumption without posting it again. + public string ExistingTransactionId { get; set; } + } + public sealed class RecordInventoryUsageRequest + { + public string RequestId { get; set; } + public List Lines { get; set; } = new(); + } + public sealed class RecordInventoryUsageCorrection + { + public string RequestId { get; set; } + public string UsageId { get; set; } + public string Reason { get; set; } + /// For controlled stock, attach the independently witnessed reversal from Inventory. + public string ExistingTransactionId { get; set; } + } +} diff --git a/Core/Resgrid.Model/ReportTypes.cs b/Core/Resgrid.Model/ReportTypes.cs index 227cbc84d..ca0d049c1 100644 --- a/Core/Resgrid.Model/ReportTypes.cs +++ b/Core/Resgrid.Model/ReportTypes.cs @@ -7,6 +7,14 @@ public enum ReportTypes Certifications = 2, ShiftReadiness = 3, ChecklistCompliance = 4, - ChecklistMissed = 5 + ChecklistMissed = 5, + InventoryOnHand = 6, + InventoryUsage = 7, + InventoryExpiration = 8, + InventoryLowStock = 9, + InventoryTransferHistory = 10, + InventoryIssuance = 11, + InventoryValuation = 12, + ControlledSubstanceLog = 13 } -} \ No newline at end of file +} diff --git a/Core/Resgrid.Model/Repositories/IInventoryStore.cs b/Core/Resgrid.Model/Repositories/IInventoryStore.cs index e7e2ac450..27d636197 100644 --- a/Core/Resgrid.Model/Repositories/IInventoryStore.cs +++ b/Core/Resgrid.Model/Repositories/IInventoryStore.cs @@ -8,6 +8,11 @@ namespace Resgrid.Model.Repositories public interface IInventoryStore { Task LockDepartmentAsync(int departmentId); + Task> AlertDepartmentsAsync(int afterDepartmentId); + Task LastEntryAsync(int departmentId); + Task OpenAlertAsync(int departmentId, string dedupKey); + Task AlertDeliveryAsync(int departmentId, string alertId, string userId); + Task> OpenAlertsAsync(int departmentId, int skip = 0); 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; @@ -19,6 +24,8 @@ public interface IInventoryStore Task LegacyItemAsync(int departmentId, int typeId); Task LegacyTransactionAsync(int departmentId, int inventoryId); Task RebuildStocksAsync(int departmentId); + Task> StockQuantitiesAsync(int departmentId, IReadOnlyCollection itemIds); + Task> AssetHistoryAsync(int departmentId, IReadOnlyCollection assetIds, System.DateTime at, bool after, int skip = 0); Task HasLegacyMigrationAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs index 3ac1da372..8f2e30e8c 100644 --- a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs +++ b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs @@ -14,14 +14,18 @@ public interface IInventoryCatalogService 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 GetLegacyTransactionAsync(InventoryActor actor, int inventoryId); 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> GetVisibleQuantitiesAsync(InventoryActor actor, IReadOnlyCollection itemIds); 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); + /// The authorized source adapter owns the active transaction and source lifecycle checks. Does not post stock again. + Task RecordUsageWithinTransactionAsync(InventoryActor actor, RecordInventoryUsage usage); Task WitnessAsync(InventoryActor actor, string requestId, string attestation); Task RebuildStocksAsync(InventoryActor actor); Task> GetByReferenceAsync(InventoryActor actor, InventoryReferenceType type, string id); diff --git a/Core/Resgrid.Model/Services/IInventoryOperationsService.cs b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs new file mode 100644 index 000000000..6c7136626 --- /dev/null +++ b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; + +namespace Resgrid.Model.Services +{ + public interface IInventoryOperationsService + { + Task StartCountAsync(InventoryActor actor, InventoryCountInput input); + Task GetCountAsync(InventoryActor actor, string id); + Task SaveCountAsync(InventoryActor actor, InventoryCountUpdate input); + Task CompleteCountAsync(InventoryActor actor, InventoryCountComplete input); + Task CancelCountAsync(InventoryActor actor, string id, int revision); + Task RefreshAlertsAsync(InventoryActor actor); + Task BuildReportAsync(InventoryActor actor, InventoryReportInput input); + } + public interface IInventoryAlertService + { + Task> AlertDepartmentsAsync(int afterDepartmentId); + Task SweepAlertsAsync(int departmentId); + Task ClaimAlertAsync(int departmentId, string userId); + Task CanReceiveAlertAsync(int departmentId, string userId, string alertId); + Task FinishAlertAsync(int departmentId, string deliveryId, string claimToken, bool handedOff); + } +} diff --git a/Core/Resgrid.Model/Services/IInventoryPurchasingService.cs b/Core/Resgrid.Model/Services/IInventoryPurchasingService.cs new file mode 100644 index 000000000..89a88c5fe --- /dev/null +++ b/Core/Resgrid.Model/Services/IInventoryPurchasingService.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Inventories; + +namespace Resgrid.Model.Services +{ + public interface IInventoryPurchasingService + { + Task> GetVendorContactsAsync(InventoryActor actor); + Task SaveVendorAsync(InventoryActor actor, InventoryVendorInput input); + Task GetPurchaseOrderAsync(InventoryActor actor, string id); + Task SavePurchaseOrderAsync(InventoryActor actor, InventoryPurchaseOrderInput input); + Task ChangePurchaseOrderStatusAsync(InventoryActor actor, InventoryPurchaseOrderChange input, InventoryPurchaseOrderStatus status); + Task ReceivePurchaseOrderAsync(InventoryActor actor, InventoryPurchaseReceiptInput input); + Task GetValuationAsync(InventoryActor actor, string locationId = null); + } +} diff --git a/Core/Resgrid.Model/Services/IInventoryScheduledReportService.cs b/Core/Resgrid.Model/Services/IInventoryScheduledReportService.cs new file mode 100644 index 000000000..fbd578626 --- /dev/null +++ b/Core/Resgrid.Model/Services/IInventoryScheduledReportService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IInventoryScheduledReportService + { + Task BuildAsync(ScheduledTask queuedTask); + /// Revalidates the successfully built delivery for this same queued task immediately before email handoff. + Task ValidateAsync(ScheduledTask queuedTask); + } +} diff --git a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs index 2e360baf2..23fab8edd 100644 --- a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs @@ -20,6 +20,14 @@ public class RmsInventoryUsage public int InventoryId { get; set; } public string TransactionId { get; set; } public string ItemId { get; set; } + public string UsageId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string LocationId { get; set; } + public InventoryUsageType UsageType { get; set; } + public string ReversesUsageId { get; set; } + public bool IsReversed { get; set; } + public string PendingReversalTransactionId { get; set; } public decimal Quantity { get; set; } public string Note { get; set; } public string ItemName { get; set; } @@ -42,6 +50,11 @@ 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, string grantToken = null); Task ConsumeModernAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, InventoryCommand command, CancellationToken cancellationToken = default); + Task> RecordModernUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, RecordInventoryUsageRequest request, CancellationToken cancellationToken = default); + Task ReverseModernUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, RecordInventoryUsageCorrection correction, CancellationToken cancellationToken = default); + Task> GetAuthorizedUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind); + /// Checks the live source and fences its version in Inventory's active transaction before a new linked stock correction. + Task RequireUsageCorrectionAccessAsync(InventoryActor actor, string usageId); Task> GetUsageForRecordAsync(int departmentId, string recordId); Task> GetUsageForLegacyLogAsync(int departmentId, int logId); diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs index 13cdb3e25..9ddf6a8bd 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -17,7 +17,13 @@ public static string Routing(JObject payload) 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.Date) safe["DueOn"] = ((DateTimeOffset)payload["DueOn"]).UtcDateTime.ToString("O"); + if (payload["DueOn"] is JValue { Type: JTokenType.Date } date) + { + var utc = date.Value is DateTimeOffset offset ? offset.UtcDateTime + : date.Value is DateTime value && value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(value, DateTimeKind.Utc) + : ((DateTime)date.Value).ToUniversalTime(); + safe["DueOn"] = utc.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 002d4e656..ac372acb2 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -771,17 +771,22 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.InventoryIssued: case WorkflowTriggerEventType.InventoryReturned: case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.InventoryPurchaseOrderReceived: + case WorkflowTriggerEventType.InventoryLowStock: + case WorkflowTriggerEventType.InventoryExpiring: + case WorkflowTriggerEventType.InventoryCountCompleted: + case WorkflowTriggerEventType.InventoryReturnOverdue: 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", + "transaction_type" or "previous_status" or "status" or "reference_type" or "usage_type" or "purchase_order_status" or "line_count" or "variance_line_count" or "alert_type" => "int", "quantity" or "from_quantity_before" or "from_quantity_after" or "to_quantity_before" or "to_quantity_after" => "decimal", - "occurred_on" => "datetime", + "occurred_on" or "due_on" => "datetime", _ => "string" }; - list.Add(new TemplateVariableDescriptor("inventory." + pair.Variable, pair.Property + (pair.Variable == "item_name" ? "; always REDACTED" : string.Empty), type, false)); + list.Add(new TemplateVariableDescriptor("inventory." + pair.Variable, pair.Property + (pair.Variable is "item_name" or "variance_value" ? "; always REDACTED" : string.Empty), type, false)); } list.AddRange(new[] { diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index a8cc0f3c2..1f962201e 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -176,7 +176,12 @@ public enum WorkflowTriggerEventType InventoryTransferCompleted = 58, InventoryIssued = 59, InventoryReturned = 60, + InventoryLowStock = 61, + InventoryExpiring = 62, + InventoryCountCompleted = 63, + InventoryReturnOverdue = 166, InventoryAssetStatusChanged = 64, + InventoryPurchaseOrderReceived = 65, ControlledSubstanceRecorded = 66 } diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index d45e3fe60..df557ca8c 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -53,11 +53,11 @@ public GdprDataExportService( ICertificationService certificationService, ITrainingService trainingService, IShiftsService shiftsService, - IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders, IInventoryStore inventoryStore = null) + IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders, IInventoryStore inventoryStore) { _repository = repository; _workOrders = workOrders ?? throw new ArgumentNullException(nameof(workOrders)); - _inventoryStore = inventoryStore; + _inventoryStore = inventoryStore ?? throw new ArgumentNullException(nameof(inventoryStore)); _checklistReminders = checklistReminders ?? throw new ArgumentNullException(nameof(checklistReminders)); _userProfileService = userProfileService; _memberSensitiveDataService = memberSensitiveDataService; diff --git a/Core/Resgrid.Services/InventoryAlertNotifications.cs b/Core/Resgrid.Services/InventoryAlertNotifications.cs new file mode 100644 index 000000000..bdd9ffb31 --- /dev/null +++ b/Core/Resgrid.Services/InventoryAlertNotifications.cs @@ -0,0 +1,94 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Resources; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Localization; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// Hands off generic notices using routing metadata, without reading Inventory content or obtaining a grant. + public sealed class InventoryAlertNotifications + { + private readonly IInventoryAlertService _alerts; + private readonly IDepartmentsService _departments; + private readonly IUserProfileService _profiles; + private readonly IDepartmentSettingsService _settings; + private readonly ICommunicationService _communication; + private readonly TimeProvider _clock; + private static readonly ResourceManager Strings = new ResourceManager("Resgrid.Localization.Areas.User.Inventory.Inventory", typeof(SupportedLocales).Assembly); + + public InventoryAlertNotifications(IInventoryAlertService alerts, IDepartmentsService departments, IUserProfileService profiles, + IDepartmentSettingsService settings, ICommunicationService communication, TimeProvider clock = null) + { _alerts = alerts; _departments = departments; _profiles = profiles; _settings = settings; _communication = communication; _clock = clock ?? TimeProvider.System; } + + public async Task ProcessDepartmentAsync(int departmentId, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true); + var users = members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId)) + .Select(m => m.UserId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToList(); + var handedOff = 0; var failed = false; + foreach (var user in users) + { + // Core claim/access checks apply current Inventory permissions, including delegated managers. + for (var batch = 0; batch < 100; batch++) + { + ct.ThrowIfCancellationRequested(); + var delivery = await _alerts.ClaimAlertAsync(departmentId, user); + if (delivery == null) break; + if (delivery.DepartmentId != departmentId || delivery.UserId != user || string.IsNullOrEmpty(delivery.Id) + || string.IsNullOrEmpty(delivery.AlertId) || string.IsNullOrEmpty(delivery.ClaimToken)) + throw new InvalidOperationException("Inventory alert claim metadata is invalid."); + try + { + ct.ThrowIfCancellationRequested(); + var profile = await _profiles.GetProfileByUserIdAsync(user, true); + ct.ThrowIfCancellationRequested(); + var department = await _departments.GetDepartmentByIdAsync(departmentId, true); + var number = await _settings.GetTextToCallNumberForDepartmentAsync(departmentId); + var culture = Culture(profile?.Language); + var title = Strings.GetString("M5AlertNotificationTitle", culture) ?? "Inventory alerts"; + var message = (Strings.GetString("M5AlertNotificationMessage", culture) ?? "Inventory needs attention. Sign in to review current alerts.") + + " " + (Config.SystemBehaviorConfig.ResgridBaseUrl ?? "").TrimEnd('/') + "/User/Inventory/Operations?tab=Alerts"; + ct.ThrowIfCancellationRequested(); + // Slow routing/profile lookups precede the final current-state and permission gate. + var allowed = department?.DepartmentId == departmentId && await _alerts.CanReceiveAlertAsync(departmentId, user, delivery.AlertId); + ct.ThrowIfCancellationRequested(); + if (allowed && (!delivery.LeaseUntil.HasValue || delivery.LeaseUntil.Value <= _clock.GetUtcNow().UtcDateTime.AddSeconds(30))) + throw new InvalidOperationException("Inventory alert notification lease is expiring."); + var sent = allowed && await _communication.SendNotificationAsync(user, departmentId, message, number, department, title, profile); + // Persist an accepted handoff even if cancellation arrived during the provider call. + await _alerts.FinishAlertAsync(departmentId, delivery.Id, delivery.ClaimToken, sent); + if (sent) handedOff++; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + failed = true; + Resgrid.Framework.Logging.LogError($"Inventory alert notification failed for department {departmentId}: {ex.GetType().FullName}."); + // Completion is claim-token fenced. A completed or superseded handoff cannot be reset. + try { await _alerts.FinishAlertAsync(departmentId, delivery.Id, delivery.ClaimToken, false); } + catch { /* The lease recovers a failed release without storing exception or message content. */ } + } + } + } + ct.ThrowIfCancellationRequested(); + if (failed) throw new InvalidOperationException("Inventory alert notifications require a retry."); + return handedOff; + } + + private static CultureInfo Culture(string language) + { + try + { + var culture = CultureInfo.GetCultureInfo(language ?? "en"); + if (SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) return culture; + } + catch (CultureNotFoundException) { } + return CultureInfo.GetCultureInfo("en"); + } + } +} diff --git a/Core/Resgrid.Services/InventoryAlerts.cs b/Core/Resgrid.Services/InventoryAlerts.cs new file mode 100644 index 000000000..f50b71ab1 --- /dev/null +++ b/Core/Resgrid.Services/InventoryAlerts.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + public Task> AlertDepartmentsAsync(int afterDepartmentId) => _store.AlertDepartmentsAsync(afterDepartmentId); + private async Task AlertTransactionAsync(int departmentId, Func, Task> work) + { + if (_uow.Transaction != null) throw new InvalidOperationException("Alert processing owns its transaction."); + var events = new List(); T result; + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _store.LockDepartmentAsync(departmentId); + if (!await _auth.IsEnabledAsync(departmentId) || !await _store.HasLegacyMigrationAsync(departmentId)) { _uow.CommitChanges(); return default; } + result = await work(events); _uow.CommitChanges(); + } + catch { _uow.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events); return result; + } + private async Task UpdateAlertMetadataAsync(T row) where T : InventoryRow + { + if (row.Content != null) throw new InvalidOperationException("Alert metadata cannot contain protected content."); + row.ModifiedOn = Now; var revision = row.Revision++; await _store.UpdateAsync(row, revision); + } + private async Task RequireAlertAccessAsync(InventoryActor actor, InventoryAlert alert) + { + if (alert == null || alert.DepartmentId != actor.DepartmentId) throw new InventoryException(404, "Unavailable"); + if (alert.LocationId == null) + { + await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + // Department totals are disclosed only when every contributing holder is visible. + var holders = (await _store.RelatedAsync(actor.DepartmentId, "ItemId", alert.ItemId)).Where(s => !s.IsDeleted && s.Quantity != 0).Select(s => s.LocationId) + .Concat((await _store.RelatedAsync(actor.DepartmentId, "ItemId", alert.ItemId)).Where(a => !a.IsDeleted && a.Status is 0 or 1 or 2 or 3).Select(a => a.CurrentLocationId)).Distinct(); + foreach (var holder in holders) if (await LiveAlertLocationAsync(actor.DepartmentId, holder)) await LocationAsync(actor, holder); + } + else await LocationAsync(actor, alert.LocationId, false, PermissionTypes.AdjustInventory); + var item = await _store.GetAsync(actor.DepartmentId, alert.ItemId); + if (item == null) throw new InventoryException(404, "Unavailable"); + if (item.IsControlledSubstance) await _auth.RequireAsync(actor, false, PermissionTypes.ManageControlledSubstances); + } + private async Task SetAlertAsync(int departmentId, InventoryAlertType type, string itemId, string locationId, string lotId, string assetId, string issuanceId, bool active, DateTime? due, decimal? quantity, List events) + { + var key = Fingerprint(new { type, itemId, locationId, lotId, assetId, issuanceId }); + var current = await _store.OpenAlertAsync(departmentId, key); + if (!active) + { + if (current != null) { current.Status = 1; current.ResolvedOn = Now; await UpdateAlertMetadataAsync(current); } + return; + } + if (current != null) + { + if (current.Quantity != quantity || current.DueOn != due) { current.Quantity = quantity; current.DueOn = due; await UpdateAlertMetadataAsync(current); } + return; + } + var row = New(new InventoryActor { DepartmentId = departmentId }); + row.AlertType = (int)type; row.DedupKey = key; row.ItemId = itemId; row.LocationId = locationId; row.LotId = lotId; row.AssetId = assetId; row.IssuanceId = issuanceId; row.OpenedOn = Now; row.DueOn = due; row.Quantity = quantity; + await _store.InsertAsync(row); + var trigger = type == InventoryAlertType.LowStock ? WorkflowTriggerEventType.InventoryLowStock : type == InventoryAlertType.OverdueReturn ? WorkflowTriggerEventType.InventoryReturnOverdue : WorkflowTriggerEventType.InventoryExpiring; + var entry = await _outbox.EnqueueAsync(departmentId, "Inventory", new DomainEventEnvelope { EventName = trigger.ToString(), Trigger = trigger, SchemaVersion = 1, + AggregateType = "InventoryAlert", AggregateId = row.Id, OccurredOn = Now, + Payload = new { InventoryEvent = true, AlertId = row.Id, row.AlertType, row.ItemId, row.LocationId, row.LotId, row.AssetId, row.IssuanceId, row.Quantity, row.DueOn, OccurredOn = Now } }); + events.Add(entry.DomainEventOutboxId); + } + private async Task RefreshLowStockAsync(InventoryActor actor, string itemId, List events) + { + var item = await GetAsync(actor, itemId); var content = Decode(item); + decimal quantity = 0; + if (item.TrackingMode == 0) + { + foreach (var stock in await _store.RelatedAsync(actor.DepartmentId, "ItemId", itemId)) + if (!stock.IsDeleted && await LiveAlertLocationAsync(actor.DepartmentId, stock.LocationId)) quantity += stock.Quantity; + } + else foreach (var asset in await _store.RelatedAsync(actor.DepartmentId, "ItemId", itemId)) + if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3 && await LiveAlertLocationAsync(actor.DepartmentId, asset.CurrentLocationId)) quantity++; + var threshold = content.ReorderPoint ?? content.MinLevel; + await SetAlertAsync(actor.DepartmentId, InventoryAlertType.LowStock, itemId, null, null, null, null, + !item.IsDeleted && item.IsActive && threshold.HasValue && quantity <= threshold.Value, null, quantity, events); + } + private async Task RefreshPostingAlertsAsync(InventoryActor actor, InventoryCommand command, List events) + { + var items = command.Lines.Select(l => l.ItemId).ToHashSet(); + var moved = command.Lines.Where(l => l.AssetId != null).Select(l => l.AssetId).ToHashSet(); + if (moved.Count > 0) + { + var locations = (await AllAsync(actor.DepartmentId)).ToDictionary(l => l.Id); + if (locations.Values.Any(l => l.ContainerAssetId != null && moved.Contains(l.ContainerAssetId))) + { + var assets = (await AllAsync(actor.DepartmentId)).ToDictionary(a => a.Id); + var affected = new HashSet(); + foreach (var location in locations.Values) + { + var current = location; var seen = new HashSet(); + while (current != null && seen.Add(current.Id) && seen.Count <= 32) + { + if (current.ContainerAssetId != null && moved.Contains(current.ContainerAssetId)) { affected.Add(location.Id); break; } + var parent = current.ContainerAssetId != null && assets.TryGetValue(current.ContainerAssetId, out var holder) ? holder.CurrentLocationId : current.ParentLocationId; + current = parent != null && locations.TryGetValue(parent, out var next) ? next : null; + } + } + foreach (var stock in await AllAsync(actor.DepartmentId)) if (affected.Contains(stock.LocationId)) items.Add(stock.ItemId); + foreach (var asset in assets.Values) if (asset.CurrentLocationId != null && affected.Contains(asset.CurrentLocationId)) items.Add(asset.ItemId); + } + } + foreach (var item in items) await RefreshLowStockAsync(actor, item, events); + } + public Task RefreshAlertsAsync(InventoryActor actor) => TransactionAsync(actor, async events => + { + await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + foreach (var item in await AllAsync(actor.DepartmentId)) await RefreshLowStockAsync(actor, item.Id, events); + await EvaluateDatedAlertsAsync(actor.DepartmentId, events); return true; + }); + public Task SweepAlertsAsync(int departmentId) => AlertTransactionAsync(departmentId, async events => { await EvaluateDatedAlertsAsync(departmentId, events); return true; }); + private async Task LiveAlertLocationAsync(int departmentId, string id) + { + if (id == null) return false; + var location = await _store.GetAsync(departmentId, id); + if (location == null || location.IsDeleted) return false; + try { return !(await EffectiveLocationAsync(departmentId, location)).IsDeleted; } + catch (InventoryException ex) when (ex.Code == "LocationUnavailable") { return false; } + } + private async Task EvaluateDatedAlertsAsync(int departmentId, List events) + { + var desired = new HashSet(); + var items = (await AllAsync(departmentId)).Where(i => !i.IsDeleted && i.IsActive).Select(i => i.Id).ToHashSet(); + var lots = (await AllAsync(departmentId)).ToDictionary(l => l.Id); + async Task Add(InventoryAlertType type, string item, string location, string lot, string asset, string issuance, DateTime due, decimal quantity) + { + if (!items.Contains(item)) return; + if (location != null) + { + var holder = await _store.GetAsync(departmentId, location); + if (holder == null || holder.IsDeleted) return; + try { await EffectiveLocationAsync(departmentId, holder); } catch (InventoryException ex) when (ex.Code == "LocationUnavailable") { return; } + } + desired.Add(Fingerprint(new { type, itemId = item, locationId = location, lotId = lot, assetId = asset, issuanceId = issuance })); + await SetAlertAsync(departmentId, type, item, location, lot, asset, issuance, true, due, quantity, events); + } + foreach (var stock in await AllAsync(departmentId)) + if (!stock.IsDeleted && stock.Quantity > 0 && stock.LotId != null && lots.TryGetValue(stock.LotId, out var lot) && !lot.IsDeleted && lot.ExpiresOn <= Now.AddDays(30)) + await Add(lot.ExpiresOn <= Now ? InventoryAlertType.Expired : InventoryAlertType.ExpiringSoon, stock.ItemId, stock.LocationId, stock.LotId, null, null, lot.ExpiresOn.Value, stock.Quantity); + foreach (var asset in await AllAsync(departmentId)) + { + var expiry = asset.ExpiresOn; + if (asset.LotId != null && lots.TryGetValue(asset.LotId, out var lot) && lot.ExpiresOn.HasValue && (!expiry.HasValue || lot.ExpiresOn < expiry)) expiry = lot.ExpiresOn; + if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3 && asset.CurrentLocationId != null && expiry <= Now.AddDays(30)) + await Add(expiry <= Now ? InventoryAlertType.Expired : InventoryAlertType.ExpiringSoon, asset.ItemId, asset.CurrentLocationId, asset.LotId, asset.Id, null, expiry.Value, 1); + } + foreach (var issuance in await AllAsync(departmentId)) + if (!issuance.IsDeleted && issuance.Status is 0 or 2 && issuance.Quantity > issuance.ReturnedQuantity && issuance.ExpectedReturnOn < Now) + await Add(InventoryAlertType.OverdueReturn, issuance.ItemId, issuance.LocationId, issuance.LotId, issuance.AssetId, issuance.Id, issuance.ExpectedReturnOn.Value, issuance.Quantity - issuance.ReturnedQuantity); + foreach (var alert in await AllOpenAlertsAsync(departmentId)) + if (alert.Status == 0 && (!items.Contains(alert.ItemId) || alert.AlertType != 0 && !desired.Contains(alert.DedupKey))) { alert.Status = 1; alert.ResolvedOn = Now; await UpdateAlertMetadataAsync(alert); } + } + public async Task CanReceiveAlertAsync(int departmentId, string userId, string alertId) + { + if (!await _auth.IsEnabledAsync(departmentId) || !await _store.HasLegacyMigrationAsync(departmentId)) return false; + var alert = await _store.GetAsync(departmentId, alertId); + if (alert == null || alert.Status != 0) return false; + var item = await _store.GetAsync(departmentId, alert.ItemId); + if (item == null || item.IsDeleted || !item.IsActive) return false; + if (alert.AlertType == (int)InventoryAlertType.OverdueReturn) + { + var issuance = await _store.GetAsync(departmentId, alert.IssuanceId); + if (issuance == null || issuance.IsDeleted || issuance.Status is not (0 or 2) || issuance.Quantity <= issuance.ReturnedQuantity || !issuance.ExpectedReturnOn.HasValue || issuance.ExpectedReturnOn >= Now) return false; + } + else if (alert.AlertType is 1 or 2) + { + DateTime? expiry = null; + if (alert.LotId != null) { var lot = await _store.GetAsync(departmentId, alert.LotId); if (lot == null || lot.IsDeleted) return false; expiry = lot.ExpiresOn; } + if (alert.AssetId != null) + { + var asset = await _store.GetAsync(departmentId, alert.AssetId); + if (asset == null || asset.IsDeleted || asset.Status is not (0 or 1 or 2 or 3) || asset.CurrentLocationId != alert.LocationId) return false; + if (asset.ExpiresOn.HasValue && (!expiry.HasValue || asset.ExpiresOn < expiry)) expiry = asset.ExpiresOn; + } + else if (!(await _store.RelatedAsync(departmentId, "ItemId", alert.ItemId)).Any(s => !s.IsDeleted && s.LotId == alert.LotId && s.LocationId == alert.LocationId && s.Quantity > 0)) return false; + if (!expiry.HasValue || (alert.AlertType == 2 ? expiry > Now : expiry <= Now || expiry > Now.AddDays(30))) return false; + } + try { await RequireAlertAccessAsync(new InventoryActor { DepartmentId = departmentId, UserId = userId }, alert); return true; } + catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { return false; } + } + public Task ClaimAlertAsync(int departmentId, string userId) => AlertTransactionAsync(departmentId, async events => + { + foreach (var alert in await AllOpenAlertsAsync(departmentId)) + { + var delivery = await _store.AlertDeliveryAsync(departmentId, alert.Id, userId); + if (delivery != null && (delivery.State is 2 or 3 || delivery.NextAttemptOn > Now || delivery.LeaseUntil > Now)) continue; + if (!await CanReceiveAlertAsync(departmentId, userId, alert.Id)) continue; + if (delivery == null) + { + delivery = New(new InventoryActor { DepartmentId = departmentId }); delivery.AlertId = alert.Id; delivery.UserId = userId; delivery.NextAttemptOn = Now; + await _store.InsertAsync(delivery); + } + delivery.State = 1; delivery.ClaimToken = Guid.NewGuid().ToString("D"); delivery.LeaseUntil = Now.AddMinutes(30); delivery.AttemptCount++; + await UpdateAlertMetadataAsync(delivery); return delivery; + } + return null; + }); + private async Task> AllOpenAlertsAsync(int departmentId) + { + var result = new List(); + for (var skip = 0; skip <= 100000; skip += 500) + { + var page = await _store.OpenAlertsAsync(departmentId, skip); result.AddRange(page.Take(500)); + if (page.Count <= 500) return result; + } + throw new InventoryException(409, "InventoryTooLarge"); + } + public Task FinishAlertAsync(int departmentId, string deliveryId, string claimToken, bool handedOff) => AlertTransactionAsync(departmentId, async events => + { + var row = await _store.GetAsync(departmentId, deliveryId); + if (row == null || row.State != 1 || row.ClaimToken != claimToken || row.LeaseUntil <= Now) return false; + row.State = handedOff ? 2 : 0; row.HandedOffOn = handedOff ? Now : null; row.LeaseUntil = null; row.ClaimToken = null; + row.NextAttemptOn = Now.AddMinutes(Math.Min(1440, 5 * Math.Pow(2, Math.Min(row.AttemptCount, 8)))); + await UpdateAlertMetadataAsync(row); return true; + }); + } +} diff --git a/Core/Resgrid.Services/InventoryCatalog.cs b/Core/Resgrid.Services/InventoryCatalog.cs index a9ab1e68f..0ee7bb301 100644 --- a/Core/Resgrid.Services/InventoryCatalog.cs +++ b/Core/Resgrid.Services/InventoryCatalog.cs @@ -22,9 +22,16 @@ public Task SaveItemAsync(InventoryActor actor, InventoryItemInpu 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); + Cost(input.Details.DefaultUnitCost); + var existingDetails = Decode(row); + input.Details.CurrencyCode = Currency(input.Details.CurrencyCode) ?? existingDetails.CurrencyCode; + if (existingDetails.CurrencyCode != null && existingDetails.CurrencyCode != input.Details.CurrencyCode && ((await _store.RelatedAsync(actor.DepartmentId, "ItemId", row.Id)).Count != 0 || await HasOpenPurchaseLineAsync(actor, row.Id))) + throw new InventoryException(409, "ItemCurrencyLocked"); + input.Details.AverageUnitCost = existingDetails.AverageUnitCost; + if (input.Details.PreferredVendorId != null && input.Details.PreferredVendorId != existingDetails.PreferredVendorId && (await GetAsync(actor, input.Details.PreferredVendorId)).IsDeleted) throw new InventoryException(409, "VendorUnavailable"); 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"); + && ((await _store.RelatedAsync(actor.DepartmentId, "ItemId", row.Id)).Count > 0 || await HasOpenPurchaseLineAsync(actor, row.Id))) 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)); @@ -34,7 +41,7 @@ public Task SaveItemAsync(InventoryActor actor, InventoryItemInpu 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); + await SaveAsync(actor, row, input.Id == null); await AuditAsync(actor, row, "InventoryItemSaved"); await RefreshLowStockAsync(actor, row.Id, events); return await RevealAsync(actor, row); }); public Task SaveCategoryAsync(InventoryActor actor, string id, int revision, string name, string parentId) => TransactionAsync(actor, async events => { @@ -84,6 +91,8 @@ public Task SaveLotAsync(InventoryActor actor, InventoryLot lot, I 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"); + Cost(details.UnitCost); + if (details.VendorId != null && (await GetAsync(actor, details.VendorId)).IsDeleted) throw new InventoryException(409, "VendorUnavailable"); 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); @@ -92,13 +101,18 @@ public Task SaveLotAsync(InventoryActor actor, InventoryLot lot, I 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 not (InventoryItem or InventoryCategory or InventoryLocation or InventoryKit or InventoryVendor)) throw new InventoryException(400, "ArchiveNotSupported"); + if (row is InventoryVendor && (await _store.RelatedAsync(actor.DepartmentId, "VendorId", id)).Any(p => !p.IsDeleted && p.Status is 0 or 1 or 2)) throw new InventoryException(409, "VendorHasOpenOrders"); 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 Task RebuildStocksAsync(InventoryActor actor) => TransactionAsync(actor, async events => { + await _auth.RequireAsync(actor, true); await _store.RebuildStocksAsync(actor.DepartmentId); + foreach (var item in await AllAsync(actor.DepartmentId)) await RefreshLowStockAsync(actor, item.Id, events); + return true; + }); public async Task> GetByReferenceAsync(InventoryActor actor, InventoryReferenceType type, string id) { await _auth.RequireAsync(actor); var result = new System.Collections.Generic.List(); diff --git a/Core/Resgrid.Services/InventoryChecklistAssets.cs b/Core/Resgrid.Services/InventoryChecklistAssets.cs index ca8ae281a..2f2a95c29 100644 --- a/Core/Resgrid.Services/InventoryChecklistAssets.cs +++ b/Core/Resgrid.Services/InventoryChecklistAssets.cs @@ -114,13 +114,17 @@ public async Task> AtCallAsync(ChecklistActor actor 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 ledger = await ChecklistAssetHistoryAsync(actor.DepartmentId, assets.Keys, callUtc, false); var histories = ledger.GroupBy(t => t.AssetId).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); - foreach (var asset in assets.Values) + var candidates = assets.Values.Select(asset => (Asset: asset, State: HistoricalChecklistPosition(asset.Id, callUtc, long.MaxValue, assets, locations, histories))) + .Where(candidate => candidate.State?.Location?.UnitId != null && units.Contains(candidate.State.Location.UnitId.Value)).ToList(); + // Later container changes are needed to prove the first departure, including moves into a different bag on the same unit. + var departureAssets = candidates.Select(c => c.Asset.Id).Concat(locations.Values.Select(l => l.ContainerAssetId).Where(id => id != null)).Distinct(); + if (candidates.Count > 0) ledger.AddRange(await ChecklistAssetHistoryAsync(actor.DepartmentId, departureAssets, callUtc, true)); + histories = ledger.GroupBy(t => t.AssetId).ToDictionary(g => g.Key, g => g.OrderBy(t => t.OccurredOn).ThenBy(t => t.EntryId).ToList(), StringComparer.Ordinal); + foreach (var candidate in candidates) { - var state = HistoricalChecklistPosition(asset.Id, callUtc, long.MaxValue, assets, locations, histories); - if (state?.Location?.UnitId == null || !units.Contains(state.Location.UnitId.Value)) continue; + var asset = candidate.Asset; var state = candidate.State; 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; @@ -145,6 +149,20 @@ public async Task> AtCallAsync(ChecklistActor actor return result; } + private async Task> ChecklistAssetHistoryAsync(int departmentId, IEnumerable assetIds, DateTime at, bool after) + { + var result = new List(); + foreach (var batch in assetIds.Distinct().Chunk(500)) + for (var skip = 0; ; skip += 500) + { + var rows = await _store.AssetHistoryAsync(departmentId, batch, at, after, skip); + result.AddRange(rows.Take(500)); + if (result.Count > 100000) throw new ChecklistException(400, "ReportTooLarge"); + if (rows.Count <= 500) break; + } + return result.OrderBy(t => t.OccurredOn).ThenBy(t => t.EntryId).ToList(); + } + private sealed class HistoricalChecklistAssetPosition { public InventoryLocation Location { get; set; } diff --git a/Core/Resgrid.Services/InventoryCosting.cs b/Core/Resgrid.Services/InventoryCosting.cs new file mode 100644 index 000000000..2d3480a62 --- /dev/null +++ b/Core/Resgrid.Services/InventoryCosting.cs @@ -0,0 +1,143 @@ +using System; +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 + { + private static void Cost(decimal? value) + { if (value < 0 || value > 100000000m || value.HasValue && decimal.Round(value.Value, 6) != value) throw new InventoryException(400, "InvalidCost"); } + private static decimal Money(decimal value) => decimal.Round(value, 6, MidpointRounding.ToEven); + private static string Currency(string value) + { + if (value == null) return null; + value = value.Trim().ToUpperInvariant(); + if (value.Length != 3 || value.Any(c => c < 'A' || c > 'Z')) throw new InventoryException(400, "InvalidCurrency"); + return value; + } + private async Task CurrentUnitCostAsync(InventoryActor actor, InventoryItem item, string lotId, string assetId) + { + var lotCost = lotId == null ? null : Decode(await GetAsync(actor, lotId)).UnitCost; + var assetCost = assetId == null ? null : Decode(await GetAsync(actor, assetId)).AcquisitionCost; + var details = Decode(item); + return lotCost ?? assetCost ?? details.AverageUnitCost ?? details.DefaultUnitCost; + } + private sealed class PostingCost + { + public decimal? UnitCost; public string CurrencyCode; public decimal? AverageAfter; public bool UpdateAverage; + } + private async Task CostForPostingAsync(InventoryActor actor, InventoryItem item, InventoryPosting line) + { + Cost(line.UnitCost); + var details = Decode(item); + var result = new PostingCost { CurrencyCode = details.CurrencyCode }; + InventoryTransaction original = null; + if (line.ReversesTransactionId != null) original = await _store.GetAsync(actor.DepartmentId, line.ReversesTransactionId); + else if (line.Type == InventoryTransactionType.Return && line.IssuanceId != null) + original = (await _store.RelatedAsync(actor.DepartmentId, "IssuanceId", line.IssuanceId)).SingleOrDefault(t => t.TransactionType == (int)InventoryTransactionType.Issue); + if (original != null) + { + original = await GetAsync(actor, original.Id); + var frozen = JObject.Parse(original.Content ?? "{}"); + result.UnitCost = frozen.Value("UnitCost"); result.CurrencyCode = frozen.Value("CurrencyCode"); + if (line.UnitCost.HasValue && line.UnitCost != result.UnitCost) throw new InventoryException(409, "ReversalCostMismatch"); + } + else + { + if (line.UnitCost.HasValue && line.Type != InventoryTransactionType.Receive) throw new InventoryException(400, "CostOverrideUnsupported"); + if (line.Type == InventoryTransactionType.Receive) + { + var lotCost = line.LotId == null ? null : Decode(await GetAsync(actor, line.LotId)).UnitCost; + var assetCost = line.AssetId == null ? null : Decode(await GetAsync(actor, line.AssetId)).AcquisitionCost; + if (lotCost.HasValue && (line.UnitCost.HasValue && line.UnitCost != lotCost || assetCost.HasValue && assetCost != lotCost)) throw new InventoryException(409, "LotCostMismatch"); + if (assetCost.HasValue && line.UnitCost.HasValue && line.UnitCost != assetCost) throw new InventoryException(409, "AssetCostMismatch"); + } + result.UnitCost = line.UnitCost ?? await CurrentUnitCostAsync(actor, item, line.LotId, line.AssetId); + } + Cost(result.UnitCost); + var restoresStock = line.ReversesTransactionId != null && original?.FromLocationId != null && original.ToLocationId == null; + var removesStock = line.ReversesTransactionId != null && original?.FromLocationId == null && original?.ToLocationId != null; + if (line.Type != InventoryTransactionType.Receive && !restoresStock && !removesStock) return result; + decimal before; + if (item.TrackingMode == (int)InventoryTrackingMode.Bulk) + { + // Fixed-price lots carry their own value. Mixing them into the fallback average + // would price untracked stock twice: once in this average and again by lot cost. + var pricedLots = new System.Collections.Generic.HashSet(); + foreach (var lot in await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id)) + if (Decode(await RevealAsync(actor, lot)).UnitCost.HasValue) pricedLots.Add(lot.Id); + if (line.LotId != null && pricedLots.Contains(line.LotId)) return result; + before = (await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id)).Where(s => s.LotId == null || !pricedLots.Contains(s.LotId)).Sum(s => s.Quantity); + } + else before = (await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id)).Count(a => !a.IsDeleted && a.CurrentLocationId != null && a.Status is 0 or 1 or 2 or 3); + var incoming = line.Type == InventoryTransactionType.Receive || restoresStock; + if (before < 0 && !incoming) throw new InventoryException(409, "CostReconciliationRequired"); + before = Math.Max(0, before); + var oldCost = details.AverageUnitCost ?? details.DefaultUnitCost; + var after = before + (incoming ? line.Quantity : -line.Quantity); + if (after < 0) throw new InventoryException(409, "CostReconciliationRequired"); + result.UpdateAverage = true; + if (!result.UnitCost.HasValue || before > 0 && !oldCost.HasValue) + result.AverageAfter = null; // Unknown opening value is not silently priced as zero. + else if (after == 0) result.AverageAfter = null; + else + { + var value = before * (oldCost ?? 0) + (incoming ? 1 : -1) * line.Quantity * result.UnitCost.Value; + if (value < 0) throw new InventoryException(409, "CostReconciliationRequired"); + result.AverageAfter = Money(value / after); Cost(result.AverageAfter); + } + return result; + } + private async Task ApplyAverageCostAsync(InventoryActor actor, InventoryItem item, PostingCost cost) + { + if (!cost.UpdateAverage) return; + var details = Decode(item); details.AverageUnitCost = cost.AverageAfter; + item.Content = JsonConvert.SerializeObject(details); await SaveAsync(actor, item, false); + } + public async Task GetValuationAsync(InventoryActor actor, string locationId = null) + { + await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + if (_uow.Transaction != null) throw new InvalidOperationException("Inventory valuation owns its read transaction."); + try + { + await _uow.CreateOrGetConnectionAsync(System.Threading.CancellationToken.None); + // Use the posting lock so quantities and protected average costs describe the same instant. + await _store.LockDepartmentAsync(actor.DepartmentId); + var result = await ReadValuationAsync(actor, locationId); _uow.CommitChanges(); return result; + } + catch { _uow.DiscardChanges(); throw; } + } + private async Task ReadValuationAsync(InventoryActor actor, string locationId) + { + await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + if (locationId != null) await LocationAsync(actor, locationId); + var result = new InventoryValuation { AsOfUtc = Now }; + var items = (await AllAsync(actor.DepartmentId)).ToDictionary(i => i.Id); + async Task Add(string itemId, string holderId, string lotId, string assetId, decimal quantity) + { + if (holderId == null || locationId != null && locationId != holderId || !items.TryGetValue(itemId, out var stored)) return; + try { await LocationAsync(actor, holderId); } + catch (InventoryException e) when (e.StatusCode is 403 or 404 || e.Code == "LocationUnavailable") { return; } + var item = await RevealAsync(actor, JsonConvert.DeserializeObject(JsonConvert.SerializeObject(stored))); + var details = Decode(item); + // Serialized equipment is valued once, at its own acquisition cost when known. + var cost = assetId == null ? await CurrentUnitCostAsync(actor, item, lotId, null) + : Decode(await GetAsync(actor, assetId)).AcquisitionCost ?? await CurrentUnitCostAsync(actor, item, lotId, assetId); + result.Lines.Add(new InventoryValuationLine { ItemId = itemId, AssetId = assetId, LotId = lotId, LocationId = holderId, ItemName = details.Name, + CurrencyCode = details.CurrencyCode, Quantity = quantity, UnitCost = cost, Value = cost.HasValue ? Money(cost.Value * quantity) : null }); + if (result.Lines.Count > 5000) throw new InventoryException(409, "InventoryTooLarge"); + } + foreach (var stock in await AllAsync(actor.DepartmentId)) if (stock.Quantity != 0) await Add(stock.ItemId, stock.LocationId, stock.LotId, null, stock.Quantity); + foreach (var asset in await AllAsync(actor.DepartmentId)) if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3) await Add(asset.ItemId, asset.CurrentLocationId, asset.LotId, asset.Id, 1); + result.HasNegativeStock = result.Lines.Any(l => l.Quantity < 0); + result.Totals = result.Lines.GroupBy(l => l.CurrencyCode).Select(g => new InventoryValuationTotal { CurrencyCode = g.Key, + KnownValue = g.Sum(l => l.Value ?? 0), UncostedRows = g.Count(l => !l.Value.HasValue) }).OrderBy(t => t.CurrencyCode).ToList(); + await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryCounts.cs b/Core/Resgrid.Services/InventoryCounts.cs new file mode 100644 index 000000000..b4928a7ca --- /dev/null +++ b/Core/Resgrid.Services/InventoryCounts.cs @@ -0,0 +1,149 @@ +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 + { + private async Task RequireCountAccessAsync(InventoryActor actor, InventoryCount count, bool historical = true) + { + if (count == null || count.DepartmentId != actor.DepartmentId || count.IsDeleted) throw new InventoryException(404, "Unavailable"); + if (count.LocationId == null) await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + else await LocationAsync(actor, count.LocationId, false, PermissionTypes.AdjustInventory, historical: historical); + foreach (var line in await _store.RelatedAsync(actor.DepartmentId, "CountId", count.Id)) + await LocationAsync(actor, line.LocationId, false, PermissionTypes.AdjustInventory, historical: historical); + } + // Conservative department fence also catches new positions, catalogue edits and rebuilds. + // Counts and observations themselves are excluded so independent counts do not invalidate each other. + private async Task CountFingerprintAsync(int departmentId) => Fingerprint(new { + Items = (await AllAsync(departmentId)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Stocks = (await AllAsync(departmentId)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision, x.Quantity }), + Assets = (await AllAsync(departmentId)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Lots = (await AllAsync(departmentId)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Locations = (await AllAsync(departmentId)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Ledger = await _store.LastEntryAsync(departmentId) + }); + public Task StartCountAsync(InventoryActor actor, InventoryCountInput input) => TransactionAsync(actor, async events => + { + if (input == null) throw new InventoryException(400, "InvalidCount"); Id(input.Id); Text(input.Name); + if (input.Note?.Length > 16000) throw new InventoryException(400, "InvalidText"); + var count = New(actor); count.Id = input.Id; count.LocationId = input.LocationId; + await RequireCountAccessAsync(actor, count, false); + if (await _store.GetAsync(actor.DepartmentId, count.Id) != null) throw new InventoryException(409, "CountAlreadyExists"); + count.SnapshotOn = Now; count.SnapshotFingerprint = await CountFingerprintAsync(actor.DepartmentId); + count.Content = JsonConvert.SerializeObject(new InventoryCountContent { Name = input.Name.Trim(), Note = input.Note }); + var lines = new List(); + async Task Add(string itemId, string locationId, string lotId, string assetId, decimal quantity) + { + if (locationId == null || input.LocationId != null && locationId != input.LocationId) return; + if (!await LiveAlertLocationAsync(actor.DepartmentId, locationId)) return; + await LocationAsync(actor, locationId, false, PermissionTypes.AdjustInventory); + var item = await GetAsync(actor, itemId); + if (item.IsDeleted || !item.IsActive) return; + var details = Decode(item); + var row = New(actor); row.CountId = count.Id; row.ItemId = itemId; row.LocationId = locationId; row.LotId = lotId; row.AssetId = assetId; row.ExpectedQuantity = quantity; + row.Content = JsonConvert.SerializeObject(new InventoryCountItemContent { ItemName = details.Name, UnitOfMeasure = details.UnitOfMeasure, CurrencyCode = details.CurrencyCode, + UnitCost = await CurrentUnitCostAsync(actor, item, lotId, assetId), + SerialNumber = assetId == null ? null : Decode(await GetAsync(actor, assetId)).SerialNumber, + LotNumber = lotId == null ? null : Decode(await GetAsync(actor, lotId)).LotNumber }); + lines.Add(row); if (lines.Count > 100) throw new InventoryException(409, "CountScopeTooLarge"); + } + foreach (var stock in await AllAsync(actor.DepartmentId)) if (!stock.IsDeleted) await Add(stock.ItemId, stock.LocationId, stock.LotId, null, stock.Quantity); + foreach (var asset in await AllAsync(actor.DepartmentId)) if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3) await Add(asset.ItemId, asset.CurrentLocationId, asset.LotId, asset.Id, 1); + if (input.LocationId != null) foreach (var item in await AllAsync(actor.DepartmentId)) + if (!item.IsDeleted && item.IsActive && item.TrackingMode == 0 && !item.RequiresLotTracking && !lines.Any(l => l.ItemId == item.Id)) await Add(item.Id, input.LocationId, null, null, 0); + if (lines.Count == 0) throw new InventoryException(409, "CountEmpty"); + await SaveAsync(actor, count); foreach (var line in lines) await SaveAsync(actor, line); + await AuditAsync(actor, count, "InventoryCountStarted"); return await GetCountAsync(actor, count.Id); + }); + public async Task GetCountAsync(InventoryActor actor, string id) + { + var count = await GetAsync(actor, id); + var result = new InventoryCountDetail { Count = count }; + foreach (var line in await _store.RelatedAsync(actor.DepartmentId, "CountId", id)) result.Lines.Add(await RevealAsync(actor, line)); + return result; + } + public Task SaveCountAsync(InventoryActor actor, InventoryCountUpdate input) => TransactionAsync(actor, async events => + { + if (input?.Lines == null || input.Lines.Count is < 1 or > 100 || input.Lines.Any(l => l == null) || input.Lines.Select(l => l.Id).Distinct().Count() != input.Lines.Count) throw new InventoryException(400, "InvalidCount"); + var detail = await GetCountAsync(actor, input.CountId); + if (detail.Count.Status != 0 || detail.Count.Revision != input.Revision) throw new InventoryException(409, "CountStateConflict"); + foreach (var observation in input.Lines) + { + Quantity(observation.Quantity, true); + var line = detail.Lines.SingleOrDefault(l => l.Id == observation.Id) ?? throw new InventoryException(404, "Unavailable"); + if (line.AssetId != null && observation.Quantity is not (0 or 1)) throw new InventoryException(400, "SerializedQuantity"); + line.CountedQuantity = observation.Quantity; await SaveAsync(actor, line, false); + } + await SaveAsync(actor, detail.Count, false); await AuditAsync(actor, detail.Count, "InventoryCountSaved"); return await GetCountAsync(actor, detail.Count.Id); + }); + public Task CancelCountAsync(InventoryActor actor, string id, int revision) => TransactionAsync(actor, async events => + { + var count = await GetAsync(actor, id); + if (count.Revision != revision || count.Status is not (0 or 1)) throw new InventoryException(409, "CountStateConflict"); + count.Status = 3; await SaveAsync(actor, count, false); await AuditAsync(actor, count, "InventoryCountCancelled"); return true; + }); + private async Task CountCommandAsync(InventoryActor actor, InventoryCountDetail detail, InventoryCountComplete input, bool witnessed) + { + if (detail.Count.Status != (witnessed ? 1 : 0) || detail.Count.Revision != input.Revision || detail.Lines.Any(l => !l.CountedQuantity.HasValue)) throw new InventoryException(409, "CountStateConflict"); + if (detail.Count.SnapshotFingerprint != await CountFingerprintAsync(actor.DepartmentId)) throw new InventoryException(409, "CountSnapshotChanged"); + var command = new InventoryCommand { RequestId = input.RequestId }; + foreach (var row in detail.Lines.Where(l => l.CountedQuantity != l.ExpectedQuantity)) + { + var variance = row.CountedQuantity.Value - row.ExpectedQuantity; + if (row.AssetId != null && (variance != -1 || (await _store.RelatedAsync(actor.DepartmentId, "AssetId", row.AssetId)).Any(i => i.Status is 0 or 2))) throw new InventoryException(409, "ReturnAssetFirst"); + command.Lines.Add(new InventoryPosting { ItemId = row.ItemId, LotId = row.LotId, AssetId = row.AssetId, CountItemId = row.Id, Type = InventoryTransactionType.Count, + FromLocationId = variance < 0 ? row.LocationId : null, ToLocationId = variance > 0 ? row.LocationId : null, Quantity = Math.Abs(variance), ReferenceType = InventoryReferenceType.Count, ReferenceId = detail.Count.Id }); + } + if (command.Lines.Count > 0) await ValidateCommandAsync(actor, command, counting: true); + return command; + } + public Task CompleteCountAsync(InventoryActor actor, InventoryCountComplete input) => TransactionAsync(actor, async events => + { + if (input == null) throw new InventoryException(400, "InvalidCount"); + await RequireCountAccessAsync(actor, await _store.GetAsync(actor.DepartmentId, input.CountId)); + var result = await OperationAsync(actor, input.RequestId, new { Kind = "Count", input }, async (operation, pending) => + { + var detail = await GetCountAsync(actor, input.CountId); var command = await CountCommandAsync(actor, detail, input, false); + if (await RequiresWitnessAsync(actor, command.Lines.Select(l => l.ItemId))) + { + detail.Count.Status = 1; detail.Count.OperationId = operation.Id; await SaveAsync(actor, detail.Count, false); + operation.Content = JsonConvert.SerializeObject(new InventoryOperationContent { PendingKind = "Count", PerformerId = actor.UserId, + PendingCount = new InventoryCountComplete { CountId = input.CountId, RequestId = input.RequestId, Revision = detail.Count.Revision } }); + await SaveAsync(actor, operation, false); await AuditAsync(actor, detail.Count, "InventoryCountWitnessRequested"); + return new InventoryResult { AwaitingWitness = true }; + } + return await CompleteCountLinesAsync(actor, operation, input, pending); + }, events); + foreach (var id in result.OutboxIds ?? new()) if (!events.Contains(id)) events.Add(id); + return result; + }); + private async Task CompleteCountLinesAsync(InventoryActor actor, InventoryOperation operation, InventoryCountComplete input, List events, string performer = null, string witness = null, string attestation = null) + { + var detail = await GetCountAsync(actor, input.CountId); + if (witness != null && detail.Count.OperationId != operation.Id) throw new InventoryException(409, "CountStateConflict"); + var command = await CountCommandAsync(actor, detail, input, witness != null); + var snapshots = detail.Lines.ToDictionary(l => l.Id, Decode); + var result = command.Lines.Count == 0 ? new InventoryResult { OperationId = operation.Id } : await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); + for (var i = 0; i < command.Lines.Count; i++) + { + var row = detail.Lines.Single(l => l.Id == command.Lines[i].CountItemId); row.TransactionId = result.TransactionIds[i]; await SaveAsync(actor, row, false); + } + var content = Decode(detail.Count); content.VarianceLineCount = command.Lines.Count; + content.Totals = detail.Lines.Where(l => l.CountedQuantity != l.ExpectedQuantity).Select(l => new { Row = l, Data = snapshots[l.Id] }) + .GroupBy(l => l.Data.CurrencyCode).Select(g => new InventoryValuationTotal { CurrencyCode = g.Key, UncostedRows = g.Count(l => !l.Data.UnitCost.HasValue), + KnownValue = g.Sum(l => Money((l.Row.CountedQuantity.Value - l.Row.ExpectedQuantity) * (l.Data.UnitCost ?? 0))) }).ToList(); + detail.Count.Status = 2; detail.Count.CompletedOn = Now; detail.Count.OperationId = operation.Id; detail.Count.Content = JsonConvert.SerializeObject(content); + await SaveAsync(actor, detail.Count, false); await AuditAsync(actor, detail.Count, "InventoryCountCompleted"); + var entry = await _outbox.EnqueueAsync(actor.DepartmentId, "Inventory", new DomainEventEnvelope { EventName = "InventoryCountCompleted", Trigger = WorkflowTriggerEventType.InventoryCountCompleted, + SchemaVersion = 1, AggregateType = "InventoryCount", AggregateId = detail.Count.Id, CorrelationId = operation.Id, OccurredOn = Now, + Payload = new { InventoryEvent = true, CountId = detail.Count.Id, LocationId = detail.Count.LocationId, LineCount = detail.Lines.Count, VarianceLineCount = command.Lines.Count, VarianceValue = ProtectedDataEnvelope.RedactionValue, OccurredOn = Now } }); + events.Add(entry.DomainEventOutboxId); result.OutboxIds = events.ToList(); return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryGdprExport.cs b/Core/Resgrid.Services/InventoryGdprExport.cs index 8d3327eb1..3717abf2a 100644 --- a/Core/Resgrid.Services/InventoryGdprExport.cs +++ b/Core/Resgrid.Services/InventoryGdprExport.cs @@ -26,11 +26,18 @@ async Task> Relevant(Func include) where T : InventoryRow 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 locationIds = locations.Where(x => x.UserId == userId).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, + Counts = await Relevant(x => x.CreatedBy == userId), + CountItems = await Relevant(x => x.CreatedBy == userId), + AlertDeliveries = await Relevant(x => x.UserId == userId), + Vendors = await Relevant(x => x.CreatedBy == userId), + PurchaseOrders = await Relevant(x => x.CreatedBy == userId), + PurchaseOrderItems = await Relevant(x => x.CreatedBy == userId), + RecordUsages = await Relevant(x => x.CreatedBy == userId), 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), diff --git a/Core/Resgrid.Services/InventoryIssuance.cs b/Core/Resgrid.Services/InventoryIssuance.cs index a32ef4598..d9d05fef8 100644 --- a/Core/Resgrid.Services/InventoryIssuance.cs +++ b/Core/Resgrid.Services/InventoryIssuance.cs @@ -22,11 +22,7 @@ public async Task CreateAssetAsync(InventoryActor actor, Invento 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 asset = await CreateUnreceivedAssetAsync(actor, item, input.Id, input.LotId, input.ExpiresOn, input.Details); 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); @@ -35,17 +31,29 @@ public async Task CreateAssetAsync(InventoryActor actor, Invento }); return await GetAsync(actor, result.AssetId); } + private async Task CreateUnreceivedAssetAsync(InventoryActor actor, InventoryItem item, string id, string lotId, DateTime? expiresOn, InventoryAssetContent details, string performer = null) + { + Text(details.SerialNumber); Cost(details.AcquisitionCost); + if (details.AssetTag?.Length > 250 || details.Barcode?.Length > 250) throw new InventoryException(400, "InvalidAsset"); + foreach (var other in await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id)) + if (string.Equals(Decode(await RevealAsync(actor, other)).SerialNumber, details.SerialNumber, StringComparison.OrdinalIgnoreCase)) throw new InventoryException(409, "DuplicateSerial"); + var asset = New(actor); if (id != null) { Id(id); asset.Id = id; } + asset.ItemId = item.Id; asset.LotId = lotId; asset.ExpiresOn = expiresOn?.ToUniversalTime(); asset.AcquiredOn = Now; asset.Status = (int)InventoryAssetStatus.InService; + asset.CreatedBy = performer ?? actor.UserId; asset.Content = JsonConvert.SerializeObject(details); await SaveAsync(actor, asset); return asset; + } + private async Task CreateKitContainerAsync(InventoryActor actor, string assetId, string performer) + { + var location = New(actor); location.CreatedBy = performer ?? actor.UserId; + location.LocationType = (int)InventoryLocationType.Container; location.ContainerAssetId = assetId; + location.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Container" }); await SaveAsync(actor, location); + } 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); - } + await CreateKitContainerAsync(actor, line.AssetId, performer); return result; } public Task IssueAsync(InventoryActor actor, InventoryIssueInput input) => TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/InventoryModernizationService.cs b/Core/Resgrid.Services/InventoryModernizationService.cs index ab70a7a96..aa9580433 100644 --- a/Core/Resgrid.Services/InventoryModernizationService.cs +++ b/Core/Resgrid.Services/InventoryModernizationService.cs @@ -15,7 +15,7 @@ namespace Resgrid.Services { - public sealed partial class InventoryModernizationService : IInventoryCatalogService, IInventoryStockService, IInventoryTransferService, IInventoryIssuanceService, IInventoryMigrationService, IChecklistAssetSource, IChecklistHistoricalAssetSource + public sealed partial class InventoryModernizationService : IInventoryCatalogService, IInventoryStockService, IInventoryTransferService, IInventoryIssuanceService, IInventoryMigrationService, IInventoryPurchasingService, IInventoryOperationsService, IInventoryAlertService, IChecklistAssetSource, IChecklistHistoricalAssetSource { private readonly IInventoryStore _store; private readonly IInventoryAuthorizationService _auth; @@ -31,11 +31,15 @@ public sealed partial class InventoryModernizationService : IInventoryCatalogSer private readonly IInventoryTypesRepository _legacyTypes; private readonly IWorkOrderRepository _workOrders; private readonly Lazy _workOrderAuthorization; + private readonly Lazy _recordsAuthorization; + private readonly Lazy _recordUsage; + private readonly IContactsService _contacts; 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; } + IWorkOrderRepository workOrders = null, Lazy workOrderAuthorization = null, + Lazy recordsAuthorization = null, Lazy recordUsage = null, IContactsService contacts = 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; _recordsAuthorization = recordsAuthorization; _recordUsage = recordUsage; _contacts = contacts; } 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"); } @@ -127,6 +131,24 @@ public async Task GetAsync(InventoryActor actor, string id) where T : Inve } private async Task AuthorizeRowAsync(InventoryActor actor, InventoryRow row) { + if (row is InventoryCount count) await RequireCountAccessAsync(actor, count); + if (row is InventoryCountItem countLine) await RequireCountAccessAsync(actor, await _store.GetAsync(actor.DepartmentId, countLine.CountId)); + if (row is InventoryAlert alert) await RequireAlertAccessAsync(actor, alert); + if (row is InventoryAlertDelivery) throw new InventoryException(403, "Unavailable"); + if (row is InventoryVendor or InventoryPurchaseOrder or InventoryPurchaseOrderItem) + { + await RequirePurchasingAccessAsync(actor, false); + if (row is InventoryPurchaseOrderItem purchaseItem && await _store.GetAsync(actor.DepartmentId, purchaseItem.PurchaseOrderId) == null) + throw new InventoryException(404, "Unavailable"); + } + // Record usage is read through the source adapter, which checks Records visibility as well as Inventory access. + if (row is RecordInventoryUsage usage) + { + if (_recordsAuthorization == null || usage.SourceType != (int)InventoryUsageSourceType.RmsRecord + || !await _recordsAuthorization.Value.CanUserViewRecordAsync(actor.UserId, usage.SourceId, actor.DepartmentId) + || !await _recordsAuthorization.Value.HasPermissionAsync(actor.UserId, actor.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new InventoryException(403, "RecordSourceAuthorizationRequired"); + await LocationAsync(actor, usage.SourceLocationId, historical: true); + } if (row is InventoryTransferItem detail) { var transfer = await _store.GetAsync(actor.DepartmentId, detail.TransferId); diff --git a/Core/Resgrid.Services/InventoryPosting.cs b/Core/Resgrid.Services/InventoryPosting.cs index 36a1cd231..8968d48e2 100644 --- a/Core/Resgrid.Services/InventoryPosting.cs +++ b/Core/Resgrid.Services/InventoryPosting.cs @@ -30,13 +30,20 @@ private async Task OperationAsync(InventoryActor actor, string 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) + private async Task ValidateCommandAsync(InventoryActor actor, InventoryCommand command, bool issuance = false, bool joined = false, bool purchasing = false, bool counting = 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) + if (line?.CountItemId != null && !counting) throw new InventoryException(400, "CountCompletionRequired"); + if (line?.PurchaseOrderItemId != null && !purchasing) throw new InventoryException(400, "PurchaseReceiptRequired"); + if (line != null && (line.UsageId != null || line.UsageType.HasValue)) + { + if (!joined || line.ReferenceType != InventoryReferenceType.RmsRecord || line.UsageId == null || !line.UsageType.HasValue || !Enum.IsDefined(line.UsageType.Value)) throw new InventoryException(400, "InvalidUsageSource"); + Id(line.UsageId); + } + if (line == null || !Enum.IsDefined(line.Type) || line.Type == InventoryTransactionType.Migrated || !counting && line.Type == 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"); @@ -48,32 +55,32 @@ private async Task ValidateCommandAsync(InventoryActor actor, InventoryCommand c 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"); + || line.Type is InventoryTransactionType.Adjust or InventoryTransactionType.Count && 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) await LocationAsync(actor, line.FromLocationId, true, permission, historical: line.ReversesTransactionId != null); + if (to) await LocationAsync(actor, line.ToLocationId, true, permission, historical: line.ReversesTransactionId != null); if (!from && !to) await _auth.RequireAsync(actor, true, permission); - await ValidatePostingItemAsync(actor, line, joined); + await ValidatePostingItemAsync(actor, line, joined, purchasing, counting); } } - private async Task ValidatePostingItemAsync(InventoryActor actor, InventoryPosting line, bool joined = false) + private async Task ValidatePostingItemAsync(InventoryActor actor, InventoryPosting line, bool joined = false, bool purchasing = false, bool counting = false) { var item = await GetAsync(actor, line.ItemId); - if (item.IsDeleted || !item.IsActive) throw new InventoryException(409, "ItemUnavailable"); + if ((item.IsDeleted || !item.IsActive) && line.ReversesTransactionId == null) 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); + await ValidateReferenceAsync(actor, line, joined, purchasing: purchasing, counting: counting); 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 (lot.ItemId != item.Id || lot.IsDeleted && line.ReversesTransactionId == null || 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.Type == InventoryTransactionType.Adjust && line.ReversesTransactionId == null) 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"); @@ -86,8 +93,8 @@ private async Task RequireCommandAccessAsync(InventoryActor actor, InventoryComm 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) await LocationAsync(actor, line.FromLocationId, true, permission, historical: line.ReversesTransactionId != null); + if (line.ToLocationId != null) await LocationAsync(actor, line.ToLocationId, true, permission, historical: line.ReversesTransactionId != null); if (line.FromLocationId == null && line.ToLocationId == null) await _auth.RequireAsync(actor, true, permission); if (line.AssetId != null) { @@ -98,6 +105,16 @@ private async Task RequireCommandAccessAsync(InventoryActor actor, InventoryComm 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); + if (!joined && line.ReversesTransactionId != null) + { + if ((await _store.GetAsync(actor.DepartmentId, line.ReversesTransactionId))?.PurchaseOrderItemId != null) throw new InventoryException(409, "PurchaseReceiptImmutable"); + var linked = await _store.RelatedAsync(actor.DepartmentId, "TransactionId", line.ReversesTransactionId); + foreach (var usage in linked) + { + if (!item.IsControlledSubstance || _recordUsage == null) throw new InventoryException(409, "RecordUsageCorrectionRequired"); + await _recordUsage.Value.RequireUsageCorrectionAccessAsync(actor, usage.Id); + } + } await ValidateReferenceAsync(actor, line, joined, requireOpen: false); } } @@ -157,6 +174,16 @@ public Task WitnessAsync(InventoryActor actor, string requestId if (operation.State == 2 && receipt.WitnessId == actor.UserId && receipt.Attestation == attestation) { if (receipt.Result == null) throw new InventoryException(409, "RequestInProgress"); + if (receipt.PendingCount != null) + { + await RequireCountAccessAsync(actor, await _store.GetAsync(actor.DepartmentId, receipt.PendingCount.CountId)); + foreach (var id in receipt.Result.OutboxIds ?? new()) if (!events.Contains(id)) events.Add(id); + } + if (receipt.PendingPurchaseReceipt != null) + { + await RequirePurchaseReceiptAccessAsync(actor, receipt.PendingPurchaseReceipt); + foreach (var id in receipt.Result.OutboxIds ?? new()) if (!events.Contains(id)) events.Add(id); + } foreach (var id in receipt.Result.TransactionIds) { var transaction = await _store.GetAsync(actor.DepartmentId, id); @@ -171,6 +198,15 @@ public Task WitnessAsync(InventoryActor actor, string requestId InventoryResult result; switch (receipt.PendingKind ?? "Post") { + case "Count": + if (receipt.PendingCount == null) throw new InventoryException(409, "CountStateConflict"); + await RequireCountAccessAsync(performer, await _store.GetAsync(actor.DepartmentId, receipt.PendingCount.CountId)); + result = await CompleteCountLinesAsync(actor, operation, receipt.PendingCount, events, performer.UserId, actor.UserId, attestation); + break; + case "PurchaseReceive": + await RequirePurchaseReceiptAccessAsync(performer, receipt.PendingPurchaseReceipt); + result = await CompletePurchaseReceiptAsync(actor, operation, receipt.PendingPurchaseReceipt, events, performer.UserId, actor.UserId, attestation); + break; case "Post": case "Transfer": case "Receive": @@ -210,14 +246,20 @@ private async Task PostLinesAsync(InventoryActor actor, Invento 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; + transaction.ReversesTransactionId = line.ReversesTransactionId; transaction.IssuanceId = line.IssuanceId; transaction.PurchaseOrderItemId = line.PurchaseOrderItemId; + transaction.CountItemId = line.CountItemId; if (line.ReversesTransactionId != null) { var original = await GetAsync(actor, line.ReversesTransactionId); + if (original.PurchaseOrderItemId != null) throw new InventoryException(409, "PurchaseReceiptImmutable"); + if (original.CountItemId != null) throw new InventoryException(409, "CountReceiptImmutable"); + if (original.ReferenceType == (int)InventoryReferenceType.RmsRecord && (line.ReferenceType != InventoryReferenceType.RmsRecord || line.ReferenceId != original.ReferenceId)) throw new InventoryException(409, "RecordUsageCorrectionRequired"); 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 + || original.FromLocationId != line.ToLocationId || original.ToLocationId != line.FromLocationId || original.TransactionType is 4 or 5 or 9 + || original.TransactionType == 0 && (line.UsageId == null || line.ReferenceType != InventoryReferenceType.RmsRecord || original.FromLocationId == null || original.ToLocationId != null) || (await _store.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", original.Id)).Count > 0) throw new InventoryException(409, "InvalidReversal"); } + var postingCost = await CostForPostingAsync(actor, item, line); if (item.TrackingMode == (int)InventoryTrackingMode.Bulk) { if (line.FromLocationId != null) @@ -232,35 +274,51 @@ private async Task PostLinesAsync(InventoryActor actor, Invento 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; + else await MoveAssetAsync(actor, line, transaction, postingCost.UnitCost); + var cost = postingCost.UnitCost; 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 }); + transaction.Content = JsonConvert.SerializeObject(new { line.Note, ItemName = Decode(item).Name, UnitOfMeasure = Decode(item).UnitOfMeasure, SerialNumber = serial, UnitCost = cost, TotalCost = cost.HasValue ? (decimal?)Money(cost.Value * line.Quantity) : null, postingCost.CurrencyCode, PerformerId = performer ?? actor.UserId, WitnessUserId = witness, WitnessedOn = witness == null ? (DateTime?)null : Now, Attestation = attestation }); + await ApplyAverageCostAsync(actor, item, postingCost); await SaveAsync(actor, transaction); await AuditAsync(actor, transaction, "InventoryTransactionPosted"); result.TransactionIds.Add(transaction.Id); - await EventAsync(transaction, WorkflowTriggerEventType.InventoryAdjusted, events); + await EventAsync(transaction, WorkflowTriggerEventType.InventoryAdjusted, events, usageId: line.UsageId, usageType: line.UsageType); 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); } } + await RefreshPostingAlertsAsync(actor, command, events); result.OutboxIds = events.ToList(); return result; } - private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, InventoryTransaction transaction) + private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, InventoryTransaction transaction, decimal? receiptCost) { 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"); + if (line.Type == InventoryTransactionType.Adjust && line.ReversesTransactionId != null) + { + var original = await GetAsync(actor, line.ReversesTransactionId); + var latest = (await _store.RelatedAsync(actor.DepartmentId, "AssetId", asset.Id)).OrderByDescending(x => x.EntryId).FirstOrDefault(); + if (original.TransactionType != (int)InventoryTransactionType.Consume || original.NewStatus != (int)InventoryAssetStatus.Consumed || original.OldStatus is not (0 or 2 or 3) + || asset.Status != original.NewStatus || asset.CurrentLocationId != original.FromLocationId || latest?.Id != original.Id || line.FromLocationId != null || line.ToLocationId != original.FromLocationId) + throw new InventoryException(409, "AssetReversalConflict"); + transaction.OldStatus = asset.Status; transaction.NewStatus = original.OldStatus; asset.Status = original.OldStatus.Value; + await SaveAsync(actor, asset, false); return; + } // 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 == InventoryTransactionType.Receive) + { + var details = Decode(asset); + details.AcquisitionCost ??= receiptCost; asset.Content = JsonConvert.SerializeObject(details); + } 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 }; + InventoryTransactionType.WriteOff or InventoryTransactionType.Count => 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"); @@ -281,15 +339,16 @@ private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, I } 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) + private async Task EventAsync(InventoryTransaction transaction, WorkflowTriggerEventType trigger, List events, string transferId = null, string usageId = null, InventoryUsageType? usageType = 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, + Payload = new { InventoryEvent = true, TransactionId = transaction.Id, transaction.ItemId, transaction.AssetId, transaction.LotId, UsageId = usageId, UsageType = usageType, 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 } + transaction.OldStatus, transaction.NewStatus, transaction.ReferenceType, transaction.ReferenceId, transaction.OccurredOn, transaction.ReversesTransactionId, transaction.PurchaseOrderItemId, transaction.CountItemId, + CountId = transaction.ReferenceType == (int)InventoryReferenceType.Count ? transaction.ReferenceId : null } }); events.Add(entry.DomainEventOutboxId); } public Task CreateAndCompleteTransferAsync(InventoryActor actor, InventoryCommand command) => TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/InventoryPurchaseReceipts.cs b/Core/Resgrid.Services/InventoryPurchaseReceipts.cs new file mode 100644 index 000000000..db6c899eb --- /dev/null +++ b/Core/Resgrid.Services/InventoryPurchaseReceipts.cs @@ -0,0 +1,114 @@ +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 + { + private async Task RequirePurchaseReceiptAccessAsync(InventoryActor actor, InventoryPurchaseReceiptInput input) + { + await RequirePurchasingAccessAsync(actor, true); + if (input?.Lines == null || input.Lines.Count is < 1 or > 100 || input.Lines.Any(l => l == null) || input.Revision < 1) throw new InventoryException(400, "InvalidPurchaseReceipt"); + Id(input.PurchaseOrderId); Id(input.RequestId); + if (await _store.GetAsync(actor.DepartmentId, input.PurchaseOrderId) == null) throw new InventoryException(404, "Unavailable"); + foreach (var line in input.Lines) + { + Id(line.PurchaseOrderItemId); Quantity(line.Quantity); await LocationAsync(actor, line.LocationId, true); + var source = await _store.GetAsync(actor.DepartmentId, line.PurchaseOrderItemId); + if (source == null || source.PurchaseOrderId != input.PurchaseOrderId || source.IsDeleted) throw new InventoryException(404, "PurchaseOrderLineUnavailable"); + var item = await _store.GetAsync(actor.DepartmentId, source.ItemId); + if (item == null) throw new InventoryException(404, "Unavailable"); + if (item.IsControlledSubstance) await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); + } + } + private async Task ValidatePurchaseReceiptAsync(InventoryActor actor, InventoryPurchaseReceiptInput input) + { + await RequirePurchaseReceiptAccessAsync(actor, input); + var detail = await GetPurchaseOrderAsync(actor, input.PurchaseOrderId); var order = detail.Order; + if (order.IsDeleted || order.Revision != input.Revision || order.Status is not (1 or 2)) throw new InventoryException(409, "PurchaseOrderStateConflict"); + var vendor = await GetAsync(actor, order.VendorId); + if (vendor.IsDeleted) throw new InventoryException(409, "VendorUnavailable"); + await VendorContactAsync(actor, vendor.ContactId, false); + foreach (var group in input.Lines.GroupBy(l => l.PurchaseOrderItemId)) + { + var source = detail.Lines.Single(l => l.Id == group.Key); + if (group.Sum(l => l.Quantity) > source.QuantityOrdered - source.QuantityReceived) throw new InventoryException(409, "PurchaseOrderOverReceipt"); + var item = await GetAsync(actor, source.ItemId); + if (item.IsDeleted || !item.IsActive || Decode(item).CurrencyCode != order.CurrencyCode) throw new InventoryException(409, "ItemCurrencyMismatch"); + foreach (var line in group) + { + if (item.TrackingMode == 1 && (line.Quantity != 1 || line.Asset == null) || item.TrackingMode == 0 && line.Asset != null) throw new InventoryException(400, "SerializedQuantity"); + if (line.Asset != null) { Text(line.Asset.SerialNumber); if (line.Asset.AssetTag?.Length > 250 || line.Asset.Barcode?.Length > 250) throw new InventoryException(400, "InvalidAsset"); } + if (line.LotId != null) + { + var lot = await GetAsync(actor, line.LotId); var lotDetails = Decode(lot); + if (lot.ItemId != item.Id || lot.IsDeleted || lot.ExpiresOn <= Now) throw new InventoryException(409, "LotMismatch"); + if (lotDetails.UnitCost.HasValue && lotDetails.UnitCost != Decode(source).UnitCost) throw new InventoryException(409, "LotCostMismatch"); + if (lotDetails.VendorId != null && lotDetails.VendorId != order.VendorId) throw new InventoryException(409, "LotVendorMismatch"); + } + else if (item.RequiresLotTracking) throw new InventoryException(400, "LotRequired"); + if (line.Asset != null && (line.Asset.ExpiresOn <= Now || item.RequiresExpiration && !line.Asset.ExpiresOn.HasValue)) throw new InventoryException(409, "ExpiryRequired"); + } + } + return detail; + } + public Task ReceivePurchaseOrderAsync(InventoryActor actor, InventoryPurchaseReceiptInput input) => TransactionAsync(actor, async events => + { + await RequirePurchaseReceiptAccessAsync(actor, input); + input = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(input)); + var result = await OperationAsync(actor, input.RequestId, new { Kind = "PurchaseReceive", input }, async (operation, pending) => + { + var detail = await ValidatePurchaseReceiptAsync(actor, input); + if (await RequiresWitnessAsync(actor, input.Lines.Select(l => detail.Lines.Single(p => p.Id == l.PurchaseOrderItemId).ItemId))) + { + operation.Content = JsonConvert.SerializeObject(new InventoryOperationContent { PendingKind = "PurchaseReceive", PendingPurchaseReceipt = input, PerformerId = actor.UserId }); + await SaveAsync(actor, operation, false); await AuditAsync(actor, operation, "InventoryPurchaseReceiptWitnessRequested"); + return new InventoryResult { AwaitingWitness = true, PurchaseOrderId = detail.Order.Id }; + } + return await CompletePurchaseReceiptAsync(actor, operation, input, pending); + }, events); + // A successful retry can redeliver only the original durable identities, never recreate a receipt. + foreach (var id in result.OutboxIds ?? new()) if (!events.Contains(id)) events.Add(id); + return result; + }); + private async Task CompletePurchaseReceiptAsync(InventoryActor actor, InventoryOperation operation, InventoryPurchaseReceiptInput input, List events, + string performer = null, string witness = null, string attestation = null) + { + var detail = await ValidatePurchaseReceiptAsync(actor, input); + var command = new InventoryCommand { RequestId = input.RequestId }; var kits = new List(); + foreach (var line in input.Lines) + { + var source = detail.Lines.Single(l => l.Id == line.PurchaseOrderItemId); var cost = Decode(source).UnitCost; + var item = await GetAsync(actor, source.ItemId); string assetId = null; + if (line.Asset != null) + { + var asset = await CreateUnreceivedAssetAsync(actor, item, null, line.LotId, line.Asset.ExpiresOn, new InventoryAssetContent { + SerialNumber = line.Asset.SerialNumber, AssetTag = line.Asset.AssetTag, Barcode = line.Asset.Barcode, AcquisitionCost = cost }, performer); + assetId = asset.Id; if (item.IsKit) kits.Add(assetId); + } + command.Lines.Add(new InventoryPosting { ItemId = item.Id, AssetId = assetId, LotId = line.LotId, ToLocationId = line.LocationId, Quantity = line.Quantity, + UnitCost = cost, Type = InventoryTransactionType.Receive, ReferenceType = InventoryReferenceType.PurchaseOrder, ReferenceId = detail.Order.Id, PurchaseOrderItemId = source.Id }); + } + await ValidateCommandAsync(actor, command, purchasing: true); + var result = await PostLinesAsync(actor, operation, command, events, performer, witness, attestation); + foreach (var kit in kits) await CreateKitContainerAsync(actor, kit, performer); + foreach (var group in input.Lines.GroupBy(l => l.PurchaseOrderItemId)) + { + var source = detail.Lines.Single(l => l.Id == group.Key); source.QuantityReceived += group.Sum(l => l.Quantity); await SaveAsync(actor, source, false); + } + var order = detail.Order; order.Status = detail.Lines.All(l => l.QuantityReceived == l.QuantityOrdered) ? 3 : 2; order.ReceivedOn = Now; + await SaveAsync(actor, order, false); await AuditAsync(actor, order, "InventoryPurchaseOrderReceived"); + var entry = await _outbox.EnqueueAsync(actor.DepartmentId, "Inventory", new DomainEventEnvelope { + EventName = WorkflowTriggerEventType.InventoryPurchaseOrderReceived.ToString(), SchemaVersion = 1, AggregateType = "InventoryPurchaseOrder", AggregateId = order.Id, + Trigger = WorkflowTriggerEventType.InventoryPurchaseOrderReceived, CorrelationId = operation.Id, OccurredOn = Now, + Payload = new { InventoryEvent = true, PurchaseOrderId = order.Id, order.VendorId, ReceiptId = operation.Id, PurchaseOrderStatus = order.Status, + LineCount = input.Lines.Select(l => l.PurchaseOrderItemId).Distinct().Count(), order.CurrencyCode, OccurredOn = Now } }); + events.Add(entry.DomainEventOutboxId); result.PurchaseOrderId = order.Id; result.OutboxIds = events.ToList(); return result; + } + } +} diff --git a/Core/Resgrid.Services/InventoryPurchasing.cs b/Core/Resgrid.Services/InventoryPurchasing.cs new file mode 100644 index 000000000..dfc99b2f5 --- /dev/null +++ b/Core/Resgrid.Services/InventoryPurchasing.cs @@ -0,0 +1,142 @@ +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 + { + private async Task HasOpenPurchaseLineAsync(InventoryActor actor, string itemId) + { + foreach (var line in (await _store.RelatedAsync(actor.DepartmentId, "ItemId", itemId)).Where(l => !l.IsDeleted && l.QuantityReceived < l.QuantityOrdered)) + if ((await _store.GetAsync(actor.DepartmentId, line.PurchaseOrderId)) is { IsDeleted: false, Status: 1 or 2 }) return true; + return false; + } + private async Task RequirePurchasingAccessAsync(InventoryActor actor, bool write) + { + await _auth.RequireAsync(actor, write, PermissionTypes.AdjustInventory); + await _auth.RequireAsync(actor, false, PermissionTypes.ContactView); + } + private async Task VendorContactAsync(InventoryActor actor, string contactId, bool reveal) + { + await _auth.RequireAsync(actor, false, PermissionTypes.ContactView); + if (_contacts == null || string.IsNullOrWhiteSpace(contactId) || contactId.Length > 128) throw new InventoryException(404, "VendorContactUnavailable"); + var contact = await _contacts.GetContactByIdAsync(contactId); + if (contact?.DepartmentId != actor.DepartmentId || contact.IsDeleted || contact.ContactType != 1) throw new InventoryException(404, "VendorContactUnavailable"); + if (reveal) + { + contact = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(contact)); + var wasPlain = !string.IsNullOrEmpty(contact.CompanyName) && !ProtectedDataEnvelope.HasEnvelopePrefix(contact.CompanyName); + var result = await _read.ResolveContactsForReadAsync(actor.DepartmentId, new[] { contact }, actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && wasPlain || ProtectedDataEnvelope.HasEnvelopePrefix(contact.CompanyName)) throw new InventoryException(403, "ProtectedDataRequired"); + } + return contact; + } + public async Task> GetVendorContactsAsync(InventoryActor actor) + { + await RequirePurchasingAccessAsync(actor, false); + if (_contacts == null) throw new InventoryException(404, "VendorContactUnavailable"); + var contacts = (await _contacts.GetAllContactsForDepartmentAsync(actor.DepartmentId)).Where(c => c.DepartmentId == actor.DepartmentId && c.ContactType == 1 && !c.IsDeleted).ToList(); + if (contacts.Count > 5000) throw new InventoryException(409, "InventoryTooLarge"); + var result = new List(); + foreach (var contact in contacts) result.Add(new() { Id = contact.ContactId, Name = (await VendorContactAsync(actor, contact.ContactId, true)).CompanyName }); + return result; + } + public Task SaveVendorAsync(InventoryActor actor, InventoryVendorInput input) => TransactionAsync(actor, async events => + { + await RequirePurchasingAccessAsync(actor, true); + if (input?.Details == null || input.Details.AccountNumber?.Length > 250 || input.Details.Note?.Length > 16000) throw new InventoryException(400, "InvalidVendor"); + await VendorContactAsync(actor, input.ContactId, true); + var row = input.Id == null ? New(actor) : await GetAsync(actor, input.Id); + if (input.Id != null && (row.Revision != input.Revision || row.IsDeleted)) throw new InventoryException(409, "RevisionConflict"); + if (input.Id != null && !string.Equals(row.ContactId, input.ContactId, StringComparison.OrdinalIgnoreCase)) throw new InventoryException(409, "VendorContactImmutable"); + if ((await _store.RelatedAsync(actor.DepartmentId, "ContactId", input.ContactId)).Any(v => !v.IsDeleted && v.Id != row.Id)) throw new InventoryException(409, "DuplicateVendor"); + row.ContactId = input.ContactId; row.Content = JsonConvert.SerializeObject(input.Details); + await SaveAsync(actor, row, input.Id == null); await AuditAsync(actor, row, "InventoryVendorSaved"); return await RevealAsync(actor, row); + }); + public async Task GetPurchaseOrderAsync(InventoryActor actor, string id) + { + var order = await GetAsync(actor, id); + var result = new InventoryPurchaseOrderDetail { Order = order }; + foreach (var line in (await _store.RelatedAsync(actor.DepartmentId, "PurchaseOrderId", id)).Where(l => !l.IsDeleted).OrderBy(l => l.LineNumber)) + result.Lines.Add(await RevealAsync(actor, line)); + result.Receipts = await GetByReferenceAsync(actor, InventoryReferenceType.PurchaseOrder, id); + return result; + } + public async Task SavePurchaseOrderAsync(InventoryActor actor, InventoryPurchaseOrderInput input) + { + var id = await TransactionAsync(actor, async events => + { + await RequirePurchasingAccessAsync(actor, true); + if (input == null || input.Revision < 0 || input.Lines == null || input.Lines.Count is < 1 or > 100 || input.Lines.Any(l => l == null) || input.Note?.Length > 16000) throw new InventoryException(400, "InvalidPurchaseOrder"); + Id(input.Id); Text(input.Number, 80); var currency = Currency(input.CurrencyCode) ?? throw new InventoryException(400, "CurrencyRequired"); + var vendor = await GetAsync(actor, input.VendorId); if (vendor.IsDeleted) throw new InventoryException(409, "VendorUnavailable"); + var contact = await VendorContactAsync(actor, vendor.ContactId, true); + var row = await _store.GetAsync(actor.DepartmentId, input.Id); + var insert = row == null; + if (insert && input.Revision != 0 || !insert && (row.IsDeleted || row.Status != 0 || row.Revision != input.Revision)) throw new InventoryException(409, "RevisionConflict"); + row ??= New(actor); row.Id = input.Id; row.VendorId = vendor.Id; row.CurrencyCode = currency; + foreach (var other in (await AllAsync(actor.DepartmentId)).Where(p => p.Id != row.Id)) + if (string.Equals(Decode(await RevealAsync(actor, other)).Number, input.Number.Trim(), StringComparison.OrdinalIgnoreCase)) throw new InventoryException(409, "DuplicatePurchaseOrderNumber"); + row.Content = JsonConvert.SerializeObject(new InventoryPurchaseOrderContent { Number = input.Number.Trim(), Note = input.Note, SupplierName = contact.CompanyName }); + await SaveAsync(actor, row, insert); + var old = (await _store.RelatedAsync(actor.DepartmentId, "PurchaseOrderId", row.Id)).Where(l => !l.IsDeleted).ToList(); + // Retire the old draft lines first; posted lines can never enter this path. + foreach (var line in old) { var retired = await RevealAsync(actor, line); retired.IsDeleted = true; await SaveAsync(actor, retired, false); } + var ids = new HashSet(); + for (var index = 0; index < input.Lines.Count; index++) + { + var line = input.Lines[index]; Quantity(line.QuantityOrdered); Cost(line.UnitCost); + if (line.Note?.Length > 16000) throw new InventoryException(400, "InvalidPurchaseOrder"); + var item = await GetAsync(actor, line.ItemId); var details = Decode(item); + if (item.IsDeleted || !item.IsActive || item.TrackingMode == 1 && decimal.Truncate(line.QuantityOrdered) != line.QuantityOrdered) throw new InventoryException(400, "InvalidItem"); + if (details.CurrencyCode != currency) throw new InventoryException(409, "ItemCurrencyMismatch"); + InventoryPurchaseOrderItem stored; + if (line.Id != null) + { + Id(line.Id); stored = old.SingleOrDefault(l => l.Id == line.Id) ?? throw new InventoryException(404, "PurchaseOrderLineUnavailable"); + stored = await GetAsync(actor, stored.Id); + } + else stored = New(actor); + if (!ids.Add(stored.Id)) throw new InventoryException(400, "DuplicatePurchaseOrderLine"); + stored.IsDeleted = false; stored.PurchaseOrderId = row.Id; stored.ItemId = item.Id; stored.LineNumber = index + 1; stored.QuantityOrdered = line.QuantityOrdered; + stored.Content = JsonConvert.SerializeObject(new InventoryPurchaseOrderItemContent { ItemName = details.Name, UnitOfMeasure = details.UnitOfMeasure, UnitCost = line.UnitCost, Note = line.Note }); + await SaveAsync(actor, stored, line.Id == null); + } + await AuditAsync(actor, row, "InventoryPurchaseOrderSaved"); return row.Id; + }); + return await GetPurchaseOrderAsync(actor, id); + } + public Task ChangePurchaseOrderStatusAsync(InventoryActor actor, InventoryPurchaseOrderChange input, InventoryPurchaseOrderStatus status) => TransactionAsync(actor, async events => + { + await RequirePurchasingAccessAsync(actor, true); + if (input == null || status is not (InventoryPurchaseOrderStatus.Ordered or InventoryPurchaseOrderStatus.Cancelled)) throw new InventoryException(400, "InvalidPurchaseOrder"); + Id(input.Id); + return await OperationAsync(actor, input.RequestId, new { Kind = "PurchaseOrderStatus", input, status }, async (op, pending) => + { + var order = await GetAsync(actor, input.Id); + if (order.IsDeleted || order.Revision != input.Revision || order.Status is 3 or 4 || status == InventoryPurchaseOrderStatus.Ordered && order.Status != 0) throw new InventoryException(409, "PurchaseOrderStateConflict"); + if (status == InventoryPurchaseOrderStatus.Ordered) + { + var vendor = await GetAsync(actor, order.VendorId); if (vendor.IsDeleted) throw new InventoryException(409, "VendorUnavailable"); + var contact = await VendorContactAsync(actor, vendor.ContactId, true); + var details = Decode(order); details.SupplierName = contact.CompanyName; order.Content = JsonConvert.SerializeObject(details); + var lines = (await _store.RelatedAsync(actor.DepartmentId, "PurchaseOrderId", order.Id)).Where(l => !l.IsDeleted).ToList(); + if (lines.Count is < 1 or > 100) throw new InventoryException(409, "InvalidPurchaseOrder"); + foreach (var line in lines) + { + var item = await GetAsync(actor, line.ItemId); + if (item.IsDeleted || !item.IsActive || Decode(item).CurrencyCode != order.CurrencyCode) throw new InventoryException(409, "ItemCurrencyMismatch"); + } + order.OrderedOn = Now; + } + order.Status = (int)status; await SaveAsync(actor, order, false); await AuditAsync(actor, order, "InventoryPurchaseOrderStatusChanged"); + return new InventoryResult { PurchaseOrderId = order.Id }; + }, events); + }); + } +} diff --git a/Core/Resgrid.Services/InventoryQueries.cs b/Core/Resgrid.Services/InventoryQueries.cs index 74e56d491..1d580a6b1 100644 --- a/Core/Resgrid.Services/InventoryQueries.cs +++ b/Core/Resgrid.Services/InventoryQueries.cs @@ -6,12 +6,41 @@ namespace Resgrid.Services { public sealed partial class InventoryModernizationService { + public async Task GetLegacyTransactionAsync(InventoryActor actor, int inventoryId) + { + await _auth.RequireAsync(actor); + if (inventoryId <= 0) throw new InventoryException(400, "InvalidIdentifier"); + var row = await _store.LegacyTransactionAsync(actor.DepartmentId, inventoryId); + if (row == null) return null; + try { await AuthorizeRowAsync(actor, row); } + catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { return null; } + return await RevealAsync(actor, row); + } + + public async Task> GetVisibleQuantitiesAsync(InventoryActor actor, System.Collections.Generic.IReadOnlyCollection itemIds) + { + await _auth.RequireAsync(actor); + if (itemIds == null || itemIds.Count > 500) throw new InventoryException(400, "InvalidPage"); + foreach (var id in itemIds) Id(id); + var totals = itemIds.Distinct().ToDictionary(id => id, _ => 0m); + if (totals.Count == 0) return totals; + var quantities = await _store.StockQuantitiesAsync(actor.DepartmentId, totals.Keys.ToArray()); + foreach (var location in quantities.GroupBy(q => q.LocationId)) + { + try { await LocationAsync(actor, location.Key); } + catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { continue; } + foreach (var quantity in location) if (totals.ContainsKey(quantity.ItemId)) totals[quantity.ItemId] += quantity.Quantity; + } + return totals; + } + 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.SourceId?.Length > 128 || filter.SourceType is < 0 or > 1 || filter.RecordKind is < 1 or > 2) throw new InventoryException(400, "InvalidUsageSource"); 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 }; diff --git a/Core/Resgrid.Services/InventoryRecordUsage.cs b/Core/Resgrid.Services/InventoryRecordUsage.cs new file mode 100644 index 000000000..c8cf6ce0b --- /dev/null +++ b/Core/Resgrid.Services/InventoryRecordUsage.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + public async Task RecordUsageWithinTransactionAsync(InventoryActor actor, RecordInventoryUsage usage) + { + if (_uow.Transaction == null) throw new InvalidOperationException("The source adapter must own a transaction."); + await _store.LockDepartmentAsync(actor.DepartmentId); + await _auth.RequireAsync(actor); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + if (usage == null || usage.DepartmentId != actor.DepartmentId || usage.SourceType != (int)InventoryUsageSourceType.RmsRecord || usage.RecordKind is not (1 or 2)) throw new InventoryException(400, "InvalidUsageSource"); + Id(usage.Id); Id(usage.SourceId); Id(usage.TransactionId); Quantity(usage.Quantity); + if (usage.RmsRevisionId != null) Id(usage.RmsRevisionId); + if (!Enum.IsDefined((InventoryUsageType)usage.UsageType)) throw new InventoryException(400, "InvalidUsageType"); + var transaction = await GetAsync(actor, usage.TransactionId); + await LocationAsync(actor, usage.SourceLocationId, true, PermissionTypes.AdjustInventory, historical: true); + if (transaction.ItemId != usage.ItemId || transaction.AssetId != usage.AssetId || transaction.LotId != usage.LotId || transaction.Quantity != usage.Quantity + || transaction.ReferenceType != (int)InventoryReferenceType.None && !(transaction.TransactionType == (int)InventoryTransactionType.Migrated && transaction.LegacyInventoryId.HasValue) + && (transaction.ReferenceType != (int)InventoryReferenceType.RmsRecord || transaction.ReferenceId != usage.SourceId)) + throw new InventoryException(409, "UsageTransactionMismatch"); + if ((await _store.RelatedAsync(actor.DepartmentId, "TransactionId", transaction.Id)).Count != 0) throw new InventoryException(409, "UsageAlreadyRecorded"); + if (usage.ReversesUsageId == null) + { + if (transaction.TransactionType != (int)InventoryTransactionType.Consume && !(transaction.TransactionType == (int)InventoryTransactionType.Migrated && transaction.LegacyInventoryId.HasValue) + || transaction.ReversesTransactionId != null || transaction.FromLocationId != usage.SourceLocationId || transaction.ToLocationId != null) + throw new InventoryException(409, "UsageTransactionMismatch"); + } + else + { + Id(usage.ReversesUsageId); + var original = await _store.GetAsync(actor.DepartmentId, usage.ReversesUsageId); + if (original == null || original.ReversesUsageId != null || original.SourceType != usage.SourceType || original.SourceId != usage.SourceId || original.RecordKind != usage.RecordKind + || original.ItemId != usage.ItemId || original.AssetId != usage.AssetId || original.LotId != usage.LotId || original.Quantity != usage.Quantity || original.UsageType != usage.UsageType + || original.SourceLocationId != usage.SourceLocationId || transaction.ReversesTransactionId != original.TransactionId || transaction.FromLocationId != null || transaction.ToLocationId != usage.SourceLocationId) + throw new InventoryException(409, "InvalidUsageReversal"); + if ((await _store.RelatedAsync(actor.DepartmentId, "ReversesUsageId", original.Id)).Count != 0) throw new InventoryException(409, "UsageAlreadyReversed"); + } + var item = await GetAsync(actor, usage.ItemId); + if (item.IsControlledSubstance) + { + await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances); + var operation = transaction.OperationId == null ? null : await _store.GetAsync(actor.DepartmentId, transaction.OperationId); + if (operation?.State != 2 || string.IsNullOrEmpty(operation.WitnessUserId) || operation.WitnessUserId == transaction.CreatedBy) throw new InventoryException(409, "IndependentWitnessRequired"); + } + usage.CreatedOn = transaction.OccurredOn; usage.CreatedBy = actor.UserId; + // Keep narrative, names, clinical detail and witness attestation in their original protected ledger. + var details = string.IsNullOrEmpty(usage.Content) ? new InventoryLabel() : Newtonsoft.Json.JsonConvert.DeserializeObject(usage.Content); + if (details?.Note?.Length > 16000) throw new InventoryException(400, "InvalidText"); + usage.Content = Newtonsoft.Json.JsonConvert.SerializeObject(new InventoryLabel { Note = details?.Note }); + await SaveAsync(actor, usage); await AuditAsync(actor, usage, "InventoryUsageRecorded"); + } + } +} diff --git a/Core/Resgrid.Services/InventoryReferences.cs b/Core/Resgrid.Services/InventoryReferences.cs index a796336ef..2eb23cf1e 100644 --- a/Core/Resgrid.Services/InventoryReferences.cs +++ b/Core/Resgrid.Services/InventoryReferences.cs @@ -9,9 +9,21 @@ namespace Resgrid.Services { public sealed partial class InventoryModernizationService { - private async Task ValidateReferenceAsync(InventoryActor actor, InventoryPosting line, bool joined, bool requireOpen = true) + private async Task ValidateReferenceAsync(InventoryActor actor, InventoryPosting line, bool joined, bool requireOpen = true, bool purchasing = false, bool counting = false) { + if (counting && line.Type == InventoryTransactionType.Count && line.ReferenceType == InventoryReferenceType.Count && line.CountItemId != null) + { + var source = await _store.GetAsync(actor.DepartmentId, line.CountItemId); + if (source == null || source.CountId != line.ReferenceId || source.ItemId != line.ItemId || source.AssetId != line.AssetId || source.LotId != line.LotId || source.LocationId != (line.FromLocationId ?? line.ToLocationId)) throw new InventoryException(409, "CountStateConflict"); + return; + } if (line.ReferenceType == InventoryReferenceType.None) return; + if (purchasing && line.ReferenceType == InventoryReferenceType.PurchaseOrder && line.Type == InventoryTransactionType.Receive && line.PurchaseOrderItemId != null) + { + var source = await _store.GetAsync(actor.DepartmentId, line.PurchaseOrderItemId); + if (source == null || source.IsDeleted || source.PurchaseOrderId != line.ReferenceId || source.ItemId != line.ItemId) throw new InventoryException(404, "PurchaseOrderLineUnavailable"); + 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; diff --git a/Core/Resgrid.Services/InventoryReportDocuments.cs b/Core/Resgrid.Services/InventoryReportDocuments.cs new file mode 100644 index 000000000..79eff65b7 --- /dev/null +++ b/Core/Resgrid.Services/InventoryReportDocuments.cs @@ -0,0 +1,92 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Resources; +using System.Text; +using Resgrid.Model.Inventories; + +namespace Resgrid.Services +{ + /// Printable documents from attended, authorized snapshots. No scripts, external resources or user HTML. + public static class InventoryReportDocuments + { + private static readonly ResourceManager Strings = new("Resgrid.Localization.Areas.User.Inventory.Inventory", typeof(Resgrid.Localization.SupportedLocales).Assembly); + public static string Text(string key, CultureInfo culture = null) + { + culture = ReportCulture(culture); + return Strings.GetResourceSet(CultureInfo.GetCultureInfo(culture.TwoLetterISOLanguageName), true, false)?.GetString(key) + ?? Strings.GetResourceSet(CultureInfo.GetCultureInfo("en"), true, false)?.GetString(key) ?? key; + } + private static CultureInfo ReportCulture(CultureInfo culture) + { + culture ??= CultureInfo.CurrentUICulture; + return Resgrid.Localization.SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName) ? culture : CultureInfo.GetCultureInfo("en"); + } + public static string Title(InventoryReportKind kind, CultureInfo culture = null) => Text("M5Report" + kind, culture); + private static string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.InvariantCulture)); + private static string Page(InventoryReportKind kind, string body, CultureInfo culture) => "" + H(Title(kind, culture)) + "

" + H(Title(kind, culture)) + "

" + body + ""; + public static string Locked(InventoryReportKind kind, CultureInfo culture = null) + { + culture = ReportCulture(culture); + return Page(kind, "

" + H(Text("M5ScheduledReportProtected", culture)) + "

", culture); + } + public static string Build(InventoryReport report, CultureInfo culture = null) + { + if (report == null || !Enum.IsDefined(report.Kind) || report.Columns.Count == 0 || report.Columns.Count > 40 || report.Rows.Count > 5000) + throw new InventoryException(400, "InvalidReport"); + culture = ReportCulture(culture); + var body = new StringBuilder("

" + H(Text("M4AsOfUtc", culture)) + ": " + H(report.GeneratedOn.ToString("u", CultureInfo.InvariantCulture)) + "

" + H(Text("M5ReportScope", culture)) + "

"); + if (report.FromUtc.HasValue || report.UntilUtc.HasValue) + body.Append("

").Append(H(Text("M5ReportRange", culture))).Append(": ").Append(H(report.FromUtc?.ToString("u", CultureInfo.InvariantCulture) ?? "—")).Append(" / ").Append(H(report.UntilUtc?.ToString("u", CultureInfo.InvariantCulture) ?? "—")).Append("

"); + if (report.Kind is InventoryReportKind.Usage or InventoryReportKind.LowStock or InventoryReportKind.Issuance or InventoryReportKind.Expiration) + body.Append("

").Append(H(Text("M5ReportMethod" + report.Kind, culture))).Append("

"); + if (report.Kind == InventoryReportKind.Valuation) + { + body.Append("

").Append(H(Text("M4ValuationScope", culture))).Append("

"); + if (report.Rows.Any(row => row.TryGetValue("Amount", out var quantity) && quantity is decimal number && number < 0)) body.Append("

").Append(H(Text("M4NegativeStock", culture))).Append("

"); + } + body.Append(""); + foreach (var column in report.Columns) body.Append(""); + body.Append(""); + if (report.Rows.Count == 0) body.Append(""); + foreach (var row in report.Rows) + { + body.Append(""); + foreach (var column in report.Columns) body.Append(""); + body.Append(""); + if (report.Kind == InventoryReportKind.ControlledSubstanceLog) + body.Append(""); + } + body.Append("
").Append(H(Text(column, culture))).Append("
").Append(H(Text("NoRows", culture))).Append("
").Append(H(Format(row.TryGetValue(column, out var value) ? value : null, column, culture))).Append("
").Append(H(Text("M5PerformerSignature", culture))).Append(": ____________________").Append(H(Text("M5WitnessSignature", culture))).Append(": ____________________
"); + if (report.Totals.Count > 0) + { + body.Append(""); + foreach (var column in new[] { "M4Currency", "M4KnownValue", "M4UncostedRows" }) body.Append(""); + body.Append(""); + foreach (var total in report.Totals) body.Append(""); + body.Append("
").Append(H(Text(column, culture))).Append("
").Append(H(total.CurrencyCode ?? Text("M4UnspecifiedCurrency", culture))).Append("").Append(H(total.KnownValue.ToString("0.######", culture))).Append("").Append(total.UncostedRows.ToString(culture)).Append("
"); + } + return Page(report.Kind, body.ToString(), culture); + } + private static string Format(object value, string column, CultureInfo culture) + { + if (value == null) return Text(column == "M4Currency" ? "M4UnspecifiedCurrency" : column is "UnitCost" or "M4KnownValue" ? "M4UnknownCost" : "M5NotRecorded", culture); + return value switch + { + InventoryTrackingMode mode => Text("Tracking" + (int)mode, culture), + InventoryAssetStatus status => Text("Status" + (int)status, culture), + InventoryTransactionType movement => Text("Movement" + (int)movement, culture), + InventoryUsageType usage => Text("RecordUsageType" + (int)usage, culture), + InventoryIssuanceStatus issuance => Text("IssuanceStatus" + (int)issuance, culture), + InventoryReferenceType reference => Text("M5ReferenceType" + (int)reference, culture), + DateTime date => date.ToString("u", CultureInfo.InvariantCulture), + decimal number => number.ToString("0.######", culture), + IFormattable formattable => formattable.ToString(null, culture), + _ => Convert.ToString(value, culture) + }; + } + } +} diff --git a/Core/Resgrid.Services/InventoryReports.cs b/Core/Resgrid.Services/InventoryReports.cs new file mode 100644 index 000000000..f4ed0af79 --- /dev/null +++ b/Core/Resgrid.Services/InventoryReports.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 async Task BuildReportAsync(InventoryActor actor, InventoryReportInput input) + { + await _auth.RequireAsync(actor); + if (input == null || !Enum.IsDefined(input.Kind)) throw new InventoryException(400, "InvalidReport"); + foreach (var id in new[] { input.LocationId, input.ItemId }.Where(x => x != null)) Id(id); + if (input.UserId != null && (string.IsNullOrWhiteSpace(input.UserId) || input.UserId.Length > 128) || input.UnitId <= 0 + || input.UserId != null && input.UnitId.HasValue) throw new InventoryException(400, "InvalidReportFilter"); + if ((input.FromUtc.HasValue || input.UntilUtc.HasValue) && input.Kind is InventoryReportKind.OnHand or InventoryReportKind.LowStock or InventoryReportKind.Valuation) + throw new InventoryException(400, "InvalidReportFilter"); + if (input.FromUtc?.Kind == DateTimeKind.Local || input.UntilUtc?.Kind == DateTimeKind.Local) throw new InventoryException(400, "InvalidReportRange"); + if (_uow.Transaction != null) throw new InvalidOperationException("Inventory reports own their read transaction."); + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); + await _store.LockDepartmentAsync(actor.DepartmentId); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + if (!await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + if (input.Kind == InventoryReportKind.ControlledSubstanceLog) await _auth.RequireAsync(actor, false, PermissionTypes.ManageControlledSubstances); + var report = await ReadInventoryReportAsync(actor, input); + await _auth.RequireAsync(actor, false, input.Kind == InventoryReportKind.ControlledSubstanceLog ? PermissionTypes.ManageControlledSubstances + : input.Kind == InventoryReportKind.Valuation ? PermissionTypes.AdjustInventory : null); + if (!await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + _uow.CommitChanges(); return report; + } + catch { _uow.DiscardChanges(); throw; } + } + + private async Task ReadInventoryReportAsync(InventoryActor actor, InventoryReportInput input) + { + var report = new InventoryReport { Kind = input.Kind, GeneratedOn = Now }; + var history = input.Kind is InventoryReportKind.Usage or InventoryReportKind.TransferHistory or InventoryReportKind.Issuance or InventoryReportKind.ControlledSubstanceLog; + if (history || input.FromUtc.HasValue || input.UntilUtc.HasValue) + { + var until = input.UntilUtc ?? report.GeneratedOn.Date.AddDays(1); + if (!input.FromUtc.HasValue && until < DateTime.MinValue.AddDays(30)) throw new InventoryException(400, "InvalidReportRange"); + var from = input.FromUtc ?? until.AddDays(-30); + if (from >= until || until - from > TimeSpan.FromDays(366)) throw new InventoryException(400, "InvalidReportRange"); + report.FromUtc = DateTime.SpecifyKind(from, DateTimeKind.Utc); report.UntilUtc = DateTime.SpecifyKind(until, DateTimeKind.Utc); + } + if (input.Kind == InventoryReportKind.Expiration && !input.FromUtc.HasValue && !input.UntilUtc.HasValue) + report.UntilUtc = report.GeneratedOn.Date.AddDays(31); // Include already expired stock and the next 30 UTC calendar days. + bool InRange(DateTime date) => (!report.FromUtc.HasValue || date >= report.FromUtc) && (!report.UntilUtc.HasValue || date < report.UntilUtc); + bool ItemMatches(string id) => input.ItemId == null || input.ItemId == id; + void Columns(params string[] columns) => report.Columns.AddRange(columns); + void Add(params (string Key, object Value)[] cells) + { + if (report.Rows.Count >= 5000) throw new InventoryException(409, "InventoryTooLarge"); + report.Rows.Add(cells.ToDictionary(x => x.Key, x => x.Value)); + } + var itemCache = new Dictionary(); + var lotCache = new Dictionary(); + var locations = new Dictionary<(string Id, bool Historical), (InventoryLocation Location, InventoryLocation Holder)>(); + var authorizedSources = new List(); + var authorizedRecipients = new List(); + async Task Item(string id) + { + if (!itemCache.TryGetValue(id, out var item)) itemCache[id] = item = await GetAsync(actor, id); + return item; + } + async Task Lot(string id) + { + if (id == null) return null; + if (!lotCache.TryGetValue(id, out var lot)) lotCache[id] = lot = await GetAsync(actor, id); + return lot; + } + async Task<(InventoryLocation Location, InventoryLocation Holder)> Location(string id, bool historical = false) + { + if (id == null) return (null, null); + if (locations.TryGetValue((id, historical), out var cached)) return cached; + var stored = await LocationAsync(actor, id, historical: historical); + var holder = await EffectiveLocationAsync(actor.DepartmentId, stored, historical: historical); + var revealed = await RevealAsync(actor, stored); + locations[(id, historical)] = (revealed, holder); return (revealed, holder); + } + async Task VisibleLocation(string id, bool historical = false, bool filterHolder = true) + { + if (id == null || input.LocationId != null && id != input.LocationId) return false; + try + { + // Scope denials are filtered before the protected content is read. ADP failures remain errors. + var location = await LocationAsync(actor, id, historical: historical); + var holder = await EffectiveLocationAsync(actor.DepartmentId, location, historical: historical); + return !filterHolder || (input.UnitId == null || holder.UnitId == input.UnitId) && (input.UserId == null || holder.UserId == input.UserId); + } + catch (InventoryException error) when (error.StatusCode is 403 or 404 || error.Code == "LocationUnavailable") { return false; } + } + async Task Authorized(InventoryRow row) + { + try { await AuthorizeRowAsync(actor, row); return true; } + catch (InventoryException error) when (error.StatusCode is 403 or 404 || error.Code == "LocationUnavailable") { return false; } + } + async Task LocationName(string id, bool historical = false) => id == null ? null : Decode((await Location(id, historical)).Location).Name ?? id; + async Task LotNumber(string id) => id == null ? null : Decode(await Lot(id)).LotNumber ?? id; + if (input.LocationId != null) await Location(input.LocationId, history); + if (input.ItemId != null) await Item(input.ItemId); + if (input.UserId != null || input.UnitId.HasValue) + { + var holder = new InventoryLocation { DepartmentId = actor.DepartmentId, UserId = input.UserId, UnitId = input.UnitId, + LocationType = (int)(input.UnitId.HasValue ? InventoryLocationType.Unit : InventoryLocationType.Personnel) }; + await _auth.ValidateHolderAsync(actor, holder); authorizedRecipients.Add(holder); + } + + if (input.Kind is InventoryReportKind.OnHand or InventoryReportKind.Expiration) + { + Columns("Name", "UnitOfMeasure", "Location", "Amount", "TrackingMode", "AssetId", "Batch", "SerialNumber", "Status", "Expiration"); + if (input.Kind == InventoryReportKind.Expiration) report.Columns.Add("M5DaysUntilExpiry"); + async Task StockRow(string itemId, string locationId, string lotId, decimal quantity, InventoryAsset asset = null) + { + if (!ItemMatches(itemId) || !await VisibleLocation(locationId)) return; + var item = await Item(itemId); var detail = Decode(item); var lot = await Lot(lotId); + var expires = asset?.ExpiresOn ?? lot?.ExpiresOn; + if (lot?.ExpiresOn != null && (!expires.HasValue || lot.ExpiresOn < expires)) expires = lot.ExpiresOn; + if (input.Kind == InventoryReportKind.Expiration && (!expires.HasValue || quantity <= 0 || !InRange(expires.Value))) return; + var assetDetails = asset == null ? null : Decode(asset); + var cells = new List<(string Key, object Value)> { ("Name", detail.Name), ("UnitOfMeasure", detail.UnitOfMeasure), ("Location", await LocationName(locationId)), ("Amount", quantity), + ("TrackingMode", (InventoryTrackingMode)item.TrackingMode), ("AssetId", asset?.Id), ("Batch", await LotNumber(lotId)), ("SerialNumber", assetDetails?.SerialNumber), + ("Status", asset == null ? null : (object)(InventoryAssetStatus)asset.Status), ("Expiration", expires) }; + if (input.Kind == InventoryReportKind.Expiration) cells.Add(("M5DaysUntilExpiry", expires.HasValue ? (object)(expires.Value.Date - report.GeneratedOn.Date).Days : null)); + Add(cells.ToArray()); + } + foreach (var stock in await AllAsync(actor.DepartmentId)) + if (!stock.IsDeleted && stock.Quantity != 0) await StockRow(stock.ItemId, stock.LocationId, stock.LotId, stock.Quantity); + foreach (var stored in await AllAsync(actor.DepartmentId)) + if (!stored.IsDeleted && stored.Status is 0 or 1 or 2 or 3 && ItemMatches(stored.ItemId) && await VisibleLocation(stored.CurrentLocationId)) + await StockRow(stored.ItemId, stored.CurrentLocationId, stored.LotId, 1, await RevealAsync(actor, stored)); + } + else if (input.Kind == InventoryReportKind.LowStock) + { + Columns("Name", "UnitOfMeasure", "OnHand", "ReorderPoint", "M5Shortfall", "M5ReorderQuantity"); + var totals = new Dictionary(); + var assetTotals = new Dictionary(); + foreach (var stock in await AllAsync(actor.DepartmentId)) + { + if (stock.IsDeleted || !ItemMatches(stock.ItemId) || !await VisibleLocation(stock.LocationId)) continue; + totals.TryGetValue(stock.ItemId, out var quantity); totals[stock.ItemId] = quantity + stock.Quantity; + await Location(stock.LocationId); + } + foreach (var asset in await AllAsync(actor.DepartmentId)) + { + if (asset.IsDeleted || asset.Status is not (0 or 1 or 2 or 3) || !ItemMatches(asset.ItemId) || !await VisibleLocation(asset.CurrentLocationId)) continue; + assetTotals.TryGetValue(asset.ItemId, out var quantity); assetTotals[asset.ItemId] = quantity + 1; + await Location(asset.CurrentLocationId); + } + foreach (var stored in await AllAsync(actor.DepartmentId)) + { + if (stored.IsDeleted || !stored.IsActive || !ItemMatches(stored.Id)) continue; + var details = Decode(await Item(stored.Id)); var threshold = details.ReorderPoint ?? details.MinLevel; + (stored.TrackingMode == (int)InventoryTrackingMode.Bulk ? totals : assetTotals).TryGetValue(stored.Id, out var quantity); + if (threshold.HasValue && quantity <= threshold.Value) Add(("Name", details.Name), ("UnitOfMeasure", details.UnitOfMeasure), ("OnHand", quantity), + ("ReorderPoint", threshold), ("M5Shortfall", threshold.Value - quantity), ("M5ReorderQuantity", details.ReorderQuantity)); + } + } + else if (input.Kind == InventoryReportKind.Valuation) + { + Columns("Name", "Location", "AssetId", "LotId", "Amount", "UnitCost", "M4Currency", "M4KnownValue"); + var valuation = await ReadValuationAsync(actor, input.LocationId); + foreach (var line in valuation.Lines) + { + if (!ItemMatches(line.ItemId) || !await VisibleLocation(line.LocationId)) continue; + Add(("Name", line.ItemName), ("Location", await LocationName(line.LocationId)), ("AssetId", line.AssetId), ("LotId", line.LotId), + ("Amount", line.Quantity), ("UnitCost", line.UnitCost), ("M4Currency", line.CurrencyCode), ("M4KnownValue", line.Value)); + } + report.Totals = report.Rows.GroupBy(row => row["M4Currency"] as string).Select(group => new InventoryValuationTotal { + CurrencyCode = group.Key, KnownValue = group.Sum(row => (decimal?)row["M4KnownValue"] ?? 0), UncostedRows = group.Count(row => row["M4KnownValue"] == null) }).OrderBy(x => x.CurrencyCode).ToList(); + } + else if (input.Kind == InventoryReportKind.Issuance) + { + Columns("M5IssuanceId", "Name", "UnitOfMeasure", "M5PersonId", "M5UnitId", "Location", "M5IssuedOn", "Amount", "M5ReturnedQuantity", "M5OutstandingQuantity", "ExpectedReturn", "M5ReturnedOn", "Status", "Note"); + foreach (var stored in (await AllAsync(actor.DepartmentId)).OrderBy(x => x.IssuedOn).ThenBy(x => x.Id)) + { + if (stored.IsDeleted || !ItemMatches(stored.ItemId) || !InRange(stored.IssuedOn) || input.UserId != null && input.UserId != stored.IssuedToUserId + || input.UnitId.HasValue && input.UnitId != stored.IssuedToUnitId || !await VisibleLocation(stored.LocationId, true, false) || !await Authorized(stored)) continue; + // The source location alone does not authorize the personnel/unit receiving the equipment. + var recipient = new InventoryLocation { DepartmentId = actor.DepartmentId, UserId = stored.IssuedToUserId, UnitId = stored.IssuedToUnitId, + LocationType = (int)(stored.IssuedToUnitId.HasValue ? InventoryLocationType.Unit : InventoryLocationType.Personnel) }; + if (!await _auth.CanLocationAsync(actor, recipient)) continue; + authorizedRecipients.Add(recipient); + var issuance = await RevealAsync(actor, stored); var item = Decode(await Item(issuance.ItemId)); + Add(("M5IssuanceId", issuance.Id), ("Name", item.Name), ("UnitOfMeasure", item.UnitOfMeasure), ("M5PersonId", issuance.IssuedToUserId), ("M5UnitId", issuance.IssuedToUnitId), + ("Location", await LocationName(issuance.LocationId, true)), ("M5IssuedOn", issuance.IssuedOn), ("Amount", issuance.Quantity), ("M5ReturnedQuantity", issuance.ReturnedQuantity), + ("M5OutstandingQuantity", issuance.Status is 0 or 2 ? issuance.Quantity - issuance.ReturnedQuantity : 0), ("ExpectedReturn", issuance.ExpectedReturnOn), + ("M5ReturnedOn", issuance.ReturnedOn), ("Status", (InventoryIssuanceStatus)issuance.Status), ("Note", JObject.Parse(issuance.Content ?? "{}").Value("Note"))); + } + } + else + { + Columns("RecordUsageRecordedUtc", "RecordUsageLedgerEntry", "Name", "UnitOfMeasure", "Movement", "Amount", "FromLocation", "ToLocation", "M5Performer", "M5ReversesTransaction"); + if (input.Kind == InventoryReportKind.Usage) report.Columns.AddRange(new[] { "RecordUsageType", "M5UsageSource", "M5SourceId", "M5CallId", "M5RevisionId", "Note" }); + if (input.Kind == InventoryReportKind.ControlledSubstanceLog) report.Columns.AddRange(new[] { "SerialNumber", "Batch", "M5DeaSchedule", "M5FromBefore", "M5FromAfter", "M5ToBefore", "M5ToAfter", "M5Witness", "M5WitnessedOn", "Attestation", "Note" }); + var transactions = await AllAsync(actor.DepartmentId); + var byId = transactions.ToDictionary(x => x.Id); + var usageByTransaction = new Dictionary(); + if (input.Kind == InventoryReportKind.Usage) + foreach (var usage in await AllAsync(actor.DepartmentId)) + { + if (!ItemMatches(usage.ItemId) || !byId.TryGetValue(usage.TransactionId, out var movement) || !InRange(movement.OccurredOn) || !await Authorized(usage)) continue; + usageByTransaction[usage.TransactionId] = usage; authorizedSources.Add(usage); + } + bool UsageMovement(InventoryTransaction movement) => movement.TransactionType == (int)InventoryTransactionType.Consume || movement.TransactionType == (int)InventoryTransactionType.WriteOff + || movement.TransactionType == (int)InventoryTransactionType.Migrated && movement.FromLocationId != null && movement.ToLocationId == null; + foreach (var stored in transactions.OrderBy(x => x.OccurredOn).ThenBy(x => x.EntryId).ThenBy(x => x.Id)) + { + if (!ItemMatches(stored.ItemId) || !InRange(stored.OccurredOn)) continue; + var original = stored.ReversesTransactionId != null && byId.TryGetValue(stored.ReversesTransactionId, out var reversed) ? reversed : null; + usageByTransaction.TryGetValue(stored.Id, out var usage); + if (input.Kind == InventoryReportKind.Usage && usage == null && !UsageMovement(stored) && (original == null || !UsageMovement(original))) continue; + if (input.Kind == InventoryReportKind.TransferHistory && stored.TransactionType != (int)InventoryTransactionType.Transfer && original?.TransactionType != (int)InventoryTransactionType.Transfer) continue; + if (!await Authorized(stored) || !await VisibleLocation(stored.FromLocationId, true) && !await VisibleLocation(stored.ToLocationId, true)) continue; + var item = await Item(stored.ItemId); + if (input.Kind == InventoryReportKind.ControlledSubstanceLog && !item.IsControlledSubstance) continue; + var transaction = await RevealAsync(actor, stored); var details = JObject.Parse(transaction.Content ?? "{}"); var itemDetails = Decode(item); + var quantity = input.Kind == InventoryReportKind.Usage && transaction.FromLocationId == null ? -transaction.Quantity : transaction.Quantity; + var cells = new List<(string Key, object Value)> { ("RecordUsageRecordedUtc", transaction.OccurredOn), ("RecordUsageLedgerEntry", transaction.Id), + ("Name", details.Value("ItemName") ?? itemDetails.Name), ("UnitOfMeasure", details.Value("UnitOfMeasure") ?? itemDetails.UnitOfMeasure), + ("Movement", (InventoryTransactionType)transaction.TransactionType), ("Amount", quantity), ("FromLocation", await LocationName(transaction.FromLocationId, true)), + ("ToLocation", await LocationName(transaction.ToLocationId, true)), ("M5Performer", details.Value("PerformerId") ?? transaction.CreatedBy), ("M5ReversesTransaction", transaction.ReversesTransactionId) }; + if (input.Kind == InventoryReportKind.Usage) + { + // The ledger is the only quantity source. Optional source metadata never adds another movement. + var source = usage == null ? transaction.ReferenceType == (int)InventoryReferenceType.Legacy ? InventoryReferenceType.Legacy : InventoryReferenceType.None : InventoryReferenceType.RmsRecord; + var note = usage == null ? transaction.ReferenceType == (int)InventoryReferenceType.RmsRecord ? null : details.Value("Note") : Decode(await RevealAsync(actor, usage)).Note; + cells.AddRange(new (string, object)[] { ("RecordUsageType", usage == null ? null : (object)(InventoryUsageType)usage.UsageType), ("M5UsageSource", source), + ("M5SourceId", usage?.SourceId), ("M5CallId", usage?.CallId), ("M5RevisionId", usage?.RmsRevisionId), ("Note", note) }); + } + if (input.Kind == InventoryReportKind.ControlledSubstanceLog) + cells.AddRange(new (string, object)[] { ("SerialNumber", details.Value("SerialNumber")), ("Batch", await LotNumber(transaction.LotId)), ("M5DeaSchedule", itemDetails.DeaSchedule), + ("M5FromBefore", transaction.FromQuantityBefore), ("M5FromAfter", transaction.FromQuantityAfter), ("M5ToBefore", transaction.ToQuantityBefore), ("M5ToAfter", transaction.ToQuantityAfter), + ("M5Witness", details.Value("WitnessUserId")), ("M5WitnessedOn", details.Value("WitnessedOn")), ("Attestation", details.Value("Attestation")), ("Note", details.Value("Note")) }); + Add(cells.ToArray()); + } + } + foreach (var location in locations.Keys) await LocationAsync(actor, location.Id, historical: location.Historical); + foreach (var usage in authorizedSources) await AuthorizeRowAsync(actor, usage); + foreach (var recipient in authorizedRecipients) if (!await _auth.CanLocationAsync(actor, recipient)) throw new InventoryException(403, "PermissionRequired"); + return report; + } + } +} diff --git a/Core/Resgrid.Services/InventoryScheduledReportService.cs b/Core/Resgrid.Services/InventoryScheduledReportService.cs new file mode 100644 index 000000000..1e1bdbabb --- /dev/null +++ b/Core/Resgrid.Services/InventoryScheduledReportService.cs @@ -0,0 +1,140 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public sealed class InventoryScheduledReportService : IInventoryScheduledReportService + { + private readonly IScheduledTasksService _tasks; + private readonly IInventoryAuthorizationService _authorization; + private readonly IInventoryMigrationService _migration; + private readonly IInventoryOperationsService _reports; + private readonly IDepartmentDataProtectionService _protection; + private readonly IUsersService _users; + private readonly IUserProfileService _profiles; + private readonly IPdfProvider _pdf; + // A weak, request-object-bound proof retains only comparison metadata, never report rows, HTML or PDF bytes. + private readonly ConditionalWeakTable _proofs = new(); + private readonly object _proofGate = new(); + + public InventoryScheduledReportService(IScheduledTasksService tasks, IInventoryAuthorizationService authorization, + IInventoryMigrationService migration, IInventoryOperationsService reports, IDepartmentDataProtectionService protection, + IUsersService users, IUserProfileService profiles, IPdfProvider pdf) + { _tasks = tasks; _authorization = authorization; _migration = migration; _reports = reports; _protection = protection; _users = users; _profiles = profiles; _pdf = pdf; } + + private sealed class DeliveryProof + { + public int TaskId; + public int DepartmentId; + public string UserId; + public string Data; + public string ScheduleHash; + public string Email; + public string CultureName; + public bool Protected; + public string ProjectionHash; + public InventoryReportKind Kind; + } + + public async Task BuildAsync(ScheduledTask queuedTask) + { + var proof = await ReadCurrentAsync(queuedTask); + var culture = CultureInfo.GetCultureInfo(proof.CultureName); + string html; + if (proof.Protected) html = InventoryReportDocuments.Locked(proof.Kind, culture); + else + { + var report = await ReportAsync(proof); + proof.ProjectionHash = ProjectionHash(report); + html = InventoryReportDocuments.Build(report, culture); + } + var bytes = _pdf.ConvertHtmlToPdf(html); + if (bytes == null || bytes.Length < 5 || Encoding.ASCII.GetString(bytes, 0, 5) != "%PDF-") throw new InventoryException(409, "ReportGenerationFailed"); + await RevalidateAsync(queuedTask, proof); + lock (_proofGate) + { + if (_proofs.TryGetValue(queuedTask, out _)) throw new InventoryException(409, "ReportDeliveryAlreadyBuilt"); + _proofs.Add(queuedTask, proof); + } + return new EmailNotification { To = proof.Email, Subject = InventoryReportDocuments.Title(proof.Kind, culture), + AttachmentName = "inventory-" + proof.Kind.ToString().ToLowerInvariant() + "-" + DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".pdf", AttachmentData = bytes }; + } + + public async Task ValidateAsync(ScheduledTask queuedTask) + { + if (queuedTask == null || !_proofs.TryGetValue(queuedTask, out var proof)) throw new InventoryException(409, "ReportDeliveryNotBuilt"); + await RevalidateAsync(queuedTask, proof); + lock (_proofGate) _proofs.Remove(queuedTask); + } + + private async Task ReadCurrentAsync(ScheduledTask queuedTask) + { + if (queuedTask == null || queuedTask.ScheduledTaskId <= 0) throw new InventoryException(403, "ReportDeliveryUnavailable"); + var task = await _tasks.GetScheduledTaskByIdAsync(queuedTask.ScheduledTaskId); + if (task == null || !task.Active || task.ScheduledTaskId != queuedTask.ScheduledTaskId || task.DepartmentId != queuedTask.DepartmentId + || task.UserId != queuedTask.UserId || task.Data != queuedTask.Data || task.TaskType != (int)TaskTypes.ReportDelivery + || queuedTask.TaskType != (int)TaskTypes.ReportDelivery || !int.TryParse(task.Data, NumberStyles.None, CultureInfo.InvariantCulture, out var type) + || type < (int)ReportTypes.InventoryOnHand || type > (int)ReportTypes.ControlledSubstanceLog || task.Data != type.ToString(CultureInfo.InvariantCulture)) + throw new InventoryException(403, "ReportDeliveryUnavailable"); + var scheduleHash = ScheduleHash(task); + if (scheduleHash != ScheduleHash(queuedTask)) throw new InventoryException(409, "ReportDeliveryChanged"); + var actor = new InventoryActor { DepartmentId = task.DepartmentId, UserId = task.UserId }; + await _authorization.RequireAsync(actor, false, PermissionTypes.AdjustInventory); + if (type == (int)ReportTypes.ControlledSubstanceLog) await _authorization.RequireAsync(actor, false, PermissionTypes.ManageControlledSubstances); + if (!await _authorization.IsEnabledAsync(task.DepartmentId) || !await _migration.IsMigratedAsync(task.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + var user = _users.GetUserById(task.UserId, true); + if (string.IsNullOrWhiteSpace(user?.Email)) throw new InventoryException(403, "ReportDeliveryUnavailable"); + var language = (await _profiles.GetProfileByUserIdAsync(task.UserId, true))?.Language; + var culture = Resgrid.Localization.SupportedLocales.GetSupportedCultures().Contains(language) ? language : "en"; + return new DeliveryProof { TaskId = task.ScheduledTaskId, DepartmentId = task.DepartmentId, UserId = task.UserId, Data = task.Data, ScheduleHash = scheduleHash, + Email = user.Email, CultureName = culture, Protected = await _protection.IsProtectionEnforcedAsync(task.DepartmentId), + Kind = (InventoryReportKind)(type - (int)ReportTypes.InventoryOnHand) }; + } + + private Task ReportAsync(DeliveryProof proof) => _reports.BuildReportAsync( + new InventoryActor { DepartmentId = proof.DepartmentId, UserId = proof.UserId }, new InventoryReportInput { Kind = proof.Kind }); + + private static string ScheduleHash(ScheduledTask task) + { + // Compare the timing selected by the worker, including changes made while a report is rendering. + // Database timestamps can lose DateTimeKind; the scheduler compares their stored wall-clock values. + var json = JsonConvert.SerializeObject(new { task.ScheduleType, SpecificDateTicks = task.SpecifcDate?.Ticks, task.Time, + task.Sunday, task.Monday, task.Tuesday, task.Wednesday, task.Thursday, task.Friday, task.Saturday }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); + } + + private async Task RevalidateAsync(ScheduledTask queuedTask, DeliveryProof proof) + { + var current = await ReadCurrentAsync(queuedTask); + if (proof.TaskId != current.TaskId || proof.DepartmentId != current.DepartmentId || proof.UserId != current.UserId || proof.Data != current.Data + || proof.ScheduleHash != current.ScheduleHash || proof.Email != current.Email || proof.CultureName != current.CultureName || proof.Protected != current.Protected) + throw new InventoryException(409, "ReportDeliveryChanged"); + if (!current.Protected && ProjectionHash(await ReportAsync(current)) != proof.ProjectionHash) throw new InventoryException(409, "ReportDeliveryChanged"); + // Report generation can itself take time; check policy, membership, recipient and schedule once more afterwards. + current = await ReadCurrentAsync(queuedTask); + if (proof.TaskId != current.TaskId || proof.DepartmentId != current.DepartmentId || proof.UserId != current.UserId || proof.Data != current.Data + || proof.ScheduleHash != current.ScheduleHash || proof.Email != current.Email || proof.CultureName != current.CultureName || proof.Protected != current.Protected) + throw new InventoryException(409, "ReportDeliveryChanged"); + } + + private static string ProjectionHash(InventoryReport report) + { + // Column order is significant; row and dictionary enumeration order are not. GeneratedOn is deliberately excluded. + var rows = report.Rows.Select(row => JsonConvert.SerializeObject(row.OrderBy(cell => cell.Key, StringComparer.Ordinal).ToDictionary(cell => cell.Key, cell => cell.Value))) + .OrderBy(row => row, StringComparer.Ordinal).ToArray(); + var json = JsonConvert.SerializeObject(new { report.Kind, report.FromUtc, report.UntilUtc, report.Columns, Rows = rows, + Totals = report.Totals.OrderBy(total => total.CurrencyCode, StringComparer.Ordinal).Select(total => new { total.CurrencyCode, total.KnownValue, total.UncostedRows }) }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); + } + } +} diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 5bd815468..161b9f17d 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -705,7 +705,11 @@ void Prevention(string table, string column, ProtectedFieldClassification classi 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)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, table switch { + "RecordInventoryUsages" => Resgrid.Model.Inventories.InventoryTables.UsageCatalogVersion, + "InventoryCounts" or "InventoryCountItems" or "InventoryAlerts" or "InventoryAlertDeliveries" => Resgrid.Model.Inventories.InventoryTables.OperationsCatalogVersion, + "InventoryVendors" or "InventoryPurchaseOrders" or "InventoryPurchaseOrderItems" => Resgrid.Model.Inventories.InventoryTables.PurchasingCatalogVersion, + _ => Resgrid.Model.Inventories.InventoryTables.CatalogVersion })); return list; } } diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs index be3c4d809..d6d83caeb 100644 --- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -414,6 +414,14 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque reference_checksum = u.ReferenceChecksum, source = u.Source, inventory_id = u.InventoryId, + usage_id = u.UsageId, + transaction_id = u.TransactionId, + item_id = u.ItemId, + asset_id = u.AssetId, + lot_id = u.LotId, + location_id = u.LocationId, + usage_type = (int)u.UsageType, + reverses_usage_id = u.ReversesUsageId, item_name = u.ItemName, unit_of_measure = u.UnitOfMeasure, source_checksum = u.SourceChecksum, diff --git a/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs b/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs new file mode 100644 index 000000000..a61bda4d9 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs @@ -0,0 +1,314 @@ +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 Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + public partial class RmsInventoryUsageAdapter + { + public async Task ConsumeModernAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, InventoryCommand command, CancellationToken cancellationToken = default) + { + if (command?.Lines?.Count != 1 || command.Lines[0] == null) throw new ArgumentException("One consumption line is required."); + var line = command.Lines[0]; + if (line.Type != InventoryTransactionType.Consume || line.ToLocationId != null || line.Status.HasValue || line.ReversesTransactionId != null || line.IssuanceId != null || line.UnitCost.HasValue || line.UsageId != null || line.UsageType.HasValue) + throw new ArgumentException("Record usage accepts a consumption from one source location."); + ValidateModernRequest(actor, recordId, kind, expectedRowVersion, command.RequestId); + // Existing M1/M2 request fingerprints and reference checksums remain valid across the M3 upgrade. + var previous = await _references.GetByIdAsync(command.RequestId); + if (previous?.SourceVersion == "2") return await ConsumeModernV2Async(actor, recordId, kind, expectedRowVersion, command, cancellationToken); + return (await RecordModernUsageAsync(actor, recordId, kind, expectedRowVersion, new RecordInventoryUsageRequest + { + RequestId = command.RequestId, Lines = new() { new() { ItemId = line.ItemId, AssetId = line.AssetId, LotId = line.LotId, LocationId = line.FromLocationId, + Quantity = line.Quantity, Note = line.Note, ExpectedAssetRevision = line.ExpectedAssetRevision } } + }, cancellationToken)).Single(); + } + + private void ValidateModernRequest(InventoryActor actor, string recordId, RmsRecordKind kind, long version, string requestId) + { + 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) || !Guid.TryParseExact(recordId, "D", out var recordGuid) || recordGuid == Guid.Empty || version < 1 + || !Guid.TryParseExact(requestId, "D", out var requestGuid) || requestGuid == Guid.Empty || requestId == "00000000-0000-0000-0000-000000000001") throw new ArgumentException("A valid Record, current version and stable request GUID are required."); + } + + public Task> RecordModernUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, RecordInventoryUsageRequest request, CancellationToken cancellationToken = default) + { + ValidateModernRequest(actor, recordId, kind, expectedRowVersion, request?.RequestId); + if (request.Lines == null || request.Lines.Count is < 1 or > 100 || request.Lines.Any(x => x == null)) throw new ArgumentException("Choose between one and 100 usage lines."); + // Copy input before defaults and source provenance are resolved. The caller's grant is never persisted. + request = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(request)); + foreach (var line in request.Lines) + { + if (!Enum.IsDefined(line.UsageType)) throw new ArgumentException("Choose a valid usage type."); + if (line.ExistingTransactionId == null) ValidateQuantity(line.Quantity, line.Note); + else if (!Guid.TryParseExact(line.ExistingTransactionId, "D", out var id) || id == Guid.Empty || line.Note != null || line.ItemId != null || line.AssetId != null || line.LotId != null || line.LocationId != null || line.Quantity != 0 || line.ExpectedAssetRevision.HasValue) + throw new ArgumentException("Attaching an existing transaction accepts only its ID and usage type."); + } + var fingerprint = Checksum(JsonConvert.SerializeObject(new { actor.DepartmentId, actor.UserId, recordId, kind, expectedRowVersion, Action = "Usage", request })); + return ExecuteUsageAsync(actor, recordId, kind, expectedRowVersion, request.RequestId, fingerprint, async events => + { + var source = await UsageSourceAsync(actor, recordId, kind); + var rows = new List(); var command = new InventoryCommand { RequestId = request.RequestId }; + var fresh = new List(); + for (var index = 0; index < request.Lines.Count; index++) + { + var input = request.Lines[index]; + var row = SourceRow(actor, recordId, kind, source, UsageIdentity(request.RequestId, index)); row.UsageType = (int)input.UsageType; + if (input.ExistingTransactionId != null) + { + var transaction = await _modernCatalog.GetAsync(actor, input.ExistingTransactionId); + if (transaction == null || transaction.TransactionType != (int)InventoryTransactionType.Consume || transaction.ReversesTransactionId != null || transaction.ReferenceType != (int)InventoryReferenceType.None + || (await _modernStore.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", transaction.Id)).Count != 0) throw new InventoryException(409, "UsageTransactionMismatch"); + AssignTransaction(row, transaction); await AuthorizeModernSourceAsync(actor, row.SourceLocationId); + } + else + { + var location = input.LocationId ?? await DefaultUsageLocationAsync(actor, source, recordId, kind); + await AuthorizeModernSourceAsync(actor, location); + await _modernCatalog.GetAsync(actor, input.ItemId); + row.ItemId = input.ItemId; row.AssetId = input.AssetId; row.LotId = input.LotId; row.SourceLocationId = location; row.Quantity = input.Quantity; + command.Lines.Add(new InventoryPosting { ItemId = input.ItemId, AssetId = input.AssetId, LotId = input.LotId, FromLocationId = location, Quantity = input.Quantity, + Type = InventoryTransactionType.Consume, ExpectedAssetRevision = input.ExpectedAssetRevision, Note = input.Note, + ReferenceType = InventoryReferenceType.RmsRecord, ReferenceId = recordId, UsageId = row.Id, UsageType = input.UsageType }); + fresh.Add(row); + } + rows.Add(row); + } + if (fresh.Count > 0) + { + var result = await _modernStock.PostWithinTransactionAsync(actor, command, cancellationToken); + if (result == null || result.AwaitingWitness || result.TransactionIds?.Count != fresh.Count) throw new InvalidOperationException("Inventory usage did not produce the expected ledger entries."); + events.AddRange(result.OutboxIds ?? new()); + for (var index = 0; index < fresh.Count; index++) + { + var transaction = await _modernStore.GetAsync(actor.DepartmentId, result.TransactionIds[index]); + var row = fresh[index]; + if (transaction == null || transaction.ItemId != row.ItemId || transaction.AssetId != row.AssetId || transaction.LotId != row.LotId || transaction.FromLocationId != row.SourceLocationId || transaction.Quantity != row.Quantity + || transaction.ReferenceType != (int)InventoryReferenceType.RmsRecord || transaction.ReferenceId != recordId || transaction.TransactionType != (int)InventoryTransactionType.Consume) throw new InvalidOperationException("Inventory usage provenance did not match the Record."); + row.TransactionId = transaction.Id; + } + } + return rows; + }, cancellationToken); + } + + public async Task ReverseModernUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, RecordInventoryUsageCorrection correction, CancellationToken cancellationToken = default) + { + ValidateModernRequest(actor, recordId, kind, expectedRowVersion, correction?.RequestId); + if (!Guid.TryParseExact(correction.UsageId, "D", out var usageId) || usageId == Guid.Empty || string.IsNullOrWhiteSpace(correction.Reason) || correction.Reason.Length > 16000 || correction.Reason == ProtectedDataEnvelope.RedactionValue) + throw new ArgumentException("A usage identity and correction reason are required."); + correction = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(correction)); + var fingerprint = Checksum(JsonConvert.SerializeObject(new { actor.DepartmentId, actor.UserId, recordId, kind, expectedRowVersion, Action = "ReverseUsage", correction })); + var results = await ExecuteUsageAsync(actor, recordId, kind, expectedRowVersion, correction.RequestId, fingerprint, async events => + { + var reference = await _references.GetByIdAsync(correction.UsageId); + ValidateUsageReference(reference, actor.DepartmentId, recordId, kind); + var recorded = FromReference(reference); + if (recorded.ReversesUsageId != null) throw new InventoryException(409, "InvalidUsageReversal"); + var source = await UsageSourceAsync(actor, recordId, kind); + var original = await _modernStore.GetAsync(actor.DepartmentId, correction.UsageId); + if (original == null) + { + // M1/M2 and pre-cutover Records references are materialized under their existing identity; no movement is replayed. + var originalTransaction = recorded.TransactionId != null ? await _modernCatalog.GetAsync(actor, recorded.TransactionId) + : await _modernStore.LegacyTransactionAsync(actor.DepartmentId, recorded.InventoryId); + if (originalTransaction == null || originalTransaction.Quantity != recorded.Quantity) throw new InventoryException(409, "UsageTransactionMismatch"); + original = SourceRow(actor, recordId, kind, source, reference.RmsExternalReferenceId); AssignTransaction(original, originalTransaction); + await AuthorizeModernSourceAsync(actor, original.SourceLocationId, historical: true); + await _modernStock.RecordUsageWithinTransactionAsync(actor, original); + } + if (original.SourceType != (int)InventoryUsageSourceType.RmsRecord || original.SourceId != recordId || original.RecordKind != (int)kind || original.ReversesUsageId != null + || (await _modernStore.RelatedAsync(actor.DepartmentId, "ReversesUsageId", original.Id)).Count != 0) throw new InventoryException(409, "UsageAlreadyReversed"); + await AuthorizeModernSourceAsync(actor, original.SourceLocationId, historical: true); + var reversed = SourceRow(actor, recordId, kind, source, correction.RequestId); reversed.ReversesUsageId = original.Id; reversed.UsageType = original.UsageType; + reversed.Content = JsonConvert.SerializeObject(new InventoryLabel { Note = correction.Reason }); + InventoryTransaction transaction; + if (correction.ExistingTransactionId != null) + { + transaction = await _modernCatalog.GetAsync(actor, correction.ExistingTransactionId); + if (transaction?.ReversesTransactionId != original.TransactionId) throw new InventoryException(409, "UsageTransactionMismatch"); + } + else + { + var originalTransaction = await _modernCatalog.GetAsync(actor, original.TransactionId); + var details = JObject.Parse(originalTransaction.Content ?? "{}"); + var result = await _modernStock.PostWithinTransactionAsync(actor, new InventoryCommand { RequestId = correction.RequestId, Lines = new() { + new InventoryPosting { ItemId = original.ItemId, AssetId = original.AssetId, LotId = original.LotId, Quantity = original.Quantity, + ToLocationId = original.SourceLocationId, Type = InventoryTransactionType.Adjust, ReversesTransactionId = original.TransactionId, + ReferenceType = InventoryReferenceType.RmsRecord, ReferenceId = recordId, UsageId = reversed.Id, UsageType = (InventoryUsageType)original.UsageType, + Note = correction.Reason, UnitCost = details.Value("UnitCost") } } }, cancellationToken); + if (result == null || result.AwaitingWitness || result.TransactionIds?.Count != 1) throw new InvalidOperationException("The correction did not produce one ledger entry."); + events.AddRange(result.OutboxIds ?? new()); transaction = await _modernStore.GetAsync(actor.DepartmentId, result.TransactionIds[0]); + } + AssignTransaction(reversed, transaction); reversed.SourceLocationId = transaction.ToLocationId; + return new List { reversed }; + }, cancellationToken); + return results.Single(); + } + + private async Task> ExecuteUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long version, string requestId, string fingerprint, + Func, Task>> work, CancellationToken ct) + { + if (_unit.Transaction != null) throw new InvalidOperationException("Record inventory usage owns its transaction."); + var events = new List(); var result = new List(); + try + { + await _unit.CreateOrGetConnectionAsync(ct); await _modernStore.LockDepartmentAsync(actor.DepartmentId); + if (!await _modernStore.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + var previous = await _references.GetByIdAsync(requestId); + if (previous != null) + { + ValidateUsageReference(previous, actor.DepartmentId, recordId, kind); + FromReference(previous); + var snapshot = JsonConvert.DeserializeObject(previous.SnapshotJson); + if (snapshot?.SchemaVersion != 3 || snapshot.RequestFingerprint != fingerprint || previous.CapturedByUserId != actor.UserId || snapshot.BatchUsageIds == null || snapshot.BatchUsageIds.Count is < 1 or > 100 + || snapshot.BatchUsageIds[0] != requestId || snapshot.BatchUsageIds.Distinct().Count() != snapshot.BatchUsageIds.Count) throw new InventoryException(409, "RequestConflict"); + await GuardAsync(actor.DepartmentId, actor.UserId, recordId, kind, null, ct, false); + foreach (var id in snapshot.BatchUsageIds) + { + var reference = await _references.GetByIdAsync(id); ValidateUsageReference(reference, actor.DepartmentId, recordId, kind); + var usage = FromReference(reference); await AuthorizeModernSourceAsync(actor, usage.LocationId, historical: true); + await _modernCatalog.GetAsync(actor, usage.TransactionId); result.Add(usage); + } + events.AddRange(snapshot.OutboxIds ?? new()); + } + else + { + await GuardAsync(actor.DepartmentId, actor.UserId, recordId, kind, version, ct); + var rows = await work(events); + foreach (var row in rows) await _modernStock.RecordUsageWithinTransactionAsync(actor, row); + foreach (var row in rows) result.Add(await WriteUsageReferenceAsync(actor, recordId, kind, row, fingerprint, rows.Select(x => x.Id).ToList(), events, ct)); + } + _unit.CommitChanges(); + } + catch { _unit.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events, ct); return result; + } + + private static void ValidateUsageReference(RmsExternalReference reference, int departmentId, string recordId, RmsRecordKind kind) + { + if (reference == null || reference.DepartmentId != departmentId || reference.RecordId != recordId || reference.RecordKind != (int)kind || reference.SourceSubsystem != SourceSubsystem || reference.SemanticRole != SemanticRole || reference.DeletedOn.HasValue) + throw new InventoryException(404, "UsageUnavailable"); + } + private static string UsageIdentity(string requestId, int index) => index == 0 ? requestId : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(requestId + ":" + index.ToString(System.Globalization.CultureInfo.InvariantCulture))).Take(16).ToArray()).ToString("D"); + private sealed class UsageSource { public int? CallId; public int? GroupId; public string RevisionId; } + private async Task UsageSourceAsync(InventoryActor actor, string recordId, RmsRecordKind kind) + { + if (kind == RmsRecordKind.Operational) + { + var record = await _records.GetByIdForDepartmentAsync(actor.DepartmentId, recordId); + return new UsageSource { CallId = record.CallId, GroupId = record.StationGroupId, RevisionId = record.AmendsRevisionId }; + } + var incident = await _incidents.GetByIdForDepartmentAsync(actor.DepartmentId, recordId); + return new UsageSource { CallId = incident.CallId, GroupId = incident.StationGroupId, RevisionId = incident.AmendsRevisionId }; + } + private static RecordInventoryUsage SourceRow(InventoryActor actor, string recordId, RmsRecordKind kind, UsageSource source, string id) => new() + { Id = id, DepartmentId = actor.DepartmentId, SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = recordId, RecordKind = (int)kind, CallId = source.CallId, RmsRevisionId = source.RevisionId }; + private static void AssignTransaction(RecordInventoryUsage row, InventoryTransaction transaction) + { + if (transaction == null) throw new InventoryException(404, "UsageUnavailable"); + row.TransactionId = transaction.Id; row.ItemId = transaction.ItemId; row.AssetId = transaction.AssetId; row.LotId = transaction.LotId; row.SourceLocationId = transaction.FromLocationId; row.Quantity = transaction.Quantity; + } + private async Task DefaultUsageLocationAsync(InventoryActor actor, UsageSource source, string recordId, RmsRecordKind kind) + { + var unitIds = new List(); + if (kind == RmsRecordKind.Operational && _recordUnits != null) + unitIds.AddRange((await _recordUnits.GetForRecordAsync(actor.DepartmentId, recordId, null)).Where(x => x.DepartmentId == actor.DepartmentId && x.RecordId == recordId && x.RevisionId == null && !x.DeletedOn.HasValue).Select(x => x.UnitId)); + if (kind == RmsRecordKind.IncidentReport && _incidentUnits != null) + unitIds.AddRange((await _incidentUnits.GetForRecordAsync(actor.DepartmentId, recordId, null)).Where(x => x.DepartmentId == actor.DepartmentId && x.RecordId == recordId && x.RevisionId == null && x.UnitId.HasValue && !x.UnableToDispatch).Select(x => x.UnitId.Value)); + unitIds = unitIds.Distinct().ToList(); + if (unitIds.Count > 1) throw new InventoryException(409, "ExplicitSourceLocationRequired"); + var locations = new List(); + for (var page = 0; page < 200; page++) + { + var batch = await _modernCatalog.ListAsync(actor, page); + locations.AddRange(batch.Items.Where(x => !x.IsDeleted && x.ParentLocationId == null)); + if (!batch.HasMore) break; + if (page == 199) throw new InventoryException(409, "InventoryTooLarge"); + } + var candidates = unitIds.Count == 1 ? locations.Where(x => x.LocationType == (int)InventoryLocationType.Unit && x.UnitId == unitIds[0]).ToList() : new(); + if (candidates.Count == 0 && source.GroupId.HasValue) candidates = locations.Where(x => x.LocationType == (int)InventoryLocationType.Station && x.GroupId == source.GroupId).ToList(); + if (candidates.Count != 1) throw new InventoryException(409, "ExplicitSourceLocationRequired"); + return candidates[0].Id; + } + + private async Task WriteUsageReferenceAsync(InventoryActor actor, string recordId, RmsRecordKind kind, RecordInventoryUsage row, string fingerprint, List batchIds, List events, CancellationToken ct) + { + var source = new { row.Id, row.SourceType, row.SourceId, row.RecordKind, row.RmsRevisionId, row.CallId, row.TransactionId, row.ItemId, row.AssetId, row.LotId, row.SourceLocationId, row.Quantity, row.UsageType, row.ReversesUsageId }; + var snapshot = JsonConvert.SerializeObject(new UsageSnapshot { SchemaVersion = 3, UsageId = row.Id, TransactionId = row.TransactionId, ItemId = row.ItemId, AssetId = row.AssetId, LotId = row.LotId, + LocationId = row.SourceLocationId, Quantity = row.Quantity, UsageType = (InventoryUsageType)row.UsageType, ReversesUsageId = row.ReversesUsageId, RequestFingerprint = fingerprint, + BatchUsageIds = batchIds, OutboxIds = events, Source = source, SourceChecksum = Checksum(JsonConvert.SerializeObject(source)), + Note = ProtectedDataEnvelope.RedactionValue, ItemName = ProtectedDataEnvelope.RedactionValue, UnitOfMeasure = ProtectedDataEnvelope.RedactionValue }); + var now = DateTime.UtcNow; + var reference = new RmsExternalReference { RmsExternalReferenceId = row.Id, DepartmentId = actor.DepartmentId, ProtectionId = Guid.NewGuid().ToString("D"), RecordId = recordId, RecordKind = (int)kind, + SourceSubsystem = SourceSubsystem, SourceEntityType = "RecordInventoryUsage", SourceEntityId = row.Id, IdentifierScheme = IdentifierScheme, SemanticRole = SemanticRole, SourceVersion = "3", + CapturedByUserId = actor.UserId, CapturedOn = now, Checksum = Checksum(snapshot), SnapshotJson = snapshot, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _references.InsertAsync(reference, ct, true); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = actor.DepartmentId, RecordId = recordId, ActorUserId = actor.UserId, Action = (int)RmsAccessAuditAction.Change, Successful = true, OccurredOn = now, + Purpose = row.ReversesUsageId == null ? "Inventory usage recorded" : "Inventory usage reversed", DetailJson = JsonConvert.SerializeObject(new { UsageId = row.Id, row.TransactionId, row.ReversesUsageId, reference.Checksum }) }, ct, true); + return FromReference(reference); + } + + public async Task> GetAuthorizedUsageAsync(InventoryActor actor, string recordId, RmsRecordKind kind) + { + async Task Authorize() + { + if (actor == null || kind is not (RmsRecordKind.Operational or RmsRecordKind.IncidentReport) || !await _authorization.CanUserViewRecordAsync(actor.UserId, recordId, actor.DepartmentId) + || !await _authorization.HasPermissionAsync(actor.UserId, actor.DepartmentId, PermissionTypes.ViewRestrictedRecords) || !await _authorization.CanUseSourceInventoryAsync(actor.UserId, actor.DepartmentId, null)) throw new UnauthorizedAccessException(); + } + await Authorize(); + var references = (await _references.GetForRecordAsync(actor.DepartmentId, recordId)).Where(x => x.DepartmentId == actor.DepartmentId && x.RecordId == recordId && x.RecordKind == (int)kind && x.SourceSubsystem == SourceSubsystem && x.SemanticRole == SemanticRole && !x.DeletedOn.HasValue).ToList(); + if (references.Count > 5000) throw new InventoryException(409, "InventoryTooLarge"); + var result = references.Select(FromReference).OrderBy(x => x.CapturedOn).ToList(); + foreach (var legacy in result.Where(x => x.TransactionId == null)) + { + // This endpoint must not reveal a copied legacy snapshot without an Inventory protection boundary. + legacy.Note = legacy.ItemName = legacy.UnitOfMeasure = ProtectedDataEnvelope.RedactionValue; + } + foreach (var usage in result.Where(x => x.TransactionId != null)) + { + var transaction = await _modernCatalog.GetAsync(actor, usage.TransactionId); + await AuthorizeModernSourceAsync(actor, transaction.FromLocationId ?? transaction.ToLocationId, historical: true); + var details = JObject.Parse(transaction.Content ?? "{}"); + usage.Note = details.Value("Note"); usage.ItemName = details.Value("ItemName"); usage.UnitOfMeasure = details.Value("UnitOfMeasure"); + usage.AssetId = transaction.AssetId; usage.LotId = transaction.LotId; usage.LocationId = transaction.FromLocationId ?? transaction.ToLocationId; + if (usage.ReversesUsageId != null) + { + var row = await _modernCatalog.GetAsync(actor, usage.UsageId); + usage.Note = JsonConvert.DeserializeObject(row.Content ?? "{}")?.Note; + } + else + { + var reversedTransactions = await _modernStore.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", usage.TransactionId); + usage.PendingReversalTransactionId = reversedTransactions.FirstOrDefault(t => !result.Any(r => r.ReversesUsageId == usage.UsageId && r.TransactionId == t.Id))?.Id; + } + } + var reversed = result.Where(x => x.ReversesUsageId != null).Select(x => x.ReversesUsageId).ToHashSet(); + foreach (var usage in result) usage.IsReversed = reversed.Contains(usage.UsageId ?? usage.ReferenceId); + await Authorize(); return result; + } + + public async Task RequireUsageCorrectionAccessAsync(InventoryActor actor, string usageId) + { + if (actor == null || _unit.Transaction == null) throw new UnauthorizedAccessException(); + var usage = await _modernStore.GetAsync(actor.DepartmentId, usageId); + if (usage == null || usage.ReversesUsageId != null || usage.SourceType != (int)InventoryUsageSourceType.RmsRecord || usage.RecordKind is not (1 or 2)) throw new InventoryException(404, "UsageUnavailable"); + // Lock the current source version through the caller's inventory commit: finalization must not + // pass its evidence check between authorizing the witnessed correction and posting its ledger row. + // An existing immutable reversal can only replay or fail the ledger's duplicate-reversal guard; + // retaining the read-only check there avoids changing the source version on completed retries. + var alreadyReversed = (await _modernStore.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", usage.TransactionId)).Count > 0; + await GuardAsync(actor.DepartmentId, actor.UserId, usage.SourceId, (RmsRecordKind)usage.RecordKind.Value, null, CancellationToken.None, bump: !alreadyReversed); + await AuthorizeModernSourceAsync(actor, usage.SourceLocationId, historical: true); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs index 4f83f3a2c..23e65ae97 100644 --- a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs +++ b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs @@ -36,12 +36,14 @@ public class RecordsEvidenceService : IRecordsEvidenceService private readonly IRmsExternalReferencesRepository _references; private readonly IRecordsProtectionService _protection; private readonly IDomainEventOutboxService _outbox; + private readonly IInventoryStore _inventoryStore; public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, IEnumerable adapters, IRecordsAuthorizationService authorization, ICallsService calls, IRmsExternalReferencesRepository references, - IRecordsProtectionService protection, IDomainEventOutboxService outbox) + IRecordsProtectionService protection, IDomainEventOutboxService outbox, IInventoryStore inventoryStore = null) { + _inventoryStore = inventoryStore; _protection = protection; _outbox = outbox; _artifacts = artifacts; @@ -61,6 +63,19 @@ public async Task RequireInventoryCoverageAsync(int departmentId, string recordI var references = ((await _references.GetForRecordAsync(departmentId, recordId)) ?? Enumerable.Empty()) .Where(r=>r.DepartmentId==departmentId && r.RecordId==recordId && !r.DeletedOn.HasValue && r.SemanticRole==RmsInventoryUsageAdapter.SemanticRole).ToList(); if (references.Count==0) return; + // A witnessed correction may be posted in Inventory before the officer attaches it here. + // Never finalize an apparently balanced evidence snapshot while that source movement is unaccounted for. + foreach (var reference in references.Where(r => r.SourceEntityType == "RecordInventoryUsage")) + { + if (_inventoryStore == null) throw new InvalidOperationException("Inventory evidence verification is unavailable."); + var usage = await _inventoryStore.GetAsync(departmentId, reference.SourceEntityId); + if (usage == null || usage.SourceId != recordId || usage.RecordKind != reference.RecordKind) throw new InvalidOperationException("Inventory usage provenance is unavailable."); + if (usage.ReversesUsageId != null) continue; + var reversals = await _inventoryStore.RelatedAsync(departmentId, "ReversesTransactionId", usage.TransactionId); + var corrections = await _inventoryStore.RelatedAsync(departmentId, "ReversesUsageId", usage.Id); + if (reversals.Any(t => !corrections.Any(c => c.TransactionId == t.Id && c.SourceId == recordId && c.RecordKind == reference.RecordKind && references.Any(r => r.SourceEntityType == "RecordInventoryUsage" && r.SourceEntityId == c.Id)))) + throw new ArgumentException("Attach the inventory reversal as a Record correction before finalizing."); + } var covered=new Dictionary(StringComparer.Ordinal); foreach(var artifact in (captured ?? Enumerable.Empty()).Where(a=>a.Kind==(int)RmsEvidenceKind.InventoryUsage)) { diff --git a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs index 9a8c455d8..39124fb11 100644 --- a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs @@ -18,7 +18,7 @@ namespace Resgrid.Services.Records /// Inventory usage over RmsExternalReference rows (semantic role InventoryUsage). Writes never touch a legacy /// Log row; reads answer for either source so callers stay source-agnostic (RMS plan RMS-1 package). /// - public class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter + public partial class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter { public const string SemanticRole = "InventoryUsage"; public const string SourceSubsystem = "Inventory"; @@ -37,15 +37,19 @@ public class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter private readonly IInventoryStockService _modernStock; private readonly IInventoryCatalogService _modernCatalog; private readonly IDomainEventOutboxService _outbox; + private readonly IRmsRecordUnitResponsesRepository _recordUnits; + private readonly IRmsUnitResponsesRepository _incidentUnits; public RmsInventoryUsageAdapter(IRmsExternalReferencesRepository references, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidents, IInventoryService inventory, IRecordsAuthorizationService authorization, IDepartmentGroupsService groups, IUnitsService units, IUnitOfWork unit, IRmsAccessAuditsRepository audits, - IInventoryStore modernStore = null, IInventoryStockService modernStock = null, IInventoryCatalogService modernCatalog = null, IDomainEventOutboxService outbox = null) + IInventoryStore modernStore = null, IInventoryStockService modernStock = null, IInventoryCatalogService modernCatalog = null, IDomainEventOutboxService outbox = null, + IRmsRecordUnitResponsesRepository recordUnits = null, IRmsUnitResponsesRepository incidentUnits = 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; + _recordUnits = recordUnits; _incidentUnits = incidentUnits; } 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) @@ -79,7 +83,7 @@ public async Task ConsumeAsync(int departmentId, string userI catch { _unit.DiscardChanges(); throw; } } - public async Task ConsumeModernAsync(InventoryActor actor, string recordId, RmsRecordKind kind, long expectedRowVersion, InventoryCommand command, CancellationToken cancellationToken = default) + private async Task ConsumeModernV2Async(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(); @@ -153,18 +157,20 @@ private async Task LegacyLocationAsync(InventoryActor actor, throw new InvalidOperationException("The inventory location list exceeds the supported size."); } - private async Task AuthorizeModernSourceAsync(InventoryActor actor, string locationId) + private async Task AuthorizeModernSourceAsync(InventoryActor actor, string locationId, bool historical = false) { - var seen = new HashSet(StringComparer.Ordinal); var location = await _modernCatalog.GetAsync(actor, locationId); + Task ReadLocation(string id) => historical ? _modernStore.GetAsync(actor.DepartmentId, id) : _modernCatalog.GetAsync(actor, id); + var seen = new HashSet(StringComparer.Ordinal); var location = await ReadLocation(locationId); while (true) { - if (location?.DepartmentId != actor.DepartmentId || location.IsDeleted || !seen.Add(location.Id) || seen.Count > 32) throw new UnauthorizedAccessException(); + if (location?.DepartmentId != actor.DepartmentId || location.IsDeleted && !historical || !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; + var asset = historical ? await _modernStore.GetAsync(actor.DepartmentId, location.ContainerAssetId) : await _modernCatalog.GetAsync(actor, location.ContainerAssetId); + if (asset?.DepartmentId != actor.DepartmentId) throw new UnauthorizedAccessException(); + location = await ReadLocation(asset.CurrentLocationId); continue; } - if (location.ParentLocationId != null) { location = await _modernCatalog.GetAsync(actor, location.ParentLocationId); continue; } + if (location.ParentLocationId != null) { location = await ReadLocation(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; @@ -205,12 +211,15 @@ private static void ValidateQuantity(decimal quantity, string note) public async Task> GetUsageForRecordAsync(int departmentId, string recordId) { var references = await _references.GetForRecordAsync(departmentId, recordId) ?? Enumerable.Empty(); - return references + var result = references .Where(r => r != null && r.DepartmentId == departmentId && r.RecordId == recordId && !r.DeletedOn.HasValue && string.Equals(r.SemanticRole, SemanticRole, StringComparison.Ordinal)) .Select(FromReference) .Where(u => u != null) .OrderBy(u => u.CapturedOn) .ToList(); + var reversed = result.Where(x => x.ReversesUsageId != null).Select(x => x.ReversesUsageId).ToHashSet(); + foreach (var usage in result) usage.IsReversed = usage.UsageId != null && reversed.Contains(usage.UsageId); + return result; } /// @@ -313,6 +322,19 @@ private static RmsInventoryUsage FromReference(RmsExternalReference reference) throw new InvalidOperationException("The inventory usage snapshot is unreadable."); } + if (snapshot.SchemaVersion == 3) + { + if (reference.SourceEntityType != "RecordInventoryUsage" || reference.SourceEntityId != reference.RmsExternalReferenceId || snapshot.UsageId != reference.SourceEntityId + || !Guid.TryParseExact(snapshot.UsageId, "D", out var usageId) || usageId == Guid.Empty || !Guid.TryParseExact(snapshot.TransactionId, "D", out var transactionId) || transactionId == Guid.Empty + || !Guid.TryParseExact(snapshot.ItemId, "D", out var itemId) || itemId == Guid.Empty || snapshot.Quantity <= 0 || !Enum.IsDefined(snapshot.UsageType) + || snapshot.ReversesUsageId != null && (!Guid.TryParseExact(snapshot.ReversesUsageId, "D", out var reversedId) || reversedId == Guid.Empty || reversedId == usageId)) + throw new InvalidOperationException("The inventory usage source identity is invalid."); + return new RmsInventoryUsage { ReferenceId = reference.RmsExternalReferenceId, ReferenceChecksum = reference.Checksum, Source = RmsInventoryUsage.SourceRecord, RecordId = reference.RecordId, + UsageId = snapshot.UsageId, TransactionId = snapshot.TransactionId, ItemId = snapshot.ItemId, AssetId = snapshot.AssetId, LotId = snapshot.LotId, LocationId = snapshot.LocationId, + UsageType = snapshot.UsageType, ReversesUsageId = snapshot.ReversesUsageId, Quantity = snapshot.ReversesUsageId == null ? snapshot.Quantity : -snapshot.Quantity, + Note = ProtectedDataEnvelope.RedactionValue, ItemName = ProtectedDataEnvelope.RedactionValue, UnitOfMeasure = ProtectedDataEnvelope.RedactionValue, + SourceChecksum = snapshot.SourceChecksum, CapturedByUserId = reference.CapturedByUserId, CapturedOn = reference.CapturedOn }; + } 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 @@ -321,7 +343,7 @@ private static RmsInventoryUsage FromReference(RmsExternalReference reference) 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, + UsageId = reference.RmsExternalReferenceId, 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 @@ -337,6 +359,7 @@ private static RmsInventoryUsage FromReference(RmsExternalReference reference) Source = RmsInventoryUsage.SourceRecord, RecordId = reference.RecordId, InventoryId = inventoryId, + UsageId = reference.RmsExternalReferenceId, Quantity = snapshot.Quantity, Note = snapshot.Note, ItemName = snapshot.ItemName, UnitOfMeasure = snapshot.UnitOfMeasure, SourceChecksum = snapshot.SourceChecksum, @@ -353,6 +376,14 @@ private static string Checksum(string text) private sealed class UsageSnapshot { + public string UsageId { get; set; } + public string AssetId { get; set; } + public string LotId { get; set; } + public string LocationId { get; set; } + public InventoryUsageType UsageType { get; set; } + public string ReversesUsageId { get; set; } + public List BatchUsageIds { get; set; } + public List OutboxIds { get; set; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public int SchemaVersion { get; set; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 9e07fc358..01059dde2 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -17,7 +17,9 @@ protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().As().As().As() - .As().As().As().As().InstancePerLifetimeScope(); + .As().As().As().As().As().As().As().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().As().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 3628870a4..493019629 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -50,6 +50,8 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit Lazy protectedWriteService, Lazy recordsCutoverService, IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null) { _recordsCutoverService = recordsCutoverService; + if ((inventoryStore == null) != (inventoryUnitOfWork == null)) + throw new ArgumentException("Inventory storage and its unit of work must be supplied together.", nameof(inventoryStore)); _inventoryStore = inventoryStore; _inventoryUnitOfWork = inventoryUnitOfWork; _unitsRepository = unitsRepository; _unitStatesRepository = unitStatesRepository; diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 791521e3e..ed7a8302b 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -220,6 +220,11 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.InventoryIssued: case WorkflowTriggerEventType.InventoryReturned: case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.InventoryPurchaseOrderReceived: + case WorkflowTriggerEventType.InventoryLowStock: + case WorkflowTriggerEventType.InventoryExpiring: + case WorkflowTriggerEventType.InventoryCountCompleted: + case WorkflowTriggerEventType.InventoryReturnOverdue: case WorkflowTriggerEventType.ControlledSubstanceRecorded: AddInventorySamples(obj, eventType); break; @@ -568,13 +573,14 @@ private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventTy 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 recordUsage = eventType == WorkflowTriggerEventType.InventoryAdjusted; var transactionType = eventType switch { WorkflowTriggerEventType.InventoryTransferCompleted => InventoryTransactionType.Transfer, WorkflowTriggerEventType.InventoryIssued => InventoryTransactionType.Issue, WorkflowTriggerEventType.InventoryReturned => InventoryTransactionType.Return, WorkflowTriggerEventType.InventoryAssetStatusChanged => InventoryTransactionType.StatusChange, - WorkflowTriggerEventType.ControlledSubstanceRecorded => InventoryTransactionType.Consume, + WorkflowTriggerEventType.ControlledSubstanceRecorded or WorkflowTriggerEventType.InventoryAdjusted => InventoryTransactionType.Consume, _ => InventoryTransactionType.Adjust }; var referenceType = eventType switch @@ -583,13 +589,15 @@ private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventTy WorkflowTriggerEventType.InventoryIssued => InventoryReferenceType.Deployment, WorkflowTriggerEventType.InventoryReturned => InventoryReferenceType.Issuance, WorkflowTriggerEventType.InventoryAssetStatusChanged => InventoryReferenceType.WorkOrder, - WorkflowTriggerEventType.ControlledSubstanceRecorded => InventoryReferenceType.RmsRecord, + WorkflowTriggerEventType.ControlledSubstanceRecorded or WorkflowTriggerEventType.InventoryAdjusted => 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, + ["UsageId"] = recordUsage ? "cccccccc-cccc-cccc-cccc-cccccccccccc" : null, + ["UsageType"] = recordUsage ? (int?)InventoryUsageType.Used : null, ["TransferId"] = eventType == WorkflowTriggerEventType.InventoryTransferCompleted ? transferId : null, ["IssuanceId"] = eventType is WorkflowTriggerEventType.InventoryIssued or WorkflowTriggerEventType.InventoryReturned ? issuanceId : null, ["TransactionType"] = (int)transactionType, ["Quantity"] = quantity, ["FromLocationId"] = sourceId, @@ -603,6 +611,17 @@ private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventTy ["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 }; + if (eventType == WorkflowTriggerEventType.InventoryPurchaseOrderReceived) + payload = new Dictionary { ["PurchaseOrderId"] = "dddddddd-dddd-dddd-dddd-dddddddddddd", ["VendorId"] = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + ["ReceiptId"] = "ffffffff-ffff-ffff-ffff-ffffffffffff", ["PurchaseOrderStatus"] = 2, ["LineCount"] = 2, ["CurrencyCode"] = "USD", ["OccurredOn"] = occurred, ["ItemName"] = ProtectedDataEnvelope.RedactionValue }; + if (eventType == WorkflowTriggerEventType.InventoryCountCompleted) + payload = new Dictionary { ["CountId"] = "dddddddd-dddd-dddd-dddd-dddddddddddd", ["LocationId"] = sourceId, + ["LineCount"] = 12, ["VarianceLineCount"] = 2, ["VarianceValue"] = ProtectedDataEnvelope.RedactionValue, ["OccurredOn"] = occurred }; + if (eventType is WorkflowTriggerEventType.InventoryLowStock or WorkflowTriggerEventType.InventoryExpiring or WorkflowTriggerEventType.InventoryReturnOverdue) + payload = new Dictionary { ["AlertId"] = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", ["ItemId"] = itemId, + ["AlertType"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? 0 : eventType == WorkflowTriggerEventType.InventoryExpiring ? 1 : 3, + ["LocationId"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? null : sourceId, ["Quantity"] = 2m, + ["IssuanceId"] = eventType == WorkflowTriggerEventType.InventoryReturnOverdue ? issuanceId : null, ["DueOn"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? null : occurred, ["OccurredOn"] = occurred }; 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) @@ -617,7 +636,7 @@ private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventTy ["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 }; + obj["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = new ScriptArray { "ItemName", "Note", "SerialNumber", "WitnessUserId", "VarianceValue" }, ["catalog_version"] = InventoryWorkflowPayload.CatalogVersion }; } /// Records snapshots match the event, record and change catalogs; state pairs follow the trigger. diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 95209d457..cc469e5ca 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -255,6 +255,11 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.InventoryIssued: case WorkflowTriggerEventType.InventoryReturned: case WorkflowTriggerEventType.InventoryAssetStatusChanged: + case WorkflowTriggerEventType.InventoryPurchaseOrderReceived: + case WorkflowTriggerEventType.InventoryLowStock: + case WorkflowTriggerEventType.InventoryExpiring: + case WorkflowTriggerEventType.InventoryCountCompleted: + case WorkflowTriggerEventType.InventoryReturnOverdue: case WorkflowTriggerEventType.ControlledSubstanceRecorded: { var modern = string.IsNullOrWhiteSpace(eventPayloadJson) ? null : JsonConvert.DeserializeObject(eventPayloadJson, new JsonSerializerSettings { FloatParseHandling = FloatParseHandling.Decimal }); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs index 63d2a7dc9..c7030ef99 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs @@ -110,6 +110,8 @@ public override void Up() 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")); + TenantHolderKey("DepartmentGroups", "DepartmentGroupId"); + TenantHolderKey("Units", "UnitId"); Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); Holder("InventoryLocations", "UnitId", "Units", "UnitId"); Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); @@ -143,11 +145,16 @@ public override void Up() Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); SpecialIndexes(); } + private void TenantHolderKey(string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists()) return; + Create.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); + } 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. + // Groups and units belong to one tenant; user identities may belong to multiple departments. if (!Schema.Table(N(parent)).Exists()) return; - if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) + if (parent is "Units" or "DepartmentGroups") 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)); } @@ -174,6 +181,9 @@ public override void Down() 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)); + foreach (var parent in new[] { "DepartmentGroups", "Units" }) + if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists()) + Delete.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).FromTable(N(parent)); } } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0200_AddRecordInventoryUsage.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0200_AddRecordInventoryUsage.cs new file mode 100644 index 000000000..fca867ed6 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0200_AddRecordInventoryUsage.cs @@ -0,0 +1,79 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(200)] + public class M0200_AddRecordInventoryUsage : Migration + { + private const string Table = "RecordInventoryUsages"; + private static string N(string value) => value; + + public override void Up() + { + Create.Table(N(Table)) + .WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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) + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("SourceId")).AsString(128).NotNullable() + .WithColumn(N("RecordKind")).AsInt32().Nullable() + .WithColumn(N("CallId")).AsInt32().Nullable() + .WithColumn(N("RmsRevisionId")).AsString(36).Nullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("SourceLocationId")).AsString(36).NotNullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("UsageType")).AsInt32().NotNullable() + .WithColumn(N("TransactionId")).AsString(36).NotNullable() + .WithColumn(N("ReversesUsageId")).AsString(36).Nullable(); + + Index("TenantId", true, "DepartmentId", "Id"); + Index("Transaction", true, "DepartmentId", "TransactionId"); + Index("Source", false, "DepartmentId", "SourceType", "SourceId", "RecordKind"); + Create.ForeignKey(N("FK_" + Table + "_Department")).FromTable(N(Table)).ForeignColumn(N("DepartmentId")) + .ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Link("ItemId", "InventoryItems"); + Link("SourceLocationId", "InventoryLocations"); + Link("TransactionId", "InventoryTransactions"); + Link("ReversesUsageId", Table); + Create.ForeignKey(N("FK_" + Table + "_ItemAsset")).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")) + .ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + Create.ForeignKey(N("FK_" + Table + "_ItemLot")).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")) + .ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + // The source, call and revision are authorized soft references to independently retained records. + Check("Revision", "Revision >= 1"); + Check("Quantity", "Quantity > 0"); + Check("UsageType", "UsageType BETWEEN 0 AND 3"); + Check("SourceKind", "(SourceType = 0 AND RecordKind IS NULL) OR (SourceType = 1 AND RecordKind IS NOT NULL AND RecordKind IN (1,2))"); + Check("Reversal", "ReversesUsageId IS NULL OR ReversesUsageId <> Id"); + Execute.Sql("CREATE UNIQUE INDEX UX_RecordInventoryUsages_Reversal ON RecordInventoryUsages(DepartmentId,ReversesUsageId) WHERE ReversesUsageId IS NOT NULL;"); + } + + private void Link(string column, string parent) => + Create.ForeignKey(N("FK_" + Table + "_" + column)).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + private void Index(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 suffix, string expression) => + Execute.Sql("ALTER TABLE " + N(Table) + " ADD CONSTRAINT " + N("CK_" + Table + "_" + suffix) + " CHECK (" + N(expression) + ");"); + + public override void Down() + { + // Never erase the source-to-ledger provenance as a side effect of rolling back code. + Execute.Sql("IF EXISTS (SELECT 1 FROM RecordInventoryUsages) THROW 51000, 'Inventory usage must be exported and removed through the authorized retention process before rollback.', 1;"); + Delete.Table(N(Table)); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0201_AddInventoryPurchasing.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0201_AddInventoryPurchasing.cs new file mode 100644 index 000000000..f518a42d3 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0201_AddInventoryPurchasing.cs @@ -0,0 +1,101 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(201)] + public class M0201_AddInventoryPurchasing : Migration + { + private static string N(string value) => value; + private static readonly string[] Tables = { "InventoryVendors", "InventoryPurchaseOrders", "InventoryPurchaseOrderItems" }; + private const string ContactTenantIndex = "UX_Contacts_InventoryPurchasingTenant"; + private const string TransactionLink = "FK_InventoryTransactions_PurchaseOrderItemId"; + + public override void Up() + { + // Contacts owns organization identity; this key makes every vendor reference tenant-scoped. + Index("Contacts", "InventoryPurchasingTenant", true, "DepartmentId", "ContactId"); + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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) + .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "InventoryVendors": + table.WithColumn(N("ContactId")).AsString(128).NotNullable(); + break; + case "InventoryPurchaseOrders": + table.WithColumn(N("VendorId")).AsString(36).NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CurrencyCode")).AsString(3).NotNullable() + .WithColumn(N("OrderedOn")).AsDateTime2().Nullable() + .WithColumn(N("ReceivedOn")).AsDateTime2().Nullable(); + break; + case "InventoryPurchaseOrderItems": + table.WithColumn(N("PurchaseOrderId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LineNumber")).AsInt32().NotNullable() + .WithColumn(N("QuantityOrdered")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("QuantityReceived")).AsDecimal(24, 6).NotNullable().WithDefaultValue(0); + 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")); + } + Create.ForeignKey(N("FK_InventoryVendors_Contact")).FromTable(N("InventoryVendors")).ForeignColumns(N("DepartmentId"), N("ContactId")) + .ToTable(N("Contacts")).PrimaryColumns(N("DepartmentId"), N("ContactId")); + Link("InventoryPurchaseOrders", "VendorId", "InventoryVendors"); + Link("InventoryPurchaseOrderItems", "PurchaseOrderId", "InventoryPurchaseOrders"); + Link("InventoryPurchaseOrderItems", "ItemId", "InventoryItems"); + Check("InventoryPurchaseOrders", "Status", "Status BETWEEN 0 AND 4"); + Check("InventoryPurchaseOrderItems", "LineNumber", "LineNumber >= 1"); + Check("InventoryPurchaseOrderItems", "Quantity", "QuantityOrdered > 0 AND QuantityReceived >= 0 AND QuantityReceived <= QuantityOrdered"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryVendors_ActiveContact ON InventoryVendors(DepartmentId,ContactId) WHERE IsDeleted = 0;"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryPurchaseOrderItems_ActiveLine ON InventoryPurchaseOrderItems(DepartmentId,PurchaseOrderId,LineNumber) WHERE IsDeleted = 0;"); + Index("InventoryPurchaseOrders", "VendorStatus", false, "DepartmentId", "VendorId", "Status"); + Index("InventoryPurchaseOrderItems", "Item", false, "DepartmentId", "ItemId"); + + // A receipt retains the exact order line; prices remain in protected Content. + Alter.Table(N("InventoryTransactions")).AddColumn(N("PurchaseOrderItemId")).AsString(36).Nullable(); + Link("InventoryTransactions", "PurchaseOrderItemId", "InventoryPurchaseOrderItems"); + Check("InventoryTransactions", "PurchaseOrderReference", "PurchaseOrderItemId IS NULL OR ReferenceType = 6"); + Index("InventoryTransactions", "PurchaseOrderItem", false, "DepartmentId", "PurchaseOrderItemId", "EntryId"); + } + + private void Link(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + 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 (" + N(expression) + ");"); + + public override void Down() + { + // Removing purchasing must never orphan a receipt or discard procurement evidence. + Execute.Sql("IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + + " OR EXISTS (SELECT 1 FROM InventoryTransactions WHERE PurchaseOrderItemId IS NOT NULL) THROW 51000, 'Inventory purchasing data must be exported and removed through the authorized retention process before rollback.', 1;"); + Delete.ForeignKey(N(TransactionLink)).OnTable(N("InventoryTransactions")); + Delete.Index(N("IX_InventoryTransactions_PurchaseOrderItem")).OnTable(N("InventoryTransactions")); + Execute.Sql("ALTER TABLE " + N("InventoryTransactions") + " DROP CONSTRAINT " + N("CK_InventoryTransactions_PurchaseOrderReference") + ";"); + Delete.Column(N("PurchaseOrderItemId")).FromTable(N("InventoryTransactions")); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + Delete.Index(N(ContactTenantIndex)).OnTable(N("Contacts")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0202_AddInventoryCountsAndAlerts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0202_AddInventoryCountsAndAlerts.cs new file mode 100644 index 000000000..06451371b --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0202_AddInventoryCountsAndAlerts.cs @@ -0,0 +1,164 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(202)] + public class M0202_AddInventoryCountsAndAlerts : Migration + { + private static string N(string value) => value; + private static readonly string[] Tables = { "InventoryCounts", "InventoryCountItems", "InventoryAlerts", "InventoryAlertDeliveries" }; + private const string TransactionItemIndex = "UX_InventoryTransactions_CountTenantItemId"; + private const string IssuanceItemIndex = "UX_InventoryIssuances_AlertTenantItemId"; + + public override void Up() + { + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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); + switch (name) + { + case "InventoryCounts": + table.WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("LocationId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("SnapshotFingerprint")).AsString(64).NotNullable() + .WithColumn(N("SnapshotOn")).AsDateTime2().NotNullable() + .WithColumn(N("CompletedOn")).AsDateTime2().Nullable() + .WithColumn(N("OperationId")).AsString(36).Nullable(); + break; + case "InventoryCountItems": + table.WithColumn(N("CountId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LocationId")).AsString(36).NotNullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("ExpectedQuantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("CountedQuantity")).AsDecimal(24, 6).Nullable() + .WithColumn(N("TransactionId")).AsString(36).Nullable(); + break; + case "InventoryAlerts": + table.WithColumn(N("AlertType")).AsInt32().NotNullable() + .WithColumn(N("DedupKey")).AsString(64).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LocationId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("IssuanceId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("OpenedOn")).AsDateTime2().NotNullable() + .WithColumn(N("ResolvedOn")).AsDateTime2().Nullable() + .WithColumn(N("DueOn")).AsDateTime2().Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).Nullable(); + break; + case "InventoryAlertDeliveries": + table.WithColumn(N("AlertId")).AsString(36).NotNullable() + .WithColumn(N("UserId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("NextAttemptOn")).AsDateTime2().NotNullable() + .WithColumn(N("LeaseUntil")).AsDateTime2().Nullable() + .WithColumn(N("ClaimToken")).AsString(36).Nullable() + .WithColumn(N("AttemptCount")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("HandedOffOn")).AsDateTime2().Nullable(); + 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("InventoryCountItems", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + // Owned parent keys enforce the item as well as the tenant on historical backlinks. + Index("InventoryTransactions", "CountTenantItemId", true, "DepartmentId", "ItemId", "Id"); + Index("InventoryIssuances", "AlertTenantItemId", true, "DepartmentId", "ItemId", "Id"); + Link("InventoryCounts", "LocationId", "InventoryLocations"); + Link("InventoryCounts", "OperationId", "InventoryOperations"); + Link("InventoryCountItems", "CountId", "InventoryCounts"); + foreach (var table in new[] { "InventoryCountItems", "InventoryAlerts" }) + { + Link(table, "ItemId", "InventoryItems"); + Link(table, "LocationId", "InventoryLocations"); + ItemLink(table, "LotId", "InventoryLots"); + ItemLink(table, "AssetId", "InventoryAssets"); + } + ItemLink("InventoryCountItems", "TransactionId", "InventoryTransactions"); + ItemLink("InventoryAlerts", "IssuanceId", "InventoryIssuances"); + Link("InventoryAlertDeliveries", "AlertId", "InventoryAlerts"); + // User identities are global; current department membership is checked before delivery. + Create.ForeignKey(N("FK_InventoryAlertDeliveries_User")).FromTable(N("InventoryAlertDeliveries")).ForeignColumn(N("UserId")) + .ToTable(N("AspNetUsers")).PrimaryColumn(N("Id")); + + Check("InventoryCounts", "Status", "Status BETWEEN 0 AND 3"); + Check("InventoryCounts", "Fingerprint", "LEN(SnapshotFingerprint) = 64"); + // Expected stock may be negative. An unentered observation remains NULL, not zero. + Check("InventoryCountItems", "CountedQuantity", "CountedQuantity IS NULL OR CountedQuantity >= 0"); + Check("InventoryCountItems", "SerializedQuantity", "AssetId IS NULL OR (ExpectedQuantity IN (0,1) AND (CountedQuantity IS NULL OR CountedQuantity IN (0,1)))"); + Check("InventoryAlerts", "AlertType", "AlertType BETWEEN 0 AND 3"); + Check("InventoryAlerts", "Status", "Status IN (0,1)"); + Check("InventoryAlerts", "DedupKey", "LEN(DedupKey) = 64"); + Check("InventoryAlerts", "Content", "Content IS NULL"); + Check("InventoryAlertDeliveries", "State", "State BETWEEN 0 AND 3"); + Check("InventoryAlertDeliveries", "Attempts", "AttemptCount >= 0"); + Check("InventoryAlertDeliveries", "Content", "Content IS NULL"); + + Index("InventoryCounts", "ScopeStatus", false, "DepartmentId", "LocationId", "Status", "SnapshotOn"); + Index("InventoryCountItems", "Count", false, "DepartmentId", "CountId", "Id"); + Index("InventoryAlerts", "ActiveFeed", false, "DepartmentId", "Status", "AlertType", "OpenedOn"); + Index("InventoryAlerts", "Item", false, "DepartmentId", "ItemId", "Status"); + Index("InventoryAlertDeliveries", "Recipient", true, "DepartmentId", "AlertId", "UserId"); + Index("InventoryAlertDeliveries", "Due", false, "DepartmentId", "State", "NextAttemptOn", "LeaseUntil"); + SpecialIndexes(); + + // Each nonzero variance is tied to exactly one count line; the ledger remains immutable. + Alter.Table(N("InventoryTransactions")).AddColumn(N("CountItemId")).AsString(36).Nullable(); + ItemLink("InventoryTransactions", "CountItemId", "InventoryCountItems"); + Check("InventoryTransactions", "CountReference", "CountItemId IS NULL OR (ReferenceType = 7 AND TransactionType = 7)"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryTransactions_CountItem ON InventoryTransactions(DepartmentId,CountItemId) WHERE CountItemId IS NOT NULL;"); + } + + private void Link(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + private void ItemLink(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + + 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 (" + N(expression) + ");"); + + private void SpecialIndexes() + { + Execute.Sql("ALTER TABLE InventoryCountItems ADD LotKey AS ISNULL(LotId, '00000000-0000-0000-0000-000000000000') PERSISTED, AssetKey AS ISNULL(AssetId, '00000000-0000-0000-0000-000000000000') PERSISTED; CREATE UNIQUE INDEX UX_InventoryCountItems_Position ON InventoryCountItems(DepartmentId,CountId,ItemId,LocationId,LotKey,AssetKey);"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryCountItems_Transaction ON InventoryCountItems(DepartmentId,TransactionId) WHERE TransactionId IS NOT NULL;"); + Execute.Sql("CREATE UNIQUE INDEX UX_InventoryAlerts_Open ON InventoryAlerts(DepartmentId,DedupKey) WHERE Status = 0;"); + } + + public override void Down() + { + // Count observations and notification handoff evidence must survive an attempted code rollback. + Execute.Sql("IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + + " OR EXISTS (SELECT 1 FROM InventoryTransactions WHERE CountItemId IS NOT NULL) THROW 51000, 'Inventory count and alert data must be exported and removed through the authorized retention process before rollback.', 1;"); + Delete.ForeignKey(N("FK_InventoryTransactions_CountItemId")).OnTable(N("InventoryTransactions")); + Delete.Index(N("UX_InventoryTransactions_CountItem")).OnTable(N("InventoryTransactions")); + Execute.Sql("ALTER TABLE " + N("InventoryTransactions") + " DROP CONSTRAINT " + N("CK_InventoryTransactions_CountReference") + ";"); + Delete.Column(N("CountItemId")).FromTable(N("InventoryTransactions")); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + Delete.Index(N(TransactionItemIndex)).OnTable(N("InventoryTransactions")); + Delete.Index(N(IssuanceItemIndex)).OnTable(N("InventoryIssuances")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs index 01a093f9a..6f652ffdd 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs @@ -110,6 +110,8 @@ public override void Up() 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")); + TenantHolderKey("DepartmentGroups", "DepartmentGroupId"); + TenantHolderKey("Units", "UnitId"); Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); Holder("InventoryLocations", "UnitId", "Units", "UnitId"); Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); @@ -143,11 +145,16 @@ public override void Up() Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); SpecialIndexes(); } + private void TenantHolderKey(string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists()) return; + Create.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); + } 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. + // Groups and units belong to one tenant; user identities may belong to multiple departments. if (!Schema.Table(N(parent)).Exists()) return; - if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) + if (parent is "Units" or "DepartmentGroups") 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)); } @@ -174,6 +181,9 @@ public override void Down() 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)); + foreach (var parent in new[] { "DepartmentGroups", "Units" }) + if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists()) + Delete.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).FromTable(N(parent)); } } } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0200_AddRecordInventoryUsagePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0200_AddRecordInventoryUsagePg.cs new file mode 100644 index 000000000..7e0b92080 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0200_AddRecordInventoryUsagePg.cs @@ -0,0 +1,79 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(200)] + public class M0200_AddRecordInventoryUsagePg : Migration + { + private const string Table = "RecordInventoryUsages"; + private static string N(string value) => value.ToLowerInvariant(); + + public override void Up() + { + Create.Table(N(Table)) + .WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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) + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("SourceId")).AsString(128).NotNullable() + .WithColumn(N("RecordKind")).AsInt32().Nullable() + .WithColumn(N("CallId")).AsInt32().Nullable() + .WithColumn(N("RmsRevisionId")).AsString(36).Nullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("SourceLocationId")).AsString(36).NotNullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("UsageType")).AsInt32().NotNullable() + .WithColumn(N("TransactionId")).AsString(36).NotNullable() + .WithColumn(N("ReversesUsageId")).AsString(36).Nullable(); + + Index("TenantId", true, "DepartmentId", "Id"); + Index("Transaction", true, "DepartmentId", "TransactionId"); + Index("Source", false, "DepartmentId", "SourceType", "SourceId", "RecordKind"); + Create.ForeignKey(N("FK_" + Table + "_Department")).FromTable(N(Table)).ForeignColumn(N("DepartmentId")) + .ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Link("ItemId", "InventoryItems"); + Link("SourceLocationId", "InventoryLocations"); + Link("TransactionId", "InventoryTransactions"); + Link("ReversesUsageId", Table); + Create.ForeignKey(N("FK_" + Table + "_ItemAsset")).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")) + .ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + Create.ForeignKey(N("FK_" + Table + "_ItemLot")).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")) + .ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + // The source, call and revision are authorized soft references to independently retained records. + Check("Revision", "Revision >= 1"); + Check("Quantity", "Quantity > 0"); + Check("UsageType", "UsageType BETWEEN 0 AND 3"); + Check("SourceKind", "(SourceType = 0 AND RecordKind IS NULL) OR (SourceType = 1 AND RecordKind IS NOT NULL AND RecordKind IN (1,2))"); + Check("Reversal", "ReversesUsageId IS NULL OR ReversesUsageId <> Id"); + Execute.Sql("CREATE UNIQUE INDEX ux_recordinventoryusages_reversal ON recordinventoryusages(departmentid,reversesusageid) WHERE reversesusageid IS NOT NULL;"); + } + + private void Link(string column, string parent) => + Create.ForeignKey(N("FK_" + Table + "_" + column)).FromTable(N(Table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + private void Index(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 suffix, string expression) => + Execute.Sql("ALTER TABLE " + N(Table) + " ADD CONSTRAINT " + N("CK_" + Table + "_" + suffix) + " CHECK (" + N(expression) + ");"); + + public override void Down() + { + // Never erase the source-to-ledger provenance as a side effect of rolling back code. + Execute.Sql("DO $guard$ BEGIN IF EXISTS (SELECT 1 FROM recordinventoryusages) THEN RAISE EXCEPTION 'Inventory usage must be exported and removed through the authorized retention process before rollback.'; END IF; END $guard$;"); + Delete.Table(N(Table)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0201_AddInventoryPurchasingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0201_AddInventoryPurchasingPg.cs new file mode 100644 index 000000000..62eaba52e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0201_AddInventoryPurchasingPg.cs @@ -0,0 +1,101 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(201)] + public class M0201_AddInventoryPurchasingPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static readonly string[] Tables = { "InventoryVendors", "InventoryPurchaseOrders", "InventoryPurchaseOrderItems" }; + private const string ContactTenantIndex = "UX_Contacts_InventoryPurchasingTenant"; + private const string TransactionLink = "FK_InventoryTransactions_PurchaseOrderItemId"; + + public override void Up() + { + // Contacts owns organization identity; this key makes every vendor reference tenant-scoped. + Index("Contacts", "InventoryPurchasingTenant", true, "DepartmentId", "ContactId"); + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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) + .WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false); + switch (name) + { + case "InventoryVendors": + table.WithColumn(N("ContactId")).AsCustom("citext").NotNullable(); + break; + case "InventoryPurchaseOrders": + table.WithColumn(N("VendorId")).AsString(36).NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CurrencyCode")).AsString(3).NotNullable() + .WithColumn(N("OrderedOn")).AsDateTime2().Nullable() + .WithColumn(N("ReceivedOn")).AsDateTime2().Nullable(); + break; + case "InventoryPurchaseOrderItems": + table.WithColumn(N("PurchaseOrderId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LineNumber")).AsInt32().NotNullable() + .WithColumn(N("QuantityOrdered")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("QuantityReceived")).AsDecimal(24, 6).NotNullable().WithDefaultValue(0); + 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")); + } + Create.ForeignKey(N("FK_InventoryVendors_Contact")).FromTable(N("InventoryVendors")).ForeignColumns(N("DepartmentId"), N("ContactId")) + .ToTable(N("Contacts")).PrimaryColumns(N("DepartmentId"), N("ContactId")); + Link("InventoryPurchaseOrders", "VendorId", "InventoryVendors"); + Link("InventoryPurchaseOrderItems", "PurchaseOrderId", "InventoryPurchaseOrders"); + Link("InventoryPurchaseOrderItems", "ItemId", "InventoryItems"); + Check("InventoryPurchaseOrders", "Status", "Status BETWEEN 0 AND 4"); + Check("InventoryPurchaseOrderItems", "LineNumber", "LineNumber >= 1"); + Check("InventoryPurchaseOrderItems", "Quantity", "QuantityOrdered > 0 AND QuantityReceived >= 0 AND QuantityReceived <= QuantityOrdered"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventoryvendors_activecontact ON inventoryvendors(departmentid,contactid) WHERE isdeleted = false;"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventorypurchaseorderitems_activeline ON inventorypurchaseorderitems(departmentid,purchaseorderid,linenumber) WHERE isdeleted = false;"); + Index("InventoryPurchaseOrders", "VendorStatus", false, "DepartmentId", "VendorId", "Status"); + Index("InventoryPurchaseOrderItems", "Item", false, "DepartmentId", "ItemId"); + + // A receipt retains the exact order line; prices remain in protected Content. + Alter.Table(N("InventoryTransactions")).AddColumn(N("PurchaseOrderItemId")).AsString(36).Nullable(); + Link("InventoryTransactions", "PurchaseOrderItemId", "InventoryPurchaseOrderItems"); + Check("InventoryTransactions", "PurchaseOrderReference", "PurchaseOrderItemId IS NULL OR ReferenceType = 6"); + Index("InventoryTransactions", "PurchaseOrderItem", false, "DepartmentId", "PurchaseOrderItemId", "EntryId"); + } + + private void Link(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + 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 (" + N(expression) + ");"); + + public override void Down() + { + // Removing purchasing must never orphan a receipt or discard procurement evidence. + Execute.Sql("DO $guard$ BEGIN IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + + " OR EXISTS (SELECT 1 FROM inventorytransactions WHERE purchaseorderitemid IS NOT NULL) THEN RAISE EXCEPTION 'Inventory purchasing data must be exported and removed through the authorized retention process before rollback.'; END IF; END $guard$;"); + Delete.ForeignKey(N(TransactionLink)).OnTable(N("InventoryTransactions")); + Delete.Index(N("IX_InventoryTransactions_PurchaseOrderItem")).OnTable(N("InventoryTransactions")); + Execute.Sql("ALTER TABLE " + N("InventoryTransactions") + " DROP CONSTRAINT " + N("CK_InventoryTransactions_PurchaseOrderReference") + ";"); + Delete.Column(N("PurchaseOrderItemId")).FromTable(N("InventoryTransactions")); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + Delete.Index(N(ContactTenantIndex)).OnTable(N("Contacts")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0202_AddInventoryCountsAndAlertsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0202_AddInventoryCountsAndAlertsPg.cs new file mode 100644 index 000000000..2ce59df4a --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0202_AddInventoryCountsAndAlertsPg.cs @@ -0,0 +1,164 @@ +using System.Linq; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(202)] + public class M0202_AddInventoryCountsAndAlertsPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + private static readonly string[] Tables = { "InventoryCounts", "InventoryCountItems", "InventoryAlerts", "InventoryAlertDeliveries" }; + private const string TransactionItemIndex = "UX_InventoryTransactions_CountTenantItemId"; + private const string IssuanceItemIndex = "UX_InventoryIssuances_AlertTenantItemId"; + + public override void Up() + { + foreach (var name in Tables) + { + var table = Create.Table(N(name)).WithColumn(N("Id")).AsString(36).NotNullable().PrimaryKey() + .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); + switch (name) + { + case "InventoryCounts": + table.WithColumn(N("IsDeleted")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("LocationId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("SnapshotFingerprint")).AsString(64).NotNullable() + .WithColumn(N("SnapshotOn")).AsDateTime2().NotNullable() + .WithColumn(N("CompletedOn")).AsDateTime2().Nullable() + .WithColumn(N("OperationId")).AsString(36).Nullable(); + break; + case "InventoryCountItems": + table.WithColumn(N("CountId")).AsString(36).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LocationId")).AsString(36).NotNullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("ExpectedQuantity")).AsDecimal(24, 6).NotNullable() + .WithColumn(N("CountedQuantity")).AsDecimal(24, 6).Nullable() + .WithColumn(N("TransactionId")).AsString(36).Nullable(); + break; + case "InventoryAlerts": + table.WithColumn(N("AlertType")).AsInt32().NotNullable() + .WithColumn(N("DedupKey")).AsString(64).NotNullable() + .WithColumn(N("ItemId")).AsString(36).NotNullable() + .WithColumn(N("LocationId")).AsString(36).Nullable() + .WithColumn(N("LotId")).AsString(36).Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("IssuanceId")).AsString(36).Nullable() + .WithColumn(N("Status")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("OpenedOn")).AsDateTime2().NotNullable() + .WithColumn(N("ResolvedOn")).AsDateTime2().Nullable() + .WithColumn(N("DueOn")).AsDateTime2().Nullable() + .WithColumn(N("Quantity")).AsDecimal(24, 6).Nullable(); + break; + case "InventoryAlertDeliveries": + table.WithColumn(N("AlertId")).AsString(36).NotNullable() + .WithColumn(N("UserId")).AsString(128).NotNullable() + .WithColumn(N("State")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("NextAttemptOn")).AsDateTime2().NotNullable() + .WithColumn(N("LeaseUntil")).AsDateTime2().Nullable() + .WithColumn(N("ClaimToken")).AsString(36).Nullable() + .WithColumn(N("AttemptCount")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("HandedOffOn")).AsDateTime2().Nullable(); + 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("InventoryCountItems", "TenantItemId", true, "DepartmentId", "ItemId", "Id"); + // Owned parent keys enforce the item as well as the tenant on historical backlinks. + Index("InventoryTransactions", "CountTenantItemId", true, "DepartmentId", "ItemId", "Id"); + Index("InventoryIssuances", "AlertTenantItemId", true, "DepartmentId", "ItemId", "Id"); + Link("InventoryCounts", "LocationId", "InventoryLocations"); + Link("InventoryCounts", "OperationId", "InventoryOperations"); + Link("InventoryCountItems", "CountId", "InventoryCounts"); + foreach (var table in new[] { "InventoryCountItems", "InventoryAlerts" }) + { + Link(table, "ItemId", "InventoryItems"); + Link(table, "LocationId", "InventoryLocations"); + ItemLink(table, "LotId", "InventoryLots"); + ItemLink(table, "AssetId", "InventoryAssets"); + } + ItemLink("InventoryCountItems", "TransactionId", "InventoryTransactions"); + ItemLink("InventoryAlerts", "IssuanceId", "InventoryIssuances"); + Link("InventoryAlertDeliveries", "AlertId", "InventoryAlerts"); + // User identities are global; current department membership is checked before delivery. + Create.ForeignKey(N("FK_InventoryAlertDeliveries_User")).FromTable(N("InventoryAlertDeliveries")).ForeignColumn(N("UserId")) + .ToTable(N("AspNetUsers")).PrimaryColumn(N("Id")); + + Check("InventoryCounts", "Status", "Status BETWEEN 0 AND 3"); + Check("InventoryCounts", "Fingerprint", "CHAR_LENGTH(SnapshotFingerprint) = 64"); + // Expected stock may be negative. An unentered observation remains NULL, not zero. + Check("InventoryCountItems", "CountedQuantity", "CountedQuantity IS NULL OR CountedQuantity >= 0"); + Check("InventoryCountItems", "SerializedQuantity", "AssetId IS NULL OR (ExpectedQuantity IN (0,1) AND (CountedQuantity IS NULL OR CountedQuantity IN (0,1)))"); + Check("InventoryAlerts", "AlertType", "AlertType BETWEEN 0 AND 3"); + Check("InventoryAlerts", "Status", "Status IN (0,1)"); + Check("InventoryAlerts", "DedupKey", "CHAR_LENGTH(DedupKey) = 64"); + Check("InventoryAlerts", "Content", "Content IS NULL"); + Check("InventoryAlertDeliveries", "State", "State BETWEEN 0 AND 3"); + Check("InventoryAlertDeliveries", "Attempts", "AttemptCount >= 0"); + Check("InventoryAlertDeliveries", "Content", "Content IS NULL"); + + Index("InventoryCounts", "ScopeStatus", false, "DepartmentId", "LocationId", "Status", "SnapshotOn"); + Index("InventoryCountItems", "Count", false, "DepartmentId", "CountId", "Id"); + Index("InventoryAlerts", "ActiveFeed", false, "DepartmentId", "Status", "AlertType", "OpenedOn"); + Index("InventoryAlerts", "Item", false, "DepartmentId", "ItemId", "Status"); + Index("InventoryAlertDeliveries", "Recipient", true, "DepartmentId", "AlertId", "UserId"); + Index("InventoryAlertDeliveries", "Due", false, "DepartmentId", "State", "NextAttemptOn", "LeaseUntil"); + SpecialIndexes(); + + // Each nonzero variance is tied to exactly one count line; the ledger remains immutable. + Alter.Table(N("InventoryTransactions")).AddColumn(N("CountItemId")).AsString(36).Nullable(); + ItemLink("InventoryTransactions", "CountItemId", "InventoryCountItems"); + Check("InventoryTransactions", "CountReference", "CountItemId IS NULL OR (ReferenceType = 7 AND TransactionType = 7)"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventorytransactions_countitem ON inventorytransactions(departmentid,countitemid) WHERE countitemid IS NOT NULL;"); + } + + private void Link(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("Id")); + + private void ItemLink(string table, string column, string parent) => + Create.ForeignKey(N("FK_" + table + "_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N("ItemId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); + + 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 (" + N(expression) + ");"); + + private void SpecialIndexes() + { + Execute.Sql("CREATE UNIQUE INDEX ux_inventorycountitems_position ON inventorycountitems(departmentid,countid,itemid,locationid,COALESCE(lotid, '00000000-0000-0000-0000-000000000000'),COALESCE(assetid, '00000000-0000-0000-0000-000000000000'));"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventorycountitems_transaction ON inventorycountitems(departmentid,transactionid) WHERE transactionid IS NOT NULL;"); + Execute.Sql("CREATE UNIQUE INDEX ux_inventoryalerts_open ON inventoryalerts(departmentid,dedupkey) WHERE status = 0;"); + } + + public override void Down() + { + // Count observations and notification handoff evidence must survive an attempted code rollback. + Execute.Sql("DO $guard$ BEGIN IF " + string.Join(" OR ", Tables.Select(t => "EXISTS (SELECT 1 FROM " + N(t) + ")")) + + " OR EXISTS (SELECT 1 FROM inventorytransactions WHERE countitemid IS NOT NULL) THEN RAISE EXCEPTION 'Inventory count and alert data must be exported and removed through the authorized retention process before rollback.'; END IF; END $guard$;"); + Delete.ForeignKey(N("FK_InventoryTransactions_CountItemId")).OnTable(N("InventoryTransactions")); + Delete.Index(N("UX_InventoryTransactions_CountItem")).OnTable(N("InventoryTransactions")); + Execute.Sql("ALTER TABLE " + N("InventoryTransactions") + " DROP CONSTRAINT " + N("CK_InventoryTransactions_CountReference") + ";"); + Delete.Column(N("CountItemId")).FromTable(N("InventoryTransactions")); + foreach (var table in Tables.Reverse()) Delete.Table(N(table)); + Delete.Index(N(TransactionItemIndex)).OnTable(N("InventoryTransactions")); + Delete.Index(N(IssuanceItemIndex)).OnTable(N("InventoryIssuances")); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs index 084374534..39067c63c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InventoryDepartmentCleanup.cs @@ -52,7 +52,9 @@ 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); + if (present.Contains("InventoryCountItems")) + await connection.ExecuteAsync(new CommandDefinition($"UPDATE {Q("InventoryCountItems")} SET {Q("TransactionId")}=NULL WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); + foreach (var table in new[] { "InventoryAlertDeliveries", "InventoryAlerts", "RecordInventoryUsages", "InventoryTransferItems", "InventoryTransactions", "InventoryCountItems", "InventoryCounts", "InventoryPurchaseOrderItems", "InventoryPurchaseOrders", "InventoryVendors", "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")) diff --git a/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs index 7a82818e6..57e180c28 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs @@ -23,7 +23,7 @@ public sealed class InventoryStore : RmsRepositoryBase, IInventor { "ParentCategoryId", "CategoryId", "ContainerAssetId", "ParentLocationId", "ItemId", "LocationId", "LotId", "CurrentLocationId", "OperationId", "AssetId", "FromLocationId", "ToLocationId", "ReferenceId", "ReversesTransactionId", "IssuanceId", "TransferId", - "TransactionId", "ReturnedToLocationId", "KitId", "RequestId", "IssuedToUserId", "UserId" + "TransactionId", "ReturnedToLocationId", "KitId", "RequestId", "IssuedToUserId", "UserId", "SourceId", "ReversesUsageId", "ContactId", "VendorId", "PurchaseOrderId", "PurchaseOrderItemId", "CountId", "CountItemId", "AlertId", "DedupKey" }; public InventoryStore(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) @@ -49,6 +49,28 @@ private void Transaction() } public Task LockDepartmentAsync(int departmentId) => LockRecordsDepartmentAsync(departmentId, default); + public async Task> AlertDepartmentsAsync(int afterDepartmentId) => (await QueryAsync( + $"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("InventoryItems")} WHERE {Col("DepartmentId")}>@AfterDepartmentId ORDER BY {Col("DepartmentId")} {Paging()}", + new { AfterDepartmentId = afterDepartmentId, Skip = 0, Take = 100 })).ToList(); + + public Task LastEntryAsync(int departmentId) => ScalarAsync( + $"SELECT COALESCE(MAX({Col("EntryId")}),0) FROM {Tbl("InventoryTransactions")} WHERE {Col("DepartmentId")}={P}DepartmentId", + new { DepartmentId = departmentId }); + + public Task OpenAlertAsync(int departmentId, string dedupKey) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("InventoryAlerts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("DedupKey")}={P}DedupKey AND {Col("Status")}=0", + new { DepartmentId = departmentId, DedupKey = dedupKey }); + + public Task AlertDeliveryAsync(int departmentId, string alertId, string userId) => QueryFirstOrDefaultAsync( + $"SELECT {Cols(Columns())} FROM {Tbl("InventoryAlertDeliveries")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("AlertId")}={P}AlertId AND {Col("UserId")}={P}UserId", + new { DepartmentId = departmentId, AlertId = alertId, UserId = userId }); + + public async Task> OpenAlertsAsync(int departmentId, int skip = 0) + { + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("InventoryAlerts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Status")}=0 ORDER BY {Col("OpenedOn")},{Col("Id")} {Paging()}", + new { DepartmentId = departmentId, Skip = skip, Take = PageSize })).ToList(); + } 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", @@ -85,11 +107,18 @@ void Equal(string column, string value) 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); + Equal("SourceId", filter.SourceId); + if (filter.SourceType.HasValue || filter.RecordKind.HasValue) + { + if (typeof(T) != typeof(RecordInventoryUsage)) throw new ArgumentException("Unsupported inventory source filter."); + if (filter.SourceType.HasValue) { conditions.Add($"{Col("SourceType")}={P}SourceType"); parameters.Add("SourceType", filter.SourceType.Value); } + if (filter.RecordKind.HasValue) { conditions.Add($"{Col("RecordKind")}={P}RecordKind"); parameters.Add("RecordKind", filter.RecordKind.Value); } + } 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); + else Equal(typeof(T) == typeof(RecordInventoryUsage) ? "SourceLocationId" : "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"); + var order = typeof(T) == typeof(InventoryTransaction) ? $"{Col("EntryId")} DESC" : typeof(T) == typeof(InventoryCount) ? $"{Col("CreatedOn")} DESC,{Col("Id")}" : typeof(T) == typeof(InventoryAlert) ? $"{Col("Status")},{Col("OpenedOn")} DESC,{Col("Id")}" : Col("Id"); return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {string.Join(" AND ", conditions)} ORDER BY {order} {Paging()}", parameters)).ToList(); } @@ -112,7 +141,7 @@ public async Task UpdateAsync(T row, int expectedRevision) where T : Inventor { 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 (row is InventoryTransaction or InventoryTransferItem or RecordInventoryUsage) throw new InvalidOperationException("Inventory ledger and usage 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); @@ -145,6 +174,24 @@ public Task LegacyItemAsync(int departmentId, int typeId) => Quer 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> StockQuantitiesAsync(int departmentId, IReadOnlyCollection itemIds) + { + if (itemIds == null || itemIds.Count > 500) throw new ArgumentException("A catalog page of item IDs is required.", nameof(itemIds)); + if (itemIds.Count == 0) return new List(); + var predicate = IsPostgres ? $"{Col("ItemId")}=ANY(@ItemIds)" : $"{Col("ItemId")} IN @ItemIds"; + return (await QueryAsync($"SELECT {Cols("ItemId", "LocationId")},SUM({Col("Quantity")}) AS {Col("Quantity")} FROM {Tbl("InventoryStocks")} WHERE {Col("DepartmentId")}=@DepartmentId AND {Col("IsDeleted")}={(IsPostgres ? "false" : "0")} AND {predicate} GROUP BY {Cols("ItemId", "LocationId")}", + new { DepartmentId = departmentId, ItemIds = itemIds.ToArray() })).ToList(); + } + + public async Task> AssetHistoryAsync(int departmentId, IReadOnlyCollection assetIds, DateTime at, bool after, int skip = 0) + { + if (assetIds == null || assetIds.Count > 500 || skip < 0) throw new ArgumentException("A bounded asset history query is required."); + if (assetIds.Count == 0) return new List(); + var predicate = IsPostgres ? $"{Col("AssetId")}=ANY(@AssetIds)" : $"{Col("AssetId")} IN @AssetIds"; + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("InventoryTransactions")} WHERE {Col("DepartmentId")}=@DepartmentId AND {predicate} AND {Col("OccurredOn")} {(after ? ">" : "<=")} @At ORDER BY {Col("OccurredOn")},{Col("EntryId")} {Paging()}", + new { DepartmentId = departmentId, AssetIds = assetIds.ToArray(), At = DatabaseTimestamp(at), Skip = skip, Take = PageSize })).ToList(); + } + public async Task RebuildStocksAsync(int departmentId) { Transaction(); diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 32d22d581..e1c9772da 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -91,5 +91,10 @@ "InventoryIssued": 59, "InventoryReturned": 60, "InventoryAssetStatusChanged": 64, - "ControlledSubstanceRecorded": 66 + "ControlledSubstanceRecorded": 66, + "InventoryPurchaseOrderReceived": 65, + "InventoryLowStock": 61, + "InventoryExpiring": 62, + "InventoryCountCompleted": 63, + "InventoryReturnOverdue": 166 } diff --git a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs index 1dbb618e8..a81a5e91a 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs @@ -8,6 +8,8 @@ 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.Records; using Resgrid.Services.Records.Evidence; @@ -169,6 +171,91 @@ public async Task Every_consumption_requires_matching_immutable_evidence_before_ reference.SnapshotJson="{\"Quantity\":3}";reference.Checksum=RecordSnapshotSerializer.Checksum(reference.SnapshotJson); Func stale=()=>_service.RequireInventoryCoverageAsync(Dept,_record.RmsOperationalRecordId,new[]{artifact});await stale.Should().ThrowAsync(); } + [TestCase(RmsRecordKind.Operational)] + [TestCase(RmsRecordKind.IncidentReport)] + public async Task Witnessed_ledger_reversal_requires_a_linked_Record_correction_and_refreshed_evidence_before_finalization(RmsRecordKind kind) + { + var recordId = _record.RmsOperationalRecordId; + var inventory = new Mock(); + var original = new RecordInventoryUsage { DepartmentId = Dept, SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = recordId, RecordKind = (int)kind, + TransactionId = Guid.NewGuid().ToString("D"), ItemId = Guid.NewGuid().ToString("D"), SourceLocationId = Guid.NewGuid().ToString("D"), Quantity = 2 }; + var originalReference = UsageReference(original); + var references = new List { originalReference }; + var reversals = new List(); var corrections = new List(); + _references.Setup(x => x.GetForRecordAsync(Dept, recordId)).ReturnsAsync(() => references); + inventory.Setup(x => x.GetAsync(Dept, original.Id)).ReturnsAsync(original); + inventory.Setup(x => x.RelatedAsync(Dept, "ReversesTransactionId", original.TransactionId)).ReturnsAsync(() => reversals); + inventory.Setup(x => x.RelatedAsync(Dept, "ReversesUsageId", original.Id)).ReturnsAsync(() => corrections); + var service = InventoryCoverageService(inventory.Object); + var signedArtifact = UsageArtifact(recordId, originalReference); signedArtifact.RevisionId = Guid.NewGuid().ToString("D"); + var frozenManifest = signedArtifact.ManifestJson; var frozenChecksum = signedArtifact.Checksum; + await service.RequireInventoryCoverageAsync(Dept, recordId, new[] { signedArtifact }); + + var reversal = new InventoryTransaction { DepartmentId = Dept, ItemId = original.ItemId, Quantity = original.Quantity, ToLocationId = original.SourceLocationId, + TransactionType = (int)InventoryTransactionType.Adjust, ReversesTransactionId = original.TransactionId }; + reversals.Add(reversal); + Func finalize = () => service.RequireInventoryCoverageAsync(Dept, recordId, new[] { signedArtifact }); + await finalize.Should().ThrowAsync().WithMessage("Attach the inventory reversal*"); + + var correction = new RecordInventoryUsage { DepartmentId = Dept, SourceType = original.SourceType, SourceId = recordId, RecordKind = (int)kind, + TransactionId = reversal.Id, ReversesUsageId = original.Id, ItemId = original.ItemId, SourceLocationId = original.SourceLocationId, Quantity = original.Quantity }; + corrections.Add(correction); + inventory.Setup(x => x.GetAsync(Dept, correction.Id)).ReturnsAsync(correction); + await finalize.Should().ThrowAsync().WithMessage("Attach the inventory reversal*", "a source usage row without a Records reference cannot satisfy the signed evidence contract"); + + var correctionReference = UsageReference(correction); references.Add(correctionReference); + await finalize.Should().ThrowAsync().WithMessage("Refresh the inventory evidence*"); + var refreshed = UsageArtifact(recordId, originalReference, correctionReference); + await service.RequireInventoryCoverageAsync(Dept, recordId, new[] { refreshed }); + signedArtifact.ManifestJson.Should().Be(frozenManifest); signedArtifact.Checksum.Should().Be(frozenChecksum); + RecordSnapshotSerializer.Checksum(signedArtifact.ManifestJson).Should().Be(frozenChecksum, "the earlier signed artifact remains verifiable after a later correction"); + var totals = JObject.Parse(refreshed.ManifestJson)["usage"].Sum(x => x.Value("quantity")); + totals.Should().Be(0, "the corrected snapshot includes the consumption and one negative reversal, without replacing history"); + } + + [Test] + public async Task A_correction_from_another_Record_cannot_cover_a_pending_inventory_ledger_reversal() + { + var recordId = _record.RmsOperationalRecordId; var inventory = new Mock(); + var original = new RecordInventoryUsage { DepartmentId = Dept, SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = recordId, RecordKind = (int)RmsRecordKind.Operational, TransactionId = Guid.NewGuid().ToString("D"), Quantity = 1 }; + var originalReference = UsageReference(original); + var reversal = new InventoryTransaction { DepartmentId = Dept, ReversesTransactionId = original.TransactionId, Quantity = 1 }; + var foreign = new RecordInventoryUsage { DepartmentId = Dept, SourceType = original.SourceType, SourceId = Guid.NewGuid().ToString("D"), RecordKind = original.RecordKind, ReversesUsageId = original.Id, TransactionId = reversal.Id, Quantity = 1 }; + _references.Setup(x => x.GetForRecordAsync(Dept, recordId)).ReturnsAsync(new[] { originalReference }); + inventory.Setup(x => x.GetAsync(Dept, original.Id)).ReturnsAsync(original); + inventory.Setup(x => x.RelatedAsync(Dept, "ReversesTransactionId", original.TransactionId)).ReturnsAsync(new List { reversal }); + inventory.Setup(x => x.RelatedAsync(Dept, "ReversesUsageId", original.Id)).ReturnsAsync(new List { foreign }); + Func finalize = () => InventoryCoverageService(inventory.Object).RequireInventoryCoverageAsync(Dept, recordId, new[] { UsageArtifact(recordId, originalReference) }); + await finalize.Should().ThrowAsync().WithMessage("Attach the inventory reversal*"); + } + + [Test] + public async Task Modern_inventory_evidence_fails_closed_when_source_verification_is_unavailable() + { + var usage = new RecordInventoryUsage { DepartmentId = Dept, SourceId = _record.RmsOperationalRecordId, RecordKind = (int)RmsRecordKind.Operational, TransactionId = Guid.NewGuid().ToString("D"), Quantity = 1 }; + var reference = UsageReference(usage); + _references.Setup(x => x.GetForRecordAsync(Dept, _record.RmsOperationalRecordId)).ReturnsAsync(new[] { reference }); + Func finalize = () => _service.RequireInventoryCoverageAsync(Dept, _record.RmsOperationalRecordId, new[] { UsageArtifact(_record.RmsOperationalRecordId, reference) }); + await finalize.Should().ThrowAsync().WithMessage("Inventory evidence verification is unavailable."); + } + + private RecordsEvidenceService InventoryCoverageService(IInventoryStore inventory) => new(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, + _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { (IRecordEvidenceAdapter)_adapter }, _authorization.Object, Mock.Of(), _references.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), inventory); + private static RmsExternalReference UsageReference(RecordInventoryUsage usage) + { + var snapshot = RecordsEvidenceService.Serialize(new { SchemaVersion = 3, UsageId = usage.Id, usage.TransactionId, usage.ItemId, usage.Quantity, usage.ReversesUsageId }); + return new RmsExternalReference { RmsExternalReferenceId = usage.Id, DepartmentId = usage.DepartmentId, RecordId = usage.SourceId, RecordKind = usage.RecordKind.Value, + SourceSubsystem = RmsInventoryUsageAdapter.SourceSubsystem, SourceEntityType = "RecordInventoryUsage", SourceEntityId = usage.Id, SemanticRole = RmsInventoryUsageAdapter.SemanticRole, + SnapshotJson = snapshot, Checksum = RecordSnapshotSerializer.Checksum(snapshot) }; + } + private static RmsEvidenceArtifact UsageArtifact(string recordId, params RmsExternalReference[] references) + { + var manifest = RecordsEvidenceService.Serialize(new { usage = references.Select(reference => { + var snapshot = JObject.Parse(reference.SnapshotJson); return new { reference_id = reference.RmsExternalReferenceId, reference_checksum = reference.Checksum, + quantity = snapshot.Value("ReversesUsageId") == null ? snapshot.Value("Quantity") : -snapshot.Value("Quantity") }; }).ToArray() }); + return new RmsEvidenceArtifact { DepartmentId = Dept, RecordId = recordId, Kind = (int)RmsEvidenceKind.InventoryUsage, ManifestJson = manifest, Checksum = RecordSnapshotSerializer.Checksum(manifest) }; + } [Test] public async Task Forged_call_and_non_author_capture_are_denied_before_reading_the_source() { diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 445535766..61085e720 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -100,9 +100,14 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.InventoryTransferCompleted).Should().Be(58); ((int)WorkflowTriggerEventType.InventoryIssued).Should().Be(59); ((int)WorkflowTriggerEventType.InventoryReturned).Should().Be(60); + ((int)WorkflowTriggerEventType.InventoryLowStock).Should().Be(61); + ((int)WorkflowTriggerEventType.InventoryExpiring).Should().Be(62); + ((int)WorkflowTriggerEventType.InventoryCountCompleted).Should().Be(63); ((int)WorkflowTriggerEventType.InventoryAssetStatusChanged).Should().Be(64); + ((int)WorkflowTriggerEventType.InventoryPurchaseOrderReceived).Should().Be(65); ((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 })) + ((int)WorkflowTriggerEventType.InventoryReturnOverdue).Should().Be(166); + foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(58, 15))) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index 2e3503ec5..725e24654 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,11 +55,14 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "every catalog binding is counted"); result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); - // Catalog v17 binds 74 tables: 740,000 rows / 100 rps + 74×30s overhead - // = 9620s; ×1.25 = 12025s → 201 min P50, ×2 → 401 min P90. - result.EstimatedP50Minutes.Should().Be(201); - result.EstimatedP90Minutes.Should().Be(401); - result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); + // Each binding contributes (10,000 / 100 + 30) × 1.25 = 162.5 seconds P50 + // and 325 seconds P90. Keep the estimate valid as new catalog tables are added. + var expectedP50Minutes = (int)Math.Ceiling(AdpTableBindings.V1.Count * 162.5 / 60); + var expectedP90Minutes = (int)Math.Ceiling(AdpTableBindings.V1.Count * 325.0 / 60); + result.EstimatedP50Minutes.Should().Be(expectedP50Minutes); + result.EstimatedP90Minutes.Should().Be(expectedP90Minutes); + result.ProjectedNights.Should().Be((int)Math.Ceiling(expectedP90Minutes / 480.0), + "each overnight window must accommodate the P90 estimate"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs index e24cbf97a..3e0193d8a 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, 22, 58, 59, 60, 64, 66); + ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166); ChecklistWorkflowPayload.IsChecklist(67).Should().BeTrue(); ChecklistWorkflowPayload.IsChecklist(999).Should().BeFalse(); } diff --git a/Tests/Resgrid.Tests/Services/InventoryApiTests.cs b/Tests/Resgrid.Tests/Services/InventoryApiTests.cs index a7e3070be..fc8c2bd9c 100644 --- a/Tests/Resgrid.Tests/Services/InventoryApiTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryApiTests.cs @@ -30,7 +30,7 @@ namespace Resgrid.Tests.Services { /// Real MVC binding, authentication and action filters around mocked domain boundaries. [TestFixture, NonParallelizable] - public sealed class InventoryApiTests + public sealed partial class InventoryApiTests { private const string Route = "/api/v4/Inventory/"; private const string Canary = "SYNTHETIC-INVENTORY-API-PHI-CANARY"; @@ -43,12 +43,13 @@ public sealed class InventoryApiTests private Mock _issuance; private Mock _migration; private Mock _authorization; + private Mock _purchasing; 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 = new(); _stock = new(); _transfers = new(); _issuance = new(); _migration = new(); _authorization = new(); _purchasing = 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) => @@ -163,21 +164,20 @@ await WithServer(async client => } [Test] - public async Task Low_stock_uses_authorized_stock_pages_and_reports_the_visibility_scope() + public async Task Low_stock_uses_authorized_totals_for_the_catalog_page_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 } } }); + _stock.Setup(s => s.GetVisibleQuantitiesAsync(It.IsAny(), It.Is>(ids => ids.Count == 1 && ids.Contains(ItemId)))) + .ReturnsAsync(new Dictionary { [ItemId] = 3.125m }); 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); + _stock.Verify(s => s.GetVisibleQuantitiesAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), It.IsAny>()), Times.Once); + _catalog.Verify(s => s.ListAsync(It.IsAny(), It.IsAny()), Times.Never); }); } @@ -190,10 +190,12 @@ 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.AddAuthentication(scheme).AddScheme(scheme, _ => { }); + builder.Services.AddAuthorization(options => options.AddPolicy(Resgrid.Providers.Claims.ResgridResources.Inventory_Delete, policy => policy.RequireClaim("inventory-delete", "true"))); 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); + builder.Services.AddSingleton(_purchasing.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); } @@ -205,7 +207,8 @@ public TestAuthentication(IOptionsMonitor options, 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") }; + var claims = new List { new Claim(ClaimTypes.PrimarySid, user.ToString()), new Claim(ClaimTypes.PrimaryGroupSid, "77") }; + if (Request.Headers["Test-Inventory-Delete"] == "true") claims.Add(new Claim("inventory-delete", "true")); return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name))); } } diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs index ac69188b5..6aa3fee9b 100644 --- a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs @@ -79,12 +79,18 @@ public async Task CreateOnlyAnIsolatedInventoryDatabase() 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"; + var contactIdType = _type == DatabaseTypes.Postgres ? "citext" : "nvarchar(128)"; + if (_type == DatabaseTypes.Postgres) + { + await db.OpenAsync(); await db.ExecuteAsync("CREATE EXTENSION IF NOT EXISTS citext;"); + await ((NpgsqlConnection)db).ReloadTypesAsync(); + } await db.ExecuteAsync($@"CREATE TABLE {Q("Departments")} ({Q("DepartmentId")} int PRIMARY KEY); INSERT INTO {Q("Departments")} VALUES(77),(88); +CREATE TABLE {Q("Contacts")} ({Q("ContactId")} {contactIdType} PRIMARY KEY, {Q("DepartmentId")} int NOT NULL); 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'); @@ -99,7 +105,10 @@ public async Task CreateOnlyAnIsolatedInventoryDatabase() 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() + _type == DatabaseTypes.Postgres ? new M0199_FenceLegacyInventoryWritesPg() : new M0199_FenceLegacyInventoryWrites(), + _type == DatabaseTypes.Postgres ? new M0200_AddRecordInventoryUsagePg() : new M0200_AddRecordInventoryUsage(), + _type == DatabaseTypes.Postgres ? new M0201_AddInventoryPurchasingPg() : new M0201_AddInventoryPurchasing(), + _type == DatabaseTypes.Postgres ? new M0202_AddInventoryCountsAndAlertsPg() : new M0202_AddInventoryCountsAndAlerts() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { @@ -114,10 +123,11 @@ 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($"UPDATE {Q("InventoryCountItems")} SET {Q("TransactionId")}=NULL"); + foreach (var table in new[] { "InventoryAlertDeliveries", "InventoryAlerts", "RecordInventoryUsages", "InventoryTransferItems", "InventoryTransactions", "InventoryCountItems", "InventoryCounts", "InventoryPurchaseOrderItems", "InventoryPurchaseOrders", "InventoryVendors", "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" }) + foreach (var table in new[] { "InventoryLocations", "InventoryAssets", "InventoryLots", "InventoryItems", "InventoryCategories", "InventoryKits", "InventoryOperations", "Inventories", "InventoryTypes", "Contacts" }) await db.ExecuteAsync("DELETE FROM " + Q(table)); } @@ -150,11 +160,45 @@ private async Task RejectAsync(Func work) 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 async Task InsertContactAsync(int department = 77, string contactId = null) + { + contactId ??= Guid.NewGuid().ToString("D"); + await using var db = Connect(_connection); + await db.ExecuteAsync($"INSERT INTO {Q("Contacts")} ({Q("ContactId")},{Q("DepartmentId")}) VALUES(@contactId,@department)", new { contactId, department }); + return contactId; + } + private async Task<(InventoryVendor Vendor, InventoryPurchaseOrder Order, InventoryPurchaseOrderItem Line, InventoryItem Item, InventoryLocation Location)> SeedPurchasingAsync(int department = 77) + { + var seed = await SeedAsync(department); + var vendor = NewRow(department); vendor.ContactId = await InsertContactAsync(department); + var order = NewRow(department); order.VendorId = vendor.Id; order.CurrencyCode = "USD"; + var line = PurchaseLine(order, seed.Item); + await WriteAsync(async store => { await store.InsertAsync(vendor); await store.InsertAsync(order); await store.InsertAsync(line); }, department); + return (vendor, order, line, seed.Item, seed.Location); + } + private static InventoryPurchaseOrderItem PurchaseLine(InventoryPurchaseOrder order, InventoryItem item, int lineNumber = 1) + { + var row = NewRow(order.DepartmentId); row.PurchaseOrderId = order.Id; row.ItemId = item.Id; + row.LineNumber = lineNumber; row.QuantityOrdered = 3.123456m; return row; + } + private static InventoryTransaction Receipt(InventoryPurchaseOrder order, InventoryPurchaseOrderItem line, InventoryItem item, InventoryLocation location, decimal quantity = 0.123456m) + { + var row = Posting(item, null, location, quantity); row.ReferenceType = (int)InventoryReferenceType.PurchaseOrder; + row.ReferenceId = order.Id; row.PurchaseOrderItemId = line.Id; return row; + } 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 static RecordInventoryUsage Usage(InventoryTransaction transaction, string sourceId = null) + { + var row = NewRow(transaction.DepartmentId); + row.SourceType = (int)InventoryUsageSourceType.RmsRecord; row.SourceId = sourceId ?? Guid.NewGuid().ToString("D"); + row.RecordKind = (int)RmsRecordKind.Operational; row.ItemId = transaction.ItemId; row.AssetId = transaction.AssetId; row.LotId = transaction.LotId; + row.SourceLocationId = transaction.FromLocationId ?? transaction.ToLocationId; row.Quantity = transaction.Quantity; + row.TransactionId = transaction.Id; return row; + } private async Task MarkMigratedAsync(int department = 77) { var marker = NewRow(department); marker.RequestId = MigrationRequest; marker.State = 2; @@ -177,7 +221,13 @@ private async Task SeedCleanupEvidenceAsync(int department = 77) } var category = Protected(); var subcategory = Protected(); subcategory.ParentCategoryId = category.Id; var item = Protected(); item.CategoryId = subcategory.Id; + var vendor = Protected(); vendor.ContactId = await InsertContactAsync(department); + var order = Protected(); order.VendorId = vendor.Id; order.CurrencyCode = "USD"; + order.Status = (int)InventoryPurchaseOrderStatus.PartiallyReceived; order.OrderedOn = DateTime.UtcNow; + var orderLine = Protected(); orderLine.PurchaseOrderId = order.Id; orderLine.ItemId = item.Id; + orderLine.LineNumber = 1; orderLine.QuantityOrdered = 3.123456m; orderLine.QuantityReceived = 0.123456m; var facility = Protected(); var child = Protected(); child.ParentLocationId = facility.Id; + var receipt = Receipt(order, orderLine, item, child); receipt.IsProtected = true; receipt.Content = "SYNTHETIC-OPAQUE-RECEIPT-EVIDENCE"; 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; @@ -195,13 +245,29 @@ private async Task SeedCleanupEvidenceAsync(int department = 77) 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; + var usage = Usage(transaction); usage.IsProtected = true; usage.Content = "SYNTHETIC-OPAQUE-USAGE-EVIDENCE"; + var correctedUsage = Usage(reversal, usage.SourceId); correctedUsage.ReversesUsageId = usage.Id; + correctedUsage.IsProtected = true; correctedUsage.Content = "SYNTHETIC-OPAQUE-CORRECTION-EVIDENCE"; + var count = Protected(); count.LocationId = child.Id; count.Status = 2; count.SnapshotFingerprint = new string('A', 64); count.SnapshotOn = DateTime.UtcNow; count.CompletedOn = DateTime.UtcNow; count.OperationId = operation.Id; + var countLine = Protected(); countLine.CountId = count.Id; countLine.ItemId = item.Id; countLine.LocationId = child.Id; countLine.ExpectedQuantity = 1; countLine.CountedQuantity = 2; + var countMovement = Protected(); countMovement.ItemId = item.Id; countMovement.ToLocationId = child.Id; countMovement.Quantity = 1; countMovement.OccurredOn = DateTime.UtcNow; + countMovement.OperationId = operation.Id; countMovement.LineNumber = 99; countMovement.CountItemId = countLine.Id; countMovement.TransactionType = 7; countMovement.ReferenceType = 7; countMovement.ReferenceId = count.Id; + var alert = NewRow(department); alert.ItemId = item.Id; alert.LocationId = child.Id; alert.DedupKey = new string('B', 64); alert.OpenedOn = DateTime.UtcNow; + var delivery = NewRow(department); delivery.AlertId = alert.Id; delivery.UserId = "inventory-test-author"; delivery.NextAttemptOn = DateTime.UtcNow; + alert.Content = null; delivery.Content = null; await WriteAsync(async store => { await store.InsertAsync(category); await store.InsertAsync(subcategory); await store.InsertAsync(item); + await store.InsertAsync(vendor); await store.InsertAsync(order); await store.InsertAsync(orderLine); await store.InsertAsync(facility); await store.InsertAsync(child); await store.InsertAsync(lot); await store.InsertAsync(containerAsset); + await store.InsertAsync(receipt); 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(usage); await store.InsertAsync(correctedUsage); await store.InsertAsync(transfer); await store.InsertAsync(transferItem); await store.InsertAsync(kit); await store.InsertAsync(kitItem); + await store.InsertAsync(count); await store.InsertAsync(countLine); await store.InsertAsync(countMovement); + countLine.TransactionId = countMovement.Id; countLine.Revision++; await store.UpdateAsync(countLine, countLine.Revision - 1); + await store.InsertAsync(alert); await store.InsertAsync(delivery); }, department); foreach (var trigger in InventoryWorkflowPayload.Triggers.Concat(new[] { 0, 67, 70 })) { @@ -218,7 +284,7 @@ await db.ExecuteAsync($"INSERT INTO {Q("AuditLogs")} VALUES(@inventory,@departme 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" })) + foreach (var table in InventoryTables.All.Values.Concat(new[] { "WorkflowRuns", "WorkflowRunLogs", "DomainEventOutbox", "AuditLogs", "RmsRecordLegalHolds", "Inventories", "InventoryTypes", "Contacts" })) { var scope = !department.HasValue ? "" : table == "WorkflowRunLogs" ? $" WHERE {Q("WorkflowRunId")} IN (SELECT {Q("WorkflowRunId")} FROM {Q("WorkflowRuns")} WHERE {Q("DepartmentId")}=@department)" diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs index 1edd0f82c..8e460dc50 100644 --- a/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseTests.cs @@ -10,9 +10,11 @@ using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Resgrid.Config; +using Resgrid.Model; using Resgrid.Model.Inventories; using Resgrid.Repositories.DataRepository; using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; namespace Resgrid.Tests.Services { @@ -28,7 +30,7 @@ public void Empty_schema_and_writer_fences_can_reverse_and_reapply() public async Task Schema_covers_every_persisted_model_column_and_six_decimal_quantities() { await using var db = Connect(_connection); - InventoryTables.All.Should().HaveCount(13); + InventoryTables.All.Should().HaveCount(21); 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(); @@ -44,6 +46,330 @@ public async Task Schema_covers_every_persisted_model_column_and_six_decimal_qua } } + [Test] + public void Usage_catalog_upgrade_adds_only_new_content_and_keeps_existing_inventory_at_version_19() + { + var catalog = new ProtectedFieldCatalog(); + var originalTables = InventoryTables.All.Values.Except(new[] { "RecordInventoryUsages", "InventoryVendors", "InventoryPurchaseOrders", "InventoryPurchaseOrderItems", "InventoryCounts", "InventoryCountItems", "InventoryAlerts", "InventoryAlertDeliveries" }).ToArray(); + originalTables.Should().HaveCount(13); + foreach (var table in originalTables) + catalog.GetForTable(table).Should().ContainSingle().Which.AddedInCatalogVersion.Should().Be(19); + catalog.GetForTable("RecordInventoryUsages").Should().ContainSingle().Which.AddedInCatalogVersion.Should().Be(20); + catalog.GetForTableAndVersion("RecordInventoryUsages", 19).Should().BeEmpty(); + var binding = AdpTableBindings.ForVersionRange(catalog, 19, 20).Should().ContainSingle().Which; + binding.TableName.Should().Be("RecordInventoryUsages"); binding.PkColumn.Should().Be("Id"); binding.PkIsNumeric.Should().BeFalse(); + binding.DepartmentColumn.Should().Be("DepartmentId"); binding.ProtectedMarkerColumn.Should().Be("IsProtected"); + binding.Columns.Select(c => c.FieldId).Should().Equal("recordinventoryusages.content"); + } + + [Test] + public void Purchasing_catalog_upgrade_registers_only_the_three_new_protected_content_fields() + { + var catalog = new ProtectedFieldCatalog(); + var tables = new[] { "InventoryVendors", "InventoryPurchaseOrders", "InventoryPurchaseOrderItems" }; + var bindings = AdpTableBindings.ForVersionRange(catalog, 20, 21).ToArray(); + bindings.Select(b => b.TableName).Should().BeEquivalentTo(tables); + foreach (var binding in bindings) + { + catalog.GetForTable(binding.TableName).Should().ContainSingle().Which.AddedInCatalogVersion.Should().Be(21); + catalog.GetForTableAndVersion(binding.TableName, 20).Should().BeEmpty(); + binding.PkColumn.Should().Be("Id"); binding.PkIsNumeric.Should().BeFalse(); + binding.DepartmentColumn.Should().Be("DepartmentId"); binding.ProtectedMarkerColumn.Should().Be("IsProtected"); + binding.Columns.Select(c => c.FieldId).Should().Equal(binding.TableName.ToLowerInvariant() + ".content"); + } + catalog.GetForTable("RecordInventoryUsages").Should().ContainSingle().Which.AddedInCatalogVersion.Should().Be(20); + } + + [Test] + public async Task Purchasing_vendor_links_preserve_contact_identity_type_tenant_and_active_uniqueness() + { + var contactId = await InsertContactAsync(contactId: new string('V', 128)); + var foreignContact = await InsertContactAsync(88); + var vendor = NewRow(); vendor.ContactId = contactId; + await WriteAsync(store => store.InsertAsync(vendor)); + await RejectAsync(store => { var duplicate = NewRow(); duplicate.ContactId = contactId; return store.InsertAsync(duplicate); }); + await RejectAsync(store => { var foreign = NewRow(); foreign.ContactId = foreignContact; return store.InsertAsync(foreign); }); + await using var db = Connect(_connection); + if (_type == DatabaseTypes.Postgres) + (await db.QueryAsync("SELECT udt_name FROM information_schema.columns WHERE table_name IN ('contacts','inventoryvendors') AND column_name='contactid'")) + .Should().HaveCount(2).And.OnlyContain(t => t == "citext"); + else + (await db.QueryAsync("SELECT character_maximum_length FROM information_schema.columns WHERE table_name IN ('Contacts','InventoryVendors') AND column_name='ContactId'")) + .Should().HaveCount(2).And.OnlyContain(length => length == 128); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("Contacts")} WHERE {Q("ContactId")}=@contactId", new { contactId })).Should().ThrowAsync(); + vendor.IsDeleted = true; vendor.Revision++; + var replacement = NewRow(); replacement.ContactId = contactId; + await WriteAsync(async store => { await store.UpdateAsync(vendor, 1); await store.InsertAsync(replacement); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.GetAsync(77, replacement.Id)).ContactId.Should().Be(contactId); + (await repository.GetAsync(88, replacement.Id)).Should().BeNull(); + (await repository.RelatedAsync(77, "ContactId", contactId)).Select(v => v.Id).Should().BeEquivalentTo(new[] { vendor.Id, replacement.Id }); + (await repository.QueryAsync(77, new InventoryQuery())).Should().ContainSingle().Which.Id.Should().Be(replacement.Id); + } + + [Test] + public async Task Purchasing_order_and_line_foreign_keys_are_tenant_scoped_and_statuses_are_bounded() + { + var seed = await SeedPurchasingAsync(); var foreign = await SeedPurchasingAsync(88); + foreach (var corrupt in new Action[] + { + row => row.DepartmentId = 88, row => row.VendorId = foreign.Vendor.Id, row => row.Status = -1, row => row.Status = 5 + }) + await RejectAsync(store => + { + var row = NewRow(); row.VendorId = seed.Vendor.Id; row.CurrencyCode = "USD"; + corrupt(row); return store.InsertAsync(row); + }); + foreach (var corrupt in new Action[] + { + row => row.DepartmentId = 88, row => row.PurchaseOrderId = foreign.Order.Id, row => row.ItemId = foreign.Item.Id + }) + await RejectAsync(store => { var row = PurchaseLine(seed.Order, seed.Item, 2); corrupt(row); return store.InsertAsync(row); }); + await WriteAsync(async store => + { + foreach (InventoryPurchaseOrderStatus status in Enum.GetValues(typeof(InventoryPurchaseOrderStatus))) + { + var row = NewRow(); row.VendorId = seed.Vendor.Id; row.CurrencyCode = "USD"; row.Status = (int)status; + await store.InsertAsync(row); + } + }); + using var uow = new UnitOfWork(Connections()); + (await Store(uow).RelatedAsync(77, "VendorId", seed.Vendor.Id)).Select(o => o.Status).Distinct().Should().BeEquivalentTo(new[] { 0, 1, 2, 3, 4 }); + } + + [Test] + public async Task Purchasing_lines_enforce_positive_ordered_nonnegative_received_and_active_line_numbers() + { + var seed = await SeedPurchasingAsync(); + foreach (var corrupt in new Action[] + { + row => row.LineNumber = 0, row => row.QuantityOrdered = 0, row => row.QuantityOrdered = -1, + row => row.QuantityReceived = -0.000001m, row => row.QuantityReceived = row.QuantityOrdered + 0.000001m + }) + await RejectAsync(store => { var row = PurchaseLine(seed.Order, seed.Item, 2); corrupt(row); return store.InsertAsync(row); }); + await RejectAsync(store => store.InsertAsync(PurchaseLine(seed.Order, seed.Item))); + seed.Line.IsDeleted = true; seed.Line.Revision++; + var replacement = PurchaseLine(seed.Order, seed.Item); replacement.QuantityReceived = replacement.QuantityOrdered; + var next = PurchaseLine(seed.Order, seed.Item, 2); next.QuantityReceived = 0.123456m; + await WriteAsync(async store => { await store.UpdateAsync(seed.Line, 1); await store.InsertAsync(replacement); await store.InsertAsync(next); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.RelatedAsync(77, "PurchaseOrderId", seed.Order.Id)).Should().HaveCount(3); + (await repository.QueryAsync(77, new InventoryQuery())).Select(l => l.Id).Should().BeEquivalentTo(new[] { replacement.Id, next.Id }); + (await repository.GetAsync(77, replacement.Id)).QuantityReceived.Should().Be(3.123456m); + (await repository.GetAsync(77, next.Id)).QuantityReceived.Should().Be(0.123456m); + } + + [Test] + public async Task Purchasing_receipt_backlinks_require_same_tenant_lines_and_purchase_order_reference_type() + { + var seed = await SeedPurchasingAsync(); var foreign = await SeedPurchasingAsync(88); + await RejectAsync(store => store.InsertAsync(Receipt(seed.Order, foreign.Line, seed.Item, seed.Location))); + await RejectAsync(store => { var invalid = Receipt(seed.Order, seed.Line, seed.Item, seed.Location); invalid.ReferenceType = (int)InventoryReferenceType.None; return store.InsertAsync(invalid); }); + var receipt = Receipt(seed.Order, seed.Line, seed.Item, seed.Location); + await WriteAsync(store => store.InsertAsync(receipt)); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + var saved = await repository.GetAsync(77, receipt.Id); + saved.PurchaseOrderItemId.Should().Be(seed.Line.Id); saved.ReferenceId.Should().Be(seed.Order.Id); saved.Quantity.Should().Be(0.123456m); + (await repository.RelatedAsync(77, "PurchaseOrderItemId", seed.Line.Id)).Should().ContainSingle().Which.Id.Should().Be(receipt.Id); + (await repository.RelatedAsync(88, "PurchaseOrderItemId", seed.Line.Id)).Should().BeEmpty(); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("InventoryPurchaseOrderItems")} WHERE {Q("Id")}=@Id", new { seed.Line.Id })).Should().ThrowAsync(); + } + + [Test] + public async Task Purchasing_receipt_counter_stock_and_ledger_rollback_as_one_transaction() + { + var seed = await SeedPurchasingAsync(); + await WriteAsync(async store => { await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, 5, "inventory-test-author"); }); + var receipt = Receipt(seed.Order, seed.Line, seed.Item, seed.Location); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + await uow.CreateOrGetConnectionAsync(); await repository.LockDepartmentAsync(77); + seed.Line.QuantityReceived = receipt.Quantity; seed.Line.Revision++; + seed.Order.Status = (int)InventoryPurchaseOrderStatus.PartiallyReceived; seed.Order.Revision++; + await repository.UpdateAsync(seed.Line, 1); await repository.UpdateAsync(seed.Order, 1); await repository.InsertAsync(receipt); + await repository.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, receipt.Quantity, "inventory-test-author"); + uow.DiscardChanges(); + (await repository.GetAsync(77, receipt.Id)).Should().BeNull(); + var line = await repository.GetAsync(77, seed.Line.Id); line.QuantityReceived.Should().Be(0); line.Revision.Should().Be(1); + var order = await repository.GetAsync(77, seed.Order.Id); order.Status.Should().Be((int)InventoryPurchaseOrderStatus.Draft); order.Revision.Should().Be(1); + (await repository.RelatedAsync(77, "ItemId", seed.Item.Id)).Single().Quantity.Should().Be(5); + } + + [Test] + public async Task Purchasing_repository_updates_preserve_protected_content_revisions_and_tenant_boundaries() + { + var seed = await SeedPurchasingAsync(); + foreach (var row in new InventoryMutableRow[] { seed.Vendor, seed.Order, seed.Line }) + { row.Revision++; row.IsProtected = true; row.Content = new string('p', 18000); } + seed.Order.Status = (int)InventoryPurchaseOrderStatus.Received; seed.Order.OrderedOn = new DateTime(2026, 9, 1); seed.Order.ReceivedOn = new DateTime(2026, 9, 2); + seed.Line.QuantityReceived = seed.Line.QuantityOrdered; + await WriteAsync(async store => { await store.UpdateAsync(seed.Vendor, 1); await store.UpdateAsync(seed.Order, 1); await store.UpdateAsync(seed.Line, 1); }); + await FluentActions.Awaiting(() => WriteAsync(store => store.UpdateAsync(seed.Vendor, 1))).Should().ThrowAsync().WithMessage("*changed or is unavailable*"); + await FluentActions.Awaiting(() => WriteAsync(store => store.UpdateAsync(seed.Order, 1))).Should().ThrowAsync(); + await FluentActions.Awaiting(() => WriteAsync(store => store.UpdateAsync(seed.Line, 1))).Should().ThrowAsync(); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + var vendor = await repository.GetAsync(77, seed.Vendor.Id); + var order = await repository.GetAsync(77, seed.Order.Id); + var line = await repository.GetAsync(77, seed.Line.Id); + foreach (var row in new InventoryMutableRow[] { vendor, order, line }) + { row.Revision.Should().Be(2); row.IsProtected.Should().BeTrue(); row.Content.Should().Be(new string('p', 18000)); } + order.CurrencyCode.Should().Be("USD"); order.OrderedOn.Should().Be(seed.Order.OrderedOn); order.ReceivedOn.Should().Be(seed.Order.ReceivedOn); + line.QuantityReceived.Should().Be(3.123456m); + (await repository.GetAsync(88, vendor.Id)).Should().BeNull(); + (await repository.GetAsync(88, order.Id)).Should().BeNull(); + (await repository.GetAsync(88, line.Id)).Should().BeNull(); + order.IsDeleted = true; order.Revision++; + await WriteAsync(store => store.UpdateAsync(order, 2)); + (await repository.QueryAsync(77, new InventoryQuery())).Should().BeEmpty(); + (await repository.RelatedAsync(77, "VendorId", vendor.Id)).Should().ContainSingle().Which.IsDeleted.Should().BeTrue(); + } + + [Test] + public async Task Purchasing_migration_refuses_rollback_without_erasing_orders_or_receipt_provenance() + { + var seed = await SeedPurchasingAsync(); var receipt = Receipt(seed.Order, seed.Line, seed.Item, seed.Location); + receipt.IsProtected = true; receipt.Content = "SYNTHETIC-OPAQUE-RECEIPT"; + await WriteAsync(store => store.InsertAsync(receipt)); + await using var db = Connect(_connection); var before = await CleanupSnapshotAsync(db); + var runner = _runner.GetRequiredService(); + try { FluentActions.Invoking(() => runner.MigrateDown(200)).Should().Throw(); } + finally { _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); } + (await CleanupSnapshotAsync(db)).Should().BeEquivalentTo(before); + } + + [Test] + public async Task Usage_foreign_keys_isolate_tenant_item_asset_lot_transaction_and_correction_links() + { + var seed = await SeedAsync(); var other = await SeedAsync(); var foreign = await SeedAsync(88); + InventoryLot Lot(InventoryItem item) { var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.ReceivedOn = DateTime.UtcNow; return row; } + InventoryAsset Asset(InventoryItem item, InventoryLocation location) { var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.CurrentLocationId = location.Id; return row; } + var lot = Lot(seed.Item); var otherLot = Lot(other.Item); var foreignLot = Lot(foreign.Item); + var asset = Asset(seed.Item, seed.Location); var otherAsset = Asset(other.Item, other.Location); var foreignAsset = Asset(foreign.Item, foreign.Location); + var transaction = Posting(seed.Item, seed.Location, null, 0.123456m, InventoryTransactionType.Consume); + var foreignTransaction = Posting(foreign.Item, foreign.Location, null, 1, InventoryTransactionType.Consume); + var foreignUsage = Usage(foreignTransaction); + await WriteAsync(async store => { await store.InsertAsync(lot); await store.InsertAsync(otherLot); await store.InsertAsync(asset); await store.InsertAsync(otherAsset); await store.InsertAsync(transaction); }); + await WriteAsync(async store => { await store.InsertAsync(foreignLot); await store.InsertAsync(foreignAsset); await store.InsertAsync(foreignTransaction); await store.InsertAsync(foreignUsage); }, 88); + foreach (var corrupt in new Action[] + { + r => r.DepartmentId = 88, r => r.ItemId = foreign.Item.Id, r => r.SourceLocationId = foreign.Location.Id, + r => r.TransactionId = foreignTransaction.Id, r => r.AssetId = foreignAsset.Id, r => r.LotId = foreignLot.Id, + r => r.AssetId = otherAsset.Id, r => r.LotId = otherLot.Id, r => r.ReversesUsageId = foreignUsage.Id + }) + await RejectAsync(store => { var invalid = Usage(transaction); corrupt(invalid); return store.InsertAsync(invalid); }); + var usage = Usage(transaction); usage.AssetId = asset.Id; usage.LotId = lot.Id; + usage.CallId = 987654; usage.RmsRevisionId = Guid.NewGuid().ToString("D"); usage.Content = new string('u', 18000); + await WriteAsync(store => store.InsertAsync(usage)); + using var uow = new UnitOfWork(Connections()); var saved = await Store(uow).GetAsync(77, usage.Id); + saved.Quantity.Should().Be(0.123456m); saved.Content.Should().HaveLength(18000); saved.RmsRevisionId.Should().Be(usage.RmsRevisionId); + (await Store(uow).GetAsync(88, usage.Id)).Should().BeNull(); + } + + [Test] + public async Task Usage_checks_reject_null_rms_kind_invalid_source_quantity_and_self_reversal() + { + var seed = await SeedAsync(); var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); + await WriteAsync(store => store.InsertAsync(transaction)); + foreach (var corrupt in new Action[] + { + r => r.SourceType = -1, r => r.SourceType = 2, r => r.RecordKind = null, r => r.RecordKind = 3, + r => r.SourceType = (int)InventoryUsageSourceType.LegacyLog, r => r.Quantity = 0, r => r.Quantity = -1, + r => r.UsageType = -1, r => r.UsageType = 4, r => r.Revision = 0, r => r.ReversesUsageId = r.Id + }) + await RejectAsync(store => { var invalid = Usage(transaction); corrupt(invalid); return store.InsertAsync(invalid); }); + var legacy = Usage(transaction); legacy.SourceType = (int)InventoryUsageSourceType.LegacyLog; legacy.RecordKind = null; legacy.SourceId = "314"; + await WriteAsync(store => store.InsertAsync(legacy)); + using var uow = new UnitOfWork(Connections()); + (await Store(uow).GetAsync(77, legacy.Id)).SourceId.Should().Be("314", "legacy source IDs remain soft references rather than GUID-only Record identities"); + } + + [Test] + public async Task Usage_transaction_and_reversal_uniqueness_prevent_duplicate_claims() + { + var seed = await SeedAsync(); var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); + var usage = Usage(transaction); + var reversal = Posting(seed.Item, null, seed.Location, 1, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = transaction.Id; + var correction = Usage(reversal, usage.SourceId); correction.ReversesUsageId = usage.Id; + var secondReversal = Posting(seed.Item, null, seed.Location, 1, InventoryTransactionType.Adjust); secondReversal.ReversesTransactionId = transaction.Id; + await WriteAsync(async store => { await store.InsertAsync(transaction); await store.InsertAsync(usage); await store.InsertAsync(reversal); await store.InsertAsync(correction); await store.InsertAsync(secondReversal); }); + await RejectAsync(store => store.InsertAsync(Usage(transaction, Guid.NewGuid().ToString("D")))); + await RejectAsync(store => { var duplicate = Usage(secondReversal, usage.SourceId); duplicate.ReversesUsageId = usage.Id; return store.InsertAsync(duplicate); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.ListAsync(77)).Select(r => r.Id).Should().BeEquivalentTo(new[] { usage.Id, correction.Id }); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("InventoryTransactions")} WHERE {Q("Id")}=@Id", new { reversal.Id })).Should().ThrowAsync(); + } + + [Test] + public async Task Usage_store_updates_are_rejected_without_rewriting_original_evidence() + { + var seed = await SeedAsync(); var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); var usage = Usage(transaction); + usage.Content = "ORIGINAL-USAGE"; + await WriteAsync(async store => { await store.InsertAsync(transaction); await store.InsertAsync(usage); }); + usage.Quantity = 2; usage.Content = "REWRITTEN-USAGE"; usage.Revision = 2; + await FluentActions.Awaiting(() => WriteAsync(store => store.UpdateAsync(usage, 1))).Should().ThrowAsync().WithMessage("*immutable*"); + using var uow = new UnitOfWork(Connections()); var saved = await Store(uow).GetAsync(77, usage.Id); + saved.Quantity.Should().Be(1); saved.Content.Should().Be("ORIGINAL-USAGE"); saved.Revision.Should().Be(1); + } + + [Test] + public async Task Usage_source_filters_apply_before_paging_and_preserve_kind_and_tenant_boundaries() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); var sourceId = Guid.NewGuid().ToString("D"); + var matches = new System.Collections.Generic.List(); + await WriteAsync(async store => + { + for (var i = 0; i < 1003; i++) + { + var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); await store.InsertAsync(transaction); + var row = Usage(transaction, i < 501 ? "unrelated" : sourceId); row.Id = $"00000000-0000-0000-0000-{i + 1:000000000000}"; + if (i >= 501) matches.Add(row); await store.InsertAsync(row); + } + foreach (var source in new[] { (Type: InventoryUsageSourceType.RmsRecord, Kind: (int?)RmsRecordKind.IncidentReport), (Type: InventoryUsageSourceType.LegacyLog, Kind: (int?)null) }) + { + var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); await store.InsertAsync(transaction); + var row = Usage(transaction, sourceId); row.SourceType = (int)source.Type; row.RecordKind = source.Kind; + row.Id = $"ffffffff-ffff-ffff-ffff-{(int)source.Type + 1:000000000000}"; await store.InsertAsync(row); + } + }); + await WriteAsync(async store => { var transaction = Posting(foreign.Item, foreign.Location, null, 1, InventoryTransactionType.Consume); await store.InsertAsync(transaction); await store.InsertAsync(Usage(transaction, sourceId)); }, 88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + var filter = new InventoryQuery { SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = sourceId, RecordKind = (int)RmsRecordKind.Operational }; + (await repository.QueryAsync(77, new InventoryQuery())).Should().HaveCount(501).And.OnlyContain(r => r.SourceId == "unrelated"); + (await repository.QueryAsync(77, filter)).Select(r => r.Id).Should().Equal(matches.Take(501).Select(r => r.Id)); + (await repository.QueryAsync(77, filter, 500)).Select(r => r.Id).Should().Equal(matches.Skip(500).Select(r => r.Id)); + (await repository.QueryAsync(88, filter)).Should().ContainSingle().Which.DepartmentId.Should().Be(88); + (await repository.QueryAsync(77, new InventoryQuery { SourceId = "' OR 1=1;--" })).Should().BeEmpty(); + await FluentActions.Awaiting(() => repository.QueryAsync(77, filter)).Should().ThrowAsync(); + } + + [Test] + public async Task Usage_stock_and_ledger_joined_transaction_roll_back_together() + { + var seed = await SeedAsync(); await WriteAsync(async store => { await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, 5, "inventory-test-author"); }); + var transaction = Posting(seed.Item, seed.Location, null, 0.123456m, InventoryTransactionType.Consume); var usage = Usage(transaction); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); await uow.CreateOrGetConnectionAsync(); await repository.LockDepartmentAsync(77); + await repository.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, -usage.Quantity, "inventory-test-author"); + await repository.InsertAsync(transaction); await repository.InsertAsync(usage); uow.DiscardChanges(); + (await repository.GetAsync(77, transaction.Id)).Should().BeNull(); + (await repository.GetAsync(77, usage.Id)).Should().BeNull(); + (await repository.RelatedAsync(77, "ItemId", seed.Item.Id)).Single().Quantity.Should().Be(5); + } + + [Test] + public async Task Usage_migration_refuses_rollback_while_usage_and_correction_provenance_exist() + { + var seed = await SeedAsync(); var transaction = Posting(seed.Item, seed.Location, null, 1, InventoryTransactionType.Consume); + var usage = Usage(transaction); var reversal = Posting(seed.Item, null, seed.Location, 1, InventoryTransactionType.Adjust); + reversal.ReversesTransactionId = transaction.Id; var correction = Usage(reversal, usage.SourceId); correction.ReversesUsageId = usage.Id; + await WriteAsync(async store => { await store.InsertAsync(transaction); await store.InsertAsync(usage); await store.InsertAsync(reversal); await store.InsertAsync(correction); }); + await using var db = Connect(_connection); var before = await CleanupSnapshotAsync(db); + var runner = _runner.GetRequiredService(); + try { FluentActions.Invoking(() => runner.MigrateDown(199)).Should().Throw(); } + finally { _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); } + (await CleanupSnapshotAsync(db)).Should().BeEquivalentTo(before); + } + [Test] public async Task Ledger_allocates_bigint_identity_keeps_guid_and_round_trips_six_decimals() { @@ -151,6 +477,9 @@ public async Task Inventory_foreign_keys_reject_cross_tenant_locations_and_misma [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.Station; row.GroupId = 223; return store.InsertAsync(row); }); + var station = NewRow(); station.LocationType = (int)InventoryLocationType.Station; station.GroupId = 123; + await WriteAsync(store => store.InsertAsync(station)); 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); }); @@ -362,6 +691,7 @@ await db.ExecuteAsync($"INSERT INTO {Q("RmsRecordLegalHolds")} VALUES(@released, .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 LegacyCountAsync(db, "Contacts", 77)).Should().Be(1, "Inventory cleanup must preserve the Contacts-owned organization identity"); await FluentActions.Awaiting(() => InsertLegacyTypeAsync(db, 88)).Should().ThrowAsync(); } } diff --git a/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs index ec425b6ba..9e7da71fb 100644 --- a/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs @@ -24,6 +24,13 @@ private static IInventoryStore EmptyInventory() 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())); + 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; } @@ -45,6 +52,28 @@ private void UseInventoryExport(IInventoryStore store, bool enforced = false, bo 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 }; + [Test] + public void Inventory_export_requires_storage_at_construction() + { + FluentActions.Invoking(() => UseInventoryExport(null)).Should().Throw().WithParameterName("inventoryStore"); + } + + [Test] + public async Task Authorship_of_shared_locations_does_not_export_other_holders_assets_or_movements() + { + var store = EmptyInventory(); + var shared = InventoryExportRow(); shared.LocationType = (int)InventoryLocationType.Station; shared.GroupId = 5; + var personal = InventoryExportRow("other"); personal.UserId = UserId; + var foreignAsset = InventoryExportRow("other", content: "UNRELATED-CANARY"); foreignAsset.CurrentLocationId = shared.Id; + var foreignMovement = InventoryExportRow("other", content: "UNRELATED-CANARY"); foreignMovement.ToLocationId = shared.Id; + var heldAsset = InventoryExportRow("other"); heldAsset.CurrentLocationId = personal.Id; + InventoryExportPages(store, shared, personal); InventoryExportPages(store, foreignAsset, heldAsset); InventoryExportPages(store, foreignMovement); + UseInventoryExport(store); + var json = (await RunExportAsync())["inventory.json"]; var data = JObject.Parse(json); + json.Should().NotContain("UNRELATED-CANARY"); data["Locations"].Should().HaveCount(2); + data["Assets"].Should().ContainSingle(); data["Assets"][0]["Id"].Value().Should().Be(heldAsset.Id); data["Transactions"].Should().BeEmpty(); + } + 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()); @@ -53,6 +82,7 @@ private static void InventoryExportPages(IInventoryStore store, params T[] ro public async Task Inventory_export_includes_subject_relationships_and_excludes_unrelated_or_foreign_candidates() { var store = EmptyInventory(); + InventoryExportPages(store, InventoryExportRow(), InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); var location = InventoryExportRow("another-user"); location.UserId = UserId; var authoredLocation = InventoryExportRow(); InventoryExportPages(store, location, authoredLocation, @@ -69,11 +99,22 @@ public async Task Inventory_export_includes_subject_relationships_and_excludes_u 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")); + var vendor = InventoryExportRow(); vendor.ContactId = Guid.NewGuid().ToString("D"); + var order = InventoryExportRow(); order.VendorId = vendor.Id; + var line = InventoryExportRow(); line.PurchaseOrderId = order.Id; + var unrelatedOrder = InventoryExportRow("another-user", content: "UNRELATED-CANARY"); unrelatedOrder.VendorId = vendor.Id; + var unrelatedLine = InventoryExportRow("another-user", content: "UNRELATED-CANARY"); unrelatedLine.PurchaseOrderId = order.Id; + InventoryExportPages(store, vendor, InventoryExportRow("another-user", content: "UNRELATED-CANARY"), InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + InventoryExportPages(store, order, unrelatedOrder, InventoryExportRow(department: 999, content: "FOREIGN-CANARY")); + InventoryExportPages(store, line, unrelatedLine, 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(); + data["Vendors"].Should().ContainSingle(); data["Vendors"][0]["Id"].Value().Should().Be(vendor.Id); + data["PurchaseOrders"].Should().ContainSingle(); data["PurchaseOrders"][0]["Id"].Value().Should().Be(order.Id); + data["PurchaseOrderItems"].Should().ContainSingle(); data["PurchaseOrderItems"][0]["Id"].Value().Should().Be(line.Id); } [TestCase(false, false), TestCase(false, true), TestCase(true, false), TestCase(true, true)] @@ -102,19 +143,33 @@ public async Task Inventory_export_masks_envelopes_enrollment_plaintext_and_poli 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); + var vendorContent = encrypted ? "rgdp:1:21:SYNTHETIC-VENDOR-CIPHERTEXT-CANARY" : "{\"AccountNumber\":\"VENDOR-ACCOUNT-CANARY\",\"Note\":\"VENDOR-NOTE-CANARY\"}"; + var orderContent = encrypted ? "rgdp:1:21:SYNTHETIC-ORDER-CIPHERTEXT-CANARY" : "{\"Number\":\"ORDER-NUMBER-CANARY\",\"SupplierName\":\"SUPPLIER-CANARY\",\"Note\":\"ORDER-NOTE-CANARY\"}"; + var lineContent = encrypted ? "rgdp:1:21:SYNTHETIC-LINE-CIPHERTEXT-CANARY" : "{\"UnitCost\":12345.678901,\"Note\":\"LINE-NOTE-CANARY\"}"; + var vendor = InventoryExportRow(content: vendorContent); vendor.ContactId = Guid.NewGuid().ToString("D"); + var order = InventoryExportRow(content: orderContent); order.VendorId = vendor.Id; + var line = InventoryExportRow(content: lineContent); line.PurchaseOrderId = order.Id; + var purchasing = new InventoryRow[] { vendor, order, line }; + var originals = purchasing.ToDictionary(row => row.Id, row => JObject.FromObject(row).ToString()); InventoryExportPages(store, location); InventoryExportPages(store, issuance); InventoryExportPages(store, asset); - InventoryExportPages(store, transaction); InventoryExportPages(store, operation); UseInventoryExport(store, enforced, policyFailure); + InventoryExportPages(store, transaction); InventoryExportPages(store, operation); + InventoryExportPages(store, vendor); InventoryExportPages(store, order); InventoryExportPages(store, line); 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); + foreach (var entry in new[] { (Property: "Vendors", Content: vendorContent), (Property: "PurchaseOrders", Content: orderContent), (Property: "PurchaseOrderItems", Content: lineContent) }) + data[entry.Property][0]["Content"].Value().Should().Be(masked ? ProtectedDataEnvelope.RedactionValue : entry.Content); if (masked) { - json.Should().NotContain("CANARY").And.NotContain("rgdp:"); - JObject.Parse(files["withheld.json"])["entries"]["inventory.json"]["valuesWithheld"].Value().Should().Be(5); + json.Should().NotContain("CANARY").And.NotContain("rgdp:").And.NotContain("12345.678901"); + var withheld = JObject.Parse(files["withheld.json"])["entries"]["inventory.json"]; + withheld["valuesWithheld"].Value().Should().Be(8); + withheld["fields"].Values().Should().Contain(new[] { "Vendors[].Content", "PurchaseOrders[].Content", "PurchaseOrderItems[].Content" }); } else files.Should().NotContainKey("withheld.json"); new InventoryRow[] { location, issuance, asset, transaction, operation }.Should().OnlyContain(x => x.Content == content); + foreach (var row in purchasing) JObject.FromObject(row).ToString().Should().Be(originals[row.Id], "export masking must not rewrite stored supplier details or purchase costs"); } [Test] @@ -125,11 +180,13 @@ public async Task Inventory_export_pages_every_row_family_and_includes_witnesses .Select(i => InventoryExportRow(i >= 999 ? UserId : "another-user")).ToArray(); var locations = Rows(); var issuances = Rows(); var assets = Rows(); var transactions = Rows(); var operations = Rows(); + var vendors = Rows(); var orders = Rows(); var lines = 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); + InventoryExportPages(store, transactions); InventoryExportPages(store, operations); + InventoryExportPages(store, vendors); InventoryExportPages(store, orders); InventoryExportPages(store, lines); UseInventoryExport(store); var data = JObject.Parse((await RunExportAsync())["inventory.json"]); - foreach (var property in new[] { "Locations", "Issuances", "Assets", "Transactions" }) + foreach (var property in new[] { "Locations", "Issuances", "Assets", "Transactions", "Vendors", "PurchaseOrders", "PurchaseOrderItems" }) { data[property].Should().HaveCount(3); data[property].Select(x => x["Id"].Value()).Should().OnlyHaveUniqueItems(); } @@ -140,6 +197,7 @@ 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(); + VerifyPages(); VerifyPages(); VerifyPages(); } [Test] diff --git a/Tests/Resgrid.Tests/Services/InventoryM3PostingTests.cs b/Tests/Resgrid.Tests/Services/InventoryM3PostingTests.cs new file mode 100644 index 000000000..1cbbef487 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM3PostingTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +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.Services; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + private RecordInventoryUsage UsageFor(InventoryTransaction transaction, string sourceId = null) => new() + { + DepartmentId = Department, SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = sourceId ?? Guid.NewGuid().ToString("D"), + RecordKind = (int)RmsRecordKind.Operational, ItemId = transaction.ItemId, AssetId = transaction.AssetId, LotId = transaction.LotId, + SourceLocationId = transaction.FromLocationId ?? transaction.ToLocationId, Quantity = transaction.Quantity, + TransactionId = transaction.Id, UsageType = (int)InventoryUsageType.Used + }; + + private async Task AttachUsage(RecordInventoryUsage usage) + { + Begin(); + try { await _service.RecordUsageWithinTransactionAsync(_actor, usage); _uow.Object.CommitChanges(); } + catch { _uow.Object.DiscardChanges(); throw; } + } + + [Test] + public async Task Usage_attachment_checks_transaction_provenance_and_keeps_stock_unchanged() + { + var item = Item(); var other = Item(); var location = Location(); var otherLocation = Location(); SeedStock(item, location, 5); + var result = await _service.PostTransactionAsync(_actor, Command(Move(item, location, null, 1.125001m, InventoryTransactionType.Consume))); + var transaction = _store.All().Single(); var usage = UsageFor(transaction); + await FluentActions.Awaiting(() => _service.RecordUsageWithinTransactionAsync(_actor, usage)).Should().ThrowAsync(); + await Fails(() => AttachUsage(new RecordInventoryUsage { DepartmentId = Department + 1 }), "InvalidUsageSource", 400); + foreach (var mismatch in new Action[] + { + r => r.Quantity = 1, r => r.ItemId = other.Id, r => r.SourceLocationId = otherLocation.Id, + r => r.AssetId = Guid.NewGuid().ToString("D"), r => r.LotId = Guid.NewGuid().ToString("D") + }) + { + var invalid = Copy(usage); mismatch(invalid); await Fails(() => AttachUsage(invalid), "UsageTransactionMismatch", 409); + } + var foreignTransaction = Copy(transaction); foreignTransaction.Id = Guid.NewGuid().ToString("D"); foreignTransaction.DepartmentId++; + _store.Seed(foreignTransaction); var foreignUsage = Copy(usage); foreignUsage.TransactionId = foreignTransaction.Id; + await Fails(() => AttachUsage(foreignUsage), "Unavailable", 404); + await AttachUsage(usage); + var saved = _store.All().Single(); saved.TransactionId.Should().Be(result.TransactionIds.Single()); + saved.SourceId.Should().Be(usage.SourceId); saved.Quantity.Should().Be(1.125001m); saved.CreatedOn.Should().Be(transaction.OccurredOn); saved.CreatedBy.Should().Be(_actor.UserId); + Stock(item, location).Should().Be(3.874999m); _events.Should().ContainSingle(); + await Fails(() => AttachUsage(Copy(usage)), "UsageAlreadyRecorded", 409); Stock(item, location).Should().Be(3.874999m); + _recordsAuth.Setup(x => x.CanUserViewRecordAsync(_actor.UserId, usage.SourceId, Department)).ReturnsAsync(false); + await Fails(() => _service.GetAsync(_actor, usage.Id), "RecordSourceAuthorizationRequired", 403); + } + + [Test] + public async Task Controlled_usage_rejects_unwitnessed_ledger_evidence_and_attaches_a_completed_independent_witness_once() + { + var item = Item(controlled: true); var location = Location(); SeedStock(item, location, 5); + var unaudited = _store.Seed(new InventoryTransaction { DepartmentId = Department, CreatedBy = _actor.UserId, ItemId = item.Id, + FromLocationId = location.Id, Quantity = 1, TransactionType = (int)InventoryTransactionType.Consume, OccurredOn = _clock.Utc }); + await Fails(() => AttachUsage(UsageFor(unaudited)), "IndependentWitnessRequired", 409); + var command = Command(Move(item, location, null, 1, InventoryTransactionType.Consume)); + (await _service.PostTransactionAsync(_actor, command)).AwaitingWitness.Should().BeTrue(); Stock(item, location).Should().Be(5); + var completed = await Witness(command.RequestId); var transaction = _store.All().Single(t => t.Id == completed.TransactionIds.Single()); + var usage = UsageFor(transaction); await AttachUsage(usage); + _store.All().Should().ContainSingle().Which.TransactionId.Should().Be(transaction.Id); Stock(item, location).Should().Be(4); + _auth.Verify(x => x.RequireAsync(_actor, true, PermissionTypes.ManageControlledSubstances, null), Times.AtLeastOnce); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Usage_content_is_protected_and_failed_protection_rolls_back_joined_stock_ledger_and_events(bool failProtection) + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); var sourceId = Guid.NewGuid().ToString("D"); + var key = RandomNumberGenerator.GetBytes(32); var crypto = new ProtectedFieldCryptoService(); + try + { + if (failProtection) + _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_usage_protection_failure")); + else EncryptWrites(key, crypto); + async Task PostAndAttach() + { + Begin(); + try + { + var posting = Move(item, location, null, 1, InventoryTransactionType.Consume); posting.ReferenceType = InventoryReferenceType.RmsRecord; posting.ReferenceId = sourceId; + var result = await _service.PostWithinTransactionAsync(_actor, Command(posting)); + var usage = UsageFor(_store.All().Single(t => t.Id == result.TransactionIds.Single()), sourceId); + usage.Content = JsonConvert.SerializeObject(new InventoryLabel { Note = Canary }); await _service.RecordUsageWithinTransactionAsync(_actor, usage); + _uow.Object.CommitChanges(); + } + catch { _uow.Object.DiscardChanges(); throw; } + } + if (failProtection) + { + await Fails(PostAndAttach, "ProtectedDataRequired", 403); Stock(item, location).Should().Be(5); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _events.Should().BeEmpty(); _audits.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + } + else + { + await PostAndAttach(); var saved = _store.All().Single(); saved.IsProtected.Should().BeTrue(); saved.Content.Should().StartWith("rgdp:").And.NotContain(Canary); + JObject.Parse(crypto.DecryptText(key, saved.Content, Department, "recordinventoryusages.content", saved.Id)).Value("Note").Should().Be(Canary); + Stock(item, location).Should().Be(4); _events.Should().ContainSingle(); _dispatches.Should().BeEmpty("the source adapter dispatches only after its own commit"); + } + } + finally { CryptographicOperations.ZeroMemory(key); } + } + + [TestCase(InventoryAssetStatus.InService)] + [TestCase(InventoryAssetStatus.OutForRepair)] + [TestCase(InventoryAssetStatus.Damaged)] + public async Task Exact_serialized_consumption_reversal_restores_the_original_status(InventoryAssetStatus originalStatus) + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + if (originalStatus != InventoryAssetStatus.InService) await _service.ChangeAssetStatusAsync(_actor, Status(item, asset, location, originalStatus)); + asset = _store.All().Single(); var consume = Move(item, location, null, 1, InventoryTransactionType.Consume); consume.AssetId = asset.Id; consume.ExpectedAssetRevision = asset.Revision; + var consumed = await _service.PostTransactionAsync(_actor, Command(consume)); var transaction = _store.All().Single(t => t.Id == consumed.TransactionIds.Single()); + var reversal = Move(item, null, location, 1, InventoryTransactionType.Adjust); reversal.AssetId = asset.Id; reversal.ReversesTransactionId = transaction.Id; + var reversed = await _service.PostTransactionAsync(_actor, Command(reversal)); var restored = _store.All().Single(); + restored.Status.Should().Be((int)originalStatus); restored.CurrentLocationId.Should().Be(location.Id); + var correction = _store.All().Single(t => t.Id == reversed.TransactionIds.Single()); + correction.ReversesTransactionId.Should().Be(transaction.Id); correction.OldStatus.Should().Be((int)InventoryAssetStatus.Consumed); correction.NewStatus.Should().Be((int)originalStatus); + } + + [Test] + public async Task Serialized_reversal_rejects_a_later_lifecycle_change_even_if_status_and_location_match_again() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + var consume = Move(item, location, null, 1, InventoryTransactionType.Consume); consume.AssetId = asset.Id; + var consumed = await _service.PostTransactionAsync(_actor, Command(consume)); var originalId = consumed.TransactionIds.Single(); + await _service.ChangeAssetStatusAsync(_actor, Status(item, _store.All().Single(), location, InventoryAssetStatus.Lost)); + await _service.ChangeAssetStatusAsync(_actor, Status(item, _store.All().Single(), location, InventoryAssetStatus.Consumed)); + var before = JsonConvert.SerializeObject(_store.All()); var count = _store.All().Count(); + var reversal = Move(item, null, location, 1, InventoryTransactionType.Adjust); reversal.AssetId = asset.Id; reversal.ReversesTransactionId = originalId; + await Fails(() => _service.PostTransactionAsync(_actor, Command(reversal)), "AssetReversalConflict", 409); + JsonConvert.SerializeObject(_store.All()).Should().Be(before); _store.All().Should().HaveCount(count); + } + + [Test] + public async Task Generic_reversal_of_attached_consumption_requires_the_record_correction_path() + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); + var consumed = await _service.PostTransactionAsync(_actor, Command(Move(item, location, null, 1, InventoryTransactionType.Consume))); + var transaction = _store.All().Single(); await AttachUsage(UsageFor(transaction)); + var reversal = Move(item, null, location, 1, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = consumed.TransactionIds.Single(); + await Fails(() => _service.PostTransactionAsync(_actor, Command(reversal)), "RecordUsageCorrectionRequired", 409); + Stock(item, location).Should().Be(4); _store.All().Should().ContainSingle(); + _recordUsageAdapter.Verify(x => x.RequireUsageCorrectionAccessAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Controlled_attached_reversal_requires_current_record_access_before_pending_and_for_both_witness_principals() + { + var item = Item(controlled: true); var location = Location(); SeedStock(item, location, 5); + var consume = Command(Move(item, location, null, 1, InventoryTransactionType.Consume)); await _service.PostTransactionAsync(_actor, consume); await Witness(consume.RequestId); + var transaction = _store.All().Single(); var usage = UsageFor(transaction); await AttachUsage(usage); + var reversal = Move(item, null, location, 1, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = transaction.Id; var command = Command(reversal); + _recordUsageAdapter.Setup(x => x.RequireUsageCorrectionAccessAsync(It.IsAny(), usage.Id)).ThrowsAsync(new InventoryException(403, "RecordSourceAuthorizationRequired")); + await Fails(() => _service.PostTransactionAsync(_actor, command), "RecordSourceAuthorizationRequired", 403); + _store.All().Should().ContainSingle(); Stock(item, location).Should().Be(4); + _recordUsageAdapter.Setup(x => x.RequireUsageCorrectionAccessAsync(It.IsAny(), usage.Id)).Returns(Task.CompletedTask); + (await _service.PostTransactionAsync(_actor, command)).AwaitingWitness.Should().BeTrue(); Stock(item, location).Should().Be(4); + _recordUsageAdapter.Setup(x => x.RequireUsageCorrectionAccessAsync(It.Is(a => a.UserId == "witness"), usage.Id)).ThrowsAsync(new InventoryException(403, "RecordSourceAuthorizationRequired")); + await Fails(() => Witness(command.RequestId), "RecordSourceAuthorizationRequired", 403); + _store.All().Should().ContainSingle(); Stock(item, location).Should().Be(4); + _recordUsageAdapter.Setup(x => x.RequireUsageCorrectionAccessAsync(It.IsAny(), usage.Id)).Returns(Task.CompletedTask); + (await Witness(command.RequestId)).AwaitingWitness.Should().BeFalse(); Stock(item, location).Should().Be(5); + _recordUsageAdapter.Verify(x => x.RequireUsageCorrectionAccessAsync(It.Is(a => a.UserId == _actor.UserId && a.GrantToken == null), usage.Id), Times.AtLeastOnce); + _recordUsageAdapter.Verify(x => x.RequireUsageCorrectionAccessAsync(It.Is(a => a.UserId == "witness"), usage.Id), Times.AtLeastOnce); + } + + [Test] + public async Task Old_public_operation_fingerprint_and_replay_survive_absent_M3_usage_fields() + { + var item = Item(); var location = Location(); var command = Receive(item, location, 3); + var oldShape = JObject.FromObject(new { Kind = "Post", command.Lines }); + foreach (var line in oldShape["Lines"].Children()) { line.Remove("UsageId"); line.Remove("UsageType"); } + var expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(oldShape.ToString(Formatting.None)))); + var completed = await _service.PostTransactionAsync(_actor, command); var receipt = JsonConvert.DeserializeObject(_store.All().Single().Content); + receipt.Fingerprint.Should().Be(expected); JsonConvert.SerializeObject(command.Lines).Should().NotContain("UsageId").And.NotContain("UsageType"); + (await _service.PostTransactionAsync(_actor, Copy(command))).TransactionIds.Should().Equal(completed.TransactionIds); + Stock(item, location).Should().Be(3); _store.All().Should().ContainSingle(); + } + + [Test] + public async Task Joined_usage_workflow_exposes_safe_usage_identifiers_and_reversal_backlink_without_narrative() + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); var sourceId = Guid.NewGuid().ToString("D"); + var usageId = Guid.NewGuid().ToString("D"); var correctionId = Guid.NewGuid().ToString("D"); + var line = Move(item, location, null, 1, InventoryTransactionType.Consume); line.ReferenceType = InventoryReferenceType.RmsRecord; line.ReferenceId = sourceId; + line.UsageId = usageId; line.UsageType = InventoryUsageType.ConsumedOnPatient; + Begin(); var original = await _service.PostWithinTransactionAsync(_actor, Command(line)); _uow.Object.CommitChanges(); + var reverse = Move(item, null, location, 1, InventoryTransactionType.Adjust); reverse.ReferenceType = InventoryReferenceType.RmsRecord; reverse.ReferenceId = sourceId; + reverse.ReversesTransactionId = original.TransactionIds.Single(); reverse.UsageId = correctionId; reverse.UsageType = line.UsageType; + Begin(); await _service.PostWithinTransactionAsync(_actor, Command(reverse)); _uow.Object.CommitChanges(); + _events.Should().HaveCount(2); var safe = InventoryWorkflowPayload.Parse(InventoryWorkflowPayload.Routing(JObject.FromObject(_events.Last().Payload))); + safe.Value("UsageId").Should().Be(correctionId); safe.Value("UsageType").Should().Be((int)InventoryUsageType.ConsumedOnPatient); + safe.Value("ReversesTransactionId").Should().Be(original.TransactionIds.Single()); safe.Value("ReferenceId").Should().Be(sourceId); + InventoryWorkflowPayload.Variables.Should().Contain(("usage_id", "UsageId")).And.Contain(("usage_type", "UsageType")); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("synthetic-manager-grant"); safe.Value("Note").Should().Be(ProtectedDataEnvelope.RedactionValue); + Stock(item, location).Should().Be(5); _dispatches.Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM4CostRegressionsTests.cs b/Tests/Resgrid.Tests/Services/InventoryM4CostRegressionsTests.cs new file mode 100644 index 000000000..dd258f94b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM4CostRegressionsTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model.Inventories; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + [TestCase(InventoryAssetStatus.Retired)] + [TestCase(InventoryAssetStatus.Lost)] + public async Task Valuation_excludes_stock_and_assets_in_terminal_containers_without_failing_the_report(InventoryAssetStatus status) + { + var location = Location(); var bagItem = M4Item(InventoryTrackingMode.Serialized); + var bag = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = bagItem.Id, CurrentLocationId = location.Id, + Status = (int)status, Content = JsonConvert.SerializeObject(new InventoryAssetContent { AcquisitionCost = 100 }) }); + var container = _store.Seed(new InventoryLocation { DepartmentId = Department, ContainerAssetId = bag.Id, LocationType = (int)InventoryLocationType.Container, + Content = "{}" }); + var bulk = M4Item(); SeedStock(bulk, container, 5); SeedStock(bulk, location, 2); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = bagItem.Id, CurrentLocationId = container.Id, Status = 0, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { AcquisitionCost = 50 }) }); + var valuation = await _service.GetValuationAsync(_actor); + valuation.Lines.Should().ContainSingle().Which.LocationId.Should().Be(location.Id); + valuation.Totals.Should().ContainSingle().Which.KnownValue.Should().Be(4); + } + + [TestCase(InventoryTrackingMode.Bulk)] + [TestCase(InventoryTrackingMode.Serialized)] + public async Task Generic_receipt_cannot_override_a_fixed_lot_price(InventoryTrackingMode tracking) + { + var item = M4Item(tracking); var location = Location(); + var lot = _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryLotContent { LotNumber = "Synthetic fixed-price lot", UnitCost = 10 }) }); + var command = M4Receive(item, location, tracking == InventoryTrackingMode.Bulk ? 10 : 1, 20); + command.Lines[0].LotId = lot.Id; + InventoryAsset asset = null; + if (tracking == InventoryTrackingMode.Serialized) + { + asset = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, LotId = lot.Id, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "Synthetic unreceived lot asset" }) }); + command.Lines[0].AssetId = asset.Id; + } + var beforeItem = JsonConvert.SerializeObject(_store.All().Single()); + var beforeAssets = JsonConvert.SerializeObject(_store.All()); + + await Fails(() => _service.PostTransactionAsync(_actor, command), "LotCostMismatch", 409); + + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); _audits.Should().BeEmpty(); + JsonConvert.SerializeObject(_store.All().Single()).Should().Be(beforeItem); + JsonConvert.SerializeObject(_store.All()).Should().Be(beforeAssets); + } + + [Test] + public async Task Generic_serialized_receipt_cannot_replace_existing_acquisition_cost() + { + var item = M4Item(InventoryTrackingMode.Serialized); var location = Location(); + var asset = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "Synthetic priced asset", AcquisitionCost = 10 }) }); + var command = M4Receive(item, location, 1, 20); command.Lines[0].AssetId = asset.Id; + var before = JsonConvert.SerializeObject(asset); + + await Fails(() => _service.PostTransactionAsync(_actor, command), "AssetCostMismatch", 409); + + JsonConvert.SerializeObject(_store.All().Single()).Should().Be(before); + M4Average(item).Should().BeNull(); _store.All().Should().BeEmpty(); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); _audits.Should().BeEmpty(); + } + + [Test] + public async Task Asset_creation_rolls_back_when_acquisition_cost_conflicts_with_its_lot() + { + var item = M4Item(InventoryTrackingMode.Serialized); var location = Location(); + var lot = _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryLotContent { LotNumber = "Synthetic acquisition lot", UnitCost = 10 }) }); + var input = new InventoryAssetInput { RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, LotId = lot.Id, LocationId = location.Id, + Details = new InventoryAssetContent { SerialNumber = "Synthetic conflicting acquisition", AcquisitionCost = 20 } }; + + await Fails(() => _service.CreateAssetAsync(_actor, input), "LotCostMismatch", 409); + + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Should().BeEmpty(); M4Average(item).Should().BeNull(); + _events.Should().BeEmpty(); _audits.Should().BeEmpty(); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Uncosted_serialized_receipt_freezes_resolved_price_before_later_average_changes(bool explicitPrice) + { + var item = M4Item(InventoryTrackingMode.Serialized, defaultCost: 10); var location = Location(); + var asset = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "Synthetic initially uncosted asset" }) }); + var command = M4Receive(item, location, 1, 10); command.Lines[0].AssetId = asset.Id; + if (!explicitPrice) command.Lines[0].UnitCost = null; + var receipt = await _service.PostTransactionAsync(_actor, command); + JsonConvert.DeserializeObject(_store.All().Single().Content).AcquisitionCost.Should().Be(10); + + await _service.CreateAssetAsync(_actor, new InventoryAssetInput { RequestId = Guid.NewGuid().ToString("D"), ItemId = item.Id, LocationId = location.Id, + Details = new InventoryAssetContent { SerialNumber = "Synthetic later expensive asset", AcquisitionCost = 30 } }); + M4Average(item).Should().Be(20); + var valuation = await _service.GetValuationAsync(_actor); + valuation.Lines.Single(l => l.AssetId == asset.Id).Value.Should().Be(10); + valuation.Totals.Should().ContainSingle().Which.KnownValue.Should().Be(40); + (await _service.PostTransactionAsync(_actor, Copy(command))).TransactionIds.Should().Equal(receipt.TransactionIds); + _store.All().Should().HaveCount(2); _store.All().Should().HaveCount(2); + + var consume = Move(item, location, null, 1, InventoryTransactionType.Consume); consume.AssetId = asset.Id; + M4Cost(await _service.PostTransactionAsync(_actor, Command(consume))).Value("UnitCost").Should().Be(10); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Fixed_lot_receipts_and_corrections_leave_the_fallback_average_and_valuation_consistent(bool lotFirst) + { + var item = M4Item(defaultCost: null); var location = Location(); + var lot = _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryLotContent { LotNumber = "Synthetic independent cost pool", UnitCost = 10 }) }); + var lotCommand = M4Receive(item, location, 10, 10); lotCommand.Lines[0].LotId = lot.Id; + var bulkCommand = M4Receive(item, location, 10, 20); + InventoryResult lotReceipt; + if (lotFirst) + { + lotReceipt = await _service.PostTransactionAsync(_actor, lotCommand); M4Average(item).Should().BeNull(); + await _service.PostTransactionAsync(_actor, bulkCommand); + } + else + { + await _service.PostTransactionAsync(_actor, bulkCommand); + lotReceipt = await _service.PostTransactionAsync(_actor, lotCommand); + } + M4Average(item).Should().Be(20); + var valuation = await _service.GetValuationAsync(_actor); + valuation.Lines.Single(l => l.LotId == lot.Id).Value.Should().Be(100); + valuation.Lines.Single(l => l.LotId == null).Value.Should().Be(200); + valuation.Totals.Should().ContainSingle().Which.KnownValue.Should().Be(300); + + var consume = Move(item, location, null, 1, InventoryTransactionType.Consume); consume.LotId = lot.Id; + var used = await _service.PostTransactionAsync(_actor, Command(consume)); + var restore = Move(item, null, location, 1, InventoryTransactionType.Adjust); restore.LotId = lot.Id; restore.ReversesTransactionId = used.TransactionIds.Single(); + await _service.PostTransactionAsync(_actor, Command(restore)); M4Average(item).Should().Be(20); + (await _service.GetValuationAsync(_actor)).Totals.Should().ContainSingle().Which.KnownValue.Should().Be(300); + + var remove = Move(item, location, null, 10, InventoryTransactionType.Adjust); remove.LotId = lot.Id; remove.ReversesTransactionId = lotReceipt.TransactionIds.Single(); + await _service.PostTransactionAsync(_actor, Command(remove)); M4Average(item).Should().Be(20); + (await _service.GetValuationAsync(_actor)).Totals.Should().ContainSingle().Which.KnownValue.Should().Be(200); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Ordered_fractional_bulk_purchase_locks_tracking_and_currency_before_any_receipt(bool changeCurrency) + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 1.5m, 3m)); + _store.All().Should().BeEmpty(); + var current = _store.All().Single(); var before = JsonConvert.SerializeObject(current); + var input = new InventoryItemInput { Id = current.Id, Revision = current.Revision, TrackingMode = InventoryTrackingMode.Bulk, + Details = JsonConvert.DeserializeObject(current.Content) }; + if (changeCurrency) input.Details.CurrencyCode = "EUR"; else input.TrackingMode = InventoryTrackingMode.Serialized; + await Fails(() => _service.SaveItemAsync(_actor, input), changeCurrency ? "ItemCurrencyLocked" : "ItemTrackingLocked", 409); + JsonConvert.SerializeObject(_store.All().Single()).Should().Be(before); + _store.All().Single().QuantityReceived.Should().Be(0); + + await _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(order, location, 1.5m)); + Stock(item, location).Should().Be(1.5m); + _store.All().Single().Status.Should().Be((int)InventoryPurchaseOrderStatus.Received); + _store.All().Single().QuantityReceived.Should().Be(1.5m); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM4HttpTests.cs b/Tests/Resgrid.Tests/Services/InventoryM4HttpTests.cs new file mode 100644 index 000000000..a4bb5d3a1 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM4HttpTests.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using File = System.IO.File; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.Inventory; +using Resgrid.Web.Helpers; +using DataProtectionController = Resgrid.Web.Services.Controllers.v4.DataProtectionController; +using MvcInventoryController = Resgrid.Web.Areas.User.Controllers.InventoryController; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryApiTests + { + private const string PurchaseOrderId = "44444444-4444-4444-4444-444444444444"; + private const string PurchaseLineId = "55555555-5555-5555-5555-555555555555"; + private const string VendorId = "66666666-6666-6666-6666-666666666666"; + private const string MvcRoute = "/User/Inventory/"; + + [Test] + public async Task Purchasing_API_sources_reads_and_typed_vendor_and_draft_writes_from_current_actor_and_header_grant() + { + InventoryActor vendorActor = null; InventoryVendorInput vendorInput = null; InventoryPurchaseOrderInput orderInput = null; + _purchasing.Setup(x => x.GetVendorContactsAsync(It.IsAny())).ReturnsAsync(new List()); + _catalog.Setup(x => x.ListAsync(It.IsAny(), 2)).ReturnsAsync(new InventoryPage { HasMore = true }); + _catalog.Setup(x => x.ListAsync(It.IsAny(), 3)).ReturnsAsync(new InventoryPage()); + _purchasing.Setup(x => x.GetValuationAsync(It.IsAny(), LocationId)).ReturnsAsync(new InventoryValuation()); + _purchasing.Setup(x => x.SaveVendorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryVendorInput input) => { vendorActor = Copy(actor); vendorInput = Copy(input); return new InventoryVendor { Id = VendorId }; }); + _purchasing.Setup(x => x.SavePurchaseOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryPurchaseOrderInput input) => { actor.GrantToken.Should().Be("header-grant"); actor.DepartmentId.Should().Be(77); actor.UserId.Should().Be("manager"); orderInput = Copy(input); return new InventoryPurchaseOrderDetail { Order = new InventoryPurchaseOrder { Id = input.Id } }; }); + await WithServer(async client => + { + (await client.GetAsync(Route + "GetVendorContacts")).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "header-grant"); + foreach (var route in new[] { "GetVendorContacts?departmentId=88&userId=forged&grantToken=query-grant", "GetVendors?page=2", "GetPurchaseOrders?page=3", "GetValuation?locationId=" + LocationId }) + { + var read = await client.GetAsync(Route + route); await Success(read); read.Headers.CacheControl.NoStore.Should().BeTrue(); + } + var contactId = new string('c', 128); + var response = await client.PostAsync(Route + "SaveVendor", Json(new { Id = VendorId, Revision = 4, ContactId = contactId, + DepartmentId = 88, UserId = "forged", GrantToken = "body-grant", Content = "raw-overpost", IsProtected = true, + Details = new { AccountNumber = "Synthetic account", Note = Canary } })); + await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + vendorActor.DepartmentId.Should().Be(77); vendorActor.UserId.Should().Be("manager"); vendorActor.GrantToken.Should().Be("header-grant"); + vendorInput.ContactId.Should().Be(contactId); vendorInput.Revision.Should().Be(4); vendorInput.Details.Note.Should().Be(Canary); + JsonConvert.SerializeObject(vendorInput).Should().NotContain("raw-overpost").And.NotContain("body-grant"); + response = await client.PostAsync(Route + "SavePurchaseOrder", Json(new { Id = PurchaseOrderId, Revision = 0, VendorId, CurrencyCode = "EUR", Number = "Synthetic PO", Note = Canary, + DepartmentId = 88, UserId = "forged", GrantToken = "body-grant", Status = 3, Content = "raw-overpost", QuantityReceived = 999, + Lines = new[] { new { Id = PurchaseLineId, ItemId, QuantityOrdered = 5.125001m, UnitCost = 2.125001m, Note = Canary, QuantityReceived = 999, DepartmentId = 88 } } })); + await Success(response); orderInput.Id.Should().Be(PurchaseOrderId); orderInput.Revision.Should().Be(0); orderInput.CurrencyCode.Should().Be("EUR"); + orderInput.Lines.Single().QuantityOrdered.Should().Be(5.125001m); orderInput.Lines.Single().UnitCost.Should().Be(2.125001m); + JsonConvert.SerializeObject(orderInput).Should().NotContain("QuantityReceived").And.NotContain("raw-overpost").And.NotContain("body-grant"); + _purchasing.Verify(x => x.GetVendorContactsAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == "header-grant")), Times.Once); + _catalog.Verify(x => x.ListAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), 2), Times.Once); + _purchasing.Verify(x => x.GetValuationAsync(It.Is(a => a.GrantToken == "header-grant"), LocationId), Times.Once); + }); + } + + [Test] + public async Task Purchasing_API_binds_explicit_status_commands_and_partial_serialized_receipt_without_stock_fallback() + { + var changes = new List<(InventoryPurchaseOrderChange Input, InventoryPurchaseOrderStatus Status)>(); InventoryPurchaseReceiptInput receipt = null; + _purchasing.Setup(x => x.ChangePurchaseOrderStatusAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryPurchaseOrderChange input, InventoryPurchaseOrderStatus status) => { changes.Add((Copy(input), status)); return new InventoryResult { PurchaseOrderId = input.Id }; }); + _purchasing.Setup(x => x.ReceivePurchaseOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, InventoryPurchaseReceiptInput input) => { actor.DepartmentId.Should().Be(77); actor.UserId.Should().Be("manager"); actor.GrantToken.Should().Be("receipt-grant"); receipt = Copy(input); return new InventoryResult { PurchaseOrderId = input.PurchaseOrderId, AwaitingWitness = true }; }); + await WithServer(async client => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "receipt-grant"); + foreach (var route in new[] { "OrderPurchaseOrder", "CancelPurchaseOrder" }) + { + var request = Guid.NewGuid().ToString("D"); + var changed = await client.PostAsync(Route + route, Json(new { Id = PurchaseOrderId, Revision = 7, RequestId = request, Status = 3 })); + await Success(changed); changes.Last().Input.RequestId.Should().Be(request); changes.Last().Input.Revision.Should().Be(7); + } + changes.Select(x => x.Status).Should().Equal(InventoryPurchaseOrderStatus.Ordered, InventoryPurchaseOrderStatus.Cancelled); + var requestId = Guid.NewGuid().ToString("D"); + var response = await client.PostAsync(Route + "ReceivePurchaseOrder", Json(new { PurchaseOrderId, Revision = 9, RequestId = requestId, + DepartmentId = 88, UserId = "body-user", GrantToken = "body-grant", Lines = new object[] { + new { PurchaseOrderItemId = PurchaseLineId, LocationId, LotId, Quantity = 1.125001m, UnitCost = 999, QuantityReceived = 999 }, + new { PurchaseOrderItemId = ItemId, LocationId, Quantity = 1m, Asset = new { SerialNumber = Canary, AssetTag = "tag", Barcode = "barcode", ExpiresOn = "2027-09-09T00:00:00Z" } } } })); + await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + JObject.Parse(await response.Content.ReadAsStringAsync())["Data"].Value("AwaitingWitness").Should().BeTrue(); + receipt.RequestId.Should().Be(requestId); receipt.Revision.Should().Be(9); receipt.Lines.Should().HaveCount(2); + receipt.Lines[0].Quantity.Should().Be(1.125001m); receipt.Lines[0].LotId.Should().Be(LotId); receipt.Lines[0].Asset.Should().BeNull(); + receipt.Lines[1].Asset.SerialNumber.Should().Be(Canary); receipt.Lines[1].Asset.ExpiresOn.Should().Be(new DateTime(2027, 9, 9, 0, 0, 0, DateTimeKind.Utc)); + JsonConvert.SerializeObject(receipt).Should().NotContain("UnitCost").And.NotContain("QuantityReceived").And.NotContain("body-grant"); + _stock.Invocations.Should().BeEmpty(); + }); + } + + [TestCase("OrderPurchaseOrder")] + [TestCase("CancelPurchaseOrder")] + [TestCase("ReceivePurchaseOrder")] + [TestCase("SavePurchaseOrder")] + public async Task Purchasing_API_never_synthesizes_missing_or_invalid_command_identity(string action) + { + await WithServer(async client => + { + SignIn(client); + foreach (var invalid in new[] { null, "", Guid.Empty.ToString("D"), "not-a-guid" }) + { + var input = JObject.FromObject(new { Id = PurchaseOrderId, PurchaseOrderId, Revision = 0, VendorId, CurrencyCode = "USD", Number = "Synthetic PO", + Lines = new[] { new { PurchaseOrderItemId = PurchaseLineId, ItemId, LocationId, Quantity = 1, QuantityOrdered = 1, UnitCost = 2 } } }); + input[action == "SavePurchaseOrder" ? "Id" : "RequestId"] = invalid; + var response = await client.PostAsync(Route + action, Json(input)); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, await response.Content.ReadAsStringAsync()); + response.Headers.CacheControl.NoStore.Should().BeTrue(); + } + _purchasing.Invocations.Should().BeEmpty(); _stock.Invocations.Should().BeEmpty(); + }); + } + + [TestCase(403, "ProtectedDataRequired")] + [TestCase(404, "Unavailable")] + [TestCase(409, "PurchaseOrderStateConflict")] + public async Task Purchasing_API_domain_errors_are_safe_and_not_cacheable(int status, string code) + { + _purchasing.Setup(x => x.GetPurchaseOrderAsync(It.IsAny(), PurchaseOrderId)).ThrowsAsync(new InventoryException(status, code)); + await WithServer(async client => + { + SignIn(client); var response = await client.GetAsync(Route + "GetPurchaseOrder?id=" + PurchaseOrderId); + ((int)response.StatusCode).Should().Be(status); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var text = await response.Content.ReadAsStringAsync(); text.Should().Contain(code).And.NotContain(Canary); + if (status == 403) { var problem = JObject.Parse(text); (problem["IsRedacted"] ?? problem["isRedacted"]).Value().Should().BeTrue(); } + }); + } + + [Test] + public async Task Purchasing_API_sanitizes_exception_and_binding_details_before_any_mutation() + { + _purchasing.Setup(x => x.GetPurchaseOrderAsync(It.IsAny(), PurchaseOrderId)).ThrowsAsync(new InvalidOperationException(Canary)); + await WithServer(async client => + { + SignIn(client); var response = await client.GetAsync(Route + "GetPurchaseOrder?id=" + PurchaseOrderId); + response.StatusCode.Should().Be(HttpStatusCode.Conflict); response.Headers.CacheControl.NoStore.Should().BeTrue(); + (await response.Content.ReadAsStringAsync()).Should().Contain("OperationUnavailable").And.NotContain(Canary).And.NotContain("InvalidOperationException"); + response = await client.PostAsync(Route + "SavePurchaseOrder", Json(new { Id = PurchaseOrderId, Lines = new[] { new { ItemId, QuantityOrdered = Canary, UnitCost = Canary } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + response = await client.PostAsync(Route + "ReceivePurchaseOrder", Json(new { PurchaseOrderId, RequestId = Guid.NewGuid().ToString("D"), Revision = 1, + Lines = new[] { new { PurchaseOrderItemId = PurchaseLineId, LocationId, Quantity = 1, Asset = new { SerialNumber = "synthetic", ExpiresOn = Canary } } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + _purchasing.Verify(x => x.SavePurchaseOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + _purchasing.Verify(x => x.ReceivePurchaseOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + }); + } + + [Test] + public async Task Purchasing_API_vendor_archive_requires_the_same_delete_policy_as_other_inventory_archives() + { + _catalog.Setup(x => x.ArchiveAsync(It.IsAny(), VendorId, 4)).Returns(Task.CompletedTask); + await WithServer(async client => + { + SignIn(client); var response = await client.PostAsync(Route + "ArchiveVendor", Json(new { Id = VendorId, Revision = 4 })); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + _catalog.Verify(x => x.ArchiveAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + client.DefaultRequestHeaders.Add("Test-Inventory-Delete", "true"); + await Success(await client.PostAsync(Route + "ArchiveVendor", Json(new { Id = VendorId, Revision = 4, DepartmentId = 88 }))); + _catalog.Verify(x => x.ArchiveAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), VendorId, 4), Times.Once); + }); + } + + [Test] + public async Task Purchasing_MVC_posts_require_CSRF_and_bind_attended_actor_grant_and_exact_decimal_values() + { + ConfigurePurchasingMvc(); InventoryActor actor = null; InventoryPurchaseReceiptInput receipt = null; + _purchasing.Setup(x => x.ReceivePurchaseOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor a, InventoryPurchaseReceiptInput input) => { actor = Copy(a); receipt = Copy(input); return new InventoryResult { AwaitingWitness = true, PurchaseOrderId = input.PurchaseOrderId }; }); + await WithPurchasingMvc(async (client, views) => + { + (await client.GetAsync(MvcRoute + "Purchasing")).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SignIn(client); client.DefaultRequestHeaders.Add("Test-Inventory-Delete", "true"); + var page = await client.GetAsync(MvcRoute + "Purchasing"); await Success(page); var token = PurchasingCsrf(await page.Content.ReadAsStringAsync()); + var fields = new Dictionary { ["PurchaseOrderId"] = PurchaseOrderId, ["Revision"] = "7", ["RequestId"] = Guid.NewGuid().ToString("D"), + ["Lines[0].PurchaseOrderItemId"] = PurchaseLineId, ["Lines[0].LocationId"] = LocationId, ["Lines[0].LotId"] = LotId, ["Lines[0].Quantity"] = "1.125001", + ["DepartmentId"] = "88", ["UserId"] = "body-user", ["GrantToken"] = "forged-grant", [HttpProtectedGrantContext.FormFieldName] = "form-grant" }; + foreach (var action in new[] { "SaveVendor", "ArchiveVendor", "SavePurchaseOrder", "OrderPurchaseOrder", "CancelPurchaseOrder", "ReceivePurchaseOrder", "ReopenPurchasing" }) + (await client.PostAsync(MvcRoute + action, new FormUrlEncodedContent(fields))).StatusCode.Should().Be(HttpStatusCode.BadRequest, action); + _purchasing.Verify(x => x.ReceivePurchaseOrderAsync(It.IsAny(), It.IsAny()), Times.Never); + fields["__RequestVerificationToken"] = token; + var response = await client.PostAsync(MvcRoute + "ReceivePurchaseOrder", new FormUrlEncodedContent(fields)); await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + actor.DepartmentId.Should().Be(77); actor.UserId.Should().Be("manager"); actor.GrantToken.Should().Be("form-grant"); + receipt.RequestId.Should().Be(fields["RequestId"]); receipt.Revision.Should().Be(7); receipt.Lines.Single().Quantity.Should().Be(1.125001m); receipt.Lines.Single().LotId.Should().Be(LotId); + (await response.Content.ReadAsStringAsync()).Should().NotContain("form-grant").And.NotContain("forged-grant"); + fields.Remove("RequestId"); + response = await client.PostAsync(MvcRoute + "ReceivePurchaseOrder", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + _purchasing.Verify(x => x.ReceivePurchaseOrderAsync(It.IsAny(), It.IsAny()), Times.Once); + }, protectedData: true); + } + + [Test] + public async Task Purchasing_MVC_locked_GET_and_protected_POST_navigation_keep_the_same_selection_without_persisting_the_grant() + { + ConfigurePurchasingMvc(); + _purchasing.Setup(x => x.GetVendorContactsAsync(It.IsAny())).ReturnsAsync((InventoryActor actor) => + { + if (actor.GrantToken != "navigation-grant") throw new InventoryException(403, "ProtectedDataRequired"); + return new List(); + }); + _purchasing.Setup(x => x.GetPurchaseOrderAsync(It.IsAny(), PurchaseOrderId)).ReturnsAsync(new InventoryPurchaseOrderDetail { + Order = new InventoryPurchaseOrder { Id = PurchaseOrderId, DepartmentId = 77, VendorId = VendorId, CurrencyCode = "USD", Content = JsonConvert.SerializeObject(new InventoryPurchaseOrderContent { Number = Canary }) } }); + await WithPurchasingMvc(async (client, views) => + { + SignIn(client); var address = MvcRoute + "Purchasing?tab=PurchaseOrders&page=3&id=" + PurchaseOrderId + "&locationId=" + LocationId + "&grantToken=navigation-grant"; + var response = await client.GetAsync(address); await Success(response); var html = await response.Content.ReadAsStringAsync(); + html.Should().NotContain(Canary).And.Contain("ReopenPurchasing"); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var locked = (InventoryWorkspaceView)views.LastView.Model; locked.Locked.Should().BeTrue(); locked.Id.Should().Be(PurchaseOrderId); locked.Page.Should().Be(3); locked.LocationId.Should().Be(LocationId); + var fields = new Dictionary { ["__RequestVerificationToken"] = PurchasingCsrf(html), ["tab"] = "PurchaseOrders", ["page"] = "3", ["id"] = PurchaseOrderId, + ["locationId"] = LocationId, [HttpProtectedGrantContext.FormFieldName] = "navigation-grant", [HttpProtectedGrantContext.ExpiresOnFormFieldName] = "2030-01-01T00:00:00Z" }; + response = await client.PostAsync(MvcRoute + "ReopenPurchasing", new FormUrlEncodedContent(fields)); await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + (await response.Content.ReadAsStringAsync()).Should().Contain(Canary); + var revealed = (InventoryWorkspaceView)views.LastView.Model; revealed.Locked.Should().BeFalse(); revealed.Id.Should().Be(PurchaseOrderId); revealed.Page.Should().Be(3); revealed.LocationId.Should().Be(LocationId); + views.LastView.ViewData["ProtectedGrant"].Should().Be("navigation-grant"); views.LastView.ViewData["GrantExpiresOn"].Should().Be(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + _purchasing.Verify(x => x.GetPurchaseOrderAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == "navigation-grant"), PurchaseOrderId), Times.Once); + response = await client.GetAsync(address); await Success(response); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + ((InventoryWorkspaceView)views.LastView.Model).Locked.Should().BeTrue(); + }, protectedData: true); + } + + [Test] + public async Task Purchasing_MVC_conflicts_and_binding_errors_use_safe_noncacheable_responses() + { + ConfigurePurchasingMvc(); + _purchasing.Setup(x => x.ChangePurchaseOrderStatusAsync(It.IsAny(), It.IsAny(), InventoryPurchaseOrderStatus.Ordered)) + .ThrowsAsync(new InventoryException(409, "PurchaseOrderStateConflict")); + await WithPurchasingMvc(async (client, views) => + { + SignIn(client); var page = await client.GetAsync(MvcRoute + "Purchasing"); await Success(page); + var fields = new Dictionary { ["__RequestVerificationToken"] = PurchasingCsrf(await page.Content.ReadAsStringAsync()), ["Id"] = PurchaseOrderId, + ["Revision"] = "3", ["RequestId"] = Guid.NewGuid().ToString("D"), ["Note"] = Canary }; + var response = await client.PostAsync(MvcRoute + "OrderPurchaseOrder", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response.Headers.CacheControl.NoStore.Should().BeTrue(); (await response.Content.ReadAsStringAsync()).Should().Contain("PurchaseOrderStateConflict").And.NotContain(Canary); + fields["Revision"] = Canary; + response = await client.PostAsync(MvcRoute + "OrderPurchaseOrder", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.Headers.CacheControl.NoStore.Should().BeTrue(); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + _purchasing.Verify(x => x.ChangePurchaseOrderStatusAsync(It.IsAny(), It.IsAny(), InventoryPurchaseOrderStatus.Ordered), Times.Once); + }); + } + + private void ConfigurePurchasingMvc() + { + _catalog.Setup(x => x.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _catalog.Setup(x => x.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _catalog.Setup(x => x.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _catalog.Setup(x => x.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new InventoryPage()); + _purchasing.Setup(x => x.GetVendorContactsAsync(It.IsAny())).ReturnsAsync(new List()); + } + private static string PurchasingCsrf(string html) + { + var token = WebUtility.HtmlDecode(Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value); + token.Should().NotBeNullOrEmpty("the real Purchasing view must issue an antiforgery token"); return token; + } + private sealed class PurchasingBodyFilter : IResultFilter + { + public ViewResult LastView { get; private set; } + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is not ViewResult view) return; + LastView = view; + context.Result = new PartialViewResult { ViewName = "/Areas/User/Views/Inventory/" + view.ViewName + ".cshtml", ViewData = view.ViewData, TempData = view.TempData, StatusCode = view.StatusCode }; + } + public void OnResultExecuted(ResultExecutedContext context) { } + } + private async Task WithPurchasingMvc(Func test, bool protectedData = false) + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + if (directory == null) throw new InvalidOperationException("The test workspace root is unavailable."); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(directory.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + const string scheme = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + builder.Services.AddAuthentication(scheme).AddScheme(scheme, _ => { }); + builder.Services.AddAuthorization(options => options.AddPolicy(Resgrid.Providers.Claims.ResgridResources.Inventory_Delete, policy => policy.RequireClaim("inventory-delete", "true"))); + var views = new PurchasingBodyFilter(); + builder.Services.AddControllersWithViews(options => options.Filters.Add(views)).AddApplicationPart(typeof(MvcInventoryController).Assembly) + .AddNewtonsoftJson(options => options.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); builder.Services.AddSingleton(_purchasing.Object); + builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(); + var protection = new Mock(); protection.Setup(x => x.IsProtectionEnforcedAsync(77)).ReturnsAsync(protectedData); builder.Services.AddSingleton(protection.Object); + await using var app = builder.Build(); var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("fr").AddSupportedCultures("fr").AddSupportedUICultures("fr")); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); using var handler = new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = new CookieContainer() }; + using var client = new HttpClient(handler) { BaseAddress = new Uri(app.Urls.Single()) }; await test(client, views); + } + finally { await app.StopAsync(); ClaimsAuthorizationHelper._httpContextAccessor = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM4Tests.cs b/Tests/Resgrid.Tests/Services/InventoryM4Tests.cs new file mode 100644 index 000000000..9263fdc98 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM4Tests.cs @@ -0,0 +1,427 @@ +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.Inventories; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + [Test] + public async Task Receipts_weight_the_average_before_moving_stock_and_replay_preserves_cost_and_ledger() + { + var item = M4Item(); var location = Location(); + var first = await _service.PostTransactionAsync(_actor, M4Receive(item, location, 10, 2.125001m)); + var command = M4Receive(item, location, 30, 4.125001m); + var second = await _service.PostTransactionAsync(_actor, command); + M4Average(item).Should().Be(3.625001m); Stock(item, location).Should().Be(40); + M4Cost(first).Value("UnitCost").Should().Be(2.125001m); + M4Cost(first).Value("TotalCost").Should().Be(21.250010m); + M4Cost(second).Value("TotalCost").Should().Be(123.750030m); + M4Cost(second).Value("CurrencyCode").Should().Be("USD"); + var version = _store.All().Single().Revision; + (await _service.PostTransactionAsync(_actor, Copy(command))).TransactionIds.Should().Equal(second.TransactionIds); + M4Average(item).Should().Be(3.625001m); _store.All().Single().Revision.Should().Be(version); + _store.All().Should().HaveCount(2); _events.Should().HaveCount(2); + } + + [TestCase(true, true, true, "7.5")] + [TestCase(true, false, true, "6.5")] + [TestCase(false, true, true, "7.5")] + [TestCase(false, false, true, "5.5")] + [TestCase(false, false, false, "4.5")] + public async Task Outbound_cost_precedence_is_lot_then_asset_then_average_then_default(bool serialized, bool withLot, bool withAverage, string expected) + { + var item = M4Item(serialized ? InventoryTrackingMode.Serialized : InventoryTrackingMode.Bulk, defaultCost: 4.5m, average: withAverage ? 5.5m : null); + var location = Location(); + var lot = withLot ? _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = item.Id, + Content = JsonConvert.SerializeObject(new InventoryLotContent { LotNumber = "Synthetic lot", UnitCost = 7.5m }) }) : null; + var movement = Move(item, location, null, 1, InventoryTransactionType.Consume); movement.LotId = lot?.Id; + if (serialized) movement.AssetId = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, LotId = lot?.Id, + CurrentLocationId = location.Id, Status = 0, Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "costed-asset", AcquisitionCost = 6.5m }) }).Id; + else _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, LotId = lot?.Id, Quantity = 2 }); + var result = await _service.PostTransactionAsync(_actor, Command(movement)); + M4Cost(result).Value("UnitCost").Should().Be(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture)); + } + + [Test] + public async Task Same_item_receipt_lines_update_the_average_sequentially_with_six_decimal_rounding() + { + var item = M4Item(defaultCost: null); var location = Location(); + var first = Move(item, null, location, 1, InventoryTransactionType.Receive); first.UnitCost = 1; + var second = Move(item, null, location, 2, InventoryTransactionType.Receive); second.UnitCost = 2; + await _service.PostTransactionAsync(_actor, Command(first, second)); + M4Average(item).Should().Be(1.666667m); Stock(item, location).Should().Be(3); + } + + [Test] + public async Task Unknown_opening_value_stays_unknown_when_receiving_priced_stock() + { + var item = M4Item(defaultCost: null); var location = Location(); SeedStock(item, location, 2); + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 3, 5)); + M4Average(item).Should().BeNull(); + var valuation = await _service.GetValuationAsync(_actor); + valuation.Lines.Should().ContainSingle().Which.Value.Should().BeNull(); + valuation.Totals.Should().ContainSingle().Which.UncostedRows.Should().Be(1); + } + + [Test] + public async Task Consumption_correction_freezes_original_cost_after_later_receipts_and_rejects_repricing() + { + var item = M4Item(); var location = Location(); + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 10, 2)); + var used = await _service.PostTransactionAsync(_actor, Command(Move(item, location, null, 3, InventoryTransactionType.Consume))); + var original = _store.All().Single(x => x.Id == used.TransactionIds.Single()); + var originalContent = original.Content; + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 5, 8)); M4Average(item).Should().Be(4.5m); + var reversal = Move(item, null, location, 3, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = original.Id; reversal.UnitCost = 9; + await Fails(() => _service.PostTransactionAsync(_actor, Command(reversal)), "ReversalCostMismatch", 409); + reversal.UnitCost = null; var corrected = await _service.PostTransactionAsync(_actor, Command(reversal)); + M4Cost(corrected).Value("UnitCost").Should().Be(2); M4Cost(corrected).Value("TotalCost").Should().Be(6); + M4Average(item).Should().Be(4m); _store.All().Single(x => x.Id == original.Id).Content.Should().Be(originalContent); + (await _service.GetValuationAsync(_actor)).Totals.Single().KnownValue.Should().Be(60m); + } + + [Test] + public async Task Receipt_reversal_removes_its_original_value_from_the_weighted_average() + { + var item = M4Item(); var location = Location(); + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 10, 2)); + var receipt = await _service.PostTransactionAsync(_actor, M4Receive(item, location, 10, 4)); M4Average(item).Should().Be(3); + var reversal = Move(item, location, null, 10, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = receipt.TransactionIds.Single(); + var result = await _service.PostTransactionAsync(_actor, Command(reversal)); + M4Average(item).Should().Be(2); Stock(item, location).Should().Be(10); M4Cost(result).Value("UnitCost").Should().Be(4); + } + + [Test] + public async Task Issuance_return_uses_the_frozen_issue_cost_without_repricing_the_current_average() + { + var item = M4Item(); var location = Location(); var returnedTo = Location(); + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 10, 2)); + var issued = await _service.IssueAsync(_actor, Issue(item, location, 3, userId: "member")); + await _service.PostTransactionAsync(_actor, M4Receive(item, location, 4, 9)); + var average = M4Average(item); average.Should().Be(4); + var issuance = _store.All().Single(x => x.Id == issued.IssuanceId); + var returned = await _service.ReturnAsync(_actor, new InventoryReturnInput { RequestId = Guid.NewGuid().ToString("D"), IssuanceId = issuance.Id, + Revision = issuance.Revision, ToLocationId = returnedTo.Id, Quantity = 3, Condition = InventoryAssetStatus.InService }); + M4Cost(returned).Value("UnitCost").Should().Be(2); M4Cost(returned).Value("TotalCost").Should().Be(6); M4Average(item).Should().Be(average); + } + + [TestCase(InventoryTransactionType.Consume)] + [TestCase(InventoryTransactionType.Adjust)] + [TestCase(InventoryTransactionType.WriteOff)] + public async Task Ordinary_outbound_movements_cannot_override_the_server_cost(InventoryTransactionType type) + { + var item = M4Item(); var location = Location(); SeedStock(item, location, 3); + var movement = Move(item, location, null, 1, type); movement.UnitCost = 99; + await Fails(() => _service.PostTransactionAsync(_actor, Command(movement)), "CostOverrideUnsupported", 400); + Stock(item, location).Should().Be(3); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Item_updates_preserve_server_average_and_lock_currency_after_ledger_history() + { + var item = M4Item(); var location = Location(); await _service.PostTransactionAsync(_actor, M4Receive(item, location, 4, 3)); + var current = _store.All().Single(); var details = JsonConvert.DeserializeObject(current.Content); + details.AverageUnitCost = 999; details.DefaultUnitCost = 5; + var input = new InventoryItemInput { Id = item.Id, Revision = current.Revision, TrackingMode = InventoryTrackingMode.Bulk, Details = details }; + var saved = await _service.SaveItemAsync(_actor, input); + M4Average(item).Should().Be(3); JsonConvert.DeserializeObject(saved.Content).DefaultUnitCost.Should().Be(5); + input.Revision = saved.Revision; input.Details.CurrencyCode = "EUR"; + await Fails(() => _service.SaveItemAsync(_actor, input), "ItemCurrencyLocked", 409); + JsonConvert.DeserializeObject(_store.All().Single().Content).CurrencyCode.Should().Be("USD"); + var added = await _service.SaveItemAsync(_actor, new InventoryItemInput { Details = new InventoryItemContent { Name = "Synthetic new item", UnitOfMeasure = "each", CurrencyCode = " eur ", AverageUnitCost = 999 } }); + var addedDetails = JsonConvert.DeserializeObject(added.Content); addedDetails.AverageUnitCost.Should().BeNull(); addedDetails.CurrencyCode.Should().Be("EUR"); + } + + [Test] + public async Task Vendor_identity_uses_the_existing_company_contact_and_cannot_switch_or_duplicate_it() + { + var contact = M4CompanyContact(new string('c', 128)); + var vendor = await _service.SaveVendorAsync(_actor, new InventoryVendorInput { ContactId = contact.ContactId, Details = new() { AccountNumber = "Synthetic account", Note = Canary } }); + vendor.ContactId.Should().Be(contact.ContactId); + (await _service.GetVendorContactsAsync(_actor)).Should().ContainSingle(x => x.Id == contact.ContactId && x.Name == contact.CompanyName); + await Fails(() => _service.SaveVendorAsync(_actor, new InventoryVendorInput { ContactId = contact.ContactId }), "DuplicateVendor", 409); + var other = M4CompanyContact(); + await Fails(() => _service.SaveVendorAsync(_actor, new InventoryVendorInput { Id = vendor.Id, Revision = vendor.Revision, ContactId = other.ContactId }), "VendorContactImmutable", 409); + _store.All().Should().ContainSingle().Which.ContactId.Should().Be(contact.ContactId); + } + + [TestCase(88, false, 1)] + [TestCase(Department, true, 1)] + [TestCase(Department, false, 0)] + public async Task Vendor_rejects_a_foreign_deleted_or_noncompany_contact(int departmentId, bool deleted, int contactType) + { + var contact = M4CompanyContact(); contact.DepartmentId = departmentId; contact.IsDeleted = deleted; contact.ContactType = contactType; + await Fails(() => _service.SaveVendorAsync(_actor, new InventoryVendorInput { ContactId = contact.ContactId }), "VendorContactUnavailable", 404); + _store.All().Should().BeEmpty(); _audits.Should().BeEmpty(); + } + + [TestCase(PermissionTypes.AdjustInventory)] + [TestCase(PermissionTypes.ContactView)] + public async Task Purchasing_requires_inventory_and_contact_permissions_before_contact_resolution(PermissionTypes denied) + { + var contact = M4CompanyContact(); + _auth.Setup(x => x.RequireAsync(It.IsAny(), It.IsAny(), denied, It.IsAny())).ThrowsAsync(new InventoryException(403, "PermissionRequired")); + await Fails(() => _service.SaveVendorAsync(_actor, new InventoryVendorInput { ContactId = contact.ContactId }), "PermissionRequired", 403); + _contacts.Verify(x => x.GetContactByIdAsync(It.IsAny()), Times.Never); _store.All().Should().BeEmpty(); + } + + [Test] + public async Task Ordered_purchase_lines_and_supplier_snapshot_cannot_be_replaced_by_a_stale_draft() + { + var item = M4Item(); var draft = await M4Draft((item, 5m, 3m)); + var change = new InventoryPurchaseOrderChange { Id = draft.Detail.Order.Id, Revision = draft.Detail.Order.Revision, RequestId = Guid.NewGuid().ToString("D") }; + await _service.ChangePurchaseOrderStatusAsync(_actor, change, InventoryPurchaseOrderStatus.Ordered); + await _service.ChangePurchaseOrderStatusAsync(_actor, Copy(change), InventoryPurchaseOrderStatus.Ordered); + var ordered = await _service.GetPurchaseOrderAsync(_actor, change.Id); var originalLine = JsonConvert.SerializeObject(ordered.Lines.Single()); + draft.Input.Revision = ordered.Order.Revision; draft.Input.Lines[0].UnitCost = 99; + await Fails(() => _service.SavePurchaseOrderAsync(_actor, draft.Input), "RevisionConflict", 409); + _companyContacts.Values.Single().CompanyName = "Changed company name"; + var current = await _service.GetPurchaseOrderAsync(_actor, change.Id); + JsonConvert.SerializeObject(current.Lines.Single()).Should().Be(originalLine); + JsonConvert.DeserializeObject(current.Order.Content).SupplierName.Should().Be(Canary); + _store.All().Should().ContainSingle(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Purchase_order_currency_must_match_each_item_and_a_failed_draft_leaves_no_order_or_lines() + { + var usd = M4Item(); var eur = M4Item(currency: "EUR"); + await Fails(async () => { await M4Draft((usd, 2m, 3m), (eur, 1m, 4m)); }, "ItemCurrencyMismatch", 409); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Partial_purchase_receipts_finish_the_order_and_replay_original_stock_and_workflow_identities() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 5m, 3.125001m)); + var input = M4Receipt(order, location, 2); var first = await _service.ReceivePurchaseOrderAsync(_actor, input); + var partial = await _service.GetPurchaseOrderAsync(_actor, order.Order.Id); + partial.Order.Status.Should().Be((int)InventoryPurchaseOrderStatus.PartiallyReceived); partial.Lines.Single().QuantityReceived.Should().Be(2); + Stock(item, location).Should().Be(2); M4Cost(first).Value("UnitCost").Should().Be(3.125001m); + var receivedEvent = _events.Single(x => x.Trigger == WorkflowTriggerEventType.InventoryPurchaseOrderReceived); + receivedEvent.AggregateId.Should().Be(order.Order.Id); receivedEvent.AggregateType.Should().Be("InventoryPurchaseOrder"); receivedEvent.CorrelationId.Should().Be(first.OperationId); + JObject.FromObject(receivedEvent.Payload).Value("ReceiptId").Should().Be(first.OperationId); + JsonConvert.SerializeObject(receivedEvent.Payload).Should().NotContain(Canary).And.NotContain("UnitCost").And.NotContain("TotalCost"); + await _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(partial, location, 3)); + var complete = await _service.GetPurchaseOrderAsync(_actor, order.Order.Id); + complete.Order.Status.Should().Be((int)InventoryPurchaseOrderStatus.Received); complete.Lines.Single().QuantityReceived.Should().Be(5); + complete.Receipts.Should().HaveCount(2).And.OnlyContain(x => x.ReferenceType == (int)InventoryReferenceType.PurchaseOrder && x.ReferenceId == order.Order.Id); + var count = _events.Count; var revision = complete.Order.Revision; + var replay = await _service.ReceivePurchaseOrderAsync(_actor, Copy(input)); + replay.OperationId.Should().Be(first.OperationId); replay.TransactionIds.Should().Equal(first.TransactionIds); replay.OutboxIds.Should().Equal(first.OutboxIds); + _dispatches.Last().Should().Equal(first.OutboxIds); _events.Should().HaveCount(count); Stock(item, location).Should().Be(5); + _store.All().Single().Revision.Should().Be(revision); + input.Lines[0].Quantity = 1; + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, input), "RequestConflict", 409); + } + + [Test] + public async Task Repeated_receipt_rows_cannot_collectively_exceed_the_ordered_quantity() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 5m, 3m)); + var input = M4Receipt(order, location, 3); input.Lines.Add(Copy(input.Lines[0])); + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, input), "PurchaseOrderOverReceipt", 409); + Stock(item, location).Should().Be(0); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Single().QuantityReceived.Should().Be(0); + } + + [Test] + public async Task Cancelling_a_partial_order_preserves_receipts_and_rejects_new_receiving() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 5m, 3m)); + await _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(order, location, 2)); + var partial = await _service.GetPurchaseOrderAsync(_actor, order.Order.Id); + var cancel = new InventoryPurchaseOrderChange { Id = order.Order.Id, Revision = partial.Order.Revision, RequestId = Guid.NewGuid().ToString("D") }; + await _service.ChangePurchaseOrderStatusAsync(_actor, cancel, InventoryPurchaseOrderStatus.Cancelled); + await _service.ChangePurchaseOrderStatusAsync(_actor, Copy(cancel), InventoryPurchaseOrderStatus.Cancelled); + var cancelled = await _service.GetPurchaseOrderAsync(_actor, order.Order.Id); var events = _events.Count; + cancelled.Order.Status.Should().Be((int)InventoryPurchaseOrderStatus.Cancelled); cancelled.Lines.Single().QuantityReceived.Should().Be(2); cancelled.Receipts.Should().ContainSingle(); + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(cancelled, location, 1)), "PurchaseOrderStateConflict", 409); + Stock(item, location).Should().Be(2); _events.Should().HaveCount(events); _store.All().Should().ContainSingle(); + } + + [Test] + public async Task Receipt_source_line_and_request_ownership_cannot_be_swapped() + { + var item = M4Item(); var location = Location(); var first = await M4Ordered((item, 2m, 3m)); var second = await M4Ordered((item, 2m, 3m)); + var wrong = M4Receipt(first, location, 1); wrong.Lines[0].PurchaseOrderItemId = second.Lines.Single().Id; + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, wrong), "PurchaseOrderLineUnavailable", 404); + var input = M4Receipt(first, location, 1); await _service.ReceivePurchaseOrderAsync(_actor, input); + await Fails(() => _service.ReceivePurchaseOrderAsync(new InventoryActor { DepartmentId = Department, UserId = "other-manager", GrantToken = "other-grant" }, Copy(input)), "RequestConflict", 409); + Stock(item, location).Should().Be(1); _store.All().Should().ContainSingle(); + } + + [Test] + public async Task Purchase_receipt_cannot_add_stock_to_an_unauthorized_destination() + { + var item = M4Item(); var hidden = Location(); var order = await M4Ordered((item, 2m, 3m)); + _deniedLocations.Add(hidden.Id); + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(order, hidden, 1)), "LocationUnavailable", 404); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Single().QuantityReceived.Should().Be(0); _events.Should().BeEmpty(); + } + + [Test] + public async Task Generic_inventory_posting_cannot_manufacture_a_purchase_order_receipt() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 2m, 3m)); + var command = M4Receive(item, location, 1, 3); command.Lines[0].ReferenceType = InventoryReferenceType.PurchaseOrder; command.Lines[0].ReferenceId = order.Order.Id; + command.Lines[0].PurchaseOrderItemId = order.Lines.Single().Id; + var error = (await ((Func)(() => _service.PostTransactionAsync(_actor, command))).Should().ThrowAsync()).Which; + error.StatusCode.Should().Be(400); Stock(item, location).Should().Be(0); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Single().QuantityReceived.Should().Be(0); + } + + [Test] + public async Task Controlled_receipt_holds_the_whole_mixed_batch_until_an_independent_witness_and_retries_once() + { + var ordinary = M4Item(); var controlled = M4Item(controlled: true); var location = Location(); + var order = await M4Ordered((ordinary, 2m, 3m), (controlled, 1m, 9m)); + var input = M4Receipt(order, location, 2); input.Lines.Add(new InventoryPurchaseReceiptLine { PurchaseOrderItemId = order.Lines[1].Id, LocationId = location.Id, Quantity = 1 }); + var pending = await _service.ReceivePurchaseOrderAsync(_actor, input); + pending.AwaitingWitness.Should().BeTrue(); pending.TransactionIds.Should().BeEmpty(); _events.Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Should().OnlyContain(x => x.QuantityReceived == 0); + (await _service.ReceivePurchaseOrderAsync(_actor, Copy(input))).OperationId.Should().Be(pending.OperationId); + await Fails(() => _service.WitnessAsync(_actor, input.RequestId, "Synthetic witness"), "IndependentWitnessRequired", 409); + var receipt = await Witness(input.RequestId); receipt.AwaitingWitness.Should().BeFalse(); receipt.TransactionIds.Should().HaveCount(2); + Stock(ordinary, location).Should().Be(2); Stock(controlled, location).Should().Be(1); + _store.All().Single().Status.Should().Be((int)InventoryPurchaseOrderStatus.Received); + _store.All().Should().OnlyContain(x => x.CreatedBy == _actor.UserId); + foreach (var transaction in _store.All()) JObject.Parse(transaction.Content).Value("WitnessUserId").Should().Be("witness"); + var count = _events.Count; + (await Witness(input.RequestId)).TransactionIds.Should().Equal(receipt.TransactionIds); + _dispatches.Last().Should().Equal(receipt.OutboxIds); + (await _service.ReceivePurchaseOrderAsync(_actor, Copy(input))).TransactionIds.Should().Equal(receipt.TransactionIds); + _events.Should().HaveCount(count); _events.Count(x => x.Trigger == WorkflowTriggerEventType.InventoryPurchaseOrderReceived).Should().Be(1); + _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == "witness"), It.IsAny(), PermissionTypes.ContactView, It.IsAny())) + .ThrowsAsync(new InventoryException(403, "PermissionRequired")); + await Fails(() => Witness(input.RequestId), "PermissionRequired", 403); + } + + [Test] + public async Task Competing_pending_controlled_receipts_cannot_overreceive_after_the_first_witness_commits() + { + var item = M4Item(controlled: true); var location = Location(); var order = await M4Ordered((item, 5m, 3m)); + var first = M4Receipt(order, location, 3); var second = M4Receipt(order, location, 3); + await _service.ReceivePurchaseOrderAsync(_actor, first); await _service.ReceivePurchaseOrderAsync(_actor, second); + await Witness(first.RequestId); var count = _events.Count; + await Fails(() => Witness(second.RequestId), "PurchaseOrderStateConflict", 409); + Stock(item, location).Should().Be(3); _store.All().Single().QuantityReceived.Should().Be(3); _events.Should().HaveCount(count); + _store.All().Single(x => x.RequestId == second.RequestId).State.Should().Be(1); + } + + [Test] + public async Task A_pending_purchase_receipt_rechecks_its_original_performers_purchasing_permission() + { + var item = M4Item(controlled: true); var location = Location(); var order = await M4Ordered((item, 1m, 3m)); var input = M4Receipt(order, location, 1); + await _service.ReceivePurchaseOrderAsync(_actor, input); + _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == _actor.UserId), It.IsAny(), PermissionTypes.ContactView, It.IsAny())) + .ThrowsAsync(new InventoryException(403, "PermissionRequired")); + await Fails(() => Witness(input.RequestId), "PermissionRequired", 403); + Stock(item, location).Should().Be(0); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Single(x => x.RequestId == input.RequestId).State.Should().Be(1); + } + + [Test] + public async Task Serialized_purchase_receipts_create_one_costed_asset_per_row_and_replay_without_new_assets() + { + var item = M4Item(InventoryTrackingMode.Serialized); var location = Location(); var order = await M4Ordered((item, 2m, 12.5m)); + var input = M4Receipt(order, location, 1); input.Lines[0].Asset = new() { SerialNumber = "purchase-serial-one" }; + input.Lines.Add(new InventoryPurchaseReceiptLine { PurchaseOrderItemId = order.Lines.Single().Id, LocationId = location.Id, Quantity = 1, Asset = new() { SerialNumber = "purchase-serial-two" } }); + var result = await _service.ReceivePurchaseOrderAsync(_actor, input); + var replay = await _service.ReceivePurchaseOrderAsync(_actor, Copy(input)); replay.TransactionIds.Should().Equal(result.TransactionIds); + _store.All().Should().HaveCount(2).And.OnlyContain(x => x.CurrentLocationId == location.Id && x.Status == 0); + _store.All().Select(x => JsonConvert.DeserializeObject(x.Content).AcquisitionCost).Should().OnlyContain(x => x == 12.5m); + _store.All().Should().BeEmpty(); M4Average(item).Should().Be(12.5m); + (await _service.GetValuationAsync(_actor)).Totals.Should().ContainSingle().Which.KnownValue.Should().Be(25); + } + + [Test] + public async Task Purchase_receipt_rejects_an_incompatible_lot_cost_without_creating_stock() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 2m, 3m)); + var lot = await _service.SaveLotAsync(_actor, new InventoryLot { ItemId = item.Id }, new InventoryLotContent { LotNumber = "Synthetic wrong-price lot", UnitCost = 4 }); + var input = M4Receipt(order, location, 1); input.Lines[0].LotId = lot.Id; + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, input), "LotCostMismatch", 409); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + } + + [Test] + public async Task Late_protected_order_write_failure_rolls_back_stock_average_lines_ledger_audit_and_outbox() + { + var item = M4Item(); var location = Location(); var order = await M4Ordered((item, 2m, 8m)); + var beforeItem = JsonConvert.SerializeObject(_store.All().Single()); var beforeOrder = JsonConvert.SerializeObject(order.Order); + var operations = _store.All().Count(); var audits = _audits.Count; var dispatches = _dispatches.Count; + _write.Setup(x => x.PrepareRecordsEntityWriteAsync(Department, It.Is(p => p.Status == 2 || p.Status == 3), It.IsAny(), It.IsAny(), + It.IsAny Get, Action Set)>>(), It.IsAny(), + It.IsAny(), It.IsAny(), false, It.IsAny())).ReturnsAsync(ProtectedWriteResult.Blocked("synthetic protection unavailable")); + await Fails(() => _service.ReceivePurchaseOrderAsync(_actor, M4Receipt(order, location, 2)), "ProtectedDataRequired", 403); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _store.All().Should().HaveCount(operations); + JsonConvert.SerializeObject(_store.All().Single()).Should().Be(beforeItem); JsonConvert.SerializeObject(_store.All().Single()).Should().Be(beforeOrder); + _store.All().Single().QuantityReceived.Should().Be(0); _audits.Should().HaveCount(audits); _dispatches.Should().HaveCount(dispatches); _events.Should().BeEmpty(); + } + + [Test] + public async Task Valuation_partitions_currency_keeps_unknown_rows_and_excludes_terminal_assets_and_hidden_holders() + { + var usd = M4Item(defaultCost: 2); var eur = M4Item(defaultCost: 3, currency: "EUR"); var unknown = M4Item(defaultCost: null); + var equipment = M4Item(InventoryTrackingMode.Serialized); var visible = Location(); var hidden = Location(); + SeedStock(usd, visible, 2); SeedStock(eur, visible, 3); SeedStock(unknown, visible, 5); SeedStock(usd, hidden, 10000); + var asset = _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = equipment.Id, CurrentLocationId = visible.Id, Status = 0, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "visible", AcquisitionCost = 12 }) }); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = equipment.Id, CurrentLocationId = visible.Id, Status = (int)InventoryAssetStatus.Retired, + Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = "retired", AcquisitionCost = 999 }) }); + _deniedLocations.Add(hidden.Id); + var result = await _service.GetValuationAsync(_actor); + result.Lines.Should().HaveCount(4).And.OnlyContain(x => x.LocationId == visible.Id); + result.Lines.Where(x => x.AssetId != null).Should().ContainSingle().Which.AssetId.Should().Be(asset.Id); + result.Totals.Single(x => x.CurrencyCode == "USD").KnownValue.Should().Be(16); result.Totals.Single(x => x.CurrencyCode == "USD").UncostedRows.Should().Be(1); + result.Totals.Single(x => x.CurrencyCode == "EUR").KnownValue.Should().Be(9); result.AsOfUtc.Should().Be(_clock.Utc); + await Fails(() => _service.GetValuationAsync(_actor, hidden.Id), "LocationUnavailable", 404); + } + + private InventoryItem M4Item(InventoryTrackingMode tracking = InventoryTrackingMode.Bulk, bool controlled = false, decimal? defaultCost = 2, decimal? average = null, string currency = "USD") + { + var item = Item(tracking, controlled); var details = JsonConvert.DeserializeObject(item.Content); + details.DefaultUnitCost = defaultCost; details.AverageUnitCost = average; details.CurrencyCode = currency; item.Content = JsonConvert.SerializeObject(details); return _store.Seed(item); + } + private decimal? M4Average(InventoryItem item) => JsonConvert.DeserializeObject(_store.All().Single(x => x.Id == item.Id).Content).AverageUnitCost; + private JObject M4Cost(InventoryResult result) => JObject.Parse(_store.All().Single(x => x.Id == result.TransactionIds.Single()).Content); + private static InventoryCommand M4Receive(InventoryItem item, InventoryLocation location, decimal quantity, decimal unitCost) + { + var command = Receive(item, location, quantity); command.Lines[0].UnitCost = unitCost; return command; + } + private Contact M4CompanyContact(string id = null) + { + var contact = new Contact { ContactId = id ?? Guid.NewGuid().ToString("D"), DepartmentId = Department, ContactType = 1, CompanyName = Canary }; + _companyContacts[contact.ContactId] = contact; return contact; + } + private async Task<(InventoryPurchaseOrderInput Input, InventoryPurchaseOrderDetail Detail)> M4Draft(params (InventoryItem Item, decimal Quantity, decimal UnitCost)[] lines) + { + var contact = M4CompanyContact(); var vendor = await _service.SaveVendorAsync(_actor, new InventoryVendorInput { ContactId = contact.ContactId, Details = new() { Note = Canary } }); + var input = new InventoryPurchaseOrderInput { Id = Guid.NewGuid().ToString("D"), Number = "PO-" + Guid.NewGuid().ToString("N"), VendorId = vendor.Id, CurrencyCode = "USD", Note = Canary, + Lines = lines.Select(x => new InventoryPurchaseOrderLineInput { ItemId = x.Item.Id, QuantityOrdered = x.Quantity, UnitCost = x.UnitCost, Note = Canary }).ToList() }; + return (input, await _service.SavePurchaseOrderAsync(_actor, input)); + } + private async Task M4Ordered(params (InventoryItem Item, decimal Quantity, decimal UnitCost)[] lines) + { + var draft = await M4Draft(lines); + await _service.ChangePurchaseOrderStatusAsync(_actor, new InventoryPurchaseOrderChange { Id = draft.Detail.Order.Id, Revision = draft.Detail.Order.Revision, RequestId = Guid.NewGuid().ToString("D") }, InventoryPurchaseOrderStatus.Ordered); + return await _service.GetPurchaseOrderAsync(_actor, draft.Detail.Order.Id); + } + private static InventoryPurchaseReceiptInput M4Receipt(InventoryPurchaseOrderDetail order, InventoryLocation location, decimal quantity) => new() { + PurchaseOrderId = order.Order.Id, Revision = order.Order.Revision, RequestId = Guid.NewGuid().ToString("D"), + Lines = new() { new InventoryPurchaseReceiptLine { PurchaseOrderItemId = order.Lines[0].Id, LocationId = location.Id, Quantity = quantity } } }; + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM5DatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5DatabaseTests.cs new file mode 100644 index 000000000..89ce0e265 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM5DatabaseTests.cs @@ -0,0 +1,324 @@ +using System; +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.Model.Inventories; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class InventoryDatabaseTests + { + [Test] + public void Operations_catalog_covers_count_snapshots_and_null_only_alert_content_slots() + { + var catalog = new ProtectedFieldCatalog(); + var bindings = AdpTableBindings.ForVersionRange(catalog, 21, 22).ToArray(); + bindings.Select(b => b.TableName).Should().BeEquivalentTo(new[] { "InventoryCounts", "InventoryCountItems", "InventoryAlerts", "InventoryAlertDeliveries" }); + foreach (var binding in bindings) + { + catalog.GetForTable(binding.TableName).Should().ContainSingle().Which.AddedInCatalogVersion.Should().Be(22); + catalog.GetForTableAndVersion(binding.TableName, 21).Should().BeEmpty(); + binding.DepartmentColumn.Should().Be("DepartmentId"); binding.PkColumn.Should().Be("Id"); + binding.Columns.Select(c => c.FieldId).Should().Equal(binding.TableName.ToLowerInvariant() + ".content"); + } + } + + [Test] + public async Task Count_schema_enforces_tenant_item_and_position_identity_without_rejecting_negative_opening_stock() + { + var seed = await SeedAsync(); var other = await SeedAsync(); var foreign = await SeedAsync(88); + var count = M5DatabaseCount(seed.Location); var foreignCount = M5DatabaseCount(foreign.Location); + var lot = M5DatabaseLot(seed.Item); var otherLot = M5DatabaseLot(other.Item); var foreignLot = M5DatabaseLot(foreign.Item); + var asset = M5DatabaseAsset(seed.Item, seed.Location); var otherAsset = M5DatabaseAsset(other.Item, seed.Location); var foreignAsset = M5DatabaseAsset(foreign.Item, foreign.Location); + await WriteAsync(async store => { await store.InsertAsync(count); await store.InsertAsync(lot); await store.InsertAsync(otherLot); await store.InsertAsync(asset); await store.InsertAsync(otherAsset); }); + await WriteAsync(async store => { await store.InsertAsync(foreignCount); await store.InsertAsync(foreignLot); await store.InsertAsync(foreignAsset); }, 88); + foreach (var corrupt in new Action[] + { + row => row.DepartmentId = 88, row => row.CountId = foreignCount.Id, row => row.ItemId = foreign.Item.Id, + row => row.LocationId = foreign.Location.Id, row => row.LotId = foreignLot.Id, row => row.AssetId = foreignAsset.Id, + row => row.LotId = otherLot.Id, row => row.AssetId = otherAsset.Id, row => row.CountedQuantity = -0.000001m + }) + await RejectAsync(store => { var row = M5DatabaseLine(count, seed.Item, seed.Location); corrupt(row); return store.InsertAsync(row); }); + var line = M5DatabaseLine(count, seed.Item, seed.Location); line.ExpectedQuantity = -1.123456m; line.CountedQuantity = 0; + var lotLine = M5DatabaseLine(count, seed.Item, seed.Location); lotLine.LotId = lot.Id; + var assetLine = M5DatabaseLine(count, seed.Item, seed.Location); assetLine.AssetId = asset.Id; + await WriteAsync(async store => { await store.InsertAsync(line); await store.InsertAsync(lotLine); await store.InsertAsync(assetLine); }); + await RejectAsync(store => store.InsertAsync(M5DatabaseLine(count, seed.Item, seed.Location))); + await RejectAsync(store => { var duplicate = M5DatabaseLine(count, seed.Item, seed.Location); duplicate.LotId = lot.Id; return store.InsertAsync(duplicate); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + var saved = await repository.GetAsync(77, line.Id); saved.ExpectedQuantity.Should().Be(-1.123456m); saved.CountedQuantity.Should().Be(0); + (await repository.RelatedAsync(77, "CountId", count.Id)).Should().HaveCount(3); + (await repository.RelatedAsync(88, "CountId", count.Id)).Should().BeEmpty(); + (await repository.GetAsync(88, count.Id)).Should().BeNull(); + } + + [Test] + public async Task Count_schema_bounds_status_fingerprint_and_serialized_observations() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); + foreach (var corrupt in new Action[] + { + row => row.Status = -1, row => row.Status = 4, row => row.Revision = 0, + row => row.SnapshotFingerprint = "short", row => row.LocationId = foreign.Location.Id, + row => row.OperationId = Guid.NewGuid().ToString("D") + }) + await RejectAsync(store => { var row = M5DatabaseCount(seed.Location); corrupt(row); return store.InsertAsync(row); }); + var count = M5DatabaseCount(seed.Location); var asset = M5DatabaseAsset(seed.Item, seed.Location); + await WriteAsync(async store => { await store.InsertAsync(count); await store.InsertAsync(asset); }); + foreach (var corrupt in new Action[] { row => row.ExpectedQuantity = -1, row => row.ExpectedQuantity = 2, row => row.CountedQuantity = 0.5m, row => row.CountedQuantity = 2 }) + await RejectAsync(store => { var line = M5DatabaseLine(count, seed.Item, seed.Location); line.AssetId = asset.Id; corrupt(line); return store.InsertAsync(line); }); + var serialized = M5DatabaseLine(count, seed.Item, seed.Location); serialized.AssetId = asset.Id; serialized.CountedQuantity = 0; + await WriteAsync(store => store.InsertAsync(serialized)); + using var uow = new UnitOfWork(Connections()); + (await Store(uow).GetAsync(77, serialized.Id)).CountedQuantity.Should().Be(0); + } + + [Test] + public async Task Count_ledger_backlinks_require_same_tenant_item_and_count_discriminator_and_remain_unique() + { + var seed = await SeedAsync(); var other = await SeedAsync(); var foreign = await SeedAsync(88); + var count = M5DatabaseCount(seed.Location); var otherCount = M5DatabaseCount(seed.Location); var foreignCount = M5DatabaseCount(foreign.Location); + var line = M5DatabaseLine(count, seed.Item, seed.Location); var otherLine = M5DatabaseLine(otherCount, other.Item, seed.Location); var foreignLine = M5DatabaseLine(foreignCount, foreign.Item, foreign.Location); + var otherTransaction = Posting(other.Item, null, other.Location, 1); var foreignTransaction = Posting(foreign.Item, null, foreign.Location, 1); + await WriteAsync(async store => { await store.InsertAsync(count); await store.InsertAsync(otherCount); await store.InsertAsync(line); await store.InsertAsync(otherLine); await store.InsertAsync(otherTransaction); }); + await WriteAsync(async store => { await store.InsertAsync(foreignCount); await store.InsertAsync(foreignLine); await store.InsertAsync(foreignTransaction); }, 88); + foreach (var corrupt in new Action[] + { + row => row.CountItemId = foreignLine.Id, row => row.CountItemId = otherLine.Id, + row => row.ReferenceType = (int)InventoryReferenceType.None, row => row.TransactionType = (int)InventoryTransactionType.Adjust + }) + await RejectAsync(store => { var row = M5DatabaseCountPosting(count, line, seed.Item, seed.Location); corrupt(row); return store.InsertAsync(row); }); + foreach (var transactionId in new[] { otherTransaction.Id, foreignTransaction.Id }) + await RejectAsync(store => { var changed = M5DatabaseLine(count, seed.Item, other.Location); changed.TransactionId = transactionId; return store.InsertAsync(changed); }); + var receipt = M5DatabaseCountPosting(count, line, seed.Item, seed.Location); + await WriteAsync(store => store.InsertAsync(receipt)); + await RejectAsync(store => store.InsertAsync(M5DatabaseCountPosting(count, line, seed.Item, seed.Location))); + line.TransactionId = receipt.Id; line.Revision++; + await WriteAsync(store => store.UpdateAsync(line, 1)); + await RejectAsync(store => { var duplicate = M5DatabaseLine(count, seed.Item, other.Location); duplicate.TransactionId = receipt.Id; return store.InsertAsync(duplicate); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.RelatedAsync(77, "CountItemId", line.Id)).Should().ContainSingle().Which.Id.Should().Be(receipt.Id); + (await repository.RelatedAsync(77, "TransactionId", receipt.Id)).Should().ContainSingle().Which.Id.Should().Be(line.Id); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("InventoryCountItems")} WHERE {Q("Id")}=@Id", new { line.Id })).Should().ThrowAsync(); + await FluentActions.Awaiting(() => db.ExecuteAsync($"DELETE FROM {Q("InventoryTransactions")} WHERE {Q("Id")}=@Id", new { receipt.Id })).Should().ThrowAsync(); + } + + [Test] + public async Task Count_completion_and_negative_opening_correction_commit_or_rollback_with_their_ledger_and_stock() + { + var seed = await SeedAsync(); var count = M5DatabaseCount(seed.Location); var line = M5DatabaseLine(count, seed.Item, seed.Location); + line.ExpectedQuantity = -1.123456m; line.CountedQuantity = 0; + await WriteAsync(async store => { await store.InsertAsync(count); await store.InsertAsync(line); await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, line.ExpectedQuantity, "inventory-test-author"); }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + async Task Complete() + { + await uow.CreateOrGetConnectionAsync(); await repository.LockDepartmentAsync(77); + var currentCount = await repository.GetAsync(77, count.Id); var currentLine = await repository.GetAsync(77, line.Id); + var receipt = M5DatabaseCountPosting(currentCount, currentLine, seed.Item, seed.Location, 1.123456m); + await repository.InsertAsync(receipt); await repository.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, receipt.Quantity, "inventory-test-author"); + currentLine.TransactionId = receipt.Id; currentLine.Revision++; await repository.UpdateAsync(currentLine, 1); + currentCount.Status = 2; currentCount.CompletedOn = DateTime.UtcNow; currentCount.Revision++; + currentCount.Content = "SYNTHETIC-OPAQUE-COUNT"; currentCount.IsProtected = true; await repository.UpdateAsync(currentCount, 1); + return receipt; + } + var abandoned = await Complete(); uow.DiscardChanges(); + (await repository.GetAsync(77, abandoned.Id)).Should().BeNull(); + (await repository.GetAsync(77, line.Id)).TransactionId.Should().BeNull(); + (await repository.GetAsync(77, count.Id)).Status.Should().Be(0); + (await repository.RelatedAsync(77, "ItemId", seed.Item.Id)).Single().Quantity.Should().Be(-1.123456m); + var completed = await Complete(); uow.CommitChanges(); + (await repository.GetAsync(77, completed.Id)).CountItemId.Should().Be(line.Id); + (await repository.GetAsync(77, line.Id)).TransactionId.Should().Be(completed.Id); + var saved = await repository.GetAsync(77, count.Id); saved.Status.Should().Be(2); saved.Revision.Should().Be(2); + saved.Content.Should().Be("SYNTHETIC-OPAQUE-COUNT"); saved.IsProtected.Should().BeTrue(); + (await repository.RelatedAsync(77, "ItemId", seed.Item.Id)).Single().Quantity.Should().Be(0); + } + + [Test] + public async Task Alert_schema_isolates_tenants_items_and_issuances_and_accepts_only_reviewed_metadata() + { + var seed = await SeedAsync(); var other = await SeedAsync(); var foreign = await SeedAsync(88); + var otherAsset = M5DatabaseAsset(other.Item, other.Location); var otherLot = M5DatabaseLot(other.Item); + var issuance = M5DatabaseIssuance(other.Item, other.Location); var foreignIssuance = M5DatabaseIssuance(foreign.Item, foreign.Location); + await WriteAsync(async store => { await store.InsertAsync(otherAsset); await store.InsertAsync(otherLot); await store.InsertAsync(issuance); }); + await WriteAsync(store => store.InsertAsync(foreignIssuance), 88); + foreach (var corrupt in new Action[] + { + row => row.DepartmentId = 88, row => row.ItemId = foreign.Item.Id, row => row.LocationId = foreign.Location.Id, + row => row.AssetId = otherAsset.Id, row => row.LotId = otherLot.Id, row => row.IssuanceId = issuance.Id, + row => row.IssuanceId = foreignIssuance.Id, row => row.AlertType = -1, row => row.AlertType = 4, + row => row.Status = -1, row => row.Status = 2, row => row.DedupKey = "short", row => row.Content = "{}" + }) + await RejectAsync(store => { var row = M5DatabaseAlert(seed.Item, seed.Location); corrupt(row); return store.InsertAsync(row); }); + var alert = M5DatabaseAlert(other.Item, other.Location); alert.IssuanceId = issuance.Id; alert.AlertType = 3; alert.Quantity = 0.123456m; + await WriteAsync(store => store.InsertAsync(alert)); + using var uow = new UnitOfWork(Connections()); var saved = await Store(uow).GetAsync(77, alert.Id); + saved.Content.Should().BeNull(); saved.Quantity.Should().Be(0.123456m); saved.IssuanceId.Should().Be(issuance.Id); + } + + [Test] + public async Task Alerts_have_one_open_identity_and_one_delivery_per_recipient_with_revision_control() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); + var alert = M5DatabaseAlert(seed.Item, seed.Location); var foreignAlert = M5DatabaseAlert(foreign.Item, foreign.Location); foreignAlert.DedupKey = alert.DedupKey; + await WriteAsync(store => store.InsertAsync(alert)); await WriteAsync(store => store.InsertAsync(foreignAlert), 88); + await RejectAsync(store => { var duplicate = M5DatabaseAlert(seed.Item, seed.Location); duplicate.DedupKey = alert.DedupKey; return store.InsertAsync(duplicate); }); + var delivery = M5DatabaseDelivery(alert); await WriteAsync(store => store.InsertAsync(delivery)); + foreach (var corrupt in new Action[] + { + row => row.AlertId = foreignAlert.Id, row => row.DepartmentId = 88, row => row.UserId = "missing-user", + row => row.State = -1, row => row.State = 4, row => row.AttemptCount = -1, row => row.Content = "{}" + }) + await RejectAsync(store => { var row = M5DatabaseDelivery(alert, "inventory-test-other"); corrupt(row); return store.InsertAsync(row); }); + await RejectAsync(store => store.InsertAsync(M5DatabaseDelivery(alert))); + alert.Status = 1; alert.ResolvedOn = DateTime.UtcNow; alert.Revision++; + var reopened = M5DatabaseAlert(seed.Item, seed.Location); reopened.DedupKey = alert.DedupKey; + delivery.State = 1; delivery.AttemptCount = 1; delivery.ClaimToken = Guid.NewGuid().ToString("D"); delivery.LeaseUntil = DateTime.UtcNow.AddMinutes(30); delivery.Revision++; + await WriteAsync(async store => { await store.UpdateAsync(alert, 1); await store.InsertAsync(reopened); await store.UpdateAsync(delivery, 1); await store.InsertAsync(M5DatabaseDelivery(alert, "inventory-test-other")); await store.InsertAsync(M5DatabaseDelivery(reopened)); }); + await FluentActions.Awaiting(() => WriteAsync(store => store.UpdateAsync(delivery, 1))).Should().ThrowAsync(); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.RelatedAsync(77, "DedupKey", alert.DedupKey)).Should().HaveCount(2).And.ContainSingle(a => a.Status == 0); + (await repository.RelatedAsync(77, "AlertId", alert.Id)).Should().HaveCount(2); + (await repository.RelatedAsync(77, "UserId", delivery.UserId)).Should().HaveCount(2); + (await repository.GetAsync(88, delivery.Id)).Should().BeNull(); + var saved = await repository.GetAsync(77, delivery.Id); saved.ClaimToken.Should().Be(delivery.ClaimToken); saved.Revision.Should().Be(2); saved.Content.Should().BeNull(); + } + + [Test] + public async Task Counts_and_alerts_prevent_schema_rollback_from_erasing_completed_evidence() + { + var seed = await SeedAsync(); var count = M5DatabaseCount(seed.Location); var line = M5DatabaseLine(count, seed.Item, seed.Location); + var alert = M5DatabaseAlert(seed.Item, seed.Location); var delivery = M5DatabaseDelivery(alert); + await WriteAsync(async store => { await store.InsertAsync(count); await store.InsertAsync(line); await store.InsertAsync(alert); await store.InsertAsync(delivery); }); + var receipt = M5DatabaseCountPosting(count, line, seed.Item, seed.Location); + await WriteAsync(async store => { await store.InsertAsync(receipt); line.TransactionId = receipt.Id; line.Revision++; await store.UpdateAsync(line, 1); }); + await using var db = Connect(_connection); var before = await CleanupSnapshotAsync(db); var runner = _runner.GetRequiredService(); + try { FluentActions.Invoking(() => runner.MigrateDown(201)).Should().Throw(); } + finally { _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); } + (await CleanupSnapshotAsync(db)).Should().BeEquivalentTo(before); + } + + [Test] + public async Task Latest_inventory_entry_query_returns_zero_for_empty_tenants_and_only_their_committed_maximum() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.LastEntryAsync(77)).Should().Be(0); (await repository.LastEntryAsync(88)).Should().Be(0); + var first = Posting(seed.Item, null, seed.Location, 1); var latest = Posting(seed.Item, null, seed.Location, 2); + await WriteAsync(async store => { await store.InsertAsync(first); await store.InsertAsync(latest); }); + var other = Posting(foreign.Item, null, foreign.Location, 3); await WriteAsync(store => store.InsertAsync(other), 88); + other.EntryId.Should().BeGreaterThan(latest.EntryId); + (await repository.LastEntryAsync(77)).Should().Be(latest.EntryId); (await repository.LastEntryAsync(88)).Should().Be(other.EntryId); + await uow.CreateOrGetConnectionAsync(); await repository.LockDepartmentAsync(77); + var abandoned = Posting(seed.Item, null, seed.Location, 4); await repository.InsertAsync(abandoned); uow.DiscardChanges(); + (await repository.LastEntryAsync(77)).Should().Be(latest.EntryId); (await repository.LastEntryAsync(99)).Should().Be(0); + } + + [Test] + public async Task Targeted_alert_queries_isolate_tenants_and_page_open_rows_beyond_lifetime_delivery_history() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); var stamp = new DateTime(2026, 9, 9, 0, 0, 0); + var open = M5DatabaseAlert(seed.Item, seed.Location); open.OpenedOn = stamp; + var history = Enumerable.Range(0, 5001).Select(_ => + { + var row = M5DatabaseAlert(seed.Item, seed.Location); row.DedupKey = open.DedupKey; row.Status = 1; row.OpenedOn = stamp.AddDays(-1); return row; + }).ToArray(); + await using var db = Connect(_connection); await db.OpenAsync(); + await M5InsertAlertMetadataAsync(db, seed.Item, seed.Location, history, includeCompletedDeliveries: true); + var delivery = M5DatabaseDelivery(open); var otherUserDelivery = M5DatabaseDelivery(open, "inventory-test-other"); + await WriteAsync(async store => { await store.InsertAsync(open); await store.InsertAsync(delivery); await store.InsertAsync(otherUserDelivery); }); + var otherTenantAlert = M5DatabaseAlert(foreign.Item, foreign.Location); otherTenantAlert.DedupKey = open.DedupKey; otherTenantAlert.OpenedOn = stamp.AddDays(-2); + var otherTenantDelivery = M5DatabaseDelivery(otherTenantAlert); + await WriteAsync(async store => { await store.InsertAsync(otherTenantAlert); await store.InsertAsync(otherTenantDelivery); }, 88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.OpenAlertAsync(77, open.DedupKey)).Id.Should().Be(open.Id); + (await repository.OpenAlertAsync(88, open.DedupKey)).Id.Should().Be(otherTenantAlert.Id); + (await repository.OpenAlertAsync(77, new string('F', 64))).Should().BeNull(); + (await repository.AlertDeliveryAsync(77, open.Id, delivery.UserId)).Id.Should().Be(delivery.Id); + (await repository.AlertDeliveryAsync(77, open.Id, otherUserDelivery.UserId)).Id.Should().Be(otherUserDelivery.Id); + (await repository.AlertDeliveryAsync(88, open.Id, delivery.UserId)).Should().BeNull(); + (await repository.AlertDeliveryAsync(77, otherTenantAlert.Id, delivery.UserId)).Should().BeNull(); + (await repository.AlertDeliveryAsync(77, open.Id, "missing-user")).Should().BeNull(); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("InventoryAlertDeliveries")} WHERE {Q("DepartmentId")}=77 AND {Q("UserId")}=@UserId", new { delivery.UserId })).Should().Be(5002); + var pending = Enumerable.Range(0, 502).Select(i => + { + var row = M5DatabaseAlert(seed.Item, seed.Location); row.OpenedOn = stamp.AddHours(1).AddMinutes(i / 2); return row; + }).ToArray(); + await M5InsertAlertMetadataAsync(db, seed.Item, seed.Location, pending); + var expected = new[] { open }.Concat(pending).OrderBy(a => a.OpenedOn).ThenBy(a => a.Id, StringComparer.Ordinal).Select(a => a.Id).ToArray(); + var first = await repository.OpenAlertsAsync(77); var second = await repository.OpenAlertsAsync(77, 500); + first.Should().HaveCount(501).And.OnlyContain(a => a.DepartmentId == 77 && a.Status == 0); + first.Select(a => a.Id).Should().Equal(expected.Take(501)); second.Select(a => a.Id).Should().Equal(expected.Skip(500)); + first.Take(500).Concat(second).Select(a => a.Id).Should().Equal(expected); + (await repository.OpenAlertsAsync(88)).Should().ContainSingle().Which.Id.Should().Be(otherTenantAlert.Id); + (await repository.OpenAlertsAsync(77, 503)).Should().BeEmpty(); + await FluentActions.Awaiting(() => repository.OpenAlertsAsync(77, -1)).Should().ThrowAsync(); + } + + private async Task M5InsertAlertMetadataAsync(DbConnection db, InventoryItem item, InventoryLocation location, InventoryAlert[] rows, bool includeCompletedDeliveries = false) + { + // Bounded parameter batches keep this lifetime-history regression fast on both real dialects. + await using var transaction = await db.BeginTransactionAsync(); + foreach (var batch in rows.Chunk(300)) + { + var parameters = new DynamicParameters(new { item.DepartmentId, ItemId = item.Id, LocationId = location.Id, CreatedOn = new DateTime(2026, 9, 9), UserId = "inventory-test-author" }); + var alerts = new System.Collections.Generic.List(); var deliveries = new System.Collections.Generic.List(); + for (var i = 0; i < batch.Length; i++) + { + parameters.Add("Id" + i, batch[i].Id); parameters.Add("Key" + i, batch[i].DedupKey); parameters.Add("Status" + i, batch[i].Status); + parameters.Add("Opened" + i, DateTime.SpecifyKind(batch[i].OpenedOn, DateTimeKind.Unspecified)); + alerts.Add($"(@Id{i},@DepartmentId,@CreatedOn,@UserId,0,@Key{i},@ItemId,@LocationId,@Status{i},@Opened{i})"); + if (includeCompletedDeliveries) + { + parameters.Add("Delivery" + i, Guid.NewGuid().ToString("D")); + deliveries.Add($"(@Delivery{i},@DepartmentId,@CreatedOn,@UserId,@Id{i},@UserId,2,@CreatedOn,1,@CreatedOn)"); + } + } + var sql = $"INSERT INTO {Q("InventoryAlerts")} ({Q("Id")},{Q("DepartmentId")},{Q("CreatedOn")},{Q("CreatedBy")},{Q("AlertType")},{Q("DedupKey")},{Q("ItemId")},{Q("LocationId")},{Q("Status")},{Q("OpenedOn")}) VALUES " + string.Join(",", alerts) + ";"; + if (includeCompletedDeliveries) sql += $"INSERT INTO {Q("InventoryAlertDeliveries")} ({Q("Id")},{Q("DepartmentId")},{Q("CreatedOn")},{Q("CreatedBy")},{Q("AlertId")},{Q("UserId")},{Q("State")},{Q("NextAttemptOn")},{Q("AttemptCount")},{Q("HandedOffOn")}) VALUES " + string.Join(",", deliveries) + ";"; + await db.ExecuteAsync(sql, parameters, transaction); + } + await transaction.CommitAsync(); + } + + private static InventoryCount M5DatabaseCount(InventoryLocation location) + { + var row = NewRow(location.DepartmentId); row.LocationId = location.Id; row.SnapshotFingerprint = new string('A', 64); row.SnapshotOn = DateTime.UtcNow; return row; + } + private static InventoryCountItem M5DatabaseLine(InventoryCount count, InventoryItem item, InventoryLocation location) + { + var row = NewRow(count.DepartmentId); row.CountId = count.Id; row.ItemId = item.Id; row.LocationId = location.Id; row.ExpectedQuantity = 1; return row; + } + private static InventoryLot M5DatabaseLot(InventoryItem item) + { + var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.ReceivedOn = DateTime.UtcNow; return row; + } + private static InventoryAsset M5DatabaseAsset(InventoryItem item, InventoryLocation location) + { + var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.CurrentLocationId = location.Id; return row; + } + private static InventoryTransaction M5DatabaseCountPosting(InventoryCount count, InventoryCountItem line, InventoryItem item, InventoryLocation location, decimal quantity = 1) + { + var row = Posting(item, null, location, quantity, InventoryTransactionType.Count); row.CountItemId = line.Id; row.ReferenceType = (int)InventoryReferenceType.Count; row.ReferenceId = count.Id; return row; + } + private static InventoryIssuance M5DatabaseIssuance(InventoryItem item, InventoryLocation location) + { + var row = NewRow(item.DepartmentId); row.ItemId = item.Id; row.LocationId = location.Id; row.Quantity = 1; row.IssuedOn = DateTime.UtcNow; row.IssuedToUserId = "inventory-test-author"; return row; + } + private static InventoryAlert M5DatabaseAlert(InventoryItem item, InventoryLocation location) + { + var row = NewRow(item.DepartmentId); row.Content = null; row.ItemId = item.Id; row.LocationId = location.Id; row.DedupKey = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); row.OpenedOn = DateTime.UtcNow; return row; + } + private static InventoryAlertDelivery M5DatabaseDelivery(InventoryAlert alert, string user = "inventory-test-author") + { + var row = NewRow(alert.DepartmentId); row.Content = null; row.AlertId = alert.Id; row.UserId = user; row.NextAttemptOn = DateTime.UtcNow; return row; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs new file mode 100644 index 000000000..315187cdb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.IO; +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.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.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; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Inventory; +using Resgrid.Web.Helpers; +using ApiClaims = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; +using ApiInventoryController = Resgrid.Web.Services.Controllers.v4.InventoryController; +using DataProtectionController = Resgrid.Web.Services.Controllers.v4.DataProtectionController; +using MvcInventoryController = Resgrid.Web.Areas.User.Controllers.InventoryController; +using File = System.IO.File; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryApiTests + { + private const string M5CountId = "77777777-7777-7777-7777-777777777777"; + private const string M5LineId = "88888888-8888-8888-8888-888888888888"; + + [Test] + public async Task Operations_API_uses_authenticated_tenant_and_header_grant_for_count_commands_and_query_filters() + { + var operations = M5Operations(); + await WithM5Server(operations, async (client, _) => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "attended-grant"); + var requestId = Guid.NewGuid().ToString("D"); + await Success(await client.PostAsync(Route + "StartCount", Json(new { Id = M5CountId, LocationId, Name = Canary, DepartmentId = 88, UserId = "forged", GrantToken = "forged-grant" }))); + await Success(await client.PostAsync(Route + "SaveCount", Json(new { CountId = M5CountId, Revision = 3, Lines = new[] { new { Id = M5LineId, Quantity = 1.125001m } }, DepartmentId = 88 }))); + await Success(await client.PostAsync(Route + "CompleteCount", Json(new { CountId = M5CountId, Revision = 4, RequestId = requestId, DepartmentId = 88 }))); + await Success(await client.PostAsync(Route + "CancelCount", Json(new { CountId = M5CountId, Revision = 5, DepartmentId = 88 }))); + await Success(await client.PostAsync(Route + "RefreshAlerts", Json(new { DepartmentId = 88 }))); + var response = await client.GetAsync(Route + "GetCounts?page=3&locationId=" + LocationId + "&departmentId=88"); + await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); json.Value("Page").Should().Be(3); json["Data"]["Items"].Should().NotBeNull(); + await Success(await client.GetAsync(Route + "GetAlerts?page=2&itemId=" + ItemId + "&locationId=" + LocationId + "&assetId=" + LotId)); + operations.Verify(x => x.StartCountAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == "attended-grant"), It.Is(i => i.Id == M5CountId && i.LocationId == LocationId && i.Name == Canary)), Times.Once); + operations.Verify(x => x.SaveCountAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), It.Is(i => i.CountId == M5CountId && i.Revision == 3 && i.Lines.Single().Quantity == 1.125001m)), Times.Once); + operations.Verify(x => x.CompleteCountAsync(It.Is(a => a.DepartmentId == 77), It.Is(i => i.RequestId == requestId && i.Revision == 4)), Times.Once); + operations.Verify(x => x.CancelCountAsync(It.Is(a => a.DepartmentId == 77), M5CountId, 5), Times.Once); + operations.Verify(x => x.RefreshAlertsAsync(It.Is(a => a.DepartmentId == 77 && a.GrantToken == "attended-grant")), Times.Once); + _catalog.Verify(x => x.QueryAsync(It.Is(a => a.DepartmentId == 77), It.Is(q => q.LocationId == LocationId), 3), Times.Once); + _catalog.Verify(x => x.QueryAsync(It.Is(a => a.DepartmentId == 77), It.Is(q => q.ItemId == ItemId && q.LocationId == LocationId && q.AssetId == LotId), 2), Times.Once); + }); + } + + [Test] + public async Task Operations_API_count_read_rejects_missing_auth_query_grants_and_foreign_sources_without_disclosure() + { + var operations = M5Operations(); var foreignId = Guid.NewGuid().ToString("D"); + operations.Setup(x => x.GetCountAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor actor, string id) => + { + if (id == foreignId) throw new InventoryException(404, "NotFound"); + if (actor.GrantToken != "read-grant") throw new InventoryException(403, "ProtectedDataRequired"); + return M5Count(); + }); + await WithM5Server(operations, async (client, _) => + { + (await client.GetAsync(Route + "GetCount?id=" + M5CountId)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SignIn(client); + var response = await client.GetAsync(Route + "GetCount?id=" + M5CountId + "&GrantToken=read-grant"); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); response.Headers.CacheControl.NoStore.Should().BeTrue(); + (await response.Content.ReadAsStringAsync()).Should().Contain("ProtectedDataRequired").And.NotContain(Canary).And.NotContain("read-grant"); + client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "read-grant"); + response = await client.GetAsync(Route + "GetCount?id=" + M5CountId); await Success(response); + (await response.Content.ReadAsStringAsync()).Should().Contain(Canary).And.NotContain("read-grant"); + response = await client.GetAsync(Route + "GetCount?id=" + foreignId + "&departmentId=88"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); response.Headers.CacheControl.NoStore.Should().BeTrue(); + (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + operations.Verify(x => x.GetCountAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), foreignId), Times.Once); + }); + } + + [Test] + public async Task Operations_MVC_all_reads_reveals_and_writes_require_CSRF_and_count_observations_preserve_decimal_precision() + { + var operations = M5Operations(); + await WithM5Server(operations, async (client, _) => + { + SignIn(client); client.DefaultRequestHeaders.Add("Test-Reports", "true"); + var page = await client.GetAsync(MvcRoute + "Operations"); await Success(page); + var fields = new Dictionary { ["Id"] = M5CountId, ["CountId"] = M5CountId, ["Revision"] = "7", ["RequestId"] = Guid.NewGuid().ToString("D"), + ["Lines[0].Id"] = M5LineId, ["Lines[0].Quantity"] = "1.125001", ["DepartmentId"] = "88", ["UserId"] = "forged", ["GrantToken"] = "forged-grant", [HttpProtectedGrantContext.FormFieldName] = "form-grant" }; + foreach (var action in new[] { "GetCounts", "GetCount", "GetAlerts", "StartCount", "SaveCount", "CompleteCount", "CancelCount", "RefreshAlerts", "ReopenOperations", "BuildReport", "ReportPdf" }) + (await client.PostAsync(MvcRoute + action, new FormUrlEncodedContent(fields))).StatusCode.Should().Be(HttpStatusCode.BadRequest, action); + operations.Verify(x => x.SaveCountAsync(It.IsAny(), It.IsAny()), Times.Never); + fields["__RequestVerificationToken"] = PurchasingCsrf(await page.Content.ReadAsStringAsync()); + var response = await client.PostAsync(MvcRoute + "SaveCount", new FormUrlEncodedContent(fields)); await Success(response); + response.Headers.CacheControl.NoStore.Should().BeTrue(); (await response.Content.ReadAsStringAsync()).Should().NotContain("form-grant").And.NotContain("forged-grant"); + operations.Verify(x => x.SaveCountAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == "form-grant"), It.Is(i => i.CountId == M5CountId && i.Revision == 7 && i.Lines.Single().Quantity == 1.125001m)), Times.Once); + fields["Lines[0].Quantity"] = Canary; + response = await client.PostAsync(MvcRoute + "SaveCount", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + fields.Remove("RequestId"); + response = await client.PostAsync(MvcRoute + "CompleteCount", new FormUrlEncodedContent(fields)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + operations.Verify(x => x.CompleteCountAsync(It.IsAny(), It.IsAny()), Times.Never); + }, mvc: true); + } + + [Test] + public async Task Operations_MVC_protected_GET_is_a_selection_preserving_shell_and_POST_reveal_renders_count_controls() + { + var operations = M5Operations(); + operations.Setup(x => x.GetCountAsync(It.IsAny(), M5CountId)).ReturnsAsync((InventoryActor actor, string _) => + { + if (actor.GrantToken != "navigation-grant") throw new InventoryException(403, "ProtectedDataRequired"); + return M5Count(); + }); + await WithM5Server(operations, async (client, views) => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, "navigation-grant"); + var address = MvcRoute + "Operations?tab=Counts&page=3&id=" + M5CountId + "&itemId=" + ItemId + "&locationId=" + LocationId; + var response = await client.GetAsync(address); await Success(response); var html = await response.Content.ReadAsStringAsync(); + html.Should().NotContain(Canary).And.Contain("ReopenOperations"); response.Headers.CacheControl.NoStore.Should().BeTrue(); + var locked = (InventoryWorkspaceView)views.LastView.Model; + locked.Locked.Should().BeTrue(); locked.Id.Should().Be(M5CountId); locked.ItemId.Should().Be(ItemId); locked.LocationId.Should().Be(LocationId); locked.Page.Should().Be(3); locked.CountDetail.Should().BeNull(); + operations.Verify(x => x.GetCountAsync(It.IsAny(), It.IsAny()), Times.Never); + _catalog.Verify(x => x.ListAsync(It.IsAny(), It.IsAny()), Times.Never); + client.DefaultRequestHeaders.Remove(DataProtectionController.GrantHeader); + var fields = new Dictionary { ["__RequestVerificationToken"] = PurchasingCsrf(html), ["tab"] = "Counts", ["page"] = "3", ["id"] = M5CountId, ["itemId"] = ItemId, + ["locationId"] = LocationId, [HttpProtectedGrantContext.FormFieldName] = "navigation-grant", [HttpProtectedGrantContext.ExpiresOnFormFieldName] = "2030-01-01T00:00:00Z" }; + response = await client.PostAsync(MvcRoute + "ReopenOperations", new FormUrlEncodedContent(fields)); await Success(response); html = await response.Content.ReadAsStringAsync(); + html.Should().Contain(Canary).And.Contain("SaveCount").And.Contain("CompleteCount").And.Contain("CancelCount").And.Contain("Lines[0].Quantity").And.Contain("1.125001"); + response.Headers.CacheControl.NoStore.Should().BeTrue(); var revealed = (InventoryWorkspaceView)views.LastView.Model; + revealed.Locked.Should().BeFalse(); revealed.CountDetail.Count.Id.Should().Be(M5CountId); revealed.Page.Should().Be(3); revealed.ItemId.Should().Be(ItemId); revealed.LocationId.Should().Be(LocationId); + views.LastView.ViewData["ProtectedGrant"].Should().Be("navigation-grant"); + response = await client.GetAsync(address); await Success(response); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + ((InventoryWorkspaceView)views.LastView.Model).Locked.Should().BeTrue(); + }, mvc: true, protectedData: true); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Operations_reports_require_Reports_View_in_addition_to_Inventory_access(bool mvc) + { + var operations = M5Operations(); + await WithM5Server(operations, async (client, views) => + { + SignIn(client); string token = null; + if (mvc) + { + var page = await client.GetAsync(MvcRoute + "Operations"); await Success(page); var html = await page.Content.ReadAsStringAsync(); token = PurchasingCsrf(html); + ((InventoryWorkspaceView)views.LastView.Model).CanViewReports.Should().BeFalse(); html.Should().NotContain("value=\"Reports\""); + (await client.GetAsync(MvcRoute + "Operations?tab=Reports")).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + HttpContent Body() => mvc ? new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = token, ["Kind"] = "Expiration" }) : Json(new { Kind = InventoryReportKind.Expiration }); + var response = await client.PostAsync((mvc ? MvcRoute : Route) + "BuildReport", Body()); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + operations.Verify(x => x.BuildReportAsync(It.IsAny(), It.IsAny()), Times.Never); + client.DefaultRequestHeaders.Add("Test-Reports", "true"); + // The synthetic identity's claims changed; obtain the CSRF token bound to that identity. + if (mvc) { var page = await client.GetAsync(MvcRoute + "Operations?tab=Reports"); await Success(page); token = PurchasingCsrf(await page.Content.ReadAsStringAsync()); } + response = await client.PostAsync((mvc ? MvcRoute : Route) + "BuildReport", Body()); await Success(response); response.Headers.CacheControl.NoStore.Should().BeTrue(); + operations.Verify(x => x.BuildReportAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager"), It.Is(i => i.Kind == InventoryReportKind.Expiration)), Times.Once); + if (mvc) + { + response = await client.GetAsync(MvcRoute + "Operations?tab=Reports"); await Success(response); + ((InventoryWorkspaceView)views.LastView.Model).CanViewReports.Should().BeTrue(); (await response.Content.ReadAsStringAsync()).Should().Contain("ReportPdf").And.Contain("inventory-report-form"); + } + else + { + response = await client.GetAsync(Route + "GetExpiring?itemId=" + ItemId + "&locationId=" + LocationId); await Success(response); + operations.Verify(x => x.BuildReportAsync(It.IsAny(), It.Is(i => i.Kind == InventoryReportKind.Expiration && i.ItemId == ItemId && i.LocationId == LocationId)), Times.Once); + } + }, mvc: mvc); + } + + [TestCase("permission", HttpStatusCode.Forbidden, "PermissionRequired")] + [TestCase("inventory", HttpStatusCode.Forbidden, "PermissionRequired")] + [TestCase("grant", HttpStatusCode.Forbidden, "ProtectedDataRequired")] + [TestCase("changed", HttpStatusCode.Conflict, "ReportChanged")] + [TestCase("renderer", HttpStatusCode.Conflict, "ReportUnavailable")] + public async Task Operations_PDF_rechecks_access_and_snapshot_after_rendering_and_never_returns_a_stale_document(string change, HttpStatusCode status, string code) + { + var operations = M5Operations(); var pdf = new Mock(); var converted = false; + operations.Setup(x => x.BuildReportAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor _, InventoryReportInput input) => + { + if (converted && change == "grant") throw new InventoryException(403, "ProtectedDataRequired"); + var report = M5Report(input.Kind); if (converted && change == "changed") report.Rows.Clear(); return report; + }); + _authorization.Setup(x => x.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => converted && change == "inventory" ? Task.FromException(new InventoryException(403, "PermissionRequired")) : Task.CompletedTask); + pdf.Setup(x => x.ConvertHtmlToPdf(It.IsAny())).Returns((string _) => + { + converted = true; if (change == "renderer") throw new InvalidOperationException(Canary); return Encoding.ASCII.GetBytes("%PDF-SYNTHETIC"); + }); + await WithM5Server(operations, async (client, _) => + { + SignIn(client); client.DefaultRequestHeaders.Add("Test-Reports", "true"); + var page = await client.GetAsync(MvcRoute + "Operations"); await Success(page); + var response = await client.PostAsync(MvcRoute + "ReportPdf", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = PurchasingCsrf(await page.Content.ReadAsStringAsync()), ["Kind"] = "Usage" })); + response.StatusCode.Should().Be(status); response.Headers.CacheControl.NoStore.Should().BeTrue(); + (await response.Content.ReadAsStringAsync()).Should().Contain(code).And.NotContain(Canary).And.NotContain("%PDF-SYNTHETIC"); + pdf.Verify(x => x.ConvertHtmlToPdf(It.IsAny()), Times.Once); + operations.Verify(x => x.BuildReportAsync(It.IsAny(), It.IsAny()), Times.Exactly(change == "renderer" ? 1 : 2)); + }, mvc: true, pdf: pdf.Object, reportsAllowed: () => !(converted && change == "permission")); + } + + [TestCase(InventoryReportKind.Usage)] + [TestCase(InventoryReportKind.Expiration)] + public async Task Operations_PDF_success_rechecks_effective_scope_and_encodes_protected_text(InventoryReportKind kind) + { + var operations = M5Operations(); var pdf = new Mock(); var inputs = new List(); + operations.Setup(x => x.BuildReportAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor actor, InventoryReportInput input) => + { + actor.GrantToken.Should().Be("pdf-grant"); inputs.Add(Copy(input)); var report = M5Report(input.Kind); + report.Rows[0]["Name"] = Canary + "" }); _store.Seed(movement); + M5Ledger(ordinary, location, null, 999, InventoryTransactionType.Consume); + var result = await _service.BuildReportAsync(_actor, new InventoryReportInput { Kind = InventoryReportKind.ControlledSubstanceLog }); + result.Rows.Should().ContainSingle(); var row = result.Rows[0]; row["Name"].Should().Be("Frozen medicine"); row["M5Performer"].Should().Be("performer-id"); row["M5Witness"].Should().Be("witness-id"); + row["M5WitnessedOn"].Should().Be(witnessed); row["M5FromBefore"].Should().Be(5m); row["M5FromAfter"].Should().Be(3m); + var html = InventoryReportDocuments.Build(result, CultureInfo.GetCultureInfo("ar")); + html.Should().Contain("dir=\"rtl\"").And.Contain("performer-id").And.Contain("witness-id").And.Contain("class=\"signatures\"").And.Contain("<script>").And.NotContain("", ["Amount"] = 1.125001m } } }; + var html = InventoryReportDocuments.Build(report, CultureInfo.GetCultureInfo("fr")); + html.Should().Contain("lang=\"fr\"").And.Contain("1,125001").And.Contain("<img").And.Contain("&").And.NotContain(" _store.Seed(new InventoryAsset { + DepartmentId = Department, ItemId = item.Id, CurrentLocationId = location.Id, Status = status, Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = serial }) }); + private InventoryTransaction M5Ledger(InventoryItem item, InventoryLocation from, InventoryLocation to, decimal quantity, InventoryTransactionType type) => _store.Seed(new InventoryTransaction { + DepartmentId = Department, ItemId = item.Id, FromLocationId = from?.Id, ToLocationId = to?.Id, Quantity = quantity, TransactionType = (int)type, OccurredOn = _clock.Utc, + CreatedBy = _actor.UserId, Content = JsonConvert.SerializeObject(new { ItemName = "Frozen item", UnitOfMeasure = "each", Note = "ledger-note" }) }); + private void M5Threshold(InventoryItem item, decimal? reorder, decimal? minimum) + { + var details = JsonConvert.DeserializeObject(item.Content); details.ReorderPoint = reorder; details.MinLevel = minimum; item.Content = JsonConvert.SerializeObject(details); _store.Seed(item); + } + private InventoryIssuance M5Issuance(InventoryItem item, InventoryLocation location, string user, int? unit, DateTime date, decimal quantity, decimal returned) => _store.Seed(new InventoryIssuance { + DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, IssuedToUserId = user, IssuedToUnitId = unit, Quantity = quantity, ReturnedQuantity = returned, + Status = returned == 0 ? 0 : 2, IssuedOn = date, ExpectedReturnOn = date.AddDays(3), Content = "{}" }); + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM5ScheduledReportTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5ScheduledReportTests.cs new file mode 100644 index 000000000..458fb1387 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM5ScheduledReportTests.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public sealed class InventoryM5ScheduledReportTests + { + private const string Canary = "SYNTHETIC-INVENTORY-REPORT-PHI-CANARY"; + private Mock _tasks; + private Mock _auth; + private Mock _migration; + private Mock _reports; + private Mock _protection; + private Mock _users; + private Mock _profiles; + private Mock _pdf; + private InventoryScheduledReportService _service; + private ScheduledTask _queued; + private ScheduledTask _current; + private IdentityUser _user; + private bool _protected; + private bool _enabled; + private bool _revoked; + private InventoryReport _report; + private string _html; + private Action _duringPdf; + private int _reportReads; + + [SetUp] + public void SetUp() + { + _tasks = new(); _auth = new(); _migration = new(); _reports = new(); _protection = new(); _users = new(); _profiles = new(); _pdf = new(); + _protected = false; _enabled = true; _revoked = false; _duringPdf = null; _reportReads = 0; _html = null; + _queued = new ScheduledTask { ScheduledTaskId = 12, DepartmentId = 77, UserId = "manager", Data = "6", TaskType = (int)TaskTypes.ReportDelivery, Active = true, + ScheduleType = (int)ScheduleTypes.Weekly, Time = "08:00", Monday = true, UserEmailAddress = "stale@example.invalid" }; + _current = Clone(_queued); _user = new IdentityUser { Email = "current@example.invalid" }; + _tasks.Setup(x => x.GetScheduledTaskByIdAsync(12)).ReturnsAsync(() => Clone(_current)); + _auth.Setup(x => x.RequireAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((InventoryActor actor, bool write, PermissionTypes? permission, int? group) => _revoked ? Task.FromException(new InventoryException(403, "MembershipRequired")) : Task.CompletedTask); + _auth.Setup(x => x.IsEnabledAsync(77)).ReturnsAsync(() => _enabled); _migration.Setup(x => x.IsMigratedAsync(77)).ReturnsAsync(true); + _protection.Setup(x => x.IsProtectionEnforcedAsync(77)).ReturnsAsync(() => _protected); + _users.Setup(x => x.GetUserById("manager", true)).Returns(() => _user); + _profiles.Setup(x => x.GetProfileByUserIdAsync("manager", true)).ReturnsAsync(new UserProfile { Language = "fr" }); + _report = new InventoryReport { Kind = InventoryReportKind.OnHand, GeneratedOn = new DateTime(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc), Columns = new() { "Name", "Amount" }, + Rows = new() { new() { ["Name"] = Canary, ["Amount"] = 1.125001m } } }; + _reports.Setup(x => x.BuildReportAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor actor, InventoryReportInput input) => + { + actor.DepartmentId.Should().Be(77); actor.UserId.Should().Be("manager"); actor.GrantToken.Should().BeNull(); + var report = Clone(_report); report.Kind = input.Kind; report.GeneratedOn = report.GeneratedOn.AddSeconds(++_reportReads); return report; + }); + _pdf.Setup(x => x.ConvertHtmlToPdf(It.IsAny())).Returns((string html) => { _html = html; _duringPdf?.Invoke(); return Encoding.ASCII.GetBytes("%PDF-1.4 synthetic"); }); + _service = new InventoryScheduledReportService(_tasks.Object, _auth.Object, _migration.Object, _reports.Object, _protection.Object, _users.Object, _profiles.Object, _pdf.Object); + } + + [TestCase(6, InventoryReportKind.OnHand)] + [TestCase(7, InventoryReportKind.Usage)] + [TestCase(8, InventoryReportKind.Expiration)] + [TestCase(9, InventoryReportKind.LowStock)] + [TestCase(10, InventoryReportKind.TransferHistory)] + [TestCase(11, InventoryReportKind.Issuance)] + [TestCase(12, InventoryReportKind.Valuation)] + [TestCase(13, InventoryReportKind.ControlledSubstanceLog)] + public async Task Unprotected_scheduled_reports_deliver_current_authorized_data_without_a_grant(int type, InventoryReportKind kind) + { + _queued.Data = _current.Data = type.ToString(CultureInfo.InvariantCulture); + var result = await _service.BuildAsync(_queued); + result.To.Should().Be("current@example.invalid"); result.AttachmentName.Should().EndWith(".pdf"); Encoding.ASCII.GetString(result.AttachmentData).Should().StartWith("%PDF-"); + _html.Should().Contain(Canary).And.Contain("lang=\"fr\""); result.Subject.Should().Be(InventoryReportDocuments.Title(kind, CultureInfo.GetCultureInfo("fr"))); + await _service.ValidateAsync(_queued); + _reports.Verify(x => x.BuildReportAsync(It.Is(a => a.DepartmentId == 77 && a.UserId == "manager" && a.GrantToken == null), It.Is(q => q.Kind == kind)), Times.AtLeast(3)); + _auth.Verify(x => x.RequireAsync(It.IsAny(), false, PermissionTypes.AdjustInventory, null), Times.AtLeastOnce); + _auth.Verify(x => x.RequireAsync(It.IsAny(), false, PermissionTypes.ManageControlledSubstances, null), type == 13 ? Times.AtLeastOnce() : Times.Never()); + } + + [TestCase(6)] + [TestCase(13)] + public async Task Protected_scheduled_reports_are_localized_value_free_notices_and_never_query_inventory_or_receive_a_grant(int type) + { + _protected = true; _queued.Data = _current.Data = type.ToString(CultureInfo.InvariantCulture); + var result = await _service.BuildAsync(_queued); await _service.ValidateAsync(_queued); + result.To.Should().Be("current@example.invalid"); _html.Should().Contain("lang=\"fr\"").And.NotContain(Canary).And.NotContain("manager").And.NotContain("current@example.invalid").And.NotContain("1.125001"); + _reports.Invocations.Should().BeEmpty(); + } + + [TestCase("policy")] + [TestCase("recipient")] + [TestCase("member")] + [TestCase("owner")] + [TestCase("department")] + [TestCase("data")] + [TestCase("inactive")] + [TestCase("module")] + public async Task A_change_during_PDF_conversion_prevents_stale_or_unauthorized_delivery(string changed) + { + _duringPdf = () => Change(changed); + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); + } + + [Test] + public async Task A_protected_to_unprotected_policy_flip_does_not_convert_the_notice_into_an_unattended_data_export() + { + _protected = true; _duringPdf = () => _protected = false; + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); _reports.Invocations.Should().BeEmpty(); + } + + [TestCase("policy")] + [TestCase("recipient")] + [TestCase("member")] + [TestCase("owner")] + [TestCase("data")] + [TestCase("inactive")] + public async Task Immediate_handoff_validation_rechecks_current_policy_recipient_membership_and_schedule(string changed) + { + await _service.BuildAsync(_queued); Change(changed); + await FluentActions.Awaiting(() => _service.ValidateAsync(_queued)).Should().ThrowAsync(); + } + + [Test] + public async Task Data_or_source_visibility_changes_during_conversion_or_before_handoff_reject_the_old_PDF() + { + _duringPdf = () => _report.Rows[0]["Amount"] = 9m; + (await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + _duringPdf = null; await _service.BuildAsync(_queued); _report.Rows.Clear(); + (await FluentActions.Awaiting(() => _service.ValidateAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + } + + [Test] + public async Task Generated_timestamp_and_enumeration_order_do_not_make_an_unchanged_snapshot_undeliverable() + { + _report.Rows.Add(new Dictionary { ["Name"] = "second", ["Amount"] = 2m }); + _duringPdf = () => { _report.Rows.Reverse(); _report.Rows = _report.Rows.Select(row => row.Reverse().ToDictionary(cell => cell.Key, cell => cell.Value)).ToList(); }; + await _service.BuildAsync(_queued); await _service.ValidateAsync(_queued); _reportReads.Should().BeGreaterThanOrEqualTo(3); + } + + [TestCase(null)] + [TestCase("5")] + [TestCase("14")] + [TestCase("06")] + [TestCase("bad")] + public async Task Non_inventory_or_noncanonical_scheduled_report_identifiers_fail_before_reading_data(string data) + { + _queued.Data = _current.Data = data; + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); _reports.Invocations.Should().BeEmpty(); _pdf.Invocations.Should().BeEmpty(); + } + + [Test] + public async Task Current_controlled_report_permission_is_required_even_for_a_protected_notice() + { + _protected = true; _queued.Data = _current.Data = "13"; + _auth.Setup(x => x.RequireAsync(It.IsAny(), false, PermissionTypes.ManageControlledSubstances, null)).ThrowsAsync(new InventoryException(403, "PermissionRequired")); + (await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync()).Which.StatusCode.Should().Be(403); + _reports.Invocations.Should().BeEmpty(); _pdf.Invocations.Should().BeEmpty(); + } + + [Test] + public async Task A_foreign_or_non_report_schedule_and_missing_current_recipient_are_rejected() + { + _current.TaskType = (int)TaskTypes.UserStaffingLevel; + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); + _current.TaskType = _queued.TaskType; _current.ScheduledTaskId = 99; + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); + _current.ScheduledTaskId = 12; _user.Email = null; + await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync(); _pdf.Invocations.Should().BeEmpty(); + } + + [Test] + public async Task Invalid_PDF_bytes_are_rejected_and_a_later_valid_retry_can_build() + { + _pdf.Setup(x => x.ConvertHtmlToPdf(It.IsAny())).Returns(Encoding.UTF8.GetBytes("not a pdf")); + (await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportGenerationFailed"); + _pdf.Setup(x => x.ConvertHtmlToPdf(It.IsAny())).Returns(Encoding.ASCII.GetBytes("%PDF-1.4 synthetic")); + await _service.BuildAsync(_queued); await _service.ValidateAsync(_queued); + } + + [Test, Combinatorial] + public async Task Rescheduling_prevents_delivery_before_build_during_rendering_and_before_handoff( + [Values("before-build", "during-pdf", "before-handoff")] string phase, + [Values("type", "date", "time", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")] string timing, + [Values(false, true)] bool protectedData) + { + _protected = protectedData; + if (phase == "before-build") ChangeTiming(_current, timing); + if (phase == "during-pdf") _duringPdf = () => ChangeTiming(_current, timing); + if (phase == "before-handoff") + { + await _service.BuildAsync(_queued); ChangeTiming(_current, timing); + (await FluentActions.Awaiting(() => _service.ValidateAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + } + else + { + (await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + if (phase == "before-build") { _reports.Invocations.Should().BeEmpty(); _pdf.Invocations.Should().BeEmpty(); } + } + if (protectedData) _reports.Invocations.Should().BeEmpty(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Updating_the_queued_object_cannot_reuse_a_proof_for_an_earlier_schedule(bool duringPdf) + { + void Reschedule() { ChangeTiming(_current, "time"); _queued.Time = _current.Time; } + if (duringPdf) + { + _duringPdf = Reschedule; + (await FluentActions.Awaiting(() => _service.BuildAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + } + else + { + await _service.BuildAsync(_queued); Reschedule(); + (await FluentActions.Awaiting(() => _service.ValidateAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + } + } + + [Test] + public async Task A_timing_change_during_report_revalidation_is_caught_by_the_final_schedule_check() + { + await _service.BuildAsync(_queued); + _reports.Setup(x => x.BuildReportAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => + { + ChangeTiming(_current, "time"); _queued.Time = _current.Time; return Clone(_report); + }); + (await FluentActions.Awaiting(() => _service.ValidateAsync(_queued)).Should().ThrowAsync()).Which.Code.Should().Be("ReportDeliveryChanged"); + } + + [Test] + public async Task Matching_schedule_wall_times_survive_database_DateTimeKind_normalization() + { + _queued.ScheduleType = _current.ScheduleType = (int)ScheduleTypes.SpecifcDateTime; + _queued.SpecifcDate = new DateTime(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc); + _current.SpecifcDate = DateTime.SpecifyKind(_queued.SpecifcDate.Value, DateTimeKind.Unspecified); + await _service.BuildAsync(_queued); await _service.ValidateAsync(_queued); + } + + private static void ChangeTiming(ScheduledTask task, string timing) + { + switch (timing) + { + case "type": task.ScheduleType = (int)ScheduleTypes.SpecifcDateTime; break; + case "date": task.SpecifcDate = new DateTime(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc); break; + case "time": task.Time = "09:00"; break; + case "Sunday": task.Sunday = !task.Sunday; break; + case "Monday": task.Monday = !task.Monday; break; + case "Tuesday": task.Tuesday = !task.Tuesday; break; + case "Wednesday": task.Wednesday = !task.Wednesday; break; + case "Thursday": task.Thursday = !task.Thursday; break; + case "Friday": task.Friday = !task.Friday; break; + case "Saturday": task.Saturday = !task.Saturday; break; + default: throw new ArgumentOutOfRangeException(nameof(timing)); + } + } + + private void Change(string change) + { + switch (change) + { + case "policy": _protected = !_protected; break; + case "recipient": _user.Email = "changed@example.invalid"; break; + case "member": _revoked = true; break; + case "owner": _current.UserId = "other-user"; break; + case "department": _current.DepartmentId = 88; break; + case "data": _current.Data = "13"; break; + case "inactive": _current.Active = false; break; + case "module": _enabled = false; break; + default: throw new ArgumentOutOfRangeException(nameof(change)); + } + } + private static T Clone(T input) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(input)); + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs b/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs new file mode 100644 index 000000000..e9fc41068 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs @@ -0,0 +1,456 @@ +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.Inventories; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + [TestCase(6, 4)] + [TestCase(4, 6)] + [TestCase(-2, 0)] + public async Task Counts_post_signed_bulk_variance_with_matching_snapshot_and_ledger_backlinks(int opening, int observed) + { + var item = Item(); var location = Location(); SeedStock(item, location, opening); + var detail = await M5ObservedCount(location, observed); + detail.Lines.Should().ContainSingle().Which.ExpectedQuantity.Should().Be(opening); + var input = M5Completion(detail); var result = await _service.CompleteCountAsync(_actor, input); + Stock(item, location).Should().Be(observed); + var ledger = _store.All().Should().ContainSingle().Which; + ledger.Quantity.Should().Be(Math.Abs(observed - opening)); ledger.ItemId.Should().Be(item.Id); + ledger.TransactionType.Should().Be((int)InventoryTransactionType.Count); + ledger.ReferenceType.Should().Be((int)InventoryReferenceType.Count); ledger.ReferenceId.Should().Be(detail.Count.Id); + ledger.CountItemId.Should().Be(detail.Lines.Single().Id); ledger.OperationId.Should().Be(result.OperationId); + ledger.FromLocationId.Should().Be(observed < opening ? location.Id : null); + ledger.ToLocationId.Should().Be(observed > opening ? location.Id : null); + var saved = await _service.GetCountAsync(_actor, detail.Count.Id); + saved.Count.Status.Should().Be((int)InventoryCountStatus.Completed); saved.Count.CompletedOn.Should().Be(_clock.Utc); + saved.Lines.Single().TransactionId.Should().Be(ledger.Id); saved.Lines.Single().CountedQuantity.Should().Be(observed); + var summary = _events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.InventoryCountCompleted).Which; + summary.AggregateId.Should().Be(detail.Count.Id); summary.CorrelationId.Should().Be(result.OperationId); + var payload = JObject.FromObject(summary.Payload); payload.Value("VarianceLineCount").Should().Be(1); + payload.Value("VarianceValue").Should().Be(ProtectedDataEnvelope.RedactionValue); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("UnitCost"); + _dispatches.Last().Should().Equal(result.OutboxIds); + } + + [Test] + public async Task Count_without_variance_completes_once_and_replays_its_summary_without_a_stock_movement() + { + var item = Item(); var location = Location(); SeedStock(item, location, 1.123456m); + var detail = await M5ObservedCount(location, 1.123456m); var input = M5Completion(detail); + var result = await _service.CompleteCountAsync(_actor, input); + result.TransactionIds.Should().BeEmpty(); _store.All().Should().BeEmpty(); Stock(item, location).Should().Be(1.123456m); + var summary = _events.Should().ContainSingle().Which; summary.Trigger.Should().Be(WorkflowTriggerEventType.InventoryCountCompleted); + JObject.FromObject(summary.Payload).Value("VarianceLineCount").Should().Be(0); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.Completed); + var replay = await _service.CompleteCountAsync(_actor, input); + replay.OperationId.Should().Be(result.OperationId); replay.OutboxIds.Should().Equal(result.OutboxIds); + _events.Should().ContainSingle(); _store.All().Should().ContainSingle(); + _dispatches.Last().Should().Equal(result.OutboxIds); + await Fails(() => _service.CompleteCountAsync(_actor, new InventoryCountComplete { CountId = input.CountId, RequestId = input.RequestId, Revision = input.Revision + 1 }), "RequestConflict", 409); + _deniedLocations.Add(location.Id); + await Fails(() => _service.CompleteCountAsync(_actor, input), "LocationUnavailable", 404); + } + + [Test] + public async Task Count_observations_require_current_revision_distinct_owned_lines_and_nonnegative_quantities() + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); + var start = M5CountInput(location); var detail = await _service.StartCountAsync(_actor, start); + await Fails(() => _service.StartCountAsync(_actor, start), "CountAlreadyExists", 409); + await Fails(() => _service.GetCountAsync(new InventoryActor { DepartmentId = 88, UserId = _actor.UserId }, detail.Count.Id), "Unavailable", 404); + var update = M5Observations(detail, 4); + update.Lines.Add(Copy(update.Lines[0])); await Fails(() => _service.SaveCountAsync(_actor, update), "InvalidCount", 400); + update.Lines.RemoveAt(1); update.Lines[0].Id = Guid.NewGuid().ToString("D"); + await Fails(() => _service.SaveCountAsync(_actor, update), "Unavailable", 404); + update = M5Observations(detail, -0.000001m); await Fails(() => _service.SaveCountAsync(_actor, update), "InvalidQuantity", 400); + update = M5Observations(detail, 0.0000001m); await Fails(() => _service.SaveCountAsync(_actor, update), "InvalidQuantity", 400); + await Fails(() => _service.CompleteCountAsync(_actor, M5Completion(detail)), "CountStateConflict", 409); + update = M5Observations(detail, 4); var saved = await _service.SaveCountAsync(_actor, update); + await Fails(() => _service.SaveCountAsync(_actor, update), "CountStateConflict", 409); + saved.Count.Revision.Should().Be(detail.Count.Revision + 1); saved.Lines.Single().CountedQuantity.Should().Be(4); + _store.All().Should().ContainSingle(); _store.All().Should().BeEmpty(); + } + + [TestCase("quantity")] + [TestCase("stock-revision")] + [TestCase("new-position")] + [TestCase("item-revision")] + [TestCase("location-revision")] + public async Task Counts_reject_changed_department_snapshots_without_overwriting_newer_stock(string change) + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); + switch (change) + { + case "quantity": SeedStock(item, location, 6); break; + case "stock-revision": var stock = _store.All().Single(); stock.Revision++; _store.Seed(stock); break; + case "new-position": SeedStock(item, Location(), 1); break; + case "item-revision": item.Revision++; _store.Seed(item); break; + case "location-revision": location.Revision++; _store.Seed(location); break; + } + var before = JsonConvert.SerializeObject(_store.All()); + await Fails(() => _service.CompleteCountAsync(_actor, M5Completion(detail)), "CountSnapshotChanged", 409); + JsonConvert.SerializeObject(_store.All()).Should().Be(before); + _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.Draft); + } + + [Test] + public async Task Controlled_count_variance_waits_for_independent_witness_and_replays_only_the_completed_receipt() + { + var item = Item(controlled: true); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); var input = M5Completion(detail); + var pending = await _service.CompleteCountAsync(_actor, input); + pending.AwaitingWitness.Should().BeTrue(); Stock(item, location).Should().Be(5); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.AwaitingWitness); + await Fails(() => _service.WitnessAsync(_actor, input.RequestId, "Synthetic count verified"), "IndependentWitnessRequired", 409); + var completed = await Witness(input.RequestId); completed.AwaitingWitness.Should().BeFalse(); Stock(item, location).Should().Be(4); + var ledger = _store.All().Should().ContainSingle().Which; + ledger.CountItemId.Should().Be(detail.Lines.Single().Id); ledger.CreatedBy.Should().Be(_actor.UserId); + var content = JObject.Parse(ledger.Content); content.Value("PerformerId").Should().Be(_actor.UserId); + content.Value("WitnessUserId").Should().Be("witness"); + _events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.ControlledSubstanceRecorded); + _events.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.InventoryCountCompleted); + var eventCount = _events.Count; (await Witness(input.RequestId)).TransactionIds.Should().Equal(completed.TransactionIds); + (await _service.CompleteCountAsync(_actor, input)).TransactionIds.Should().Equal(completed.TransactionIds); + _events.Should().HaveCount(eventCount); _store.All().Should().ContainSingle(); + } + + [Test] + public async Task Controlled_count_witness_rechecks_snapshot_and_original_performer_access_before_posting() + { + var item = Item(controlled: true); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); var input = M5Completion(detail); + await _service.CompleteCountAsync(_actor, input); + _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == _actor.UserId && a.GrantToken == null), true, PermissionTypes.ManageControlledSubstances, null)) + .ThrowsAsync(new InventoryException(403, "PermissionRequired")); + await Fails(() => Witness(input.RequestId), "PermissionRequired", 403); + _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == _actor.UserId && a.GrantToken == null), true, PermissionTypes.ManageControlledSubstances, null)).Returns(Task.CompletedTask); + SeedStock(item, location, 6); + await Fails(() => Witness(input.RequestId), "CountSnapshotChanged", 409); + Stock(item, location).Should().Be(6); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + _store.All().Should().ContainSingle().Which.State.Should().Be(1); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.AwaitingWitness); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Cancelled_counts_cannot_post_or_complete_a_pending_witness_request(bool controlled) + { + var item = Item(controlled: controlled); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); var input = M5Completion(detail); + if (controlled) { await _service.CompleteCountAsync(_actor, input); detail = await _service.GetCountAsync(_actor, detail.Count.Id); } + await _service.CancelCountAsync(_actor, detail.Count.Id, detail.Count.Revision); + if (controlled) await Fails(() => Witness(input.RequestId), "CountStateConflict", 409); + else await Fails(() => _service.CompleteCountAsync(_actor, input), "CountStateConflict", 409); + Stock(item, location).Should().Be(5); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.Cancelled); + } + + [Test] + public async Task Assigned_serialized_assets_cannot_be_closed_by_a_count_variance() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); var asset = await CreateAsset(item, location); + await _service.IssueAsync(_actor, Issue(item, location, 1, unitId: 101, assetId: asset.Id)); + var issuance = _store.All().Single(); var holder = _store.All().Single(l => l.Id == issuance.LocationId); + var detail = await M5ObservedCount(holder, 0); var before = _store.All().Count(); + await Fails(() => _service.CompleteCountAsync(_actor, M5Completion(detail)), "ReturnAssetFirst", 409); + _store.All().Should().HaveCount(before); + _store.All().Single().Status.Should().Be((int)InventoryIssuanceStatus.Outstanding); + _store.All().Single().Status.Should().Be((int)InventoryAssetStatus.Issued); + await Fails(() => _service.SaveCountAsync(_actor, M5Observations(detail, 2)), "SerializedQuantity", 400); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Finished_count_evidence_and_completed_replays_survive_container_loss_with_current_holder_authorization(bool completed) + { + var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var location = Location(); var bag = await CreateAsset(bagItem, location); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); var item = Item(); + await _service.PostTransactionAsync(_actor, Receive(item, container, 5)); + var detail = await M5ObservedCount(container, 4); var input = M5Completion(detail); InventoryResult receipt = null; + if (completed) receipt = await _service.CompleteCountAsync(_actor, input); + else await _service.CancelCountAsync(_actor, detail.Count.Id, detail.Count.Revision); + await _service.ChangeAssetStatusAsync(_actor, Status(bagItem, bag, location, InventoryAssetStatus.Lost)); + var events = _events.Count; var saved = await _service.GetCountAsync(_actor, detail.Count.Id); + saved.Count.Status.Should().Be(completed ? (int)InventoryCountStatus.Completed : (int)InventoryCountStatus.Cancelled); + saved.Lines.Should().ContainSingle().Which.LocationId.Should().Be(container.Id); + if (completed) + { + var replay = await _service.CompleteCountAsync(_actor, input); replay.TransactionIds.Should().Equal(receipt.TransactionIds); + replay.OutboxIds.Should().Equal(receipt.OutboxIds); _events.Should().HaveCount(events); + } + _deniedLocations.Add(location.Id); + await Fails(() => _service.GetCountAsync(_actor, detail.Count.Id), "LocationUnavailable", 404); + if (completed) await Fails(() => _service.CompleteCountAsync(_actor, input), "LocationUnavailable", 404); + } + + [Test] + public async Task Department_counts_skip_retained_stock_and_assets_inside_terminal_containers() + { + var location = Location(); var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var bag = await CreateAsset(bagItem, location); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); + var hiddenSupplies = Item(); var available = Item(); var hiddenEquipment = Item(InventoryTrackingMode.Serialized); + await _service.PostTransactionAsync(_actor, Receive(hiddenSupplies, container, 2)); + await _service.PostTransactionAsync(_actor, Receive(available, location, 3)); + var containedAsset = await CreateAsset(hiddenEquipment, container, "contained-equipment"); + await _service.ChangeAssetStatusAsync(_actor, Status(bagItem, bag, location, InventoryAssetStatus.Retired)); + var detail = await _service.StartCountAsync(_actor, new InventoryCountInput { Id = Guid.NewGuid().ToString("D"), Name = "Synthetic department count" }); + detail.Count.LocationId.Should().BeNull(); detail.Lines.Should().ContainSingle().Which.ItemId.Should().Be(available.Id); + detail.Lines.Single().ExpectedQuantity.Should().Be(3); + Stock(hiddenSupplies, container).Should().Be(2, "terminal-holder evidence remains stored"); + _store.All().Single(a => a.Id == containedAsset.Id).Status.Should().Be((int)InventoryAssetStatus.InService); + } + + [TestCase(false)] + [TestCase(true)] + public async Task A_stale_draft_or_pending_count_can_be_cancelled_after_its_container_is_lost(bool awaitingWitness) + { + var location = Location(); var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var bag = await CreateAsset(bagItem, location); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); var item = Item(controlled: awaitingWitness); + SeedStock(item, container, 5); var detail = await M5ObservedCount(container, 4); var input = M5Completion(detail); + if (awaitingWitness) (await _service.CompleteCountAsync(_actor, input)).AwaitingWitness.Should().BeTrue(); + await _service.ChangeAssetStatusAsync(_actor, Status(bagItem, bag, location, InventoryAssetStatus.Lost)); + var current = await _service.GetCountAsync(_actor, detail.Count.Id); + if (!awaitingWitness) await Fails(() => _service.CompleteCountAsync(_actor, input), "CountSnapshotChanged", 409); + await _service.CancelCountAsync(_actor, current.Count.Id, current.Count.Revision); + (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.Cancelled); + if (awaitingWitness) await Fails(() => Witness(input.RequestId), "CountStateConflict", 409); + Stock(item, container).Should().Be(5); _store.All().Should().OnlyContain(t => t.CountItemId == null); + } + + [Test] + public async Task Losing_an_outer_container_refreshes_low_stock_for_nested_bulk_and_serialized_contents() + { + var location = Location(); var outerItem = Item(InventoryTrackingMode.Serialized, kit: true); var outer = await CreateAsset(outerItem, location, "outer-container"); + var outerLocation = _store.All().Single(l => l.ContainerAssetId == outer.Id); + var innerItem = Item(InventoryTrackingMode.Serialized, kit: true); var inner = await CreateAsset(innerItem, outerLocation, "inner-container"); + var innerLocation = _store.All().Single(l => l.ContainerAssetId == inner.Id); + var supplies = M5ThresholdItem(3); var equipment = M5ThresholdItem(0, InventoryTrackingMode.Serialized); + await _service.PostTransactionAsync(_actor, Receive(supplies, innerLocation, 5)); + var asset = await CreateAsset(equipment, innerLocation, "nested-equipment"); + _store.All().Should().BeEmpty(); + await _service.ChangeAssetStatusAsync(_actor, Status(outerItem, outer, location, InventoryAssetStatus.Lost)); + var alerts = _store.All().ToList(); alerts.Should().HaveCount(2).And.OnlyContain(a => a.Status == 0 && a.AlertType == (int)InventoryAlertType.LowStock && a.Quantity == 0); + alerts.Select(a => a.ItemId).Should().BeEquivalentTo(new[] { supplies.Id, equipment.Id }); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryLowStock).Should().Be(2); + Stock(supplies, innerLocation).Should().Be(5); _store.All().Single(a => a.Id == asset.Id).CurrentLocationId.Should().Be(innerLocation.Id); + } + + [Test] + public async Task Protected_count_summary_failure_rolls_back_ledger_stock_backlinks_and_outbox() + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); var audits = _audits.Count; + _write.Setup(x => x.PrepareRecordsEntityWriteAsync(Department, It.Is(c => c.Status == 2), 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.CompleteCountAsync(_actor, M5Completion(detail)), "ProtectedDataRequired", 403); + Stock(item, location).Should().Be(5); _store.All().Should().BeEmpty(); _store.All().Should().BeEmpty(); + _store.All().Single().TransactionId.Should().BeNull(); _events.Should().BeEmpty(); _audits.Should().HaveCount(audits); + var saved = await _service.GetCountAsync(_actor, detail.Count.Id); saved.Count.Revision.Should().Be(detail.Count.Revision); saved.Count.Status.Should().Be(0); + } + + [Test] + public async Task Count_ledger_entries_cannot_be_forged_or_reversed_through_generic_posting() + { + var item = Item(); var location = Location(); SeedStock(item, location, 5); + var detail = await M5ObservedCount(location, 4); + var forged = Move(item, location, null, 1, InventoryTransactionType.Count); forged.CountItemId = detail.Lines.Single().Id; + await Fails(() => _service.PostTransactionAsync(_actor, Command(forged)), "CountCompletionRequired", 400); + var result = await _service.CompleteCountAsync(_actor, M5Completion(detail)); + var reversal = Move(item, null, location, 1, InventoryTransactionType.Adjust); reversal.ReversesTransactionId = result.TransactionIds.Single(); + await Fails(() => _service.PostTransactionAsync(_actor, Command(reversal)), "CountReceiptImmutable", 409); + Stock(item, location).Should().Be(4); _store.All().Should().ContainSingle(); + } + + [Test] + public async Task Low_stock_alerts_deduplicate_update_resolve_and_reopen_with_new_history() + { + var item = M5ThresholdItem(3); var location = Location(); SeedStock(item, location, 3); + await _service.RefreshAlertsAsync(_actor); await _service.RefreshAlertsAsync(_actor); + var first = _store.All().Should().ContainSingle().Which; + first.AlertType.Should().Be((int)InventoryAlertType.LowStock); first.Quantity.Should().Be(3); first.Status.Should().Be(0); + SeedStock(item, location, 2); await _service.RefreshAlertsAsync(_actor); + _store.All().Should().ContainSingle().Which.Quantity.Should().Be(2); _events.Should().ContainSingle(); + SeedStock(item, location, 4); await _service.RefreshAlertsAsync(_actor); + _store.All().Single().Status.Should().Be(1); _store.All().Single().ResolvedOn.Should().Be(_clock.Utc); + SeedStock(item, location, 1); await _service.RefreshAlertsAsync(_actor); + var alerts = _store.All().ToList(); alerts.Should().HaveCount(2).And.OnlyContain(a => a.Content == null); + var reopened = alerts.Single(a => a.Status == 0); reopened.Id.Should().NotBe(first.Id); reopened.DedupKey.Should().Be(first.DedupKey); + _events.Should().HaveCount(2).And.OnlyContain(e => e.Trigger == WorkflowTriggerEventType.InventoryLowStock); + JsonConvert.SerializeObject(_events).Should().NotContain(Canary).And.NotContain("ReorderPoint").And.NotContain("UnitCost"); + } + + [TestCase(InventoryTrackingMode.Bulk)] + [TestCase(InventoryTrackingMode.Serialized)] + public async Task Department_low_stock_totals_are_hidden_when_a_contributing_unit_is_not_visible(InventoryTrackingMode tracking) + { + var item = M5ThresholdItem(3, tracking); var visible = Location(); var hidden = Location(InventoryLocationType.Unit, 101); + if (tracking == InventoryTrackingMode.Bulk) { SeedStock(item, visible, 1); SeedStock(item, hidden, 2); } + else { await CreateAsset(item, visible, "visible-equipment"); await CreateAsset(item, hidden, "hidden-equipment-1"); await CreateAsset(item, hidden, "hidden-equipment-2"); } + await _service.RefreshAlertsAsync(_actor); var alert = _store.All().Single(); alert.Quantity.Should().Be(3); + _deniedLocations.Add(hidden.Id); _read.Invocations.Clear(); + (await _service.QueryAsync(_actor, new InventoryQuery { ItemId = item.Id })).Items.Should().BeEmpty(); + await Fails(() => _service.GetAsync(_actor, alert.Id), "LocationUnavailable", 404); + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse(); + (await _service.ClaimAlertAsync(Department, "recipient")).Should().BeNull(); _store.All().Should().BeEmpty(); + _read.Invocations.Should().NotContain(i => i.Method.IsGenericMethod && i.Method.GetGenericArguments().Contains(typeof(InventoryAlert))); + _deniedLocations.Remove(hidden.Id); + (await _service.QueryAsync(_actor, new InventoryQuery { ItemId = item.Id })).Items.Should().ContainSingle().Which.Quantity.Should().Be(3); + } + + [Test] + public async Task Rebuilding_stock_from_ledger_resolves_a_stale_low_stock_alert_in_the_same_attended_operation() + { + var item = M5ThresholdItem(3); var location = Location(); await _service.PostTransactionAsync(_actor, Receive(item, location, 5)); + SeedStock(item, location, 1); await _service.RefreshAlertsAsync(_actor); + var alert = _store.All().Should().ContainSingle().Which; alert.Status.Should().Be(0); alert.Quantity.Should().Be(1); + await _service.RebuildStocksAsync(_actor); + Stock(item, location).Should().Be(5); _store.All().Should().ContainSingle(); + var resolved = _store.All().Single(); resolved.Id.Should().Be(alert.Id); resolved.Status.Should().Be(1); resolved.ResolvedOn.Should().Be(_clock.Utc); + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse(); + } + + [Test] + public async Task Alert_refresh_and_claim_are_not_limited_by_more_than_five_thousand_completed_deliveries() + { + var item = M5ThresholdItem(3); var location = Location(); SeedStock(item, location, 2); + await _service.RefreshAlertsAsync(_actor); var open = _store.All().Single(); + for (var i = 0; i < 5001; i++) + { + var historical = _store.Seed(new InventoryAlert { DepartmentId = Department, ItemId = item.Id, DedupKey = open.DedupKey, Status = 1, OpenedOn = _clock.Utc.AddDays(-1), ResolvedOn = _clock.Utc }); + _store.Seed(new InventoryAlertDelivery { DepartmentId = Department, AlertId = historical.Id, UserId = "recipient", State = 2, NextAttemptOn = _clock.Utc, HandedOffOn = _clock.Utc }); + } + await _service.RefreshAlertsAsync(_actor); _events.Should().ContainSingle(); + var claim = await _service.ClaimAlertAsync(Department, "recipient"); claim.AlertId.Should().Be(open.Id); claim.State.Should().Be(1); + await _service.FinishAlertAsync(Department, claim.Id, claim.ClaimToken, true); + (await _service.ClaimAlertAsync(Department, "recipient")).Should().BeNull(); + _store.All().Should().HaveCount(5002).And.OnlyContain(d => d.State == 2); + } + + [Test] + public async Task Unattended_alert_sweep_uses_only_dates_and_metadata_and_rolls_expiring_alerts_into_expired_history() + { + var item = M5ThresholdItem(100); var location = Location(); + var lot = _store.Seed(new InventoryLot { DepartmentId = Department, ItemId = item.Id, ExpiresOn = _clock.Utc.AddDays(1), ReceivedOn = _clock.Utc.AddDays(-1), IsProtected = true, Content = "SYNTHETIC-OPAQUE-LOT" }); + var stock = _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, LotId = lot.Id, Quantity = 2 }); + var assetItem = Item(InventoryTrackingMode.Serialized); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = assetItem.Id, CurrentLocationId = location.Id, ExpiresOn = _clock.Utc.AddDays(-1), Content = "SYNTHETIC-OPAQUE-ASSET", IsProtected = true }); + var issuance = _store.Seed(new InventoryIssuance { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, Quantity = 5, ReturnedQuantity = 2, Status = 2, + ExpectedReturnOn = _clock.Utc.AddHours(-1), Content = "SYNTHETIC-OPAQUE-ISSUANCE", IsProtected = true }); + _read.Invocations.Clear(); _write.Invocations.Clear(); _auth.Invocations.Clear(); + await _service.SweepAlertsAsync(Department); await _service.SweepAlertsAsync(Department); + var alerts = _store.All().ToList(); alerts.Should().HaveCount(3); + alerts.Select(a => a.AlertType).Should().BeEquivalentTo(new[] { 1, 2, 3 }); + alerts.Single(a => a.AlertType == 3).Quantity.Should().Be(3); + _read.Invocations.Should().BeEmpty(); _write.Invocations.Should().BeEmpty(); + _auth.Invocations.Should().OnlyContain(i => i.Method.Name == nameof(IInventoryAuthorizationService.IsEnabledAsync)); + _events.Should().HaveCount(3); var expiring = alerts.Single(a => a.LotId == lot.Id); + _clock.Advance(TimeSpan.FromDays(2)); issuance.ReturnedQuantity = issuance.Quantity; issuance.Status = 1; _store.Seed(issuance); + await _service.SweepAlertsAsync(Department); + alerts = _store.All().ToList(); alerts.Single(a => a.Id == expiring.Id).Status.Should().Be(1); + alerts.Should().ContainSingle(a => a.LotId == lot.Id && a.AlertType == 2 && a.Status == 0); + alerts.Single(a => a.IssuanceId == issuance.Id).Status.Should().Be(1); _events.Should().HaveCount(4); + stock.Quantity = 0; _store.Seed(stock); await _service.SweepAlertsAsync(Department); + _store.All().Where(a => a.LotId == lot.Id).Should().OnlyContain(a => a.Status == 1); + _store.All().Should().OnlyContain(a => a.Content == null); + JsonConvert.SerializeObject(_events).Should().NotContain("SYNTHETIC-OPAQUE").And.NotContain(Canary).And.NotContain("ReorderPoint"); + } + + [Test] + public async Task Alert_claims_recheck_access_retry_with_backoff_and_deduplicate_each_recipient() + { + var item = M5ThresholdItem(3); var location = Location(); SeedStock(item, location, 2); await _service.RefreshAlertsAsync(_actor); + var alert = _store.All().Single(); const string recipient = "inventory-recipient"; + M5RecipientPermission(recipient, false); + (await _service.CanReceiveAlertAsync(Department, recipient, alert.Id)).Should().BeFalse(); + (await _service.ClaimAlertAsync(Department, recipient)).Should().BeNull(); _store.All().Should().BeEmpty(); + M5RecipientPermission(recipient, true); _read.Invocations.Clear(); _write.Invocations.Clear(); + var first = await _service.ClaimAlertAsync(Department, recipient); first.State.Should().Be(1); first.AttemptCount.Should().Be(1); + first.LeaseUntil.Should().Be(_clock.Utc.AddMinutes(30)); first.Content.Should().BeNull(); + (await _service.ClaimAlertAsync(Department, recipient)).Should().BeNull(); + await _service.FinishAlertAsync(Department, first.Id, Guid.NewGuid().ToString("D"), true); + _store.All().Single().State.Should().Be(1); + await _service.FinishAlertAsync(Department, first.Id, first.ClaimToken, false); + var retry = _store.All().Single(); retry.State.Should().Be(0); retry.ClaimToken.Should().BeNull(); retry.LeaseUntil.Should().BeNull(); + retry.NextAttemptOn.Should().Be(_clock.Utc.AddMinutes(10)); (await _service.ClaimAlertAsync(Department, recipient)).Should().BeNull(); + _clock.Advance(TimeSpan.FromMinutes(10)); M5RecipientPermission(recipient, false); + (await _service.ClaimAlertAsync(Department, recipient)).Should().BeNull(); + M5RecipientPermission(recipient, true); var second = await _service.ClaimAlertAsync(Department, recipient); + second.Id.Should().Be(first.Id); second.ClaimToken.Should().NotBe(first.ClaimToken); second.AttemptCount.Should().Be(2); + M5RecipientPermission(recipient, false); (await _service.CanReceiveAlertAsync(Department, recipient, alert.Id)).Should().BeFalse(); + M5RecipientPermission(recipient, true); await _service.FinishAlertAsync(Department, second.Id, second.ClaimToken, true); + var delivered = _store.All().Single(); delivered.State.Should().Be(2); delivered.HandedOffOn.Should().Be(_clock.Utc); + (await _service.ClaimAlertAsync(Department, recipient)).Should().BeNull(); + var other = await _service.ClaimAlertAsync(Department, "other-recipient"); other.Id.Should().NotBe(first.Id); other.AlertId.Should().Be(alert.Id); + _read.Invocations.Should().BeEmpty(); _write.Invocations.Should().BeEmpty(); + _store.All().Should().HaveCount(2).And.OnlyContain(d => d.Content == null); + SeedStock(item, location, 4); await _service.RefreshAlertsAsync(_actor); + (await _service.CanReceiveAlertAsync(Department, "other-recipient", alert.Id)).Should().BeFalse(); + (await _service.CanReceiveAlertAsync(88, recipient, alert.Id)).Should().BeFalse(); + } + + [Test] + public async Task Expired_alert_claims_can_be_reclaimed_but_old_claim_tokens_cannot_finish_them() + { + M5ThresholdItem(1); await _service.RefreshAlertsAsync(_actor); + var first = await _service.ClaimAlertAsync(Department, "recipient"); _clock.Advance(TimeSpan.FromMinutes(31)); + await _service.FinishAlertAsync(Department, first.Id, first.ClaimToken, true); + _store.All().Single().State.Should().Be(1); + var reclaimed = await _service.ClaimAlertAsync(Department, "recipient"); reclaimed.Id.Should().Be(first.Id); + reclaimed.ClaimToken.Should().NotBe(first.ClaimToken); reclaimed.AttemptCount.Should().Be(2); + await _service.FinishAlertAsync(Department, first.Id, first.ClaimToken, true); + _store.All().Single().ClaimToken.Should().Be(reclaimed.ClaimToken); + await _service.FinishAlertAsync(Department, reclaimed.Id, reclaimed.ClaimToken, true); + _store.All().Single().State.Should().Be(2); + } + + [Test] + public async Task Dated_alert_delivery_rechecks_the_current_location_scope_without_reading_protected_content() + { + var item = Item(InventoryTrackingMode.Serialized); var location = Location(); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, CurrentLocationId = location.Id, ExpiresOn = _clock.Utc.AddDays(1), IsProtected = true, Content = "SYNTHETIC-OPAQUE-ASSET" }); + await _service.SweepAlertsAsync(Department); var alert = _store.All().Single(); + _deniedLocations.Add(location.Id); _read.Invocations.Clear(); + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse(); + (await _service.ClaimAlertAsync(Department, "recipient")).Should().BeNull(); + _deniedLocations.Remove(location.Id); var delivery = await _service.ClaimAlertAsync(Department, "recipient"); delivery.AlertId.Should().Be(alert.Id); + _deniedLocations.Add(location.Id); (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse(); + _read.Invocations.Should().BeEmpty(); + } + + private InventoryCountInput M5CountInput(InventoryLocation location) => new() { Id = Guid.NewGuid().ToString("D"), LocationId = location.Id, Name = "Synthetic stock count", Note = Canary }; + private static InventoryCountUpdate M5Observations(InventoryCountDetail detail, decimal quantity) => new() { CountId = detail.Count.Id, Revision = detail.Count.Revision, + Lines = detail.Lines.Select(l => new InventoryCountObservation { Id = l.Id, Quantity = quantity }).ToList() }; + private static InventoryCountComplete M5Completion(InventoryCountDetail detail) => new() { CountId = detail.Count.Id, Revision = detail.Count.Revision, RequestId = Guid.NewGuid().ToString("D") }; + private async Task M5ObservedCount(InventoryLocation location, decimal quantity) + { + var detail = await _service.StartCountAsync(_actor, M5CountInput(location)); + return await _service.SaveCountAsync(_actor, M5Observations(detail, quantity)); + } + private InventoryItem M5ThresholdItem(decimal threshold, InventoryTrackingMode tracking = InventoryTrackingMode.Bulk) + { + var item = Item(tracking); var content = JsonConvert.DeserializeObject(item.Content); content.ReorderPoint = threshold; + item.Content = JsonConvert.SerializeObject(content); return _store.Seed(item); + } + private void M5RecipientPermission(string recipient, bool allowed) + { + var setup = _auth.Setup(x => x.RequireAsync(It.Is(a => a.UserId == recipient && a.GrantToken == null), false, PermissionTypes.AdjustInventory, null)); + if (allowed) setup.Returns(Task.CompletedTask); else setup.ThrowsAsync(new InventoryException(403, "PermissionRequired")); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs index 7208db618..47a395d8d 100644 --- a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs @@ -22,7 +22,7 @@ namespace Resgrid.Tests.Services { [TestFixture] - public sealed class InventoryModernizationTests + public sealed partial class InventoryModernizationTests { private const int Department = 77; private const string Canary = "PII-PHI-CANARY inventory narrative"; @@ -47,6 +47,10 @@ public sealed class InventoryModernizationTests private Mock _legacyTypes; private Mock _workOrders; private Mock _workOrderAuth; + private Mock _recordsAuth; + private Mock _recordUsageAdapter; + private Mock _contacts; + private Dictionary _companyContacts; [SetUp] public void SetUp() @@ -87,9 +91,17 @@ public void SetUp() _legacyInventory.Setup(x => x.GetAllInventoriesByDepartmentIdAsync(Department)).ReturnsAsync(Array.Empty()); _legacyTypes.Setup(x => x.GetAllByDepartmentIdAsync(Department)).ReturnsAsync(Array.Empty()); _workOrders = new(); _workOrderAuth = new(); + _recordsAuth = new(); _recordUsageAdapter = new(); + _recordsAuth.Setup(x => x.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Department)).ReturnsAsync(true); + _recordsAuth.Setup(x => x.HasPermissionAsync(It.IsAny(), Department, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(true); + _recordUsageAdapter.Setup(x => x.RequireUsageCorrectionAccessAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + _contacts = new(); _companyContacts = new(); + _contacts.Setup(x => x.GetContactByIdAsync(It.IsAny())).ReturnsAsync((string id) => _companyContacts.TryGetValue(id, out var contact) ? Copy(contact) : null); + _contacts.Setup(x => x.GetAllContactsForDepartmentAsync(It.IsAny())).ReturnsAsync((int departmentId) => _companyContacts.Values.Where(c => c.DepartmentId == departmentId).Select(Copy).ToList()); _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)); + _workOrders.Object, new Lazy(() => _workOrderAuth.Object), + new Lazy(() => _recordsAuth.Object), new Lazy(() => _recordUsageAdapter.Object), _contacts.Object); } [Test] @@ -117,7 +129,7 @@ public async Task Expiration_tracking_cannot_change_after_an_item_has_ledger_his 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; + input.Id = item.Id; input.Revision = (await _service.GetAsync(_actor, item.Id)).Revision; input.RequiresExpiration = !initiallyRequired; await Fails(() => _service.SaveItemAsync(_actor, input), "ItemTrackingLocked", 409); (await _service.GetAsync(_actor, item.Id)).RequiresExpiration.Should().Be(initiallyRequired); } @@ -520,7 +532,7 @@ public async Task Catalog_cannot_change_tracking_after_posting_or_move_existing_ { 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; + input.Id = item.Id; input.Revision = (await _service.GetAsync(_actor, item.Id)).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); @@ -811,8 +823,19 @@ public T Seed(T row) where T : InventoryRow 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> AlertDepartmentsAsync(int afterDepartmentId) => Task.FromResult(All().Select(i => i.DepartmentId).Distinct().Where(i => i > afterDepartmentId).OrderBy(i => i).Take(100).ToList()); + public Task LastEntryAsync(int departmentId) => Task.FromResult(All().Where(t => t.DepartmentId == departmentId).Select(t => t.EntryId).DefaultIfEmpty().Max()); + public Task OpenAlertAsync(int departmentId, string dedupKey) => Task.FromResult(All().SingleOrDefault(a => a.DepartmentId == departmentId && a.DedupKey == dedupKey && a.Status == 0)); + public Task AlertDeliveryAsync(int departmentId, string alertId, string userId) => Task.FromResult(All().SingleOrDefault(d => d.DepartmentId == departmentId && d.AlertId == alertId && d.UserId == userId)); + public Task> OpenAlertsAsync(int departmentId, int skip = 0) => Task.FromResult(All().Where(a => a.DepartmentId == departmentId && a.Status == 0).OrderBy(a => a.OpenedOn).ThenBy(a => a.Id).Skip(skip).Take(501).ToList()); 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> StockQuantitiesAsync(int departmentId, IReadOnlyCollection itemIds) => Task.FromResult(All() + .Where(s => s.DepartmentId == departmentId && !s.IsDeleted && itemIds.Contains(s.ItemId)).GroupBy(s => new { s.ItemId, s.LocationId }) + .Select(g => new InventoryStockQuantity { ItemId = g.Key.ItemId, LocationId = g.Key.LocationId, Quantity = g.Sum(s => s.Quantity) }).ToList()); + public Task> AssetHistoryAsync(int departmentId, IReadOnlyCollection assetIds, DateTime at, bool after, int skip = 0) => Task.FromResult(All() + .Where(t => t.DepartmentId == departmentId && assetIds.Contains(t.AssetId) && (after ? t.OccurredOn > at : t.OccurredOn <= at)) + .OrderBy(t => t.OccurredOn).ThenBy(t => t.EntryId).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)); @@ -831,7 +854,7 @@ public Task InsertAsync(T row) where T : InventoryRow } public Task UpdateAsync(T row, int expectedRevision) where T : InventoryRow { - RequireTransaction(); if (row is InventoryTransaction or InventoryTransferItem) throw new InvalidOperationException("Ledger identities are immutable."); + RequireTransaction(); if (row is InventoryTransaction or InventoryTransferItem or RecordInventoryUsage) 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; diff --git a/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs b/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs new file mode 100644 index 000000000..d6667dc77 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + [Test] + public async Task Historical_departure_follows_a_bag_entered_after_the_call_on_the_same_unit() + { + var item = Item(InventoryTrackingMode.Serialized); var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); + var first = Location(InventoryLocationType.Unit, 101); var second = Location(InventoryLocationType.Unit, 102); + var asset = await CreateAsset(item, first); var bag = await CreateAsset(bagItem, first, "bag"); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); + _clock.Advance(TimeSpan.FromMinutes(10)); var call = _clock.Utc; + _clock.Advance(TimeSpan.FromMinutes(10)); + await _service.CreateAndCompleteTransferAsync(_actor, Command(AssetMove(item, asset, first, container))); + _clock.Advance(TimeSpan.FromMinutes(10)); + await _service.CreateAndCompleteTransferAsync(_actor, Command(AssetMove(bagItem, bag, first, second))); + var snapshot = (await _service.AtCallAsync(ChecklistActor(), 25, call, new[] { 101 }, false)).Single(a => a.AssetId == asset.Id); + snapshot.ReturnedUtc.Should().Be(_clock.Utc); + } + + [Test] + public async Task Transfer_item_listing_rechecks_both_parent_holders_before_returning_items() + { + var item = Item(); var first = Location(); var second = Location(); + await _service.PostTransactionAsync(_actor, Receive(item, first, 5)); + await _service.CreateAndCompleteTransferAsync(_actor, Command(new InventoryPosting { ItemId = item.Id, FromLocationId = first.Id, ToLocationId = second.Id, Quantity = 1, Type = InventoryTransactionType.Transfer })); + (await _service.ListAsync(_actor)).Items.Should().ContainSingle(); + _deniedLocations.Add(second.Id); + (await _service.ListAsync(_actor)).Items.Should().BeEmpty(); + } + + [Test] + public async Task Visible_totals_preserve_decimals_and_exclude_deleted_foreign_and_inaccessible_stock() + { + var item = Item(); var empty = Item(); var location = Location(); var denied = Location(); + _deniedLocations.Add(denied.Id); + _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, Quantity = 1.125m }); + _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, LotId = Guid.NewGuid().ToString("D"), Quantity = 2m }); + _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = denied.Id, Quantity = 100m }); + _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = item.Id, LocationId = location.Id, Quantity = 100m, IsDeleted = true }); + _store.Seed(new InventoryStock { DepartmentId = 999, ItemId = item.Id, LocationId = location.Id, Quantity = 100m }); + _store.Seed(new InventoryStock { DepartmentId = Department, ItemId = Item().Id, LocationId = location.Id, Quantity = 100m }); + var totals = await _service.GetVisibleQuantitiesAsync(_actor, new[] { item.Id, empty.Id }); + totals.Should().HaveCount(2); totals[item.Id].Should().Be(3.125m); totals[empty.Id].Should().Be(0m); + _auth.Verify(a => a.CanLocationAsync(_actor, It.Is(l => l.Id == location.Id)), Times.Once); + } + + [Test] + public async Task Visible_totals_exclude_stock_inside_a_terminal_container() + { + var item = Item(); var bagItem = Item(InventoryTrackingMode.Serialized, kit: true); var location = Location(); + var bag = await CreateAsset(bagItem, location, "bag"); + var container = _store.All().Single(l => l.ContainerAssetId == bag.Id); + await _service.PostTransactionAsync(_actor, Receive(item, container, 5)); + (await _service.GetVisibleQuantitiesAsync(_actor, new[] { item.Id }))[item.Id].Should().Be(5); + await _service.ChangeAssetStatusAsync(_actor, Status(bagItem, bag, location, InventoryAssetStatus.Lost)); + (await _service.GetVisibleQuantitiesAsync(_actor, new[] { item.Id }))[item.Id].Should().Be(0); + } + + [Test] + public async Task Legacy_lookup_preserves_location_authorization_and_attended_protection() + { + var item = Item(); var location = Location(); + var row = _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, ToLocationId = location.Id, LegacyInventoryId = 42, Content = "{}" }); + (await _service.GetLegacyTransactionAsync(_actor, 42)).Id.Should().Be(row.Id); + _read.Invocations.Count.Should().BeGreaterThan(0); + (await _service.GetLegacyTransactionAsync(_actor, 43)).Should().BeNull(); + _deniedLocations.Add(location.Id); + (await _service.GetLegacyTransactionAsync(_actor, 42)).Should().BeNull(); + } + } + + public partial class InventoryDatabaseTests + { + [Test] + public async Task Stock_aggregation_scopes_items_and_department_and_sums_lots_before_holder_authorization() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); + var lot = NewRow(); lot.ItemId = seed.Item.Id; lot.ReceivedOn = DateTime.UtcNow; + await WriteAsync(async store => + { + await store.InsertAsync(lot); + await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, null, 1.125m, "inventory-test-author"); + await store.ApplyStockDeltaAsync(77, seed.Item.Id, seed.Location.Id, lot.Id, 2m, "inventory-test-author"); + }); + await WriteAsync(async store => { await store.ApplyStockDeltaAsync(88, foreign.Item.Id, foreign.Location.Id, null, 100m, "inventory-test-author"); }, 88); + using var uow = new Resgrid.Repositories.DataRepository.Transactions.UnitOfWork(Connections()); var repository = Store(uow); + var totals = await repository.StockQuantitiesAsync(77, new[] { seed.Item.Id, foreign.Item.Id }); + totals.Should().ContainSingle(); totals[0].Quantity.Should().Be(3.125m); totals[0].LocationId.Should().Be(seed.Location.Id); + (await repository.StockQuantitiesAsync(77, Array.Empty())).Should().BeEmpty(); + } + + [Test] + public async Task Asset_history_queries_preserve_the_call_boundary_and_filter_assets_before_paging() + { + var seed = await SeedAsync(); var asset = NewRow(); asset.ItemId = seed.Item.Id; asset.CurrentLocationId = seed.Location.Id; + var at = new DateTime(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc); + InventoryTransaction Movement(DateTime when, string id) { var row = NewRow(); row.ItemId = seed.Item.Id; row.AssetId = id; row.OccurredOn = when; row.Quantity = 1; row.TransactionType = 1; return row; } + var before = Movement(at.AddDays(-1), asset.Id); var boundary = Movement(at, asset.Id); var after = Movement(at.AddDays(1), asset.Id); + await WriteAsync(async store => { await store.InsertAsync(asset); await store.InsertAsync(before); await store.InsertAsync(boundary); await store.InsertAsync(after); await store.InsertAsync(Movement(at, null)); }); + using var uow = new Resgrid.Repositories.DataRepository.Transactions.UnitOfWork(Connections()); var repository = Store(uow); + (await repository.AssetHistoryAsync(77, new[] { asset.Id }, at, false)).Select(t => t.Id).Should().Equal(before.Id, boundary.Id); + (await repository.AssetHistoryAsync(77, new[] { asset.Id }, at, true)).Select(t => t.Id).Should().Equal(after.Id); + (await repository.AssetHistoryAsync(88, new[] { asset.Id }, at, false)).Should().BeEmpty(); + } + } + + [TestFixture] + public class InventoryDependencyTests + { + [Test] + public void Every_registered_inventory_trigger_has_a_label_in_each_supported_locale() + { + var strings = new System.Resources.ResourceManager("Resgrid.Localization.Areas.User.Inventory.Inventory", typeof(Resgrid.Localization.SupportedLocales).Assembly); + foreach (var language in Resgrid.Localization.SupportedLocales.GetSupportedCultures()) + { + var resources = strings.GetResourceSet(System.Globalization.CultureInfo.GetCultureInfo(language), true, false); + resources.Should().NotBeNull(language); + foreach (var key in InventoryWorkflowPayload.Triggers.Select(t => ((Resgrid.Model.WorkflowTriggerEventType)t).ToString()).Append("HolderHistoryRetained")) + resources.GetString(key).Should().NotBeNullOrWhiteSpace(key + " in " + language).And.NotBe(key); + } + } + + [TestCase(false, false), TestCase(true, true), TestCase(true, false), TestCase(false, true)] + public void Unit_retention_dependencies_are_accepted_only_as_a_pair(bool hasStore, bool hasUnit) + { + Action construct = () => new UnitsService(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + hasStore ? Mock.Of() : null, hasUnit ? Mock.Of() : null); + if (hasStore == hasUnit) construct.Should().NotThrow(); + else construct.Should().Throw(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs index e968bf6ce..ff5dea56c 100644 --- a/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryWorkflowTests.cs @@ -35,6 +35,9 @@ public sealed class InventoryWorkflowTests 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 const string PurchaseOrderId = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + private const string VendorId = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + private const string ReceiptId = "ffffffff-ffff-ffff-ffff-ffffffffffff"; private FakeRmsStore _store; private EventAggregator _bus; private Mock _policy; @@ -79,6 +82,52 @@ public async Task All_inventory_events_render_safe_quantities_ids_and_metadata_a Template.Parse("{{ inventory.quantity + 1 }}|{{ event.name }}|{{ protection.is_redacted }}").Render(sample).Should().EndWith("|" + (WorkflowTriggerEventType)trigger + "|true"); } + [Test] + public async Task Record_usage_routing_survives_projection_and_has_a_renderable_preview() + { + const string usageId = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + var wrapped = Wrapped(22); wrapped["Payload"]["UsageId"] = usageId; + wrapped["Payload"]["UsageType"] = (int)InventoryUsageType.Used; + var safe = await InventoryWorkflowPayload.ProjectAsync(42, wrapped, _projection, true); + var context = await Builder().BuildContextAsync(42, WorkflowTriggerEventType.InventoryAdjusted, safe, CancellationToken.None); + var template = Template.Parse("{{ inventory.usage_id }}|{{ inventory.usage_type }}"); + template.Render(context).Should().Be(usageId + "|0"); + template.Render(WorkflowSampleDataGenerator.GenerateSampleData(WorkflowTriggerEventType.InventoryAdjusted)).Should().Be(usageId + "|0"); + } + + [Test] + public async Task Purchase_receipt_summary_projects_routing_and_preview_without_costs_or_contact_details() + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var projected = await ChecklistWorkflowPayload.ProjectAsync(42, Wrapped(65), _projection, true); + projected.Should().NotContain(Canary).And.NotContain("12345.678901").And.NotContain("98765.432109").And.NotContain("rgdp:"); + var envelope = JObject.Parse(projected); envelope.Value("AggregateId").Should().Be(PurchaseOrderId); + envelope.Value("AggregateType").Should().Be("InventoryPurchaseOrder"); envelope.Value("CorrelationId").Should().Be(CorrelationId); + var payload = (JObject)envelope["Payload"]; + foreach (var property in new[] { "UnitCost", "TotalCost", "ContactId", "CompanyContactId", "SupplierName", "AccountNumber", "Email", "PurchaseOrderNumber" }) + payload.Property(property).Should().BeNull(property + " is outside the Workflow routing contract"); + payload.Property("Quantity").Should().BeNull("a purchase receipt summary does not add quantities with different units"); + var context = (ScriptObject)await Builder().BuildContextAsync(42, WorkflowTriggerEventType.InventoryPurchaseOrderReceived, projected, CancellationToken.None); + var sample = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(WorkflowTriggerEventType.InventoryPurchaseOrderReceived); + var template = Template.Parse("{{ inventory.purchase_order_id }}|{{ inventory.vendor_id }}|{{ inventory.receipt_id }}|{{ inventory.purchase_order_status }}|{{ inventory.line_count + 1 }}|{{ inventory.currency_code }}|{{ event.name }}|{{ protection.is_redacted }}"); + template.HasErrors.Should().BeFalse(); + var expected = PurchaseOrderId + "|" + VendorId + "|" + ReceiptId + "|2|3|USD|InventoryPurchaseOrderReceived|true"; + template.Render(context).Should().Be(expected); template.Render(sample).Should().Be(expected); + foreach (var root in new[] { context, sample }) + { + var inventory = (ScriptObject)root["inventory"]; inventory["quantity"].Should().BeNull(); inventory["transaction_id"].Should().BeNull(); + foreach (var property in new[] { "unit_cost", "total_cost", "contact_id", "company_contact_id", "supplier_name", "account_number", "email", "purchase_order_number" }) + inventory.ContainsKey(property).Should().BeFalse(); + foreach (var descriptor in WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.InventoryPurchaseOrderReceived) + .Where(d => d.Name.StartsWith("inventory.") || d.Name.StartsWith("event.") || d.Name.StartsWith("protection."))) + { + var path = descriptor.Name.Split('.'); ((ScriptObject)root[path[0]]).ContainsKey(path[1]).Should().BeTrue("runtime and preview advertise " + descriptor.Name); + } + } + ((ScriptObject)context["event"])["sequence"].Should().Be(7L); + ((ScriptObject)context["event"])["is_replay"].Should().Be(true); + } + [Test] public async Task Large_stock_balances_preserve_all_six_fractional_digits_through_projection_and_template_arithmetic() { @@ -95,33 +144,60 @@ public async Task Large_stock_balances_preserve_all_six_fractional_digits_throug 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; + payload["ReferenceId"] = "person-" + Canary; payload["OccurredOn"] = Canary; payload["UsageId"] = Canary; payload["UsageType"] = 4; + foreach (var property in new[] { "PurchaseOrderId", "PurchaseOrderItemId", "VendorId", "ReceiptId" }) payload[property] = Canary; + payload["PurchaseOrderStatus"] = 5; payload["LineCount"] = 101; payload["CurrencyCode"] = "U$D"; 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["UnitCost"] = 12345.678901m; returned["TotalCost"] = 98765.432109m; returned["ContactId"] = SourceId; returned["SupplierName"] = 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"); + foreach (var name in new[] { "Quantity", "ItemId", "OldStatus", "ReferenceId", "OccurredOn", "UserId", "UsageId", "UsageType" }) safe.Property(name).Should().BeNull(name + " is not a valid routing value"); + foreach (var name in new[] { "PurchaseOrderId", "PurchaseOrderItemId", "VendorId", "ReceiptId", "PurchaseOrderStatus", "LineCount", "CurrencyCode", "UnitCost", "TotalCost", "ContactId", "SupplierName" }) + safe.Property(name).Should().BeNull(name + " must not escape the allowlist"); 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() + [TestCase("PurchaseOrderId", "123"), TestCase("PurchaseOrderItemId", "{}"), TestCase("VendorId", "[]"), TestCase("ReceiptId", "true")] + [TestCase("PurchaseOrderStatus", "\"2\""), TestCase("PurchaseOrderStatus", "2.5"), TestCase("PurchaseOrderStatus", "-1")] + [TestCase("LineCount", "\"2\""), TestCase("LineCount", "1.5"), TestCase("LineCount", "-1")] + [TestCase("CurrencyCode", "123"), TestCase("CurrencyCode", "\"usd\""), TestCase("CurrencyCode", "\"USDD\"")] + public async Task Purchase_routing_rejects_invalid_scalar_types_and_formats(string property, string invalidJson) + { + var payload = PurchasePayload(); payload[property] = JToken.Parse(invalidJson); + var safe = JObject.Parse(await InventoryWorkflowPayload.ProjectAsync(42, payload, _projection)); + safe.Property(property).Should().BeNull(property + " cannot be coerced into a routing value"); + safe.ToString().Should().NotContain(Canary); + } + + [TestCase(22), TestCase(65)] + public async Task Reprojection_after_enrollment_or_deactivation_never_restores_authored_content(int trigger) { - var safe = await InventoryWorkflowPayload.ProjectAsync(42, Wrapped(22), _projection, true); + var safe = await InventoryWorkflowPayload.ProjectAsync(42, Wrapped(trigger), _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"); + payload.Value("ItemName").Should().Be("REDACTED"); + var context = await Builder().BuildContextAsync(42, (WorkflowTriggerEventType)trigger, safe, CancellationToken.None); + if (trigger == 65) + { + payload.Property("UnitCost").Should().BeNull(); payload.Property("ContactId").Should().BeNull(); + Template.Parse("{{ inventory.purchase_order_id }}|{{ inventory.receipt_id }}|{{ inventory.purchase_order_status }}|{{ inventory.line_count }}|{{ inventory.currency_code }}").Render(context) + .Should().Be(PurchaseOrderId + "|" + ReceiptId + "|2|2|USD"); + } + else + { + payload.Value("Quantity").Should().Be(2.125001m); + 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] @@ -134,33 +210,45 @@ public async Task Legacy_adjustment_still_renders_original_legacy_aliases() .Should().Be("15|10.25|12.5|Synthetic gloves|pair"); } - [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(66)] + [TestCase(61), TestCase(62), TestCase(63), TestCase(166)] + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(65), 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"); + var routingId = trigger == 65 ? ReceiptId : TransactionId; + entry.ReadinessRoutingJson.Should().NotContain(Canary).And.Contain(routingId); + var history = _history.Decrypt(42, "domaineventoutbox.payloadjson", entry.DomainEventOutboxId.ToString(), entry.PayloadJson); + history.Should().NotContain(Canary).And.Contain(routingId).And.NotContain("12345.678901").And.NotContain("98765.432109"); + if (trigger != 65) history.Should().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); + delivered.ProducerSubsystem.Should().Be("Inventory"); delivered.AggregateId.Should().Be(trigger == 65 ? PurchaseOrderId : AssetId); delivered.CorrelationId.Should().Be(CorrelationId); + delivered.PayloadJson.Should().NotContain(Canary).And.NotContain("rgdp:"); + var deliveredPayload = JObject.Parse(delivered.PayloadJson); + if (trigger == 65) + { + deliveredPayload.Value("PurchaseOrderStatus").Should().Be(2); deliveredPayload.Value("LineCount").Should().Be(2); + deliveredPayload.Value("CurrencyCode").Should().Be("USD"); deliveredPayload.Property("Quantity").Should().BeNull(); + } + else deliveredPayload.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"); + Template.Parse("{{ event.id }}|{{ inventory." + (trigger == 65 ? "receipt_id" : "transaction_id") + " }}|{{ inventory.item_name }}").Render(context).Should().Be(entry.EventId + "|" + routingId + "|REDACTED"); _history.Broker.Invocations.Should().OnlyContain(i => i.Method.Name == "EncryptAsync"); } - [Test] - public async Task Unknown_protection_policy_prevents_inventory_outbox_initialization() + [TestCase(22), TestCase(65)] + public async Task Unknown_protection_policy_prevents_inventory_outbox_initialization(int trigger) { _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ThrowsAsync(new InvalidOperationException("policy unavailable")); - await ((Func)(() => _outbox.EnqueueAsync(42, "Inventory", Event(22)))).Should().ThrowAsync(); + await ((Func)(() => _outbox.EnqueueAsync(42, "Inventory", Event(trigger)))).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)] + [TestCase(61), TestCase(62), TestCase(63), TestCase(166)] + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(65), 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(); @@ -178,11 +266,12 @@ public async Task Queue_rejection_retries_existing_inventory_workflow_run_with_s (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:"); + queued.Value("AggregateId").Should().Be(trigger == 65 ? PurchaseOrderId : 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)] + [TestCase(61), TestCase(62), TestCase(63), TestCase(166)] + [TestCase(22), TestCase(58), TestCase(59), TestCase(60), TestCase(64), TestCase(65), TestCase(66)] public async Task Duplicate_consumer_claim_does_not_execute_completed_inventory_run(int trigger) { _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); @@ -197,21 +286,47 @@ public async Task Duplicate_consumer_claim_does_not_execute_completed_inventory_ 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); + claimed.Should().StartWith("rgdp:"); _history.Decrypt(42, "workflowruns.inputpayload", run.WorkflowRunId, claimed).Should().NotContain(Canary).And.Contain(trigger == 65 ? ReceiptId : TransactionId); context.Verify(s => s.BuildContextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [TestCase(61), TestCase(62), TestCase(63), TestCase(166)] + public async Task Count_and_alert_summaries_have_safe_routing_and_template_previews(int trigger) + { + _policy.Setup(s => s.IsProtectionEnforcedAsync(42)).ReturnsAsync(true); + var wrapped = Wrapped(trigger); wrapped["AggregateType"] = trigger == 63 ? "InventoryCount" : "InventoryAlert"; + wrapped["Payload"] = JObject.FromObject(new { InventoryEvent = true, CountId = PurchaseOrderId, CountItemId = ReceiptId, AlertId = VendorId, LocationId = SourceId, + AlertType = trigger == 61 ? 0 : trigger == 62 ? 1 : 3, LineCount = 12, VarianceLineCount = 2, VarianceValue = 12345.678901m, + ReorderPoint = 98765.432109m, DueOn = new DateTime(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc), ItemName = Canary }); + var safe = await ChecklistWorkflowPayload.ProjectAsync(42, wrapped, _projection, true); + safe.Should().NotContain(Canary).And.NotContain("12345.678901").And.NotContain("98765.432109"); + var context = (ScriptObject)await Builder().BuildContextAsync(42, (WorkflowTriggerEventType)trigger, safe, CancellationToken.None); + Template.Parse("{{ inventory.count_id }}|{{ inventory.count_item_id }}|{{ inventory.alert_id }}|{{ inventory.variance_line_count + 1 }}|{{ inventory.variance_value }}") + .Render(context).Should().Be(PurchaseOrderId + "|" + ReceiptId + "|" + VendorId + "|3|REDACTED"); + var sample = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData((WorkflowTriggerEventType)trigger); + var inventory = (ScriptObject)sample["inventory"]; + foreach (var pair in InventoryWorkflowPayload.Variables) inventory.ContainsKey(pair.Variable).Should().BeTrue(); + inventory[trigger == 63 ? "count_id" : "alert_id"].Should().NotBeNull(); + if (trigger == 63) Template.Parse("{{ inventory.variance_line_count + 1 }}|{{ inventory.variance_value }}").Render(sample).Should().Be("3|REDACTED"); + } + 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 PurchasePayload() => JObject.FromObject(new { InventoryEvent = true, PurchaseOrderId, VendorId, ReceiptId, + PurchaseOrderStatus = (int)InventoryPurchaseOrderStatus.PartiallyReceived, LineCount = 2, CurrencyCode = "USD", + OccurredOn = new DateTime(2026, 9, 9, 8, 0, 0, DateTimeKind.Utc), UnitCost = 12345.678901m, TotalCost = 98765.432109m, + ContactId = SourceId, CompanyContactId = DestinationId, SupplierName = Canary, AccountNumber = Canary, Email = Canary, + PurchaseOrderNumber = Canary, Note = 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, + ["EventName"] = ((WorkflowTriggerEventType)trigger).ToString(), ["TriggerEventType"] = trigger, ["SchemaVersion"] = 1, ["AggregateType"] = trigger == 65 ? "InventoryPurchaseOrder" : "InventoryAsset", ["AggregateId"] = trigger == 65 ? PurchaseOrderId : AssetId, ["CorrelationId"] = CorrelationId, ["Sequence"] = 7L, ["IsReplay"] = true, ["OriginClient"] = "Api", ["OccurredOn"] = new DateTime(2026, 9, 9, 8, 0, 0, DateTimeKind.Utc), - ["Payload"] = Payload(), ["UnreviewedEnvelopeText"] = Canary }; + ["Payload"] = trigger == 65 ? PurchasePayload() : 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() }; + AggregateType = trigger == 65 ? "InventoryPurchaseOrder" : "InventoryAsset", AggregateId = trigger == 65 ? PurchaseOrderId : AssetId, + Trigger = (WorkflowTriggerEventType)trigger, CorrelationId = CorrelationId, Payload = trigger == 65 ? PurchasePayload() : Payload() }; } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 457146d1a..d01810ca6 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -358,6 +358,14 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude // Checklist content and outcome companions use ChecklistTables.Fields() and the generic resolver. "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings", "ChecklistSchedules", + // Work-order content uses WorkOrderTables.Fields(); file downloads use the binary resolver. + "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles", + // Inventory content uses InventoryTables.Fields() through the generic protected resolver. + "InventoryCategories", "InventoryItems", "InventoryLocations", "InventoryLots", "InventoryStocks", + "InventoryAssets", "InventoryTransactions", "InventoryOperations", "InventoryTransfers", "InventoryTransferItems", + "InventoryIssuances", "InventoryKits", "InventoryKitItems", "RecordInventoryUsages", + "InventoryVendors", "InventoryPurchaseOrders", "InventoryPurchaseOrderItems", + "InventoryCounts", "InventoryCountItems", "InventoryAlerts", "InventoryAlertDeliveries", // ReadinessHistoryFields supplies masked audit/run/log views; outbox consumers use the safe routing copy. "AuditLogs", "DomainEventOutbox", "WorkflowRuns", "WorkflowRunLogs" }; diff --git a/Tests/Resgrid.Tests/Services/RecordInventoryM3HttpTests.cs b/Tests/Resgrid.Tests/Services/RecordInventoryM3HttpTests.cs new file mode 100644 index 000000000..4c920ead5 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/RecordInventoryM3HttpTests.cs @@ -0,0 +1,278 @@ +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; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using ApiClaims = Resgrid.Web.ServicesCore.Helpers.ClaimsAuthorizationHelper; + +namespace Resgrid.Tests.Services +{ + /// Actual authenticated HTTP binding and response handling around the Records/Inventory domain boundary. + [TestFixture, NonParallelizable] + public sealed class RecordInventoryM3HttpTests + { + private const int Department = 77; + private const string Route = "/api/v4/RecordInventory/"; + private const string RecordId = "11111111-1111-1111-1111-111111111111"; + private const string ItemId = "22222222-2222-2222-2222-222222222222"; + private const string UsageId = "33333333-3333-3333-3333-333333333333"; + private const string TransactionId = "44444444-4444-4444-4444-444444444444"; + private const string LocationId = "55555555-5555-5555-5555-555555555555"; + private const string Grant = "synthetic-current-inventory-grant"; + private const string Canary = "SYNTHETIC-RECORD-INVENTORY-PHI-CANARY"; + private Mock _usage; + private Mock _evidence; + private Mock _authorization; + private Mock _cutover; + private List<(InventoryActor Actor, string RecordId, RmsRecordKind Kind, long Version, RecordInventoryUsageRequest Request)> _posted; + private List<(InventoryActor Actor, string RecordId, RmsRecordKind Kind, long Version, RecordInventoryUsageCorrection Correction)> _reversed; + + [SetUp] + public void SetUp() + { + _usage = new(); _evidence = new(); _authorization = new(); _cutover = new(); _posted = new(); _reversed = new(); + _cutover.Setup(x => x.GetModuleStateAsync(Department, false)).ReturnsAsync(new RecordsModuleState { FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active }); + _authorization.Setup(x => x.CanUserViewRecordAsync("officer", RecordId, Department)).ReturnsAsync(true); + _authorization.Setup(x => x.HasPermissionAsync("officer", Department, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(true); + _authorization.Setup(x => x.CanUseSourceInventoryAsync("officer", Department, null)).ReturnsAsync(true); + _usage.Setup(x => x.RecordModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, string record, RmsRecordKind kind, long version, RecordInventoryUsageRequest request, CancellationToken ct) => + { + _posted.Add((Copy(actor), record, kind, version, Copy(request))); + return new List { Usage() }; + }); + _usage.Setup(x => x.ReverseModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((InventoryActor actor, string record, RmsRecordKind kind, long version, RecordInventoryUsageCorrection correction, CancellationToken ct) => + { + _reversed.Add((Copy(actor), record, kind, version, Copy(correction))); + return Usage(true); + }); + _usage.Setup(x => x.GetAuthorizedUsageAsync(It.IsAny(), RecordId, It.IsAny())).ReturnsAsync(new List { Usage(), Usage(true) }); + _evidence.Setup(x => x.CaptureAsync(It.IsAny(), true, It.IsAny())).ReturnsAsync(new RmsEvidenceArtifact { RmsEvidenceArtifactId = "synthetic-evidence" }); + } + + [Test] + public async Task Routes_require_authentication_and_the_Record_create_policy_before_domain_calls() + { + await WithServer(async client => + { + foreach (var action in new[] { "RecordUsage", "ReverseUsage" }) + (await client.PostAsync(Route + action, Json(Input(action)))).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SignIn(client, canCreate: false); + foreach (var action in new[] { "RecordUsage", "ReverseUsage" }) + (await client.PostAsync(Route + action, Json(Input(action)))).StatusCode.Should().Be(HttpStatusCode.Forbidden); + _usage.Invocations.Should().BeEmpty(); _evidence.Invocations.Should().BeEmpty(); + }); + } + + [Test] + public async Task Typed_usage_takes_actor_and_grant_from_authenticated_context_and_preserves_client_request_identity() + { + await WithServer(async client => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, Grant); + var requestId = Guid.NewGuid().ToString("D"); + var response = await client.PostAsync(Route + "RecordUsage?departmentId=88&userId=forged", Json(new + { + RecordId, Kind = (int)RmsRecordKind.IncidentReport, ExpectedRowVersion = 9, DepartmentId = 88, UserId = "forged", GrantToken = "forged-grant", + Actor = new { DepartmentId = 88, UserId = "forged", GrantToken = "forged-grant" }, + Request = new { RequestId = requestId, Lines = new[] { new { ItemId, LocationId, Quantity = 2.125001m, UsageType = (int)InventoryUsageType.LeftAtScene, Note = Canary } } } + })); + var json = await Created(response); var posting = _posted.Single(); + posting.Actor.Should().BeEquivalentTo(new InventoryActor { DepartmentId = Department, UserId = "officer", GrantToken = Grant }); + posting.RecordId.Should().Be(RecordId); posting.Kind.Should().Be(RmsRecordKind.IncidentReport); posting.Version.Should().Be(9); + posting.Request.RequestId.Should().Be(requestId); var line = posting.Request.Lines.Single(); + line.ItemId.Should().Be(ItemId); line.LocationId.Should().Be(LocationId); line.Quantity.Should().Be(2.125001m); line.UsageType.Should().Be(InventoryUsageType.LeftAtScene); line.Note.Should().Be(Canary); + json["usage"].Single().Value("UsageId").Should().Be(UsageId); + json.Value("evidenceCaptureRequired").Should().BeFalse(); + _evidence.Verify(x => x.CaptureAsync(It.Is(r => r.DepartmentId == Department && r.RecordId == RecordId && r.RecordKind == RmsRecordKind.IncidentReport && r.CapturedByUserId == "officer" && r.Kind == RmsEvidenceKind.InventoryUsage && r.OriginClient == RmsOriginClient.Api), true, It.IsAny()), Times.Once); + (await response.Content.ReadAsStringAsync()).Should().NotContain(Grant).And.NotContain("forged-grant").And.NotContain(Canary); + }); + } + + [Test] + public async Task Correction_carries_witnessed_transaction_and_reason_and_returns_signed_usage() + { + await WithServer(async client => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, Grant); + var requestId = Guid.NewGuid().ToString("D"); + var response = await client.PostAsync(Route + "ReverseUsage", Json(new { RecordId, Kind = 1, ExpectedRowVersion = 12, + Correction = new { RequestId = requestId, UsageId, ExistingTransactionId = TransactionId, Reason = Canary, DepartmentId = 88, UserId = "forged" } })); + var json = await Created(response); var reversal = _reversed.Single(); + reversal.Actor.Should().BeEquivalentTo(new InventoryActor { DepartmentId = Department, UserId = "officer", GrantToken = Grant }); + reversal.RecordId.Should().Be(RecordId); reversal.Kind.Should().Be(RmsRecordKind.Operational); reversal.Version.Should().Be(12); + reversal.Correction.RequestId.Should().Be(requestId); reversal.Correction.UsageId.Should().Be(UsageId); reversal.Correction.ExistingTransactionId.Should().Be(TransactionId); reversal.Correction.Reason.Should().Be(Canary); + json["usage"].Value("Quantity").Should().Be(-2.125001m); json["usage"].Value("ReversesUsageId").Should().Be(UsageId); + _posted.Should().BeEmpty(); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary).And.NotContain(Grant); + }); + } + + [Test] + public async Task Existing_consumption_attachment_is_bound_without_inventing_fresh_stock_fields() + { + await WithServer(async client => + { + SignIn(client); + await Created(await client.PostAsync(Route + "RecordUsage", Json(new { RecordId, Kind = 1, ExpectedRowVersion = 3, + Request = new { RequestId = Guid.NewGuid().ToString("D"), Lines = new[] { new { ExistingTransactionId = TransactionId, UsageType = (int)InventoryUsageType.ConsumedOnPatient } } } }))); + var line = _posted.Single().Request.Lines.Single(); line.ExistingTransactionId.Should().Be(TransactionId); line.UsageType.Should().Be(InventoryUsageType.ConsumedOnPatient); + line.Quantity.Should().Be(0); line.ItemId.Should().BeNull(); line.LocationId.Should().BeNull(); line.Note.Should().BeNull(); + }); + } + + [TestCase("RecordUsage")] + [TestCase("ReverseUsage")] + public async Task Evidence_failure_returns_created_with_refresh_required_and_never_repeats_the_inventory_action(string action) + { + _evidence.Setup(x => x.CaptureAsync(It.IsAny(), true, It.IsAny())).ThrowsAsync(new InventoryException(403, "ProtectedDataRequired")); + await WithServer(async client => + { + SignIn(client); var response = await client.PostAsync(Route + action, Json(Input(action))); var json = await Created(response); + json.Value("evidenceCaptureRequired").Should().BeTrue(); json["evidenceId"].Type.Should().Be(JTokenType.Null); + (_posted.Count + _reversed.Count).Should().Be(1); + _usage.Invocations.Count(x => x.Method.Name is nameof(IRmsInventoryUsageAdapter.RecordModernUsageAsync) or nameof(IRmsInventoryUsageAdapter.ReverseModernUsageAsync)).Should().Be(1); + _usage.Invocations.Should().NotContain(x => x.Method.Name == nameof(IRmsInventoryUsageAdapter.ConsumeAsync) || x.Method.Name == nameof(IRmsInventoryUsageAdapter.ConsumeModernAsync)); + _evidence.Verify(x => x.CaptureAsync(It.IsAny(), true, It.IsAny()), Times.Once); + }); + } + + [TestCase("RecordUsage")] + [TestCase("ReverseUsage")] + public async Task Domain_concurrency_protection_and_missing_source_failures_keep_their_status_without_leaking_content(string action) + { + await WithServer(async client => + { + SignIn(client); + foreach (var failure in new (Exception Error, HttpStatusCode Status, string Code)[] { + (new RecordConcurrencyException(RecordId, 3, 4), HttpStatusCode.Conflict, null), + (new InventoryException(403, "ProtectedDataRequired"), HttpStatusCode.Forbidden, "protected_data_required"), + (new InventoryException(404, "UsageUnavailable"), HttpStatusCode.NotFound, "UsageUnavailable"), + (new InventoryException(409, "UsageAlreadyReversed"), HttpStatusCode.Conflict, "UsageAlreadyReversed"), + (new UnauthorizedAccessException(Canary), HttpStatusCode.Forbidden, null), + (new InvalidOperationException(Canary), HttpStatusCode.BadRequest, null) }) + { + if (action == "RecordUsage") _usage.Setup(x => x.RecordModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(failure.Error); + else _usage.Setup(x => x.ReverseModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(failure.Error); + var response = await client.PostAsync(Route + action, Json(Input(action))); var body = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(failure.Status, body); response.Headers.CacheControl.NoStore.Should().BeTrue(); body.Should().NotContain(Canary).And.NotContain(Grant); + if (failure.Code != null) body.Should().Contain(failure.Code); + } + _evidence.Invocations.Should().BeEmpty(); + }); + } + + [Test] + public async Task Missing_request_id_is_not_replaced_with_a_generated_id_at_the_HTTP_boundary() + { + RecordInventoryUsageRequest posted = null; RecordInventoryUsageCorrection reversed = null; + _usage.Setup(x => x.RecordModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((a, r, k, v, request, ct) => posted = request).ThrowsAsync(new ArgumentException(Canary)); + _usage.Setup(x => x.ReverseModernUsageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((a, r, k, v, correction, ct) => reversed = correction).ThrowsAsync(new ArgumentException(Canary)); + await WithServer(async client => + { + SignIn(client); + foreach (var requestId in new[] { (string)null, Guid.Empty.ToString("D"), "not-a-guid" }) + { + var response = await client.PostAsync(Route + "RecordUsage", Json(new { RecordId, Kind = 1, ExpectedRowVersion = 3, Request = new { RequestId = requestId, Lines = new[] { new { ItemId, Quantity = 1 } } } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); posted.RequestId.Should().Be(requestId); (await response.Content.ReadAsStringAsync()).Should().NotContain(Canary); + response = await client.PostAsync(Route + "ReverseUsage", Json(new { RecordId, Kind = 1, ExpectedRowVersion = 3, Correction = new { RequestId = requestId, UsageId, Reason = "Correction" } })); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); reversed.RequestId.Should().Be(requestId); + } + _evidence.Invocations.Should().BeEmpty(); + }); + } + + [Test] + public async Task Unknown_or_inactive_source_is_hidden_before_any_usage_or_evidence_call() + { + _authorization.Setup(x => x.CanUserViewRecordAsync("officer", RecordId, Department)).ReturnsAsync(false); + await WithServer(async client => + { + SignIn(client); + foreach (var action in new[] { "RecordUsage", "ReverseUsage" }) (await client.PostAsync(Route + action, Json(Input(action)))).StatusCode.Should().Be(HttpStatusCode.NotFound); + _authorization.Setup(x => x.CanUserViewRecordAsync("officer", RecordId, Department)).ReturnsAsync(true); + _cutover.Setup(x => x.GetModuleStateAsync(Department, false)).ReturnsAsync(new RecordsModuleState()); + (await client.GetAsync(Route + "Usage?recordId=" + RecordId)).StatusCode.Should().Be(HttpStatusCode.NotFound); + _usage.Invocations.Should().BeEmpty(); _evidence.Invocations.Should().BeEmpty(); + }); + } + + [Test] + public async Task Usage_reads_use_current_context_and_authorization_revocation_after_post_hides_the_receipt() + { + await WithServer(async client => + { + SignIn(client); client.DefaultRequestHeaders.Add(DataProtectionController.GrantHeader, Grant); + var response = await client.GetAsync(Route + "Usage?recordId=" + RecordId + "&kind=2&departmentId=88&grantToken=forged-grant"); + response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.CacheControl.NoStore.Should().BeTrue(); + _usage.Verify(x => x.GetAuthorizedUsageAsync(It.Is(a => a.DepartmentId == Department && a.UserId == "officer" && a.GrantToken == Grant), RecordId, RmsRecordKind.IncidentReport), Times.Once); + _authorization.SetupSequence(x => x.CanUserViewRecordAsync("officer", RecordId, Department)).ReturnsAsync(true).ReturnsAsync(false); + response = await client.PostAsync(Route + "RecordUsage", Json(Input("RecordUsage"))); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); _posted.Should().HaveCount(1); (await response.Content.ReadAsStringAsync()).Should().NotContain(UsageId); + }); + } + + private static RmsInventoryUsage Usage(bool correction = false) => new() { ReferenceId = correction ? TransactionId : UsageId, UsageId = correction ? TransactionId : UsageId, RecordId = RecordId, + TransactionId = TransactionId, ItemId = ItemId, Quantity = correction ? -2.125001m : 2.125001m, UsageType = InventoryUsageType.LeftAtScene, ReversesUsageId = correction ? UsageId : null, + ItemName = ProtectedDataEnvelope.RedactionValue, Note = ProtectedDataEnvelope.RedactionValue, UnitOfMeasure = ProtectedDataEnvelope.RedactionValue }; + private static object Input(string action) => action == "RecordUsage" + ? new { RecordId, Kind = 1, ExpectedRowVersion = 3, Request = new RecordInventoryUsageRequest { RequestId = Guid.NewGuid().ToString("D"), Lines = new() { new() { ItemId = ItemId, LocationId = LocationId, Quantity = 2.125001m, UsageType = InventoryUsageType.LeftAtScene } } } } + : new { RecordId, Kind = 1, ExpectedRowVersion = 3, Correction = new RecordInventoryUsageCorrection { RequestId = Guid.NewGuid().ToString("D"), UsageId = UsageId, Reason = "Synthetic correction" } }; + 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, bool canCreate = true) { client.DefaultRequestHeaders.Add("Test-Member", "officer"); if (canCreate) client.DefaultRequestHeaders.Add("Test-Record-Create", "true"); } + private static async Task Created(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.Created, body); response.Headers.CacheControl.NoStore.Should().BeTrue(); return JObject.Parse(body); + } + 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.AddApiVersioning(); + const string scheme = OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + builder.Services.AddAuthentication(scheme).AddScheme(scheme, _ => { }); + builder.Services.AddAuthorization(options => options.AddPolicy(ResgridResources.Record_Create, policy => policy.RequireClaim("records-create", "true"))); + builder.Services.AddControllers().AddApplicationPart(typeof(RecordInventoryController).Assembly).AddNewtonsoftJson(o => o.SerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver()); + builder.Services.AddSingleton(_usage.Object); builder.Services.AddSingleton(_evidence.Object); builder.Services.AddSingleton(_authorization.Object); builder.Services.AddSingleton(_cutover.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 member)) return Task.FromResult(AuthenticateResult.NoResult()); + var claims = new List { new(ClaimTypes.PrimarySid, member.ToString()), new(ClaimTypes.PrimaryGroupSid, Department.ToString()) }; + if (Request.Headers["Test-Record-Create"] == "true") claims.Add(new Claim("records-create", "true")); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name))); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/RecordInventoryM3Tests.cs b/Tests/Resgrid.Tests/Services/RecordInventoryM3Tests.cs new file mode 100644 index 000000000..263441381 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/RecordInventoryM3Tests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +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.Services; + +namespace Resgrid.Tests.Services +{ + public sealed partial class RmsInventoryModernUsageTests + { + private static RecordInventoryUsageRequest UsageRequest(params decimal[] quantities) => new() + { RequestId = Guid.NewGuid().ToString("D"), Lines = quantities.Select(q => new RecordInventoryUsageLine { ItemId = ItemId, LocationId = LocationId, Quantity = q, UsageType = InventoryUsageType.LeftAtScene, Note = Canary }).ToList() }; + + [TestCase(RmsRecordKind.Operational), TestCase(RmsRecordKind.IncidentReport)] + public async Task Linked_witnessed_correction_holds_a_source_version_bump_in_the_callers_inventory_transaction(RmsRecordKind kind) + { + var usage = LinkedUsage(kind); + await _uow.Object.CreateOrGetConnectionAsync(CancellationToken.None); + try + { + await _adapter.RequireUsageCorrectionAccessAsync(_actor, usage.Id); + (kind == RmsRecordKind.Operational ? _record.RowVersion : _incident.RowVersion).Should().Be(2); + _transaction.Should().NotBeNull("the source lock must remain in the inventory caller's transaction until it commits the movement"); + if (kind == RmsRecordKind.Operational) _records.Verify(x => x.TryBumpRowVersionAsync(Department, RecordId, 1, It.IsAny()), Times.Once); + else _incidents.Verify(x => x.TryBumpRowVersionAsync(Department, RecordId, 1, It.IsAny()), Times.Once); + _uow.Verify(x => x.CommitChanges(), Times.Never); _uow.Verify(x => x.DiscardChanges(), Times.Never); + _posts.Should().BeEmpty(); _references.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + } + finally { _uow.Object.DiscardChanges(); } + (kind == RmsRecordKind.Operational ? _record.RowVersion : _incident.RowVersion).Should().Be(1, "failure later in the owning inventory command rolls the source bump back too"); + } + + [TestCase(RmsRecordKind.Operational), TestCase(RmsRecordKind.IncidentReport)] + public async Task Competing_finalization_before_the_source_version_lock_rejects_a_witnessed_correction(RmsRecordKind kind) + { + var usage = LinkedUsage(kind); + if (kind == RmsRecordKind.Operational) + _records.Setup(x => x.TryBumpRowVersionAsync(Department, RecordId, It.IsAny(), It.IsAny())).ReturnsAsync((int department, string id, long expected, CancellationToken ct) => + { + expected.Should().Be(1); _record.State = (int)RmsRecordState.Finalized; _record.RowVersion = 2; + return _record.RowVersion == expected; + }); + else + _incidents.Setup(x => x.TryBumpRowVersionAsync(Department, RecordId, It.IsAny(), It.IsAny())).ReturnsAsync((int department, string id, long expected, CancellationToken ct) => + { + expected.Should().Be(1); _incident.State = (int)RmsRecordState.Finalized; _incident.RowVersion = 2; + return _incident.RowVersion == expected; + }); + await _uow.Object.CreateOrGetConnectionAsync(CancellationToken.None); + try + { + Func correction = () => _adapter.RequireUsageCorrectionAccessAsync(_actor, usage.Id); + await correction.Should().ThrowAsync(); + (kind == RmsRecordKind.Operational ? _record.State : _incident.State).Should().Be((int)RmsRecordState.Finalized); + _posts.Should().BeEmpty(); _references.Should().BeEmpty(); _dispatches.Should().BeEmpty(); + _uow.Verify(x => x.CommitChanges(), Times.Never); + } + finally { _uow.Object.DiscardChanges(); } + } + + [TestCase(RmsRecordKind.Operational), TestCase(RmsRecordKind.IncidentReport)] + public async Task Reauthorizing_an_already_posted_linked_reversal_does_not_bump_the_source_again(RmsRecordKind kind) + { + var usage = LinkedUsage(kind); + _ledger.Add(new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, ToLocationId = LocationId, Quantity = usage.Quantity, + TransactionType = (int)InventoryTransactionType.Adjust, ReversesTransactionId = usage.TransactionId }); + await _uow.Object.CreateOrGetConnectionAsync(CancellationToken.None); + try + { + await _adapter.RequireUsageCorrectionAccessAsync(_actor, usage.Id); + (kind == RmsRecordKind.Operational ? _record.RowVersion : _incident.RowVersion).Should().Be(1); + _records.Verify(x => x.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _incidents.Verify(x => x.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _transaction.Should().NotBeNull(); _posts.Should().BeEmpty(); + } + finally { _uow.Object.DiscardChanges(); } + } + + private RecordInventoryUsage LinkedUsage(RmsRecordKind kind) + { + var transaction = new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, FromLocationId = LocationId, Quantity = 1, TransactionType = (int)InventoryTransactionType.Consume }; + _ledger.Add(transaction); + var usage = new RecordInventoryUsage { DepartmentId = Department, SourceType = (int)InventoryUsageSourceType.RmsRecord, SourceId = RecordId, + RecordKind = (int)kind, ItemId = ItemId, SourceLocationId = LocationId, TransactionId = transaction.Id, Quantity = transaction.Quantity }; + _usageRows.Add(usage); return usage; + } + + [Test] + public async Task Batch_usage_has_stable_independent_identities_and_one_atomic_version_bump() + { + var input = UsageRequest(1.125001m, 2.000002m); + var result = await _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, input); + result.Should().HaveCount(2); result.Select(x => x.UsageId).Distinct().Should().HaveCount(2); + result[0].UsageId.Should().Be(input.RequestId); _balance.Should().Be(6.874997m); _record.RowVersion.Should().Be(2); + _usageRows.Should().HaveCount(2).And.OnlyContain(x => x.UsageType == (int)InventoryUsageType.LeftAtScene && x.SourceId == RecordId); + var retried = await _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, Copy(input)); + retried.Select(x => x.UsageId).Should().Equal(result.Select(x => x.UsageId)); _ledger.Should().HaveCount(2); _record.RowVersion.Should().Be(2); + _dispatches.Should().HaveCount(2).And.OnlyContain(x => x.SequenceEqual(new long[] { 101, 102 })); + input.Lines[1].UsageType = InventoryUsageType.Damaged; + await ((Func)(() => _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, input))).Should().ThrowAsync().Where(x => x.Code == "RequestConflict"); + } + + [Test] + public async Task Failure_saving_later_usage_rolls_back_whole_batch_and_outbox() + { + _stock.Setup(s => s.RecordUsageWithinTransactionAsync(It.IsAny(), It.IsAny())).Returns((InventoryActor a, RecordInventoryUsage row) => + { if (_usageRows.Count == 1) throw new InventoryException(403, "ProtectedDataRequired"); _usageRows.Add(Copy(row)); return Task.CompletedTask; }); + await ((Func)(() => _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, UsageRequest(1, 2)))).Should().ThrowAsync(); + _balance.Should().Be(10); _usageRows.Should().BeEmpty(); _ledger.Should().BeEmpty(); _references.Should().BeEmpty(); _record.RowVersion.Should().Be(1); _dispatches.Should().BeEmpty(); + } + + [TestCase(RmsRecordKind.Operational), TestCase(RmsRecordKind.IncidentReport)] + public async Task Full_correction_is_append_only_signed_and_retry_safe_with_original_cost(RmsRecordKind kind) + { + var usage = (await _adapter.RecordModernUsageAsync(_actor, RecordId, kind, 1, UsageRequest(2.125001m))).Single(); + var frozen = Copy(_references.Single()); + var correction = new RecordInventoryUsageCorrection { RequestId = Guid.NewGuid().ToString("D"), UsageId = usage.UsageId, Reason = "Synthetic correction reason" }; + var result = await _adapter.ReverseModernUsageAsync(_actor, RecordId, kind, 2, correction); + result.Quantity.Should().Be(-usage.Quantity); result.ReversesUsageId.Should().Be(usage.UsageId); _balance.Should().Be(10); + _references.First().SnapshotJson.Should().Be(frozen.SnapshotJson); _references.First().Checksum.Should().Be(frozen.Checksum); + _posts.Last().Command.Lines.Single().UnitCost.Should().Be(4.25m); _posts.Last().Command.Lines.Single().ReversesTransactionId.Should().Be(usage.TransactionId); + var retry = await _adapter.ReverseModernUsageAsync(_actor, RecordId, kind, 2, Copy(correction)); retry.UsageId.Should().Be(result.UsageId); + _ledger.Should().HaveCount(2); _usageRows.Should().HaveCount(2); + var projection = await _adapter.GetUsageForRecordAsync(Department, RecordId); projection.Sum(x => x.Quantity).Should().Be(0); projection.First().IsReversed.Should().BeTrue(); + correction.RequestId = Guid.NewGuid().ToString("D"); + await ((Func)(() => _adapter.ReverseModernUsageAsync(_actor, RecordId, kind, 3, correction))).Should().ThrowAsync().Where(x => x.Code == "UsageAlreadyReversed"); + } + + [Test] + public async Task Open_amendment_retains_old_usage_and_links_new_correction_to_base_revision() + { + var original = (await _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, UsageRequest(1))).Single(); + _record.State = (int)RmsRecordState.Finalized; _record.AmendsRevisionId = Guid.NewGuid().ToString("D"); + var correction = new RecordInventoryUsageCorrection { RequestId = Guid.NewGuid().ToString("D"), UsageId = original.UsageId, Reason = Canary }; + await _adapter.ReverseModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 2, correction); + _usageRows.First().RmsRevisionId.Should().BeNull(); _usageRows.Last().RmsRevisionId.Should().Be(_record.AmendsRevisionId); + _record.AmendsRevisionId = null; // Abandoning document edits must not create a new stock mutation. + (await _adapter.GetUsageForRecordAsync(Department, RecordId)).Sum(x => x.Quantity).Should().Be(0); _ledger.Should().HaveCount(2); _balance.Should().Be(10); + } + + [Test] + public async Task Attachment_and_witnessed_reversal_never_post_stock_again_and_keep_correction_reason() + { + var transaction = new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, FromLocationId = LocationId, Quantity = 2, TransactionType = (int)InventoryTransactionType.Consume, Content = "{}" }; + _ledger.Add(transaction); + var request = new RecordInventoryUsageRequest { RequestId = Guid.NewGuid().ToString("D"), Lines = new() { new() { ExistingTransactionId = transaction.Id, UsageType = InventoryUsageType.ConsumedOnPatient } } }; + var usage = (await _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, request)).Single(); + _posts.Should().BeEmpty(); _balance.Should().Be(10); _usageRows.Single().TransactionId.Should().Be(transaction.Id); + var reversal = new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, ToLocationId = LocationId, Quantity = 2, TransactionType = (int)InventoryTransactionType.Adjust, ReversesTransactionId = transaction.Id, Content = "{}" }; + _ledger.Add(reversal); + var before = await _adapter.GetAuthorizedUsageAsync(_actor, RecordId, RmsRecordKind.Operational); before.Single().PendingReversalTransactionId.Should().Be(reversal.Id); + await _adapter.ReverseModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 2, new RecordInventoryUsageCorrection { RequestId = Guid.NewGuid().ToString("D"), UsageId = usage.UsageId, ExistingTransactionId = reversal.Id, Reason = Canary }); + _posts.Should().BeEmpty(); _balance.Should().Be(10); + (await _adapter.GetAuthorizedUsageAsync(_actor, RecordId, RmsRecordKind.Operational)).Last().Note.Should().Be(Canary); + _references.Should().OnlyContain(x => !x.SnapshotJson.Contains(Canary)); + } + + [Test] + public async Task Legacy_reference_keeps_identity_and_reverses_exact_migrated_negative_entry_without_legacy_writes() + { + _migrated = false; + var legacy = await _adapter.ConsumeAsync(Department, "author", RecordId, RmsRecordKind.Operational, 1, 1, 7, null, 2.5m, Canary); + var frozen = _references.Single().Checksum; _migrated = true; + _ledger.Add(new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, FromLocationId = LocationId, Quantity = 2.5m, LegacyInventoryId = 901, + TransactionType = (int)InventoryTransactionType.Migrated, ReferenceType = (int)InventoryReferenceType.Legacy, Content = "{}" }); + legacy.UsageId.Should().Be(legacy.ReferenceId); + var result = await _adapter.ReverseModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 2, new RecordInventoryUsageCorrection { RequestId = Guid.NewGuid().ToString("D"), UsageId = legacy.UsageId, Reason = Canary }); + result.Quantity.Should().Be(-2.5m); _usageRows.First().Id.Should().Be(legacy.UsageId); _references.First().Checksum.Should().Be(frozen); + _legacy.Verify(x => x.SaveInventoryAsync(It.IsAny(), It.IsAny()), Times.Once); + (await _adapter.GetUsageForLegacyLogAsync(Department, 99)).Should().BeEmpty("no Log linkage may be invented from a unit or timestamp"); + } + + [Test] + public async Task Automatic_source_uses_unique_report_station_and_rejects_absent_source() + { + var input = UsageRequest(1); input.Lines[0].LocationId = null; + await ((Func)(() => _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, input))).Should().ThrowAsync().Where(x => x.Code == "ExplicitSourceLocationRequired"); + _record.StationGroupId = 7; + await _adapter.RecordModernUsageAsync(_actor, RecordId, RmsRecordKind.Operational, 1, input); + _posts.Single().Command.Lines.Single().FromLocationId.Should().Be(LocationId); input.Lines.Single().LocationId.Should().BeNull(); + } + + [Test] + public async Task Pre_M3_fingerprint_without_new_optional_posting_properties_still_replays() + { + var command = Command(); var oldPosting = Copy(command); oldPosting.Lines[0].ReferenceType = InventoryReferenceType.RmsRecord; oldPosting.Lines[0].ReferenceId = RecordId; + var oldLines = JArray.FromObject(oldPosting.Lines); foreach (var line in oldLines.OfType()) { line.Remove("UsageId"); line.Remove("UsageType"); } + string Hash(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + var fingerprint = Hash(JsonConvert.SerializeObject(new { DepartmentId = Department, UserId = "author", recordId = RecordId, kind = RmsRecordKind.Operational, expectedRowVersion = 1L, Lines = oldLines })); + var transaction = new InventoryTransaction { DepartmentId = Department, ItemId = ItemId, FromLocationId = LocationId, Quantity = command.Lines[0].Quantity, Content = "{}" }; _ledger.Add(transaction); + var snapshot = JsonConvert.SerializeObject(new { SchemaVersion = 2, TransactionId = transaction.Id, ItemId, Quantity = transaction.Quantity, RequestFingerprint = fingerprint }); + _references.Add(new RmsExternalReference { RmsExternalReferenceId = command.RequestId, DepartmentId = Department, RecordId = RecordId, RecordKind = 1, SourceSubsystem = "Inventory", SemanticRole = "InventoryUsage", + SourceEntityType = "InventoryTransaction", SourceEntityId = transaction.Id, SourceVersion = "2", CapturedByUserId = "author", SnapshotJson = snapshot, Checksum = Hash(snapshot) }); + var result = await _adapter.ConsumeModernAsync(_actor, RecordId, RmsRecordKind.Operational, 1, command); + result.TransactionId.Should().Be(transaction.Id); _posts.Should().BeEmpty(); _record.RowVersion.Should().Be(1); _references.Should().ContainSingle(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs index 7aff741d6..d9a6d2eae 100644 --- a/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs +++ b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs @@ -19,7 +19,7 @@ namespace Resgrid.Tests.Services { [TestFixture] - public sealed class RmsInventoryModernUsageTests + public sealed partial class RmsInventoryModernUsageTests { private const int Department = 77; private const string RecordId = "11111111-1111-1111-1111-111111111111"; @@ -44,6 +44,7 @@ public sealed class RmsInventoryModernUsageTests private List _references; private List _audits; private List _ledger; + private List _usageRows; private List<(InventoryActor Actor, InventoryCommand Command)> _posts; private List> _dispatches; private DbTransaction _transaction; @@ -54,7 +55,7 @@ public sealed class RmsInventoryModernUsageTests [SetUp] public void SetUp() { - _references = new(); _audits = new(); _ledger = new(); _posts = new(); _dispatches = new(); _balance = 10; _transaction = null; _migrated = true; + _references = new(); _audits = new(); _ledger = new(); _usageRows = 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(); @@ -84,15 +85,28 @@ public void SetUp() _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))); + _modernStore.Setup(s => s.GetAsync(Department, LocationId)).ReturnsAsync(() => Copy(location)); + _modernStore.Setup(s => s.GetAsync(Department, It.IsAny())).ReturnsAsync((int d, string id) => Copy(_usageRows.SingleOrDefault(t => t.Id == id))); + _modernStore.Setup(s => s.RelatedAsync(Department, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string col, string id) => _usageRows.Where(x => (string)typeof(RecordInventoryUsage).GetProperty(col).GetValue(x) == id).Select(Copy).ToList()); + _modernStore.Setup(s => s.RelatedAsync(Department, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string col, string id) => _ledger.Where(x => (string)typeof(InventoryTransaction).GetProperty(col).GetValue(x) == id).Select(Copy).ToList()); + _modernStore.Setup(s => s.LegacyTransactionAsync(Department, It.IsAny())).ReturnsAsync((int d, int id) => Copy(_ledger.SingleOrDefault(t => t.LegacyInventoryId == id))); + _catalog.Setup(c => c.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, string id) => Copy(_usageRows.SingleOrDefault(t => t.Id == id))); + _stock.Setup(s => s.RecordUsageWithinTransactionAsync(It.IsAny(), It.IsAny())).Returns((InventoryActor a, RecordInventoryUsage usage) => + { _transaction.Should().NotBeNull(); if (_usageRows.Any(x => x.TransactionId == usage.TransactionId)) throw new InventoryException(409, "UsageAlreadyRecorded"); _usageRows.Add(Copy(usage)); return Task.CompletedTask; }); _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 result = new InventoryResult(); + foreach (var line in command.Lines) + { + var before = _balance; _balance += line.FromLocationId != null ? -line.Quantity : 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 } }; + TransactionType = (int)line.Type, ReversesTransactionId = line.ReversesTransactionId, OccurredOn = DateTime.UtcNow, Content = JsonConvert.SerializeObject(new { line.Note, ItemName = Canary, UnitOfMeasure = "each", UnitCost = 4.25m }) }; + _ledger.Add(entry); result.TransactionIds.Add(entry.Id); result.OutboxIds.Add(100 + _ledger.Count); + } + return result; }); _uow.SetupGet(u => u.Transaction).Returns(() => _transaction); _uow.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync(() => { Begin(); return (DbConnection)null; }); @@ -118,8 +132,8 @@ public async Task Modern_consumption_joins_stock_to_parent_version_clamps_proven 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); + var reference = _references.Single(); reference.RmsExternalReferenceId.Should().Be(command.RequestId); reference.SourceEntityType.Should().Be("RecordInventoryUsage"); reference.SourceEntityId.Should().Be(result.UsageId); + JObject.Parse(reference.SnapshotJson).Value("SchemaVersion").Should().Be(3); 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); @@ -132,7 +146,7 @@ public async Task Receipt_retry_rechecks_current_grant_and_source_without_rebump 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); + _posts.Should().HaveCount(1); _references.Should().HaveCount(1); _audits.Should().HaveCount(1); _dispatches.Should().HaveCount(2, "retry recovers the original outbox dispatch without another movement"); _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; @@ -195,8 +209,8 @@ 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; }; + var record = Copy(_record); var incident = Copy(_incident); var balance = _balance; var usages = _usageRows.Select(Copy).ToList(); + _rollback = () => { _references = references; _audits = audits; _ledger = ledger; _usageRows = usages; _record = record; _incident = incident; _balance = balance; }; } } } diff --git a/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs index 8200d7f73..b32025cfe 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs @@ -49,7 +49,9 @@ public void Workflow_dates_keep_the_same_UTC_instant_for_typed_and_string_values { 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") }) + foreach (var value in new JToken[] { new JValue(expected), new JValue(expected.ToLocalTime()), new JValue(DateTime.SpecifyKind(expected, DateTimeKind.Unspecified)), + JObject.Parse("{\"DueOn\":\"2026-09-03T12:34:56\"}")["DueOn"], new JValue("2026-09-03T12:34:56"), + 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); diff --git a/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs new file mode 100644 index 000000000..27aa95cbf --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture, NonParallelizable] + public sealed class ProfileReportScheduleSecurityTests + { + private const int DepartmentId = 77; + private const int ScheduleId = 410; + private const string UserId = "report-owner"; + private Mock _tasks; + private ProfileController _controller; + private IHttpContextAccessor _previousAccessor; + + [SetUp] + public void SetUp() + { + _previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; + var http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "Test")) + }; + http.Request.Method = "POST"; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + _tasks = new Mock(MockBehavior.Strict); + _controller = new ProfileController( + departmentsService: null, usersService: null, authorizationService: null, + userProfileService: null, scheduledTasksService: _tasks.Object, certificationService: null, + customStateService: null, imageService: null, appOptionsAccessor: null, + emailService: null, userManager: null, signInManager: null, + departmentSsoService: null, secLocalizer: null, deleteService: null, + externalIdentityLinkService: null, userSessionService: null, systemAuditsService: null, + departmentGroupsService: null, departmentSettingsService: null, passwordRecoveryService: null, + eventAggregator: null, protectedReadService: null) + { + ControllerContext = new ControllerContext { HttpContext = http } + }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + + private static IEnumerable RejectedReportTargets() + { + foreach (var action in new[] { nameof(ProfileController.ActivateScheduledReport), nameof(ProfileController.DeactivateScheduledReport), nameof(ProfileController.DeleteScheduledReport) }) + foreach (var target in new[] { "missing", "department", "owner", "staffing" }) + yield return new TestCaseData(action, target).SetName($"{action}_rejects_{target}_without_mutation"); + } + + [TestCaseSource(nameof(RejectedReportTargets))] + public async Task Report_actions_reject_inaccessible_or_non_report_schedules_without_mutation(string action, string target) + { + var schedule = OwnedReport(); + switch (target) + { + case "missing": schedule = null; break; + case "department": schedule.DepartmentId = DepartmentId + 1; break; + case "owner": schedule.UserId = "another-owner"; break; + case "staffing": schedule.TaskType = (int)TaskTypes.UserStaffingLevel; break; + } + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(schedule); + + (await InvokeAsync(action)).Should().BeOfType(); + + _tasks.Verify(s => s.GetScheduledTaskByIdAsync(ScheduleId), Times.Once); + VerifyNoMutation(); + } + + [TestCase(nameof(ProfileController.ActivateScheduledReport))] + [TestCase(nameof(ProfileController.DeactivateScheduledReport))] + [TestCase(nameof(ProfileController.DeleteScheduledReport))] + public async Task Report_owner_can_mutate_the_reloaded_schedule_and_passes_cancellation(string action) + { + using var cancellation = new CancellationTokenSource(); + var schedule = OwnedReport(); + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(schedule); + ExpectMutation(action, schedule, cancellation.Token); + + (await InvokeAsync(action, cancellation.Token)).Should().BeOfType(); + + _tasks.VerifyAll(); + _tasks.Invocations.Select(i => i.Method.Name).Should().Equal(nameof(IScheduledTasksService.GetScheduledTaskByIdAsync), MutationMethod(action)); + } + + [TestCase(nameof(ProfileController.ActivateScheduledReport))] + [TestCase(nameof(ProfileController.DeactivateScheduledReport))] + [TestCase(nameof(ProfileController.DeleteScheduledReport))] + public void Report_mutations_require_post_csrf_and_profile_update(string action) + { + var method = typeof(ProfileController).GetMethod(action, new[] { typeof(int), typeof(CancellationToken) }); + method.Should().NotBeNull(); + method.GetCustomAttribute().Should().NotBeNull(); + method.GetCustomAttribute().Should().NotBeNull(); + method.GetCustomAttributes().Should().Contain(a => a.Policy == ResgridResources.Profile_Update); + method.GetCustomAttribute().Should().BeNull(); + } + + [TestCase(nameof(ProfileController.ActivateSchedule))] + [TestCase(nameof(ProfileController.DeactivateSchedule))] + [TestCase(nameof(ProfileController.DeleteSchedule))] + public async Task Generic_routes_cannot_bypass_report_mutation_guards_even_for_the_owner(string action) + { + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(OwnedReport()); + + (await InvokeAsync(action)).Should().BeOfType(); + + _tasks.Verify(s => s.GetScheduledTaskByIdAsync(ScheduleId), Times.Once); + VerifyNoMutation(); + } + + [TestCase(nameof(ProfileController.ActivateSchedule))] + [TestCase(nameof(ProfileController.DeactivateSchedule))] + [TestCase(nameof(ProfileController.DeleteSchedule))] + public async Task Generic_routes_preserve_staffing_schedule_mutations(string action) + { + var schedule = OwnedReport(); + schedule.TaskType = (int)TaskTypes.UserStaffingLevel; + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(schedule); + ExpectMutation(action, schedule, CancellationToken.None); + + (await InvokeAsync(action)).Should().BeOfType(); + + _tasks.VerifyAll(); + _tasks.Invocations.Select(i => i.Method.Name).Should().Equal(nameof(IScheduledTasksService.GetScheduledTaskByIdAsync), MutationMethod(action)); + } + + private static ScheduledTask OwnedReport() => new() + { + ScheduledTaskId = ScheduleId, + DepartmentId = DepartmentId, + UserId = UserId, + TaskType = (int)TaskTypes.ReportDelivery, + Data = ((int)ReportTypes.ControlledSubstanceLog).ToString(), + Active = true + }; + + private Task InvokeAsync(string action, CancellationToken cancellation = default) => action switch + { + nameof(ProfileController.ActivateScheduledReport) => _controller.ActivateScheduledReport(ScheduleId, cancellation), + nameof(ProfileController.DeactivateScheduledReport) => _controller.DeactivateScheduledReport(ScheduleId, cancellation), + nameof(ProfileController.DeleteScheduledReport) => _controller.DeleteScheduledReport(ScheduleId, cancellation), + nameof(ProfileController.ActivateSchedule) => _controller.ActivateSchedule(ScheduleId, cancellation), + nameof(ProfileController.DeactivateSchedule) => _controller.DeactivateSchedule(ScheduleId, cancellation), + nameof(ProfileController.DeleteSchedule) => _controller.DeleteSchedule(ScheduleId, cancellation), + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }; + + private void ExpectMutation(string action, ScheduledTask schedule, CancellationToken cancellation) + { + switch (MutationMethod(action)) + { + case nameof(IScheduledTasksService.EnableScheduledTaskByIdAsync): + _tasks.Setup(s => s.EnableScheduledTaskByIdAsync(ScheduleId, cancellation)).ReturnsAsync(schedule); break; + case nameof(IScheduledTasksService.DisabledScheduledTaskByIdAsync): + _tasks.Setup(s => s.DisabledScheduledTaskByIdAsync(ScheduleId, cancellation)).ReturnsAsync(schedule); break; + case nameof(IScheduledTasksService.DeleteScheduledTask): + _tasks.Setup(s => s.DeleteScheduledTask(ScheduleId, cancellation)).ReturnsAsync(true); break; + } + } + + private static string MutationMethod(string action) => action switch + { + nameof(ProfileController.ActivateScheduledReport) or nameof(ProfileController.ActivateSchedule) => nameof(IScheduledTasksService.EnableScheduledTaskByIdAsync), + nameof(ProfileController.DeactivateScheduledReport) or nameof(ProfileController.DeactivateSchedule) => nameof(IScheduledTasksService.DisabledScheduledTaskByIdAsync), + nameof(ProfileController.DeleteScheduledReport) or nameof(ProfileController.DeleteSchedule) => nameof(IScheduledTasksService.DeleteScheduledTask), + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }; + + private void VerifyNoMutation() + { + _tasks.Verify(s => s.EnableScheduledTaskByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _tasks.Verify(s => s.DisabledScheduledTaskByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _tasks.Verify(s => s.DeleteScheduledTask(It.IsAny(), It.IsAny()), Times.Never); + _tasks.Verify(s => s.SaveScheduledTaskAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs b/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs index 5fc2f0920..e517b3c0f 100644 --- a/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs +++ b/Tests/Resgrid.Tests/Web/inventory-modern.test.cjs @@ -4,6 +4,31 @@ const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); +test('report date bounds are omitted for snapshots and preserved when returning to history', async () => { + const form = new Element('form'); + const kind = form.appendChild(new Element('select', { name: 'Kind', value: '0' })); + const from = form.appendChild(new Element('input', { name: 'FromUtc', value: '2026-09-01' })); + const until = form.appendChild(new Element('input', { name: 'UntilUtc', value: '2026-09-09' })); + const script = fs.readFileSync(path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-operations.js'), 'utf8'); + vm.runInNewContext(script, { document: { getElementById: () => form } }); + for (const value of ['0', '3', '6']) { kind.value = value; await kind.emit('change'); assert.equal(from.disabled, true); assert.equal(until.disabled, true); } + for (const value of ['1', '2', '4', '5', '7']) { kind.value = value; await kind.emit('change'); assert.equal(from.disabled, false); assert.equal(until.disabled, false); } + assert.equal(from.value, '2026-09-01'); assert.equal(until.value, '2026-09-09'); +}); + +test('report recipient filters select either a person or a unit without sending a stale selection', async () => { + const form = new Element('form'); + form.appendChild(new Element('select', { name: 'Kind', value: '5' })); + const person = form.appendChild(new Element('select', { name: 'UserId', value: '' })); + const unit = form.appendChild(new Element('select', { name: 'UnitId', value: '7' })); + const script = fs.readFileSync(path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-operations.js'), 'utf8'); + vm.runInNewContext(script, { document: { getElementById: () => form } }); + person.value = 'member'; await person.emit('change'); assert.equal(unit.value, ''); + assert.equal(person.value, 'member'); + unit.value = '9'; await unit.emit('change'); assert.equal(person.value, ''); assert.equal(unit.value, '9'); + unit.value = ''; await unit.emit('change'); assert.equal(person.value, ''); +}); + // 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 { @@ -44,7 +69,7 @@ class Element { 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]; + for (const key of ['id', 'name', 'value', 'type', 'className', 'htmlFor', 'disabled', 'readOnly']) copy[key] = this[key]; if (deep) this.children.forEach(child => copy.appendChild(child.cloneNode(true))); return copy; } @@ -52,7 +77,7 @@ class Element { replaceChildren() { this.children = []; } } class FormData { - constructor(form) { this.values = form.querySelectorAll('[name]').map(field => [field.name, field.value]); } + constructor(form) { this.values = form.querySelectorAll('[name]').filter(field => !field.disabled).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' })); @@ -67,7 +92,7 @@ function kitLine(index, item) { 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 } = {}) { +function page({ response = { ok: true, json: async () => ({ success: true }) }, createAsset = false, denied = false, kit = false, issuedAssets = null, movementType = null } = {}) { const document = new Element('document'); document.createElement = tag => new Element(tag); document.getElementById = id => document.querySelectorAll('[id]').find(element => element.id === id); @@ -78,6 +103,11 @@ function page({ response = { ok: true, json: async () => ({ success: true }) }, 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'); + let movement, cost; + if (movementType !== null) { + movement = field(command, 'Lines[0].Type', movementType, 'select'); + cost = field(command, 'Lines[0].UnitCost', '19.500001'); cost.type = 'number'; + } if (createAsset) command.dataset.inventoryCreateAsset = 'true'; if (issuedAssets) issuedAssets.forEach((id, index) => field(command, 'Lines[' + index + '].AssetId', id, 'select')); let lines; @@ -90,7 +120,46 @@ function page({ response = { ok: true, json: async () => ({ success: true }) }, 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 }; + return { settings, message, reopen, getForm, command, requests, bound, lines, movement, cost }; +} + +function purchaseLine(formIndex, index, { receipt, serialized }) { + const row = new Element('fieldset', { className: 'm4-line' }); + field(row, 'Lines[' + index + '].' + (receipt ? 'PurchaseOrderItemId' : 'Id'), 'order-line-' + formIndex + '-' + index); + const controls = receipt + ? [['Quantity', serialized ? '1' : '4.125001', 'number'], ['LocationId', 'location-' + index, 'select'], ['LotId', 'lot-' + index, 'select']] + : [['ItemId', 'item-' + index, 'select'], ['QuantityOrdered', '2.125001', 'number'], ['UnitCost', '19.500001', 'number'], ['Note', 'draft note', 'text']]; + if (receipt && serialized) controls.push(['Asset.SerialNumber', 'serial-' + index, 'text'], ['Asset.AssetTag', 'tag-' + index, 'text'], ['Asset.Barcode', 'barcode-' + index, 'text'], ['Asset.ExpiresOn', '2030-01-01', 'date']); + for (const [name, value, type] of controls) { + const id = 'purchase-' + formIndex + '-' + index + '-' + name.replaceAll('.', '-'); + row.appendChild(new Element('label', { htmlFor: id })); + const input = field(row, 'Lines[' + index + '].' + name, value, type === 'select' ? 'select' : 'input'); + input.id = id; input.type = type; input.readOnly = !!(receipt && serialized && name === 'Quantity'); + } + if (receipt) row.appendChild(new Element('button', { className: 'm4-copy-line' })); + row.appendChild(new Element('button', { className: 'm4-remove-line' })); + return row; +} +function purchasingPage(specifications = [{ receipt: false, count: 2 }]) { + const document = new Element('document'); + const forms = specifications.map(({ receipt = false, serialized = true, count = 2 }, formIndex) => { + const form = document.appendChild(new Element('form', { className: 'm4-lines-form', dataset: { receipt: String(receipt) } })); + const lines = form.appendChild(new Element('div', { className: 'm4-lines' })); + for (let index = 0; index < count; index++) lines.appendChild(purchaseLine(formIndex, index, { receipt, serialized })); + const add = receipt ? null : form.appendChild(new Element('button', { className: 'm4-add-line' })); + return { form, lines, add }; + }); + vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js'), 'utf8'), { document }); + return forms; +} +function assertPurchaseBindings(forms) { + for (const { lines } of forms) lines.children.forEach((row, index) => { + assert.ok(row.querySelectorAll('[name]').every(input => input.name.startsWith('Lines[' + index + '].'))); + const ids = row.querySelectorAll('[id]').map(input => input.id); + assert.deepEqual(row.querySelectorAll('label[for]').map(label => label.htmlFor), ids); + }); + const ids = forms.flatMap(({ lines }) => lines.querySelectorAll('[id]').map(input => input.id)); + assert.equal(new Set(ids).size, ids.length, 'visible controls have unique IDs across purchasing forms'); } test('grant and expiry reach POST forms only, then leave the bootstrap JSON', () => { @@ -151,3 +220,76 @@ test('kit removal and addition keep contiguous binding indices and unique access 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, ''); }); + +test('purchase draft addition clears the stored line identity and starts a new editable line', async () => { + const forms = purchasingPage(); const [ui] = forms; + await ui.form.emit('click', ui.add); + assert.equal(ui.lines.children.length, 3); + assert.deepEqual(Object.fromEntries(new FormData(ui.lines.children[2])), { + 'Lines[2].Id': '', 'Lines[2].ItemId': '', 'Lines[2].QuantityOrdered': '1', 'Lines[2].UnitCost': '0', 'Lines[2].Note': '' + }); + assert.equal(ui.lines.children[0].querySelector('[name="Lines[0].Id"]').value, 'order-line-0-0'); + assert.equal(ui.lines.children[0].querySelector('[name="Lines[0].UnitCost"]').value, '19.500001'); + await ui.form.emit('click', ui.lines.children[0].querySelector('.m4-remove-line')); + assert.equal(ui.lines.children[0].querySelector('[name="Lines[0].Id"]').value, 'order-line-0-1'); + assert.equal(ui.lines.children[1].querySelector('[name="Lines[1].Id"]').value, ''); + assertPurchaseBindings(forms); +}); + +test('serialized receipt copies keep order line, location and lot while clearing new asset identifiers', async () => { + const forms = purchasingPage([{ receipt: true, count: 2 }]); const [ui] = forms; + await ui.form.emit('click', ui.lines.children[1].querySelector('.m4-copy-line')); + const copied = ui.lines.children[2]; + assert.deepEqual(Object.fromEntries(new FormData(copied)), { + 'Lines[2].PurchaseOrderItemId': 'order-line-0-1', 'Lines[2].Quantity': '1', 'Lines[2].LocationId': 'location-1', 'Lines[2].LotId': 'lot-1', + 'Lines[2].Asset.SerialNumber': '', 'Lines[2].Asset.AssetTag': '', 'Lines[2].Asset.Barcode': '', 'Lines[2].Asset.ExpiresOn': '' + }); + assert.equal(copied.querySelector('[name="Lines[2].Quantity"]').readOnly, true); + assert.equal(ui.lines.children[1].querySelector('[name="Lines[1].Asset.SerialNumber"]').value, 'serial-1'); + await ui.form.emit('click', ui.lines.children[1].querySelector('.m4-remove-line')); + assert.equal(ui.lines.children[1].querySelector('[name="Lines[1].PurchaseOrderItemId"]').value, 'order-line-0-1'); + assertPurchaseBindings(forms); +}); + +test('bulk receipt copies start at one and keep their selected destination and lot', async () => { + const forms = purchasingPage([{ receipt: true, serialized: false, count: 1 }]); const [ui] = forms; + await ui.form.emit('click', ui.lines.children[0].querySelector('.m4-copy-line')); + assert.equal(ui.lines.children[0].querySelector('[name="Lines[0].Quantity"]').value, '4.125001'); + assert.deepEqual(Object.fromEntries(new FormData(ui.lines.children[1])), { + 'Lines[1].PurchaseOrderItemId': 'order-line-0-0', 'Lines[1].Quantity': '1', 'Lines[1].LocationId': 'location-0', 'Lines[1].LotId': 'lot-0' + }); + assertPurchaseBindings(forms); +}); + +test('purchasing forms enforce one to one hundred rows and retain unique accessible IDs after removals', async () => { + const forms = purchasingPage([{ receipt: false, count: 1 }, { receipt: true, count: 1 }]); + for (const ui of forms) { + const add = () => ui.add || ui.lines.children[0].querySelector('.m4-copy-line'); + assert.equal(ui.lines.children[0].querySelector('.m4-remove-line').disabled, true); + await ui.form.emit('click', ui.lines.children[0].querySelector('.m4-remove-line')); + assert.equal(ui.lines.children.length, 1); + for (let index = 1; index < 100; index++) await ui.form.emit('click', add()); + assert.equal(ui.lines.children.length, 100); + assert.ok(ui.form.querySelectorAll('.m4-add-line, .m4-copy-line').every(button => button.disabled)); + await ui.form.emit('click', add()); assert.equal(ui.lines.children.length, 100); + await ui.form.emit('click', ui.lines.children[49].querySelector('.m4-remove-line')); + assert.equal(ui.lines.children.length, 99); + assert.ok(ui.form.querySelectorAll('.m4-add-line, .m4-copy-line').every(button => !button.disabled)); + await ui.form.emit('click', add()); assert.equal(ui.lines.children.length, 100); + } + assertPurchaseBindings(forms); +}); + +test('optional movement cost is submitted only for Receive and survives switching movement types', async () => { + const ui = page({ movementType: '3' }); + assert.equal(ui.cost.disabled, true); + await ui.command.emit('submit'); + assert.equal(Object.hasOwn(ui.requests[0].values, 'Lines[0].UnitCost'), false); + ui.movement.value = '1'; await ui.movement.emit('change'); + assert.equal(ui.cost.disabled, false); + await ui.command.emit('submit'); assert.equal(ui.requests[1].values['Lines[0].UnitCost'], '19.500001'); + ui.movement.value = '2'; await ui.movement.emit('change'); + assert.equal(ui.cost.disabled, true); assert.equal(ui.cost.value, '19.500001'); + await ui.command.emit('submit'); assert.equal(Object.hasOwn(ui.requests[2].values, 'Lines[0].UnitCost'), false); + assert.equal(page({ movementType: '1' }).cost.disabled, false); +}); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs index 99f91b97e..0299c8bfe 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InventoryController.cs @@ -17,10 +17,11 @@ 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 sealed partial class InventoryController : V4AuthenticatedApiControllerbase, IAsyncActionFilter, IOrderedFilter { public int Order => -3000; // Sanitize model-binding errors before ApiController's automatic response. private readonly IInventoryCatalogService _catalog; + private readonly IInventoryPurchasingService _purchasing; private readonly IInventoryStockService _stock; private readonly IInventoryTransferService _transfers; private readonly IInventoryIssuanceService _issuance; @@ -28,8 +29,8 @@ public sealed class InventoryController : V4AuthenticatedApiControllerbase, IAsy 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; } + IInventoryMigrationService migration, IInventoryAuthorizationService authorization, IStringLocalizer strings, IInventoryPurchasingService purchasing = null) + { _catalog = catalog; _stock = stock; _transfers = transfers; _issuance = issuance; _migration = migration; _authorization = authorization; _strings = strings; _purchasing = purchasing; } 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) { @@ -126,13 +127,7 @@ public async Task GetHistory(InventoryReferenceType referenceType [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) { } - } + var result = await _catalog.ListAsync(Actor, page); return Reply(result, result.Items.Count, result.HasMore, page); } [HttpGet("GetAssets")] @@ -204,14 +199,8 @@ public async Task UpdateItem([FromBody] InventoryAdjustmentInput 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 itemIds = items.Items.Where(i => !i.IsDeleted && i.IsActive && i.TrackingMode == (int)InventoryTrackingMode.Bulk).Select(i => i.Id).ToArray(); + var totals = await _stock.GetVisibleQuantitiesAsync(actor, itemIds); var result = new InventoryPage { HasMore = items.HasMore }; foreach (var item in items.Items.Where(i => totals.ContainsKey(i.Id))) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs new file mode 100644 index 000000000..cd3e06c8c --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed partial class InventoryController + { + private IInventoryOperationsService OperationsService => HttpContext.RequestServices.GetService() + ?? throw new InventoryException(409, "OperationUnavailable"); + [HttpGet("GetCounts")] + public Task GetCounts(int page = 0, string locationId = null) + => Query(new InventoryQuery { LocationId = locationId }, page); + [HttpGet("GetCount")] + public async Task GetCount(string id) => Reply(await OperationsService.GetCountAsync(Actor, id)); + [HttpPost("StartCount")] + public async Task StartCount([FromBody] InventoryCountInput input) + { + Required(input); RequireRequestId(input.Id); return Reply(await OperationsService.StartCountAsync(Actor, input)); + } + [HttpPost("SaveCount")] + public async Task SaveCount([FromBody] InventoryCountUpdate input) => Reply(await OperationsService.SaveCountAsync(Actor, Required(input))); + [HttpPost("CompleteCount")] + public async Task CompleteCount([FromBody] InventoryCountComplete input) + { + Required(input); RequireRequestId(input.RequestId); return Reply(await OperationsService.CompleteCountAsync(Actor, input)); + } + [HttpPost("CancelCount")] + public async Task CancelCount([FromBody] InventoryCountComplete input) + { + Required(input); await OperationsService.CancelCountAsync(Actor, input.CountId, input.Revision); + return Reply(new { input.CountId, Cancelled = true }); + } + [HttpGet("GetAlerts")] + public Task GetAlerts(int page = 0, string itemId = null, string locationId = null, string assetId = null) + => Query(new InventoryQuery { ItemId = itemId, LocationId = locationId, AssetId = assetId }, page); + [HttpPost("RefreshAlerts")] + public async Task RefreshAlerts() + { + await OperationsService.RefreshAlertsAsync(Actor); return Reply(new { Refreshed = true }); + } + [HttpPost("BuildReport"), Authorize(Policy = ResgridResources.Reports_View)] + public async Task BuildReport([FromBody] InventoryReportInput input) + { + var report = await OperationsService.BuildReportAsync(Actor, Required(input)); return Reply(report, report.Rows.Count); + } + [HttpGet("GetExpiring"), Authorize(Policy = ResgridResources.Reports_View)] + public async Task GetExpiring(DateTime? fromUtc = null, DateTime? untilUtc = null, string itemId = null, string locationId = null) + { + var report = await OperationsService.BuildReportAsync(Actor, new InventoryReportInput { Kind = InventoryReportKind.Expiration, + FromUtc = fromUtc, UntilUtc = untilUtc, ItemId = itemId, LocationId = locationId }); + return Reply(report, report.Rows.Count); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InventoryPurchasingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InventoryPurchasingController.cs new file mode 100644 index 000000000..ed5603c0f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/InventoryPurchasingController.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Inventories; +using Resgrid.Web.Services.Models.v4.Inventory; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed partial class InventoryController + { + [HttpGet("GetVendorContacts")] + public async Task GetVendorContacts() => Reply(await _purchasing.GetVendorContactsAsync(Actor)); + [HttpGet("GetVendors")] + public Task GetVendors(int page = 0) => Page(page); + [HttpGet("GetVendor")] + public async Task GetVendor(string id) => Reply(await _catalog.GetAsync(Actor, id)); + [HttpPost("SaveVendor")] + public async Task SaveVendor([FromBody] InventoryVendorInput input) => Reply(await _purchasing.SaveVendorAsync(Actor, Required(input))); + [HttpPost("ArchiveVendor"), Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public Task ArchiveVendor([FromBody] InventoryArchiveInput input) => Archive(input); + [HttpGet("GetPurchaseOrders")] + public Task GetPurchaseOrders(int page = 0) => Page(page); + [HttpGet("GetPurchaseOrder")] + public async Task GetPurchaseOrder(string id) => Reply(await _purchasing.GetPurchaseOrderAsync(Actor, id)); + [HttpPost("SavePurchaseOrder")] + public async Task SavePurchaseOrder([FromBody] InventoryPurchaseOrderInput input) + { Required(input); RequireRequestId(input.Id); return Reply(await _purchasing.SavePurchaseOrderAsync(Actor, input)); } + [HttpPost("OrderPurchaseOrder")] + public async Task OrderPurchaseOrder([FromBody] InventoryPurchaseOrderChange input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _purchasing.ChangePurchaseOrderStatusAsync(Actor, input, InventoryPurchaseOrderStatus.Ordered)); } + [HttpPost("CancelPurchaseOrder")] + public async Task CancelPurchaseOrder([FromBody] InventoryPurchaseOrderChange input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _purchasing.ChangePurchaseOrderStatusAsync(Actor, input, InventoryPurchaseOrderStatus.Cancelled)); } + [HttpPost("ReceivePurchaseOrder")] + public async Task ReceivePurchaseOrder([FromBody] InventoryPurchaseReceiptInput input) + { Required(input); RequireRequestId(input.RequestId); return Reply(await _purchasing.ReceivePurchaseOrderAsync(Actor, input)); } + [HttpGet("GetValuation")] + public async Task GetValuation(string locationId = null) => Reply(await _purchasing.GetValuationAsync(Actor, locationId)); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs index 6dd7cdc68..29f0844a3 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Resgrid.Model; +using Resgrid.Model.Inventories; using Resgrid.Model.Services; using Resgrid.Providers.Claims; @@ -25,6 +26,21 @@ public class ConsumeInput public decimal Quantity {get;set;} public string Note {get;set;} } + public class UsageInput + { + public string RecordId { get; set; } + public RmsRecordKind Kind { get; set; } + public long ExpectedRowVersion { get; set; } + public RecordInventoryUsageRequest Request { get; set; } + } + public class CorrectionInput + { + public string RecordId { get; set; } + public RmsRecordKind Kind { get; set; } + public long ExpectedRowVersion { get; set; } + public RecordInventoryUsageCorrection Correction { get; set; } + } + private InventoryActor Actor => new() { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[DataProtectionController.GrantHeader].ToString() }; private readonly IRmsInventoryUsageAdapter _usage; private readonly IRecordsEvidenceService _evidence; private readonly IRecordsAuthorizationService _auth; @@ -33,12 +49,42 @@ public RecordInventoryController(IRmsInventoryUsageAdapter usage,IRecordsEvidenc {_usage=usage;_evidence=evidence;_auth=auth;_cutover=cutover;} private async Task Allowed(string id) => (await _cutover.GetModuleStateAsync(DepartmentId)).RecordsUsable && await _auth.CanUserViewRecordAsync(UserId,id,DepartmentId) && await _auth.HasPermissionAsync(UserId,DepartmentId,PermissionTypes.ViewRestrictedRecords) && await _auth.CanUseSourceInventoryAsync(UserId,DepartmentId,null); [HttpGet("Usage")] - public async Task Usage(string recordId) + public async Task Usage(string recordId, RmsRecordKind kind = RmsRecordKind.Operational) { Response.Headers.CacheControl="no-store"; if (!await Allowed(recordId)) return NotFound(); - var usage=await _usage.GetUsageForRecordAsync(DepartmentId,recordId); - return await Allowed(recordId) ? Ok(usage) : Forbid(); + try + { + var usage = await _usage.GetAuthorizedUsageAsync(Actor, recordId, kind); + return await Allowed(recordId) ? Ok(usage) : Forbid(); + } + catch (InventoryException ex) { return InventoryFailure(ex); } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException) { return BadRequest(new { error = "Inventory usage could not be read." }); } + } + [HttpPost("RecordUsage")] + public Task RecordUsage(UsageInput input, CancellationToken cancellationToken) => input == null ? Task.FromResult(BadRequest()) + : ExecuteUsage(input.RecordId, input.Kind, () => _usage.RecordModernUsageAsync(Actor, input.RecordId, input.Kind, input.ExpectedRowVersion, input.Request, cancellationToken), cancellationToken); + [HttpPost("ReverseUsage")] + public Task ReverseUsage(CorrectionInput input, CancellationToken cancellationToken) => input == null ? Task.FromResult(BadRequest()) + : ExecuteUsage(input.RecordId, input.Kind, () => _usage.ReverseModernUsageAsync(Actor, input.RecordId, input.Kind, input.ExpectedRowVersion, input.Correction, cancellationToken), cancellationToken); + private IActionResult InventoryFailure(InventoryException ex) => StatusCode(ex.StatusCode, new { code = ex.Code, error = "Inventory action could not be completed.", type = ex.Code == "ProtectedDataRequired" ? "protected_data_required" : "inventory_error" }); + private async Task ExecuteUsage(string recordId, RmsRecordKind kind, Func> action, CancellationToken ct) + { + Response.Headers.CacheControl = "no-store"; + if (!await Allowed(recordId)) return NotFound(); + try + { + var usage = await action(); string evidenceId = null; + try { evidenceId = (await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = DepartmentId, RecordId = recordId, RecordKind = kind, Kind = RmsEvidenceKind.InventoryUsage, + CapturedByUserId = UserId, CaptureReason = "Officer recorded inventory usage or correction", OriginClient = RmsOriginClient.Api }, true, ct)).RmsEvidenceArtifactId; } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { } + return await Allowed(recordId) ? StatusCode(201, new { usage, evidenceId, evidenceCaptureRequired = evidenceId == null }) : Forbid(); + } + catch (RecordConcurrencyException) { return Conflict(new { error = "The draft changed. Reload its version and recorded usage before retrying." }); } + catch (InventoryException ex) { return InventoryFailure(ex); } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException) { return BadRequest(new { error = "Inventory usage could not be recorded." }); } } [HttpPost("Consume")] public async Task Consume(ConsumeInput input,CancellationToken cancellationToken) @@ -54,14 +100,14 @@ public async Task Consume(ConsumeInput input,CancellationToken ca : 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) { } + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { } if (!await Allowed(input.RecordId)) return Forbid(); 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});} + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) {return BadRequest(new {error="Inventory consumption could not be completed."});} } } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs index 4fbb57087..9871508f1 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs @@ -15,9 +15,10 @@ namespace Resgrid.Web.Areas.User.Controllers { [WorkOrderFormCulture, Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] - public sealed class InventoryController : SecureBaseController + public sealed partial class InventoryController : SecureBaseController { private readonly IInventoryCatalogService _catalog; + private readonly IInventoryPurchasingService _purchasing; private readonly IInventoryStockService _stock; private readonly IInventoryTransferService _transfers; private readonly IInventoryIssuanceService _issuance; @@ -31,8 +32,8 @@ public sealed class InventoryController : SecureBaseController 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; } + IUnitsService units, IDepartmentGroupsService groups, IDepartmentsService departments, IStringLocalizer strings, IInventoryPurchasingService purchasing = null) + { _catalog = catalog; _stock = stock; _transfers = transfers; _issuance = issuance; _migration = migration; _auth = auth; _grant = grant; _protection = protection; _units = units; _groups = groups; _departments = departments; _strings = strings; _purchasing = purchasing; } private InventoryActor Actor => new() { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { @@ -49,7 +50,11 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context 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 }); + else + { + var localized = _strings[error.Code]; + executed.Result = StatusCode(error.StatusCode, new { message = localized.ResourceNotFound ? _strings["UnableToComplete"].Value : localized.Value, code = error.Code }); + } } } private async Task PageAsync(InventoryWorkspaceView view) where T : InventoryRow @@ -78,6 +83,16 @@ public async Task Index(string tab = "OnHand", int page = 0, stri 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; + if (_purchasing != null && view.CanWrite && tab is "Items" or "Lots") + { + try + { + view.VendorContacts = await _purchasing.GetVendorContactsAsync(Actor); + view.Vendors = await PurchasingChoicesAsync(); + view.CanChooseVendors = true; + } + catch (InventoryException error) when (error.StatusCode == 403) { /* Catalog editing remains available without purchasing access. */ } + } switch (tab) { case "OnHand": await QueryAsync(view, new InventoryQuery { ItemId = itemId, LocationId = locationId }); break; @@ -107,8 +122,9 @@ public async Task Index(string tab = "OnHand", int page = 0, stri } 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 }); + if (view.CanWrite && tab == "Locations") + 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); @@ -147,7 +163,8 @@ public async Task Archive(string kind, string id, int revision) [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; } + var row = await _catalog.GetLegacyTransactionAsync(Actor, inventoryId); + if (row != null) return RedirectToAction("Index", new { tab = "Transaction", id = row.Id }); return NotFound(); } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs new file mode 100644 index 000000000..5cf753cc0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs @@ -0,0 +1,151 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Services; +using Resgrid.Web.Areas.User.Models.Inventory; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public sealed partial class InventoryController + { + private IInventoryOperationsService OperationsService => HttpContext.RequestServices.GetService() + ?? throw new InventoryException(409, "OperationUnavailable"); + private async Task MayViewInventoryReportsAsync() + { + var authorization = HttpContext.RequestServices.GetService(); + return authorization != null && (await authorization.AuthorizeAsync(User, null, ResgridResources.Reports_View)).Succeeded; + } + private async Task RequireInventoryReportsAsync() + { + await _auth.RequireAsync(Actor); + if (!await MayViewInventoryReportsAsync()) throw new InventoryException(403, "PermissionRequired"); + } + + [HttpGet] + public async Task Operations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null) + { + if (tab is not ("Counts" or "Alerts" or "Reports") || page < 0 || page > 10000) throw new InventoryException(400, "InvalidPage"); + var view = new InventoryWorkspaceView { Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId, + Migrated = await _migration.IsMigratedAsync(DepartmentId), CanViewReports = await MayViewInventoryReportsAsync() }; + var groupId = (await _groups.GetGroupForUserAsync(UserId, DepartmentId))?.DepartmentGroupId; + view.CanWrite = await MayWriteAsync(PermissionTypes.AdjustInventory, groupId); + if (tab == "Reports" && !view.CanViewReports) throw new InventoryException(403, "PermissionRequired"); + if (!view.Migrated) return View("Operations", view); + // Protected browser navigation opens a shell; disclosure requires the CSRF-protected reveal POST. + if (HttpMethods.IsGet(Request.Method) && await _protection.IsProtectionEnforcedAsync(DepartmentId)) + { + view.Locked = true; return View("Operations", view); + } + try + { + view.Items = await PurchasingChoicesAsync(); + view.Locations = await PurchasingChoicesAsync(); + if (tab == "Counts") + { + if (id == null) await QueryAsync(view, new InventoryQuery { LocationId = locationId }); + else view.CountDetail = await OperationsService.GetCountAsync(Actor, id); + } + else if (tab == "Alerts") await QueryAsync(view, new InventoryQuery { ItemId = itemId, LocationId = locationId }); + else if (tab == "Reports") + { + foreach (var unit in await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new()) + if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = (int)InventoryLocationType.Unit, UnitId = unit.UnitId })) + view.Units.Add(new InventoryChoice { Id = unit.UnitId.ToString(CultureInfo.InvariantCulture), Name = unit.Name }); + foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new()) + if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = (int)InventoryLocationType.Personnel, UserId = person.UserId })) + view.People.Add(new InventoryChoice { Id = person.UserId, Name = person.Name }); + } + return View("Operations", view); + } + catch (InventoryException error) when (error.Code == "ProtectedDataRequired" && HttpMethods.IsGet(Request.Method)) + { + return View("Operations", new InventoryWorkspaceView { Locked = true, Migrated = view.Migrated, CanWrite = view.CanWrite, + CanViewReports = view.CanViewReports, Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId }); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public Task ReopenOperations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null) + => Operations(tab, page, id, itemId, locationId); + [HttpPost, ValidateAntiForgeryToken] + public async Task GetCounts(int page = 0, string locationId = null) + => Json(await _catalog.QueryAsync(Actor, new InventoryQuery { LocationId = locationId }, page)); + [HttpPost, ValidateAntiForgeryToken] + public async Task GetCount(string id) => Json(await OperationsService.GetCountAsync(Actor, id)); + [HttpPost, ValidateAntiForgeryToken] + public async Task GetAlerts(int page = 0, string itemId = null, string locationId = null, string assetId = null) + => Json(await _catalog.QueryAsync(Actor, new InventoryQuery { ItemId = itemId, LocationId = locationId, AssetId = assetId }, page)); + [HttpPost, ValidateAntiForgeryToken] + public async Task StartCount(InventoryCountInput input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.Id); return Json(await OperationsService.StartCountAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveCount(InventoryCountUpdate input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + return Json(await OperationsService.SaveCountAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task CompleteCount(InventoryCountComplete input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.RequestId); return Json(await OperationsService.CompleteCountAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task CancelCount(string countId, int revision) + { + await OperationsService.CancelCountAsync(Actor, countId, revision); return Json(new { success = true }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task RefreshAlerts() + { + await OperationsService.RefreshAlertsAsync(Actor); return Json(new { success = true }); + } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Reports_View)] + public async Task BuildReport(InventoryReportInput input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + await RequireInventoryReportsAsync(); return Json(await OperationsService.BuildReportAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = ResgridResources.Reports_View)] + public async Task ReportPdf(InventoryReportInput input, [FromServices] IPdfProvider pdf) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + if (pdf == null) throw new InventoryException(409, "OperationUnavailable"); + await RequireInventoryReportsAsync(); + var report = await OperationsService.BuildReportAsync(Actor, input); + byte[] data; + try { data = pdf.ConvertHtmlToPdf(InventoryReportDocuments.Build(report, CultureInfo.CurrentUICulture)); } + catch (InventoryException) { throw; } + catch (Exception error) when (error is not OperationCanceledException) + { + Resgrid.Framework.Logging.LogError($"Inventory report rendering failed: {error.GetType().FullName}."); + throw new InventoryException(409, "ReportUnavailable"); + } + if (data == null || data.Length == 0) throw new InventoryException(409, "OperationUnavailable"); + // Freeze effective date bounds and re-read every source after potentially slow PDF conversion. + // Default expiration reports deliberately have no lower bound, unlike an explicit date-range query. + var freezeBounds = input.Kind != InventoryReportKind.Expiration || input.FromUtc.HasValue || input.UntilUtc.HasValue; + var current = await OperationsService.BuildReportAsync(Actor, new InventoryReportInput { Kind = input.Kind, FromUtc = freezeBounds ? report.FromUtc : null, + UntilUtc = freezeBounds ? report.UntilUtc : null, ItemId = input.ItemId, LocationId = input.LocationId, UnitId = input.UnitId, UserId = input.UserId }); + if (!JToken.DeepEquals(InventoryReportSnapshot(report), InventoryReportSnapshot(current))) throw new InventoryException(409, "ReportChanged"); + await RequireInventoryReportsAsync(); + Response.Headers["Cache-Control"] = "no-store"; + return File(data, "application/pdf", "inventory-" + report.Kind.ToString().ToLowerInvariant() + ".pdf"); + } + private static JToken InventoryReportSnapshot(InventoryReport report) + => JToken.FromObject(new { report.Kind, report.Columns, report.Rows, report.Totals }); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryPurchasingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryPurchasingController.cs new file mode 100644 index 000000000..5bc02cc81 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryPurchasingController.cs @@ -0,0 +1,120 @@ +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 Resgrid.Model; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.Inventory; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public sealed partial class InventoryController + { + private IInventoryPurchasingService PurchasingService => _purchasing ?? throw new InventoryException(409, "OperationUnavailable"); + private static void PurchasingRequestId(string value) + { + if (!Guid.TryParseExact(value, "D", out var id) || id == Guid.Empty) throw new InventoryException(400, "RequestIdRequired"); + } + private async Task> PurchasingChoicesAsync() where T : InventoryRow + { + var choices = new List(); + for (var page = 0; page < 20; page++) + { + var result = await _catalog.ListAsync(Actor, page); + choices.AddRange(result.Items); + if (!result.HasMore) return choices; + } + throw new InventoryException(409, "InventoryTooLarge"); + } + + [HttpGet] + public async Task Purchasing(string tab = "PurchaseOrders", int page = 0, string id = null, string locationId = null) + { + if (tab is not ("Vendors" or "PurchaseOrders" or "Valuation") || page < 0 || page > 10000) throw new InventoryException(400, "InvalidPage"); + var view = new InventoryWorkspaceView { Tab = tab, Page = page, Id = id, LocationId = locationId }; + try + { + view.Migrated = await _migration.IsMigratedAsync(DepartmentId); + view.CanWrite = await MayWriteAsync(PermissionTypes.AdjustInventory, null); + if (!view.Migrated) return View("Purchasing", view); + if (tab == "Valuation") + { + view.Locations = await PurchasingChoicesAsync(); + view.Valuation = await PurchasingService.GetValuationAsync(Actor, locationId); + } + else + { + view.VendorContacts = await PurchasingService.GetVendorContactsAsync(Actor); + view.Vendors = await PurchasingChoicesAsync(); + if (tab == "Vendors") await PageAsync(view); + else + { + if (id == null) await PageAsync(view); + else view.PurchaseOrder = await PurchasingService.GetPurchaseOrderAsync(Actor, id); + view.Items = await PurchasingChoicesAsync(); + if (view.PurchaseOrder != null) + { + // Historical lines retain their frozen labels; load their current tracking rules separately. + foreach (var itemId in view.PurchaseOrder.Lines.Select(x => x.ItemId).Distinct().Where(itemId => view.Items.All(x => x.Id != itemId))) + view.Items.Add(await _catalog.GetAsync(Actor, itemId)); + view.Locations = await PurchasingChoicesAsync(); + view.Lots = await PurchasingChoicesAsync(); + } + } + } + return View("Purchasing", view); + } + catch (InventoryException error) when (error.Code == "ProtectedDataRequired" && HttpMethods.IsGet(Request.Method)) + { + return View("Purchasing", new InventoryWorkspaceView { Locked = true, Tab = tab, Page = page, Id = id, LocationId = locationId }); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public Task ReopenPurchasing(string tab = "PurchaseOrders", int page = 0, string id = null, string locationId = null) => Purchasing(tab, page, id, locationId); + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveVendor(InventoryVendorInput input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + return Json(await PurchasingService.SaveVendorAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken, Authorize(Policy = Resgrid.Providers.Claims.ResgridResources.Inventory_Delete)] + public async Task ArchiveVendor(string id, int revision) + { + await _catalog.ArchiveAsync(Actor, id, revision); + return Json(new { success = true }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task SavePurchaseOrder(InventoryPurchaseOrderInput input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.Id); + return Json(await PurchasingService.SavePurchaseOrderAsync(Actor, input)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task OrderPurchaseOrder(InventoryPurchaseOrderChange input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.RequestId); + return Json(await PurchasingService.ChangePurchaseOrderStatusAsync(Actor, input, InventoryPurchaseOrderStatus.Ordered)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task CancelPurchaseOrder(InventoryPurchaseOrderChange input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.RequestId); + return Json(await PurchasingService.ChangePurchaseOrderStatusAsync(Actor, input, InventoryPurchaseOrderStatus.Cancelled)); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task ReceivePurchaseOrder(InventoryPurchaseReceiptInput input) + { + if (input == null) throw new InventoryException(400, "InvalidInput"); + PurchasingRequestId(input.RequestId); + return Json(await PurchasingService.ReceivePurchaseOrderAsync(Actor, input)); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 7f323f00a..ee4dc55e0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -117,7 +117,7 @@ private string ResolvePwdError(string key) } #region Reporting - private static string ChecklistReportName(ReportTypes type) => type == ReportTypes.ChecklistCompliance ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistComplianceReport") : type == ReportTypes.ChecklistMissed ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistMissedReport") : type.ToString(); + private static string ChecklistReportName(ReportTypes type) => (int)type >= 6 && (int)type <= 13 ? Resgrid.Services.InventoryReportDocuments.Title((Resgrid.Model.Inventories.InventoryReportKind)((int)type - 6)) : type == ReportTypes.ChecklistCompliance ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistComplianceReport") : type == ReportTypes.ChecklistMissed ? Resgrid.Services.ChecklistReportDocuments.Text("ChecklistMissedReport") : type.ToString(); private static Microsoft.AspNetCore.Mvc.Rendering.SelectList ChecklistReportTypes(ReportTypes selected) => new Microsoft.AspNetCore.Mvc.Rendering.SelectList(Enum.GetValues().Select(t => new { Value = (int)t, Text = ChecklistReportName(t) }), "Value", "Text", (int)selected); [HttpGet] @@ -205,6 +205,7 @@ public async Task AddNewScheduledReport() [ValidateAntiForgeryToken] public async Task AddNewScheduledReport(NewScheduledReportView model, CancellationToken cancellationToken) { + if (!Enum.IsDefined(model.ReportType)) return BadRequest(); model.ReportTypes = ChecklistReportTypes(model.ReportType); if (!model.SpecificDatetime) @@ -292,6 +293,7 @@ public async Task EditScheduledReport(int scheduleId) model.ReportTypes = ChecklistReportTypes(model.ReportType); var schedule= await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (schedule == null || schedule.DepartmentId != DepartmentId || schedule.UserId != UserId || schedule.TaskType != (int)TaskTypes.ReportDelivery) return NotFound(); if (schedule.ScheduleType == (int)ScheduleTypes.SpecifcDateTime) { model.SpecificDatetime = true; @@ -317,8 +319,10 @@ public async Task EditScheduledReport(int scheduleId) [HttpPost] [Authorize(Policy = ResgridResources.Profile_Update)] + [ValidateAntiForgeryToken] public async Task EditScheduledReport(EditScheduledReportView model, CancellationToken cancellationToken) { + if (!Enum.IsDefined(model.ReportType)) return BadRequest(); model.ReportTypes = ChecklistReportTypes(model.ReportType); @@ -362,6 +366,7 @@ public async Task EditScheduledReport(EditScheduledReportView mo if (ModelState.IsValid) { ScheduledTask task= await _scheduledTasksService.GetScheduledTaskByIdAsync(model.ScheduleId); + if (task == null || task.DepartmentId != DepartmentId || task.UserId != UserId || task.TaskType != (int)TaskTypes.ReportDelivery) return NotFound(); task.UserId = UserId; if (model.SpecificDatetime) @@ -398,6 +403,38 @@ public async Task EditScheduledReport(EditScheduledReportView mo return View(model); } + [HttpPost] + [Authorize(Policy = ResgridResources.Profile_Update)] + [ValidateAntiForgeryToken] + public async Task ActivateScheduledReport(int scheduleId, CancellationToken cancellationToken) + { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task == null || task.DepartmentId != DepartmentId || task.UserId != UserId || task.TaskType != (int)TaskTypes.ReportDelivery) return NotFound(); + await _scheduledTasksService.EnableScheduledTaskByIdAsync(scheduleId, cancellationToken); + return new EmptyResult(); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Profile_Update)] + [ValidateAntiForgeryToken] + public async Task DeactivateScheduledReport(int scheduleId, CancellationToken cancellationToken) + { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task == null || task.DepartmentId != DepartmentId || task.UserId != UserId || task.TaskType != (int)TaskTypes.ReportDelivery) return NotFound(); + await _scheduledTasksService.DisabledScheduledTaskByIdAsync(scheduleId, cancellationToken); + return new EmptyResult(); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Profile_Update)] + [ValidateAntiForgeryToken] + public async Task DeleteScheduledReport(int scheduleId, CancellationToken cancellationToken) + { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task == null || task.DepartmentId != DepartmentId || task.UserId != UserId || task.TaskType != (int)TaskTypes.ReportDelivery) return NotFound(); + await _scheduledTasksService.DeleteScheduledTask(scheduleId, cancellationToken); + return new EmptyResult(); + } #endregion Reporting #region Staffing Schedules @@ -741,6 +778,8 @@ public async Task EditStaffingSchedule(EditStaffingLevelView mod [Authorize(Policy = ResgridResources.Profile_Update)] public async Task DeactivateSchedule(int scheduleId, CancellationToken cancellationToken) { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); await _scheduledTasksService.DisabledScheduledTaskByIdAsync(scheduleId, cancellationToken); return new EmptyResult(); } @@ -749,6 +788,8 @@ public async Task DeactivateSchedule(int scheduleId, Cancellatio [Authorize(Policy = ResgridResources.Profile_Update)] public async Task ActivateSchedule(int scheduleId, CancellationToken cancellationToken) { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); await _scheduledTasksService.EnableScheduledTaskByIdAsync(scheduleId, cancellationToken); return new EmptyResult(); } @@ -757,6 +798,8 @@ public async Task ActivateSchedule(int scheduleId, CancellationT [Authorize(Policy = ResgridResources.Profile_Update)] public async Task DeleteSchedule(int scheduleId, CancellationToken cancellationToken) { + var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); + if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); await _scheduledTasksService.DeleteScheduledTask(scheduleId, cancellationToken); return new EmptyResult(); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs index eb06934bc..6654b2617 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs @@ -43,6 +43,7 @@ public RecordsInventoryController(IRmsInventoryUsageAdapter usage, IRecordsEvide { _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 string Text(string key, string fallback) => _strings?[key].Value ?? fallback; private Task IsModernAsync() => _migration?.IsMigratedAsync(DepartmentId) ?? Task.FromResult(false); private async Task VersionAsync(string recordId, RmsRecordKind kind) { @@ -89,8 +90,11 @@ private async Task RenderAsync(RecordInventoryView model, bool pr 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); + model.Usage = model.ModernInventory + ? await _usage.GetAuthorizedUsageAsync(Actor, model.RecordId, model.Kind) + : await _usage.GetUsageForRecordAsync(DepartmentId, model.RecordId); } + catch (UnauthorizedAccessException) { return Forbid(); } catch (InventoryException ex) { model.Types.Clear(); model.Locations.Clear(); model.Lots.Clear(); model.Assets.Clear(); model.Usage.Clear(); @@ -127,6 +131,25 @@ private async Task> ChoicesAsync() where T : InventoryRow 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(); + private string RequirePostedRequestId(RecordInventoryView model) + { + // A model's generated default is never proof that a network submission supplied a replay key. + 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"); + return requestId.ToString("D"); + } + private async Task CaptureSavedUsageAsync(string recordId, RmsRecordKind kind, CancellationToken cancellationToken) + { + try + { + await CaptureAsync(recordId, kind, cancellationToken); + return Text("RecordUsageSaved", "Usage saved and supporting evidence captured."); + } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) + { + return Text("RecordUsageEvidencePending", "The inventory action was saved. Evidence could not be captured; use Refresh evidence after resolving access or draft changes. Do not submit the inventory action again."); + } + } [HttpPost] [ValidateAntiForgeryToken] public async Task Consume(RecordInventoryView model,CancellationToken cancellationToken) @@ -140,23 +163,23 @@ public async Task Consume(RecordInventoryView model,CancellationT { 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 + var requestId = RequirePostedRequestId(model); + var line = string.IsNullOrWhiteSpace(model.ExistingTransactionId) + ? new RecordInventoryUsageLine { ItemId = model.ItemId, LocationId = model.LocationId, LotId = model.LotId, AssetId = model.AssetId, Quantity = model.Quantity, UsageType = model.UsageType, Note = model.Note } + : new RecordInventoryUsageLine { ExistingTransactionId = model.ExistingTransactionId.Trim(), UsageType = model.UsageType }; + await _usage.RecordModernUsageAsync(Actor, model.RecordId, model.Kind, model.RowVersion, new RecordInventoryUsageRequest { - 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 } } + RequestId = requestId, + Lines = new List { line } }, 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."; } + message = await CaptureSavedUsageAsync(model.RecordId, model.Kind, cancellationToken); } catch(UnauthorizedAccessException) { return Forbid(); } - 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(RecordConcurrencyException) { message = Text("RecordUsageDraftChanged", "The draft changed. Check the recorded usage before submitting another inventory action."); } + catch(InventoryException ex) { message = ex.Code == "IndependentWitnessRequired" ? Text("RecordUsageWitnessRequired", "Complete the independent witness process in Inventory, then attach the resulting transaction ID here. No stock movement was recorded by this action.") : UnableToComplete; } catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException) { message=UnableToComplete; } if (modern || _protection != null && await _protection.IsProtectionEnforcedAsync(DepartmentId)) { @@ -166,6 +189,32 @@ public async Task Consume(RecordInventoryView model,CancellationT TempData["InventoryMessage"] = message; return RedirectToAction("Edit",new {recordId=model.RecordId,kind=model.Kind}); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Reverse(RecordInventoryView model, CancellationToken cancellationToken) + { + Response.Headers.CacheControl = "no-store"; + if (!(await VersionAsync(model.RecordId, model.Kind)).HasValue || !await IsModernAsync()) return NotFound(); + if (!ModelState.IsValid) { model.ErrorMessage = UnableToComplete; return await RenderAsync(model, true); } + var saved = false; string message; + try + { + var requestId = RequirePostedRequestId(model); + await _usage.ReverseModernUsageAsync(Actor, model.RecordId, model.Kind, model.RowVersion, new RecordInventoryUsageCorrection + { + RequestId = requestId, UsageId = model.UsageId, Reason = model.CorrectionReason, + ExistingTransactionId = string.IsNullOrWhiteSpace(model.ExistingTransactionId) ? null : model.ExistingTransactionId.Trim() + }, cancellationToken); + saved = true; + message = await CaptureSavedUsageAsync(model.RecordId, model.Kind, cancellationToken); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordConcurrencyException) { message = Text("RecordUsageDraftChanged", "The draft changed. Check the recorded usage before submitting another inventory action."); } + catch (InventoryException ex) { message = ex.Code == "IndependentWitnessRequired" ? Text("RecordUsageWitnessRequired", "Complete the independent witness process in Inventory, then attach the resulting transaction ID here. No stock movement was recorded by this action.") : UnableToComplete; } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException) { message = UnableToComplete; } + if (saved) { ModelState.Clear(); model = new RecordInventoryView { RecordId = model.RecordId, Kind = model.Kind }; } + model.ErrorMessage = message; + return await RenderAsync(model, !saved); + } [HttpPost] [ValidateAntiForgeryToken] public async Task RefreshEvidence(string recordId,RmsRecordKind kind,CancellationToken cancellationToken) @@ -173,7 +222,7 @@ public async Task RefreshEvidence(string recordId,RmsRecordKind k Response.Headers.CacheControl = "no-store"; if (!(await VersionAsync(recordId,kind)).HasValue) return NotFound(); string message; - try { await CaptureAsync(recordId,kind,cancellationToken); message="Supporting evidence captured."; } + try { await CaptureAsync(recordId,kind,cancellationToken); message = Text("RecordUsageEvidenceSaved", "Supporting evidence captured."); } catch(UnauthorizedAccessException) { return Forbid(); } catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is InventoryException) { message=UnableToComplete; } if (await IsModernAsync() || _protection != null && await _protection.IsProtectionEnforcedAsync(DepartmentId)) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index 8ffbb23b1..6756d7c06 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -925,7 +925,10 @@ public async Task DeleteUnit(int unitId, CancellationToken cancel 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." }); } + { + TempData["InventoryHolderRetained"] = true; + return RedirectToAction("Index"); + } _eventAggregator.SendMessage(auditEvent); return RedirectToAction("Index"); diff --git a/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs index dbe5afc42..ae95cbca6 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs @@ -16,6 +16,7 @@ public sealed class InventoryWorkspaceView public bool CanTransfer { get; set; } public bool CanIssue { get; set; } public bool CanWitness { get; set; } + public bool CanChooseVendors { get; set; } public int? UnitId { get; set; } public string UserId { get; set; } public string Id { get; set; } @@ -31,6 +32,12 @@ public sealed class InventoryWorkspaceView public List Units { get; set; } = new(); public List People { get; set; } = new(); public List Groups { get; set; } = new(); + public List Vendors { get; set; } = new(); + public List VendorContacts { get; set; } = new(); + public InventoryPurchaseOrderDetail PurchaseOrder { get; set; } + public InventoryValuation Valuation { get; set; } + public InventoryCountDetail CountDetail { get; set; } + public bool CanViewReports { get; set; } 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 06ca36a2b..348c21777 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -153,6 +153,10 @@ public class RecordParticipantEditRow public class RecordInventoryView : RecordsBaseView { + public Resgrid.Model.Inventories.InventoryUsageType UsageType { get; set; } + public string UsageId { get; set; } + public string ExistingTransactionId { get; set; } + public string CorrectionReason { get; set; } public bool ModernInventory { get; set; } public string ItemId { get; set; } public string LocationId { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml new file mode 100644 index 000000000..4524f29ff --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml @@ -0,0 +1,115 @@ +@using System.Globalization +@using System.Linq +@using Newtonsoft.Json +@using Resgrid.Model.Inventories +@using Resgrid.Services +@using Resgrid.Web.Areas.User.Models +@using Resgrid.Web.Areas.User.Models.Inventory +@model InventoryWorkspaceView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["M5Operations"]; + var protectedData = ViewBag.ProtectionEnforced == true; + var count = Model.CountDetail?.Count; + string LocationName(string id) => Model.Locations.FirstOrDefault(l => l.Id == id) is { } row ? InventoryWorkspaceView.Details(row).Name : id; + string ItemName(string id) => Model.Items.FirstOrDefault(i => i.Id == id) is { } row ? InventoryWorkspaceView.Details(row).Name : id; +} +

@localizer["M5Operations"]

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

@localizer["InitializeDescription"]

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

@localizer["M5" + Model.Tab]

+ @if (Model.Tab == "Counts" && count == null) { +

@localizer["M5CountHelp"]

+ @if (Model.CanWrite) { +
+ @Html.AntiForgeryToken() +
+
+
+ +

+ } + + @foreach (var row in Model.Rows.OfType()) { } +
@localizer["Name"]@localizer["Location"]@localizer["Status"]@localizer["Actions"]
@(InventoryWorkspaceView.Details(row).Name)@(row.LocationId == null ? localizer["M5AllLocations"].Value : LocationName(row.LocationId))@localizer["M5CountStatus" + row.Status]
@Html.AntiForgeryToken()
+ } + @if (count != null) { + var details = InventoryWorkspaceView.Details(count); +

@details.Name · @localizer["M5CountStatus" + count.Status]

@details.Note

@localizer["M5SnapshotOn"]: @count.SnapshotOn.ToString("u")

+
+ @Html.AntiForgeryToken() +
+ @for (var i = 0; i < Model.CountDetail.Lines.Count; i++) { + var line = Model.CountDetail.Lines[i]; var data = InventoryWorkspaceView.Details(line); + + } +
@localizer["Items"]@localizer["Location"]@localizer["M5Expected"]@localizer["M5Counted"]@localizer["M5Variance"]
@data.ItemName @data.LotNumber @data.SerialNumber@LocationName(line.LocationId)@line.ExpectedQuantity.ToString(CultureInfo.CurrentCulture) @data.UnitOfMeasure + @if (count.Status == 0 && Model.CanWrite) { } + else { @line.CountedQuantity?.ToString(CultureInfo.CurrentCulture) } + @((line.CountedQuantity - line.ExpectedQuantity)?.ToString(CultureInfo.CurrentCulture))
+ @if (count.Status == 0 && Model.CanWrite) { } +
+ @if (Model.CanWrite && count.Status == 0) { +
@Html.AntiForgeryToken()
+ } + @if (Model.CanWrite && count.Status is 0 or 1) { +
@Html.AntiForgeryToken()
+ } + @if (count.Status == 1) {

@localizer["M5WitnessHelp"]

@Html.AntiForgeryToken()
} + @if (count.Status == 2) { +

@localizer["M5VarianceLines"]: @details.VarianceLineCount

+ @foreach (var total in details.Totals) {

@(total.CurrencyCode ?? localizer["M4UnspecifiedCurrency"].Value): @total.KnownValue.ToString(CultureInfo.CurrentCulture) · @localizer["M4UnknownCost"]: @total.UncostedRows

} + } + } + @if (Model.Tab == "Alerts") { +

@localizer["M5AlertHelp"]

+ @if (Model.CanWrite) {
@Html.AntiForgeryToken()
} +
+ @foreach (var alert in Model.Rows.OfType()) { } +
@localizer["Type"]@localizer["Items"]@localizer["Location"]@localizer["Status"]@localizer["Amount"]@localizer["M5DueOn"]
@localizer["M5AlertType" + alert.AlertType]@ItemName(alert.ItemId)@alert.AssetId @alert.LotId @alert.IssuanceId@(alert.LocationId == null ? localizer["M5AllLocations"].Value : LocationName(alert.LocationId))@localizer[alert.Status == 0 ? "M5Open" : "M5Resolved"]@alert.Quantity?.ToString(CultureInfo.CurrentCulture)@alert.DueOn?.ToString("u")
+ } + @if (Model.Tab == "Reports" && Model.CanViewReports) { +

@localizer["M5ReportHelp"]

+
+ @Html.AntiForgeryToken() +
+
+
+
+
+
+
+ +
+

@localizer["M5ScheduleReports"]

+ } + @if (Model.Tab != "Reports" && count == null) { + 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()
+ } + } + } +
+@section Scripts { + + + +@if (protectedData) { @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/Inventory/Purchasing.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Purchasing.cshtml new file mode 100644 index 000000000..287f26301 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Purchasing.cshtml @@ -0,0 +1,205 @@ +@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 PurchasingField(string name, string label, object value = null, string type = "text", IEnumerable options = null, bool required = false, bool readOnly = false, int maxLength = 16000) { + var wrap = new TagBuilder("div"); wrap.AddCssClass("form-group"); + var caption = new TagBuilder("label"); var identity = "purchase-" + 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; + if (required) element.Attributes["required"] = "required"; + if (readOnly) element.Attributes["readonly"] = "readonly"; + var text = value is DateTime date ? date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + if (options == null) { + element.Attributes["type"] = type; element.Attributes["value"] = text; element.Attributes["maxlength"] = maxLength.ToString(CultureInfo.InvariantCulture); + if (type == "number") { element.Attributes["step"] = "0.000001"; element.Attributes["min"] = name.EndsWith(".UnitCost", StringComparison.Ordinal) ? "0" : "0.000001"; } + if (name == "CurrencyCode") { element.Attributes["pattern"] = "[A-Za-z]{3}"; element.Attributes["autocomplete"] = "off"; } + } 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 PurchasingChoices(IEnumerable choices) => new[] { new SelectListItem { Value = "", Text = "—" } }.Concat(choices.Select(x => new SelectListItem { Value = x.Id, Text = x.Name })); +} +@{ + ViewBag.Title = "Resgrid | " + localizer["M4Purchasing"]; + var protectedData = ViewBag.ProtectionEnforced == true; + var itemOptions = PurchasingChoices(Model.Items.Where(x => !x.IsDeleted && x.IsActive).Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).Name })); + var locationOptions = PurchasingChoices(Model.Locations.Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).Name })); + var contactOptions = PurchasingChoices(Model.VendorContacts.Select(x => new InventoryChoice { Id = x.Id, Name = x.Name })); + string ContactName(string id) => Model.VendorContacts.FirstOrDefault(x => x.Id == id)?.Name ?? id; + var vendorOptions = PurchasingChoices(Model.Vendors.Select(x => new InventoryChoice { Id = x.Id, Name = ContactName(x.ContactId) })); + string LocationName(string id) => Model.Locations.FirstOrDefault(x => x.Id == id) is { } row ? InventoryWorkspaceView.Details(row).Name : id; + var detail = Model.PurchaseOrder; + var order = detail?.Order; + var orderContent = InventoryWorkspaceView.Details(order); +} +

@localizer["M4Purchasing"]

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

@localizer["InitializeDescription"]

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

@localizer["M4" + Model.Tab]

+ @if (Model.Tab == "Vendors") { +

@localizer["M4VendorHelp"]

+ @foreach (var vendor in Model.Rows.OfType()) { + var details = InventoryWorkspaceView.Details(vendor); +

@ContactName(vendor.ContactId)

@vendor.Id
+

@localizer["M4AccountNumber"]: @details.AccountNumber

@details.Note

+ @if (Model.CanWrite) { +
@localizer["Edit"]
+ @Html.AntiForgeryToken() +

@localizer["M4CompanyContact"]: @ContactName(vendor.ContactId)

+ @PurchasingField("Details.AccountNumber", "M4AccountNumber", details.AccountNumber, maxLength: 250)@PurchasingField("Details.Note", "Note", details.Note) + +
+
+ @Html.AntiForgeryToken() +
+ } +
+ } + @if (Model.CanWrite) { +
@localizer["M4NewVendor"]
+ @Html.AntiForgeryToken() + @PurchasingField("ContactId", "M4CompanyContact", options: contactOptions, required: true) + @PurchasingField("Details.AccountNumber", "M4AccountNumber", maxLength: 250)@PurchasingField("Details.Note", "Note") + +
+ } + } + @if (Model.Tab == "PurchaseOrders" && order == null) { +
+ @foreach (var purchase in Model.Rows.OfType()) { + var details = InventoryWorkspaceView.Details(purchase); + + } +
@localizer["M4PurchaseOrderNumber"]@localizer["M4Supplier"]@localizer["Status"]@localizer["M4Currency"]@localizer["Actions"]
@details.Number@details.SupplierName@localizer["M4Status" + purchase.Status]@purchase.CurrencyCode +
@Html.AntiForgeryToken()
+
+ } + @if (Model.Tab == "PurchaseOrders" && order != null) { +

@orderContent.Number · @localizer["M4Status" + order.Status]

+

@orderContent.SupplierName · @order.CurrencyCode

@orderContent.Note

@order.Id +
+ @foreach (var line in detail.Lines.OrderBy(x => x.LineNumber)) { + var content = InventoryWorkspaceView.Details(line); + + } +
@localizer["Items"]@localizer["UnitOfMeasure"]@localizer["M4QuantityOrdered"]@localizer["M4QuantityReceived"]@localizer["M4Remaining"]@localizer["UnitCost"]
@content.ItemName

@content.Note

@content.UnitOfMeasure@line.QuantityOrdered.ToString(CultureInfo.CurrentCulture)@line.QuantityReceived.ToString(CultureInfo.CurrentCulture)@((line.QuantityOrdered - line.QuantityReceived).ToString(CultureInfo.CurrentCulture))@content.UnitCost.ToString(CultureInfo.CurrentCulture) @order.CurrencyCode
+ @if (Model.CanWrite && order.Status is 0 or 1 or 2) { +

@localizer["M4OrderHelp"]

+ @if (order.Status == (int)InventoryPurchaseOrderStatus.Draft) { +
@Html.AntiForgeryToken()
+ } +
@Html.AntiForgeryToken()
+ } + @if (Model.CanWrite && order.Status is 1 or 2 && detail.Lines.Any(x => x.QuantityOrdered > x.QuantityReceived)) { +

@localizer["M4Receive"]

@localizer["M4ReceiptHelp"]

@localizer["M4SerializedReceiptHelp"]

+
+ @Html.AntiForgeryToken() +
+ @{ var receiptIndex = 0; } + @foreach (var line in detail.Lines.Where(x => x.QuantityOrdered > x.QuantityReceived).OrderBy(x => x.LineNumber)) { + var content = InventoryWorkspaceView.Details(line); + var item = Model.Items.FirstOrDefault(x => x.Id == line.ItemId); + var serialized = item?.TrackingMode == (int)InventoryTrackingMode.Serialized; + var prefix = "Lines[" + receiptIndex++ + "]."; + var lotOptions = PurchasingChoices(Model.Lots.Where(x => x.ItemId == line.ItemId).Select(x => new InventoryChoice { Id = x.Id, Name = InventoryWorkspaceView.Details(x).LotNumber })); +
@content.ItemName · @localizer["M4Remaining"]: @((line.QuantityOrdered - line.QuantityReceived).ToString(CultureInfo.CurrentCulture)) @content.UnitOfMeasure + + @PurchasingField(prefix + "Quantity", "M4ReceiveQuantity", serialized ? 1m : line.QuantityOrdered - line.QuantityReceived, "number", required: true, readOnly: serialized) + @PurchasingField(prefix + "LocationId", "ToLocation", options: locationOptions, required: true) + @PurchasingField(prefix + "LotId", "Batch", options: lotOptions, required: item?.RequiresLotTracking == true) + @if (serialized) { + @PurchasingField(prefix + "Asset.SerialNumber", "SerialNumber", required: true, maxLength: 250)@PurchasingField(prefix + "Asset.AssetTag", "AssetTag", maxLength: 250)@PurchasingField(prefix + "Asset.Barcode", "Barcode", maxLength: 250)@PurchasingField(prefix + "Asset.ExpiresOn", "Expiration", type: "date", required: item?.RequiresExpiration == true) + } + +
+ } +
+
+
@Html.AntiForgeryToken()
+ } + @if (detail.Receipts.Count > 0) { +

@localizer["M4Receipts"]

+
+ @foreach (var receipt in detail.Receipts) { + var content = InventoryWorkspaceView.Details(receipt); + + } +
@localizer["Items"]@localizer["Amount"]@localizer["ToLocation"]@localizer["Details"]
@receipt.ItemId@receipt.Quantity.ToString(CultureInfo.CurrentCulture)@LocationName(receipt.ToLocationId)

@content.Note

@Html.AntiForgeryToken()
+ } + } + @if (Model.CanWrite && Model.Tab == "PurchaseOrders" && (order == null || order.Status == (int)InventoryPurchaseOrderStatus.Draft)) { +
@localizer[order == null ? "M4NewPurchaseOrder" : "Edit"] +
+ @Html.AntiForgeryToken() + @PurchasingField("Number", "M4PurchaseOrderNumber", orderContent.Number, required: true, maxLength: 80) + @PurchasingField("VendorId", "M4Supplier", order?.VendorId, options: vendorOptions, required: true) + @PurchasingField("CurrencyCode", "M4Currency", order?.CurrencyCode, required: true, maxLength: 3) + @PurchasingField("Note", "Note", orderContent.Note) +
+ @{ var draftIndex = 0; var draftLines = detail?.Lines.OrderBy(x => x.LineNumber).ToList() ?? new List(); } + @foreach (var line in draftLines.DefaultIfEmpty(new InventoryPurchaseOrderItem { QuantityOrdered = 1 })) { + var prefix = "Lines[" + draftIndex++ + "]."; + var content = InventoryWorkspaceView.Details(line); +
@localizer["Items"] + + @PurchasingField(prefix + "ItemId", "Items", line.ItemId, options: itemOptions, required: true) + @PurchasingField(prefix + "QuantityOrdered", "M4QuantityOrdered", line.QuantityOrdered, "number", required: true) + @PurchasingField(prefix + "UnitCost", "UnitCost", content.UnitCost, "number", required: true) + @PurchasingField(prefix + "Note", "Note", content.Note) + +
+ } +
+
+
+ } + @if (Model.Tab == "Valuation" && Model.Valuation != null) { +

@localizer["M4ValuationScope"]

@localizer["M4AsOfUtc"]:

+
@Html.AntiForgeryToken()@PurchasingField("locationId", "Location", Model.LocationId, options: locationOptions)
+ @if (Model.Valuation.HasNegativeStock) {

@localizer["M4NegativeStock"]

} +
+ @foreach (var total in Model.Valuation.Totals) { } +
@localizer["M4Currency"]@localizer["M4KnownValue"]@localizer["M4UncostedRows"]
@(string.IsNullOrEmpty(total.CurrencyCode) ? localizer["M4UnspecifiedCurrency"].Value : total.CurrencyCode)@total.KnownValue.ToString(CultureInfo.CurrentCulture)@total.UncostedRows
+
+ @foreach (var line in Model.Valuation.Lines) { + + } +
@localizer["Items"]@localizer["Location"]@localizer["Amount"]@localizer["UnitCost"]@localizer["M4KnownValue"]@localizer["M4Currency"]
@line.ItemName@line.AssetId @line.LotId@LocationName(line.LocationId)@line.Quantity.ToString(CultureInfo.CurrentCulture)@(line.UnitCost?.ToString(CultureInfo.CurrentCulture) ?? localizer["M4UnknownCost"].Value)@(line.Value?.ToString(CultureInfo.CurrentCulture) ?? localizer["M4UnknownCost"].Value)@(string.IsNullOrEmpty(line.CurrencyCode) ? localizer["M4UnspecifiedCurrency"].Value : line.CurrencyCode)
+ @if (Model.Valuation.Lines.Count == 0) {

@localizer["NoRows"]

} + } + @if (Model.Tab != "Valuation" && order == null) { + 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()
+ } + } + } +
+@section Scripts { + + + +@if (protectedData) { @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/Inventory/Workspace.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml index ec703e07e..da8cb63a6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml @@ -29,6 +29,7 @@ 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 })); + var vendorOptions = Choice(Model.Vendors.Select(x => new InventoryChoice { Id = x.Id, Name = Model.VendorContacts.FirstOrDefault(c => c.Id == x.ContactId)?.Name ?? x.ContactId })); 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; } @@ -48,6 +49,8 @@ @foreach (var tab in new[] { "OnHand", "Items", "Categories", "Locations", "Lots", "Assets", "Issuances", "Kits", "Transfers", "History" }) {
@Html.AntiForgeryToken()
} +
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()

@localizer[Model.Tab]

@if (Model.ChoicesHaveMore) {

@localizer["MoreChoices"]

} @if (Model.Tab is "OnHand" or "History") { @@ -129,15 +132,19 @@ @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") + @Field("Details.CurrencyCode", "M4Currency", details.CurrencyCode)

@localizer["M4CurrencyHelp"]

+

@localizer["M4AverageUnitCost"]: @(details.AverageUnitCost?.ToString(CultureInfo.CurrentCulture) ?? localizer["M4UnknownCost"].Value)

+ @if (Model.CanChooseVendors) { @Field("Details.PreferredVendorId", "M4PreferredVendor", details.PreferredVendorId, options: vendorOptions) } + else { } } } @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.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.CanChooseVendors) { @Field("details.VendorId", "M4Supplier", options: vendorOptions) }
} + @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.AcquisitionCost", "UnitCost", null, "number")@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")@if (Model.Tab == "OnHand") { @Field("Lines[0].UnitCost", "UnitCost", 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")
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml index 841ec759d..66437c594 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml @@ -36,6 +36,7 @@
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml index 149f920bf..e20fb539b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml @@ -1,12 +1,20 @@ @using Resgrid.Model +@using Resgrid.Model.Inventories @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.

+@{ + ViewBag.Title = "Resgrid | " + inventoryLocalizer["RecordUsageTitle"]; + var controller = Model.Kind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; + var protectedForms = new System.Collections.Generic.List { "#record-inventory-reopen", "#record-inventory-consume", "#record-inventory-evidence" }; + var correctionIndex = 0; + var attachingConsumption = string.IsNullOrEmpty(Model.UsageId) && !string.IsNullOrWhiteSpace(Model.ExistingTransactionId); +} +

@inventoryLocalizer["RecordUsageTitle"]

+

@inventoryLocalizer["RecordUsageHelp"]

+ @if (Model.ModernInventory) {

@inventoryLocalizer["RecordUsageWitnessHelp"]

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

Recorded usage

- +

@inventoryLocalizer["RecordUsageRecorded"]

+
ItemQuantityNoteLedger entryRecorded at (UTC)
@foreach (var usage in Model.Usage) { - var redact = Model.ProtectionEnforced || !string.IsNullOrEmpty(usage.TransactionId); - - - - + var redact = !Model.ModernInventory && Model.ProtectionEnforced; + + + + } -
@inventoryLocalizer["Items"]@inventoryLocalizer["RecordUsageType"]@inventoryLocalizer["Amount"]@inventoryLocalizer["Note"]@inventoryLocalizer["RecordUsageLedgerEntry"]@inventoryLocalizer["RecordUsageRecordedUtc"]@inventoryLocalizer["Actions"]
@(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")
@(redact ? ProtectedDataEnvelope.RedactionValue : usage.ItemName) + @if (!string.IsNullOrEmpty(usage.ItemId)) { @usage.ItemId } + @inventoryLocalizer["RecordUsageType" + (int)usage.UsageType]@usage.Quantity.ToString("+0.######;-0.######;0") @(redact ? ProtectedDataEnvelope.RedactionValue : usage.UnitOfMeasure)@(redact ? ProtectedDataEnvelope.RedactionValue : usage.Note)@if (!string.IsNullOrEmpty(usage.TransactionId)) { @usage.TransactionId } else { @usage.InventoryId } + @if (!string.IsNullOrEmpty(usage.ReversesUsageId)) { @inventoryLocalizer["RecordUsageReverses"] @usage.ReversesUsageId } + @if (usage.IsReversed) { @inventoryLocalizer["RecordUsageReversed"] } + @if (!string.IsNullOrEmpty(usage.PendingReversalTransactionId)) {

@inventoryLocalizer["RecordUsageReconciliationRequired"] @usage.PendingReversalTransactionId

} +
@usage.CapturedOn.ToString("u") + @if (Model.ModernInventory && !string.IsNullOrEmpty(usage.UsageId) && string.IsNullOrEmpty(usage.ReversesUsageId) && !usage.IsReversed) { + var formId = "record-inventory-reverse-" + correctionIndex++; + var retryingCorrection = Model.UsageId == usage.UsageId; + protectedForms.Add("#" + formId); +
@inventoryLocalizer["RecordUsageReverse"] +
+ @Html.AntiForgeryToken() + + + + + +

@inventoryLocalizer["RecordUsageReverseHelp"]

+
+
+ +
+ } +
- @if (Model.ModernInventory) {

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

} +
+ @if (Model.ModernInventory) {

@inventoryLocalizer["RecordUsageLedgerHelp"]

}
- @Html.AntiForgeryToken() - + @Html.AntiForgeryToken() + @if (Model.ModernInventory) { -
-
-
-
+
+
+

@inventoryLocalizer["RecordUsageAttachHelp"]

+
+
+ @inventoryLocalizer["RecordUsageNewConsumption"] +
+

@inventoryLocalizer["RecordUsageLocationHelp"]

+
+
+
+
+
} else { -
-
-
+
+
+
+
+
} -
-
- - Return to report + + @inventoryLocalizer["RecordUsageReturnToReport"]
- @Html.AntiForgeryToken() + @Html.AntiForgeryToken() - +
}
@section Scripts { + @if (Model.ProtectionEnforced) {