From 875e3b2d7679c4a20763c41bbbf4e742a725ddcc Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 9 Sep 2026 20:01:26 -0700 Subject: [PATCH 1/2] RG-T66 Records, Checklists, Inventory and Work Orders work --- .../Areas/User/Checklists/Checklists.ar.resx | 2 + .../Areas/User/Checklists/Checklists.de.resx | 2 + .../Areas/User/Checklists/Checklists.el.resx | 2 + .../Areas/User/Checklists/Checklists.en.resx | 2 + .../Areas/User/Checklists/Checklists.es.resx | 2 + .../Areas/User/Checklists/Checklists.fr.resx | 2 + .../Areas/User/Checklists/Checklists.it.resx | 2 + .../Areas/User/Checklists/Checklists.pl.resx | 2 + .../Areas/User/Checklists/Checklists.resx | 2 + .../Areas/User/Checklists/Checklists.sv.resx | 2 + .../Areas/User/Checklists/Checklists.uk.resx | 2 + .../Areas/User/Records/Records.ar.resx | 46 +++ .../Areas/User/Records/Records.de.resx | 46 +++ .../Areas/User/Records/Records.el.resx | 46 +++ .../Areas/User/Records/Records.en.resx | 46 +++ .../Areas/User/Records/Records.es.resx | 46 +++ .../Areas/User/Records/Records.fr.resx | 46 +++ .../Areas/User/Records/Records.it.resx | 46 +++ .../Areas/User/Records/Records.pl.resx | 46 +++ .../Areas/User/Records/Records.sv.resx | 46 +++ .../Areas/User/Records/Records.uk.resx | 46 +++ .../Areas/User/WorkOrders/WorkOrders.ar.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.de.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.el.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.en.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.es.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.fr.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.it.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.pl.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.sv.resx | 116 +++++++ .../Areas/User/WorkOrders/WorkOrders.uk.resx | 116 +++++++ .../Checklists/ChecklistReporting.cs | 5 +- .../Inventories/InventoryContracts.cs | 1 + .../Inventories/InventoryModels.cs | 1 + .../Records/RecordsReadinessContracts.cs | 220 +++++++++++++ .../Repositories/IRmsRepositories.cs | 2 + .../IWorkOrderMaintenanceRepository.cs | 2 + .../Repositories/IWorkOrderRepository.cs | 7 + .../Services/IRecordsAnalyticsService.cs | 6 + .../Services/IWorkOrdersService.cs | 5 +- .../WorkOrders/WorkOrderMaintenance.cs | 1 + .../WorkOrders/WorkOrderModels.cs | 25 +- .../WorkOrders/WorkOrderOperations.cs | 108 ++++++ .../WorkOrders/WorkOrderReporting.cs | 145 +++++++++ .../WorkOrders/WorkOrderWorkflowPayload.cs | 27 +- .../WorkflowTemplateVariableCatalog.cs | 7 +- .../Resgrid.Model/WorkflowTriggerEventType.cs | 4 + .../ChecklistReportDocuments.cs | 3 +- Core/Resgrid.Services/ChecklistReporting.cs | 20 +- Core/Resgrid.Services/ChecklistsService.cs | 5 +- Core/Resgrid.Services/InventoryPosting.cs | 5 +- .../InventoryWorkOrderAllocations.cs | 69 ++++ Core/Resgrid.Services/InventoryWorkOrders.cs | 3 +- .../Evidence/RecordEvidenceAdapters.cs | 6 +- .../RecordsAnalyticsService.Readiness.cs | 308 ++++++++++++++++++ .../Records/RecordsAnalyticsService.cs | 14 +- Core/Resgrid.Services/ServicesModule.cs | 2 +- .../WorkOrderBulkOperations.cs | 102 ++++++ Core/Resgrid.Services/WorkOrderCsvImport.cs | 44 +++ Core/Resgrid.Services/WorkOrderGdprExport.cs | 11 +- .../WorkOrderInventoryParts.cs | 11 +- .../WorkOrderMaintenanceCore.cs | 6 +- .../WorkOrderMaintenanceGdprExport.cs | 4 + .../WorkOrderNotificationService.cs | 16 +- .../WorkOrderPartAllocations.cs | 129 ++++++++ Core/Resgrid.Services/WorkOrderPolicies.cs | 137 ++++++++ .../WorkOrderRecurrenceService.cs | 4 +- .../WorkOrderReportDocuments.cs | 46 +++ Core/Resgrid.Services/WorkOrderReporting.cs | 244 ++++++++++++++ .../WorkOrderSlaEscalation.cs | 32 ++ .../WorkOrderTimeAnalytics.cs | 74 +++++ .../WorkOrderVendorCharges.cs | 65 ++++ Core/Resgrid.Services/WorkOrdersService.cs | 52 ++- .../WorkflowSampleDataGenerator.cs | 16 +- .../WorkflowTemplateContextBuilder.cs | 9 +- .../Migrations/M0206_AddWorkOrderReporting.cs | 58 ++++ .../M0207_AddWorkOrderOperations.cs | 98 ++++++ .../M0203_AddWorkOrderIntegrationsPg.cs | 2 +- .../M0206_AddWorkOrderReportingPg.cs | 58 ++++ .../M0207_AddWorkOrderOperationsPg.cs | 98 ++++++ .../ChecklistDepartmentCleanup.cs | 2 +- .../RmsRepositories.cs | 14 + .../WorkOrderMaintenanceRepository.cs | 2 +- .../WorkOrderOperationsRepository.cs | 18 + .../WorkOrderReportingRepository.cs | 85 +++++ .../WorkOrderRepository.cs | 2 +- .../Allocations/trigger-baseline.json | 6 +- .../Rms/ChecklistReadinessEvidenceTests.cs | 5 +- .../Rms/RecordsAnalyticsReadinessTests.cs | 264 +++++++++++++++ .../Rms/RecordsAnalyticsServiceTests.cs | 3 +- .../Services/ChecklistP1M4Tests.cs | 4 +- .../Services/ChecklistP2M4Tests.cs | 44 +++ .../Services/ChecklistPr504SecurityTests.cs | 2 +- .../Services/InventoryDatabaseFixture.cs | 4 +- .../Services/InventoryPr507DatabaseTests.cs | 3 +- .../InventoryWorkOrderDatabaseTests.cs | 2 +- ...ventoryWorkOrderOperationsDatabaseTests.cs | 99 ++++++ .../Services/ProtectedReadServiceTests.cs | 27 +- .../Services/WorkOrderDatabaseTests.cs | 4 +- .../Services/WorkOrderGdprTests.cs | 13 + .../Services/WorkOrderHttpTests.cs | 25 +- .../Services/WorkOrderOperationsTests.cs | 148 +++++++++ .../Services/WorkOrderP2M1Tests.cs | 4 +- .../Services/WorkOrderPr505Tests.cs | 7 +- .../Services/WorkOrderProtectionTests.cs | 4 +- .../WorkOrderReportingDatabaseTests.cs | 163 +++++++++ .../Services/WorkOrderReportingTests.cs | 203 ++++++++++++ .../Web/work-orders-maintenance.test.cjs | 8 +- .../v4/RecordAnalyticsController.cs | 9 + .../v4/WorkOrderMaintenanceController.cs | 2 +- .../v4/WorkOrderOperationsController.cs | 45 +++ .../v4/WorkOrderReportingController.cs | 30 ++ .../Controllers/v4/WorkOrdersController.cs | 5 +- .../v4/Records/RecordsAnalyticsApiModels.cs | 1 + .../Resgrid.Web.Services.xml | 3 +- .../Controllers/RecordsAnalyticsController.cs | 5 + .../WorkOrderFormValuesAttribute.cs | 35 ++ .../WorkOrderOperationsController.cs | 75 +++++ .../WorkOrderReportingController.cs | 32 ++ .../User/Controllers/WorkOrdersController.cs | 10 +- .../Records/RecordsAnalyticsViewModels.cs | 6 + .../WorkOrders/WorkOrderOperationsViews.cs | 34 ++ .../User/Models/WorkOrders/WorkOrderViews.cs | 3 +- .../Views/RecordsAnalytics/Readiness.cshtml | 168 ++++++++++ .../RecordsAnalytics/_AnalyticsShell.cshtml | 3 +- .../Areas/User/Views/WorkOrders/Bulk.cshtml | 35 ++ .../Areas/User/Views/WorkOrders/Detail.cshtml | 19 +- .../Areas/User/Views/WorkOrders/Index.cshtml | 6 +- .../Areas/User/Views/WorkOrders/Locked.cshtml | 3 +- .../User/Views/WorkOrders/Operations.cshtml | 55 ++++ .../Areas/User/Views/WorkOrders/Policy.cshtml | 25 ++ .../User/Views/WorkOrders/Reports.cshtml | 44 +++ .../User/Views/WorkOrders/_ReportCosts.cshtml | 5 + .../Views/WorkOrders/_ReportFields.cshtml | 9 + .../js/app/internal/workorders/work-orders.js | 3 +- .../Logic/MaintenanceEscalationLogic.cs | 5 +- .../Logic/MaintenanceGenerationLogic.cs | 5 +- 138 files changed, 5791 insertions(+), 129 deletions(-) create mode 100644 Core/Resgrid.Model/Records/RecordsReadinessContracts.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs create mode 100644 Core/Resgrid.Services/InventoryWorkOrderAllocations.cs create mode 100644 Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs create mode 100644 Core/Resgrid.Services/WorkOrderBulkOperations.cs create mode 100644 Core/Resgrid.Services/WorkOrderCsvImport.cs create mode 100644 Core/Resgrid.Services/WorkOrderPartAllocations.cs create mode 100644 Core/Resgrid.Services/WorkOrderPolicies.cs create mode 100644 Core/Resgrid.Services/WorkOrderReportDocuments.cs create mode 100644 Core/Resgrid.Services/WorkOrderReporting.cs create mode 100644 Core/Resgrid.Services/WorkOrderSlaEscalation.cs create mode 100644 Core/Resgrid.Services/WorkOrderTimeAnalytics.cs create mode 100644 Core/Resgrid.Services/WorkOrderVendorCharges.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0206_AddWorkOrderReportingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkOrderOperationsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderOperationsTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderReportingDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderReportingTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormValuesAttribute.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportCosts.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportFields.cshtml diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index ff30b709..b8e6fb4a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -691,4 +691,6 @@ تخزين الصيانة غير متاح. ليس لديك إذن لتنفيذ هذا الإجراء. اختر هدفًا متاحًا في قسمك. + تعذّر إعادة بناء بعض الحالات التاريخية لأوامر العمل. لا تدّعي الحزمة تغطية الصيانة بالكامل. + تقتصر أدلة أوامر العمل على النطاق المصرّح للقارئ بالوصول إليه حاليًا. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index 61f4f4bd..99b395dd 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -691,4 +691,6 @@ Wartungsspeicher nicht verfügbar. Sie haben keine Berechtigung für diese Aktion. Wählen Sie ein verfügbares Ziel in Ihrer Organisation. + Einige historische Arbeitsauftragszustände konnten nicht rekonstruiert werden. Das Paket beansprucht keine vollständige Wartungsabdeckung. + Arbeitsauftragsnachweise sind auf den aktuell berechtigten Lesebereich beschränkt. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 883236b8..8cf5714b 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -691,4 +691,6 @@ Ο χώρος αποθήκευσης συντήρησης δεν είναι διαθέσιμος. Δεν έχετε άδεια για αυτή την ενέργεια. Επιλέξτε έναν διαθέσιμο στόχο στο τμήμα σας. + Δεν ήταν δυνατή η ανακατασκευή ορισμένων ιστορικών καταστάσεων εντολών. Το πακέτο δεν εγγυάται πλήρη κάλυψη συντήρησης. + Τα τεκμήρια εντολών περιορίζονται στο τρέχον εξουσιοδοτημένο πεδίο πρόσβασης του αναγνώστη. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index bed28f45..a78f1888 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -691,4 +691,6 @@ Maintenance storage is unavailable. You do not have permission to perform this action. Select an available target in your department. + Some historical work-order states could not be reconstructed. The packet does not claim complete maintenance coverage. + Work-order evidence is limited to the reader’s current authorized scope. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index 4bdb95d7..c4a6cbb1 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -691,4 +691,6 @@ El almacenamiento de mantenimiento no está disponible. No tiene permiso para realizar esta acción. Seleccione un destino disponible en su departamento. + No se pudieron reconstruir algunos estados históricos de las órdenes. El paquete no garantiza una cobertura completa del mantenimiento. + Las pruebas de las órdenes se limitan al ámbito autorizado actual del lector. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index 57f21287..2334de67 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -691,4 +691,6 @@ Le stockage de maintenance est indisponible. Vous n’avez pas l’autorisation d’effectuer cette action. Sélectionnez une cible disponible dans votre service. + Certains états historiques des ordres n’ont pas pu être reconstitués. Le dossier ne garantit pas une couverture complète de la maintenance. + Les preuves des ordres sont limitées au périmètre actuellement autorisé du lecteur. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index cc6fc7b3..652caff9 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -691,4 +691,6 @@ L'archivio di manutenzione non è disponibile. Non hai l’autorizzazione per eseguire questa azione. Seleziona un destinatario disponibile nel tuo dipartimento. + Non è stato possibile ricostruire alcuni stati storici degli ordini. Il pacchetto non garantisce una copertura completa della manutenzione. + Le prove degli ordini sono limitate all’ambito attualmente autorizzato del lettore. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 8c6241a6..83fc3279 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -691,4 +691,6 @@ Magazyn danych konserwacji jest niedostępny. Nie masz uprawnień do wykonania tej czynności. Wybierz dostępny obiekt w swoim dziale. + Nie udało się odtworzyć niektórych historycznych stanów zleceń. Pakiet nie gwarantuje pełnego pokrycia prac konserwacyjnych. + Dowody zleceń są ograniczone do bieżącego zakresu uprawnień odbiorcy. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index bed28f45..a78f1888 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -691,4 +691,6 @@ Maintenance storage is unavailable. You do not have permission to perform this action. Select an available target in your department. + Some historical work-order states could not be reconstructed. The packet does not claim complete maintenance coverage. + Work-order evidence is limited to the reader’s current authorized scope. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index 56410f8b..0afbb9f1 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -691,4 +691,6 @@ Underhållslagringen är inte tillgänglig. Du har inte behörighet att utföra denna åtgärd. Välj ett tillgängligt mål inom din avdelning. + Vissa historiska tillstånd för arbetsorder kunde inte återskapas. Paketet garanterar inte fullständig underhållstäckning. + Arbetsorderbevisen begränsas till läsarens nuvarande behörighet. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index 39c980e1..d1c8cfac 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -691,4 +691,6 @@ Сховище технічного обслуговування недоступне. Ви не маєте дозволу на цю дію. Виберіть доступний об’єкт у своєму підрозділі. + Деякі історичні стани нарядів не вдалося відновити. Пакет не гарантує повного охоплення технічного обслуговування. + Докази нарядів обмежені поточним дозволеним обсягом доступу читача. diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 04dd7be6..d5187f3b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1946,4 +1946,50 @@ أصبحت متأخرة خلال الفترة ضمن الهدف عبء العمل + الأصول + الأصول المخصصة + حسب الحالة + الالتزام بقوائم الفحص + الفحوصات + المتوقعة + الفترة المقاسة + معدل الإكمال + التكاليف + التغطية + تالفة + طارئة + المعدات + حزم الجاهزية + تنتهي خلال 30 يومًا + رفضت الوحدة هذا العرض لحسابك؛ لم يُحتسب شيء منها. + الجاهزية + جاهزية المركبات والمعدات مجمّعة من وحدات قوائم الفحص والصيانة والمخزون. كل وحدة تتحقق من صلاحيتك بنفسها؛ تضيف السجلات فقط حزم الجاهزية التي التقطتها. + عمليات الصرف المعلقة + مصروفة + العمالة + الفائتة + بدون أوقات + متوسط وقت الإصلاح (س) + تحتاج إلى اهتمام + معلّقة + في الوقت المحدد + معدل الالتزام بالوقت + المفتوحة حسب العمر + المفتوحة حسب الأولوية + قيد الإصلاح + إرجاعات متأخرة + الحزم + الحزم الملتقطة + القطع + سجلات بحزمة + تُحجب تفاصيل الإجابات لمستوى وصولك؛ الأعداد كاملة. + إصلاحات موقوتة + متجاوزة + الحالة + الفحوصات المتوقعة والفائتة حسب اليوم + مصدر غير متاح + الوحدة + لوحة جاهزية الوحدات + بنود بدون سعر + أوامر العمل diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 49a0aa3e..5befc67c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1946,4 +1946,50 @@ Im Zeitraum überfällig geworden Im Ziel Arbeitsaufkommen + Ausrüstungsgegenstände + Zugewiesene Gegenstände + Nach Zustand + Checklisten-Einhaltung + Prüfungen + Erwartet + Gemessener Zeitraum + Erledigungsquote + Kosten + Abdeckung + Beschädigt + Notfall + Ausrüstung + Bereitschaftspakete + laufen innerhalb von 30 Tagen ab + Das Modul hat diese Ansicht für Ihr Konto abgelehnt; nichts daraus wird gezählt. + Einsatzbereitschaft + Einsatzbereitschaft von Fahrzeugen und Ausrüstung, zusammengeführt aus den Modulen Checklisten, Instandhaltung und Lagerbestand. Jedes Modul prüft Ihre Berechtigung selbst; Datensätze steuern nur die erfassten Bereitschaftspakete bei. + Offene Ausgaben + Ausgegeben + Arbeit + Versäumt + ohne Zeiten + Mittlere Reparaturzeit (h) + benötigen Aufmerksamkeit + Zurückgestellt + termingerecht + Termintreue + Offen nach Alter + Offen nach Priorität + In Reparatur + Überfällige Rückgaben + Pakete + Erfasste Pakete + Teile + Datensätze mit Paket + Antwortdetails werden für Ihre Zugriffsstufe zurückgehalten; die Zählungen sind vollständig. + gemessene Reparaturen + übersprungen + Zustand + Erwartete und versäumte Prüfungen je Tag + Nicht verfügbare Quelle + Einheit + Bereitschaftstafel der Einheiten + Unbepreiste Positionen + Arbeitsaufträge diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index d4903fce..bde9eef9 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1946,4 +1946,50 @@ Έγιναν εκπρόθεσμες στο διάστημα Εντός στόχου Φόρτος εργασίας + Στοιχεία εξοπλισμού + Ανατεθειμένα στοιχεία + Ανά κατάσταση + Συμμόρφωση λιστών ελέγχου + Έλεγχοι + Αναμενόμενοι + Μετρούμενη περίοδος + Ποσοστό ολοκλήρωσης + Κόστος + Κάλυψη + Κατεστραμμένα + Επείγουσες + Εξοπλισμός + Πακέτα ετοιμότητας + λήγουν εντός 30 ημερών + Η ενότητα αρνήθηκε αυτή την προβολή για τον λογαριασμό σας· τίποτα από αυτήν δεν μετριέται. + Ετοιμότητα + Ετοιμότητα οχημάτων και εξοπλισμού συντεθειμένη από τις ενότητες λιστών ελέγχου, συντήρησης και αποθέματος. Κάθε ενότητα σας εξουσιοδοτεί μόνη της· οι Εγγραφές προσθέτουν μόνο τα πακέτα ετοιμότητας που κατέγραψαν. + Εκκρεμείς χορηγήσεις + Χορηγημένα + Εργασία + Χαμένοι + χωρίς χρόνους + Μέσος χρόνος επισκευής (ω) + χρειάζονται προσοχή + Σε αναμονή + εγκαίρως + Ποσοστό εγκαιρότητας + Ανοικτές ανά ηλικία + Ανοικτές ανά προτεραιότητα + Σε επισκευή + Εκπρόθεσμες επιστροφές + Πακέτα + Καταγεγραμμένα πακέτα + Ανταλλακτικά + Εγγραφές με πακέτο + Οι λεπτομέρειες απαντήσεων αποκρύπτονται για το επίπεδο πρόσβασής σας· οι μετρήσεις είναι πλήρεις. + χρονομετρημένες επισκευές + παραλείφθηκαν + Κατάσταση + Αναμενόμενοι και χαμένοι έλεγχοι ανά ημέρα + Μη διαθέσιμη πηγή + Μονάδα + Πίνακας ετοιμότητας μονάδων + Γραμμές χωρίς τιμή + Εντολές εργασίας diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index 41cdb724..49bd31ea 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1946,4 +1946,50 @@ Went overdue in window Within target Workload + Assets + Assets assigned + By status + Checklist compliance + Checks + Expected + Measured period + Completion rate + Costs + Coverage + Damaged + Emergency + Equipment + Readiness packets + expiring within 30 days + The module refused this view for your account; nothing from it is counted. + Readiness + Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules. Each module authorizes you itself; Records adds only the readiness packets it captured. + Issuances outstanding + Issued + Labor + Missed + without times + Mean time to repair (h) + need attention + On hold + on time + On-time rate + Open by age + Open by priority + Out for repair + Overdue returns + Packets + Packets captured + Parts + Records with a packet + Answer-level detail is withheld for your access level; the counts are complete. + repairs timed + skipped + State + Expected and missed checks by day + Unavailable source + Unit + Unit readiness board + Unpriced lines + Work orders diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 8047ad28..fda0feaa 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1946,4 +1946,50 @@ Vencidas en el período Dentro del objetivo Carga de trabajo + Activos + Activos asignados + Por estado + Cumplimiento de listas de verificación + Verificaciones + Esperadas + Período medido + Tasa de cumplimiento + Costos + Cobertura + Dañados + Emergencia + Equipos + Paquetes de preparación + vencen en 30 días + El módulo rechazó esta vista para su cuenta; no se cuenta nada de él. + Preparación operativa + Preparación de vehículos y equipos compuesta a partir de los módulos de listas de verificación, mantenimiento e inventario. Cada módulo le autoriza por sí mismo; Registros solo añade los paquetes de preparación que capturó. + Entregas pendientes + Entregados + Mano de obra + Omitidas + sin tiempos + Tiempo medio de reparación (h) + requieren atención + En espera + a tiempo + Tasa de puntualidad + Abiertas por antigüedad + Abiertas por prioridad + En reparación + Devoluciones vencidas + Paquetes + Paquetes capturados + Repuestos + Registros con paquete + El detalle de las respuestas se oculta para su nivel de acceso; los recuentos están completos. + reparaciones cronometradas + omitidas a propósito + Estado + Verificaciones esperadas y omitidas por día + Fuente no disponible + Unidad + Tablero de preparación de unidades + Líneas sin precio + Órdenes de trabajo diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 9d100c38..6cdff23b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1946,4 +1946,50 @@ Passées en retard sur la période Dans l'objectif Charge de travail + Actifs + Actifs affectés + Par statut + Conformité des listes de contrôle + Contrôles + Attendus + Période mesurée + Taux de réalisation + Coûts + Couverture + Endommagés + Urgence + Équipements + Dossiers de disponibilité + expirent sous 30 jours + Le module a refusé cette vue pour votre compte ; rien n'en est comptabilisé. + Disponibilité opérationnelle + Disponibilité des engins et des équipements composée à partir des modules listes de contrôle, maintenance et inventaire. Chaque module vous autorise lui-même ; les dossiers n'ajoutent que les dossiers de disponibilité qu'ils ont capturés. + Remises en cours + Remis + Main-d'œuvre + Manqués + sans durée + Temps moyen de réparation (h) + nécessitent une attention + En attente + à l'heure + Taux de ponctualité + Ouverts par ancienneté + Ouverts par priorité + En réparation + Retours en retard + Dossiers + Dossiers capturés + Pièces + Dossiers avec un dossier de disponibilité + Le détail des réponses est masqué pour votre niveau d'accès ; les décomptes sont complets. + réparations chronométrées + ignorés + État + Contrôles attendus et manqués par jour + Source indisponible + Unité + Tableau de disponibilité des unités + Lignes non chiffrées + Ordres de travail diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 5f903670..e0d32260 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1946,4 +1946,50 @@ Diventate scadute nel periodo Entro l'obiettivo Carico di lavoro + Beni + Beni assegnati + Per stato + Conformità delle liste di controllo + Controlli + Previsti + Periodo misurato + Tasso di completamento + Costi + Copertura + Danneggiati + Emergenza + Attrezzature + Pacchetti di prontezza + in scadenza entro 30 giorni + Il modulo ha rifiutato questa vista per il tuo account; nulla di esso viene conteggiato. + Prontezza operativa + Prontezza di mezzi e attrezzature composta dai moduli liste di controllo, manutenzione e inventario. Ogni modulo ti autorizza da sé; i record aggiungono solo i pacchetti di prontezza che hanno acquisito. + Consegne in corso + Consegnati + Manodopera + Mancati + senza tempi + Tempo medio di riparazione (h) + richiedono attenzione + In sospeso + puntuali + Tasso di puntualità + Aperti per anzianità + Aperti per priorità + In riparazione + Restituzioni in ritardo + Pacchetti + Pacchetti acquisiti + Ricambi + Record con pacchetto + Il dettaglio delle risposte è nascosto per il tuo livello di accesso; i conteggi sono completi. + riparazioni cronometrate + saltati + Stato + Controlli previsti e mancati per giorno + Origine non disponibile + Unità + Quadro di prontezza delle unità + Righe senza prezzo + Ordini di lavoro diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 74aa285a..f0aa832d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1946,4 +1946,50 @@ Przeterminowane w okresie W granicach celu Obciążenie pracą + Środki trwałe + Przypisane środki + Według statusu + Zgodność list kontrolnych + Kontrole + Oczekiwane + Mierzony okres + Wskaźnik wykonania + Koszty + Pokrycie + Uszkodzone + Awaryjne + Sprzęt + Pakiety gotowości + wygasają w ciągu 30 dni + Moduł odmówił tego widoku dla Twojego konta; nic z niego nie jest liczone. + Gotowość + Gotowość pojazdów i sprzętu zestawiona z modułów list kontrolnych, konserwacji i magazynu. Każdy moduł autoryzuje Cię samodzielnie; Rekordy dodają tylko przechwycone pakiety gotowości. + Wydania nierozliczone + Wydane + Robocizna + Pominięte + bez czasów + Średni czas naprawy (h) + wymagają uwagi + Wstrzymane + na czas + Wskaźnik terminowości + Otwarte według wieku + Otwarte według priorytetu + W naprawie + Zaległe zwroty + Pakiety + Przechwycone pakiety + Części + Rekordy z pakietem + Szczegóły odpowiedzi są ukryte dla Twojego poziomu dostępu; liczby są kompletne. + naprawy zmierzone + pominięte celowo + Stan + Kontrole oczekiwane i pominięte według dnia + Niedostępne źródło + Jednostka + Tablica gotowości jednostek + Pozycje bez ceny + Zlecenia pracy diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index a2141de5..4484cbc4 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1946,4 +1946,50 @@ Blev försenade under perioden Inom målet Arbetsbelastning + Tillgångar + Tilldelade tillgångar + Per status + Efterlevnad av checklistor + Kontroller + Förväntade + Uppmätt period + Genomförandegrad + Kostnader + Täckning + Skadade + Akut + Utrustning + Beredskapspaket + går ut inom 30 dagar + Modulen nekade den här vyn för ditt konto; inget därifrån räknas. + Beredskap + Beredskap för fordon och utrustning sammansatt från modulerna checklistor, underhåll och lager. Varje modul behörighetsprövar dig själv; Poster bidrar bara med de beredskapspaket som fångats. + Utestående utlämningar + Utlämnade + Arbete + Missade + utan tider + Genomsnittlig reparationstid (h) + kräver uppmärksamhet + Pausade + i tid + Punktlighetsgrad + Öppna per ålder + Öppna per prioritet + På reparation + Försenade återlämningar + Paket + Fångade paket + Delar + Poster med paket + Svarsdetaljer undanhålls för din behörighetsnivå; räkningarna är fullständiga. + tidsatta reparationer + överhoppade + Tillstånd + Förväntade och missade kontroller per dag + Otillgänglig källa + Enhet + Beredskapstavla för enheter + Oprissatta rader + Arbetsorder diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index c723c911..3210e0b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1946,4 +1946,50 @@ Стали простроченими за період У межах цілі Навантаження + Активи + Призначені активи + За статусом + Дотримання контрольних списків + Перевірки + Очікувано + Вимірюваний період + Частка виконання + Витрати + Покриття + Пошкоджено + Аварійні + Обладнання + Пакети готовності + спливають протягом 30 днів + Модуль відмовив у цьому перегляді для вашого облікового запису; нічого з нього не враховано. + Готовність + Готовність техніки та обладнання, зібрана з модулів контрольних списків, обслуговування та інвентарю. Кожен модуль авторизує вас самостійно; Записи додають лише захоплені пакети готовності. + Незакриті видачі + Видано + Праця + Пропущено + без часу + Середній час ремонту (год) + потребують уваги + Призупинено + вчасно + Частка вчасних + Відкриті за віком + Відкриті за пріоритетом + У ремонті + Прострочені повернення + Пакети + Захоплені пакети + Запчастини + Записи з пакетом + Деталі відповідей приховано для вашого рівня доступу; підрахунки повні. + ремонтів із часом + пропущено навмисно + Стан + Очікувані та пропущені перевірки за днями + Недоступне джерело + Підрозділ + Табло готовності підрозділів + Рядки без ціни + Наряди на роботи diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx index b1f5e8fb..06db3f88 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx @@ -260,4 +260,120 @@ تم فرض حظر السلامة تم رفع حظر السلامة تم تأجيل الموعد + تقارير الصيانة + سجل الخدمة + أُنشئ من (UTC، شامل) + أُنشئ قبل (UTC، غير شامل) + وقت الحساب (UTC) + إجمالي أوامر العمل + أوامر العمل المفتوحة + أوامر العمل المتأخرة + متوسط زمن الإصلاح (ساعات) + الإصلاحات المكتملة المقاسة + إصلاحات تفتقر إلى أوقات صالحة + المفتوح حسب الأولوية + المفتوح حسب العمر + ٠–٦ أيام + ٧–٢٩ يومًا + ٣٠–٨٩ يومًا + ٩٠ يومًا فأكثر + تصدير CSV + التكاليف + الإجمالي المعروف + سجلات عمل بتكلفة غير معروفة + سجلات قطع بتكلفة غير معروفة + عملة غير معروفة + تاريخ الإنشاء + تاريخ البدء + تاريخ الإكمال + زمن الإصلاح (ساعات) + المراجعة + هذا التقرير كبير جدًا. قلّل النطاق الزمني أو حدّد الأهداف بدقة أكبر. + اختر نطاقًا زمنيًا صالحًا؛ تدعم تقارير لوحة المعلومات مدة تصل إلى ٣٦٦ يومًا. + الحالة الحالية لأوامر العمل المنشأة ضمن النطاق المحدد بالتوقيت UTC. تشمل الأوامر المفتوحة الأعمال المكتملة التي تنتظر الإغلاق. متوسط زمن الإصلاح هو المدة من أول بدء إلى آخر إكمال، بما فيها فترات التعليق وإعادة الفتح؛ تُستبعد الأوقات غير الصالحة. تُقرّب التكاليف لكل سجل وتُفصل حسب العملة، وتُستبعد القطع الملغاة أو غير المصروفة. تبقى التكاليف وعملات العمل القديمة غير المعروفة كذلك؛ لا تُفترض أسعار صرف. + سجل الخدمة الكامل ما لم تُحدّد تواريخ. يمثّل كل صف في CSV أمر عمل وعملة واحدة. تستبعد التكاليف المعروفة القطع الملغاة أو غير المصروفة؛ تُعرض الأسعار المفقودة وعملات العمل القديمة على أنها غير معروفة. + حالة أمر العمل وقت البلاغ، مع مراجع مراجعات غير قابلة للتغيير في البيان. تُدرج أعمال الصيانة المفتوحة الأقدم؛ وتخضع الأعمال المكتملة لفترة الاسترجاع. تُعلّم الحالة التاريخية التي يتعذّر استعادتها بأنها غير متاحة. + +عمليات أوامر العمل +سياسة العمليات +موافقات الإنفاق +اشتراط الموافقة عند تجاوز الحدود +حد الإنفاق +المبلغ المطلوب اعتماده +طلب الموافقة +اعتماد الإنفاق +رفض الإنفاق +القرار +لم تُطلب موافقة +طُلبت الموافقة +الإنفاق معتمد +الإنفاق مرفوض +تقويم العمل +أيام العمل +تواريخ العطلات (YYYY-MM-DD) +الوقت المستهدف للاستجابة (دقائق العمل) +الوقت المستهدف للإصلاح (دقائق العمل) +الموعد النهائي للاستجابة (UTC) +الموعد النهائي للإصلاح (UTC) +وقت الاستجابة (UTC) +رسوم الموردين +إضافة رسوم مورد +إلغاء الرسوم +مرجع الفاتورة +تاريخ الخدمة +تخصيص القطع +الكمية المحجوزة +الكمية المصروفة +الكمية المستهلكة +الكمية غير المستخدمة المُعادة +سجل حركة القطع +حجز القطع +صرف القطع المحجوزة +تسجيل الاستهلاك +إعادة القطع غير المستخدمة +إلغاء الحجز +موقع صرف القطع +العمليات الجماعية +الإسناد الجماعي +معاينة الصفوف +تطبيق الصفوف الصالحة +نتائج الدفعة +تم التطبيق +جاهز للتطبيق +رقم الصف +تحديد +استيراد أوامر العمل +بيانات CSV +تنزيل قالب CSV +ساعات الإصلاح الفعلي +ساعات الانتظار +ساعات التعطل بسبب إيقاف السلامة +أوامر بسجل زمني غير مكتمل +الالتزام بالصيانة الوقائية +في الموعد / مستحق +الأعطال المتكررة +أهداف الاستجابة المتجاوزة +أهداف الإصلاح المتجاوزة +تحقق من قواعد الإنفاق والتقويم وأهداف الخدمة. +يلزم اعتماد مستقل صالح لهذه التكلفة والعملة. +يجب أن يبتّ مدير آخر مخوّل في هذا الطلب. +حدد من صف واحد إلى 200 صف صالح بأرقام صفوف فريدة. +تحقق من أعمدة هذا الصف وأرقامه وتنسيق تاريخ UTC. +تغيرت الدفعة. عاينها مجددًا قبل التطبيق. +تتجاوز الكمية الرصيد المحجوز أو المصروف المتاح. +ألغِ الحجوزات واستهلك القطع المصروفة أو أعدها قبل إكمال هذا الأمر. +تغيرت عملة تكلفة المخزون. راجع الحجز قبل المتابعة. +لا يمكن لمقدم الطلب اعتماد طلبه بنفسه. +عاين حتى 200 صف. يُطبّق كل صف صالح منفردًا، وتظل الأخطاء ظاهرة، ولا تتكرر الصفوف الناجحة عند إعادة المحاولة. +الصق بيانات CSV باستخدام القالب المُنزّل. احتفظ بأسماء الأعمدة واستخدم النقطة العشرية وقيم النوع الرقمية (0–4) والأولوية (0–3) وتواريخ UTC مثل 2026-09-09T12:00:00Z. +تقارن الحدود التكلفة المقدرة بإجمالي العمالة ورسوم الموردين والقطع المخصصة. تتطلب التكاليف المجهولة أو العملات دون قاعدة مراجعة. الموافقات مستقلة ومحددة لكل عملة. +اختر أيام وساعات العمل المحلية؛ تعني 00:00 كنهاية منتصف الليل. أدخل كل عطلة في سطر. عطّل أولوية بضبط هدفيها على صفر. تبدأ المهل عند الإنشاء وتتغير بتعديل الأولوية. يوقف القبول عداد الاستجابة، والإكمال عداد الإصلاح. تحتفظ إعادة الفتح بالمواعيد الأصلية. +احجز المخزون واصرفه إلى موقع العمل ثم استهلكه أو أعد القطع غير المستخدمة إلى المصدر. لا يمكن لأوامر أخرى استخدام الأرصدة المحجوزة والمصروفة. يظل المخزون الخاضع للرقابة بحاجة إلى شاهد مستقل. يُحتسب الاستهلاك فقط كمصروف قطع. +الوقت الفعلي هو وقت التنفيذ؛ يشمل الانتظار الطلب والقبول والإسناد والتعليق. التعطل هو مجموع فترات إيقاف السلامة غير المتداخلة لكل أمر. يظل سجل الحالة المفقود مجهولًا. يعتمد الالتزام الوقائي على المواعيد الأصلية ويشمل الأعمال المتأخرة غير المكتملة. تشترك الأعطال المتكررة في أصل أو وحدة وسبب مسجل. +تغير اعتماد أمر العمل +تجاوز هدف خدمة أمر العمل +تغيرت رسوم مورد أمر العمل +تغيرت سياسة عمليات أوامر العمل +الموعد النهائي للاستجابة (UTC) +الموعد النهائي للإصلاح (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx index 8229f257..b1696cae 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx @@ -260,4 +260,120 @@ Sicherheitssperre gesetzt Sicherheitssperre aufgehoben Fälligkeit verschoben + Wartungsberichte + Wartungsverlauf + Erstellt ab (UTC, einschließlich) + Erstellt vor (UTC, ausschließlich) + Berechnet am (UTC) + Arbeitsaufträge insgesamt + Offene Arbeitsaufträge + Überfällige Arbeitsaufträge + Mittlere Reparaturzeit (Stunden) + Gemessene abgeschlossene Reparaturen + Reparaturen ohne gültige Zeitangaben + Offene Aufträge nach Priorität + Offene Aufträge nach Alter + 0–6 Tage + 7–29 Tage + 30–89 Tage + Ab 90 Tagen + CSV exportieren + Kosten + Bekannte Gesamtkosten + Arbeitseinträge mit unbekannten Kosten + Teileeinträge mit unbekannten Kosten + Unbekannte Währung + Erstellt am + Begonnen am + Abgeschlossen am + Reparaturzeit (Stunden) + Überarbeitungsstand + Dieser Bericht ist zu groß. Schränken Sie den Zeitraum oder die Zielfilter ein. + Wählen Sie einen gültigen Zeitraum; Übersichtsberichte unterstützen bis zu 366 Tage. + Aktueller Stand der im gewählten UTC-Zeitraum erstellten Arbeitsaufträge. Offen umfasst abgeschlossene Arbeiten ohne endgültigen Abschluss. Die mittlere Reparaturzeit reicht vom ersten Beginn bis zur letzten Fertigstellung, einschließlich Unterbrechungen und Wiedereröffnungen; ungültige Zeiten werden ausgeschlossen. Kosten werden je Eintrag gerundet und nach Währung getrennt; stornierte oder nicht ausgegebene Teile zählen nicht. Unbekannte Kosten und frühere Arbeitswährungen bleiben unbekannt; Wechselkurse werden nicht angenommen. + Gesamter Wartungsverlauf, sofern kein Zeitraum ausgewählt ist. Jede CSV-Zeile steht für einen Auftrag und eine Währung. Bekannte Kosten schließen stornierte oder nicht ausgegebene Teile aus; fehlende Preise und frühere Arbeitswährungen werden als unbekannt ausgewiesen. + Stand des Arbeitsauftrags zum Einsatzzeitpunkt mit unveränderlichen Versionsreferenzen im Manifest. Ältere offene Wartungen sind enthalten; abgeschlossene Arbeiten berücksichtigen den Rückblickzeitraum. Nicht rekonstruierbare historische Zustände werden als nicht verfügbar gekennzeichnet. + +Arbeitsauftragsvorgänge +Vorgangsrichtlinie +Ausgabenfreigaben +Freigabe oberhalb der Schwellen verlangen +Ausgabenschwelle +Freizugebender Betrag +Freigabe anfordern +Ausgaben freigeben +Ausgaben ablehnen +Entscheidung +Keine Freigabe angefordert +Freigabe angefordert +Ausgaben freigegeben +Ausgaben abgelehnt +Betriebskalender +Arbeitstage +Arbeitsfreie Tage (YYYY-MM-DD) +Reaktionsziel (Arbeitsminuten) +Reparaturziel (Arbeitsminuten) +Reaktionsfrist (UTC) +Reparaturfrist (UTC) +Reagiert am (UTC) +Lieferantenkosten +Lieferantenkosten hinzufügen +Kosten stornieren +Rechnungsreferenz +Leistungsdatum +Teilezuordnung +Reservierte Menge +Ausgegebene Menge +Verbrauchte Menge +Unverbrauchte Rückgabemenge +Teilebewegungsverlauf +Teile reservieren +Reservierte Teile ausgeben +Verbrauch erfassen +Unverbrauchte Teile zurückgeben +Reservierung freigeben +Ausgabeort +Sammelvorgänge +Sammelzuweisung +Zeilen prüfen +Gültige Zeilen anwenden +Stapelergebnisse +Angewendet +Bereit zur Anwendung +Zeilennummer +Auswählen +Arbeitsaufträge importieren +CSV-Daten +CSV-Vorlage herunterladen +Aktive Reparaturstunden +Wartestunden +Ausfallstunden durch Sicherheitssperren +Aufträge mit unvollständigem Zeitverlauf +Einhaltung vorbeugender Wartung +Pünktlich / fällig +Wiederholte Ausfälle +Verfehlte Reaktionsziele +Verfehlte Reparaturziele +Prüfen Sie Ausgabenregeln, Kalender und Serviceziele. +Für diese Kosten und Währung ist eine gültige unabhängige Freigabe erforderlich. +Eine andere berechtigte Führungskraft muss über diesen Antrag entscheiden. +Wählen Sie 1 bis 200 gültige Zeilen mit eindeutigen Zeilennummern. +Prüfen Sie Spalten, Zahlen und UTC-Datumsformat dieser Zeile. +Der Stapel wurde geändert. Prüfen Sie ihn vor der Anwendung erneut. +Die Menge übersteigt den verfügbaren reservierten oder ausgegebenen Bestand. +Geben Sie Reservierungen frei und verbrauchen oder retournieren Sie ausgegebene Teile vor dem Abschluss. +Die Währung der Bestandskosten wurde geändert. Prüfen Sie die Reservierung vor dem Fortfahren. +Antragstellende dürfen ihren eigenen Antrag nicht freigeben. +Prüfen Sie bis zu 200 Zeilen. Gültige Zeilen werden einzeln angewendet; Fehler bleiben sichtbar und erfolgreiche Zeilen werden bei Wiederholung nicht dupliziert. +Fügen Sie CSV-Daten anhand der heruntergeladenen Vorlage ein. Behalten Sie Spaltennamen bei; verwenden Sie Dezimalpunkte, numerische Typwerte (0–4) und Prioritätswerte (0–3) und UTC-Daten wie 2026-09-09T12:00:00Z. +Schwellen vergleichen Schätzkosten mit der Summe aus Arbeit, Lieferantenkosten und gebundenen Teilen. Unbekannte Kosten oder Währungen ohne Regel erfordern Prüfung. Freigaben sind unabhängig und währungsspezifisch. +Wählen Sie lokale Arbeitstage und -zeiten; 00:00 als Ende bedeutet Mitternacht. Tragen Sie je Zeile einen freien Tag ein. Zwei Nullwerte deaktivieren eine Priorität. Fristen beginnen bei Erstellung und werden bei Prioritätsänderung neu berechnet. Annahme stoppt die Reaktions-, Fertigstellung die Reparaturzeit. Wiederöffnung behält ursprüngliche Fristen. +Reservieren Sie Bestand, geben Sie ihn an einen Arbeitsort aus und verbrauchen Sie ihn oder retournieren Sie unbenutzte Teile an die Quelle. Andere Aufträge können reservierte und ausgegebene Mengen nicht nutzen. Kontrollierte Bestände benötigen weiterhin unabhängige Bezeugung. Nur Verbrauch zählt als Teileaufwand. +Aktive Zeit ist Bearbeitungszeit; Warten umfasst Anfrage, Annahme, Zuweisung und Unterbrechungen. Ausfallzeit vereint Sicherheitssperren je Auftrag. Fehlender Statusverlauf bleibt unbekannt. Wartungstreue nutzt ursprüngliche Fälligkeiten einschließlich überfälliger offener Arbeit. Wiederholte Ausfälle haben dasselbe Objekt bzw. dieselbe Einheit und eine erfasste Ursache. +Arbeitsauftragsfreigabe geändert +Serviceziel des Arbeitsauftrags verfehlt +Lieferantenkosten des Arbeitsauftrags geändert +Vorgangsrichtlinie für Arbeitsaufträge geändert +Reaktionsfrist (UTC) +Reparaturfrist (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx index 768aa7db..fb82f476 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx @@ -260,4 +260,120 @@ Επιβολή δέσμευσης ασφαλείας Άρση δέσμευσης ασφαλείας Αναβολή προθεσμίας + Αναφορές συντήρησης + Ιστορικό συντήρησης + Δημιουργία από (UTC, συμπεριλαμβάνεται) + Δημιουργία πριν από (UTC, εξαιρείται) + Χρόνος υπολογισμού (UTC) + Σύνολο εντολών εργασίας + Ανοιχτές εντολές εργασίας + Εκπρόθεσμες εντολές εργασίας + Μέσος χρόνος επισκευής (ώρες) + Μετρημένες ολοκληρωμένες επισκευές + Επισκευές χωρίς έγκυρους χρόνους + Ανοιχτές ανά προτεραιότητα + Ανοιχτές ανά ηλικία + 0–6 ημέρες + 7–29 ημέρες + 30–89 ημέρες + 90 ημέρες και άνω + Εξαγωγή CSV + Κόστη + Γνωστό σύνολο + Εγγραφές εργασίας με άγνωστο κόστος + Εγγραφές ανταλλακτικών με άγνωστο κόστος + Άγνωστο νόμισμα + Δημιουργήθηκε + Ξεκίνησε + Ολοκληρώθηκε + Χρόνος επισκευής (ώρες) + Αναθεώρηση + Η αναφορά είναι πολύ μεγάλη. Περιορίστε το χρονικό διάστημα ή τα φίλτρα στόχων. + Επιλέξτε έγκυρο χρονικό διάστημα· οι αναφορές του πίνακα υποστηρίζουν έως 366 ημέρες. + Τρέχουσα κατάσταση των εντολών που δημιουργήθηκαν στο επιλεγμένο διάστημα UTC. Οι ανοιχτές περιλαμβάνουν ολοκληρωμένες εργασίες που αναμένουν κλείσιμο. Ο μέσος χρόνος επισκευής μετρά από την πρώτη έναρξη έως την τελευταία ολοκλήρωση, μαζί με αναστολές και επανανοίγματα· οι μη έγκυροι χρόνοι εξαιρούνται. Τα κόστη στρογγυλοποιούνται ανά εγγραφή και χωρίζονται ανά νόμισμα, χωρίς ακυρωμένα ή μη χορηγημένα ανταλλακτικά. Τα άγνωστα κόστη και τα παλαιά νομίσματα εργασίας παραμένουν άγνωστα· δεν υποτίθενται ισοτιμίες. + Πλήρες ιστορικό συντήρησης, εκτός αν επιλεγούν ημερομηνίες. Κάθε γραμμή CSV αντιστοιχεί σε μία εντολή και ένα νόμισμα. Τα γνωστά κόστη εξαιρούν ακυρωμένα ή μη χορηγημένα ανταλλακτικά· οι ελλείπουσες τιμές και τα παλαιά νομίσματα εργασίας αναφέρονται ως άγνωστα. + Κατάσταση εντολής κατά τον χρόνο του συμβάντος, με αμετάβλητες αναφορές αναθεωρήσεων στο αρχείο τεκμηρίωσης. Περιλαμβάνονται παλαιότερες ανοιχτές συντηρήσεις· οι ολοκληρωμένες εργασίες περιορίζονται στο διάστημα αναδρομής. Η ιστορική κατάσταση που δεν ανακτάται επισημαίνεται ως μη διαθέσιμη. + +Λειτουργίες εντολών εργασίας +Πολιτική λειτουργιών +Εγκρίσεις δαπανών +Απαίτηση έγκρισης πάνω από τα όρια +Όριο δαπάνης +Ποσό προς έγκριση +Αίτημα έγκρισης +Έγκριση δαπάνης +Απόρριψη δαπάνης +Απόφαση +Δεν ζητήθηκε έγκριση +Ζητήθηκε έγκριση +Η δαπάνη εγκρίθηκε +Η δαπάνη απορρίφθηκε +Εργάσιμο ημερολόγιο +Εργάσιμες ημέρες +Μη εργάσιμες ημερομηνίες (YYYY-MM-DD) +Στόχος απόκρισης (εργάσιμα λεπτά) +Στόχος επισκευής (εργάσιμα λεπτά) +Προθεσμία απόκρισης (UTC) +Προθεσμία επισκευής (UTC) +Απόκριση στις (UTC) +Χρεώσεις προμηθευτών +Προσθήκη χρέωσης προμηθευτή +Ακύρωση χρέωσης +Αναφορά τιμολογίου +Ημερομηνία παροχής υπηρεσίας +Δέσμευση ανταλλακτικών +Δεσμευμένη ποσότητα +Χορηγηθείσα ποσότητα +Καταναλωθείσα ποσότητα +Επιστραφείσα αχρησιμοποίητη ποσότητα +Ιστορικό κινήσεων ανταλλακτικών +Δέσμευση ανταλλακτικών +Χορήγηση δεσμευμένων ανταλλακτικών +Καταγραφή κατανάλωσης +Επιστροφή αχρησιμοποίητων ανταλλακτικών +Αποδέσμευση κράτησης +Θέση χορήγησης +Μαζικές λειτουργίες +Μαζική ανάθεση +Προεπισκόπηση γραμμών +Εφαρμογή έγκυρων γραμμών +Αποτελέσματα παρτίδας +Εφαρμόστηκε +Έτοιμο για εφαρμογή +Αριθμός γραμμής +Επιλογή +Εισαγωγή εντολών εργασίας +Δεδομένα CSV +Λήψη προτύπου CSV +Ώρες ενεργής επισκευής +Ώρες αναμονής +Ώρες ακινησίας λόγω ασφάλειας +Εντολές με ελλιπές χρονικό ιστορικό +Συμμόρφωση προληπτικής συντήρησης +Έγκαιρα / ληξιπρόθεσμα +Επαναλαμβανόμενες βλάβες +Υπερβάσεις στόχων απόκρισης +Υπερβάσεις στόχων επισκευής +Ελέγξτε τους κανόνες δαπανών, το ημερολόγιο και τους στόχους υπηρεσίας. +Απαιτείται έγκυρη ανεξάρτητη έγκριση για αυτό το κόστος και νόμισμα. +Ένας άλλος εξουσιοδοτημένος διαχειριστής πρέπει να αποφασίσει για αυτό το αίτημα. +Επιλέξτε από 1 έως 200 έγκυρες γραμμές με μοναδικούς αριθμούς. +Ελέγξτε τις στήλες, τους αριθμούς και τη μορφή ημερομηνίας UTC αυτής της γραμμής. +Η παρτίδα άλλαξε. Δείτε ξανά την προεπισκόπηση πριν την εφαρμογή. +Η ποσότητα υπερβαίνει το διαθέσιμο δεσμευμένο ή χορηγημένο υπόλοιπο. +Αποδεσμεύστε τις κρατήσεις και καταναλώστε ή επιστρέψτε τα χορηγημένα ανταλλακτικά πριν ολοκληρώσετε την εντολή. +Το νόμισμα κόστους αποθέματος άλλαξε. Ελέγξτε τη δέσμευση πριν συνεχίσετε. +Ο αιτών δεν μπορεί να εγκρίνει το δικό του αίτημα. +Προεπισκόπηση έως 200 γραμμών. Κάθε έγκυρη γραμμή εφαρμόζεται χωριστά· τα σφάλματα παραμένουν ορατά και οι επιτυχείς γραμμές δεν διπλασιάζονται στην επανάληψη. +Επικολλήστε CSV με το πρότυπο που κατεβάσατε. Διατηρήστε τα ονόματα στηλών, χρησιμοποιήστε δεκαδική τελεία, αριθμητικές τιμές τύπου (0–4) και προτεραιότητας (0–3) και ημερομηνίες UTC όπως 2026-09-09T12:00:00Z. +Τα όρια συγκρίνουν το εκτιμώμενο κόστος με εργασία, χρεώσεις προμηθευτών και δεσμευμένα ανταλλακτικά. Άγνωστα κόστη ή νομίσματα χωρίς κανόνα απαιτούν έλεγχο. Οι εγκρίσεις είναι ανεξάρτητες και ανά νόμισμα. +Επιλέξτε τοπικές εργάσιμες ημέρες και ώρες· λήξη 00:00 σημαίνει μεσάνυχτα. Γράψτε μία αργία ανά γραμμή. Μηδενίστε και τους δύο στόχους για απενεργοποίηση προτεραιότητας. Οι προθεσμίες αρχίζουν στη δημιουργία και επανυπολογίζονται με αλλαγή προτεραιότητας. Η αποδοχή σταματά την απόκριση, η ολοκλήρωση την επισκευή. Η επαναφορά διατηρεί τις αρχικές προθεσμίες. +Δεσμεύστε απόθεμα, χορηγήστε το σε θέση εργασίας και καταναλώστε το ή επιστρέψτε τα αχρησιμοποίητα ανταλλακτικά στην πηγή. Άλλες εντολές δεν μπορούν να χρησιμοποιήσουν δεσμευμένα ή χορηγημένα υπόλοιπα. Το ελεγχόμενο απόθεμα απαιτεί ανεξάρτητο μάρτυρα. Μόνο η κατανάλωση χρεώνεται ως δαπάνη ανταλλακτικών. +Ενεργός χρόνος είναι ο χρόνος σε εξέλιξη· η αναμονή περιλαμβάνει αιτήματα, αποδοχή, ανάθεση και αναστολές. Η ακινησία ενώνει τις περιόδους ασφάλειας ανά εντολή. Το ελλιπές ιστορικό παραμένει άγνωστο. Η προληπτική συμμόρφωση χρησιμοποιεί αρχικές προθεσμίες, μαζί με εκπρόθεσμη ημιτελή εργασία. Επαναλαμβανόμενες βλάβες αφορούν ίδιο πάγιο ή μονάδα και καταγεγραμμένη αιτία. +Αλλαγή έγκρισης εντολής εργασίας +Υπέρβαση στόχου υπηρεσίας εντολής εργασίας +Αλλαγή χρέωσης προμηθευτή εντολής εργασίας +Αλλαγή πολιτικής λειτουργιών εντολών εργασίας +Προθεσμία απόκρισης (UTC) +Προθεσμία επισκευής (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx index e3441bfe..d6b0fb5d 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx @@ -260,4 +260,120 @@ Safety hold applied Safety hold released Due date deferred + Maintenance reports + Service history + Created from (UTC, inclusive) + Created before (UTC, exclusive) + Calculated at (UTC) + Total work orders + Open work orders + Overdue work orders + Mean repair time (hours) + Completed repairs measured + Repairs missing valid times + Open by priority + Open by age + 0–6 days + 7–29 days + 30–89 days + 90+ days + Export CSV + Costs + Known total + Labor entries with unknown cost + Part entries with unknown cost + Unknown currency + Created + Started + Completed + Repair time (hours) + Revision + This report is too large. Narrow the date range or target filters. + Choose a valid date range; dashboard reports support up to 366 days. + Current state of work orders created in the selected UTC range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. + All-time service history unless dates are selected. Each CSV row represents one order and currency. Known costs exclude voided or unissued parts; missing prices and legacy labor currency are reported as unknown. + Work-order state at the call time, with immutable revision references in the manifest. Older open maintenance is included; completed work uses the lookback window. Unrecoverable historical state is marked unavailable. + +Work order operations +Operations policy +Spending approvals +Require approval above thresholds +Spending threshold +Amount to approve +Request approval +Approve spending +Reject spending +Decision +No approval requested +Approval requested +Spending approved +Spending rejected +Business calendar +Working days +Nonworking dates (YYYY-MM-DD) +Response target (working minutes) +Repair target (working minutes) +Response deadline (UTC) +Repair deadline (UTC) +Responded at (UTC) +Vendor charges +Add vendor charge +Void charge +Invoice reference +Service date +Parts allocation +Reserved quantity +Issued quantity +Consumed quantity +Unused quantity returned +Parts movement history +Reserve parts +Issue reserved parts +Record consumption +Return unused parts +Release reservation +Issue to location +Bulk operations +Bulk assignment +Preview rows +Apply valid rows +Batch results +Applied +Ready to apply +Row number +Select +Import work orders +CSV data +Download CSV template +Active repair hours +Waiting hours +Safety-hold downtime hours +Orders with incomplete time history +Preventive maintenance compliance +On time / due +Repeated failures +Missed response targets +Missed repair targets +Check the spending rules, calendar and service targets. +A valid independent approval is required for this cost and currency. +A different authorized manager must decide this request. +Select between 1 and 200 valid rows with unique row numbers. +Check this row’s columns, numbers and UTC date format. +The batch changed. Preview it again before applying. +The quantity exceeds the available reserved or issued balance. +Release reservations and consume or return issued parts before completing this order. +The inventory cost currency changed. Review the reservation before continuing. +The requester cannot approve their own request. +Preview up to 200 rows. Each valid row is applied separately; errors remain visible and successful rows are not duplicated on retry. +Paste CSV using the downloaded template. Keep its column names, use decimal points, numeric type values (0–4) and priority values (0–3), and UTC dates such as 2026-09-09T12:00:00Z. +Thresholds compare estimated cost with total labor, vendor charges and committed parts. Unknown costs or currencies without a rule require review. Approvals are independent and currency-specific. +Choose local working days and hours; 00:00 as the end means midnight. Enter holidays one per line. Set both targets to zero to disable a priority. Deadlines start at creation; priority edits recalculate them. Acceptance stops the response clock; completion stops the repair clock. Reopening retains the original deadlines. +Reserve stock, issue it to a work location, then consume it or return unused parts to the source. Reserved and issued balances cannot be used by other orders. Controlled stock still needs an independent witness. Only consumption is charged as a parts expense. +Active time is time in progress; waiting includes requests, acceptance, assignment and holds. Downtime is the union of safety holds per order. Missing status history stays unknown. Preventive compliance uses original due dates, including overdue unfinished work. Repeated failures share an asset or unit and a recorded cause. +Work order approval changed +Work order service target missed +Work order vendor charge changed +Work order operations policy changed +Response deadline (UTC) +Repair deadline (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx index 08e439a1..e1f9dd43 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx @@ -260,4 +260,120 @@ Bloqueo de seguridad aplicado Bloqueo de seguridad liberado Fecha aplazada + Informes de mantenimiento + Historial de servicio + Creado desde (UTC, inclusive) + Creado antes de (UTC, sin incluir) + Calculado a las (UTC) + Órdenes de trabajo totales + Órdenes de trabajo abiertas + Órdenes de trabajo vencidas + Tiempo medio de reparación (horas) + Reparaciones completadas medidas + Reparaciones sin tiempos válidos + Abiertas por prioridad + Abiertas por antigüedad + 0–6 días + 7–29 días + 30–89 días + 90 días o más + Exportar CSV + Costes + Total conocido + Registros de mano de obra con coste desconocido + Registros de piezas con coste desconocido + Moneda desconocida + Fecha de creación + Fecha de inicio + Fecha de finalización + Tiempo de reparación (horas) + Revisión + Este informe es demasiado grande. Reduzca el intervalo de fechas o los filtros de destino. + Elija un intervalo de fechas válido; los informes del panel admiten hasta 366 días. + Estado actual de las órdenes creadas en el intervalo UTC seleccionado. Las abiertas incluyen trabajos completados pendientes de cierre. El tiempo medio de reparación va desde el primer inicio hasta la última finalización, incluidas pausas y reaperturas; se excluyen los tiempos no válidos. Los costes se redondean por registro y se separan por moneda; se excluyen piezas anuladas o no entregadas. Los costes y las monedas antiguas de mano de obra desconocidos siguen siendo desconocidos; no se suponen tipos de cambio. + Historial completo salvo que se seleccionen fechas. Cada fila CSV representa una orden y una moneda. Los costes conocidos excluyen piezas anuladas o no entregadas; los precios ausentes y las monedas antiguas de mano de obra se indican como desconocidos. + Estado de la orden en el momento del incidente, con referencias inmutables de revisión en el manifiesto. Se incluye el mantenimiento abierto anterior; los trabajos completados usan el periodo retrospectivo. El estado histórico irrecuperable se indica como no disponible. + +Operaciones de órdenes de trabajo +Política de operaciones +Aprobaciones de gastos +Exigir aprobación al superar los límites +Límite de gasto +Importe por aprobar +Solicitar aprobación +Aprobar gasto +Rechazar gasto +Decisión +Sin aprobación solicitada +Aprobación solicitada +Gasto aprobado +Gasto rechazado +Calendario laboral +Días laborables +Fechas no laborables (YYYY-MM-DD) +Objetivo de respuesta (minutos laborables) +Objetivo de reparación (minutos laborables) +Fecha límite de respuesta (UTC) +Fecha límite de reparación (UTC) +Respuesta registrada (UTC) +Cargos de proveedores +Añadir cargo de proveedor +Anular cargo +Referencia de factura +Fecha del servicio +Asignación de piezas +Cantidad reservada +Cantidad entregada +Cantidad consumida +Cantidad sin usar devuelta +Historial de movimientos de piezas +Reservar piezas +Entregar piezas reservadas +Registrar consumo +Devolver piezas sin usar +Liberar reserva +Ubicación de entrega +Operaciones masivas +Asignación masiva +Vista previa de filas +Aplicar filas válidas +Resultados del lote +Aplicada +Lista para aplicar +Número de fila +Seleccionar +Importar órdenes de trabajo +Datos CSV +Descargar plantilla CSV +Horas de reparación activa +Horas de espera +Horas de inactividad por bloqueo de seguridad +Órdenes con historial de tiempos incompleto +Cumplimiento del mantenimiento preventivo +A tiempo / con vencimiento +Fallos repetidos +Objetivos de respuesta incumplidos +Objetivos de reparación incumplidos +Revise las reglas de gasto, el calendario y los objetivos de servicio. +Se requiere una aprobación independiente válida para este coste y moneda. +Otro responsable autorizado debe decidir sobre esta solicitud. +Seleccione entre 1 y 200 filas válidas con números únicos. +Revise las columnas, los números y el formato de fecha UTC de esta fila. +El lote cambió. Revise la vista previa antes de aplicarlo. +La cantidad supera el saldo disponible reservado o entregado. +Libere las reservas y consuma o devuelva las piezas entregadas antes de completar la orden. +Cambió la moneda del coste de inventario. Revise la reserva antes de continuar. +La persona solicitante no puede aprobar su propia solicitud. +Revise hasta 200 filas. Cada fila válida se aplica por separado; los errores permanecen visibles y los reintentos no duplican filas correctas. +Pegue CSV con la plantilla descargada. Mantenga los nombres de columna y use punto decimal, valores numéricos de tipo (0–4) y prioridad (0–3) y fechas UTC como 2026-09-09T12:00:00Z. +Los límites comparan el coste estimado con la suma de mano de obra, proveedores y piezas comprometidas. Los costes desconocidos o las monedas sin regla requieren revisión. Las aprobaciones son independientes y por moneda. +Elija días y horas laborables locales; 00:00 como fin significa medianoche. Introduzca un festivo por línea. Ponga ambos objetivos a cero para desactivar una prioridad. Los plazos empiezan al crear y se recalculan al cambiar la prioridad. La aceptación detiene la respuesta y la finalización la reparación. La reapertura conserva los plazos originales. +Reserve existencias, entréguelas a una ubicación de trabajo y consúmalas o devuelva las piezas sin usar al origen. Otras órdenes no pueden usar saldos reservados o entregados. El stock controlado sigue requiriendo un testigo independiente. Solo el consumo cuenta como gasto de piezas. +El tiempo activo corresponde al trabajo en curso; la espera incluye solicitud, aceptación, asignación y pausas. La inactividad une los bloqueos de seguridad por orden. El historial ausente permanece desconocido. El cumplimiento preventivo usa fechas originales e incluye trabajos vencidos sin terminar. Los fallos repetidos comparten activo o unidad y causa registrada. +Aprobación de orden de trabajo modificada +Objetivo de servicio de orden de trabajo incumplido +Cargo de proveedor de orden de trabajo modificado +Política de operaciones de órdenes de trabajo modificada +Fecha límite de respuesta (UTC) +Fecha límite de reparación (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx index b310c4ad..ac228c28 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx @@ -260,4 +260,120 @@ Blocage de sécurité appliqué Blocage de sécurité levé Échéance reportée + Rapports de maintenance + Historique d’entretien + Créé à partir du (UTC, inclus) + Créé avant le (UTC, exclu) + Calculé le (UTC) + Total des ordres de travail + Ordres de travail ouverts + Ordres de travail en retard + Durée moyenne de réparation (heures) + Réparations terminées mesurées + Réparations sans horaires valides + Ordres ouverts par priorité + Ordres ouverts par ancienneté + 0–6 jours + 7–29 jours + 30–89 jours + 90 jours ou plus + Exporter en CSV + Coûts + Total connu + Lignes de main-d’œuvre au coût inconnu + Lignes de pièces au coût inconnu + Devise inconnue + Date de création + Date de début + Date de fin + Durée de réparation (heures) + Révision + Ce rapport est trop volumineux. Réduisez la période ou précisez les filtres de cible. + Choisissez une période valide ; les rapports du tableau de bord acceptent jusqu’à 366 jours. + État actuel des ordres créés dans la période UTC sélectionnée. Les ordres ouverts incluent les travaux terminés en attente de clôture. La durée moyenne de réparation va du premier début à la dernière fin, y compris les suspensions et réouvertures ; les horaires invalides sont exclus. Les coûts sont arrondis par ligne et séparés par devise, hors pièces annulées ou non délivrées. Les coûts et anciennes devises de main-d’œuvre inconnus le restent ; aucun taux de change n’est supposé. + Historique complet sauf si des dates sont sélectionnées. Chaque ligne CSV représente un ordre et une devise. Les coûts connus excluent les pièces annulées ou non délivrées ; les prix manquants et anciennes devises de main-d’œuvre sont signalés comme inconnus. + État de l’ordre au moment de l’intervention, avec des références de révision immuables dans le manifeste. Les anciennes maintenances ouvertes sont incluses ; les travaux terminés utilisent la période rétrospective. Tout état historique irrécupérable est signalé comme indisponible. + +Opérations des ordres de travail +Politique des opérations +Approbations des dépenses +Exiger une approbation au-delà des seuils +Seuil de dépense +Montant à approuver +Demander une approbation +Approuver la dépense +Refuser la dépense +Décision +Aucune approbation demandée +Approbation demandée +Dépense approuvée +Dépense refusée +Calendrier de travail +Jours ouvrés +Dates chômées (YYYY-MM-DD) +Délai de réponse (minutes ouvrées) +Délai de réparation (minutes ouvrées) +Échéance de réponse (UTC) +Échéance de réparation (UTC) +Réponse enregistrée (UTC) +Frais des fournisseurs +Ajouter des frais de fournisseur +Annuler les frais +Référence de facture +Date de prestation +Affectation des pièces +Quantité réservée +Quantité délivrée +Quantité consommée +Quantité inutilisée retournée +Historique des mouvements de pièces +Réserver des pièces +Délivrer les pièces réservées +Enregistrer la consommation +Retourner les pièces inutilisées +Libérer la réservation +Lieu de délivrance +Opérations groupées +Affectation groupée +Aperçu des lignes +Appliquer les lignes valides +Résultats du lot +Appliquée +Prête à appliquer +Numéro de ligne +Sélectionner +Importer des ordres de travail +Données CSV +Télécharger le modèle CSV +Heures de réparation active +Heures d’attente +Heures d’arrêt pour sécurité +Ordres avec historique temporel incomplet +Respect de la maintenance préventive +À temps / à échéance +Pannes répétées +Délais de réponse dépassés +Délais de réparation dépassés +Vérifiez les règles de dépense, le calendrier et les objectifs de service. +Une approbation indépendante valide est requise pour ce coût et cette devise. +Un autre responsable autorisé doit statuer sur cette demande. +Sélectionnez de 1 à 200 lignes valides avec des numéros uniques. +Vérifiez les colonnes, les nombres et le format de date UTC de cette ligne. +Le lot a changé. Prévisualisez-le à nouveau avant de l’appliquer. +La quantité dépasse le solde disponible réservé ou délivré. +Libérez les réservations et consommez ou retournez les pièces délivrées avant de terminer cet ordre. +La devise du coût du stock a changé. Vérifiez la réservation avant de continuer. +Le demandeur ne peut pas approuver sa propre demande. +Prévisualisez jusqu’à 200 lignes. Chaque ligne valide est appliquée séparément ; les erreurs restent visibles et les nouvelles tentatives ne créent pas de doublons. +Collez le CSV à partir du modèle téléchargé. Conservez les noms de colonnes ; utilisez le point décimal, des valeurs numériques de type (0–4) et de priorité (0–3) et des dates UTC comme 2026-09-09T12:00:00Z. +Les seuils comparent le coût estimé au total du travail, des frais fournisseurs et des pièces engagées. Les coûts inconnus ou devises sans règle exigent un examen. Les approbations sont indépendantes et propres à chaque devise. +Choisissez les jours et horaires locaux ; une fin à 00:00 signifie minuit. Saisissez un jour chômé par ligne. Deux objectifs à zéro désactivent une priorité. Les délais commencent à la création et sont recalculés lors d’un changement de priorité. L’acceptation arrête le délai de réponse, l’achèvement celui de réparation. La réouverture conserve les échéances initiales. +Réservez le stock, délivrez-le sur un lieu de travail, puis consommez-le ou retournez les pièces inutilisées à leur origine. Les autres ordres ne peuvent pas utiliser les soldes réservés ou délivrés. Le stock contrôlé exige toujours un témoin indépendant. Seule la consommation est comptabilisée en dépense de pièces. +Le temps actif correspond au travail en cours ; l’attente inclut demande, acceptation, affectation et pauses. L’arrêt réunit les blocages de sécurité par ordre. Un historique absent reste inconnu. Le respect préventif utilise les échéances initiales, y compris le travail en retard inachevé. Les pannes répétées partagent un actif ou une unité et une cause enregistrée. +Approbation d’ordre de travail modifiée +Objectif de service d’ordre de travail dépassé +Frais fournisseur d’ordre de travail modifiés +Politique des opérations des ordres de travail modifiée +Échéance de réponse (UTC) +Échéance de réparation (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx index e70ba4a1..45eec56c 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx @@ -260,4 +260,120 @@ Blocco di sicurezza applicato Blocco di sicurezza rimosso Scadenza rinviata + Rapporti di manutenzione + Storico degli interventi + Creato dal (UTC, incluso) + Creato prima del (UTC, escluso) + Calcolato il (UTC) + Ordini di lavoro totali + Ordini di lavoro aperti + Ordini di lavoro scaduti + Tempo medio di riparazione (ore) + Riparazioni completate misurate + Riparazioni senza orari validi + Aperti per priorità + Aperti per anzianità + 0–6 giorni + 7–29 giorni + 30–89 giorni + 90 giorni o più + Esporta CSV + Costi + Totale noto + Voci di manodopera con costo sconosciuto + Voci di ricambi con costo sconosciuto + Valuta sconosciuta + Data di creazione + Data di inizio + Data di completamento + Tempo di riparazione (ore) + Revisione + Il rapporto è troppo grande. Restringi l’intervallo di date o i filtri delle risorse. + Scegli un intervallo di date valido; i rapporti del pannello supportano fino a 366 giorni. + Stato attuale degli ordini creati nell’intervallo UTC selezionato. Gli ordini aperti includono i lavori completati in attesa di chiusura. Il tempo medio di riparazione va dal primo avvio all’ultimo completamento, incluse sospensioni e riaperture; gli orari non validi sono esclusi. I costi sono arrotondati per voce e separati per valuta, escludendo ricambi annullati o non consegnati. Costi e valute storiche della manodopera sconosciuti restano tali; non si presumono tassi di cambio. + Storico completo, salvo selezione di date. Ogni riga CSV rappresenta un ordine e una valuta. I costi noti escludono i ricambi annullati o non consegnati; prezzi mancanti e valute storiche della manodopera sono indicati come sconosciuti. + Stato dell’ordine al momento dell’intervento, con riferimenti di revisione immutabili nel manifesto. Sono incluse le manutenzioni precedenti ancora aperte; i lavori completati usano il periodo retrospettivo. Lo stato storico non recuperabile è indicato come non disponibile. + +Operazioni degli ordini di lavoro +Regole operative +Approvazioni di spesa +Richiedi approvazione oltre le soglie +Soglia di spesa +Importo da approvare +Richiedi approvazione +Approva la spesa +Rifiuta la spesa +Decisione +Nessuna approvazione richiesta +Approvazione richiesta +Spesa approvata +Spesa rifiutata +Calendario lavorativo +Giorni lavorativi +Date non lavorative (YYYY-MM-DD) +Obiettivo di risposta (minuti lavorativi) +Obiettivo di riparazione (minuti lavorativi) +Scadenza di risposta (UTC) +Scadenza di riparazione (UTC) +Risposta registrata (UTC) +Addebiti dei fornitori +Aggiungi addebito del fornitore +Annulla addebito +Riferimento fattura +Data del servizio +Assegnazione dei ricambi +Quantità riservata +Quantità consegnata +Quantità consumata +Quantità inutilizzata restituita +Storico movimenti dei ricambi +Riserva ricambi +Consegna ricambi riservati +Registra consumo +Restituisci ricambi inutilizzati +Rilascia la prenotazione +Luogo di consegna +Operazioni in blocco +Assegnazione in blocco +Anteprima delle righe +Applica righe valide +Risultati del lotto +Applicata +Pronta da applicare +Numero di riga +Seleziona +Importa ordini di lavoro +Dati CSV +Scarica modello CSV +Ore di riparazione attiva +Ore di attesa +Ore di fermo per sicurezza +Ordini con cronologia temporale incompleta +Conformità della manutenzione preventiva +Puntuali / in scadenza +Guasti ripetuti +Obiettivi di risposta non rispettati +Obiettivi di riparazione non rispettati +Controlla le regole di spesa, il calendario e gli obiettivi di servizio. +È richiesta un’approvazione indipendente valida per questo costo e questa valuta. +Un altro responsabile autorizzato deve decidere su questa richiesta. +Seleziona da 1 a 200 righe valide con numeri univoci. +Controlla colonne, numeri e formato della data UTC di questa riga. +Il lotto è cambiato. Visualizza di nuovo l’anteprima prima di applicarlo. +La quantità supera il saldo disponibile riservato o consegnato. +Rilascia le prenotazioni e consuma o restituisci i ricambi consegnati prima di completare l’ordine. +La valuta del costo di magazzino è cambiata. Verifica la prenotazione prima di continuare. +Il richiedente non può approvare la propria richiesta. +Visualizza fino a 200 righe. Ogni riga valida viene applicata separatamente; gli errori restano visibili e i nuovi tentativi non duplicano le righe riuscite. +Incolla il CSV usando il modello scaricato. Mantieni i nomi delle colonne; usa il punto decimale, valori numerici di tipo (0–4) e priorità (0–3) e date UTC come 2026-09-09T12:00:00Z. +Le soglie confrontano il costo stimato con lavoro, addebiti dei fornitori e ricambi impegnati. Costi sconosciuti o valute senza regola richiedono verifica. Le approvazioni sono indipendenti e specifiche per valuta. +Scegli giorni e orari locali; la fine alle 00:00 indica mezzanotte. Inserisci una festività per riga. Imposta entrambi gli obiettivi a zero per disattivare una priorità. Le scadenze partono dalla creazione e cambiano modificando la priorità. L’accettazione ferma il tempo di risposta, il completamento quello di riparazione. La riapertura mantiene le scadenze originali. +Riserva le scorte, consegnale a un luogo di lavoro, poi consumale o restituisci i ricambi inutilizzati all’origine. Altri ordini non possono usare i saldi riservati o consegnati. Le scorte controllate richiedono un testimone indipendente. Solo il consumo è conteggiato come spesa per ricambi. +Il tempo attivo è quello in corso; l’attesa include richiesta, accettazione, assegnazione e sospensioni. Il fermo unisce i blocchi di sicurezza per ordine. La cronologia mancante resta sconosciuta. La conformità preventiva usa le scadenze originali, incluso il lavoro scaduto incompleto. I guasti ripetuti condividono bene o unità e causa registrata. +Approvazione dell’ordine di lavoro modificata +Obiettivo di servizio dell’ordine non rispettato +Addebito del fornitore dell’ordine modificato +Regole operative degli ordini di lavoro modificate +Scadenza di risposta (UTC) +Scadenza di riparazione (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx index c8838187..fdcaf0a7 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx @@ -260,4 +260,120 @@ Nałożono blokadę bezpieczeństwa Zwolniono blokadę bezpieczeństwa Termin odroczony + Raporty konserwacji + Historia serwisowa + Utworzono od (UTC, włącznie) + Utworzono przed (UTC, bez tej chwili) + Obliczono o (UTC) + Wszystkie zlecenia pracy + Otwarte zlecenia pracy + Przeterminowane zlecenia pracy + Średni czas naprawy (godziny) + Zmierzone zakończone naprawy + Naprawy bez prawidłowych czasów + Otwarte według priorytetu + Otwarte według wieku + 0–6 dni + 7–29 dni + 30–89 dni + Co najmniej 90 dni + Eksportuj CSV + Koszty + Znana suma + Wpisy robocizny o nieznanym koszcie + Wpisy części o nieznanym koszcie + Nieznana waluta + Data utworzenia + Data rozpoczęcia + Data zakończenia + Czas naprawy (godziny) + Wersja + Ten raport jest za duży. Zawęź zakres dat lub filtry zasobów. + Wybierz prawidłowy zakres dat; raporty panelu obsługują do 366 dni. + Bieżący stan zleceń utworzonych w wybranym zakresie UTC. Otwarte obejmują prace zakończone oczekujące na zamknięcie. Średni czas naprawy biegnie od pierwszego rozpoczęcia do ostatniego zakończenia, wraz ze wstrzymaniami i ponownymi otwarciami; nieprawidłowe czasy są pomijane. Koszty są zaokrąglane dla każdego wpisu i rozdzielane według waluty; anulowane lub niewydane części są wykluczone. Nieznane koszty i dawne waluty robocizny pozostają nieznane; nie zakłada się kursów wymiany. + Pełna historia serwisowa, chyba że wybrano daty. Każdy wiersz CSV odpowiada jednemu zleceniu i jednej walucie. Znane koszty nie obejmują anulowanych lub niewydanych części; brakujące ceny i dawne waluty robocizny są oznaczone jako nieznane. + Stan zlecenia w chwili zdarzenia, z niezmiennymi odwołaniami do wersji w manifeście. Uwzględnia wcześniejsze otwarte prace konserwacyjne; zakończone prace obejmują okres retrospektywny. Niemożliwy do odtworzenia stan historyczny jest oznaczony jako niedostępny. + +Operacje zleceń pracy +Zasady operacyjne +Zatwierdzanie wydatków +Wymagaj zatwierdzenia powyżej progów +Próg wydatków +Kwota do zatwierdzenia +Poproś o zatwierdzenie +Zatwierdź wydatki +Odrzuć wydatki +Decyzja +Nie poproszono o zatwierdzenie +Wysłano prośbę o zatwierdzenie +Wydatki zatwierdzone +Wydatki odrzucone +Kalendarz pracy +Dni robocze +Dni wolne (YYYY-MM-DD) +Docelowy czas reakcji (minuty robocze) +Docelowy czas naprawy (minuty robocze) +Termin reakcji (UTC) +Termin naprawy (UTC) +Czas reakcji (UTC) +Koszty dostawców +Dodaj koszt dostawcy +Anuluj koszt +Numer referencyjny faktury +Data wykonania usługi +Przydział części +Ilość zarezerwowana +Ilość wydana +Ilość zużyta +Ilość niewykorzystana zwrócona +Historia przepływu części +Zarezerwuj części +Wydaj zarezerwowane części +Zarejestruj zużycie +Zwróć niewykorzystane części +Zwolnij rezerwację +Miejsce wydania +Operacje zbiorcze +Przypisanie zbiorcze +Podgląd wierszy +Zastosuj poprawne wiersze +Wyniki operacji zbiorczej +Zastosowano +Gotowe do zastosowania +Numer wiersza +Wybierz +Importuj zlecenia pracy +Dane CSV +Pobierz szablon CSV +Godziny aktywnej naprawy +Godziny oczekiwania +Godziny przestoju z powodu blokady bezpieczeństwa +Zlecenia z niepełną historią czasu +Zgodność konserwacji zapobiegawczej z terminami +Terminowo / wymagane +Powtarzające się awarie +Przekroczone terminy reakcji +Przekroczone terminy naprawy +Sprawdź zasady wydatków, kalendarz i docelowe terminy usług. +Dla tego kosztu i waluty wymagane jest ważne niezależne zatwierdzenie. +Decyzję musi podjąć inny uprawniony kierownik. +Wybierz od 1 do 200 poprawnych wierszy o unikalnych numerach. +Sprawdź kolumny, liczby i format daty UTC w tym wierszu. +Zawartość pakietu się zmieniła. Ponownie wyświetl podgląd przed zastosowaniem. +Ilość przekracza dostępne saldo zarezerwowanych lub wydanych części. +Zwolnij rezerwacje oraz zużyj lub zwróć wydane części przed zakończeniem zlecenia. +Zmieniła się waluta kosztu zapasów. Sprawdź rezerwację przed kontynuacją. +Wnioskodawca nie może zatwierdzić własnego wniosku. +Wyświetl podgląd maksymalnie 200 wierszy. Poprawne wiersze są stosowane osobno; błędy pozostają widoczne, a ponowienie nie tworzy duplikatów. +Wklej CSV zgodny z pobranym szablonem. Zachowaj nazwy kolumn; używaj kropki dziesiętnej, liczbowych wartości typu (0–4) i priorytetu (0–3) i dat UTC, np. 2026-09-09T12:00:00Z. +Progi porównują koszt szacowany z sumą robocizny, kosztów dostawców i przydzielonych części. Nieznane koszty lub waluty bez reguły wymagają weryfikacji. Zatwierdzenia są niezależne i dotyczą konkretnej waluty. +Wybierz lokalne dni i godziny pracy; koniec o 00:00 oznacza północ. Wpisz po jednym dniu wolnym w wierszu. Ustaw oba cele na zero, aby wyłączyć priorytet. Terminy liczą się od utworzenia i są przeliczane przy zmianie priorytetu. Przyjęcie kończy czas reakcji, a ukończenie czas naprawy. Ponowne otwarcie zachowuje pierwotne terminy. +Zarezerwuj zapasy, wydaj je do miejsca pracy, następnie zużyj lub zwróć niewykorzystane części do źródła. Inne zlecenia nie mogą korzystać z ilości zarezerwowanych lub wydanych. Zapasy kontrolowane nadal wymagają niezależnego świadka. Tylko zużycie stanowi koszt części. +Czas aktywny to praca w toku; oczekiwanie obejmuje zgłoszenie, przyjęcie, przypisanie i wstrzymanie. Przestój jest sumą niepokrywających się blokad bezpieczeństwa w zleceniu. Brak historii oznacza czas nieznany. Zgodność konserwacji opiera się na pierwotnych terminach, także dla zaległych prac. Powtarzające się awarie dotyczą tego samego zasobu lub jednostki i zapisanej przyczyny. +Zmieniono zatwierdzenie zlecenia pracy +Przekroczono docelowy termin zlecenia pracy +Zmieniono koszt dostawcy w zleceniu pracy +Zmieniono zasady operacyjne zleceń pracy +Termin reakcji (UTC) +Termin naprawy (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx index e3441bfe..d6b0fb5d 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx @@ -260,4 +260,120 @@ Safety hold applied Safety hold released Due date deferred + Maintenance reports + Service history + Created from (UTC, inclusive) + Created before (UTC, exclusive) + Calculated at (UTC) + Total work orders + Open work orders + Overdue work orders + Mean repair time (hours) + Completed repairs measured + Repairs missing valid times + Open by priority + Open by age + 0–6 days + 7–29 days + 30–89 days + 90+ days + Export CSV + Costs + Known total + Labor entries with unknown cost + Part entries with unknown cost + Unknown currency + Created + Started + Completed + Repair time (hours) + Revision + This report is too large. Narrow the date range or target filters. + Choose a valid date range; dashboard reports support up to 366 days. + Current state of work orders created in the selected UTC range. Open includes completed work awaiting closure. MTTR is elapsed time from first start to latest completion, including holds and reopens; invalid times are excluded. Costs are rounded per entry, separated by currency, and exclude voided or unissued parts. Unknown costs and legacy labor currency remain unknown; no exchange rates are assumed. + All-time service history unless dates are selected. Each CSV row represents one order and currency. Known costs exclude voided or unissued parts; missing prices and legacy labor currency are reported as unknown. + Work-order state at the call time, with immutable revision references in the manifest. Older open maintenance is included; completed work uses the lookback window. Unrecoverable historical state is marked unavailable. + +Work order operations +Operations policy +Spending approvals +Require approval above thresholds +Spending threshold +Amount to approve +Request approval +Approve spending +Reject spending +Decision +No approval requested +Approval requested +Spending approved +Spending rejected +Business calendar +Working days +Nonworking dates (YYYY-MM-DD) +Response target (working minutes) +Repair target (working minutes) +Response deadline (UTC) +Repair deadline (UTC) +Responded at (UTC) +Vendor charges +Add vendor charge +Void charge +Invoice reference +Service date +Parts allocation +Reserved quantity +Issued quantity +Consumed quantity +Unused quantity returned +Parts movement history +Reserve parts +Issue reserved parts +Record consumption +Return unused parts +Release reservation +Issue to location +Bulk operations +Bulk assignment +Preview rows +Apply valid rows +Batch results +Applied +Ready to apply +Row number +Select +Import work orders +CSV data +Download CSV template +Active repair hours +Waiting hours +Safety-hold downtime hours +Orders with incomplete time history +Preventive maintenance compliance +On time / due +Repeated failures +Missed response targets +Missed repair targets +Check the spending rules, calendar and service targets. +A valid independent approval is required for this cost and currency. +A different authorized manager must decide this request. +Select between 1 and 200 valid rows with unique row numbers. +Check this row’s columns, numbers and UTC date format. +The batch changed. Preview it again before applying. +The quantity exceeds the available reserved or issued balance. +Release reservations and consume or return issued parts before completing this order. +The inventory cost currency changed. Review the reservation before continuing. +The requester cannot approve their own request. +Preview up to 200 rows. Each valid row is applied separately; errors remain visible and successful rows are not duplicated on retry. +Paste CSV using the downloaded template. Keep its column names, use decimal points, numeric type values (0–4) and priority values (0–3), and UTC dates such as 2026-09-09T12:00:00Z. +Thresholds compare estimated cost with total labor, vendor charges and committed parts. Unknown costs or currencies without a rule require review. Approvals are independent and currency-specific. +Choose local working days and hours; 00:00 as the end means midnight. Enter holidays one per line. Set both targets to zero to disable a priority. Deadlines start at creation; priority edits recalculate them. Acceptance stops the response clock; completion stops the repair clock. Reopening retains the original deadlines. +Reserve stock, issue it to a work location, then consume it or return unused parts to the source. Reserved and issued balances cannot be used by other orders. Controlled stock still needs an independent witness. Only consumption is charged as a parts expense. +Active time is time in progress; waiting includes requests, acceptance, assignment and holds. Downtime is the union of safety holds per order. Missing status history stays unknown. Preventive compliance uses original due dates, including overdue unfinished work. Repeated failures share an asset or unit and a recorded cause. +Work order approval changed +Work order service target missed +Work order vendor charge changed +Work order operations policy changed +Response deadline (UTC) +Repair deadline (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx index 1b1afcd9..c34417a0 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx @@ -260,4 +260,120 @@ Säkerhetsspärr införd Säkerhetsspärr hävd Förfallodatum uppskjutet + Underhållsrapporter + Servicehistorik + Skapad från (UTC, inklusive) + Skapad före (UTC, exklusive) + Beräknad vid (UTC) + Totalt antal arbetsorder + Öppna arbetsorder + Försenade arbetsorder + Genomsnittlig reparationstid (timmar) + Uppmätta slutförda reparationer + Reparationer utan giltiga tider + Öppna efter prioritet + Öppna efter ålder + 0–6 dagar + 7–29 dagar + 30–89 dagar + 90 dagar eller fler + Exportera CSV + Kostnader + Känd totalsumma + Arbetsposter med okänd kostnad + Reservdelsposter med okänd kostnad + Okänd valuta + Skapad + Påbörjad + Slutförd + Reparationstid (timmar) + Revisionsnummer + Rapporten är för stor. Begränsa datumintervallet eller målfiltren. + Välj ett giltigt datumintervall; översiktsrapporter stöder upp till 366 dagar. + Aktuellt tillstånd för arbetsorder skapade inom valt UTC-intervall. Öppna omfattar slutfört arbete som väntar på stängning. Genomsnittlig reparationstid räknas från första start till senaste slutförande, inklusive uppehåll och återöppningar; ogiltiga tider utesluts. Kostnader avrundas per post och delas upp efter valuta; makulerade eller ej utlämnade delar utesluts. Okända kostnader och äldre arbetsvalutor förblir okända; inga växelkurser antas. + Fullständig servicehistorik om inga datum väljs. Varje CSV-rad motsvarar en order och en valuta. Kända kostnader utesluter makulerade eller ej utlämnade delar; saknade priser och äldre arbetsvalutor anges som okända. + Arbetsorderns tillstånd vid larmtillfället, med oföränderliga revisionsreferenser i manifestet. Äldre öppet underhåll ingår; slutfört arbete använder tillbakablicksperioden. Historiska tillstånd som inte kan återskapas markeras som otillgängliga. + +Arbetsorderåtgärder +Regler för arbetsorder +Godkännande av utgifter +Kräv godkännande över gränsvärden +Utgiftsgräns +Belopp att godkänna +Begär godkännande +Godkänn utgiften +Avslå utgiften +Beslut +Inget godkännande begärt +Godkännande begärt +Utgiften godkänd +Utgiften avslagen +Arbetskalender +Arbetsdagar +Arbetsfria datum (YYYY-MM-DD) +Svarsmål (arbetsminuter) +Reparationsmål (arbetsminuter) +Sista svarstid (UTC) +Sista reparationstid (UTC) +Svar registrerat (UTC) +Leverantörskostnader +Lägg till leverantörskostnad +Makulera kostnad +Fakturareferens +Tjänstedatum +Tilldelning av reservdelar +Reserverad mängd +Utlämnad mängd +Förbrukad mängd +Återlämnad oanvänd mängd +Historik över reservdelsrörelser +Reservera reservdelar +Lämna ut reserverade delar +Registrera förbrukning +Återlämna oanvända delar +Frigör reservation +Utlämningsplats +Massåtgärder +Masstilldelning +Förhandsgranska rader +Tillämpa giltiga rader +Resultat för omgången +Tillämpad +Klar att tillämpa +Radnummer +Välj +Importera arbetsorder +CSV-data +Hämta CSV-mall +Aktiva reparationstimmar +Väntetimmar +Stilleståndstimmar vid säkerhetsspärr +Order med ofullständig tidshistorik +Efterlevnad av förebyggande underhåll +I tid / förfallna +Återkommande fel +Missade svarsmål +Missade reparationsmål +Kontrollera utgiftsregler, kalender och servicemål. +Ett giltigt oberoende godkännande krävs för denna kostnad och valuta. +En annan behörig chef måste besluta om begäran. +Välj mellan 1 och 200 giltiga rader med unika radnummer. +Kontrollera radens kolumner, tal och UTC-datumformat. +Omgången har ändrats. Förhandsgranska den igen innan du tillämpar den. +Mängden överstiger tillgängligt reserverat eller utlämnat saldo. +Frigör reservationer och förbruka eller återlämna utlämnade delar innan ordern slutförs. +Valutan för lagerkostnaden har ändrats. Granska reservationen innan du fortsätter. +Den som begär godkännande kan inte godkänna sin egen begäran. +Förhandsgranska upp till 200 rader. Varje giltig rad tillämpas separat; fel förblir synliga och lyckade rader dupliceras inte vid nya försök. +Klistra in CSV enligt den hämtade mallen. Behåll kolumnnamnen och använd decimalpunkt, numeriska typvärden (0–4) och prioritetsvärden (0–3) och UTC-datum som 2026-09-09T12:00:00Z. +Gränserna jämför uppskattad kostnad med arbete, leverantörskostnader och bundna delar. Okända kostnader eller valutor utan regel kräver granskning. Godkännanden är oberoende och valutaspecifika. +Välj lokala arbetsdagar och tider; sluttiden 00:00 betyder midnatt. Ange en helgdag per rad. Sätt båda målen till noll för att inaktivera en prioritet. Tider räknas från skapandet och räknas om vid ändrad prioritet. Acceptans stoppar svarstiden, slutförande reparationstiden. Återöppning behåller ursprungliga tidsgränser. +Reservera lagret, lämna ut till en arbetsplats och förbruka eller återlämna oanvända delar till ursprunget. Andra order kan inte använda reserverade eller utlämnade saldon. Kontrollerat lager kräver fortfarande ett oberoende vittne. Endast förbrukning räknas som reservdelskostnad. +Aktiv tid är tid under arbete; väntan omfattar begäran, acceptans, tilldelning och pauser. Stillestånd förenar säkerhetsspärrar per order. Saknad historik förblir okänd. Förebyggande efterlevnad använder ursprungliga förfallodatum, även för försenat oavslutat arbete. Återkommande fel delar tillgång eller enhet och registrerad orsak. +Arbetsorderns godkännande ändrat +Arbetsorderns servicemål missat +Arbetsorderns leverantörskostnad ändrad +Reglerna för arbetsorder ändrade +Sista svarstid (UTC) +Sista reparationstid (UTC) diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx index 237d37c5..4d74142f 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx @@ -260,4 +260,120 @@ Накладено блокування безпеки Блокування безпеки знято Термін відкладено + Звіти з технічного обслуговування + Історія обслуговування + Створено від (UTC, включно) + Створено до (UTC, не включно) + Час розрахунку (UTC) + Усього нарядів на роботи + Відкриті наряди на роботи + Прострочені наряди на роботи + Середній час ремонту (години) + Виміряні завершені ремонти + Ремонти без коректного часу + Відкриті за пріоритетом + Відкриті за давністю + 0–6 днів + 7–29 днів + 30–89 днів + 90 днів і більше + Експортувати CSV + Витрати + Відома сума + Записи праці з невідомою вартістю + Записи запчастин з невідомою вартістю + Невідома валюта + Дата створення + Дата початку + Дата завершення + Час ремонту (години) + Редакція + Цей звіт завеликий. Звузьте діапазон дат або фільтри ресурсів. + Виберіть коректний діапазон дат; звіти панелі підтримують до 366 днів. + Поточний стан нарядів, створених у вибраному діапазоні UTC. Відкриті включають завершені роботи, що очікують закриття. Середній час ремонту вимірюється від першого початку до останнього завершення, включно з призупиненнями та повторними відкриттями; некоректні значення часу виключаються. Витрати округлюються для кожного запису й розділяються за валютою, без скасованих або невиданих запчастин. Невідомі витрати й давні валюти оплати праці залишаються невідомими; курси обміну не припускаються. + Повна історія обслуговування, якщо не вибрано дати. Кожен рядок CSV відповідає одному наряду й одній валюті. Відомі витрати не включають скасовані або невидані запчастини; відсутні ціни та давні валюти оплати праці позначаються як невідомі. + Стан наряду на момент виклику з незмінними посиланнями на редакції в маніфесті. Включено давніші відкриті роботи з обслуговування; завершені роботи охоплюють ретроспективний період. Історичний стан, який неможливо відновити, позначається як недоступний. + +Операції нарядів на роботи +Правила операцій +Погодження витрат +Вимагати погодження понад установлені пороги +Поріг витрат +Сума для погодження +Запросити погодження +Погодити витрати +Відхилити витрати +Рішення +Погодження не запитано +Погодження запитано +Витрати погоджено +Витрати відхилено +Робочий календар +Робочі дні +Неробочі дати (YYYY-MM-DD) +Цільовий час реагування (робочі хвилини) +Цільовий час ремонту (робочі хвилини) +Кінцевий термін реагування (UTC) +Кінцевий термін ремонту (UTC) +Час реагування (UTC) +Витрати на постачальників +Додати витрати на постачальника +Анулювати витрати +Посилання на рахунок +Дата надання послуги +Розподіл запчастин +Зарезервована кількість +Видана кількість +Використана кількість +Повернена невикористана кількість +Історія руху запчастин +Зарезервувати запчастини +Видати зарезервовані запчастини +Зареєструвати використання +Повернути невикористані запчастини +Зняти резервування +Місце видачі +Масові операції +Масове призначення +Переглянути рядки +Застосувати коректні рядки +Результати пакета +Застосовано +Готово до застосування +Номер рядка +Вибрати +Імпортувати наряди на роботи +Дані CSV +Завантажити шаблон CSV +Години активного ремонту +Години очікування +Години простою через блокування з міркувань безпеки +Наряди з неповною історією часу +Дотримання строків профілактичного обслуговування +Вчасно / заплановано +Повторні несправності +Порушені строки реагування +Порушені строки ремонту +Перевірте правила витрат, календар і цільові строки обслуговування. +Для цієї суми та валюти потрібне чинне незалежне погодження. +Рішення за цим запитом має ухвалити інший уповноважений керівник. +Виберіть від 1 до 200 коректних рядків з унікальними номерами. +Перевірте стовпці, числа та формат дати UTC в цьому рядку. +Пакет змінено. Перегляньте його повторно перед застосуванням. +Кількість перевищує доступний зарезервований або виданий залишок. +Зніміть резервування та використайте або поверніть видані запчастини перед завершенням наряду. +Валюту вартості запасів змінено. Перевірте резервування перед продовженням. +Заявник не може погоджувати власний запит. +Перегляньте до 200 рядків. Коректні рядки застосовуються окремо; помилки залишаються видимими, а повторна спроба не дублює успішні рядки. +Вставте CSV за завантаженим шаблоном. Збережіть назви стовпців, використовуйте десяткову крапку, числові значення типу (0–4) та пріоритету (0–3) і дати UTC, як-от 2026-09-09T12:00:00Z. +Пороги порівнюють оцінену вартість із сумою праці, витрат на постачальників і виділених запчастин. Невідомі витрати або валюти без правила потребують перевірки. Погодження незалежні та діють для окремої валюти. +Виберіть місцеві робочі дні й години; кінець о 00:00 означає північ. Укажіть по одному неробочому дню в рядку. Нуль для обох цілей вимикає пріоритет. Строки рахуються від створення й перераховуються при зміні пріоритету. Прийняття зупиняє час реагування, завершення — час ремонту. Повторне відкриття зберігає початкові терміни. +Зарезервуйте запаси, видайте їх до місця роботи, потім використайте або поверніть невикористані запчастини до джерела. Інші наряди не можуть використовувати зарезервовані чи видані залишки. Контрольовані запаси потребують незалежного свідка. Лише використання враховується як витрати на запчастини. +Активний час — робота в процесі; очікування охоплює запит, прийняття, призначення та призупинення. Простій об’єднує інтервали блокувань безпеки в межах наряду. Відсутня історія лишається невідомою. Профілактична відповідність враховує початкові строки, зокрема прострочені незавершені роботи. Повторні несправності мають спільний актив або підрозділ і зафіксовану причину. +Погодження наряду змінено +Цільовий строк обслуговування наряду порушено +Витрати на постачальника в наряді змінено +Правила операцій нарядів змінено +Кінцевий термін реагування (UTC) +Кінцевий термін ремонту (UTC) diff --git a/Core/Resgrid.Model/Checklists/ChecklistReporting.cs b/Core/Resgrid.Model/Checklists/ChecklistReporting.cs index 35cd3771..d9df204c 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistReporting.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistReporting.cs @@ -89,8 +89,8 @@ public interface IChecklistHistoricalAssetSource public sealed class ReadinessEvidenceManifestV1 { public string Schema => "ReadinessEvidenceManifestV1"; - public string GeneratorVersion => "checklists-p1m4/1"; - public string ProfileVersion => "checklists-only/1"; + public string GeneratorVersion => "readiness-p2m4/1"; + public string ProfileVersion => "checklists-work-orders/1"; public int DepartmentId { get; set; } public int CallId { get; set; } public DateTime CallUtc { get; set; } @@ -101,6 +101,7 @@ public sealed class ReadinessEvidenceManifestV1 public List Units { get; set; } = new(); public List Assets { get; set; } = new(); public List Checklists { get; set; } = new(); + public List WorkOrders { get; set; } = new(); public List UnavailableSources { get; set; } = new(); } /// Checksum the exact UTF-8 ManifestJson bytes and decoded PDF bytes, not this wrapper. diff --git a/Core/Resgrid.Model/Inventories/InventoryContracts.cs b/Core/Resgrid.Model/Inventories/InventoryContracts.cs index f21048af..d919d3c4 100644 --- a/Core/Resgrid.Model/Inventories/InventoryContracts.cs +++ b/Core/Resgrid.Model/Inventories/InventoryContracts.cs @@ -22,6 +22,7 @@ public sealed class InventoryLocationInput { public string Id { get; set; } publ public sealed class InventoryAssetInput { public string Id { get; set; } public string RequestId { get; set; } public string ItemId { get; set; } public string LocationId { get; set; } public string LotId { get; set; } public DateTime? ExpiresOn { get; set; } public InventoryAssetContent Details { get; set; } = new(); } public sealed class InventoryPosting { + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int? WorkOrderPartMovementId { get; set; } public string ItemId { get; set; } public string AssetId { get; set; } public string LotId { get; set; } diff --git a/Core/Resgrid.Model/Inventories/InventoryModels.cs b/Core/Resgrid.Model/Inventories/InventoryModels.cs index 0c349217..cef418c0 100644 --- a/Core/Resgrid.Model/Inventories/InventoryModels.cs +++ b/Core/Resgrid.Model/Inventories/InventoryModels.cs @@ -72,6 +72,7 @@ public sealed class InventoryAsset : InventoryMutableRow /// EntryId is the bigint database primary key; Id is the stable GUID exposed to integration consumers. Append-only. public sealed class InventoryTransaction : InventoryRow { + public int? WorkOrderPartMovementId { get; set; } public long EntryId { get; set; } public string OperationId { get; set; } public int LineNumber { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordsReadinessContracts.cs b/Core/Resgrid.Model/Records/RecordsReadinessContracts.cs new file mode 100644 index 00000000..d3844b68 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsReadinessContracts.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Readiness (RMS-6, third bullet): apparatus and equipment readiness composed from the checklists, maintenance + /// and inventory modules through their own authorized read seams, plus the readiness-packet evidence Records + /// already capture. RMS computes nothing readiness-specific itself — every figure here is a count over what those + /// modules returned to this viewer, so a member who cannot read work orders or an inventory location does not + /// see them counted either. Each module section is present only while its module is enabled for the department + /// and readable by the viewer; the flags say which. + /// + public class RecordsReadiness : RecordsAnalyticsBase + { + public bool ChecklistsEnabled { get; set; } + public bool MaintenanceEnabled { get; set; } + public bool InventoryEnabled { get; set; } + + /// The module is on but refused this viewer; the section is null and nothing was counted. + public bool ChecklistsForbidden { get; set; } + public bool MaintenanceForbidden { get; set; } + public bool InventoryForbidden { get; set; } + + public RecordsChecklistMetrics Checklists { get; set; } + public RecordsWorkOrderMetrics WorkOrders { get; set; } + public RecordsEquipmentMetrics Equipment { get; set; } + public RecordsReadinessEvidenceMetrics Evidence { get; set; } = new RecordsReadinessEvidenceMetrics(); + + /// One row per unit the viewer may see, joining the module figures on the unit id. + public List Units { get; set; } = new List(); + } + + /// Checklist compliance for one target (unit, station group, person, inventory asset). + public class RecordsReadinessTargetRow + { + public string Key { get; set; } + public string Label { get; set; } + public int Expected { get; set; } + public int Completed { get; set; } + public int OnTime { get; set; } + public int Missed { get; set; } + public int Skipped { get; set; } + public double? CompletionRatePercent { get; set; } + public double? OnTimePercent { get; set; } + } + + public class RecordsChecklistTrendPoint + { + public DateTime Day { get; set; } + public int Expected { get; set; } + public int Missed { get; set; } + } + + /// + /// Checklist compliance from IChecklistsService.GetComplianceSummaryAsync. The checklists module accepts a + /// cohort of at most 93 days, so the cohort is the tail of the dashboard window; and + /// say exactly what was measured. + /// + public class RecordsChecklistMetrics + { + public DateTime CohortStart { get; set; } + public DateTime CohortEnd { get; set; } + public int Expected { get; set; } + public int Completed { get; set; } + public int OnTime { get; set; } + public int Missed { get; set; } + public int Skipped { get; set; } + public double? CompletionRatePercent { get; set; } + public double? OnTimePercent { get; set; } + + /// True when the module withheld answer-level detail from this viewer; counts are still exact. + public bool IsRedacted { get; set; } + public List UnavailableSources { get; set; } = new List(); + public List ByUnit { get; set; } = new List(); + public List ByStationGroup { get; set; } = new List(); + public List ByPerson { get; set; } = new List(); + public List ByAsset { get; set; } = new List(); + public List Trend { get; set; } = new List(); + } + + public class RecordsWorkOrderCost + { + public string Currency { get; set; } + public decimal Labor { get; set; } + public decimal Parts { get; set; } + public decimal Total => Labor + Parts; + + /// Lines whose cost the module could not price; the totals above exclude them. + public int Unknown { get; set; } + } + + /// Per-unit work-order load from the maintenance module's history feed. + public class RecordsWorkOrderUnitRow + { + public string Key { get; set; } + public string Label { get; set; } + public int Total { get; set; } + public int Open { get; set; } + public int Overdue { get; set; } + public int OnHold { get; set; } + public int Emergency { get; set; } + } + + /// + /// Maintenance from IWorkOrderReportingService: the module's own statistics over the window (created in + /// window, open and overdue evaluated now, mean time to repair, priority and age bands, costs by currency) and + /// a per-unit roll-up read from its paged history, bounded by . + /// + public class RecordsWorkOrderMetrics + { + public DateTime CohortStart { get; set; } + public DateTime CohortEnd { get; set; } + public int Total { get; set; } + public int Open { get; set; } + public int Overdue { get; set; } + public int OnHold { get; set; } + public double? MeanTimeToRepairHours { get; set; } + public int RepairSamples { get; set; } + public int MissingRepairTimes { get; set; } + public List OpenByPriority { get; set; } = new List(); + public List OpenByAge { get; set; } = new List(); + public List Costs { get; set; } = new List(); + public List ByUnit { get; set; } = new List(); + + /// The per-unit roll-up stopped at the page cap; the module's own totals above are still complete. + public bool HistoryTruncated { get; set; } + } + + /// Assets and issuances per unit, from the inventory catalog the viewer may read. + public class RecordsEquipmentUnitRow + { + public string Key { get; set; } + public string Label { get; set; } + public int Assets { get; set; } + public int Issued { get; set; } + public int OutForRepair { get; set; } + public int Damaged { get; set; } + public int Expired { get; set; } + public int OverdueReturns { get; set; } + } + + /// + /// Equipment from IInventoryCatalogService: serialized assets by status and expiry, outstanding and overdue + /// issuances, and the per-unit view through the asset's location and the issuance's unit. Only rows the + /// inventory module reveals to this viewer are counted. + /// + public class RecordsEquipmentMetrics + { + public int Assets { get; set; } + public int InService { get; set; } + public int Issued { get; set; } + public int OutForRepair { get; set; } + public int Damaged { get; set; } + public int Expired { get; set; } + public int ExpiringWithin30Days { get; set; } + public int IssuancesOutstanding { get; set; } + public int IssuancesOverdueReturn { get; set; } + public List ByStatus { get; set; } = new List(); + public List ByUnit { get; set; } = new List(); + + /// The catalog read stopped at the page cap. + public bool Truncated { get; set; } + } + + /// Readiness-packet evidence captured on Records in the window (plan section 4.5). + public class RecordsReadinessEvidenceMetrics + { + /// Current (not superseded) readiness packets captured in the window. + public int PacketsCaptured { get; set; } + + /// Finalized Records in the window that carry a readiness packet. + public int RecordsWithPacket { get; set; } + + /// Finalized Records in the window linked to a call — the ones a packet could describe. + public int CallLinkedRecords { get; set; } + public double? CoveragePercent { get; set; } + public List ByMonth { get; set; } = new List(); + } + + /// One unit's readiness at a glance: current state and the module figures joined on the unit id. + public class RecordsUnitReadinessRow + { + public int UnitId { get; set; } + public string Label { get; set; } + public int? StationGroupId { get; set; } + public string StationGroupLabel { get; set; } + + /// Latest unit state (UnitStateTypes / custom state value) and when it was set; null when none. + public int? State { get; set; } + public DateTime? StateOn { get; set; } + public int ChecklistExpected { get; set; } + public int ChecklistCompleted { get; set; } + public int ChecklistMissed { get; set; } + public double? ChecklistCompletionRatePercent { get; set; } + public int OpenWorkOrders { get; set; } + public int OverdueWorkOrders { get; set; } + public int OnHoldWorkOrders { get; set; } + public int AssetsAssigned { get; set; } + public int AssetsOutForRepair { get; set; } + public int AssetsExpired { get; set; } + public int ReadinessPackets { get; set; } + + /// True when any figure on the row asks for attention: missed checks, overdue or on-hold orders, out-for-repair or expired assets. + public bool NeedsAttention => ChecklistMissed > 0 || OverdueWorkOrders > 0 || OnHoldWorkOrders > 0 || AssetsOutForRepair > 0 || AssetsExpired > 0; + } + + /// Header columns of an evidence artifact — never the manifest, the storage reference or the envelope. + public class RmsEvidenceArtifactHeader + { + public string RmsEvidenceArtifactId { get; set; } + public string RecordId { get; set; } + public int RecordKind { get; set; } + public string RevisionId { get; set; } + public int Kind { get; set; } + public int Classification { get; set; } + public DateTime CapturedOn { get; set; } + public DateTime? SupersededOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 53e8ef09..bda5c0d8 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -244,6 +244,8 @@ public interface IRmsEvidenceArtifactsRepository : IRepositoryStamps every unbound draft artifact with the revision being written at finalize. Task BindDraftToRevisionAsync(int departmentId, string recordId, string revisionId, DateTime utcNow, CancellationToken cancellationToken = default); Task CountForRecordAsync(int departmentId, string recordId); + /// Header columns (never manifest, storage reference or envelope) of live artifacts of one kind captured in [start, end), oldest first, at most rows. + Task> GetHeadersByKindInRangeAsync(int departmentId, RmsEvidenceKind kind, DateTime startUtc, DateTime endUtc, int take); } public interface IRmsRecordDueStatesRepository : IRepository diff --git a/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs b/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs index 1d31da0f..26df47c3 100644 --- a/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs @@ -7,6 +7,8 @@ namespace Resgrid.Model.Repositories { public interface IWorkOrderMaintenanceRepository { + Task> AllocatedPartsAsync(int departmentId, string itemId, string locationId, string lotId, string assetId); + Task> SlaDueAsync(int departmentId, DateTime now); Task> QueryMaintenanceAsync(int departmentId, string field = null, object value = null, int skip = 0, bool pendingOnly = false) where T : WorkOrderRow; Task> MaintenanceDepartmentsAsync(int afterDepartmentId); Task> OverdueAsync(int departmentId, DateTime now); diff --git a/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs index 8c69308f..72a3444a 100644 --- a/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs +++ b/Core/Resgrid.Model/Repositories/IWorkOrderRepository.cs @@ -12,6 +12,13 @@ public interface IWorkOrderRepository Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow; Task RequestAsync(int departmentId, string requestId); Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter); + Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, int[] ids = null); + Task> ReportChildrenAsync(int departmentId, int[] orderIds, int afterId, int take) where T : WorkOrderRow; + Task CaptureReportSnapshotAsync(WorkOrder row, System.DateTime recordedOn, int? activityId); + Task> ReportSnapshotsAsync(int departmentId, int[] orderIds, System.DateTime asOf); + Task> ReportSnapshotHistoryAsync(int departmentId, int orderId, long afterId); + Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, System.DateTime asOf, int[] unitIds, string[] assetIds, int afterId); + Task HasUnrecoverableReportHistoryAsync(int departmentId, WorkOrderReadScope scope, System.DateTime asOf); Task> ChildrenAsync(int departmentId, int orderId, int skip = 0) where T : WorkOrderRow; Task NextNumberAsync(int departmentId, int year); Task AllocateAsync(T row) where T : WorkOrderRow; diff --git a/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs b/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs index 20d44696..b443f4c0 100644 --- a/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs +++ b/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs @@ -23,5 +23,11 @@ public interface IRecordsAnalyticsService Task GetExecutiveSummaryAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); Task GetAccreditationAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); Task GetCommunityRiskAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + + /// + /// Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules through their + /// own authorized reads, plus readiness-packet evidence on Records; RMS computes no readiness of its own. + /// + Task GetReadinessAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs index 30ba39c8..9d600d4f 100644 --- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -111,6 +111,9 @@ public sealed class WorkOrderSummary public sealed class WorkOrderPage { public List Items { get; set; } = new List(); public bool HasMore { get; set; } public bool CanWrite { get; set; } } public sealed class WorkOrderDetail { + public DateTime? ResponseDueOn { get; set; } + public DateTime? RepairDueOn { get; set; } + public DateTime? ResponseOn { get; set; } public WorkOrderSummary Order { get; set; } public WorkOrderInput Input { get; set; } public bool CanWrite { get; set; } @@ -135,7 +138,7 @@ public sealed class WorkOrderDetail } public sealed class WorkOrderActivityView { public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } public WorkOrderContent Snapshot { get; set; } public string AssignedToUserId { get; set; } public int? AssignedToRoleId { get; set; } public int Id { get; set; } public WorkOrderActivityType Type { get; set; } public string UserId { get; set; } public DateTime CreatedOn { get; set; } public string Note { get; set; } public int? OldStatus { get; set; } public int? NewStatus { get; set; } } public sealed class WorkOrderLaborView { public int Id { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } } - public sealed class WorkOrderPartView { public string InventoryWitnessRequestId { get; set; } public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } public string InventoryOperationId { get; set; } public bool AwaitingWitness { get; set; } public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent Content { get; set; } } + public sealed class WorkOrderPartView { public bool Staged { get; set; } public decimal ReservedQuantity { get; set; } public decimal IssuedQuantity { get; set; } public decimal ConsumedQuantity { get; set; } public decimal ReturnedQuantity { get; set; } public string InventoryWitnessRequestId { get; set; } public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } public string InventoryOperationId { get; set; } public bool AwaitingWitness { get; set; } public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent Content { get; set; } } public sealed class WorkOrderFileView { public int Id { get; set; } public string Name { get; set; } public string ContentType { get; set; } public int Size { get; set; } public DateTime? WithdrawnOn { get; set; } } public sealed class WorkOrderChoice { public string Id { get; set; } public string Name { get; set; } } public sealed class WorkOrderChoices diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs index 15cbc548..ae4b52fa 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs @@ -154,6 +154,7 @@ public interface IWorkOrderMaintenanceService /// Inventory owns its permissions, controlled witnesses, ledger and asset state. Caller owns the shared transaction. public interface IInventoryWorkOrderAdapter { + Task QuotePartAsync(InventoryActor actor, int orderId, WorkOrderPartInput input); Task PostPartAsync(InventoryActor actor, int partId, InventoryCommand command); Task CancelPendingPartAsync(InventoryActor actor, int partId, string operationId); Task ApplyHoldAsync(InventoryActor actor, int orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false); diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs index 7b8d02a7..53a98c0c 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs @@ -29,6 +29,12 @@ public abstract class WorkOrderRow : IEntity } public sealed class WorkOrder : WorkOrderRow { + public DateTime? ResponseDueOn { get; set; } + public DateTime? RepairDueOn { get; set; } + public DateTime? ResponseOn { get; set; } + public DateTime? ResponseBreachedOn { get; set; } + public DateTime? RepairBreachedOn { get; set; } + public int? SlaPolicyRevision { get; set; } public string RequestId { get; set; } public int NumberYear { get; set; } public int NumberSequence { get; set; } @@ -76,6 +82,15 @@ public sealed class WorkOrderActivity : WorkOrderRow public sealed class WorkOrderLabor : WorkOrderRow { public string UserId { get; set; } public DateTime WorkDate { get; set; } } public sealed class WorkOrderPart : WorkOrderRow { + public bool Staged { get; set; } + public string ReservedLocationId { get; set; } + public string IssuedLocationId { get; set; } + public string ReservedAssetId { get; set; } + public string ReservedLotId { get; set; } + public decimal ReservedQuantity { get; set; } + public decimal IssuedQuantity { get; set; } + public decimal ConsumedQuantity { get; set; } + public decimal ReturnedQuantity { get; set; } public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } public string InventoryOperationId { get; set; } @@ -94,6 +109,7 @@ public sealed class WorkOrderFile : WorkOrderRow } public sealed class WorkOrderContent { + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public WorkOrderApproval Approval { get; set; } public string Title { get; set; } public string Description { get; set; } public string LocationText { get; set; } @@ -116,16 +132,19 @@ public sealed class WorkOrderContent public List Steps { get; set; } = new List(); } public sealed class WorkOrderTaskStep { public string Text { get; set; } public bool Completed { get; set; } } - public sealed class WorkOrderLaborContent { public decimal Hours { get; set; } public decimal? RatePerHour { get; set; } public string Note { get; set; } } - public sealed class WorkOrderPartContent { [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string RequestFingerprint { get; set; } public string Currency { get; set; } public string Description { get; set; } public decimal Quantity { get; set; } public decimal? UnitCost { get; set; } public string VoidReason { get; set; } } + public sealed class WorkOrderLaborContent { [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string Currency { get; set; } public decimal Hours { get; set; } public decimal? RatePerHour { get; set; } public string Note { get; set; } } + public sealed class WorkOrderPartContent { [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public decimal? ConsumedCost { get; set; } [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string RequestFingerprint { get; set; } public string Currency { get; set; } public string Description { get; set; } public decimal Quantity { get; set; } public decimal? UnitCost { get; set; } public string VoidReason { get; set; } } public static class WorkOrderTables { public const int CatalogVersion = 18; public const int IntegrationCatalogVersion = 23; public const int RecurrenceCatalogVersion = 24; - public static int VersionFor(string table) => table is "WorkOrderSafetyHolds" or "WorkOrderFailureIntents" ? IntegrationCatalogVersion : table is "WorkOrderRecurrences" or "WorkOrderRecurrenceVersions" or "WorkOrderMeterReadings" or "WorkOrderRecurrenceChanges" ? RecurrenceCatalogVersion : CatalogVersion; + public const int OperationsCatalogVersion = 25; + public static int VersionFor(string table) => table is "WorkOrderPolicies" or "WorkOrderOperationReceipts" or "WorkOrderVendorCharges" or "WorkOrderPartMovements" ? OperationsCatalogVersion : table is "WorkOrderSafetyHolds" or "WorkOrderFailureIntents" ? IntegrationCatalogVersion : table is "WorkOrderRecurrences" or "WorkOrderRecurrenceVersions" or "WorkOrderMeterReadings" or "WorkOrderRecurrenceChanges" ? RecurrenceCatalogVersion : CatalogVersion; public static readonly IReadOnlyDictionary All = new Dictionary { + [typeof(WorkOrderPolicy)] = "WorkOrderPolicies", [typeof(WorkOrderOperationReceipt)] = "WorkOrderOperationReceipts", + [typeof(WorkOrderVendorCharge)] = "WorkOrderVendorCharges", [typeof(WorkOrderPartMovement)] = "WorkOrderPartMovements", [typeof(WorkOrder)] = "WorkOrders", [typeof(WorkOrderActivity)] = "WorkOrderActivities", [typeof(WorkOrderLabor)] = "WorkOrderLabors", [typeof(WorkOrderPart)] = "WorkOrderParts", [typeof(WorkOrderFile)] = "WorkOrderFiles", [typeof(WorkOrderFailureIntent)] = "WorkOrderFailureIntents", [typeof(WorkOrderSafetyHold)] = "WorkOrderSafetyHolds", diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs new file mode 100644 index 00000000..613160d5 --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderOperations.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; + +namespace Resgrid.Model.WorkOrders +{ + public enum WorkOrderApprovalState { None = 0, Requested = 1, Approved = 2, Rejected = 3 } + public enum WorkOrderPartMovementKind { Reserve = 0, Issue = 1, Consume = 2, ReturnUnused = 3, ReleaseReservation = 4 } + public sealed class WorkOrderApproval + { + public WorkOrderApprovalState State { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public string RequestedBy { get; set; } + public DateTime RequestedOn { get; set; } + public string DecidedBy { get; set; } + public DateTime? DecidedOn { get; set; } + public string Reason { get; set; } + } + public sealed class WorkOrderSpendingRule { public string Currency { get; set; } public decimal Threshold { get; set; } } + public sealed class WorkOrderSlaTarget { public WorkOrderPriority Priority { get; set; } public int ResponseMinutes { get; set; } public int RepairMinutes { get; set; } } + public sealed class WorkOrderBusinessCalendar + { + public string TimeZoneId { get; set; } = "UTC"; + public int Weekdays { get; set; } = 62; + public int StartMinute { get; set; } = 540; + public int EndMinute { get; set; } = 1020; + public List Holidays { get; set; } = new(); + public List Targets { get; set; } = new(); + } + public sealed class WorkOrderPolicy : WorkOrderRow + { + // Equipment service clocks contain only validated time zones, dates and durations. + public string CalendarJson { get; set; } + } + public sealed class WorkOrderPolicyInput + { + public int Revision { get; set; } + public bool ApprovalsEnabled { get; set; } + public List SpendingRules { get; set; } = new(); + public WorkOrderBusinessCalendar Calendar { get; set; } = new(); + } + public sealed class WorkOrderApprovalInput { public int Revision { get; set; } public decimal Amount { get; set; } public bool Approve { get; set; } public string Reason { get; set; } } + public sealed class WorkOrderOperationReceipt : WorkOrderRow { public string RequestId { get; set; } public int Kind { get; set; } } + public sealed class WorkOrderBulkRow + { + public int RowNumber { get; set; } + public string ParseError { get; set; } + public int? WorkOrderId { get; set; } + public WorkOrderInput Import { get; set; } + public WorkOrderAssignment Assignment { get; set; } + } + public sealed class WorkOrderBulkInput { public string RequestId { get; set; } public string PreviewHash { get; set; } public List Rows { get; set; } = new(); } + public sealed class WorkOrderBulkRowResult { public int RowNumber { get; set; } public int? WorkOrderId { get; set; } public string Title { get; set; } public string ErrorCode { get; set; } public bool Applied { get; set; } } + public sealed class WorkOrderBulkResult { public string PreviewHash { get; set; } public List Rows { get; set; } = new(); } + public sealed class WorkOrderVendorCharge : WorkOrderRow { public string RequestId { get; set; } public DateTime? VoidedOn { get; set; } } + public sealed class WorkOrderVendorChargeContent + { + public string VendorId { get; set; } + public string VendorName { get; set; } + public string InvoiceReference { get; set; } + public string Description { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } + public DateTime ServiceDate { get; set; } + public string RequestHash { get; set; } + } + public sealed class WorkOrderVendorChargeInput { public int Revision { get; set; } public string RequestId { get; set; } public WorkOrderVendorChargeContent Content { get; set; } = new(); } + public sealed class WorkOrderVendorChargeView { public int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderVendorChargeContent Content { get; set; } } + public sealed class WorkOrderPartMovement : WorkOrderRow + { + public int PartId { get; set; } + public string RequestId { get; set; } + public int Kind { get; set; } + public decimal Quantity { get; set; } + public string FromLocationId { get; set; } + public string ToLocationId { get; set; } + public string InventoryOperationId { get; set; } + public string InventoryTransactionId { get; set; } + public bool Cancelled { get; set; } + } + public sealed class WorkOrderPartMovementInput { public int Revision { get; set; } public string RequestId { get; set; } public WorkOrderPartMovementKind Kind { get; set; } public decimal Quantity { get; set; } public string LocationId { get; set; } public string Reason { get; set; } } + public sealed class WorkOrderPartQuote { public decimal? UnitCost { get; set; } public string Currency { get; set; } } +} + +namespace Resgrid.Model.Services +{ + using Resgrid.Model.WorkOrders; + public interface IWorkOrderOperationsService + { + Task PolicyAsync(ChecklistActor actor); + Task SavePolicyAsync(ChecklistActor actor, WorkOrderPolicyInput input); + Task PreviewBulkAsync(ChecklistActor actor, WorkOrderBulkInput input); + Task ApplyBulkAsync(ChecklistActor actor, WorkOrderBulkInput input); + Task RequestApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input); + Task DecideApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input); + Task AddVendorChargeAsync(ChecklistActor actor, int id, WorkOrderVendorChargeInput input); + Task VoidVendorChargeAsync(ChecklistActor actor, int id, int chargeId, int revision, string reason); + Task> VendorChargesAsync(ChecklistActor actor, int id, int afterId = 0); + Task ReservePartAsync(ChecklistActor actor, int id, WorkOrderPartInput input); + Task MovePartAsync(ChecklistActor actor, int id, int partId, WorkOrderPartMovementInput input); + Task CancelPartMovementAsync(ChecklistActor actor, int id, int movementId, int revision, string reason); + Task> PartMovementsAsync(ChecklistActor actor, int id, int afterId = 0); + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs new file mode 100644 index 00000000..c59c5830 --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderReporting.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; + +namespace Resgrid.Model.WorkOrders +{ + public sealed class WorkOrderReportQuery + { + [Newtonsoft.Json.JsonIgnore] public bool PreventiveDueCohort { get; set; } + public DateTime? FromUtc { get; set; } + public DateTime? UntilUtc { get; set; } + public WorkOrderStatus? Status { get; set; } + public WorkOrderPriority? Priority { get; set; } + public int? UnitId { get; set; } + public int? GroupId { get; set; } + public string AssetId { get; set; } + public int AfterId { get; set; } + } + public sealed class WorkOrderCostTotal + { + public string Currency { get; set; } + public decimal Labor { get; set; } + public decimal Parts { get; set; } + public decimal Vendor { get; set; } + public decimal Total => Labor + Parts + Vendor; + public int UnknownLabor { get; set; } + public int UnknownParts { get; set; } + } + public sealed class WorkOrderReportEntry + { + public WorkOrderSummary Order { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public DateTime? ClosedOn { get; set; } + public decimal? RepairHours { get; set; } + public decimal? ActiveRepairHours { get; set; } + public decimal? WaitingHours { get; set; } + public decimal DowntimeHours { get; set; } + public DateTime? ResponseDueOn { get; set; } + public DateTime? RepairDueOn { get; set; } + public DateTime? ResponseOn { get; set; } + public List Costs { get; set; } = new(); + } + public sealed class WorkOrderHistoryPage + { + public List Items { get; set; } = new(); + public int? NextAfterId { get; set; } + } + public sealed class WorkOrderEvidencePage + { + public List Items { get; set; } = new(); + public int? NextAfterId { get; set; } + } + public sealed class WorkOrderStats + { + public DateTime FromUtc { get; set; } + public DateTime UntilUtc { get; set; } + public DateTime AsOfUtc { get; set; } + public int Total { get; set; } + public int Open { get; set; } + public int Overdue { get; set; } + public int RepairSamples { get; set; } + public int MissingRepairTimes { get; set; } + public decimal? MeanTimeToRepairHours { get; set; } + public decimal ActiveRepairHours { get; set; } + public decimal WaitingHours { get; set; } + public decimal DowntimeHours { get; set; } + public int MissingTimeHistory { get; set; } + public int PreventiveDue { get; set; } + public int PreventiveOnTime { get; set; } + public decimal? PreventiveCompliancePercent => PreventiveDue == 0 ? null : 100m * PreventiveOnTime / PreventiveDue; + public int RepeatedFailures { get; set; } + public int ResponseSlaBreaches { get; set; } + public int RepairSlaBreaches { get; set; } + public int[] OpenByPriority { get; set; } = new int[4]; + // 0-6, 7-29, 30-89, 90+ elapsed days since creation, evaluated at AsOfUtc. + public int[] OpenByAge { get; set; } = new int[4]; + public List Costs { get; set; } = new(); + } + /// Immutable operational metadata only. Sensitive fields are resolved from the + /// referenced immutable activity/template through ADP; ciphertext is never copied between rows. + public sealed class WorkOrderReportSnapshot + { + public long Id { get; set; } + public int DepartmentId { get; set; } + public int WorkOrderId { get; set; } + public int Revision { get; set; } + public DateTime? ResponseDueOn { get; set; } + public DateTime? RepairDueOn { get; set; } + public DateTime? ResponseOn { get; set; } + public DateTime? ResponseBreachedOn { get; set; } + public DateTime? RepairBreachedOn { get; set; } + public int? SlaPolicyRevision { get; set; } + public DateTime RecordedOn { get; set; } + public int? SourceActivityId { get; set; } + public int? RecurrenceVersionId { get; set; } + public int SourceType { get; set; } + public int Status { get; set; } + public int Priority { get; set; } + public int? TargetUnitId { get; set; } + public int? TargetGroupId { get; set; } + public string InventoryAssetId { get; set; } + public DateTime? DueOn { get; set; } + public DateTime? StartedOn { get; set; } + public DateTime? CompletedOn { get; set; } + public DateTime? ClosedOn { get; set; } + } + public sealed class ReadinessWorkOrderEvidence + { + public int WorkOrderId { get; set; } + public int Revision { get; set; } + public long? SnapshotId { get; set; } + public int? SourceActivityId { get; set; } + public int? RecurrenceVersionId { get; set; } + public DateTime RecordedOn { get; set; } + public string Title { get; set; } + public int Status { get; set; } + public int Priority { get; set; } + public int? UnitId { get; set; } + public string AssetId { get; set; } + public DateTime? DueOn { get; set; } + public string ChecklistCompletionId { get; set; } + public string ChecklistItemId { get; set; } + public List ActiveSafetyHoldIds { get; set; } = new(); + } + public sealed class ReadinessWorkOrderSection + { + public List Items { get; set; } = new(); + public bool HistoryUnavailable { get; set; } + public bool RestrictedScope { get; set; } + } +} +namespace Resgrid.Model.Services +{ + public interface IWorkOrderReportingService + { + Task> GetWorkOrderActivityAsync(ChecklistActor actor, int id, int afterId = 0); + Task> GetWorkOrderHoldsAsync(ChecklistActor actor, int id, int afterId = 0); + Task GetWorkOrderStatsAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); + Task GetWorkOrderHistoryAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); + Task ExportWorkOrdersAsync(ChecklistActor actor, WorkOrders.WorkOrderReportQuery query); + Task ReadinessEvidenceAsync(ChecklistActor actor, DateTime fromUtc, DateTime callUtc, int[] unitIds, string[] assetIds); + } +} diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs index 9d8099f8..1a4ea3bb 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -9,24 +9,27 @@ namespace Resgrid.Model.WorkOrders /// Workflow and notification routing never contains work-order content, personnel IDs, file names or grants. public static class WorkOrderWorkflowPayload { - public static readonly (string Variable, string Property)[] Variables = { ("id", "WorkOrderId"), ("revision", "Revision"), ("status", "Status"), ("priority", "Priority"), ("unit_id", "TargetUnitId"), ("group_id", "TargetGroupId"), ("asset_id", "InventoryAssetId"), ("role_id", "AssignedToRoleId"), ("due_on", "DueOn"), ("title", "Title"), ("recurrence_id", "RecurrenceId"), ("hold_id", "HoldId"), ("old_status", "OldStatus") }; - public static readonly int[] Triggers = { 70, 71, 72, 73, 167, 168, 169, 170, 171, 172 }; + public static readonly (string Variable, string Property)[] Variables = { ("id", "WorkOrderId"), ("revision", "Revision"), ("status", "Status"), ("priority", "Priority"), ("unit_id", "TargetUnitId"), ("group_id", "TargetGroupId"), ("asset_id", "InventoryAssetId"), ("role_id", "AssignedToRoleId"), ("due_on", "DueOn"), ("title", "Title"), ("recurrence_id", "RecurrenceId"), ("hold_id", "HoldId"), ("old_status", "OldStatus"), ("policy_id", "PolicyId"), ("approval_state", "ApprovalState"), ("response_due_on", "ResponseDueOn"), ("repair_due_on", "RepairDueOn"), ("response_breached_on", "ResponseBreachedOn"), ("repair_breached_on", "RepairBreachedOn") }; + public static readonly int[] Triggers = { 70, 71, 72, 73, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176 }; public static bool IsWorkOrder(int trigger) => Array.IndexOf(Triggers, trigger) >= 0; public static string Routing(JObject payload) { var safe = new JObject(); - foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId", "RecurrenceId", "HoldId", "OldStatus" }) + foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId", "RecurrenceId", "HoldId", "OldStatus", "PolicyId", "ApprovalState" }) 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"] 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.RecurrenceCatalogVersion; + foreach (var field in new[] { "DueOn", "ResponseDueOn", "RepairDueOn", "ResponseBreachedOn", "RepairBreachedOn" }) + { + if (payload[field] 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[field] = utc.ToString("O"); + } + else if (payload[field]?.Type == JTokenType.String && DateTimeOffset.TryParse(payload[field].Value(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var instant)) safe[field] = instant.UtcDateTime.ToString("O"); + } + safe["Title"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray("Title"); safe["catalog_version"] = WorkOrderTables.OperationsCatalogVersion; return safe.ToString(Formatting.None); } public static async Task ProjectAsync(int departmentId, JObject payload, IProtectedProjectionService protection, bool wrapped) diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 6c2b0142..2b2f4558 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -396,8 +396,13 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.WorkOrderThresholdReached: case WorkflowTriggerEventType.WorkOrderDeferred: case WorkflowTriggerEventType.WorkOrderPartChanged: + case WorkflowTriggerEventType.WorkOrderApprovalChanged: + case WorkflowTriggerEventType.WorkOrderSlaBreached: + case WorkflowTriggerEventType.WorkOrderVendorChargeChanged: + case WorkflowTriggerEventType.WorkOrderPolicyChanged: + foreach (var pair in WorkOrders.WorkOrderWorkflowPayload.Variables) - list.Add(new TemplateVariableDescriptor("work_order." + pair.Variable, "Work order " + pair.Variable.Replace('_', ' ') + (pair.Variable == "title" ? "; always REDACTED. Do not compare or render this value." : ""), pair.Variable is "asset_id" or "due_on" or "title" ? "string" : "int", false)); + list.Add(new TemplateVariableDescriptor("work_order." + pair.Variable, "Work order " + pair.Variable.Replace('_', ' ') + (pair.Variable == "title" ? "; always REDACTED. Do not compare or render this value." : ""), (pair.Variable is "asset_id" or "due_on" or "title" || pair.Variable.EndsWith("_on", System.StringComparison.Ordinal)) ? "string" : "int", false)); list.Add(new TemplateVariableDescriptor("work_order.url", "Authenticated work-order link", "string", false)); list.Add(new TemplateVariableDescriptor("protection.is_redacted", "Sensitive work-order fields are withheld", "bool", false)); list.Add(new TemplateVariableDescriptor("protection.redacted_fields", "Withheld fields", "array", false)); diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 9b0ec643..216fdcd3 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -178,6 +178,10 @@ public enum WorkflowTriggerEventType WorkOrderThresholdReached = 170, WorkOrderDeferred = 171, WorkOrderPartChanged = 172, + WorkOrderApprovalChanged = 173, + WorkOrderSlaBreached = 174, + WorkOrderVendorChargeChanged = 175, + WorkOrderPolicyChanged = 176, // Inventory modernization: persisted registry allocations; InventoryAdjusted retains value 22. InventoryTransferCompleted = 58, diff --git a/Core/Resgrid.Services/ChecklistReportDocuments.cs b/Core/Resgrid.Services/ChecklistReportDocuments.cs index 3f04e95e..342fd8b7 100644 --- a/Core/Resgrid.Services/ChecklistReportDocuments.cs +++ b/Core/Resgrid.Services/ChecklistReportDocuments.cs @@ -44,12 +44,13 @@ private static string Entries(IEnumerable entries) return body.Append("").ToString(); } private static string Unavailable(IEnumerable sources) => "
    " + string.Concat(sources.Select(s => "
  • " + H(Text(s)) + "
  • ")) + "
"; - public static string Packet(ReadinessEvidenceManifestV1 packet) => Page("ReadinessPacketReport", "

" + H(Text("CallId")) + ": " + H(packet.CallId) + "

" + H(packet.CoverageStartUtc.ToString("u")) + " — " + H(packet.CoverageEndUtc.ToString("u")) + "

" + H(Text("PacketMethod")) + "

" + H(Text("DispatchedUnits")) + "

    " + string.Concat(packet.Units.Select(u => "
  • " + H(u.Name) + " (#" + H(u.UnitId) + "; " + H(u.DispatchedUtc.ToString("u")) + ")
  • ")) + "

" + H(Text("IssuedEquipment")) + "

    " + string.Concat(packet.Assets.Select(a => "
  • " + H(a.Name) + " (" + H(a.AssetId) + ")
  • ")) + "
" + Entries(packet.Checklists) + Unavailable(packet.UnavailableSources)); + public static string Packet(ReadinessEvidenceManifestV1 packet) => Page("ReadinessPacketReport", "

" + H(Text("CallId")) + ": " + H(packet.CallId) + "

" + H(packet.CoverageStartUtc.ToString("u")) + " — " + H(packet.CoverageEndUtc.ToString("u")) + "

" + H(Text("PacketMethod")) + "

" + H(Text("DispatchedUnits")) + "

    " + string.Concat(packet.Units.Select(u => "
  • " + H(u.Name) + " (#" + H(u.UnitId) + "; " + H(u.DispatchedUtc.ToString("u")) + ")
  • ")) + "

" + H(Text("IssuedEquipment")) + "

    " + string.Concat(packet.Assets.Select(a => "
  • " + H(a.Name) + " (" + H(a.AssetId) + ")
  • ")) + "
" + Entries(packet.Checklists) + WorkOrderReportDocuments.PacketSection(packet.WorkOrders) + Unavailable(packet.UnavailableSources)); public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); public static void EnsureStillAuthorized(ReadinessEvidenceManifestV1 captured, ReadinessEvidenceManifestV1 current) { if (captured.DepartmentId != current.DepartmentId || captured.CallId != current.CallId || captured.Checklists.Any(e => !current.Checklists.Any(c => c.OccurrenceId == e.OccurrenceId)) + || captured.WorkOrders.Any(e => !current.WorkOrders.Any(c => c.WorkOrderId == e.WorkOrderId && c.Revision == e.Revision && c.SnapshotId == e.SnapshotId && c.SourceActivityId == e.SourceActivityId)) || captured.Units.Any(u => !current.Units.Any(c => c.UnitId == u.UnitId)) || captured.Assets.Any(a => !current.Assets.Any(c => c.AssetId == a.AssetId && c.SourceId == a.SourceId))) throw new ChecklistException(403, "The request could not be completed."); diff --git a/Core/Resgrid.Services/ChecklistReporting.cs b/Core/Resgrid.Services/ChecklistReporting.cs index bf6b4bfe..f879064d 100644 --- a/Core/Resgrid.Services/ChecklistReporting.cs +++ b/Core/Resgrid.Services/ChecklistReporting.cs @@ -101,7 +101,7 @@ private async Task ReportTargetAsync(ChecklistActor actor, Chec private static void AddUnavailable(List list, string value) { if (!list.Contains(value)) list.Add(value); } public async Task GetReadinessPacketForCallAsync(ChecklistActor actor, int callId, int lookbackDays = 30) { - await RequireWriteAsync(actor); + await _authorization.RequireMemberAsync(actor); if (lookbackDays < 1 || lookbackDays > 93) throw new ChecklistException(400, "ReportRangeInvalid"); if (callId <= 0 || _reportCalls == null || _reportAuthorization == null) throw new ChecklistException(404, "ReadinessCallUnavailable"); var call = await _reportCalls.Value.GetCallByIdAsync(callId, true); @@ -140,9 +140,23 @@ public async Task GetReadinessPacketForCallAsync(Ch packet.Assets.Add(asset); targets.Add((ChecklistTargetType.InventoryAsset, asset.AssetId)); } } - AddUnavailable(packet.UnavailableSources, "WorkOrdersUnavailable"); + if (_workOrderReports == null) AddUnavailable(packet.UnavailableSources, "WorkOrdersUnavailable"); + else + { + try + { + var section = await _workOrderReports.Value.ReadinessEvidenceAsync(actor, packet.CoverageStartUtc, at, packet.Units.Select(u => u.UnitId).Distinct().ToArray(), packet.Assets.Select(a => a.AssetId).Distinct().ToArray()); + packet.WorkOrders = section.Items; + if (section.HistoryUnavailable) AddUnavailable(packet.UnavailableSources, "HistoricalWorkOrdersUnavailable"); + if (section.RestrictedScope) AddUnavailable(packet.UnavailableSources, "RestrictedWorkOrders"); + } + catch (Resgrid.Model.WorkOrders.WorkOrderException ex) + { + throw new ChecklistException(ex.StatusCode, ex.Code == "ProtectedDataRequired" ? "Unlock protected data to use this checklist." : ex.Code == "ReportTooLarge" ? "ReportTooLarge" : "ReadinessCallUnavailable"); + } + } packet.Checklists = await ReportEntriesAsync(actor, new ChecklistReportQuery { FromUtc = packet.CoverageStartUtc, UntilUtc = at.AddTicks(1) }, at, packet.UnavailableSources, targets); - await RequireWriteAsync(actor); + await _authorization.RequireMemberAsync(actor); if (!await _reportAuthorization.Value.CanUserViewCallAsync(actor.UserId, callId)) throw new ChecklistException(404, "ReadinessCallUnavailable"); return packet; } diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs index d6e987f5..b9c49da2 100644 --- a/Core/Resgrid.Services/ChecklistsService.cs +++ b/Core/Resgrid.Services/ChecklistsService.cs @@ -36,11 +36,12 @@ public partial class ChecklistsService : IChecklistsService private readonly Lazy _reportAuthorization; private readonly IChecklistHistoricalAssetSource _historicalAssets; private readonly Lazy _failureMaintenance; + private readonly Lazy _workOrderReports; public ChecklistsService(IChecklistRepository store, IChecklistAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, IChecklistAssignmentService assignments = null, IChecklistAssetSource assets = null, - Lazy reportCalls = null, Lazy reportAuthorization = null, IChecklistHistoricalAssetSource historicalAssets = null, Lazy failureMaintenance = null) - { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; _assignments = assignments; _assets = assets; _reportCalls = reportCalls; _reportAuthorization = reportAuthorization; _historicalAssets = historicalAssets; _failureMaintenance = failureMaintenance; } + Lazy reportCalls = null, Lazy reportAuthorization = null, IChecklistHistoricalAssetSource historicalAssets = null, Lazy failureMaintenance = null, Lazy workOrderReports = null) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; _assignments = assignments; _assets = assets; _reportCalls = reportCalls; _reportAuthorization = reportAuthorization; _historicalAssets = historicalAssets; _failureMaintenance = failureMaintenance; _workOrderReports = workOrderReports; } private async Task ValidateMaintenanceOptionsAsync(ChecklistActor actor, ChecklistForm form) { diff --git a/Core/Resgrid.Services/InventoryPosting.cs b/Core/Resgrid.Services/InventoryPosting.cs index 960a5e22..0c1a5c3c 100644 --- a/Core/Resgrid.Services/InventoryPosting.cs +++ b/Core/Resgrid.Services/InventoryPosting.cs @@ -36,6 +36,7 @@ private async Task ValidateCommandAsync(InventoryActor actor, InventoryCommand c 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?.WorkOrderPartMovementId != null && (!joined || line.WorkOrderPartId == null)) throw new InventoryException(400, "WorkOrderPostingRequired"); if (line?.WorkOrderPartId != null && (!joined || line.ReferenceType != InventoryReferenceType.WorkOrder)) throw new InventoryException(400, "WorkOrderPostingRequired"); if (line?.WorkOrderPartId != null) await RequireWorkOrderPartAsync(actor, line); if (line?.CountItemId != null && !counting) throw new InventoryException(400, "CountCompletionRequired"); @@ -259,6 +260,7 @@ private async Task PostLinesAsync(InventoryActor actor, Invento transaction.ReferenceType = (int)line.ReferenceType; transaction.ReferenceId = line.ReferenceId; transaction.OccurredOn = Now; transaction.ReversesTransactionId = line.ReversesTransactionId; transaction.IssuanceId = line.IssuanceId; transaction.PurchaseOrderItemId = line.PurchaseOrderItemId; transaction.CountItemId = line.CountItemId; transaction.WorkOrderPartId = line.WorkOrderPartId; + transaction.WorkOrderPartMovementId = line.WorkOrderPartMovementId; if (line.ReversesTransactionId != null) { var original = await GetAsync(actor, line.ReversesTransactionId); @@ -277,7 +279,7 @@ private async Task PostLinesAsync(InventoryActor actor, Invento if (line.FromLocationId != null) { var stock = await _store.ApplyStockDeltaAsync(actor.DepartmentId, item.Id, line.FromLocationId, line.LotId, -line.Quantity, actor.UserId); - if (stock.Quantity < 0) throw new InventoryException(409, "InsufficientStock"); + if (stock.Quantity < await AllocatedQuantityAsync(actor.DepartmentId, line)) throw new InventoryException(409, "InsufficientStock"); transaction.FromQuantityAfter = stock.Quantity; transaction.FromQuantityBefore = stock.Quantity + line.Quantity; } if (line.ToLocationId != null) @@ -303,6 +305,7 @@ private async Task PostLinesAsync(InventoryActor actor, Invento private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, InventoryTransaction transaction, decimal? receiptCost) { var asset = await GetAsync(actor, line.AssetId); + if (line.FromLocationId != null && await AllocatedQuantityAsync(actor.DepartmentId, line) > 0) throw new InventoryException(409, "AssetNotAvailable"); 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) { diff --git a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs new file mode 100644 index 00000000..ced7349e --- /dev/null +++ b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class InventoryModernizationService + { + public async Task QuotePartAsync(InventoryActor actor, int orderId, WorkOrderPartInput input) + { + if (_uow.Transaction == null || _maintenanceOrders == null || _workOrders == null) throw new InvalidOperationException("Reservations require their source transaction."); + await _store.LockDepartmentAsync(actor.DepartmentId); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId) || !await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = input.InventoryItemId, AssetId = input.InventoryAssetId, LotId = input.InventoryLotId, FromLocationId = input.InventoryLocationId, Quantity = input.Content.Quantity, Type = InventoryTransactionType.Consume, ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = orderId.ToString(CultureInfo.InvariantCulture) } } }; + await RequireCommandAccessAsync(actor, command, joined: true); await ValidateCommandAsync(actor, command, joined: true); + var line = command.Lines[0]; var item = await GetAsync(actor, line.ItemId); + var cost = await CostForPostingAsync(actor, item, line); + var allocations = await AllocatedQuantityAsync(actor.DepartmentId, line); + if (line.AssetId == null) + { + var stocks = await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id); + var available = stocks.Where(s => s.LocationId == line.FromLocationId && s.LotId == line.LotId).Sum(s => s.Quantity); + if (available - allocations < line.Quantity) throw new InventoryException(409, "InsufficientStock"); + } + else + { + var asset = await GetAsync(actor, line.AssetId); + if (allocations > 0 || asset.Status != (int)InventoryAssetStatus.InService || asset.CurrentLocationId != line.FromLocationId || asset.LotId != line.LotId || asset.ExpiresOn <= Now) throw new InventoryException(409, "AssetNotAvailable"); + } + return new WorkOrderPartQuote { UnitCost = cost.UnitCost, Currency = cost.CurrencyCode }; + } + private async Task AllocatedQuantityAsync(int departmentId, InventoryPosting line) + { + if (_maintenanceOrders == null || line.FromLocationId == null) return 0; + var allocations = await _maintenanceOrders.AllocatedPartsAsync(departmentId, line.ItemId, line.FromLocationId, line.LotId, line.AssetId); + var total = allocations.Sum(p => (p.ReservedLocationId == line.FromLocationId ? p.ReservedQuantity : 0) + (p.IssuedLocationId == line.FromLocationId ? p.IssuedQuantity : 0)); + if (line.WorkOrderPartMovementId.HasValue) + { + await ValidatePartMovementAsync(departmentId, line); + total -= line.Quantity; // Only the validated source movement may use its own allocation. + } + return Math.Max(0, total); + } + private async Task ValidatePartMovementAsync(int departmentId, InventoryPosting line) + { + if (!line.WorkOrderPartId.HasValue || !line.WorkOrderPartMovementId.HasValue || line.ReversesTransactionId != null) throw new InventoryException(409, "WorkOrderPostingRequired"); + var part = await _workOrders.GetAsync(departmentId, line.WorkOrderPartId.Value); + var movement = await _workOrders.GetAsync(departmentId, line.WorkOrderPartMovementId.Value); + if (part == null || !part.Staged || part.VoidedOn.HasValue || movement?.PartId != part.Id || movement.WorkOrderId != part.WorkOrderId || movement.Cancelled || movement.InventoryTransactionId != null + || movement.Quantity != line.Quantity || movement.FromLocationId != line.FromLocationId || movement.ToLocationId != line.ToLocationId || line.AssetId != part.ReservedAssetId || line.LotId != part.ReservedLotId) throw new InventoryException(409, "ReferenceUnavailable"); + var kind = (WorkOrderPartMovementKind)movement.Kind; + if (kind == WorkOrderPartMovementKind.Issue) + { + if (line.Type != InventoryTransactionType.Transfer || line.FromLocationId != part.ReservedLocationId || line.Quantity > part.ReservedQuantity || part.IssuedQuantity > 0 && part.IssuedLocationId != line.ToLocationId) throw new InventoryException(409, "ReferenceUnavailable"); + } + else if (kind is WorkOrderPartMovementKind.Consume or WorkOrderPartMovementKind.ReturnUnused) + { + if (line.FromLocationId != part.IssuedLocationId || line.Quantity > part.IssuedQuantity || line.Type != (kind == WorkOrderPartMovementKind.Consume ? InventoryTransactionType.Consume : InventoryTransactionType.Transfer) + || kind == WorkOrderPartMovementKind.ReturnUnused && line.ToLocationId != part.ReservedLocationId) throw new InventoryException(409, "ReferenceUnavailable"); + } + else throw new InventoryException(409, "ReferenceUnavailable"); + } + } +} diff --git a/Core/Resgrid.Services/InventoryWorkOrders.cs b/Core/Resgrid.Services/InventoryWorkOrders.cs index 227d7ab4..fbcdfca9 100644 --- a/Core/Resgrid.Services/InventoryWorkOrders.cs +++ b/Core/Resgrid.Services/InventoryWorkOrders.cs @@ -46,7 +46,8 @@ private async Task RequireWorkOrderPartAsync(InventoryActor actor, InventoryPost if (!await _readinessAccess.CanUseMaintenanceAsync(actor.DepartmentId)) throw new InventoryException(402, "ReadinessProRequired"); var part = await _workOrders.GetAsync(actor.DepartmentId, line.WorkOrderPartId.Value); if (part?.WorkOrderId?.ToString(System.Globalization.CultureInfo.InvariantCulture) != line.ReferenceId || part.InventoryItemId != line.ItemId || part.VoidedOn.HasValue) throw new InventoryException(409, "ReferenceUnavailable"); - if (line.ReversesTransactionId == null && part.InventoryTransactionId != null || line.ReversesTransactionId != null && part.InventoryTransactionId != line.ReversesTransactionId) throw new InventoryException(409, "ReferenceUnavailable"); + if (part.Staged) await ValidatePartMovementAsync(actor.DepartmentId, line); + else if (line.WorkOrderPartMovementId.HasValue || line.ReversesTransactionId == null && part.InventoryTransactionId != null || line.ReversesTransactionId != null && part.InventoryTransactionId != line.ReversesTransactionId) throw new InventoryException(409, "ReferenceUnavailable"); var order = await _workOrders.GetAsync(actor.DepartmentId, part.WorkOrderId.Value); var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; if (order == null || order.Status >= 5 || !await _workOrderAuthorization.Value.CanContributeAsync(principal, order)) throw new InventoryException(409, "ReferenceClosed"); diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs index d6d83cae..a11c40a3 100644 --- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -59,7 +59,7 @@ public class ReadinessPacketEvidenceAdapter : IRecordEvidenceAdapter public ReadinessPacketEvidenceAdapter(IChecklistsService checklists = null, IReadinessAccessService access = null, IProtectedGrantContext grant = null, Resgrid.Model.Providers.IPdfProvider pdf = null) { _checklists = checklists; _access = access; _grant = grant; _pdf = pdf; } public RmsEvidenceKind Kind => RmsEvidenceKind.ReadinessPacket; - public Task IsAvailableAsync(int departmentId) => _checklists == null || _access == null || _pdf == null ? Task.FromResult(false) : _access.CanUseChecklistsAsync(departmentId); + public Task IsAvailableAsync(int departmentId) => Task.FromResult(_checklists != null && _pdf != null); public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { if (!await IsAvailableAsync(request.DepartmentId)) return RecordEvidenceCapture.Unavailable(ChecklistReportDocuments.Text("ChecklistsDisabled")); @@ -67,7 +67,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque if (!request.CallId.HasValue || request.CoverageStart.HasValue || request.CoverageEnd.HasValue) throw new ArgumentException(ChecklistReportDocuments.Text("PacketCaptureWindow")); var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = request.DepartmentId, UserId = request.CapturedByUserId, GrantToken = _grant.GrantToken }; var manifest = await _checklists.GetReadinessPacketForCallAsync(actor, request.CallId.Value); - if (manifest.Checklists.Count > EvidenceLimits.MaxItems) throw new ArgumentException(ChecklistReportDocuments.Text("ReportTooLarge")); + if (manifest.Checklists.Count + manifest.WorkOrders.Count > EvidenceLimits.MaxItems) throw new ArgumentException(ChecklistReportDocuments.Text("ReportTooLarge")); cancellationToken.ThrowIfCancellationRequested(); var package = ChecklistReportDocuments.Package(manifest, _pdf); // Repeat source authorization after PDF conversion, before Records retains the snapshot. @@ -76,7 +76,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque return new RecordEvidenceCapture { Title = ChecklistReportDocuments.Text("ReadinessPacketReport"), SourceSubsystem = SourceSubsystem, SourceEntityType = "ReadinessEvidenceManifestV1", SourceEntityId = EvidenceLimits.SelectionIdentity(new { request.CallId, lookback_days = 30 }), IdentifierScheme = "resgrid:readiness-packet", SourceVersion = manifest.GeneratorVersion, CoverageStart = manifest.CoverageStartUtc, - CoverageEnd = manifest.CoverageEndUtc, SourceItemCount = manifest.Checklists.Count, Classification = RmsEvidenceClassification.Restricted, Manifest = package }; + CoverageEnd = manifest.CoverageEndUtc, SourceItemCount = manifest.Checklists.Count + manifest.WorkOrders.Count, Classification = RmsEvidenceClassification.Restricted, Manifest = package }; } } diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs new file mode 100644 index 00000000..52604e59 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.Readiness.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services.Records +{ + /// + /// RMS-6 readiness dashboard (RMS plan section 6, RMS-6, third bullet; section 4.5). RMS computes no readiness of + /// its own: the checklists module answers compliance through , the maintenance + /// module answers work-order statistics and history through , the inventory + /// module reveals the assets, locations and issuances this viewer may read through , + /// and Records contributes only the readiness-packet evidence it already captured. Each module authorizes the + /// viewer itself; a refusal marks the section forbidden and nothing from that module is counted. The unit board + /// joins those answers on the unit id, restricted to the units a group-scoped viewer may see. + /// + public partial class RecordsAnalyticsService + { + /// The checklists module accepts a compliance cohort of at most 93 days. + public const int ChecklistCohortDays = 93; + + /// Inventory pages read per table (500 rows each) before the equipment section reports truncation. + public const int InventoryPageCap = 10; + + /// Work-order history pages read (50 orders each) for the per-unit roll-up before it reports truncation. + public const int WorkOrderHistoryPageCap = 40; + + private static readonly int[] OutstandingIssuance = { (int)InventoryIssuanceStatus.Outstanding, (int)InventoryIssuanceStatus.PartiallyReturned }; + private static readonly string[] AgeBands = { "0-6 days", "7-29 days", "30-89 days", "90+ days" }; + + public async Task GetReadinessAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsReadiness { DepartmentId = c.DepartmentId }; + var labels = await LabelsAsync(departmentId, false, true, false); + var units = await VisibleUnitsAsync(c); + var rows = units.ToDictionary(u => u.UnitId, u => new RecordsUnitReadinessRow { UnitId = u.UnitId, Label = u.Name, StationGroupId = u.StationGroupId, StationGroupLabel = u.StationGroupId.HasValue ? labels.Group(u.StationGroupId.Value.ToString()) : null }); + var actor = new ChecklistActor { DepartmentId = departmentId, UserId = userId }; + + await SectionAsync(result, "Unit states", async () => + { + foreach (var state in (await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId)) ?? new List()) + if (state != null && rows.TryGetValue(state.UnitId, out var row)) { row.State = state.State; row.StateOn = state.Timestamp; } + }); + + result.ChecklistsEnabled = await FlagAsync(() => _readinessAccess.CanUseChecklistsAsync(departmentId)); + if (result.ChecklistsEnabled) + await ModuleSectionAsync(result, "Checklists", async () => { result.Checklists = await ChecklistMetricsAsync(c, actor, rows, labels); }, + status => { if (status == 403) result.ChecklistsForbidden = true; else result.ChecklistsEnabled = false; }); + + result.MaintenanceEnabled = await FlagAsync(() => _flags.IsEnabledAsync(FeatureFlagKeys.MaintenanceWorkOrders, departmentId)); + if (result.MaintenanceEnabled) + await ModuleSectionAsync(result, "Work orders", async () => { result.WorkOrders = await WorkOrderMetricsAsync(c, actor, rows); }, + status => { if (status == 403) result.MaintenanceForbidden = true; else result.MaintenanceEnabled = false; }); + + result.InventoryEnabled = await FlagAsync(() => _inventoryAuthorization.IsEnabledAsync(departmentId)); + if (result.InventoryEnabled) + await ModuleSectionAsync(result, "Equipment", async () => { result.Equipment = await EquipmentMetricsAsync(c, rows); }, + status => { if (status == 403) result.InventoryForbidden = true; else result.InventoryEnabled = false; }); + + await SectionAsync(result, "Readiness evidence", async () => { result.Evidence = await EvidenceMetricsAsync(c, data, rows); }); + + result.Units = rows.Values.OrderByDescending(r => r.NeedsAttention).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); + return Finish(result, c, data); + } + + #region Gates and scope + + private static async Task FlagAsync(Func> probe) + { + try { return await probe(); } + catch (Exception ex) { Logging.LogException(ex, "Records analytics: readiness module probe failed; treating the module as off."); return false; } + } + + /// A module's own refusal (403) or "not available" (402/404/409) answer is reported as such; anything else degrades to a warning. + private static async Task ModuleSectionAsync(RecordsAnalyticsBase result, string area, Func work, Action onModuleStatus) + { + try { await work(); } + catch (Exception ex) + { + var status = ex switch + { + ChecklistException checklist => checklist.StatusCode, + WorkOrderException workOrder => workOrder.StatusCode, + InventoryException inventory => inventory.StatusCode, + UnauthorizedAccessException _ => 403, + _ => (int?)null + }; + if (status == 403 || status == 402 || status == 404 || status == 409) { onModuleStatus(status.Value); return; } + Logging.LogException(ex, $"Records analytics: {area} unavailable for department {result.DepartmentId}."); + result.Warnings.Add($"{area} unavailable."); + } + } + + /// Units of the department, narrowed by the station filter and — for a group-scoped viewer — to groups they may see (a unit without a group stays visible, as a Record without one does). + private async Task> VisibleUnitsAsync(Context c) + { + var units = ((await _unitsService.GetUnitsForDepartmentAsync(c.DepartmentId)) ?? new List()).Where(u => u != null).ToList(); + if (c.StationGroupId.HasValue) units = units.Where(u => u.StationGroupId == c.StationGroupId).ToList(); + if (c.GroupScoped) units = units.Where(u => !u.StationGroupId.HasValue || c.Visible.Contains(u.StationGroupId.Value)).ToList(); + return units.OrderBy(u => u.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static bool GroupVisible(Context c, int? groupId) => !groupId.HasValue || (!c.StationGroupId.HasValue || groupId == c.StationGroupId) && (!c.GroupScoped || c.Visible.Contains(groupId.Value)); + + #endregion + + #region Checklists + + private async Task ChecklistMetricsAsync(Context c, ChecklistActor actor, Dictionary rows, Labels labels) + { + var cohortStart = c.End - c.Start > TimeSpan.FromDays(ChecklistCohortDays) ? c.End.AddDays(-ChecklistCohortDays) : c.Start; + var summary = await _checklists.GetComplianceSummaryAsync(actor, new ChecklistReportQuery { FromUtc = cohortStart, UntilUtc = c.End }); + var m = new RecordsChecklistMetrics { CohortStart = cohortStart, CohortEnd = c.End, IsRedacted = summary.IsRedacted, UnavailableSources = summary.UnavailableSources?.ToList() ?? new List() }; + if (cohortStart > c.Start) c.Warnings.Add($"Checklist compliance covers the last {ChecklistCohortDays} days of the window ({cohortStart:yyyy-MM-dd} to {c.End.AddDays(-1):yyyy-MM-dd}); the checklists module measures at most {ChecklistCohortDays} days at a time."); + + // Unit and group targets follow the dashboard's station filter and the viewer's group scope; person and asset + // targets were already authorized by the module and pass through. + var groups = (summary.Groups ?? new List()).Where(g => g?.Target != null).Where(g => + { + switch (g.Target.Type) + { + case ChecklistTargetType.Unit: return int.TryParse(g.Target.Id, out var unitId) ? rows.ContainsKey(unitId) : GroupVisible(c, g.Target.GroupId); + case ChecklistTargetType.Group: return int.TryParse(g.Target.Id, out var groupId) ? GroupVisible(c, groupId) : GroupVisible(c, g.Target.GroupId); + default: return GroupVisible(c, g.Target.GroupId); + } + }).ToList(); + + m.Expected = groups.Sum(g => g.Expected); m.Completed = groups.Sum(g => g.Completed); m.OnTime = groups.Sum(g => g.OnTime); + m.Missed = groups.Sum(g => g.Missed); m.Skipped = groups.Sum(g => g.Skipped); + m.CompletionRatePercent = Pct(m.Completed, m.Expected); m.OnTimePercent = Pct(m.OnTime, m.Expected); + + RecordsReadinessTargetRow Row(ChecklistComplianceGroup g, string label) => new RecordsReadinessTargetRow + { + Key = g.Target.Id, Label = label, Expected = g.Expected, Completed = g.Completed, OnTime = g.OnTime, Missed = g.Missed, Skipped = g.Skipped, + CompletionRatePercent = Pct(g.Completed, g.Expected), OnTimePercent = Pct(g.OnTime, g.Expected) + }; + m.ByUnit = groups.Where(g => g.Target.Type == ChecklistTargetType.Unit).Select(g => Row(g, int.TryParse(g.Target.Id, out var id) && rows.TryGetValue(id, out var r) ? r.Label : g.Target.Name ?? g.Target.Id)).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); + m.ByStationGroup = groups.Where(g => g.Target.Type == ChecklistTargetType.Group).Select(g => Row(g, labels.Group(g.Target.Id))).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); + m.ByPerson = groups.Where(g => g.Target.Type == ChecklistTargetType.Personnel).Select(g => Row(g, g.Target.Name ?? g.Target.Id)).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); + m.ByAsset = groups.Where(g => g.Target.Type == ChecklistTargetType.InventoryAsset).Select(g => Row(g, g.Target.Name ?? g.Target.Id)).OrderByDescending(r => r.Missed).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase).ToList(); + m.Trend = (summary.Trend ?? new List()).OrderBy(t => t.DayUtc).Select(t => new RecordsChecklistTrendPoint { Day = t.DayUtc, Expected = t.Expected, Missed = t.Missed }).ToList(); + + foreach (var g in groups.Where(g => g.Target.Type == ChecklistTargetType.Unit)) + if (int.TryParse(g.Target.Id, out var unitId) && rows.TryGetValue(unitId, out var row)) + { + row.ChecklistExpected += g.Expected; row.ChecklistCompleted += g.Completed; row.ChecklistMissed += g.Missed; + row.ChecklistCompletionRatePercent = Pct(row.ChecklistCompleted, row.ChecklistExpected); + } + return m; + } + + #endregion + + #region Work orders + + private async Task WorkOrderMetricsAsync(Context c, ChecklistActor actor, Dictionary rows) + { + var stats = await _workOrderReporting.GetWorkOrderStatsAsync(actor, new WorkOrderReportQuery { FromUtc = c.Start, UntilUtc = c.End, GroupId = c.StationGroupId }); + var m = new RecordsWorkOrderMetrics + { + CohortStart = stats.FromUtc, CohortEnd = stats.UntilUtc, Total = stats.Total, Open = stats.Open, Overdue = stats.Overdue, + MeanTimeToRepairHours = stats.MeanTimeToRepairHours.HasValue ? Math.Round((double)stats.MeanTimeToRepairHours.Value, 1) : (double?)null, + RepairSamples = stats.RepairSamples, MissingRepairTimes = stats.MissingRepairTimes, + OpenByPriority = (stats.OpenByPriority ?? new int[4]).Select((n, i) => new RecordsAnalyticsCount { Key = i.ToString(), Label = EnumLabel(i), Count = n, Percent = Pct(n, stats.Open) }).ToList(), + OpenByAge = (stats.OpenByAge ?? new int[4]).Select((n, i) => new RecordsAnalyticsCount { Key = i.ToString(), Label = i < AgeBands.Length ? AgeBands[i] : i.ToString(), Count = n, Percent = Pct(n, stats.Open) }).ToList(), + Costs = (stats.Costs ?? new List()).Select(x => new RecordsWorkOrderCost { Currency = x.Currency, Labor = x.Labor, Parts = x.Parts, Unknown = x.UnknownLabor + x.UnknownParts }).ToList() + }; + + // Per-unit roll-up from the module's paged history: the same window, the same scope, bounded by the page cap. + var byUnit = new Dictionary(); + var query = new WorkOrderReportQuery { FromUtc = c.Start, UntilUtc = c.End, GroupId = c.StationGroupId, AfterId = 0 }; + for (var page = 0; ; page++) + { + if (page >= WorkOrderHistoryPageCap) { m.HistoryTruncated = true; c.Warnings.Add($"The per-unit work-order roll-up covers the first {WorkOrderHistoryPageCap * 50:N0} orders in the window; the module's totals are complete."); break; } + var history = await _workOrderReporting.GetWorkOrderHistoryAsync(actor, query); + foreach (var entry in history?.Items ?? new List()) + { + var order = entry?.Order; + if (order == null || !order.UnitId.HasValue || !rows.TryGetValue(order.UnitId.Value, out var unitRow)) continue; + if (!byUnit.TryGetValue(order.UnitId.Value, out var u)) byUnit[order.UnitId.Value] = u = new RecordsWorkOrderUnitRow { Key = order.UnitId.Value.ToString(), Label = unitRow.Label }; + var open = (int)order.Status <= (int)WorkOrderStatus.Completed; + var overdue = (int)order.Status < (int)WorkOrderStatus.Completed && order.DueOn.HasValue && order.DueOn < c.Now; + u.Total++; + if (open) { u.Open++; unitRow.OpenWorkOrders++; if (order.Priority == WorkOrderPriority.Emergency) u.Emergency++; } + if (overdue) { u.Overdue++; unitRow.OverdueWorkOrders++; } + if (order.Status == WorkOrderStatus.OnHold) { u.OnHold++; unitRow.OnHoldWorkOrders++; m.OnHold++; } + } + if (history?.NextAfterId == null) break; + query.AfterId = history.NextAfterId.Value; + } + m.ByUnit = byUnit.Values.OrderByDescending(u => u.Overdue).ThenByDescending(u => u.Open).ThenBy(u => u.Label, StringComparer.OrdinalIgnoreCase).ToList(); + return m; + } + + #endregion + + #region Equipment + + private async Task> InventoryPagesAsync(InventoryActor actor, Action truncated) where T : InventoryRow + { + var rows = new List(); + for (var page = 0; page < InventoryPageCap; page++) + { + var result = await _inventory.ListAsync(actor, page); + rows.AddRange(result?.Items ?? new List()); + if (result == null || !result.HasMore) return rows; + } + truncated(); + return rows; + } + + private async Task EquipmentMetricsAsync(Context c, Dictionary rows) + { + var actor = new InventoryActor { DepartmentId = c.DepartmentId, UserId = c.UserId }; + var m = new RecordsEquipmentMetrics(); + void Truncate() => m.Truncated = true; + var assets = (await InventoryPagesAsync(actor, Truncate)).Where(a => a != null && !a.IsDeleted).ToList(); + var locations = (await InventoryPagesAsync(actor, Truncate)).Where(l => l != null && !l.IsDeleted).ToDictionary(l => l.Id, StringComparer.Ordinal); + var issuances = (await InventoryPagesAsync(actor, Truncate)).Where(i => i != null && !i.IsDeleted).ToList(); + if (m.Truncated) c.Warnings.Add($"Equipment covers the first {InventoryPageCap * 500:N0} rows of each inventory table the viewer may read."); + var assetById = assets.ToDictionary(a => a.Id, StringComparer.Ordinal); + + int? UnitOf(InventoryAsset asset) + { + var locationId = asset.CurrentLocationId; + for (var depth = 0; depth < 6 && locationId != null && locations.TryGetValue(locationId, out var location); depth++) + { + if (location.UnitId.HasValue) return location.UnitId; + if (location.ContainerAssetId != null && assetById.TryGetValue(location.ContainerAssetId, out var container) && container.Id != asset.Id) { locationId = container.CurrentLocationId; continue; } + locationId = location.ParentLocationId; + } + return null; + } + + var soon = c.Now.AddDays(30); + var outstanding = issuances.Where(i => OutstandingIssuance.Contains(i.Status) && !i.ReturnedOn.HasValue).ToList(); + var issuedToUnit = outstanding.Where(i => i.IssuedToUnitId.HasValue && i.AssetId != null).GroupBy(i => i.AssetId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First().IssuedToUnitId.Value, StringComparer.Ordinal); + + m.Assets = assets.Count; + m.InService = assets.Count(a => a.Status == (int)InventoryAssetStatus.InService); + m.Issued = assets.Count(a => a.Status == (int)InventoryAssetStatus.Issued); + m.OutForRepair = assets.Count(a => a.Status == (int)InventoryAssetStatus.OutForRepair); + m.Damaged = assets.Count(a => a.Status == (int)InventoryAssetStatus.Damaged); + bool Live(InventoryAsset a) => a.Status == (int)InventoryAssetStatus.InService || a.Status == (int)InventoryAssetStatus.Issued; + m.Expired = assets.Count(a => Live(a) && a.ExpiresOn.HasValue && a.ExpiresOn < c.Now); + m.ExpiringWithin30Days = assets.Count(a => Live(a) && a.ExpiresOn.HasValue && a.ExpiresOn >= c.Now && a.ExpiresOn < soon); + m.IssuancesOutstanding = outstanding.Count; + m.IssuancesOverdueReturn = outstanding.Count(i => i.ExpectedReturnOn.HasValue && i.ExpectedReturnOn < c.Now); + m.ByStatus = Counts(assets, a => a.Status.ToString(), k => EnumLabel(int.Parse(k))); + + var byUnit = new Dictionary(); + RecordsEquipmentUnitRow UnitRow(int unitId, string label) => byUnit.TryGetValue(unitId, out var r) ? r : byUnit[unitId] = new RecordsEquipmentUnitRow { Key = unitId.ToString(), Label = label }; + foreach (var asset in assets) + { + var unitId = issuedToUnit.TryGetValue(asset.Id, out var issued) ? issued : UnitOf(asset); + if (!unitId.HasValue || !rows.TryGetValue(unitId.Value, out var unitRow)) continue; + var u = UnitRow(unitId.Value, unitRow.Label); + u.Assets++; unitRow.AssetsAssigned++; + if (issuedToUnit.ContainsKey(asset.Id)) u.Issued++; + if (asset.Status == (int)InventoryAssetStatus.OutForRepair) { u.OutForRepair++; unitRow.AssetsOutForRepair++; } + if (asset.Status == (int)InventoryAssetStatus.Damaged) u.Damaged++; + if (Live(asset) && asset.ExpiresOn.HasValue && asset.ExpiresOn < c.Now) { u.Expired++; unitRow.AssetsExpired++; } + } + foreach (var issuance in outstanding.Where(i => i.IssuedToUnitId.HasValue && i.ExpectedReturnOn.HasValue && i.ExpectedReturnOn < c.Now)) + if (rows.TryGetValue(issuance.IssuedToUnitId.Value, out var unitRow)) UnitRow(issuance.IssuedToUnitId.Value, unitRow.Label).OverdueReturns++; + m.ByUnit = byUnit.Values.OrderByDescending(u => u.OutForRepair + u.Expired + u.OverdueReturns).ThenBy(u => u.Label, StringComparer.OrdinalIgnoreCase).ToList(); + return m; + } + + #endregion + + #region Evidence + + private async Task EvidenceMetricsAsync(Context c, Dataset data, Dictionary rows) + { + var headers = ((await _evidence.GetHeadersByKindInRangeAsync(c.DepartmentId, RmsEvidenceKind.ReadinessPacket, c.Start, c.End, RecordsAnalyticsLimits.RowCap + 1)) ?? Enumerable.Empty()) + .Where(h => h != null && !h.SupersededOn.HasValue).ToList(); + if (headers.Count > RecordsAnalyticsLimits.RowCap) { headers = headers.Take(RecordsAnalyticsLimits.RowCap).ToList(); c.Warnings.Add($"More than {RecordsAnalyticsLimits.RowCap:N0} readiness packets fall in this window; evidence figures cover the first {RecordsAnalyticsLimits.RowCap:N0} by capture."); } + var callLinked = data.Records.Where(r => r.CallId.HasValue).Select(r => r.RmsOperationalRecordId).Concat(data.Reports.Select(r => r.RmsIncidentReportId)).ToHashSet(StringComparer.Ordinal); + // A group-scoped viewer counts packets only on Records they can open; a department viewer counts every packet captured. + if (c.GroupScoped) headers = headers.Where(h => callLinked.Contains(h.RecordId) || data.RevisionOf.ContainsKey(h.RecordId)).ToList(); + var withPacket = headers.Select(h => h.RecordId).ToHashSet(StringComparer.Ordinal); + var m = new RecordsReadinessEvidenceMetrics + { + PacketsCaptured = headers.Count, + CallLinkedRecords = callLinked.Count, + RecordsWithPacket = callLinked.Count(id => withPacket.Contains(id)), + ByMonth = Counts(headers.Select(h => Month(h.CapturedOn, c)), k => k, k => k, null, false, StringComparer.Ordinal) + }; + m.CoveragePercent = Pct(m.RecordsWithPacket, m.CallLinkedRecords); + foreach (var unitId in data.Units.Where(u => withPacket.Contains(u.RecordId) && u.UnitId > 0).Select(u => (u.RecordId, UnitId: u.UnitId)) + .Concat(data.ReportUnits.Where(u => withPacket.Contains(u.RecordId) && u.UnitId.HasValue).Select(u => (u.RecordId, UnitId: u.UnitId.Value))).Distinct()) + if (rows.TryGetValue(unitId.UnitId, out var row)) row.ReadinessPackets++; + return m; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs index ca3aa0bd..6db4ce11 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -26,7 +26,7 @@ namespace Resgrid.Services.Records /// instead of under-counting. Every section is computed independently and degrades to a warning. /// /// - public class RecordsAnalyticsService : IRecordsAnalyticsService + public partial class RecordsAnalyticsService : IRecordsAnalyticsService { /// An interval longer than this is a mis-keyed time, not a response, and is excluded from every distribution. public static readonly TimeSpan MaxInterval = TimeSpan.FromHours(24); @@ -64,18 +64,28 @@ public class RecordsAnalyticsService : IRecordsAnalyticsService private readonly IUnitsService _unitsService; private readonly IDepartmentGroupsService _groupsService; private readonly IDepartmentsService _departmentsService; + private readonly IFeatureToggleService _flags; + private readonly IChecklistsService _checklists; + private readonly IReadinessAccessService _readinessAccess; + private readonly IWorkOrderReportingService _workOrderReporting; + private readonly IInventoryCatalogService _inventory; + private readonly IInventoryAuthorizationService _inventoryAuthorization; + private readonly IRmsEvidenceArtifactsRepository _evidence; public RecordsAnalyticsService(RecordsPreventionGate gate, IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IRmsRecordUnitResponsesRepository units, IRmsRecordParticipantsRepository participants, IRmsRecordGroupScopesRepository scopes, IRmsOperationalRecordDetailsRepository details, IRmsIncidentReportsRepository reports, IRmsUnitResponsesRepository reportUnits, IRmsIncidentTypesRepository incidentTypes, IRmsRevisionsRepository revisions, IRmsRecordDueStatesRepository dueStates, IRmsInspectionsRepository inspections, IRmsInspectionProgramsRepository programs, IRmsViolationsRepository violations, IRmsPermitsRepository permits, IRmsPermitTypesRepository permitTypes, IRmsHydrantsRepository hydrants, IRmsHydrantFlowTestsRepository flowTests, IRmsOccupanciesRepository occupancies, IRmsCrrActivitiesRepository crr, - IUnitsService unitsService, IDepartmentGroupsService groupsService, IDepartmentsService departmentsService) + IUnitsService unitsService, IDepartmentGroupsService groupsService, IDepartmentsService departmentsService, + IFeatureToggleService flags, IChecklistsService checklists, IReadinessAccessService readinessAccess, IWorkOrderReportingService workOrderReporting, + IInventoryCatalogService inventory, IInventoryAuthorizationService inventoryAuthorization, IRmsEvidenceArtifactsRepository evidence) { _gate = gate; _authorization = authorization; _records = records; _units = units; _participants = participants; _scopes = scopes; _details = details; _reports = reports; _reportUnits = reportUnits; _incidentTypes = incidentTypes; _revisions = revisions; _dueStates = dueStates; _inspections = inspections; _programs = programs; _violations = violations; _permits = permits; _permitTypes = permitTypes; _hydrants = hydrants; _flowTests = flowTests; _occupancies = occupancies; _crr = crr; _unitsService = unitsService; _groupsService = groupsService; _departmentsService = departmentsService; + _flags = flags; _checklists = checklists; _readinessAccess = readinessAccess; _workOrderReporting = workOrderReporting; _inventory = inventory; _inventoryAuthorization = inventoryAuthorization; _evidence = evidence; } public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Analytics); diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index a8637bc0..7e849305 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -31,7 +31,7 @@ protected override void Load(ContainerBuilder builder) var scope = context.Resolve(); return (Func)(() => scope.ResolveNamed("readiness-billing-client")); }).InstancePerLifetimeScope(); - builder.RegisterType().As().As().InstancePerLifetimeScope(); + builder.RegisterType().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkOrderBulkOperations.cs b/Core/Resgrid.Services/WorkOrderBulkOperations.cs new file mode 100644 index 00000000..2290735a --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderBulkOperations.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model.Checklists; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private static string BulkHash(ChecklistActor actor, WorkOrderBulkInput input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { actor.DepartmentId, actor.UserId, input.RequestId, input.Rows })))); + private static string BulkRowId(WorkOrderBulkInput input, WorkOrderBulkRow row) => MaintenanceIdentity("work-order-bulk:" + input.RequestId + ":" + row.RowNumber); + private async Task BulkAccessAsync(ChecklistActor actor) + { + await RequireWriteAsync(actor); RequireMaintenanceStore(); + var protection = await _write.Value.PreflightWriteAsync(actor.DepartmentId, actor.GrantToken, actor.UserId, false); + if (protection?.Success != true) throw new WorkOrderException(403, "ProtectedDataRequired"); + } + public async Task PreviewBulkAsync(ChecklistActor actor, WorkOrderBulkInput input) + { + await BulkAccessAsync(actor); + if (input?.Rows == null || input.Rows.Count is < 1 or > 200 || !Guid.TryParseExact(input.RequestId, "D", out _) + || input.Rows.Any(r => r == null || r.RowNumber < 1) || input.Rows.Select(r => r.RowNumber).Distinct().Count() != input.Rows.Count) throw new WorkOrderException(400, "BulkInputInvalid"); + var result = new WorkOrderBulkResult(); var seen = new HashSet(); var captured = new Dictionary(); + foreach (var row in input.Rows) + { + var item = new WorkOrderBulkRowResult { RowNumber = row.RowNumber, WorkOrderId = row.WorkOrderId }; result.Rows.Add(item); + try + { + if (row.ParseError != null) throw new WorkOrderException(400, "BulkCsvInvalid"); + if (row.Import != null) + { + if (row.WorkOrderId.HasValue || row.Assignment != null || row.Import.Content?.Approval != null) throw new WorkOrderException(400, "BulkInputInvalid"); + row.Import.RequestId = BulkRowId(input, row); Validate(row.Import); await _authorization.ValidateTargetAsync(actor, row.Import); + if (!await _authorization.CanManageAsync(actor, row.Import.TargetGroupId) && (row.Import.Content.ApprovedCost.HasValue || !string.IsNullOrEmpty(row.Import.Content.VerificationEvidence) || !string.IsNullOrEmpty(row.Import.Content.Resolution))) throw new WorkOrderException(403, "PermissionRequired"); + if (row.Import.Content.ApprovedCost.HasValue && (await ReadPolicyAsync(actor)).ApprovalsEnabled) throw new WorkOrderException(409, "SpendingApprovalRequired"); + var existing = await _store.RequestAsync(actor.DepartmentId, row.Import.RequestId); + if (existing != null) + { + captured[existing.Id] = await ReadOrderAsync(actor, existing.Id); await RevealAsync(actor, existing); + if (existing.CreatedBy != actor.UserId || Decode(existing.Content).RequestHash != Fingerprint(row.Import)) throw new WorkOrderException(409, "Conflict"); + item.WorkOrderId = existing.Id; item.Applied = true; + } + item.Title = row.Import.Content.Title; + } + else + { + if (!row.WorkOrderId.HasValue || row.Assignment == null || !seen.Add(row.WorkOrderId.Value)) throw new WorkOrderException(400, "BulkInputInvalid"); + var order = await ReadOrderAsync(actor, row.WorkOrderId.Value); captured[order.Id] = order; + if (!await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + var receipt = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", BulkRowId(input, row))).SingleOrDefault(); + if (receipt != null) { await ValidateBulkReceiptAsync(actor, input, row, receipt); item.Applied = true; } + else + { + Revision(order, row.Assignment.Revision); + if (order.Status is not (1 or 2 or 3 or 4)) throw new WorkOrderException(409, "InvalidTransition"); + await _authorization.ValidateAssignmentAsync(actor, order, row.Assignment.UserId, row.Assignment.RoleId); + } + item.Title = Decode(order.Content).Fields.Title; + } + } + catch (WorkOrderException ex) { item.ErrorCode = ex.Code; item.Title = null; } + } + await VerifyReportAccessAsync(actor, captured.Values.ToList()); + result.PreviewHash = BulkHash(actor, input); await BulkAccessAsync(actor); return result; + } + private async Task ValidateBulkReceiptAsync(ChecklistActor actor, WorkOrderBulkInput input, WorkOrderBulkRow row, WorkOrderOperationReceipt receipt) + { + await RevealAsync(actor, receipt); + var expected = JsonConvert.SerializeObject(new { input.RequestId, Row = row }); + if (receipt.Kind != 1 || receipt.CreatedBy != actor.UserId || receipt.WorkOrderId != row.WorkOrderId || receipt.Content != expected) throw new WorkOrderException(409, "Conflict"); + } + public async Task ApplyBulkAsync(ChecklistActor actor, WorkOrderBulkInput input) + { + var expected = input?.PreviewHash; var result = await PreviewBulkAsync(actor, input); + if (string.IsNullOrEmpty(expected) || result.PreviewHash != expected) throw new WorkOrderException(409, "BulkPreviewChanged"); + foreach (var item in result.Rows.Where(r => r.ErrorCode == null && !r.Applied)) + { + var row = input.Rows.Single(r => r.RowNumber == item.RowNumber); + try + { + if (row.Import != null) item.WorkOrderId = (await CreateAsync(actor, row.Import)).Order.Id; + else await TransactionAsync(actor, async events => + { + var receipt = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", BulkRowId(input, row))).SingleOrDefault(); + if (receipt != null) { await ReadOrderAsync(actor, row.WorkOrderId.Value); await ValidateBulkReceiptAsync(actor, input, row, receipt); return true; } + await AssignWithinAsync(actor, row.WorkOrderId.Value, row.Assignment, events); + receipt = New(actor, row.WorkOrderId); receipt.Kind = 1; receipt.RequestId = BulkRowId(input, row); receipt.Content = JsonConvert.SerializeObject(new { input.RequestId, Row = row }); await SaveAsync(actor, receipt, true); return true; + }); + item.Applied = true; + } + catch (WorkOrderException ex) { item.ErrorCode = ex.Code; item.Title = null; } + } + await BulkAccessAsync(actor); return result; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderCsvImport.cs b/Core/Resgrid.Services/WorkOrderCsvImport.cs new file mode 100644 index 00000000..2018b595 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderCsvImport.cs @@ -0,0 +1,44 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.VisualBasic.FileIO; +using Resgrid.Model; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public static class WorkOrderCsvImport + { + // Stable interchange keys; presentation labels and examples are localized by the UI. + public const string Header = "Title,Description,Type,Priority,UnitId,GroupId,AssetId,Location,Currency,EstimatedCost,DueUtc,CostCenter"; + public static WorkOrderBulkInput Parse(string text, string requestId) + { + if (text == null || Encoding.UTF8.GetByteCount(text) > 1024 * 1024) throw new WorkOrderException(400, "BulkCsvInvalid"); + using var reader = new TextFieldParser(new StringReader(text)) { HasFieldsEnclosedInQuotes = true, TrimWhiteSpace = false }; + reader.SetDelimiters(","); string[] headers; + try { headers = reader.ReadFields(); } catch (MalformedLineException) { throw new WorkOrderException(400, "BulkCsvInvalid"); } + if (headers?.Length > 0) headers[0] = headers[0].TrimStart('\uFEFF'); + var keys = Header.Split(','); + if (headers == null || !headers.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)) throw new WorkOrderException(400, "BulkCsvInvalid"); + var result = new WorkOrderBulkInput { RequestId = requestId }; var rowNumber = 1; + while (!reader.EndOfData) + { + var row = new WorkOrderBulkRow { RowNumber = rowNumber++ }; result.Rows.Add(row); + if (result.Rows.Count > 200) throw new WorkOrderException(400, "BulkInputInvalid"); + try + { + var f = reader.ReadFields(); if (f?.Length != keys.Length) throw new FormatException(); + int? Number(string v) => string.IsNullOrWhiteSpace(v) ? null : int.Parse(v, NumberStyles.None, CultureInfo.InvariantCulture); + row.Import = new WorkOrderInput { Type = string.IsNullOrWhiteSpace(f[2]) ? WorkOrderType.Corrective : (WorkOrderType)int.Parse(f[2], CultureInfo.InvariantCulture), Priority = string.IsNullOrWhiteSpace(f[3]) ? WorkOrderPriority.Normal : (WorkOrderPriority)int.Parse(f[3], CultureInfo.InvariantCulture), + TargetUnitId = Number(f[4]), TargetGroupId = Number(f[5]), InventoryAssetId = string.IsNullOrWhiteSpace(f[6]) ? null : f[6], + DueOn = string.IsNullOrWhiteSpace(f[10]) ? null : DateTime.ParseExact(f[10], new[] { "yyyy-MM-ddTHH:mm:ss'Z'", "yyyy-MM-ddTHH:mm'Z'" }, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal), + Content = new WorkOrderContent { Title = f[0], Description = f[1], LocationText = f[7], Currency = f[8], EstimatedCost = string.IsNullOrWhiteSpace(f[9]) ? null : decimal.Parse(f[9], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture), CostCenter = f[11] } }; + } + catch (Exception ex) when (ex is FormatException or OverflowException or MalformedLineException) { row.Import = null; row.ParseError = "BulkCsvInvalid"; } + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderGdprExport.cs b/Core/Resgrid.Services/WorkOrderGdprExport.cs index 1bdef0ab..bebb8395 100644 --- a/Core/Resgrid.Services/WorkOrderGdprExport.cs +++ b/Core/Resgrid.Services/WorkOrderGdprExport.cs @@ -27,7 +27,16 @@ async Task> Children(int id) where T : WorkOrderRow { var activity = await Children(row.Id); var labor = await Children(row.Id); if (row.CreatedBy != userId && row.AssignedToUserId != userId && row.CompletedBy != userId && row.VerifiedBy != userId && !activity.Any(a => a.CreatedBy == userId) && !labor.Any(l => l.UserId == userId || l.CreatedBy == userId)) continue; - orders.Add(new { Order = await Safe(row), Activity = activity, Labor = labor, Parts = await Children(row.Id), Files = (await Children(row.Id)).Select(f => new { f.Id, f.DepartmentId, f.WorkOrderId, f.Content, f.ContentType, f.Size, f.Sha256, f.ScanState, f.WithdrawnOn, f.CreatedOn, f.CreatedBy }) }); + var snapshots = new List(); + long afterId = 0; + while (true) + { + var batch = await _workOrders.ReportSnapshotHistoryAsync(departmentId, row.Id, afterId); + if (batch == null) throw new InvalidOperationException("Work-order revision export storage is unavailable."); + snapshots.AddRange(batch); + if (batch.Count < 500) break; afterId = batch.Last().Id; + } + orders.Add(new { Order = await Safe(row), Activity = activity, Labor = labor, ReportSnapshots = snapshots, VendorCharges = await Children(row.Id), PartMovements = await Children(row.Id), OperationReceipts = await Children(row.Id), Parts = await Children(row.Id), Files = (await Children(row.Id)).Select(f => new { f.Id, f.DepartmentId, f.WorkOrderId, f.Content, f.ContentType, f.Size, f.Sha256, f.ScanState, f.WithdrawnOn, f.CreatedOn, f.CreatedBy }) }); } if (rows.Count <= 50) return orders; } diff --git a/Core/Resgrid.Services/WorkOrderInventoryParts.cs b/Core/Resgrid.Services/WorkOrderInventoryParts.cs index e0764f0a..5004c2e0 100644 --- a/Core/Resgrid.Services/WorkOrderInventoryParts.cs +++ b/Core/Resgrid.Services/WorkOrderInventoryParts.cs @@ -21,7 +21,7 @@ await TransactionAsync(actor, async events => { var order = await ContributionAsync(actor, orderId, revision); var part = await _store.GetAsync(actor.DepartmentId, partId); - if (part?.WorkOrderId != orderId || part.InventoryOperationId == null || part.VoidedOn.HasValue || _inventoryMaintenance == null) + if (part?.WorkOrderId != orderId || part.InventoryOperationId == null || part.VoidedOn.HasValue || _inventoryMaintenance == null || _inventoryCatalog == null) throw new WorkOrderException(409, "Unavailable"); await RevealAsync(actor, part); var content = Decode(part.Content); if (part.InventoryTransactionId != null && (content.VoidReason == null || part.InventoryReversalId != null)) throw new WorkOrderException(409, "Conflict"); @@ -34,6 +34,7 @@ await TransactionAsync(actor, async events => } private async Task RequireNoPendingPartsAsync(ChecklistActor actor, int id) { + if ((await ChildrenAsync(actor, id)).Any(p => p.Staged && (p.ReservedQuantity > 0 || p.IssuedQuantity > 0))) throw new WorkOrderException(409, "PartBalanceOutstanding"); foreach (var part in await ChildrenAsync(actor, id)) if (part.InventoryOperationId != null && !part.VoidedOn.HasValue && (part.InventoryTransactionId == null || Decode(part.Content).VoidReason != null && part.InventoryReversalId == null)) throw new WorkOrderException(409, "InventoryWitnessPending"); @@ -55,13 +56,14 @@ await TransactionAsync(actor, async events => { await ReadOrderAsync(actor, id); await RevealAsync(actor, prior); var content = Decode(prior.Content); - if (prior.WorkOrderId != id || prior.CreatedBy != actor.UserId || prior.InventoryItemId != input.InventoryItemId || content.Quantity != input.Content.Quantity || content.RequestFingerprint != fingerprint) throw new WorkOrderException(409, "Conflict"); + if (prior.Staged || prior.WorkOrderId != id || prior.CreatedBy != actor.UserId || prior.InventoryItemId != input.InventoryItemId || content.Quantity != input.Content.Quantity || content.RequestFingerprint != fingerprint) throw new WorkOrderException(409, "Conflict"); return true; } } var row = await ContributionAsync(actor, id, input.Revision); var part = New(actor, id); part.InventoryItemId = linked ? input.InventoryItemId : null; part.InventoryRequestId = linked ? input.RequestId : null; input.Content.Currency = Decode(row.Content).Fields.Currency; input.Content.RequestFingerprint = linked ? fingerprint : null; + if (!linked) await RequireSpendingAsync(actor, row, input.Content.UnitCost.HasValue ? decimal.Round(input.Content.Quantity * input.Content.UnitCost.Value, 2, MidpointRounding.AwayFromZero) : null, input.Content.Currency); part.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, part, true); if (linked) { @@ -81,7 +83,7 @@ public async Task VoidPartAsync(ChecklistActor actor, int id, int partId, int re await TransactionAsync(actor, async events => { var row = await ContributionAsync(actor, id, revision); var part = await _store.GetAsync(actor.DepartmentId, partId); - if (part?.WorkOrderId != id || part.VoidedOn.HasValue) throw new WorkOrderException(409, "Unavailable"); + if (part?.WorkOrderId != id || part.VoidedOn.HasValue || part.Staged) throw new WorkOrderException(409, "Unavailable"); await RevealAsync(actor, part); var content = Decode(part.Content); if (content.VoidReason != null) throw new WorkOrderException(409, "InventoryWitnessPending"); content.VoidReason = reason; part.Content = JsonConvert.SerializeObject(content); part.Revision++; @@ -114,6 +116,8 @@ public async Task CompleteInventoryPartAsync(InventoryActor actor, int partId, s await RevealAsync(principal, part); var ledger = await _inventoryCatalog.Value.GetAsync(actor, transactionId); if (ledger.WorkOrderPartId != partId || ledger.ReferenceType != (int)InventoryReferenceType.WorkOrder || ledger.ReferenceId != row.Id.ToString(CultureInfo.InvariantCulture) || ledger.ItemId != part.InventoryItemId) throw new WorkOrderException(409, "InventoryPartInvalid"); + if (part.Staged && ledger.WorkOrderPartMovementId.HasValue) { await CompletePartMovementAsync(principal, row, part, ledger, events); return; } + if (part.Staged || ledger.WorkOrderPartMovementId.HasValue) throw new WorkOrderException(409, "InventoryPartInvalid"); var content = Decode(part.Content); if (ledger.Quantity != content.Quantity || reversal && ledger.ReversesTransactionId != part.InventoryTransactionId || !reversal && ledger.ReversesTransactionId != null) throw new WorkOrderException(409, "InventoryPartInvalid"); if (reversal) { if (part.InventoryReversalId != null) throw new WorkOrderException(409, "Conflict"); part.InventoryReversalId = ledger.Id; part.VoidedOn = Now; } @@ -125,6 +129,7 @@ public async Task CompleteInventoryPartAsync(InventoryActor actor, int partId, s part.Content = JsonConvert.SerializeObject(content); } part.Revision++; await SaveAsync(principal, part); + if (!reversal) await RequireSpendingAsync(principal, row); await ChangedAsync(principal, row, reversal ? WorkOrderActivityType.PartVoided : WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); } } diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs index 182d3646..d3204025 100644 --- a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs +++ b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs @@ -25,6 +25,10 @@ public sealed partial class WorkOrdersService private async Task RevealOrderAsync(ChecklistActor actor, WorkOrder row) { await RevealAsync(actor, row); + return await PopulateOrderContentAsync(actor, row); + } + private async Task PopulateOrderContentAsync(ChecklistActor actor, WorkOrder row) + { if (!string.IsNullOrEmpty(row.Content)) return row; WorkOrderContent fields; if (row.SourceType == 2 && row.RecurrenceVersionId.HasValue) @@ -114,7 +118,7 @@ await WorkerTransactionAsync(departmentId, async events => order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); order.SourceType = 1; order.SourceChecklistCompletionId = intent.CompletionId; order.SourceChecklistItemId = intent.ItemId; order.SourceOccurrenceId = intent.OccurrenceId; order.Priority = intent.Priority; order.TargetUnitId = target.TargetUnitId; order.TargetGroupId = target.TargetGroupId; order.InventoryAssetId = target.InventoryAssetId; - await _store.AllocateAsync(order); await EventAsync(order, WorkflowTriggerEventType.WorkOrderCreated, events); result.Generated++; + await PinSlaAsync(order); await _store.AllocateAsync(order); await RecordGeneratedCreationAsync(order); await EventAsync(order, WorkflowTriggerEventType.WorkOrderCreated, events); result.Generated++; } if (intent.HoldUnit && order.TargetUnitId.HasValue) { await CreateHoldAsync(owner, order, true, null, events, true); result.Held++; } if (intent.HoldAsset && order.InventoryAssetId != null) { await CreateHoldAsync(owner, order, false, null, events, true); result.Held++; } diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs index 8756c990..8593dcd6 100644 --- a/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs +++ b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs @@ -29,6 +29,10 @@ async Task> Rows(Func subject) where T : WorkOrderRow var changes = await Rows(r => r.CreatedBy == userId); var related = versions.Select(r => r.RecurrenceId).Concat(readings.Select(r => r.RecurrenceId)).Concat(changes.Select(r => r.RecurrenceId)).ToHashSet(); return new { + Policies = await Rows(r => r.CreatedBy == userId), + OperationReceipts = await Rows(r => r.CreatedBy == userId), + VendorCharges = await Rows(r => r.CreatedBy == userId), + PartMovements = await Rows(r => r.CreatedBy == userId), FailureIntents = await Rows(r => r.CreatedBy == userId || r.AuthorizedBy == userId), SafetyHolds = await Rows(r => r.CreatedBy == userId || r.ReleasedBy == userId), Recurrences = await Rows(r => r.CreatedBy == userId || r.AssignedToUserId == userId || related.Contains(r.Id)), diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index ca5c11d1..8cee3559 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -35,11 +35,11 @@ private async Task TransactionAsync(int departmentId, Func> action } public async Task DispatchAsync(DomainEventOutboxEntry entry) { - if (entry?.ProducerSubsystem != "WorkOrders" || entry.TriggerEventType is not (70 or 71 or 72 or 73 or 167 or 168) || !Guid.TryParseExact(entry.EventId, "D", out _) || !int.TryParse(entry.AggregateId, out var id)) return; + if (entry?.ProducerSubsystem != "WorkOrders" || entry.TriggerEventType is not (70 or 71 or 72 or 73 or 167 or 168 or 173 or 174) || !Guid.TryParseExact(entry.EventId, "D", out _) || !int.TryParse(entry.AggregateId, out var id)) return; if (!await _access.CanUseMaintenanceAsync(entry.DepartmentId)) return; var row = await _orders.GetAsync(entry.DepartmentId, id); if (row == null) return; - foreach (var user in await RecipientsAsync(entry.DepartmentId, row)) + foreach (var user in await RecipientsAsync(entry.DepartmentId, row, entry.TriggerEventType is 173 or 174)) { var notice = new WorkOrderNotification { DepartmentId = entry.DepartmentId, WorkOrderId = id, EventId = entry.EventId, UserId = user, LeaseOwner = Guid.NewGuid().ToString("D") }; var state = await TransactionAsync(entry.DepartmentId, () => _orders.ClaimNotificationAsync(notice, DateTime.UtcNow)); @@ -51,7 +51,7 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) var department = await _departments.GetDepartmentByIdAsync(entry.DepartmentId, true); var number = await _settings.GetTextToCallNumberForDepartmentAsync(entry.DepartmentId); var current = await _orders.GetAsync(entry.DepartmentId, id); - if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !await IsRecipientAsync(entry.DepartmentId, current, user)) + if (department == null || profile == null || current == null || !await _access.CanUseMaintenanceAsync(entry.DepartmentId) || !await IsRecipientAsync(entry.DepartmentId, current, user, entry.TriggerEventType is 173 or 174)) { await FinishAsync(notice, 3); continue; } CultureInfo culture; try { culture = CultureInfo.GetCultureInfo(profile.Language ?? "en"); if (!SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) culture = CultureInfo.GetCultureInfo("en"); } @@ -72,17 +72,19 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) } } } - private async Task IsRecipientAsync(int departmentId, WorkOrder row, string user) + private async Task IsRecipientAsync(int departmentId, WorkOrder row, string user, bool managerNotice = false) { var member = await _departments.GetDepartmentMemberAsync(user, departmentId, true); if (member?.DepartmentId != departmentId || member.IsDeleted || member.IsDisabled == true) return false; if (row.CreatedBy == user) return true; var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = user }; - if (row.Status is 0 or 1 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) return true; + if ((managerNotice || row.Status is 0 or 1) && await _authorization.CanManageAsync(actor, row.TargetGroupId)) return true; + // Escalation-role members are recipients too; revalidating without this drops them before the assigned-user check. + if (row.EscalatedOn.HasValue && row.EscalationRoleId.HasValue && (await _authorization.ScopeAsync(actor)).RoleIds.Contains(row.EscalationRoleId.Value)) return true; if (row.AssignedToUserId != null) return row.AssignedToUserId == user; return row.AssignedToRoleId.HasValue && (await _authorization.ScopeAsync(actor)).RoleIds.Contains(row.AssignedToRoleId.Value); } - private async Task> RecipientsAsync(int departmentId, WorkOrder row) + private async Task> RecipientsAsync(int departmentId, WorkOrder row, bool managerNotice = false) { var result = new System.Collections.Generic.HashSet(await _authorization.RecipientsAsync(departmentId, row)); if (row.EscalatedOn.HasValue && row.EscalationRoleId.HasValue) @@ -91,7 +93,7 @@ private async Task IsRecipientAsync(int departmentId, WorkOrder row, strin foreach (var member in members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true)) { var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = member.UserId }; - if (member.UserId == row.CreatedBy || row.Status is 0 or 1 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) + if (member.UserId == row.CreatedBy || (managerNotice || row.Status is 0 or 1) && await _authorization.CanManageAsync(actor, row.TargetGroupId)) result.Add(member.UserId); } return result.OrderBy(id => id, StringComparer.Ordinal).ToList(); diff --git a/Core/Resgrid.Services/WorkOrderPartAllocations.cs b/Core/Resgrid.Services/WorkOrderPartAllocations.cs new file mode 100644 index 00000000..6b777466 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderPartAllocations.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private static string OperationHash(object input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input)))); + public async Task> PartMovementsAsync(ChecklistActor actor, int id, int afterId = 0) => (await EvidencePageAsync(actor, id, afterId, r => r)).Items; + public async Task ReservePartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) + { + if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Guid.TryParseExact(input.InventoryItemId, "D", out _) || !Guid.TryParseExact(input.InventoryLocationId, "D", out _) + || input.Content.Quantity is <= 0 or > 100000 || decimal.Round(input.Content.Quantity, 6) != input.Content.Quantity || _inventoryMaintenance == null) throw new WorkOrderException(400, "InventoryPartInvalid"); + Text(input.Content.Description, 2000, true); + var hash = OperationHash(new { input.InventoryItemId, input.InventoryAssetId, input.InventoryLotId, input.InventoryLocationId, input.Content.Description, input.Content.Quantity }); + await TransactionAsync(actor, async events => + { + RequireMaintenanceStore(); await ReadOrderAsync(actor, id); + var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "InventoryRequestId", input.RequestId)).SingleOrDefault(); + if (existing != null) + { + await RevealAsync(actor, existing); + if (!existing.Staged || existing.CreatedBy != actor.UserId || existing.WorkOrderId != id || Decode(existing.Content).RequestFingerprint != hash) throw new WorkOrderException(409, "Conflict"); + return true; + } + var row = await ContributionAsync(actor, id, input.Revision); var quote = await _inventoryMaintenance.Value.QuotePartAsync(InventoryActor(actor), id, input); + await RequireSpendingAsync(actor, row, quote.UnitCost.HasValue ? decimal.Round(quote.UnitCost.Value * input.Content.Quantity, 2, MidpointRounding.AwayFromZero) : null, quote.Currency); + var part = New(actor, id); part.Staged = true; part.InventoryItemId = input.InventoryItemId; part.InventoryRequestId = input.RequestId; + part.ReservedLocationId = input.InventoryLocationId; part.ReservedAssetId = input.InventoryAssetId; part.ReservedLotId = input.InventoryLotId; part.ReservedQuantity = input.Content.Quantity; + part.Content = JsonConvert.SerializeObject(new WorkOrderPartContent { RequestFingerprint = hash, Description = input.Content.Description, Quantity = input.Content.Quantity, UnitCost = quote.UnitCost, Currency = quote.Currency, ConsumedCost = 0 }); await SaveAsync(actor, part, true); + var movement = New(actor, id); movement.PartId = part.Id; movement.Kind = (int)WorkOrderPartMovementKind.Reserve; movement.Quantity = input.Content.Quantity; movement.FromLocationId = part.ReservedLocationId; + movement.RequestId = MaintenanceIdentity("part-reservation:" + input.RequestId); movement.Content = JsonConvert.SerializeObject(new { RequestHash = hash }); await SaveAsync(actor, movement, true); + await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + }); + } + public async Task MovePartAsync(ChecklistActor actor, int id, int partId, WorkOrderPartMovementInput input) + { + if (input == null || !Guid.TryParseExact(input.RequestId, "D", out _) || !Enum.IsDefined(input.Kind) || input.Kind == WorkOrderPartMovementKind.Reserve || input.Quantity is <= 0 or > 100000 || decimal.Round(input.Quantity, 6) != input.Quantity) throw new WorkOrderException(400, "InventoryPartInvalid"); + Text(input.Reason, 4000, input.Kind is WorkOrderPartMovementKind.ReturnUnused or WorkOrderPartMovementKind.ReleaseReservation); + var hash = OperationHash(new { id, partId, input.Kind, input.Quantity, input.LocationId, input.Reason }); + await TransactionAsync(actor, async events => + { + RequireMaintenanceStore(); await ReadOrderAsync(actor, id); + var prior = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.RequestId)).SingleOrDefault(); + if (prior != null) + { + await RevealAsync(actor, prior); + if (prior.WorkOrderId != id || prior.PartId != partId || prior.CreatedBy != actor.UserId || JObject.Parse(prior.Content)["RequestHash"]?.Value() != hash) throw new WorkOrderException(409, "Conflict"); + return true; + } + var row = await ContributionAsync(actor, id, input.Revision); var part = await _store.GetAsync(actor.DepartmentId, partId); + if (part?.WorkOrderId != id || !part.Staged || part.VoidedOn.HasValue) throw new WorkOrderException(409, "Unavailable"); + await RevealAsync(actor, part); + var movements = new List(); + for (var skip = 0; ; skip += 500) { var page = await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "PartId", partId, skip); movements.AddRange(page); if (page.Count < 500) break; if (skip >= 9500) throw new WorkOrderException(400, "HistoryLimit"); } + if (movements.Any(m => !m.Cancelled && m.InventoryOperationId != null && m.InventoryTransactionId == null)) throw new WorkOrderException(409, "InventoryWitnessPending"); + var fromReserved = input.Kind is WorkOrderPartMovementKind.Issue or WorkOrderPartMovementKind.ReleaseReservation; + if (input.Quantity > (fromReserved ? part.ReservedQuantity : part.IssuedQuantity)) throw new WorkOrderException(409, "PartBalanceInvalid"); + if (input.Kind == WorkOrderPartMovementKind.Issue && (!Guid.TryParseExact(input.LocationId, "D", out _) || input.LocationId == part.ReservedLocationId || part.IssuedQuantity > 0 && part.IssuedLocationId != input.LocationId)) throw new WorkOrderException(400, "InventoryPartInvalid"); + var movement = New(actor, id); movement.PartId = partId; movement.RequestId = input.RequestId; movement.Kind = (int)input.Kind; movement.Quantity = input.Quantity; + movement.FromLocationId = fromReserved ? part.ReservedLocationId : part.IssuedLocationId; + movement.ToLocationId = input.Kind == WorkOrderPartMovementKind.Issue ? input.LocationId : input.Kind == WorkOrderPartMovementKind.ReturnUnused ? part.ReservedLocationId : null; + movement.Content = JsonConvert.SerializeObject(new { RequestHash = hash, input.Reason }); await SaveAsync(actor, movement, true); + if (input.Kind == WorkOrderPartMovementKind.ReleaseReservation) + { + part.ReservedQuantity -= input.Quantity; part.Revision++; await SaveAsync(actor, part); + await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, input.Reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + } + if (_inventoryMaintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + if (input.Kind != WorkOrderPartMovementKind.ReturnUnused) await RequireSpendingAsync(actor, row); + var command = new InventoryCommand { RequestId = input.RequestId, Lines = new() { new InventoryPosting { ItemId = part.InventoryItemId, AssetId = part.ReservedAssetId, LotId = part.ReservedLotId, + FromLocationId = movement.FromLocationId, ToLocationId = movement.ToLocationId, Quantity = input.Quantity, Type = input.Kind == WorkOrderPartMovementKind.Consume ? InventoryTransactionType.Consume : InventoryTransactionType.Transfer, + ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = partId, WorkOrderPartMovementId = movement.Id } } }; + var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), partId, command); events.AddRange(result.OutboxIds); + movement.InventoryOperationId = result.OperationId; movement.Revision++; await SaveAsync(actor, movement); + if (!result.AwaitingWitness) await CompleteInventoryPartAsync(InventoryActor(actor), partId, result.TransactionIds.Single(), false, events); + else await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); + return true; + }); + } + private async Task CompletePartMovementAsync(ChecklistActor actor, WorkOrder row, WorkOrderPart part, InventoryTransaction ledger, List events) + { + var movement = await _store.GetAsync(actor.DepartmentId, ledger.WorkOrderPartMovementId.Value); + if (movement?.PartId != part.Id || movement.WorkOrderId != row.Id || movement.Cancelled || movement.Quantity != ledger.Quantity || movement.InventoryTransactionId != null + || movement.FromLocationId != ledger.FromLocationId || movement.ToLocationId != ledger.ToLocationId) throw new WorkOrderException(409, "InventoryPartInvalid"); + await RevealAsync(actor, movement); var c = Decode(part.Content); + switch ((WorkOrderPartMovementKind)movement.Kind) + { + case WorkOrderPartMovementKind.Issue: part.ReservedQuantity -= ledger.Quantity; part.IssuedQuantity += ledger.Quantity; part.IssuedLocationId = ledger.ToLocationId; break; + case WorkOrderPartMovementKind.Consume: + part.IssuedQuantity -= ledger.Quantity; part.ConsumedQuantity += ledger.Quantity; + var cost = JObject.Parse(ledger.Content); if (cost["CurrencyCode"]?.Value() != c.Currency) throw new WorkOrderException(409, "PartCurrencyChanged"); + c.ConsumedCost = c.ConsumedCost.HasValue && cost["TotalCost"]?.Value() is decimal amount ? c.ConsumedCost.Value + amount : null; + break; + case WorkOrderPartMovementKind.ReturnUnused: part.IssuedQuantity -= ledger.Quantity; part.ReturnedQuantity += ledger.Quantity; break; + default: throw new WorkOrderException(409, "InventoryPartInvalid"); + } + if (part.ReservedQuantity < 0 || part.IssuedQuantity < 0) throw new WorkOrderException(409, "PartBalanceInvalid"); + part.Content = JsonConvert.SerializeObject(c); part.Revision++; await SaveAsync(actor, part); + movement.InventoryTransactionId = ledger.Id; movement.InventoryOperationId = ledger.OperationId; movement.Revision++; await SaveAsync(actor, movement); + if (movement.Kind != (int)WorkOrderPartMovementKind.ReturnUnused) await RequireSpendingAsync(actor, row); + await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); + } + public async Task CancelPartMovementAsync(ChecklistActor actor, int id, int movementId, int revision, string reason) + { + Text(reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await ContributionAsync(actor, id, revision); var movement = await _store.GetAsync(actor.DepartmentId, movementId); + if (movement?.WorkOrderId != id || movement.Cancelled || movement.InventoryOperationId == null || movement.InventoryTransactionId != null || _inventoryMaintenance == null) throw new WorkOrderException(409, "Unavailable"); + await RevealAsync(actor, movement); await _inventoryMaintenance.Value.CancelPendingPartAsync(InventoryActor(actor), movement.PartId, movement.InventoryOperationId); + movement.Cancelled = true; movement.Revision++; await SaveAsync(actor, movement); + await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + }); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderPolicies.cs b/Core/Resgrid.Services/WorkOrderPolicies.cs new file mode 100644 index 00000000..5018c60d --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderPolicies.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; +using Resgrid.Model.Security; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private async Task PolicyRowAsync(int departmentId) => _maintenance == null ? null : (await _maintenance.QueryMaintenanceAsync(departmentId)).SingleOrDefault(); + private async Task ReadPolicyAsync(ChecklistActor actor) + { + var row = await PolicyRowAsync(actor.DepartmentId); + if (row == null) return new WorkOrderPolicyInput(); + await RevealAsync(actor, row); + var value = Decode(row.Content); value.Revision = row.Revision; value.Calendar = Decode(row.CalendarJson); return value; + } + private async Task RequirePolicyManagerAsync(ChecklistActor actor) + { + await _authorization.RequireMemberAsync(actor); + if (!(await _authorization.ScopeAsync(actor)).All || !await _authorization.CanManageAsync(actor, null)) throw new WorkOrderException(403, "PermissionRequired"); + } + public async Task PolicyAsync(ChecklistActor actor) { await RequirePolicyManagerAsync(actor); return await ReadPolicyAsync(actor); } + public static void ValidateBusinessCalendar(WorkOrderBusinessCalendar c) + { + if (c == null || c.Weekdays is < 1 or > 127 || c.StartMinute is < 0 or > 1439 || c.EndMinute <= c.StartMinute || c.EndMinute > 1440 || c.Holidays == null || c.Holidays.Count > 366 || c.Targets == null || c.Targets.Count > 4) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + MaintenanceZone(c.TimeZoneId); + if (c.Holidays.Any(d => !DateTime.TryParseExact(d, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) || c.Holidays.Distinct().Count() != c.Holidays.Count + || c.Targets.Any(t => t == null || !Enum.IsDefined(t.Priority) || t.ResponseMinutes is < 1 or > 525600 || t.RepairMinutes < t.ResponseMinutes || t.RepairMinutes > 525600) || c.Targets.Select(t => t.Priority).Distinct().Count() != c.Targets.Count) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + } + public static DateTime AddBusinessMinutes(DateTime utc, int minutes, WorkOrderBusinessCalendar calendar) + { + ValidateBusinessCalendar(calendar); + if (minutes is < 1 or > 525600) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc); var zone = MaintenanceZone(calendar.TimeZoneId); + var day = TimeZoneInfo.ConvertTimeFromUtc(utc, zone).Date; + var remaining = TimeSpan.FromMinutes(minutes); + // Integrate actual UTC time inside configured local windows, including DST transitions. + for (var i = 0; i < 3660; i++, day = day.AddDays(1)) + { + if ((calendar.Weekdays & 1 << (int)day.DayOfWeek) == 0 || calendar.Holidays.Contains(day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))) continue; + var start = MaintenanceUtc(day.AddMinutes(calendar.StartMinute), calendar.TimeZoneId); + var end = MaintenanceUtc(day.AddMinutes(calendar.EndMinute), calendar.TimeZoneId); + if (start < utc) start = utc; + if (end <= start) continue; + if (end - start >= remaining) return start + remaining; + remaining -= end - start; + } + throw new WorkOrderException(400, "OperationsPolicyInvalid"); + } + public async Task SavePolicyAsync(ChecklistActor actor, WorkOrderPolicyInput input) + { + if (input == null || input.SpendingRules == null || input.SpendingRules.Count > 30 || input.SpendingRules.Any(r => r == null || !ValidCurrency(r.Currency) || r.Threshold < 0 || decimal.Round(r.Threshold, 2) != r.Threshold || r.Threshold > 100000000m) + || input.SpendingRules.Select(r => r.Currency).Distinct().Count() != input.SpendingRules.Count || input.ApprovalsEnabled && input.SpendingRules.Count == 0) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + ValidateBusinessCalendar(input.Calendar); await RequirePolicyManagerAsync(actor); RequireMaintenanceStore(); + await TransactionAsync(actor, async events => + { + await RequirePolicyManagerAsync(actor); var row = await PolicyRowAsync(actor.DepartmentId); var previous = row == null ? null : (await RevealAsync(actor, row)).Content; + if (row == null && input.Revision != 0 || row != null && row.Revision != input.Revision) throw new WorkOrderException(409, "Conflict"); + var insert = row == null; row ??= New(actor); if (!insert) row.Revision++; + row.CalendarJson = JsonConvert.SerializeObject(input.Calendar); row.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, row, insert); + var receipt = New(actor); receipt.Kind = 2; receipt.RequestId = Guid.NewGuid().ToString("D"); receipt.Content = JsonConvert.SerializeObject(new { PolicyId = row.Id, row.Revision, Previous = previous, Settings = input }); await SaveAsync(actor, receipt, true); + var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = "WorkOrderPolicy:" + row.Id, + LogType = (int)AuditLogTypes.WorkOrderChanged, LoggedOn = Now, Successful = true, Message = "WorkOrderPolicyChanged", ServerName = Environment.MachineName }, CancellationToken.None); + audit.Data = JsonConvert.SerializeObject(new { PolicyId = row.Id, row.Revision, ReceiptId = receipt.Id }); + var protection = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, audit, null, audit.AuditLogId.ToString(CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, actor.GrantToken, actor.UserId, false); + if (protection?.Success != true || protection.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new WorkOrderException(403, "ProtectedDataRequired"); + await _audit.UpdateAsync(audit, CancellationToken.None); + var entry = await _outbox.EnqueueAsync(actor.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = "WorkOrderPolicyChanged", AggregateType = "WorkOrderPolicy", AggregateId = Key(row), AggregateVersion = row.Revision, Trigger = WorkflowTriggerEventType.WorkOrderPolicyChanged, OccurredOn = Now, Payload = new { PolicyId = row.Id, row.Revision } }); events.Add(entry.DomainEventOutboxId); return true; + }); + } + private async Task PinSlaAsync(WorkOrder row) + { + row.ResponseDueOn = null; row.RepairDueOn = null; row.ResponseBreachedOn = null; row.RepairBreachedOn = null; row.SlaPolicyRevision = null; + var policy = await PolicyRowAsync(row.DepartmentId); if (policy == null) return; + var calendar = Decode(policy.CalendarJson); ValidateBusinessCalendar(calendar); + var target = calendar.Targets.SingleOrDefault(t => (int)t.Priority == row.Priority); if (target == null) return; + row.SlaPolicyRevision = policy.Revision; + row.ResponseDueOn = AddBusinessMinutes(row.CreatedOn, target.ResponseMinutes, calendar); + row.RepairDueOn = AddBusinessMinutes(row.CreatedOn, target.RepairMinutes, calendar); + } + private static bool ValidCurrency(string value) => value?.Length == 3 && value.All(c => c is >= 'A' and <= 'Z'); + private async Task RequireSpendingAsync(ChecklistActor actor, WorkOrder order, decimal? additional = 0m, string currency = null) + { + var policy = await ReadPolicyAsync(actor); if (!policy.ApprovalsEnabled) return; + var content = Decode(order.Content).Fields; currency ??= content.Currency; + var rule = policy.SpendingRules.SingleOrDefault(r => r.Currency == currency); + if (rule == null || currency != content.Currency || !additional.HasValue) throw new WorkOrderException(409, "SpendingApprovalRequired"); + decimal total = additional.Value; + void Include(decimal? amount, string code) { if (!amount.HasValue || code != currency) throw new WorkOrderException(409, "SpendingApprovalRequired"); total += amount.Value; } + foreach (var labor in await ChildrenAsync(actor, order.Id)) { var c = Decode(labor.Content); Include(c.RatePerHour.HasValue ? decimal.Round(c.Hours * c.RatePerHour.Value, 2, MidpointRounding.AwayFromZero) : null, c.Currency); } + foreach (var part in (await ChildrenAsync(actor, order.Id)).Where(p => !p.VoidedOn.HasValue)) + { + var c = Decode(part.Content); var quantity = part.Staged ? part.ReservedQuantity + part.IssuedQuantity : c.Quantity; + if (quantity > 0) Include(c.UnitCost.HasValue ? decimal.Round(quantity * c.UnitCost.Value, 2, MidpointRounding.AwayFromZero) : null, c.Currency); + if (part.Staged && part.ConsumedQuantity > 0) Include(c.ConsumedCost, c.Currency); + } + foreach (var charge in (await ChildrenAsync(actor, order.Id)).Where(c => !c.VoidedOn.HasValue)) { var c = Decode(charge.Content); Include(c.Amount, c.Currency); } + total = Math.Max(total, content.EstimatedCost ?? 0); + if (total <= rule.Threshold) return; + if (content.Approval?.State != WorkOrderApprovalState.Approved || content.Approval.Currency != currency || content.Approval.Amount < total) throw new WorkOrderException(409, "SpendingApprovalRequired"); + } + public async Task RequestApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input) + { + if (input == null) throw new WorkOrderException(400, "InvalidInput"); Money(input.Amount); Text(input.Reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); + if (Terminal(row) || row.Status == 5 || row.CreatedBy != actor.UserId && !await _authorization.CanContributeAsync(actor, row)) throw new WorkOrderException(403, "PermissionRequired"); + var document = Decode(row.Content); + document.Fields.Approval = new WorkOrderApproval { State = WorkOrderApprovalState.Requested, Amount = input.Amount, Currency = document.Fields.Currency, RequestedBy = actor.UserId, RequestedOn = Now, Reason = input.Reason }; + document.Fields.ApprovedCost = null; row.Content = JsonConvert.SerializeObject(document); + await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, trigger: WorkflowTriggerEventType.WorkOrderApprovalChanged); return true; + }); + } + public async Task DecideApprovalAsync(ChecklistActor actor, int id, WorkOrderApprovalInput input) + { + if (input == null) throw new WorkOrderException(400, "InvalidInput"); Text(input.Reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); var document = Decode(row.Content); var approval = document.Fields.Approval; + if (Terminal(row) || row.Status == 5 || !await _authorization.CanManageAsync(actor, row.TargetGroupId) || approval == null || approval.RequestedBy == actor.UserId) throw new WorkOrderException(403, "IndependentApprovalRequired"); + if (approval.State != WorkOrderApprovalState.Requested || approval.Currency != document.Fields.Currency) throw new WorkOrderException(409, "Conflict"); + approval.State = input.Approve ? WorkOrderApprovalState.Approved : WorkOrderApprovalState.Rejected; approval.DecidedBy = actor.UserId; approval.DecidedOn = Now; approval.Reason = input.Reason; + document.Fields.ApprovedCost = input.Approve ? approval.Amount : null; row.Content = JsonConvert.SerializeObject(document); + await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, trigger: WorkflowTriggerEventType.WorkOrderApprovalChanged); return true; + }); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs index 07276a2d..c5af6a58 100644 --- a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -305,7 +305,8 @@ await WorkerTransactionAsync(departmentId, async events => order.AssignedToUserId = row.AssignedToUserId; order.AssignedToRoleId = row.AssignedToRoleId; order.Status = order.AssignedToUserId != null || order.AssignedToRoleId.HasValue ? (int)WorkOrderStatus.Assigned : (int)WorkOrderStatus.Accepted; if (order.Status == 2) { await _authorization.ValidateAssignmentAsync(owner, order, order.AssignedToUserId, order.AssignedToRoleId); order.AssignedOn = Now; } - await _store.AllocateAsync(order); + await PinSlaAsync(order); order.ResponseOn = Now; + await _store.AllocateAsync(order); await RecordGeneratedCreationAsync(order); var generated = New(owner, order.Id); generated.RecurrenceId = row.Id; generated.ChangeType = (int)MaintenanceChangeType.Generated; generated.OriginalDueOn = original; generated.RevisedDueOn = due; await _store.AllocateAsync(generated); await EventAsync(order, WorkflowTriggerEventType.WorkOrderCreated, events); if (order.Status == 2) await EventAsync(order, WorkflowTriggerEventType.WorkOrderAssigned, events); @@ -324,6 +325,7 @@ public async Task EscalateMaintenanceAsync(int depart { RequireMaintenanceStore(); var result = new WorkOrderMaintenanceSweep(); if (!await _access.CanUseMaintenanceAsync(departmentId)) return result; + await EscalateServiceLevelsAsync(departmentId, result); foreach (var candidate in await _maintenance.OverdueAsync(departmentId, Now)) { try diff --git a/Core/Resgrid.Services/WorkOrderReportDocuments.cs b/Core/Resgrid.Services/WorkOrderReportDocuments.cs new file mode 100644 index 00000000..2a798f76 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderReportDocuments.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Resources; +using System.Text; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public static class WorkOrderReportDocuments + { + private static readonly ResourceManager Strings = new("Resgrid.Localization.Areas.User.WorkOrders.WorkOrders", typeof(Resgrid.Localization.SupportedLocales).Assembly); + private static string Text(string key) => Strings.GetString(key, CultureInfo.CurrentUICulture) ?? key; + private static string CsvCell(object value) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""; + if (text.TrimStart().FirstOrDefault() is '=' or '+' or '-' or '@' || text.StartsWith('\t') || text.StartsWith('\r') || text.StartsWith('\n')) text = "'" + text; + return "\"" + text.Replace("\"", "\"\"") + "\""; + } + public static byte[] Csv(IEnumerable entries) + { + var csv = new StringBuilder(string.Join(",", new[] { "Number", "Title", "Status", "Priority", "Unit", "Asset", "CreatedOn", "StartedOn", "CompletedOn", "RepairHours", "ActiveRepairHours", "WaitingHours", "DowntimeHours", "ResponseDueOn", "RepairDueOn", "Currency", "Labor", "Parts", "VendorCharges", "TotalCost", "UnknownLabor", "UnknownParts" }.Select(k => CsvCell(Text(k)))) + "\r\n"); + foreach (var entry in entries) + foreach (var cost in entry.Costs.DefaultIfEmpty(new WorkOrderCostTotal())) + csv.AppendLine(string.Join(",", new object[] { entry.Order.Number, entry.Order.Title, Text("Status" + entry.Order.Status), Text("Priority" + entry.Order.Priority), entry.Order.UnitId, entry.Order.AssetId, + entry.Order.CreatedOn.ToString("O"), entry.StartedOn?.ToString("O"), entry.CompletedOn?.ToString("O"), entry.RepairHours, entry.ActiveRepairHours, entry.WaitingHours, entry.DowntimeHours, entry.ResponseDueOn?.ToString("O"), entry.RepairDueOn?.ToString("O"), cost.Currency ?? Text("UnknownCurrency"), cost.Labor, cost.Parts, cost.Vendor, cost.Total, cost.UnknownLabor, cost.UnknownParts }.Select(CsvCell))); + return new UTF8Encoding(true).GetPreamble().Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(); + } + public static string PacketSection(IEnumerable entries) + { + string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.CurrentCulture)); + var html = new StringBuilder("

" + H(Text("WorkOrders")) + "

" + H(Text("WorkOrderPacketMethod")) + "

"); + foreach (var key in new[] { "Number", "Title", "Status", "Priority", "Unit", "Asset", "DueOn", "Revision", "SafetyHolds" }) html.Append(""); + html.Append(""); + foreach (var item in entries) + { + html.Append(""); + foreach (var value in new object[] { item.WorkOrderId, item.Title, Text("Status" + (WorkOrderStatus)item.Status), Text("Priority" + (WorkOrderPriority)item.Priority), item.UnitId, item.AssetId, item.DueOn?.ToString("u"), item.Revision, string.Join(", ", item.ActiveSafetyHoldIds) }) html.Append(""); + html.Append(""); + } + return html.Append("
").Append(H(Text(key))).Append("
").Append(H(value)).Append("
").ToString(); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderReporting.cs b/Core/Resgrid.Services/WorkOrderReporting.cs new file mode 100644 index 00000000..00679216 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderReporting.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private const int ReportLimit = 50000; + private async Task RevealReportBatchAsync(ChecklistActor actor, IReadOnlyCollection rows) where T : WorkOrderRow + { + if (rows.Count == 0) return; + if (rows.Any(r => r == null || r.DepartmentId != actor.DepartmentId)) throw new WorkOrderException(404, "Unavailable"); + var hadPlaintext = rows.Any(r => !string.IsNullOrEmpty(r.Content) && !ProtectedDataEnvelope.HasEnvelopePrefix(r.Content)); + var result = await _read.Value.ResolveRecordsEntitiesForReadAsync(actor.DepartmentId, rows.Select(r => (r, Key(r))).ToList(), WorkOrderTables.Fields(), actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && hadPlaintext) throw new WorkOrderException(403, "ProtectedDataRequired"); + } + private async Task> EvidencePageAsync(ChecklistActor actor, int id, int afterId, Func project) where T : WorkOrderRow + { + if (afterId < 0) throw new WorkOrderException(400, "InvalidInput"); + var order = await ReadOrderAsync(actor, id); + var rows = await _store.ReportChildrenAsync(actor.DepartmentId, new[] { id }, afterId, 51); + var page = new WorkOrderEvidencePage { NextAfterId = rows.Count > 50 ? rows[49].Id : null }; + foreach (var row in rows.Take(50)) page.Items.Add(project(await RevealAsync(actor, row))); + await VerifyReportAccessAsync(actor, new[] { order }); return page; + } + public Task> GetWorkOrderActivityAsync(ChecklistActor actor, int id, int afterId = 0) => EvidencePageAsync(actor, id, afterId, a => + { + var view = Decode(a.Content); view.Id = a.Id; view.Type = (WorkOrderActivityType)a.ActivityType; view.UserId = a.CreatedBy; view.CreatedOn = a.CreatedOn; view.OldStatus = a.OldStatus; view.NewStatus = a.NewStatus; return view; + }); + public Task> GetWorkOrderHoldsAsync(ChecklistActor actor, int id, int afterId = 0) => EvidencePageAsync(actor, id, afterId, h => new WorkOrderHoldView { Hold = h, Content = Decode(h.Content) }); + private WorkOrderReportQuery ReportQuery(WorkOrderReportQuery input, bool statistics) + { + if (input == null || input.AfterId < 0 || input.UnitId <= 0 || input.GroupId <= 0 || input.FromUtc?.Kind == DateTimeKind.Local || input.UntilUtc?.Kind == DateTimeKind.Local + || input.Status.HasValue && !Enum.IsDefined(input.Status.Value) || input.Priority.HasValue && !Enum.IsDefined(input.Priority.Value) + || input.AssetId != null && !Guid.TryParseExact(input.AssetId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); + var query = new WorkOrderReportQuery { FromUtc = input.FromUtc, UntilUtc = input.UntilUtc, AfterId = input.AfterId, UnitId = input.UnitId, GroupId = input.GroupId, AssetId = input.AssetId, Status = input.Status, Priority = input.Priority }; + query.UntilUtc = query.UntilUtc.HasValue ? DateTime.SpecifyKind(query.UntilUtc.Value, DateTimeKind.Utc) : Now.AddTicks(1); + query.FromUtc = query.FromUtc.HasValue ? DateTime.SpecifyKind(query.FromUtc.Value, DateTimeKind.Utc) : statistics ? query.UntilUtc.Value.AddDays(-365) : null; + if (query.FromUtc >= query.UntilUtc || query.FromUtc?.Year < 2000 || query.UntilUtc.Value.Year > 2200 + || statistics && query.UntilUtc - query.FromUtc > TimeSpan.FromDays(366)) throw new WorkOrderException(400, "ReportRangeInvalid"); + return query; + } + private async Task> ReportChildrenAsync(ChecklistActor actor, int[] ids) where T : WorkOrderRow + { + var result = new List(); var after = 0; + while (true) + { + var rows = await _store.ReportChildrenAsync(actor.DepartmentId, ids, after, 500); + await RevealReportBatchAsync(actor, rows); + foreach (var row in rows) + { + if (!row.WorkOrderId.HasValue || !ids.Contains(row.WorkOrderId.Value)) throw new WorkOrderException(404, "Unavailable"); + result.Add(row); + } + if (result.Count > ReportLimit) throw new WorkOrderException(400, "ReportTooLarge"); + if (rows.Count < 500) return result; + after = rows.Last().Id; + } + } + private async Task VerifyReportAccessAsync(ChecklistActor actor, IReadOnlyCollection captured, bool checkRevision = true) + { + await _authorization.RequireMemberAsync(actor); + var scope = await _authorization.ScopeAsync(actor); + foreach (var group in captured.Chunk(500)) + { + var fresh = await _store.ReportOrdersAsync(actor.DepartmentId, scope, new WorkOrderReportQuery(), 500, group.Select(x => x.Id).ToArray()); + if (fresh.Count != group.Length) throw new WorkOrderException(403, "PermissionRequired"); + await RevealReportBatchAsync(actor, fresh); + foreach (var row in fresh) + { + if (checkRevision && group.Single(x => x.Id == row.Id).Revision != row.Revision) throw new WorkOrderException(409, "Conflict"); + await PopulateOrderContentAsync(actor, row); // Generated orders additionally require access to their immutable protected template. + } + } + } + private async Task> ReportEntriesAsync(ChecklistActor actor, List rows) + { + if (rows.Count == 0) return new(); + var ids = rows.Select(x => x.Id).ToArray(); + var labor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var parts = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var vendor = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var activity = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var holds = (await ReportChildrenAsync(actor, ids)).ToLookup(x => x.WorkOrderId.Value); + var entries = new List(); + await RevealReportBatchAsync(actor, rows); + foreach (var row in rows) + { + await PopulateOrderContentAsync(actor, row); + var entry = new WorkOrderReportEntry { Order = Summary(row, Decode(row.Content).Fields), StartedOn = row.StartedOn, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn }; + entry.ResponseDueOn = row.ResponseDueOn; entry.RepairDueOn = row.RepairDueOn; entry.ResponseOn = row.ResponseOn; + PopulateTimeAnalytics(entry, row, activity[row.Id], holds[row.Id], Now); + if (row.Status is 5 or 6 && row.StartedOn.HasValue && row.CompletedOn >= row.StartedOn) + entry.RepairHours = (decimal)(row.CompletedOn.Value - row.StartedOn.Value).TotalHours; + foreach (var item in labor[row.Id]) + { + var content = Decode(item.Content); + var total = CurrencyTotal(entry.Costs, content.Currency); + // Old labor rows did not pin currency. Never assign today's currency to a historical rate. + if (content.RatePerHour.HasValue && total.Currency != null) total.Labor += decimal.Round(content.Hours * content.RatePerHour.Value, 2, MidpointRounding.AwayFromZero); + else total.UnknownLabor++; + } + foreach (var item in parts[row.Id].Where(p => !p.VoidedOn.HasValue && (p.Staged ? p.ConsumedQuantity > 0 : p.InventoryItemId == null || p.InventoryTransactionId != null))) + { + var content = Decode(item.Content); var total = CurrencyTotal(entry.Costs, content.Currency); + if (item.Staged && content.ConsumedCost.HasValue && total.Currency != null) total.Parts += content.ConsumedCost.Value; + else if (!item.Staged && content.UnitCost.HasValue && total.Currency != null) total.Parts += decimal.Round(content.Quantity * content.UnitCost.Value, 2, MidpointRounding.AwayFromZero); + else total.UnknownParts++; + } + foreach (var item in vendor[row.Id].Where(c => !c.VoidedOn.HasValue)) { var content = Decode(item.Content); CurrencyTotal(entry.Costs, content.Currency).Vendor += content.Amount; } + entries.Add(entry); + } + return entries; + } + private static WorkOrderCostTotal CurrencyTotal(List totals, string currency) + { + currency = currency?.Length == 3 && currency.All(c => c is >= 'A' and <= 'Z') ? currency : null; + var total = totals.SingleOrDefault(c => c.Currency == currency); + if (total == null) totals.Add(total = new WorkOrderCostTotal { Currency = currency }); + return total; + } + public async Task GetWorkOrderHistoryAsync(ChecklistActor actor, WorkOrderReportQuery query) + { + query = ReportQuery(query, false); await _authorization.RequireMemberAsync(actor); + var rows = await _store.ReportOrdersAsync(actor.DepartmentId, await _authorization.ScopeAsync(actor), query, 51); + var page = new WorkOrderHistoryPage { NextAfterId = rows.Count > 50 ? rows[49].Id : null }; + rows = rows.Take(50).ToList(); page.Items = await ReportEntriesAsync(actor, rows); + await VerifyReportAccessAsync(actor, rows); return page; + } + private async Task<(List Rows, List Entries)> AllReportEntriesAsync(ChecklistActor actor, WorkOrderReportQuery query) + { + await _authorization.RequireMemberAsync(actor); var scope = await _authorization.ScopeAsync(actor); + var all = new List(); var entries = new List(); query.AfterId = 0; + while (true) + { + var rows = await _store.ReportOrdersAsync(actor.DepartmentId, scope, query, 500); + all.AddRange(rows); if (all.Count > ReportLimit) throw new WorkOrderException(400, "ReportTooLarge"); + entries.AddRange(await ReportEntriesAsync(actor, rows)); + if (rows.Count < 500) break; + query.AfterId = rows.Last().Id; + } + return (all, entries); + } + public async Task GetWorkOrderStatsAsync(ChecklistActor actor, WorkOrderReportQuery query) + { + query = ReportQuery(query, true); var asOf = Now; + var (rows, entries) = await AllReportEntriesAsync(actor, query); + var stats = new WorkOrderStats { FromUtc = query.FromUtc.Value, UntilUtc = query.UntilUtc.Value, AsOfUtc = asOf, Total = rows.Count }; + decimal repair = 0; + foreach (var entry in entries) + { + var order = entry.Order; + if ((int)order.Status <= 5) + { + stats.Open++; stats.OpenByPriority[(int)order.Priority]++; + var days = Math.Max(0, (asOf - order.CreatedOn).TotalDays); stats.OpenByAge[days < 7 ? 0 : days < 30 ? 1 : days < 90 ? 2 : 3]++; + if ((int)order.Status < 5 && order.DueOn < asOf) stats.Overdue++; + } + if (entry.RepairHours.HasValue) { repair += entry.RepairHours.Value; stats.RepairSamples++; } + else if (order.Status is WorkOrderStatus.Completed or WorkOrderStatus.Closed) stats.MissingRepairTimes++; + foreach (var cost in entry.Costs) + { + var total = CurrencyTotal(stats.Costs, cost.Currency); total.Labor += cost.Labor; total.Parts += cost.Parts; total.Vendor += cost.Vendor; + total.UnknownLabor += cost.UnknownLabor; total.UnknownParts += cost.UnknownParts; + } + } + stats.MeanTimeToRepairHours = stats.RepairSamples > 0 ? repair / stats.RepairSamples : null; + await PopulateAdditionalStatsAsync(actor, query, rows, entries, stats); + await VerifyReportAccessAsync(actor, rows); return stats; + } + public async Task ExportWorkOrdersAsync(ChecklistActor actor, WorkOrderReportQuery query) + { + var (rows, entries) = await AllReportEntriesAsync(actor, ReportQuery(query, false)); + var bytes = WorkOrderReportDocuments.Csv(entries); + await VerifyReportAccessAsync(actor, rows); return bytes; + } + public async Task ReadinessEvidenceAsync(ChecklistActor actor, DateTime fromUtc, DateTime callUtc, int[] unitIds, string[] assetIds) + { + if (unitIds == null || assetIds == null || unitIds.Length > 250 || assetIds.Length > 1000 || fromUtc > callUtc) throw new WorkOrderException(400, "InvalidInput"); + await _authorization.RequireMemberAsync(actor); var scope = await _authorization.ScopeAsync(actor); + var section = new ReadinessWorkOrderSection { RestrictedScope = !scope.All }; if (unitIds.Length == 0 && assetIds.Length == 0) return section; + // Include older still-open maintenance as well as completed work in the packet lookback. + section.HistoryUnavailable = await _store.HasUnrecoverableReportHistoryAsync(actor.DepartmentId, scope, callUtc); + var captured = new List(); var scanned = 0; var afterId = 0; + while (true) + { + var rows = await _store.ReportPacketOrdersAsync(actor.DepartmentId, scope, callUtc, unitIds, assetIds, afterId); + scanned += rows.Count; if (scanned > ReportLimit) throw new WorkOrderException(400, "ReportTooLarge"); + if (rows.Count == 0) break; + var snapshots = (await _store.ReportSnapshotsAsync(actor.DepartmentId, rows.Select(r => r.Id).ToArray(), callUtc)).ToDictionary(s => s.WorkOrderId); + var included = new List(); + foreach (var row in rows) + { + snapshots.TryGetValue(row.Id, out var snapshot); + if (snapshot == null && row.UpdatedOn > callUtc) { section.HistoryUnavailable = true; continue; } + // A restored/older writer may have changed the root without emitting a revision reference. + // A known newer state before the call cannot be represented by an older snapshot. + if (snapshot != null && row.UpdatedOn <= callUtc && row.Revision != snapshot.Revision) { section.HistoryUnavailable = true; continue; } + var unit = snapshot?.TargetUnitId ?? (snapshot == null ? row.TargetUnitId : null); + var asset = snapshot == null ? row.InventoryAssetId : snapshot.InventoryAssetId; + if (!(unit.HasValue && unitIds.Contains(unit.Value)) && !(asset != null && assetIds.Contains(asset))) continue; + var status = snapshot?.Status ?? row.Status; var completed = snapshot == null ? row.CompletedOn : snapshot.CompletedOn; + var lastChange = snapshot?.RecordedOn ?? row.UpdatedOn; + if (status >= 5 && (completed ?? row.CreatedOn) < fromUtc && lastChange < fromUtc) continue; + WorkOrderContent fields; + if (snapshot == null) { await RevealOrderAsync(actor, row); fields = Decode(row.Content).Fields; } + else if (snapshot.SourceActivityId.HasValue) + { + var activity = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.SourceActivityId.Value)); + if (activity.WorkOrderId != row.Id) throw new WorkOrderException(409, "Unavailable"); + fields = JObject.Parse(activity.Content)["Snapshot"]?.ToObject(); + } + else if (snapshot.SourceType == 2 && snapshot.RecurrenceVersionId.HasValue) + { + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.RecurrenceVersionId.Value)); + fields = Decode(version.Content)?.Template?.Content; + } + else if (snapshot.SourceType == 1) { await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.Id)); fields = new WorkOrderContent { Title = MaintenanceText("GeneratedFailureTitle") }; } + else fields = null; + if (fields == null) { section.HistoryUnavailable = true; continue; } + var evidence = new ReadinessWorkOrderEvidence { WorkOrderId = row.Id, Revision = snapshot?.Revision ?? row.Revision, SnapshotId = snapshot?.Id, SourceActivityId = snapshot?.SourceActivityId, + RecurrenceVersionId = snapshot?.RecurrenceVersionId, RecordedOn = snapshot?.RecordedOn ?? row.UpdatedOn, Title = fields.Title, Status = status, Priority = snapshot?.Priority ?? row.Priority, + UnitId = unit, AssetId = asset, DueOn = snapshot == null ? row.DueOn : snapshot.DueOn, ChecklistCompletionId = row.SourceChecklistCompletionId, ChecklistItemId = row.SourceChecklistItemId }; + section.Items.Add(evidence); included.Add(evidence); + captured.Add(row); + if (section.Items.Count > 1000) throw new WorkOrderException(400, "ReportTooLarge"); + } + if (included.Count > 0) + { + var holds = await ReportChildrenAsync(actor, included.Select(e => e.WorkOrderId).ToArray()); + foreach (var evidence in included) evidence.ActiveSafetyHoldIds = holds.Where(h => h.WorkOrderId == evidence.WorkOrderId && h.CreatedOn <= callUtc && (!h.ReleasedOn.HasValue || h.ReleasedOn > callUtc)).Select(h => h.Id).ToList(); + } + if (rows.Count < 500) break; afterId = rows.Last().Id; + } + await VerifyReportAccessAsync(actor, captured, false); return section; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderSlaEscalation.cs b/Core/Resgrid.Services/WorkOrderSlaEscalation.cs new file mode 100644 index 00000000..c3e6b52d --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderSlaEscalation.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private async Task EscalateServiceLevelsAsync(int departmentId, WorkOrderMaintenanceSweep result) + { + foreach (var candidate in await _maintenance.SlaDueAsync(departmentId, Now)) + { + try + { + await WorkerTransactionAsync(departmentId, async events => + { + var order = await _store.GetAsync(departmentId, candidate.Id); + if (order == null || order.IsDeleted || order.Status >= 5) return; + var changed = false; + if (!order.ResponseOn.HasValue && order.ResponseDueOn < Now && !order.ResponseBreachedOn.HasValue) { order.ResponseBreachedOn = Now; changed = true; } + if (order.RepairDueOn < Now && !order.RepairBreachedOn.HasValue) { order.RepairBreachedOn = Now; changed = true; } + if (!changed) return; + order.Revision++; order.UpdatedOn = Now; await _store.WriteAsync(order); + await EventAsync(order, WorkflowTriggerEventType.WorkOrderSlaBreached, events); result.Escalated++; + }); + } + catch { result.Errors++; } + } + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs b/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs new file mode 100644 index 00000000..d18f69ce --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderTimeAnalytics.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private async Task RecordGeneratedCreationAsync(WorkOrder row) + { + var activity = New(new ChecklistActor { DepartmentId = row.DepartmentId, UserId = row.CreatedBy }, row.Id); + activity.ActivityType = (int)WorkOrderActivityType.Created; activity.NewStatus = row.Status; await _store.AllocateAsync(activity); + } + private static void PopulateTimeAnalytics(WorkOrderReportEntry entry, WorkOrder order, IEnumerable activities, IEnumerable holds, DateTime now) + { + var events = activities.Where(a => a.CreatedOn <= now).OrderBy(a => a.CreatedOn).ThenBy(a => a.Id).ToList(); + var created = events.FirstOrDefault(a => a.ActivityType == (int)WorkOrderActivityType.Created); + if (created != null && Math.Abs((created.CreatedOn - order.CreatedOn).TotalMinutes) < 1) + { + var state = created.NewStatus ?? 0; var from = order.CreatedOn; decimal active = 0, waiting = 0; var valid = true; + foreach (var transition in events.Where(a => a.OldStatus.HasValue && a.NewStatus.HasValue)) + { + if (transition.OldStatus != state || transition.CreatedOn < from) { valid = false; break; } + Add(transition.CreatedOn); state = transition.NewStatus.Value; + } + if (state != order.Status) valid = false; + if (valid) { Add(now); entry.ActiveRepairHours = active; entry.WaitingHours = waiting; } + void Add(DateTime until) { var hours = (decimal)Math.Max(0, (until - from).TotalHours); if (state == 3) active += hours; else if (state is 0 or 1 or 2 or 4) waiting += hours; from = until; } + } + // Union this order's explicit equipment safety holds; simultaneous holds do not double count. + DateTime? start = null, end = null; + foreach (var hold in holds.Where(h => h.CreatedOn <= now).OrderBy(h => h.CreatedOn)) + { + var until = hold.ReleasedOn.HasValue && hold.ReleasedOn < now ? hold.ReleasedOn.Value : now; + if (until < hold.CreatedOn) continue; + if (!start.HasValue) { start = hold.CreatedOn; end = until; } + else if (hold.CreatedOn <= end) { if (until > end) end = until; } + else { entry.DowntimeHours += (decimal)(end.Value - start.Value).TotalHours; start = hold.CreatedOn; end = until; } + } + if (start.HasValue) entry.DowntimeHours += (decimal)(end.Value - start.Value).TotalHours; + } + private async Task PopulateAdditionalStatsAsync(ChecklistActor actor, WorkOrderReportQuery query, List rows, List entries, WorkOrderStats stats) + { + foreach (var entry in entries) + { + stats.DowntimeHours += entry.DowntimeHours; + if (entry.ActiveRepairHours.HasValue) { stats.ActiveRepairHours += entry.ActiveRepairHours.Value; stats.WaitingHours += entry.WaitingHours ?? 0; } else stats.MissingTimeHistory++; + if ((int)entry.Order.Status is not (7 or 8 or 9) && entry.ResponseDueOn < (entry.ResponseOn ?? entry.CompletedOn ?? stats.AsOfUtc)) stats.ResponseSlaBreaches++; + if ((int)entry.Order.Status is not (7 or 8 or 9) && entry.RepairDueOn < (entry.CompletedOn ?? stats.AsOfUtc)) stats.RepairSlaBreaches++; + } + // A repeated failure requires the same authorized asset/unit and explicitly recorded cause. + stats.RepeatedFailures = rows.Where(r => r.Type is 0 or 2 && r.Status is not (7 or 8 or 9) && (r.InventoryAssetId != null || r.TargetUnitId.HasValue)) + .Select(r => new { Target = r.InventoryAssetId != null ? "a:" + r.InventoryAssetId : "u:" + r.TargetUnitId, Cause = Decode(r.Content).Fields.Cause?.Trim().ToUpperInvariant() }) + .Where(r => !string.IsNullOrEmpty(r.Cause)).GroupBy(r => new { r.Target, r.Cause }).Sum(g => Math.Max(0, g.Count() - 1)); + var dueQuery = new WorkOrderReportQuery { PreventiveDueCohort = true, FromUtc = query.FromUtc, UntilUtc = query.UntilUtc < stats.AsOfUtc ? query.UntilUtc : stats.AsOfUtc, UnitId = query.UnitId, GroupId = query.GroupId, AssetId = query.AssetId, Priority = query.Priority }; + var captured = new List(); var scope = await _authorization.ScopeAsync(actor); + while (true) + { + var batch = await _store.ReportOrdersAsync(actor.DepartmentId, scope, dueQuery, 500); captured.AddRange(batch); + if (captured.Count > ReportLimit) throw new WorkOrderException(400, "ReportTooLarge"); + foreach (var row in batch.Where(r => r.Status is not (7 or 8 or 9))) + { + stats.PreventiveDue++; + if (row.Status is 5 or 6 && row.CompletedOn <= (row.OriginalDueOn ?? row.DueOn)) stats.PreventiveOnTime++; + } + if (batch.Count < 500) break; dueQuery.AfterId = batch.Last().Id; + } + await VerifyReportAccessAsync(actor, captured); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderVendorCharges.cs b/Core/Resgrid.Services/WorkOrderVendorCharges.cs new file mode 100644 index 00000000..685d2278 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderVendorCharges.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + public async Task> VendorChargesAsync(ChecklistActor actor, int id, int afterId = 0) + { + var page = await EvidencePageAsync(actor, id, afterId, row => new WorkOrderVendorChargeView { Id = row.Id, VoidedOn = row.VoidedOn, Content = Decode(row.Content) }); + return page.Items; + } + public async Task AddVendorChargeAsync(ChecklistActor actor, int id, WorkOrderVendorChargeInput input) + { + if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); + var c = input.Content; Money(c.Amount); Text(c.VendorName, 200, true); Text(c.InvoiceReference, 200, true); Text(c.Description, 4000, true); + if (!ValidCurrency(c.Currency) || c.Amount <= 0 || c.ServiceDate.Year < 2000 || c.ServiceDate > Now.AddDays(1) || c.VendorId != null && !Guid.TryParseExact(c.VendorId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { c.VendorId, c.VendorName, c.InvoiceReference, c.Description, c.Currency, c.Amount, c.ServiceDate })))); + await TransactionAsync(actor, async events => + { + RequireMaintenanceStore(); var row = await ReadOrderAsync(actor, id); + var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.RequestId)).SingleOrDefault(); + if (existing != null) + { + await RevealAsync(actor, existing); + if (existing.CreatedBy != actor.UserId || existing.WorkOrderId != id || Decode(existing.Content).RequestHash != hash) throw new WorkOrderException(409, "Conflict"); + return true; + } + row = await ContributionAsync(actor, id, input.Revision); + if (c.VendorId != null) + { + if (_inventoryCatalog == null) throw new WorkOrderException(409, "InventoryOperationFailed"); + var vendor = await _inventoryCatalog.Value.GetAsync(InventoryActor(actor), c.VendorId); + if (vendor == null || vendor.IsDeleted) throw new WorkOrderException(404, "Unavailable"); + } + await RequireSpendingAsync(actor, row, c.Amount, c.Currency); + // The request ID owns retries; a reused invoice reference may legitimately contain separate service lines. + var charge = New(actor, id); charge.RequestId = input.RequestId; c.RequestHash = hash; charge.Content = JsonConvert.SerializeObject(c); await SaveAsync(actor, charge, true); + await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, trigger: WorkflowTriggerEventType.WorkOrderVendorChargeChanged); return true; + }); + } + public async Task VoidVendorChargeAsync(ChecklistActor actor, int id, int chargeId, int revision, string reason) + { + Text(reason, 4000, true); + await TransactionAsync(actor, async events => + { + var row = await ContributionAsync(actor, id, revision); + if (!await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + var charge = await _store.GetAsync(actor.DepartmentId, chargeId); + if (charge?.WorkOrderId != id || charge.VoidedOn.HasValue) throw new WorkOrderException(409, "Unavailable"); + await RevealAsync(actor, charge); charge.VoidedOn = Now; charge.Revision++; await SaveAsync(actor, charge); + await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events, reason, trigger: WorkflowTriggerEventType.WorkOrderVendorChargeChanged); return true; + }); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index cca9d73a..1fd1302f 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -17,7 +17,7 @@ namespace Resgrid.Services { - public sealed partial class WorkOrdersService : IWorkOrdersService, IWorkOrderMaintenanceService + public sealed partial class WorkOrdersService : IWorkOrdersService, IWorkOrderMaintenanceService, IWorkOrderReportingService, IWorkOrderOperationsService { private readonly IWorkOrderRepository _store; private readonly IWorkOrderAuthorizationService _authorization; @@ -58,7 +58,7 @@ private async Task ReadOrderAsync(ChecklistActor actor, int id) { await _authorization.RequireMemberAsync(actor); var row = id > 0 ? await _store.GetAsync(actor.DepartmentId, id) : null; - if (row == null || !(await _authorization.ScopeAsync(actor)).Allows(row)) throw new WorkOrderException(404, "Unavailable"); + if (row == null || row.IsDeleted || !(await _authorization.ScopeAsync(actor)).Allows(row)) throw new WorkOrderException(404, "Unavailable"); return await RevealOrderAsync(actor, row); } private async Task RevealAsync(ChecklistActor actor, T row) where T : WorkOrderRow @@ -92,28 +92,32 @@ private async Task TransactionAsync(ChecklistActor actor, Func, catch { _uow.DiscardChanges(); throw; } await _outbox.DispatchAfterCommitAsync(events); return result; } - private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, string note = null, int? oldStatus = null, int? newStatus = null, WorkOrderContent snapshot = null, DateTime? previousDue = null) + private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, string note = null, int? oldStatus = null, int? newStatus = null, WorkOrderContent snapshot = null, DateTime? previousDue = null) { var activity = New(actor, row.Id); activity.ActivityType = (int)type; activity.OldStatus = oldStatus; activity.NewStatus = newStatus; - activity.Content = JsonConvert.SerializeObject(new { Note = note, Snapshot = snapshot, OriginalDueOn = previousDue, RevisedDueOn = type == WorkOrderActivityType.Deferred ? row.DueOn : null, AssignedToUserId = type == WorkOrderActivityType.Assigned ? row.AssignedToUserId : null, AssignedToRoleId = type == WorkOrderActivityType.Assigned ? row.AssignedToRoleId : null }); await SaveAsync(actor, activity, true); + activity.Content = JsonConvert.SerializeObject(new { Note = note, Snapshot = snapshot, row.ResponseDueOn, row.RepairDueOn, row.ResponseOn, row.SlaPolicyRevision, OriginalDueOn = previousDue, RevisedDueOn = type == WorkOrderActivityType.Deferred ? row.DueOn : null, AssignedToUserId = type == WorkOrderActivityType.Assigned ? row.AssignedToUserId : null, AssignedToRoleId = type == WorkOrderActivityType.Assigned ? row.AssignedToRoleId : null }); await SaveAsync(actor, activity, true); var audit = await _audit.InsertAsync(new AuditLog { DepartmentId = actor.DepartmentId, ObjectDepartmentId = actor.DepartmentId, UserId = actor.UserId, ObjectId = Key(row), LogType = (int)AuditLogTypes.WorkOrderChanged, LoggedOn = Now, Successful = true, Message = "WorkOrderChanged", ServerName = Environment.MachineName }, CancellationToken.None); audit.Data = JsonConvert.SerializeObject(new { row.Id, row.Revision, ActivityId = activity.Id, ActivityType = (int)type, OldStatus = oldStatus, NewStatus = newStatus }); var protection = await _write.Value.PrepareRecordsEntityWriteAsync(actor.DepartmentId, audit, null, audit.AuditLogId.ToString(CultureInfo.InvariantCulture), ReadinessHistoryFields.Audits, null, actor.GrantToken, actor.UserId, false); if (protection?.Success != true || protection.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new WorkOrderException(403, "ProtectedDataRequired"); await _audit.UpdateAsync(audit, CancellationToken.None); + return activity.Id; } - private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events, int? oldStatus = null) + private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events, int? oldStatus = null, int? activityId = null, int? approvalState = null) { + await _store.CaptureReportSnapshotAsync(row, Now, activityId); var entry = await _outbox.EnqueueAsync(row.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger?.ToString() ?? "WorkOrderUpdated", AggregateType = "WorkOrder", AggregateId = Key(row), AggregateVersion = row.Revision, Trigger = trigger, OccurredOn = Now, CorrelationId = row.RequestId, - Payload = new { WorkOrderId = row.Id, row.Revision, row.Status, row.Priority, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, row.AssignedToRoleId, row.DueOn, OldStatus = oldStatus } }); + Payload = new { WorkOrderId = row.Id, row.Revision, row.Status, row.Priority, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, row.AssignedToRoleId, row.DueOn, row.ResponseDueOn, row.RepairDueOn, row.ResponseBreachedOn, row.RepairBreachedOn, ApprovalState = approvalState, OldStatus = oldStatus } }); events.Add(entry.DomainEventOutboxId); } private async Task ChangedAsync(ChecklistActor actor, WorkOrder row, WorkOrderActivityType type, List events, string note = null, int? previous = null, WorkflowTriggerEventType? trigger = null, DateTime? previousDue = null) { - var snapshot = type is WorkOrderActivityType.Updated or WorkOrderActivityType.StatusChanged ? Decode(row.Content).Fields : null; - row.Revision++; await SaveAsync(actor, row); await ActivityAsync(actor, row, type, note, previous, previous.HasValue ? row.Status : null, snapshot, previousDue); await EventAsync(row, trigger, events, previous); + var snapshot = Decode(row.Content).Fields; + row.Revision++; await SaveAsync(actor, row); + var activityId = await ActivityAsync(actor, row, type, note, previous, previous.HasValue ? row.Status : null, snapshot, previousDue); + await EventAsync(row, trigger, events, previous, activityId, (int?)snapshot?.Approval?.State); } private static void Validate(WorkOrderInput input) { @@ -130,6 +134,7 @@ private static void Validate(WorkOrderInput input) private static string Fingerprint(WorkOrderInput input) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { input.Type, input.Priority, input.TargetUnitId, input.TargetGroupId, input.InventoryAssetId, input.DueOn, input.Content })))); public async Task CreateAsync(ChecklistActor actor, WorkOrderInput input) { + if (input?.Content?.Approval != null) throw new WorkOrderException(400, "InvalidInput"); Validate(input); await RequireWriteAsync(actor); await _authorization.ValidateTargetAsync(actor, input); var id = await TransactionAsync(actor, async events => { @@ -145,7 +150,11 @@ public async Task CreateAsync(ChecklistActor actor, WorkOrderIn } var row = New(actor); row.RequestId = input.RequestId; row.NumberYear = Now.Year; row.NumberSequence = await _store.NextNumberAsync(actor.DepartmentId, row.NumberYear); Apply(row, input); row.OriginalDueOn = row.DueOn; row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = input.Content, RequestHash = hash }); - await SaveAsync(actor, row, true); await ActivityAsync(actor, row, WorkOrderActivityType.Created, snapshot: input.Content); await EventAsync(row, WorkflowTriggerEventType.WorkOrderCreated, events); return row.Id; + if (input.Content.ApprovedCost.HasValue && (await ReadPolicyAsync(actor)).ApprovalsEnabled) throw new WorkOrderException(409, "SpendingApprovalRequired"); + await PinSlaAsync(row); + await SaveAsync(actor, row, true); + var activityId = await ActivityAsync(actor, row, WorkOrderActivityType.Created, snapshot: input.Content); + await EventAsync(row, WorkflowTriggerEventType.WorkOrderCreated, events, activityId: activityId); return row.Id; }); return await GetAsync(actor, id); } @@ -166,7 +175,12 @@ await TransactionAsync(actor, async events => if (row.StartedOn.HasValue && (document.Fields.SafetyCritical && !input.Content.SafetyCritical || document.Fields.HazardousWork && !input.Content.HazardousWork)) throw new WorkOrderException(409, "SafetyRequirements"); if (!manage && (input.Content.ApprovedCost.HasValue && input.Content.ApprovedCost != document.Fields.ApprovedCost || input.Content.Resolution != document.Fields.Resolution || input.Content.VerificationEvidence != document.Fields.VerificationEvidence)) throw new WorkOrderException(403, "PermissionRequired"); if (!manage) input.Content.ApprovedCost = document.Fields.ApprovedCost; + input.Content.Approval = document.Fields.Approval; + if ((await ReadPolicyAsync(actor)).ApprovalsEnabled && input.Content.ApprovedCost != document.Fields.ApprovedCost) throw new WorkOrderException(409, "SpendingApprovalRequired"); + if (input.Content.Currency != document.Fields.Currency) { input.Content.Approval = null; input.Content.ApprovedCost = null; } + var oldPriority = row.Priority; document.Fields = input.Content; row.Content = JsonConvert.SerializeObject(document); Apply(row, input); + if (oldPriority != row.Priority) await PinSlaAsync(row); await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events); return true; }); } @@ -193,6 +207,8 @@ await TransactionAsync(actor, async events => var technicianStep = input.Status is WorkOrderStatus.InProgress or WorkOrderStatus.OnHold or WorkOrderStatus.Completed; if (!manage && (!contribute || !technicianStep || old == (int)WorkOrderStatus.Completed)) throw new WorkOrderException(403, "PermissionRequired"); var document = Decode(row.Content); var c = document.Fields; + if (input.Status is WorkOrderStatus.InProgress or WorkOrderStatus.Completed or WorkOrderStatus.Closed) await RequireSpendingAsync(actor, row); + if (input.Status == WorkOrderStatus.Accepted && !row.ResponseOn.HasValue) row.ResponseOn = Now; if (Terminal(row) && input.Status == WorkOrderStatus.Accepted) await RecurrenceReopenedAsync(actor, row, input.Reason, events); if (input.Status is WorkOrderStatus.OnHold or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate || Terminal(row) || old == (int)WorkOrderStatus.Completed && input.Status == WorkOrderStatus.InProgress) Text(input.Reason, 4000, true); if (input.Status == WorkOrderStatus.Duplicate) @@ -225,14 +241,20 @@ await TransactionAsync(actor, async events => } row.Status = (int)input.Status; row.Content = JsonConvert.SerializeObject(document); if ((int)input.Status >= 5) await RequireNoPendingPartsAsync(actor, row.Id); - await ChangedAsync(actor, row, WorkOrderActivityType.StatusChanged, events, input.Reason, old, WorkflowTriggerEventType.WorkOrderStatusChanged); await RecurrenceCompletedAsync(row); return true; + var responseBreach = !row.ResponseBreachedOn.HasValue && row.ResponseDueOn < row.ResponseOn; + var repairBreach = !row.RepairBreachedOn.HasValue && row.RepairDueOn < row.CompletedOn; + if (responseBreach) row.ResponseBreachedOn = Now; if (repairBreach) row.RepairBreachedOn = Now; + await ChangedAsync(actor, row, WorkOrderActivityType.StatusChanged, events, input.Reason, old, WorkflowTriggerEventType.WorkOrderStatusChanged); + if (responseBreach || repairBreach) await EventAsync(row, WorkflowTriggerEventType.WorkOrderSlaBreached, events); await RecurrenceCompletedAsync(row); return true; }); } public async Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input) { if (input == null) throw new WorkOrderException(400, "InvalidInput"); - await TransactionAsync(actor, async events => - { + await TransactionAsync(actor, events => AssignWithinAsync(actor, id, input, events)); + } + private async Task AssignWithinAsync(ChecklistActor actor, int id, WorkOrderAssignment input, List events) + { var row = await ReadOrderAsync(actor, id); Revision(row, input.Revision); if (!await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); if (row.Status is not (1 or 2 or 3 or 4)) throw new WorkOrderException(409, "InvalidTransition"); @@ -241,7 +263,6 @@ await TransactionAsync(actor, async events => var old = row.Status; row.AssignedToUserId = string.IsNullOrEmpty(input.UserId) ? null : input.UserId; row.AssignedToRoleId = input.RoleId; row.AssignedOn = Now; row.AssignmentAcceptedOn = null; row.AssignmentAcceptedBy = null; row.Status = (int)WorkOrderStatus.Assigned; await ChangedAsync(actor, row, WorkOrderActivityType.Assigned, events, previous: old, trigger: WorkflowTriggerEventType.WorkOrderAssigned); if (old != row.Status) await EventAsync(row, WorkflowTriggerEventType.WorkOrderStatusChanged, events); return true; - }); } public async Task AcceptAssignmentAsync(ChecklistActor actor, int id, int revision) { @@ -273,6 +294,8 @@ await TransactionAsync(actor, async events => var row = await ContributionAsync(actor, id, input.Revision); var user = string.IsNullOrEmpty(input.UserId) ? actor.UserId : input.UserId; await _authorization.RequireMemberAsync(new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = user }); if (user != actor.UserId && !await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + input.Content.Currency = Decode(row.Content).Fields.Currency; + await RequireSpendingAsync(actor, row, input.Content.RatePerHour.HasValue ? decimal.Round(input.Content.Hours * input.Content.RatePerHour.Value, 2, MidpointRounding.AwayFromZero) : null, input.Content.Currency); var labor = New(actor, id); labor.UserId = user; labor.WorkDate = DateTime.SpecifyKind(input.WorkDate, DateTimeKind.Utc); labor.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, labor, true); await ChangedAsync(actor, row, WorkOrderActivityType.LaborAdded, events); return true; }); @@ -309,8 +332,9 @@ public async Task GetAsync(ChecklistActor actor, int id) if (write) detail.Transitions = NextStatuses((WorkOrderStatus)row.Status).Where(s => manage || contribute && row.Status != 5 && s is WorkOrderStatus.InProgress or WorkOrderStatus.OnHold or WorkOrderStatus.Completed).ToArray(); detail.Activities = (await ChildrenAsync(actor, id)).Select(a => { var activity = Decode(a.Content); activity.Id = a.Id; activity.Type = (WorkOrderActivityType)a.ActivityType; activity.UserId = a.CreatedBy; activity.CreatedOn = a.CreatedOn; activity.OldStatus = a.OldStatus; activity.NewStatus = a.NewStatus; return activity; }).ToList(); detail.Labor = (await ChildrenAsync(actor, id)).Select(l => new WorkOrderLaborView { Id = l.Id, UserId = l.UserId, WorkDate = l.WorkDate, Content = Decode(l.Content) }).ToList(); - detail.Parts = (await ChildrenAsync(actor, id)).Select(p => new WorkOrderPartView { InventoryWitnessRequestId = p.InventoryTransactionId == null ? p.InventoryRequestId : MaintenanceIdentity("part-reversal:" + actor.DepartmentId + ":" + p.Id + ":" + p.Revision), Id = p.Id, InventoryItemId = p.InventoryItemId, InventoryTransactionId = p.InventoryTransactionId, InventoryOperationId = p.InventoryOperationId, AwaitingWitness = !p.VoidedOn.HasValue && p.InventoryOperationId != null && (p.InventoryTransactionId == null || p.InventoryReversalId == null && Decode(p.Content).VoidReason != null), VoidedOn = p.VoidedOn, Content = Decode(p.Content) }).ToList(); + detail.Parts = (await ChildrenAsync(actor, id)).Select(p => new WorkOrderPartView { InventoryWitnessRequestId = p.InventoryTransactionId == null ? p.InventoryRequestId : MaintenanceIdentity("part-reversal:" + actor.DepartmentId + ":" + p.Id + ":" + p.Revision), Id = p.Id, Staged = p.Staged, ReservedQuantity = p.ReservedQuantity, IssuedQuantity = p.IssuedQuantity, ConsumedQuantity = p.ConsumedQuantity, ReturnedQuantity = p.ReturnedQuantity, InventoryItemId = p.InventoryItemId, InventoryTransactionId = p.InventoryTransactionId, InventoryOperationId = p.InventoryOperationId, AwaitingWitness = !p.VoidedOn.HasValue && p.InventoryOperationId != null && (p.InventoryTransactionId == null || p.InventoryReversalId == null && Decode(p.Content).VoidReason != null), VoidedOn = p.VoidedOn, Content = Decode(p.Content) }).ToList(); detail.Files = (await ChildrenAsync(actor, id)).Select(f => new WorkOrderFileView { Id = f.Id, Name = f.Content, ContentType = f.ContentType, Size = f.Size, WithdrawnOn = f.WithdrawnOn }).ToList(); + detail.ResponseDueOn = row.ResponseDueOn; detail.RepairDueOn = row.RepairDueOn; detail.ResponseOn = row.ResponseOn; return detail; } } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 696e624d..db3a8d4a 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -90,8 +90,18 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.WorkOrderThresholdReached: case WorkflowTriggerEventType.WorkOrderDeferred: case WorkflowTriggerEventType.WorkOrderPartChanged: + case WorkflowTriggerEventType.WorkOrderApprovalChanged: + case WorkflowTriggerEventType.WorkOrderSlaBreached: + case WorkflowTriggerEventType.WorkOrderVendorChargeChanged: + case WorkflowTriggerEventType.WorkOrderPolicyChanged: + obj["work_order"] = new ScriptObject { ["id"] = 123, ["revision"] = 2, ["status"] = 2, ["priority"] = 1, ["unit_id"] = 12, ["group_id"] = 3, ["role_id"] = 4, ["asset_id"] = null, ["due_on"] = "2026-09-09T08:00:00Z", ["title"] = ProtectedDataEnvelope.RedactionValue, ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/Detail/123" }; - obj["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = new ScriptArray { "Title" }, ["catalog_version"] = 18 }; + var sampleOrder = (ScriptObject)obj["work_order"]; + foreach (var variable in Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Variables) if (!sampleOrder.ContainsKey(variable.Variable)) sampleOrder[variable.Variable] = null; + if (eventType == WorkflowTriggerEventType.WorkOrderPolicyChanged) { sampleOrder["id"] = null; sampleOrder["policy_id"] = 1; sampleOrder["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/Policy"; } + if (eventType == WorkflowTriggerEventType.WorkOrderApprovalChanged) sampleOrder["approval_state"] = 1; + sampleOrder["response_due_on"] = "2026-09-09T09:00:00Z"; sampleOrder["repair_due_on"] = "2026-09-10T09:00:00Z"; + obj["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = new ScriptArray { "Title" }, ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.OperationsCatalogVersion }; break; case WorkflowTriggerEventType.ChecklistCompleted: case WorkflowTriggerEventType.ChecklistFailed: @@ -754,7 +764,7 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType r["revision_id"] = revisionId ?? ""; r["revision_number"] = revisionNumber; r["checksum"] = revisionId == null ? "" : "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"; - r["summary"] = "Pump Operations — Hose evolutions"; + r["summary"] = "Pump Operations — Hose evolutions"; r["url"] = "https://resgrid.local/User/Records/Details/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; obj["record"] = r; @@ -833,7 +843,7 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType if (!isDefinitionTrigger) { var fields = new ScriptObject(); - fields["client_site"] = "Harbor Logistics — Pier 4"; + fields["client_site"] = "Harbor Logistics — Pier 4"; fields["officer"] = "J. Alvarez"; fields["exception_reported"] = true; var checkpoint = new ScriptObject(); diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 51281a02..0ee670cd 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -427,13 +427,18 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.WorkOrderThresholdReached: case WorkflowTriggerEventType.WorkOrderDeferred: case WorkflowTriggerEventType.WorkOrderPartChanged: + case WorkflowTriggerEventType.WorkOrderApprovalChanged: + case WorkflowTriggerEventType.WorkOrderSlaBreached: + case WorkflowTriggerEventType.WorkOrderVendorChargeChanged: + case WorkflowTriggerEventType.WorkOrderPolicyChanged: + { var orderEvent = TryDeserialize(eventPayloadJson); var payload = orderEvent?.Payload ?? new JObject(); var order = new ScriptObject(); foreach (var pair in Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Variables) order[pair.Variable] = ToScriptValue(payload[pair.Property]); - order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/{(payload["WorkOrderId"] != null ? "Detail/" + payload["WorkOrderId"]?.Value() : "Recurrence/" + payload["RecurrenceId"]?.Value())}"; + order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/{(payload["PolicyId"] != null ? "Policy" : payload["WorkOrderId"] != null ? "Detail/" + payload["WorkOrderId"]?.Value() : "Recurrence/" + payload["RecurrenceId"]?.Value())}"; scriptObject["work_order"] = order; - scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.RecurrenceCatalogVersion }; + scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.OperationsCatalogVersion }; break; } case WorkflowTriggerEventType.ChecklistCompleted: diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs new file mode 100644 index 00000000..42b850cc --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0206_AddWorkOrderReporting.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(206)] + public class M0206_AddWorkOrderReporting : Migration + { + private static string N(string value) => value; + public override void Up() + { + Create.Table(N("WorkOrderReportSnapshots")) + .WithColumn(N("Id")).AsInt64().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("Revision")).AsInt32().NotNullable() + .WithColumn(N("RecordedOn")).AsDateTime2().NotNullable() + .WithColumn(N("SourceActivityId")).AsInt32().Nullable() + .WithColumn(N("RecurrenceVersionId")).AsInt32().Nullable() + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("Priority")).AsInt32().NotNullable() + .WithColumn(N("TargetUnitId")).AsInt32().Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("DueOn")).AsDateTime2().Nullable() + .WithColumn(N("StartedOn")).AsDateTime2().Nullable() + .WithColumn(N("CompletedOn")).AsDateTime2().Nullable() + .WithColumn(N("ClosedOn")).AsDateTime2().Nullable(); + Foreign("Order", "WorkOrderId", "WorkOrders"); + Foreign("Activity", "SourceActivityId", "WorkOrderActivities"); + Foreign("Template", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); + Index("WorkOrderReportSnapshots", "Revision", true, "DepartmentId", "WorkOrderId", "Revision"); + Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Id"); + Index("WorkOrders", "ReportCreated", false, "DepartmentId", "CreatedOn", "Id"); + // PostgreSQL checks this self-reference for every deleted parent; a missing child index + // turns an authorized large-department purge into repeated full scans. + Index("WorkOrders", "DuplicateParent", false, "DepartmentId", "DuplicateOfId"); + Index("WorkOrderReportSnapshots", "Unit", false, "DepartmentId", "TargetUnitId", "RecordedOn", "WorkOrderId"); + Index("WorkOrderReportSnapshots", "Asset", false, "DepartmentId", "InventoryAssetId", "RecordedOn", "WorkOrderId"); + } + private void Foreign(string suffix, string column, string parent) => Create.ForeignKey(N("FK_WorkOrderReportSnapshots_" + suffix)) + .FromTable(N("WorkOrderReportSnapshots")).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(); + } + public override void Down() + { + Execute.Sql("IF EXISTS (SELECT 1 FROM WorkOrderReportSnapshots) THROW 51000, 'Readiness evidence requires authorized retention before rollback.', 1;"); + Delete.Table(N("WorkOrderReportSnapshots")); + Delete.Index(N("IX_WorkOrders_ReportCreated")).OnTable(N("WorkOrders")); + Delete.Index(N("IX_WorkOrders_DuplicateParent")).OnTable(N("WorkOrders")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs new file mode 100644 index 00000000..a193b827 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0207_AddWorkOrderOperations.cs @@ -0,0 +1,98 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(207)] + public sealed class M0207_AddWorkOrderOperations : Migration + { + private string N(string value) => value; + public override void Up() + { + Base("WorkOrderPolicies"); + Alter.Table(N("WorkOrderPolicies")).AddColumn(N("CalendarJson")).AsString(int.MaxValue).Nullable(); + Index("WorkOrderPolicies", "Department", true, "DepartmentId"); + Base("WorkOrderOperationReceipts"); + Alter.Table(N("WorkOrderOperationReceipts")).AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("Kind")).AsInt32().NotNullable().WithDefaultValue(0); + Index("WorkOrderOperationReceipts", "Request", true, "DepartmentId", "RequestId"); + Base("WorkOrderVendorCharges"); + Alter.Table(N("WorkOrderVendorCharges")).AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("VoidedOn")).AsDateTime2().Nullable(); + Index("WorkOrderVendorCharges", "Request", true, "DepartmentId", "RequestId"); + Base("WorkOrderPartMovements"); + Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsInt32().NotNullable() + .AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("Kind")).AsInt32().NotNullable() + .AddColumn(N("Quantity")).AsDecimal(24,6).NotNullable() + .AddColumn(N("FromLocationId")).AsString(36).Nullable() + .AddColumn(N("ToLocationId")).AsString(36).Nullable() + .AddColumn(N("InventoryOperationId")).AsString(36).Nullable() + .AddColumn(N("InventoryTransactionId")).AsString(36).Nullable() + .AddColumn(N("Cancelled")).AsBoolean().NotNullable().WithDefaultValue(false); + Foreign("WorkOrderPartMovements", "Part", "PartId", "WorkOrderParts"); + Index("WorkOrderPartMovements", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrderPartMovements", "Part", false, "DepartmentId", "PartId", "Id"); + foreach (var name in new[] { "ResponseDueOn", "RepairDueOn", "ResponseOn", "ResponseBreachedOn", "RepairBreachedOn" }) + Alter.Table(N("WorkOrders")).AddColumn(N(name)).AsDateTime2().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("SlaPolicyRevision")).AsInt32().Nullable(); + foreach (var name in new[] { "ResponseDueOn", "RepairDueOn", "ResponseOn", "ResponseBreachedOn", "RepairBreachedOn" }) + Alter.Table(N("WorkOrderReportSnapshots")).AddColumn(N(name)).AsDateTime2().Nullable(); + Alter.Table(N("WorkOrderReportSnapshots")).AddColumn(N("SlaPolicyRevision")).AsInt32().Nullable(); + Alter.Table(N("WorkOrderParts")).AddColumn(N("Staged")).AsBoolean().NotNullable().WithDefaultValue(false); + foreach (var name in new[] { "ReservedLocationId", "IssuedLocationId", "ReservedAssetId", "ReservedLotId" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(name)).AsString(36).Nullable(); + foreach (var name in new[] { "ReservedQuantity", "IssuedQuantity", "ConsumedQuantity", "ReturnedQuantity" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(name)).AsDecimal(24,6).NotNullable().WithDefaultValue(0); + Index("WorkOrderParts", "Reservations", false, "DepartmentId", "InventoryItemId", "ReservedLocationId", "ReservedLotId"); + Index("WorkOrders", "ResponseSla", false, "DepartmentId", "ResponseDueOn", "ResponseBreachedOn"); + Index("WorkOrders", "RepairSla", false, "DepartmentId", "RepairDueOn", "RepairBreachedOn"); + if (Schema.Table(N("InventoryTransactions")).Exists()) + { + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsInt32().Nullable(); + Foreign("InventoryTransactions", "WorkOrderMovement", "WorkOrderPartMovementId", "WorkOrderPartMovements"); + Index("InventoryTransactions", "WorkOrderMovement", false, "DepartmentId", "WorkOrderPartMovementId"); + } + } + private void Base(string table) + { + Create.Table(N(table)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + Create.ForeignKey(N("FK_"+table+"_Department")).FromTable(N(table)).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index(table, "Tenant", true, "DepartmentId", "Id"); + Foreign(table, "Order", "WorkOrderId", "WorkOrders"); + Index(table, "Order", false, "DepartmentId", "WorkOrderId", "Id"); + } + private void Foreign(string table, string suffix, string column, string parent) => Create.ForeignKey(N("FK_"+table+"_"+suffix)).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 value.ToLowerInvariant(); + public override void Up() + { + Create.Table(N("WorkOrderReportSnapshots")) + .WithColumn(N("Id")).AsInt64().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().NotNullable() + .WithColumn(N("Revision")).AsInt32().NotNullable() + .WithColumn(N("RecordedOn")).AsDateTime().NotNullable() + .WithColumn(N("SourceActivityId")).AsInt32().Nullable() + .WithColumn(N("RecurrenceVersionId")).AsInt32().Nullable() + .WithColumn(N("SourceType")).AsInt32().NotNullable() + .WithColumn(N("Status")).AsInt32().NotNullable() + .WithColumn(N("Priority")).AsInt32().NotNullable() + .WithColumn(N("TargetUnitId")).AsInt32().Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("DueOn")).AsDateTime().Nullable() + .WithColumn(N("StartedOn")).AsDateTime().Nullable() + .WithColumn(N("CompletedOn")).AsDateTime().Nullable() + .WithColumn(N("ClosedOn")).AsDateTime().Nullable(); + Foreign("Order", "WorkOrderId", "WorkOrders"); + Foreign("Activity", "SourceActivityId", "WorkOrderActivities"); + Foreign("Template", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); + Index("WorkOrderReportSnapshots", "Revision", true, "DepartmentId", "WorkOrderId", "Revision"); + Index("WorkOrderReportSnapshots", "AsOf", false, "DepartmentId", "WorkOrderId", "RecordedOn", "Id"); + Index("WorkOrders", "ReportCreated", false, "DepartmentId", "CreatedOn", "Id"); + // PostgreSQL checks this self-reference for every deleted parent; a missing child index + // turns an authorized large-department purge into repeated full scans. + Index("WorkOrders", "DuplicateParent", false, "DepartmentId", "DuplicateOfId"); + Index("WorkOrderReportSnapshots", "Unit", false, "DepartmentId", "TargetUnitId", "RecordedOn", "WorkOrderId"); + Index("WorkOrderReportSnapshots", "Asset", false, "DepartmentId", "InventoryAssetId", "RecordedOn", "WorkOrderId"); + } + private void Foreign(string suffix, string column, string parent) => Create.ForeignKey(N("FK_WorkOrderReportSnapshots_" + suffix)) + .FromTable(N("WorkOrderReportSnapshots")).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(); + } + public override void Down() + { + Execute.Sql("DO $$ BEGIN IF EXISTS (SELECT 1 FROM workorderreportsnapshots) THEN RAISE EXCEPTION 'Readiness evidence requires authorized retention before rollback.'; END IF; END $$;"); + Delete.Table(N("WorkOrderReportSnapshots")); + Delete.Index(N("IX_WorkOrders_ReportCreated")).OnTable(N("WorkOrders")); + Delete.Index(N("IX_WorkOrders_DuplicateParent")).OnTable(N("WorkOrders")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs new file mode 100644 index 00000000..9e21c4ba --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0207_AddWorkOrderOperationsPg.cs @@ -0,0 +1,98 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(207)] + public sealed class M0207_AddWorkOrderOperationsPg : Migration + { + private string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + Base("WorkOrderPolicies"); + Alter.Table(N("WorkOrderPolicies")).AddColumn(N("CalendarJson")).AsString(int.MaxValue).Nullable(); + Index("WorkOrderPolicies", "Department", true, "DepartmentId"); + Base("WorkOrderOperationReceipts"); + Alter.Table(N("WorkOrderOperationReceipts")).AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("Kind")).AsInt32().NotNullable().WithDefaultValue(0); + Index("WorkOrderOperationReceipts", "Request", true, "DepartmentId", "RequestId"); + Base("WorkOrderVendorCharges"); + Alter.Table(N("WorkOrderVendorCharges")).AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("VoidedOn")).AsDateTime2().Nullable(); + Index("WorkOrderVendorCharges", "Request", true, "DepartmentId", "RequestId"); + Base("WorkOrderPartMovements"); + Alter.Table(N("WorkOrderPartMovements")).AddColumn(N("PartId")).AsInt32().NotNullable() + .AddColumn(N("RequestId")).AsString(36).NotNullable() + .AddColumn(N("Kind")).AsInt32().NotNullable() + .AddColumn(N("Quantity")).AsDecimal(24,6).NotNullable() + .AddColumn(N("FromLocationId")).AsString(36).Nullable() + .AddColumn(N("ToLocationId")).AsString(36).Nullable() + .AddColumn(N("InventoryOperationId")).AsString(36).Nullable() + .AddColumn(N("InventoryTransactionId")).AsString(36).Nullable() + .AddColumn(N("Cancelled")).AsBoolean().NotNullable().WithDefaultValue(false); + Foreign("WorkOrderPartMovements", "Part", "PartId", "WorkOrderParts"); + Index("WorkOrderPartMovements", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrderPartMovements", "Part", false, "DepartmentId", "PartId", "Id"); + foreach (var name in new[] { "ResponseDueOn", "RepairDueOn", "ResponseOn", "ResponseBreachedOn", "RepairBreachedOn" }) + Alter.Table(N("WorkOrders")).AddColumn(N(name)).AsDateTime2().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("SlaPolicyRevision")).AsInt32().Nullable(); + foreach (var name in new[] { "ResponseDueOn", "RepairDueOn", "ResponseOn", "ResponseBreachedOn", "RepairBreachedOn" }) + Alter.Table(N("WorkOrderReportSnapshots")).AddColumn(N(name)).AsDateTime2().Nullable(); + Alter.Table(N("WorkOrderReportSnapshots")).AddColumn(N("SlaPolicyRevision")).AsInt32().Nullable(); + Alter.Table(N("WorkOrderParts")).AddColumn(N("Staged")).AsBoolean().NotNullable().WithDefaultValue(false); + foreach (var name in new[] { "ReservedLocationId", "IssuedLocationId", "ReservedAssetId", "ReservedLotId" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(name)).AsString(36).Nullable(); + foreach (var name in new[] { "ReservedQuantity", "IssuedQuantity", "ConsumedQuantity", "ReturnedQuantity" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(name)).AsDecimal(24,6).NotNullable().WithDefaultValue(0); + Index("WorkOrderParts", "Reservations", false, "DepartmentId", "InventoryItemId", "ReservedLocationId", "ReservedLotId"); + Index("WorkOrders", "ResponseSla", false, "DepartmentId", "ResponseDueOn", "ResponseBreachedOn"); + Index("WorkOrders", "RepairSla", false, "DepartmentId", "RepairDueOn", "RepairBreachedOn"); + if (Schema.Table(N("InventoryTransactions")).Exists()) + { + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartMovementId")).AsInt32().Nullable(); + Foreign("InventoryTransactions", "WorkOrderMovement", "WorkOrderPartMovementId", "WorkOrderPartMovements"); + Index("InventoryTransactions", "WorkOrderMovement", false, "DepartmentId", "WorkOrderPartMovementId"); + } + } + private void Base(string table) + { + Create.Table(N(table)).WithColumn(N("Id")).AsInt32().PrimaryKey().Identity() + .WithColumn(N("DepartmentId")).AsInt32().NotNullable() + .WithColumn(N("WorkOrderId")).AsInt32().Nullable() + .WithColumn(N("Content")).AsString(int.MaxValue).Nullable() + .WithColumn(N("Revision")).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn(N("CreatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime2().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false); + Create.ForeignKey(N("FK_"+table+"_Department")).FromTable(N(table)).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index(table, "Tenant", true, "DepartmentId", "Id"); + Foreign(table, "Order", "WorkOrderId", "WorkOrders"); + Index(table, "Order", false, "DepartmentId", "WorkOrderId", "Id"); + } + private void Foreign(string table, string suffix, string column, string parent) => Create.ForeignKey(N("FK_"+table+"_"+suffix)).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 Exists(string table) } if (await Exists("DomainEventOutbox")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("DomainEventOutbox")} WHERE {Q("DepartmentId")}=@DepartmentId AND {producerPredicate}", parameters, transaction, cancellationToken: ct)); if (await Exists("AuditLogs")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("AuditLogs")} WHERE {Q("DepartmentId")}=@DepartmentId AND {auditPredicate}", parameters, transaction, cancellationToken: ct)); - foreach (var table in new[] { "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + foreach (var table in new[] { "WorkOrderPartMovements", "WorkOrderVendorCharges", "WorkOrderOperationReceipts", "WorkOrderPolicies", "WorkOrderReportSnapshots", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 7ce47059..bd872207 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -1253,6 +1253,20 @@ public Task CountForRecordAsync(int departmentId, string recordId) $"SELECT COUNT(1) FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, RecordId = recordId }); } + + public Task> GetHeadersByKindInRangeAsync(int departmentId, RmsEvidenceKind kind, DateTime startUtc, DateTime endUtc, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Kind", (int)kind); + parameters.Add("Start", startUtc); + parameters.Add("End", endUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT {Col("RmsEvidenceArtifactId")} AS RmsEvidenceArtifactId, {Col("RecordId")} AS RecordId, {Col("RecordKind")} AS RecordKind, {Col("RevisionId")} AS RevisionId, {Col("Kind")} AS Kind, {Col("Classification")} AS Classification, {Col("CapturedOn")} AS CapturedOn, {Col("SupersededOn")} AS SupersededOn FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Kind")} = {P}Kind AND {Col("CapturedOn")} >= {P}Start AND {Col("CapturedOn")} < {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CapturedOn")}, {Col("RmsEvidenceArtifactId")} {Paging()}", + parameters); + } } /// Due state per (Record, obligation) — registry M0170, RMS-3. diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs index e2ff78ea..68ae843e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs @@ -11,7 +11,7 @@ public sealed partial class WorkOrderRepository { public async Task> QueryMaintenanceAsync(int departmentId, string field = null, object value = null, int skip = 0, bool pendingOnly = false) where T : WorkOrderRow { - if (skip < 0 || field != null && field is not ("RecurrenceId" or "WorkOrderId" or "RequestId" or "InventoryRequestId")) throw new ArgumentException("Invalid maintenance query."); + if (skip < 0 || field != null && field is not ("RecurrenceId" or "WorkOrderId" or "RequestId" or "InventoryRequestId" or "PartId")) throw new ArgumentException("Invalid maintenance query."); var where = $"{Col("DepartmentId")}={P}DepartmentId"; if (field != null) where += $" AND {Col(field)}={P}Value"; if (pendingOnly) diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderOperationsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderOperationsRepository.cs new file mode 100644 index 00000000..d7f0a845 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderOperationsRepository.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class WorkOrderRepository + { + public async Task> AllocatedPartsAsync(int departmentId, string itemId, string locationId, string lotId, string assetId) + { + var sql = $"SELECT {Cols("Id", "WorkOrderId", "DepartmentId", "ReservedLocationId", "IssuedLocationId", "ReservedQuantity", "IssuedQuantity", "ReservedAssetId", "ReservedLotId")} FROM {Tbl("WorkOrderParts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("InventoryItemId")}={P}ItemId AND {Col("Staged")}={Bool(true)} AND {Col("VoidedOn")} IS NULL AND ({Col("ReservedLotId")}={P}LotId OR ({Col("ReservedLotId")} IS NULL AND {P}LotId IS NULL)) AND ({Col("ReservedAssetId")}={P}AssetId OR ({Col("ReservedAssetId")} IS NULL AND {P}AssetId IS NULL)) AND (({Col("ReservedLocationId")}={P}LocationId AND {Col("ReservedQuantity")}>0) OR ({Col("IssuedLocationId")}={P}LocationId AND {Col("IssuedQuantity")}>0))"; + return (await QueryAsync(sql, new { DepartmentId = departmentId, ItemId = itemId, LocationId = locationId, LotId = lotId, AssetId = assetId }, default)).ToList(); + } + public async Task> SlaDueAsync(int departmentId, DateTime now) => (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsDeleted")}={Bool(false)} AND {Col("Status")}<5 AND (({Col("ResponseOn")} IS NULL AND {Col("ResponseDueOn")}<{P}Now AND {Col("ResponseBreachedOn")} IS NULL) OR ({Col("RepairDueOn")}<{P}Now AND {Col("RepairBreachedOn")} IS NULL)) ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, Now = now, Skip = 0, Take = 500 }, default)).ToList(); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs new file mode 100644 index 00000000..462e6e17 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderReportingRepository.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Repositories.DataRepository +{ + public sealed partial class WorkOrderRepository + { + private string ReportScope(WorkOrderReadScope scope, DynamicParameters parameters) + { + if (scope == null || string.IsNullOrWhiteSpace(scope.UserId)) throw new ArgumentException("Invalid work-order scope."); + parameters.Add("UserId", scope.UserId); parameters.Add("AllowedGroup", scope.GroupId); + parameters.Add("Roles", InListValue(scope.RoleIds == null || scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); + return scope.All ? "1=1" : $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; + } + public async Task> ReportOrdersAsync(int departmentId, WorkOrderReadScope scope, WorkOrderReportQuery query, int take, int[] ids = null) + { + if (query == null || query.AfterId < 0 || take < 1 || take > 500) throw new ArgumentException("Invalid report page."); + if (ids?.Length > 500 || ids?.Length == 0) throw new ArgumentException("Invalid report identities."); + var parameters = new DynamicParameters(new { DepartmentId = departmentId, query.FromUtc, query.UntilUtc, query.AfterId, Status = (int?)query.Status, Priority = (int?)query.Priority, query.UnitId, query.GroupId, query.AssetId, Skip = 0, Take = take }); + var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId", $"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}", $"{Col("Id")}>{P}AfterId", ReportScope(scope, parameters) }; + if (ids != null) { parameters.Add("Ids", InListValue(ids)); conditions.Add(InList("Id", "Ids")); } + var cohort = query.PreventiveDueCohort ? $"COALESCE({Col("OriginalDueOn")},{Col("DueOn")})" : Col("CreatedOn"); + if (query.PreventiveDueCohort) conditions.Add($"{Col("Type")}=1"); + if (query.FromUtc.HasValue) conditions.Add($"{cohort}>={P}FromUtc"); + if (query.UntilUtc.HasValue) conditions.Add($"{cohort}<{P}UntilUtc"); + foreach (var item in new[] { (query.Status.HasValue, "Status", "Status"), (query.Priority.HasValue, "Priority", "Priority"), (query.UnitId.HasValue, "TargetUnitId", "UnitId"), (query.GroupId.HasValue, "TargetGroupId", "GroupId"), (query.AssetId != null, "InventoryAssetId", "AssetId") }) + if (item.Item1) conditions.Add(Col(item.Item2) + "=" + P + item.Item3); + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + } + public async Task> ReportChildrenAsync(int departmentId, int[] orderIds, int afterId, int take) where T : WorkOrderRow + { + if (orderIds == null || orderIds.Length == 0 || orderIds.Length > 500 || afterId < 0 || take < 1 || take > 500 || typeof(T) == typeof(WorkOrder)) throw new ArgumentException("Invalid report child page."); + var parameters = new DynamicParameters(new { DepartmentId = departmentId, AfterId = afterId, Skip = 0, Take = take }); + parameters.Add("OrderIds", InListValue(orderIds)); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {Col("Id")}>{P}AfterId ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + } + public async Task CaptureReportSnapshotAsync(WorkOrder row, DateTime recordedOn, int? activityId) + { + Transaction(); + var snapshots = await ReportSnapshotsAsync(row.DepartmentId, new[] { row.Id }, DateTime.MaxValue); + var previous = snapshots.SingleOrDefault(); + if (previous?.Revision >= row.Revision) return; + // Background updates inherit a protected immutable source reference, never its plaintext. + var snapshot = new WorkOrderReportSnapshot { DepartmentId = row.DepartmentId, WorkOrderId = row.Id, Revision = row.Revision, RecordedOn = recordedOn, + SourceActivityId = activityId ?? previous?.SourceActivityId, RecurrenceVersionId = row.RecurrenceVersionId, + SourceType = activityId.HasValue ? row.SourceType : previous?.SourceType ?? (row.Content == null ? row.SourceType : 0), + Status = row.Status, Priority = row.Priority, TargetUnitId = row.TargetUnitId, TargetGroupId = row.TargetGroupId, InventoryAssetId = row.InventoryAssetId, + DueOn = row.DueOn, StartedOn = row.StartedOn, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn, ResponseDueOn = row.ResponseDueOn, RepairDueOn = row.RepairDueOn, ResponseOn = row.ResponseOn, ResponseBreachedOn = row.ResponseBreachedOn, RepairBreachedOn = row.RepairBreachedOn, SlaPolicyRevision = row.SlaPolicyRevision }; + var columns = typeof(WorkOrderReportSnapshot).GetProperties().Select(p => p.Name).Where(n => n != "Id").ToArray(); + await ExecuteAsync($"INSERT INTO {Tbl("WorkOrderReportSnapshots")} ({Cols(columns)}) VALUES ({string.Join(",", columns.Select(c => P + c))})", snapshot, default); + } + public async Task> ReportSnapshotsAsync(int departmentId, int[] orderIds, DateTime asOf) + { + if (orderIds == null || orderIds.Length == 0 || orderIds.Length > 500) throw new ArgumentException("Invalid snapshot page."); + var parameters = new DynamicParameters(new { DepartmentId = departmentId, AsOf = asOf }); parameters.Add("OrderIds", InListValue(orderIds)); + var columns = typeof(WorkOrderReportSnapshot).GetProperties().Select(p => p.Name).ToArray(); + return (await QueryAsync($"SELECT {Cols(columns)} FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY {Col("WorkOrderId")} ORDER BY {Col("RecordedOn")} DESC,{Col("Id")} DESC) AS rn FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("WorkOrderId", "OrderIds")} AND {Col("RecordedOn")}<={P}AsOf) s WHERE rn=1", parameters, default)).ToList(); + } + public async Task> ReportSnapshotHistoryAsync(int departmentId, int orderId, long afterId) + { + if (orderId <= 0 || afterId < 0) throw new ArgumentException("Invalid snapshot history page."); + return (await QueryAsync($"SELECT * FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("WorkOrderId")}={P}OrderId AND {Col("Id")}>{P}AfterId ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, OrderId = orderId, AfterId = afterId, Skip = 0, Take = 500 }, default)).ToList(); + } + public async Task> ReportPacketOrdersAsync(int departmentId, WorkOrderReadScope scope, DateTime asOf, int[] unitIds, string[] assetIds, int afterId) + { + if (unitIds == null || assetIds == null || unitIds.Length > 250 || assetIds.Length > 1000 || afterId < 0) throw new ArgumentException("Invalid packet target page."); + if (unitIds.Length == 0 && assetIds.Length == 0) return new(); + var parameters = new DynamicParameters(new { DepartmentId = departmentId, AsOf = asOf, AfterId = afterId, Skip = 0, Take = 500 }); + parameters.Add("Units", InListValue(unitIds.Length == 0 ? new[] { -1 } : unitIds)); + parameters.Add("Assets", InListValue(assetIds.Length == 0 ? new[] { "" } : assetIds)); + var targets = $"({InList("TargetUnitId", "Units")} OR {InList("InventoryAssetId", "Assets")})"; + var candidates = $"SELECT {Col("Id")} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {targets} UNION SELECT {Col("WorkOrderId")} FROM {Tbl("WorkOrderReportSnapshots")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RecordedOn")}<={P}AsOf AND {targets}"; + return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsDeleted")}={(IsPostgres ? "false" : "0")} AND {Col("CreatedOn")}<={P}AsOf AND {Col("Id")}>{P}AfterId AND {Col("Id")} IN ({candidates}) AND {ReportScope(scope, parameters)} ORDER BY {Col("Id")} {Paging()}", parameters, default)).ToList(); + } + public async Task HasUnrecoverableReportHistoryAsync(int departmentId, WorkOrderReadScope scope, DateTime asOf) + { + var parameters = new DynamicParameters(new { DepartmentId = departmentId, AsOf = asOf }); + return await ScalarAsync($"SELECT CASE WHEN EXISTS(SELECT 1 FROM {Tbl("WorkOrders")} w WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IsDeleted")}={(IsPostgres ? "false" : "0")} AND {Col("CreatedOn")}<={P}AsOf AND {Col("UpdatedOn")}>{P}AsOf AND {ReportScope(scope, parameters)} AND NOT EXISTS (SELECT 1 FROM {Tbl("WorkOrderReportSnapshots")} s WHERE s.{Col("DepartmentId")}=w.{Col("DepartmentId")} AND s.{Col("WorkOrderId")}=w.{Col("Id")} AND s.{Col("RecordedOn")}<={P}AsOf)) THEN 1 ELSE 0 END", parameters, default) == 1; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs index 38aa2ed3..4656409a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -45,7 +45,7 @@ public async Task> ListAsync(int departmentId, WorkOrderReadScop var parameters = new DynamicParameters(new { DepartmentId = departmentId, UserId = scope.UserId, AllowedGroup = scope.GroupId, Status = (int?)filter.Status, Priority = (int?)filter.Priority, UnitId = filter.UnitId, GroupId = filter.GroupId, AssetId = filter.AssetId, ChecklistCompletionId = filter.ChecklistCompletionId, Skip = filter.Page * 50, Take = 51 }); parameters.Add("Roles", InListValue(scope.RoleIds == null || scope.RoleIds.Length == 0 ? new[] { -1 } : scope.RoleIds)); var own = $"({Col("CreatedBy")}={P}UserId OR {Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")} OR {Col("TargetGroupId")}={P}AllowedGroup)"; - var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId" }; + var conditions = new List { $"{Col("DepartmentId")}={P}DepartmentId", $"{Col("IsDeleted")}={(IsPostgres ? "false" : "0")}" }; if (!scope.All) conditions.Add(own); if (filter.AssignedToMe) conditions.Add($"({Col("AssignedToUserId")}={P}UserId OR {InList("AssignedToRoleId", "Roles")})"); foreach (var item in new[] { (filter.Status.HasValue, "Status", "Status"), (filter.Priority.HasValue, "Priority", "Priority"), (filter.UnitId.HasValue, "TargetUnitId", "UnitId"), (filter.GroupId.HasValue, "TargetGroupId", "GroupId"), (filter.AssetId != null, "InventoryAssetId", "AssetId"), (filter.ChecklistCompletionId != null, "SourceChecklistCompletionId", "ChecklistCompletionId") }) diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 852215a9..48a29adb 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -103,5 +103,9 @@ "WorkOrderRecurrenceChanged": 169, "WorkOrderThresholdReached": 170, "WorkOrderDeferred": 171, - "WorkOrderPartChanged": 172 + "WorkOrderPartChanged": 172, + "WorkOrderApprovalChanged": 173, + "WorkOrderSlaBreached": 174, + "WorkOrderVendorChargeChanged": 175, + "WorkOrderPolicyChanged": 176 } diff --git a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs index 5a81a3c7..a4c6de78 100644 --- a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs +++ b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs @@ -24,7 +24,8 @@ public async Task Readiness_PDF_and_manifest_are_immutable_classified_evidence_a CoverageStartUtc = DateTime.UtcNow.AddDays(-30), CoverageEndUtc = DateTime.UtcNow.AddHours(-2), Checklists = new List { new() { OccurrenceId = "source-occurrence", VersionId = "pinned-version", Version = 3, Name = "Original evidence", Target = new ChecklistTarget { Id = "1", Type = ChecklistTargetType.Unit, Name = "Engine 1" } } } }; var checklists = new Mock(); checklists.Setup(c => c.GetReadinessPacketForCallAsync(It.IsAny(), 501, 30)).ReturnsAsync(() => source); - var access = new Mock(); access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(true); + source.WorkOrders.Add(new Resgrid.Model.WorkOrders.ReadinessWorkOrderEvidence { WorkOrderId = 19, Revision = 3, SnapshotId = 29, Title = "Pinned maintenance", UnitId = 1, ActiveSafetyHoldIds = new() { 8 } }); + var access = new Mock(); access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(false); var grant = Mock.Of(g => g.UserId == "author" && g.GrantToken == "synthetic-grant" && !g.IsWorkloadCaller); var pdf = new Mock(); pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(Encoding.ASCII.GetBytes("%PDF-1.4 synthetic evidence")); var adapter = new ReadinessPacketEvidenceAdapter(checklists.Object, access.Object, grant, pdf.Object); @@ -34,9 +35,11 @@ public async Task Readiness_PDF_and_manifest_are_immutable_classified_evidence_a var artifact = await service.CaptureAsync(Request(RmsEvidenceKind.ReadinessPacket)); artifact.Classification.Should().Be((int)RmsEvidenceClassification.Restricted); artifact.ManifestJson.Should().Contain("Original evidence").And.Contain("PdfSha256").And.Contain("ManifestSha256"); + artifact.ManifestJson.Should().Contain("Pinned maintenance").And.Contain("SnapshotId"); var json = artifact.ManifestJson; var checksum = artifact.Checksum; await service.BindToRevisionAsync(Dept, _record.RmsOperationalRecordId, "finalized-revision"); source.Checklists[0].Name = "Later edited source"; source.Checklists.Clear(); + source.WorkOrders.Clear(); var retained = (await service.GetForRecordAsync(Dept, _record.RmsOperationalRecordId, "finalized-revision"))[0]; retained.ManifestJson.Should().Be(json); retained.Checksum.Should().Be(checksum); (await service.VerifyAsync(Dept, retained.RmsEvidenceArtifactId)).Should().BeTrue(); checklists.Verify(c => c.GetReadinessPacketForCallAsync(It.Is(a => a.UserId == "author" && a.GrantToken == "synthetic-grant"), 501, 30), Times.Exactly(2)); diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs new file mode 100644 index 00000000..376617b0 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsReadinessTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// + /// RMS-6 readiness dashboard: the checklists, maintenance and inventory modules answer through their own seams and + /// authorize the viewer themselves; RMS joins their answers on the unit id, adds the readiness-packet evidence it + /// captured, and never counts what a module refused. + /// + [TestFixture] + public class RecordsAnalyticsReadinessTests + { + private static readonly DateTime T0 = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Start = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime End = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + private RmsPreventionHarness _h; + private Mock _checklists; + private Mock _access; + private Mock _workOrders; + private Mock _inventory; + private Mock _inventoryAuth; + private Mock _evidence; + private Mock _participants; + private Mock _unitsService; + private List _records; + private List _units; + private List _headers; + private List _assets; + private List _locations; + private List _issuances; + private List _orders; + private ChecklistComplianceSummary _summary; + private RecordsAnalyticsService _svc; + + [SetUp] + public void SetUp() + { + _h = new RmsPreventionHarness(); + _checklists = new Mock(); _access = new Mock(); _workOrders = new Mock(); + _inventory = new Mock(); _inventoryAuth = new Mock(); _evidence = new Mock(); + _participants = new Mock(); _unitsService = new Mock(); + _records = new List(); _units = new List(); _headers = new List(); + _assets = new List(); _locations = new List(); _issuances = new List(); _orders = new List(); + _summary = new ChecklistComplianceSummary(); + + _h.Records.Setup(r => r.GetFinalizedInRangeAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int d, IEnumerable s, DateTime a, DateTime b, int t) => _records.ToList()); + _h.Units.Setup(u => u.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToHashSet(); return _units.Where(u => set.Contains(u.RevisionId)).ToList(); }); + _participants.Setup(p => p.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync(new List()); + _h.Reports.Setup(r => r.QueryAsync(Dept, It.IsAny())).ReturnsAsync(new List()); + _evidence.Setup(e => e.GetHeadersByKindInRangeAsync(Dept, RmsEvidenceKind.ReadinessPacket, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(() => _headers.ToList()); + _unitsService.Setup(u => u.GetUnitsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Unit { UnitId = 1, Name = "Engine 1", StationGroupId = 10 }, new Unit { UnitId = 2, Name = "Medic 2", StationGroupId = 20 }, new Unit { UnitId = 3, Name = "Brush 3" } }); + _unitsService.Setup(u => u.GetAllLatestStatusForUnitsByDepartmentIdAsync(Dept)).ReturnsAsync(new List { new UnitState { UnitId = 1, State = (int)UnitStateTypes.Available, Timestamp = T0 }, new UnitState { UnitId = 2, State = (int)UnitStateTypes.OutOfService, Timestamp = T0.AddDays(1) } }); + _access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(true); + _inventoryAuth.Setup(a => a.IsEnabledAsync(Dept)).ReturnsAsync(true); + _checklists.Setup(c => c.GetComplianceSummaryAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistReportQuery q) => { _summary.FromUtc = q.FromUtc; _summary.UntilUtc = q.UntilUtc; return _summary; }); + _workOrders.Setup(w => w.GetWorkOrderStatsAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, WorkOrderReportQuery q) => Stats(q)); + _workOrders.Setup(w => w.GetWorkOrderHistoryAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, WorkOrderReportQuery q) => + { + var rows = _orders.Where(o => o.Order.Id > q.AfterId).OrderBy(o => o.Order.Id).ToList(); + return new WorkOrderHistoryPage { Items = rows.Take(50).ToList(), NextAfterId = rows.Count > 50 ? rows[49].Order.Id : (int?)null }; + }); + _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, int page) => Page(_assets, page)); + _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, int page) => Page(_locations, page)); + _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, int page) => Page(_issuances, page)); + var groups = new Mock(); groups.Setup(g => g.GetAllGroupsForDepartmentAsync(Dept)).ReturnsAsync(new List { new DepartmentGroup { DepartmentGroupId = 10, Name = "Station 10" }, new DepartmentGroup { DepartmentGroupId = 20, Name = "Station 20" } }); + var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "UTC" }); + + _svc = new RecordsAnalyticsService(_h.Gate, _h.Authorization.Object, _h.Records.Object, _h.Units.Object, _participants.Object, Mock.Of(), Mock.Of(), _h.Reports.Object, + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), _h.Inspections, _h.Programs, _h.Violations, _h.Permits, _h.PermitTypes, _h.Hydrants, _h.FlowTests, _h.Occupancies, _h.Crr, + _unitsService.Object, groups.Object, departments.Object, + _h.Flags.Object, _checklists.Object, _access.Object, _workOrders.Object, _inventory.Object, _inventoryAuth.Object, _evidence.Object); + } + + private static InventoryPage Page(List rows, int page) where T : InventoryRow => new InventoryPage { Items = rows.Skip(page * 500).Take(500).ToList(), HasMore = rows.Count > (page + 1) * 500 }; + + private WorkOrderStats Stats(WorkOrderReportQuery q) + { + var open = _orders.Where(o => (int)o.Order.Status <= 5).ToList(); + var stats = new WorkOrderStats { FromUtc = q.FromUtc ?? Start, UntilUtc = q.UntilUtc ?? End, AsOfUtc = DateTime.UtcNow, Total = _orders.Count, Open = open.Count, Overdue = open.Count(o => (int)o.Order.Status < 5 && o.Order.DueOn < DateTime.UtcNow), MeanTimeToRepairHours = 6.4m, RepairSamples = 3, MissingRepairTimes = 1 }; + foreach (var o in open) stats.OpenByPriority[(int)o.Order.Priority]++; + stats.OpenByAge[0] = open.Count; + stats.Costs.Add(new WorkOrderCostTotal { Currency = "USD", Labor = 120.5m, Parts = 80m, UnknownParts = 1 }); + return stats; + } + + private static RecordsAnalyticsQuery Q() => new RecordsAnalyticsQuery { Start = Start, End = End }; + + private static ChecklistComplianceGroup Group(ChecklistTargetType type, string id, string name, int expected, int completed, int onTime, int missed, int skipped = 0, int? groupId = null) + => new ChecklistComplianceGroup { Target = new ChecklistTarget { Type = type, Id = id, Name = name, GroupId = groupId }, Expected = expected, Completed = completed, OnTime = onTime, Missed = missed, Skipped = skipped }; + + private void Order(int id, int? unitId, WorkOrderStatus status, WorkOrderPriority priority = WorkOrderPriority.Normal, DateTime? due = null) + => _orders.Add(new WorkOrderReportEntry { Order = new WorkOrderSummary { Id = id, UnitId = unitId, Status = status, Priority = priority, DueOn = due, CreatedOn = T0 } }); + + private void Record(string id, int? callId, int unitId) + { + _records.Add(new RmsOperationalRecord { RmsOperationalRecordId = id, DepartmentId = Dept, DefinitionKey = RmsDefinitionKeys.Run, RecordType = 1, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rev-" + id, StartedOn = T0, CallId = callId, CreatedOn = T0, FinalizedOn = T0, AuthorUserId = "u-a" }); + _units.Add(new RmsRecordUnitResponse { RecordId = id, RevisionId = "rev-" + id, DepartmentId = Dept, UnitId = unitId }); + } + + [Test] + public async Task Readiness_joins_checklists_work_orders_equipment_and_evidence_on_the_unit_board() + { + _summary.Groups.AddRange(new[] + { + Group(ChecklistTargetType.Unit, "1", "Engine 1", 10, 9, 8, 1), Group(ChecklistTargetType.Unit, "2", "Medic 2", 10, 6, 5, 4, 1), + Group(ChecklistTargetType.Group, "10", "Station 10", 4, 4, 4, 0), Group(ChecklistTargetType.Personnel, "u-a", "Ann Author", 3, 2, 2, 1), Group(ChecklistTargetType.InventoryAsset, "asset-1", "SCBA 12", 2, 2, 2, 0) + }); + _summary.Trend.Add(new ChecklistMissedTrend { DayUtc = T0, Expected = 5, Missed = 1 }); + _summary.UnavailableSources.Add("contractor-deployments"); + Order(1, 1, WorkOrderStatus.InProgress, WorkOrderPriority.High, DateTime.UtcNow.AddDays(-2)); Order(2, 1, WorkOrderStatus.Closed); Order(3, 2, WorkOrderStatus.OnHold, WorkOrderPriority.Emergency); Order(4, null, WorkOrderStatus.Requested); Order(5, 3, WorkOrderStatus.Completed); + _locations.Add(new InventoryLocation { Id = "loc-e1", DepartmentId = Dept, UnitId = 1 }); _locations.Add(new InventoryLocation { Id = "loc-bay", DepartmentId = Dept, ParentLocationId = "loc-e1" }); _locations.Add(new InventoryLocation { Id = "loc-store", DepartmentId = Dept, GroupId = 10 }); + _assets.Add(new InventoryAsset { Id = "a1", DepartmentId = Dept, Status = (int)InventoryAssetStatus.InService, CurrentLocationId = "loc-bay" }); + _assets.Add(new InventoryAsset { Id = "a2", DepartmentId = Dept, Status = (int)InventoryAssetStatus.OutForRepair, CurrentLocationId = "loc-e1" }); + _assets.Add(new InventoryAsset { Id = "a3", DepartmentId = Dept, Status = (int)InventoryAssetStatus.InService, CurrentLocationId = "loc-store", ExpiresOn = DateTime.UtcNow.AddDays(-1) }); + _assets.Add(new InventoryAsset { Id = "a4", DepartmentId = Dept, Status = (int)InventoryAssetStatus.Issued, CurrentLocationId = "loc-store", ExpiresOn = DateTime.UtcNow.AddDays(10) }); + _assets.Add(new InventoryAsset { Id = "a5", DepartmentId = Dept, Status = (int)InventoryAssetStatus.Retired, CurrentLocationId = "loc-store", IsDeleted = true }); + _issuances.Add(new InventoryIssuance { Id = "i1", DepartmentId = Dept, AssetId = "a4", IssuedToUnitId = 2, Status = (int)InventoryIssuanceStatus.Outstanding, ExpectedReturnOn = DateTime.UtcNow.AddDays(-3), IssuedOn = T0 }); + _issuances.Add(new InventoryIssuance { Id = "i2", DepartmentId = Dept, AssetId = "a1", IssuedToUnitId = 1, Status = (int)InventoryIssuanceStatus.Returned, ReturnedOn = T0, IssuedOn = T0 }); + Record("r1", 100, 1); Record("r2", 101, 2); Record("r3", null, 3); + _headers.Add(new RmsEvidenceArtifactHeader { RmsEvidenceArtifactId = "e1", RecordId = "r1", Kind = 1, CapturedOn = T0.AddDays(1) }); + _headers.Add(new RmsEvidenceArtifactHeader { RmsEvidenceArtifactId = "e2", RecordId = "r1", Kind = 1, CapturedOn = T0, SupersededOn = T0.AddDays(1) }); + _headers.Add(new RmsEvidenceArtifactHeader { RmsEvidenceArtifactId = "e3", RecordId = "elsewhere", Kind = 1, CapturedOn = T0.AddDays(2) }); + + var r = await _svc.GetReadinessAsync(Dept, Member, Q()); + + r.ChecklistsEnabled.Should().BeTrue(); r.MaintenanceEnabled.Should().BeTrue(); r.InventoryEnabled.Should().BeTrue(); + r.ChecklistsForbidden.Should().BeFalse(); r.MaintenanceForbidden.Should().BeFalse(); r.InventoryForbidden.Should().BeFalse(); + + var ck = r.Checklists; + ck.CohortStart.Should().Be(Start, "a 62-day window fits inside the module's 93-day cohort"); + r.Warnings.Should().BeEmpty(); + ck.Expected.Should().Be(29); ck.Completed.Should().Be(23); ck.OnTime.Should().Be(21); ck.Missed.Should().Be(6); ck.Skipped.Should().Be(1); + ck.CompletionRatePercent.Should().Be(79.3); ck.OnTimePercent.Should().Be(72.4); + ck.ByUnit.Should().HaveCount(2); ck.ByUnit[0].Label.Should().Be("Medic 2", "worst first"); ck.ByUnit[0].Missed.Should().Be(4); + ck.ByStationGroup.Single().Label.Should().Be("Station 10"); ck.ByPerson.Single().Label.Should().Be("Ann Author"); ck.ByAsset.Single().Label.Should().Be("SCBA 12"); + ck.Trend.Single().Missed.Should().Be(1); ck.UnavailableSources.Should().ContainSingle("contractor-deployments"); + + var wo = r.WorkOrders; + wo.Total.Should().Be(5); wo.Open.Should().Be(4); wo.Overdue.Should().Be(1); wo.OnHold.Should().Be(1); + wo.MeanTimeToRepairHours.Should().Be(6.4); wo.RepairSamples.Should().Be(3); + wo.OpenByPriority.Single(p => p.Label == "Emergency").Count.Should().Be(1); + wo.OpenByAge[0].Label.Should().Be("0-6 days"); wo.OpenByAge[0].Count.Should().Be(4); + wo.Costs.Single().Should().Match(c => c.Currency == "USD" && c.Total == 200.5m && c.Unknown == 1); + wo.ByUnit.Should().HaveCount(3, "the order without a unit is not on the board"); + wo.ByUnit[0].Label.Should().Be("Engine 1", "the overdue unit sorts first"); wo.ByUnit[0].Open.Should().Be(1); wo.ByUnit[0].Total.Should().Be(2); + wo.HistoryTruncated.Should().BeFalse(); + + var eq = r.Equipment; + eq.Assets.Should().Be(4, "a deleted asset is not counted"); eq.InService.Should().Be(2); eq.Issued.Should().Be(1); eq.OutForRepair.Should().Be(1); + eq.Expired.Should().Be(1); eq.ExpiringWithin30Days.Should().Be(1); eq.IssuancesOutstanding.Should().Be(1); eq.IssuancesOverdueReturn.Should().Be(1); + eq.ByStatus.Should().Contain(s => s.Label == "Out For Repair" && s.Count == 1); + eq.ByUnit.Single(u => u.Label == "Engine 1").Assets.Should().Be(2, "a1 sits in a bay under the engine's location, a2 at the engine itself"); + eq.ByUnit.Single(u => u.Label == "Medic 2").Should().Match(u => u.Assets == 1 && u.Issued == 1 && u.OverdueReturns == 1, "a4 is issued to the medic"); + + r.Evidence.PacketsCaptured.Should().Be(2, "the superseded packet is not current"); + r.Evidence.CallLinkedRecords.Should().Be(2); r.Evidence.RecordsWithPacket.Should().Be(1); r.Evidence.CoveragePercent.Should().Be(50); + + r.Units.Should().HaveCount(3); + var e1 = r.Units.Single(u => u.UnitId == 1); var m2 = r.Units.Single(u => u.UnitId == 2); var b3 = r.Units.Single(u => u.UnitId == 3); + e1.State.Should().Be((int)UnitStateTypes.Available); e1.StationGroupLabel.Should().Be("Station 10"); + e1.ChecklistExpected.Should().Be(10); e1.ChecklistMissed.Should().Be(1); e1.ChecklistCompletionRatePercent.Should().Be(90); + e1.OpenWorkOrders.Should().Be(1); e1.OverdueWorkOrders.Should().Be(1); e1.AssetsAssigned.Should().Be(2); e1.AssetsOutForRepair.Should().Be(1); e1.ReadinessPackets.Should().Be(1); + m2.State.Should().Be((int)UnitStateTypes.OutOfService); m2.OnHoldWorkOrders.Should().Be(1); m2.AssetsAssigned.Should().Be(1); m2.ReadinessPackets.Should().Be(0); + b3.NeedsAttention.Should().BeFalse(); b3.StationGroupLabel.Should().BeNull(); + r.Units.Take(2).Should().OnlyContain(u => u.NeedsAttention, "units needing attention sort first"); + r.RecordsRead.Should().Be(3); + } + + [Test] + public async Task Modules_that_are_off_or_that_refuse_the_viewer_contribute_nothing() + { + _access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(false); + _h.DisabledFlags.Add(FeatureFlagKeys.MaintenanceWorkOrders); + _inventory.Setup(i => i.ListAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InventoryException(403, "MembershipRequired")); + + var r = await _svc.GetReadinessAsync(Dept, Member, Q()); + + r.ChecklistsEnabled.Should().BeFalse(); r.Checklists.Should().BeNull(); + r.MaintenanceEnabled.Should().BeFalse(); r.WorkOrders.Should().BeNull(); + r.InventoryEnabled.Should().BeTrue(); r.InventoryForbidden.Should().BeTrue(); r.Equipment.Should().BeNull(); + r.Warnings.Should().BeEmpty("a module's own refusal is a flag, not a warning"); + _checklists.Verify(c => c.GetComplianceSummaryAsync(It.IsAny(), It.IsAny()), Times.Never); + _workOrders.Verify(w => w.GetWorkOrderStatsAsync(It.IsAny(), It.IsAny()), Times.Never); + + _access.Setup(a => a.CanUseChecklistsAsync(Dept)).ReturnsAsync(true); + _checklists.Setup(c => c.GetComplianceSummaryAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new ChecklistException(403, "Checklist management permission is required.")); + _h.DisabledFlags.Clear(); + _workOrders.Setup(w => w.GetWorkOrderStatsAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new WorkOrderException(402, "ReadinessProRequired")); + var again = await _svc.GetReadinessAsync(Dept, Member, Q()); + again.ChecklistsEnabled.Should().BeTrue(); again.ChecklistsForbidden.Should().BeTrue(); again.Checklists.Should().BeNull(); + again.MaintenanceEnabled.Should().BeFalse("a 402 from the module means the paid module is not available, not that the viewer is refused"); again.WorkOrders.Should().BeNull(); + again.Units.Should().HaveCount(3, "the unit board still lists the units with their states"); + } + + [Test] + public async Task Group_scoped_viewer_sees_only_their_units_and_the_packets_on_records_they_can_open() + { + _h.Authorization.Setup(a => a.GetVisibleGroupIdsAsync(Member, Dept)).ReturnsAsync(new List { 10 }); + _summary.Groups.AddRange(new[] { Group(ChecklistTargetType.Unit, "1", "Engine 1", 5, 5, 5, 0), Group(ChecklistTargetType.Unit, "2", "Medic 2", 5, 1, 1, 4), Group(ChecklistTargetType.Group, "20", "Station 20", 3, 0, 0, 3), Group(ChecklistTargetType.Personnel, "u-z", "Zed", 1, 0, 0, 1, groupId: 20) }); + Order(1, 2, WorkOrderStatus.InProgress, due: DateTime.UtcNow.AddDays(-1)); + Record("mine", 100, 1); _records[0].AuthorUserId = Member; Record("theirs", 101, 2); + _headers.Add(new RmsEvidenceArtifactHeader { RmsEvidenceArtifactId = "e1", RecordId = "mine", Kind = 1, CapturedOn = T0 }); + _headers.Add(new RmsEvidenceArtifactHeader { RmsEvidenceArtifactId = "e2", RecordId = "theirs", Kind = 1, CapturedOn = T0 }); + _h.Records.Setup(r => r.GetFinalizedInRangeAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(() => _records.ToList()); + var scopes = new Mock(); scopes.Setup(s => s.GetForRecordsAsync(Dept, It.IsAny>())).ReturnsAsync(new List()); + _svc = new RecordsAnalyticsService(_h.Gate, _h.Authorization.Object, _h.Records.Object, _h.Units.Object, _participants.Object, scopes.Object, Mock.Of(), _h.Reports.Object, + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), _h.Inspections, _h.Programs, _h.Violations, _h.Permits, _h.PermitTypes, _h.Hydrants, _h.FlowTests, _h.Occupancies, _h.Crr, + _unitsService.Object, Mock.Of(), Mock.Of(), + _h.Flags.Object, _checklists.Object, _access.Object, _workOrders.Object, _inventory.Object, _inventoryAuth.Object, _evidence.Object); + + var r = await _svc.GetReadinessAsync(Dept, Member, Q()); + + r.GroupScoped.Should().BeTrue(); + r.Units.Select(u => u.UnitId).Should().BeEquivalentTo(new[] { 1, 3 }, "station 20's medic is outside the viewer's scope; the unit without a group stays visible"); + r.Checklists.ByUnit.Should().ContainSingle(u => u.Key == "1"); + r.Checklists.ByStationGroup.Should().BeEmpty("station 20 is not visible"); + r.Checklists.ByPerson.Should().BeEmpty("the person target belongs to station 20"); + r.Checklists.Missed.Should().Be(0); + r.WorkOrders.ByUnit.Should().BeEmpty("the only order sits on the invisible medic"); + r.WorkOrders.Overdue.Should().Be(1, "the module's own totals are what the module answered for this viewer"); + r.Evidence.PacketsCaptured.Should().Be(1, "only the packet on a Record the viewer can open"); + r.Evidence.CallLinkedRecords.Should().Be(1); r.Evidence.CoveragePercent.Should().Be(100); + r.Units.Single(u => u.UnitId == 1).ReadinessPackets.Should().Be(1); + } + + [Test] + public async Task Checklist_cohort_is_the_tail_of_a_window_wider_than_93_days() + { + var wide = await _svc.GetReadinessAsync(Dept, Member, new RecordsAnalyticsQuery { Start = End.AddDays(-120), End = End }); + + wide.Checklists.CohortStart.Should().Be(End.AddDays(-RecordsAnalyticsService.ChecklistCohortDays), "the module measures at most 93 days, so the cohort is the tail of the window"); + wide.Checklists.CohortEnd.Should().Be(End); + wide.Warnings.Should().ContainSingle(w => w.Contains("93 days")); + _checklists.Verify(c => c.GetComplianceSummaryAsync(It.Is(a => a.DepartmentId == Dept && a.UserId == Member), It.Is(q => q.FromUtc == End.AddDays(-93) && q.UntilUtc == End)), Times.Once); + } + + [Test] + public async Task Inventory_pages_stop_at_the_cap_and_say_so() + { + for (var i = 0; i < RecordsAnalyticsService.InventoryPageCap * 500 + 1; i++) _assets.Add(new InventoryAsset { Id = "a" + i, DepartmentId = Dept, Status = (int)InventoryAssetStatus.InService }); + + var r = await _svc.GetReadinessAsync(Dept, Member, Q()); + + r.Equipment.Truncated.Should().BeTrue(); + r.Equipment.Assets.Should().Be(RecordsAnalyticsService.InventoryPageCap * 500); + r.Warnings.Should().ContainSingle(w => w.Contains("Equipment covers the first")); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs index 3c8b399d..5aa4230d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs @@ -73,7 +73,8 @@ public void SetUp() _svc = new RecordsAnalyticsService(_h.Gate, _h.Authorization.Object, _h.Records.Object, _h.Units.Object, _participants.Object, _scopes.Object, _details.Object, _h.Reports.Object, _reportUnits.Object, _incidentTypes.Object, _revisions.Object, _dueStates.Object, _h.Inspections, _h.Programs, _h.Violations, _h.Permits, _h.PermitTypes, _h.Hydrants, _h.FlowTests, _h.Occupancies, _h.Crr, - _unitsService.Object, _groupsService.Object, _departmentsService.Object); + _unitsService.Object, _groupsService.Object, _departmentsService.Object, + _h.Flags.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); } private static RecordsAnalyticsQuery Q() => new RecordsAnalyticsQuery { Start = Start, End = End }; diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs index 3c47e0a2..0502e2ce 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs @@ -79,14 +79,14 @@ public async Task P1M4_open_windows_and_on_demand_checks_do_not_inflate_the_deno var report = await _service.GetComplianceSummaryAsync(_actor, Month()); report.Entries.Where(e => e.DueUtc > DateTime.UtcNow || !e.Scheduled).Should().OnlyContain(e => !e.Expected && !e.Missed); } - private void PacketService(Mock assets, DateTime? callAt = null, int department = 77) + private void PacketService(Mock assets, DateTime? callAt = null, int department = 77, IWorkOrderReportingService workOrders = null) { var call = new Call { CallId = 101, DepartmentId = department, LoggedOn = callAt ?? ReportMonth.AddDays(30), Name = "CALL-PHI-CANARY", UnitDispatches = new List { new() { CallId = 101, UnitId = 1, CallDispatchUnitId = 21, DispatchedOn = ReportMonth.AddDays(30) } } }; var calls = new Mock(); calls.Setup(c => c.GetCallByIdAsync(101, true)).ReturnsAsync(call); calls.Setup(c => c.PopulateCallData(call, false, false, false, false, true, false, false, false, false, false)).ReturnsAsync(call); var auth = new Mock(); auth.Setup(a => a.CanUserViewCallAsync("author", 101)).ReturnsAsync(true); _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, new(() => _read.Object), new(() => _write.Object), _scanner.Object, - reportCalls: new(() => calls.Object), reportAuthorization: new(() => auth.Object), historicalAssets: assets?.Object); + reportCalls: new(() => calls.Object), reportAuthorization: new(() => auth.Object), historicalAssets: assets?.Object, workOrderReports: workOrders == null ? null : new(() => workOrders)); } [Test] public async Task P1M4_packet_includes_all_dispatched_unit_and_historically_issued_asset_checks_without_call_narrative() diff --git a/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs new file mode 100644 index 00000000..70fffefd --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ChecklistP2M4Tests.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; +using Resgrid.Services.Records.Evidence; + +namespace Resgrid.Tests.Services +{ + public partial class ChecklistWorkflowTests + { + [Test] + public async Task P2M4_packet_emits_provenance_and_rechecks_work_order_disclosure_after_PDF_generation() + { + await SeedReportMonth(); + var reports = new Mock(); + var section = new ReadinessWorkOrderSection { HistoryUnavailable = true, RestrictedScope = true, Items = { new() { WorkOrderId = 19, Revision = 3, SnapshotId = 99, UnitId = 1, Title = "Synthetic immutable maintenance", SourceActivityId = 27 } } }; + reports.Setup(r => r.ReadinessEvidenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(section); + PacketService(null, workOrders: reports.Object); _access.Setup(a => a.CanUseChecklistsAsync(77)).ReturnsAsync(false); + var packet = await _service.GetReadinessPacketForCallAsync(_actor, 101); + packet.WorkOrders.Should().ContainSingle(); packet.UnavailableSources.Should().Contain("HistoricalWorkOrdersUnavailable").And.Contain("RestrictedWorkOrders").And.NotContain("WorkOrdersUnavailable"); + reports.Verify(r => r.ReadinessEvidenceAsync(It.Is(a => a.UserId == _actor.UserId && a.DepartmentId == 77), packet.CoverageStartUtc, packet.CallUtc, It.Is(ids => ids.SequenceEqual(new[] { 1 })), It.IsAny()), Times.Once); + var pdf = new Mock(); pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns((string html) => + { + html.Should().Contain("Synthetic immutable maintenance"); + reports.Setup(r => r.ReadinessEvidenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ReadinessWorkOrderSection()); + return Encoding.ASCII.GetBytes("%PDF-1.4 synthetic"); + }); + var grant = Mock.Of(g => g.UserId == _actor.UserId && !g.IsWorkloadCaller); + var adapter = new ReadinessPacketEvidenceAdapter(_service, _access.Object, grant, pdf.Object); + await FluentActions.Awaiting(() => adapter.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = 77, CallId = 101, CapturedByUserId = _actor.UserId })).Should().ThrowAsync(); + reports.Setup(r => r.ReadinessEvidenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new WorkOrderException(403, "ProtectedDataRequired")); + (await FluentActions.Awaiting(() => _service.GetReadinessPacketForCallAsync(_actor, 101)).Should().ThrowAsync()).Which.Message.Should().StartWith("Unlock"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs index 77a66a79..51eeb617 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, 73, 167, 168, 169, 170, 171, 172, 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166); + ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 73, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 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/InventoryDatabaseFixture.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs index a60df2d2..0c6b4a8a 100644 --- a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs @@ -112,7 +112,9 @@ public async Task CreateOnlyAnIsolatedInventoryDatabase() _type == DatabaseTypes.Postgres ? new M0202_AddInventoryCountsAndAlertsPg() : new M0202_AddInventoryCountsAndAlerts(), _type == DatabaseTypes.Postgres ? new M0203_AddWorkOrderIntegrationsPg() : new M0203_AddWorkOrderIntegrations(), _type == DatabaseTypes.Postgres ? new M0204_AddWorkOrderRecurrencesPg() : new M0204_AddWorkOrderRecurrences(), - _type == DatabaseTypes.Postgres ? new M0205_EnforceInventoryTenantHoldersPg() : new M0205_EnforceInventoryTenantHolders() + _type == DatabaseTypes.Postgres ? new M0205_EnforceInventoryTenantHoldersPg() : new M0205_EnforceInventoryTenantHolders(), + _type == DatabaseTypes.Postgres ? new M0206_AddWorkOrderReportingPg() : new M0206_AddWorkOrderReporting(), + _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { diff --git a/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs index 78ad171c..541b2e9b 100644 --- a/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs @@ -14,7 +14,8 @@ namespace Resgrid.Tests.Services { public partial class InventoryDatabaseTests { - [Test] + // Upgrade reversals run before service cases create evidence guarded by later migrations. + [Test, Order(1)] public async Task Tenant_holder_upgrade_rejects_existing_cross_department_links_without_rewriting_inventory() { var runner = _runner.GetRequiredService(); diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs index 816d7081..85185929 100644 --- a/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs @@ -25,7 +25,7 @@ public partial class InventoryDatabaseTests private (WorkOrdersService Orders, InventoryModernizationService Inventory, Mock Access) MaintenancePair(UnitOfWork uow) { var orders = new WorkOrderRepository(Connections(), Configuration(), uow, Mock.Of()); - var auth = new Mock(); auth.Setup(a=>a.ScopeAsync(It.IsAny())).ReturnsAsync(new WorkOrderReadScope { All=true }); + var auth = new Mock(); auth.Setup(a=>a.ScopeAsync(It.IsAny())).ReturnsAsync((ChecklistActor actor)=>new WorkOrderReadScope { All=true, UserId=actor.UserId }); auth.Setup(a=>a.CanManageAsync(It.IsAny(),It.IsAny())).ReturnsAsync(true); auth.Setup(a=>a.CanContributeAsync(It.IsAny(),It.IsAny())).ReturnsAsync(true); auth.Setup(a=>a.RecipientsAsync(It.IsAny(),It.IsAny())).ReturnsAsync(new List()); diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs new file mode 100644 index 00000000..378bb632 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryWorkOrderOperationsDatabaseTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class InventoryDatabaseTests + { + [TestCase(false), TestCase(true)] + public async Task Operations_reserved_parts_issue_consume_return_and_release_exact_balances(bool controlled) + { + await MarkMigratedAsync(); using var uow=new UnitOfWork(Connections()); var pair=MaintenancePair(uow); + var actor=new InventoryActor {DepartmentId=77,UserId="inventory-test-author"}; + var witness=new InventoryActor {DepartmentId=77,UserId="inventory-test-other"}; + var principal=new ChecklistActor {DepartmentId=77,UserId=actor.UserId}; + var item=await pair.Inventory.SaveItemAsync(actor,new() {IsControlledSubstance=controlled,Details=new() {Name="Synthetic reserved component",UnitOfMeasure="each",CurrencyCode="USD"}}); + var source=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic store",Type=InventoryLocationType.Facility}); + var destination=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic workshop",Type=InventoryLocationType.Facility}); + var receipt=new InventoryCommand {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,ToLocationId=source.Id,Quantity=10,UnitCost=4.25m,Type=InventoryTransactionType.Receive}}}; + await pair.Inventory.PostTransactionAsync(actor,receipt); if(controlled) await pair.Inventory.WitnessAsync(witness,receipt.RequestId,"Receipt verified"); + var order=await pair.Orders.CreateAsync(principal,new() {RequestId=Guid.NewGuid().ToString("D"),Content=new() {Title="Synthetic readiness repair"}}); + await pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=order.Order.Revision,Status=WorkOrderStatus.Accepted}); + order=await pair.Orders.GetAsync(principal,order.Order.Id); + var reserve=new WorkOrderPartInput {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),InventoryItemId=item.Id,InventoryLocationId=source.Id,Content=new() {Description="Synthetic component",Quantity=6,UnitCost=999}}; + await pair.Orders.ReservePartAsync(principal,order.Order.Id,reserve); await pair.Orders.ReservePartAsync(principal,order.Order.Id,reserve); + order=await pair.Orders.GetAsync(principal,order.Order.Id); var part=order.Parts.Single(); part.Staged.Should().BeTrue(); part.ReservedQuantity.Should().Be(6); part.Content.UnitCost.Should().Be(4.25m); + var steal=new InventoryCommand {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,FromLocationId=source.Id,Quantity=5,Type=InventoryTransactionType.Consume}}}; + if(controlled) { await pair.Inventory.PostTransactionAsync(actor,steal); await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(witness,steal.RequestId,"Independent but reserved")).Should().ThrowAsync(); } + else await FluentActions.Awaiting(()=>pair.Inventory.PostTransactionAsync(actor,steal)).Should().ThrowAsync(); + (await Store(uow).ListAsync(77)).Single().Quantity.Should().Be(10); + await FluentActions.Awaiting(()=>pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=order.Order.Revision,Status=WorkOrderStatus.Cancelled,Reason="Must settle parts first"})).Should().ThrowAsync(); + async Task Move(WorkOrderPartMovementKind kind,decimal quantity) + { + var current=await pair.Orders.GetAsync(principal,order.Order.Id); + var input=new WorkOrderPartMovementInput {Revision=current.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),Kind=kind,Quantity=quantity,LocationId=kind==WorkOrderPartMovementKind.Issue?destination.Id:null,Reason="Synthetic movement"}; + await pair.Orders.MovePartAsync(principal,order.Order.Id,part.Id,input); + if(controlled && kind!=WorkOrderPartMovementKind.ReleaseReservation) + { + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(actor,input.RequestId,"Self witness")).Should().ThrowAsync(); + await pair.Inventory.WitnessAsync(witness,input.RequestId,"Independent movement"); + } + await pair.Orders.MovePartAsync(principal,order.Order.Id,part.Id,input); + } + await Move(WorkOrderPartMovementKind.Issue,4); + part=(await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Single(); part.ReservedQuantity.Should().Be(2); part.IssuedQuantity.Should().Be(4); + await Move(WorkOrderPartMovementKind.Consume,1.5m); await Move(WorkOrderPartMovementKind.ReturnUnused,2.5m); await Move(WorkOrderPartMovementKind.ReleaseReservation,2); + order=await pair.Orders.GetAsync(principal,order.Order.Id); part=order.Parts.Single(); part.ReservedQuantity.Should().Be(0); part.IssuedQuantity.Should().Be(0); part.ConsumedQuantity.Should().Be(1.5m); part.ReturnedQuantity.Should().Be(2.5m); part.Content.ConsumedCost.Should().Be(6.375m); + var stocks=await Store(uow).ListAsync(77); stocks.Single(s=>s.LocationId==source.Id).Quantity.Should().Be(8.5m); stocks.Single(s=>s.LocationId==destination.Id).Quantity.Should().Be(0); + var ledger=await Store(uow).ListAsync(77); ledger.Where(t=>t.WorkOrderPartMovementId.HasValue).Should().HaveCount(3); + (await pair.Orders.PartMovementsAsync(principal,order.Order.Id)).Should().HaveCount(5); + await pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=order.Order.Revision,Status=WorkOrderStatus.Cancelled,Reason="Settled"}); + } + [Test] + public async Task Operations_serialized_reservation_prevents_transfer_and_unused_return_restores_source() + { + await MarkMigratedAsync(); using var uow=new UnitOfWork(Connections()); var pair=MaintenancePair(uow); + var actor=new InventoryActor {DepartmentId=77,UserId="inventory-test-author"}; var principal=new ChecklistActor {DepartmentId=77,UserId=actor.UserId}; + var item=await pair.Inventory.SaveItemAsync(actor,new() {TrackingMode=InventoryTrackingMode.Serialized,Details=new() {Name="Synthetic spare",UnitOfMeasure="each"}}); + var source=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic source",Type=InventoryLocationType.Facility}); + var target=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic target",Type=InventoryLocationType.Facility}); + var asset=await pair.Inventory.CreateAssetAsync(actor,new() {RequestId=Guid.NewGuid().ToString("D"),ItemId=item.Id,LocationId=source.Id,Details=new() {SerialNumber="SYNTHETIC-RESERVED"}}); + var order=await pair.Orders.CreateAsync(principal,new() {RequestId=Guid.NewGuid().ToString("D"),Content=new() {Title="Synthetic repair"}}); + await pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=1,Status=WorkOrderStatus.Accepted}); order=await pair.Orders.GetAsync(principal,order.Order.Id); + var input=new WorkOrderPartInput {RequestId=Guid.NewGuid().ToString("D"),Revision=order.Order.Revision,InventoryItemId=item.Id,InventoryLocationId=source.Id,InventoryAssetId=asset.Id,Content=new() {Description="Synthetic spare",Quantity=1}}; + await pair.Orders.ReservePartAsync(principal,order.Order.Id,input); order=await pair.Orders.GetAsync(principal,order.Order.Id); var part=order.Parts.Single(); + await FluentActions.Awaiting(()=>pair.Inventory.ChangeAssetStatusAsync(actor,new() {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,AssetId=asset.Id,FromLocationId=source.Id,Type=InventoryTransactionType.StatusChange,Status=InventoryAssetStatus.Retired,Note="Reserved"}}})).Should().ThrowAsync(); + await pair.Orders.MovePartAsync(principal,order.Order.Id,part.Id,new() {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),Kind=WorkOrderPartMovementKind.Issue,Quantity=1,LocationId=target.Id}); + (await pair.Inventory.GetAsync(actor,asset.Id)).CurrentLocationId.Should().Be(target.Id); + order=await pair.Orders.GetAsync(principal,order.Order.Id); + await pair.Orders.MovePartAsync(principal,order.Order.Id,part.Id,new() {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),Kind=WorkOrderPartMovementKind.ReturnUnused,Quantity=1,Reason="Unused"}); + (await pair.Inventory.GetAsync(actor,asset.Id)).CurrentLocationId.Should().Be(source.Id); + } + [Test] + public async Task Operations_cancelled_controlled_movement_keeps_reservation_and_rejects_stale_witness() + { + await MarkMigratedAsync(); using var uow=new UnitOfWork(Connections()); var pair=MaintenancePair(uow); + var actor=new InventoryActor {DepartmentId=77,UserId="inventory-test-author"}; var witness=new InventoryActor {DepartmentId=77,UserId="inventory-test-other"}; var principal=new ChecklistActor {DepartmentId=77,UserId=actor.UserId}; + var item=await pair.Inventory.SaveItemAsync(actor,new() {IsControlledSubstance=true,Details=new() {Name="Synthetic controlled spare",UnitOfMeasure="each"}}); + var source=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic store",Type=InventoryLocationType.Facility}); var target=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic bench",Type=InventoryLocationType.Facility}); + var receive=new InventoryCommand {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,ToLocationId=source.Id,Quantity=2,Type=InventoryTransactionType.Receive}}}; + await pair.Inventory.PostTransactionAsync(actor,receive); await pair.Inventory.WitnessAsync(witness,receive.RequestId,"Receipt checked"); + var order=await pair.Orders.CreateAsync(principal,new() {RequestId=Guid.NewGuid().ToString("D"),Content=new() {Title="Synthetic order"}}); await pair.Orders.TransitionAsync(principal,order.Order.Id,new() {Revision=1,Status=WorkOrderStatus.Accepted}); order=await pair.Orders.GetAsync(principal,order.Order.Id); + await pair.Orders.ReservePartAsync(principal,order.Order.Id,new() {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),InventoryItemId=item.Id,InventoryLocationId=source.Id,Content=new() {Description="Synthetic reserved stock",Quantity=1}}); order=await pair.Orders.GetAsync(principal,order.Order.Id); + var move=new WorkOrderPartMovementInput {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),Kind=WorkOrderPartMovementKind.Issue,Quantity=1,LocationId=target.Id}; await pair.Orders.MovePartAsync(principal,order.Order.Id,order.Parts.Single().Id,move); + var pending=(await pair.Orders.PartMovementsAsync(principal,order.Order.Id)).Single(m=>m.Kind==1); order=await pair.Orders.GetAsync(principal,order.Order.Id); + await pair.Orders.CancelPartMovementAsync(principal,order.Order.Id,pending.Id,order.Order.Revision,"Not needed"); + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(witness,move.RequestId,"Stale approval")).Should().ThrowAsync(); + order=await pair.Orders.GetAsync(principal,order.Order.Id); order.Parts.Single().ReservedQuantity.Should().Be(1); order.Parts.Single().IssuedQuantity.Should().Be(0); + (await Store(uow).ListAsync(77)).Should().ContainSingle(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 43a106e3..1903c90d 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -19,7 +19,7 @@ namespace Resgrid.Tests.Services /// /// Attended protected-read pipeline (plan section 7.1): unprotected passthrough, redaction with /// machine-readable reasons for every grant failure mode, one batched broker round trip on a - /// valid grant, and fail-closed behavior on broker faults — a client never sees ciphertext. + /// valid grant, and fail-closed behavior on broker faults — a client never sees ciphertext. /// [TestFixture] public class ProtectedReadServiceTests @@ -322,7 +322,7 @@ public void Child_accessor_maps_cover_every_catalog_v1_child_column() /// /// The per-table assertions above enumerate tables by hand, so a NEW binding added without a - /// read accessor map would slip past them silently — and a bound-but-unreadable column is + /// read accessor map would slip past them silently — and a bound-but-unreadable column is /// exactly how an envelope reaches a client as ciphertext. This pins the whole set: every /// bound table either has accessor coverage or is on the explicit exclusion list below. /// @@ -360,6 +360,7 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings", "ChecklistSchedules", // Work-order content uses WorkOrderTables.Fields(); file downloads use the binary resolver. "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles", + "WorkOrderPolicies", "WorkOrderOperationReceipts", "WorkOrderVendorCharges", "WorkOrderPartMovements", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrences", "WorkOrderRecurrenceVersions", "WorkOrderMeterReadings", "WorkOrderRecurrenceChanges", // Inventory content uses InventoryTables.Fields() through the generic protected resolver. "InventoryCategories", "InventoryItems", "InventoryLocations", "InventoryLots", "InventoryStocks", @@ -501,7 +502,7 @@ public async Task Messages_and_their_recipients_reveal_in_one_broker_batch() "the conversation resolves in one round trip, not one call per row"); } - // ── protected writes ───────────────────────────────────────────────────── + // ── protected writes ───────────────────────────────────────────────────── [Test] public async Task A_message_write_envelopes_the_subject_and_the_body() @@ -661,7 +662,7 @@ public async Task Redacted_sentinel_on_an_edit_restores_the_stored_envelope() var result = await _service.PrepareCallWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, workloadCaller: false); result.Success.Should().BeTrue(); - edited.Name.Should().Be("rgdp:1:1:storedname==", "REDACTED means unchanged — the stored envelope survives"); + edited.Name.Should().Be("rgdp:1:1:storedname==", "REDACTED means unchanged — the stored envelope survives"); edited.NatureOfCall.Should().Be("rgdp:1:1:calls.natureofcall==", "genuinely changed fields encrypt"); } @@ -692,7 +693,7 @@ public async Task Redacted_sentinel_without_a_stored_row_is_neutralized_not_stor result.Success.Should().BeTrue(); sentItems.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "calls.natureofcall" }, - "the placeholder must never be enveloped — that would destroy the original"); + "the placeholder must never be enveloped — that would destroy the original"); // And it must not survive as the literal word either. These nets run AFTER the row was // saved, so leaving "REDACTED" in the field means it is what sits in the database, and a @@ -717,7 +718,7 @@ public async Task Fields_added_after_the_departments_pinned_catalog_version_are_ Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = $"rgdp:1:1:{i.FieldId}==" }).ToList() }); - // The department is stamped at catalog v0 — it owns nothing yet, so no cataloged field + // The department is stamped at catalog v0 — it owns nothing yet, so no cataloged field // may be encrypted under its (older) AAD until an upgrade sweeps it. _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 0 }); @@ -750,7 +751,7 @@ public async Task Fields_at_or_below_the_pinned_catalog_version_still_encrypt() } /// - /// Stamps the department at the CURRENT catalog version — catalog-v2 fields are only written + /// Stamps the department at the CURRENT catalog version — catalog-v2 fields are only written /// for a department that has taken the upgrade (EncryptSlotsAsync skips fields the department /// does not yet own), so the operational-family write tests must model an upgraded tenant. /// @@ -776,7 +777,7 @@ public async Task Certifications_redact_without_a_grant_and_strip_the_enveloped_ certification.Data.Should().BeNull("enveloped document bytes must never ride out through a serializer"); // Filetype is not cataloged, and the list view uses it to decide whether a document - // exists — concealing a file must not look like deleting it. + // exists — concealing a file must not look like deleting it. certification.Filetype.Should().Be("application/pdf"); } @@ -882,7 +883,7 @@ public async Task Certification_write_restores_a_redacted_field_from_the_stored_ public async Task Certifications_are_skipped_by_a_department_pinned_below_catalog_v6() { // The department has not been swept for v6 yet, so its certification columns are still - // plaintext by design — encrypting them now would leave rows the sweep never migrated. + // plaintext by design — encrypting them now would leave rows the sweep never migrated. SetupWriteEnforced(); SetupEncryptEcho(); _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) @@ -916,7 +917,7 @@ public async Task Certifications_are_skipped_by_a_department_pinned_below_catalo [Test] public async Task Call_log_narratives_redact_without_a_grant() { - // CallLogs is a different table and entity from the Log family — both call their text + // CallLogs is a different table and entity from the Log family — both call their text // column Narrative, and only the Log family had a read path. These rows were being // enveloped by the sweep and then rendered raw by the call export and the logs index. var log = new CallLog @@ -994,8 +995,8 @@ public async Task Certification_sentinel_restore_reports_changed_so_the_caller_r } /// - /// The sentinel has destroyed data four times in this codebase — member addresses, emergency - /// contacts, UDF values and certifications — because nine of the twelve write paths only + /// The sentinel has destroyed data four times in this codebase — member addresses, emergency + /// contacts, UDF values and certifications — because nine of the twelve write paths only /// refused to ENCRYPT it and let the literal word stay in the row the service had already /// saved. This pins the policy centrally so a new write path inherits it. /// @@ -1506,7 +1507,7 @@ public async Task Call_resolution_carries_populated_children_in_the_same_batch() It.IsAny>(), It.IsAny()), Times.Once); } - // ── contact writes ─────────────────────────────────────────────────────── + // ── contact writes ─────────────────────────────────────────────────────── [Test] public async Task Contact_write_envelopes_text_fields_and_the_binary_image() diff --git a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs index 0bf76827..4c7d9dd1 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs @@ -63,7 +63,9 @@ public async Task CreateIsolatedDatabaseAndMigrate() var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { _type == DatabaseTypes.Postgres ? new M0197_AddWorkOrdersPg() : new M0197_AddWorkOrders(), _type == DatabaseTypes.Postgres ? new M0203_AddWorkOrderIntegrationsPg() : new M0203_AddWorkOrderIntegrations(), - _type == DatabaseTypes.Postgres ? new M0204_AddWorkOrderRecurrencesPg() : new M0204_AddWorkOrderRecurrences() + _type == DatabaseTypes.Postgres ? new M0204_AddWorkOrderRecurrencesPg() : new M0204_AddWorkOrderRecurrences(), + _type == DatabaseTypes.Postgres ? new M0206_AddWorkOrderReportingPg() : new M0206_AddWorkOrderReporting(), + _type == DatabaseTypes.Postgres ? new M0207_AddWorkOrderOperationsPg() : new M0207_AddWorkOrderOperations() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { diff --git a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs index 8129e00e..a46b036d 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs @@ -25,12 +25,20 @@ private static IWorkOrderMaintenanceRepository EmptyMaintenance() 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; } private static IWorkOrderRepository EmptyWorkOrders() { var store = new Mock(); store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List())); return store.Object; } [TestCase(false), TestCase(true)] @@ -42,7 +50,11 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve }); store.SetReturnsDefault(Task.FromResult(new List())); store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id=8,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="VENDOR-CANARY"}})); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id=9,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="MOVEMENT-CANARY"}})); + store.SetReturnsDefault(Task.FromResult(new List {new() {Id=10,DepartmentId=DeptId,WorkOrderId=1,CreatedBy=UserId,Content="BULK-CANARY"}})); store.SetReturnsDefault(Task.FromResult(new List())); + store.SetReturnsDefault(Task.FromResult(new List { new() { DepartmentId = DeptId, WorkOrderId = 1, Revision = 2, Id = 3 } })); store.Setup(s => s.ChildrenAsync(DeptId, 1, 0)).ReturnsAsync(new List { new WorkOrderFile { Id=2, DepartmentId=DeptId, WorkOrderId=1, CreatedBy=UserId, Content="PII-FILENAME-CANARY", Data=new byte[]{1,2,3} } }); @@ -68,6 +80,7 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve new Lazy(()=>new ReadinessHistoryProtectionService(Mock.Of(),policy.Object)),reminders.Object,store.Object,EmptyInventory(), maintenance.Object); var files = await RunExportAsync(); files["workorders.json"].Should().Contain("REDACTED").And.NotContain("CANARY").And.NotContain("AQID"); + files["workorders.json"].Should().Contain("ReportSnapshots").And.Contain("Revision"); files["maintenance.json"].Should().Contain("REDACTED").And.NotContain("CANARY").And.NotContain("UNRELATED-HOLD"); var exported = Newtonsoft.Json.Linq.JObject.Parse(files["maintenance.json"]); exported["SafetyHolds"].Count().Should().Be(1); exported["Readings"].Count().Should().Be(1); exported["Recurrences"].Count().Should().Be(1); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs index 05ce0d0b..037a4d95 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs @@ -69,7 +69,7 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc builder.Services.AddAuthorization(); builder.Services.AddControllersWithViews(o=>o.Filters.Add(new WorkOrderBodyFilter())).AddApplicationPart(typeof(MvcController).Assembly).AddApplicationPart(typeof(ApiController).Assembly) .AddNewtonsoftJson(o=>o.SerializerSettings.ContractResolver=new Newtonsoft.Json.Serialization.DefaultContractResolver()); - builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_auth.Object); builder.Services.AddSingleton(_access.Object); + builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_service); builder.Services.AddSingleton(_auth.Object); builder.Services.AddSingleton(_access.Object); var departments=new Mock(); departments.Setup(d=>d.GetDepartmentMemberAsync(It.IsAny(),77,true)).ReturnsAsync((string user,int dept,bool fresh)=>new DepartmentMember {DepartmentId=77,UserId=user}); departments.Setup(d=>d.GetDepartmentByIdAsync(77,true)).ReturnsAsync(new Department {DepartmentId=77,ManagingUserId="manager"}); @@ -130,10 +130,33 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc (await client.GetAsync("/api/v4/WorkOrders/GetWorkOrderRecurrence?id=9999")).StatusCode.Should().Be(HttpStatusCode.NotFound); var replayBody=new StringContent(JsonConvert.SerializeObject(new {Id=id,Input=new WorkOrderDeferralInput {Revision=2,DueOn=_maintenanceClock.Utc.AddDays(2),Reason="Approved"}}),Encoding.UTF8,"application/json"); response=await client.PostAsync("/api/v4/WorkOrders/DeferWorkOrder",replayBody); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + foreach (var operationsPage in new[] { "Operations?id="+id, "Policy", "Bulk" }) + { response=await client.GetAsync("/User/WorkOrders/"+operationsPage); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); } + var policyFields=new Dictionary { ["Revision"]="0",["SpendingRules[0].Currency"]="EUR",["SpendingRules[0].Threshold"]="100.25",["Calendar.TimeZoneId"]="UTC",["businessStart"]="09:00",["businessEnd"]="17:00",["weekdays"]="2",["Calendar.Targets[0].Priority"]="1",["Calendar.Targets[0].ResponseMinutes"]="60",["Calendar.Targets[0].RepairMinutes"]="480" }; + (await client.PostAsync("/User/WorkOrders/SavePolicy",new FormUrlEncodedContent(policyFields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + policyFields["__RequestVerificationToken"]=csrf; + response=await client.PostAsync("/User/WorkOrders/SavePolicy",new FormUrlEncodedContent(policyFields)); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + (await _service.PolicyAsync(_actor)).SpendingRules.Single().Threshold.Should().Be(100.25m); + response=await client.GetAsync("/User/WorkOrders/Policy"); (await response.Content.ReadAsStringAsync()).Should().Contain("value=\"100.25\"").And.NotContain("value=\"100,25\""); + var csvInput=new { RequestId=Guid.NewGuid().ToString("D"),Csv=Resgrid.Services.WorkOrderCsvImport.Header+"\nSynthetic import,,0,1,,,,,USD,25,," }; + response=await client.PostAsync("/api/v4/WorkOrders/PreviewWorkOrderImport",new StringContent(JsonConvert.SerializeObject(csvInput),Encoding.UTF8,"application/json")); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + var previewJson=JObject.Parse(await response.Content.ReadAsStringAsync()); var batch=previewJson.SelectToken("Data.Input") ?? previewJson.SelectToken("data.input"); batch.Should().NotBeNull(previewJson.ToString()); + response=await client.PostAsync("/api/v4/WorkOrders/ApplyWorkOrderBatch",new StringContent(batch.ToString(),Encoding.UTF8,"application/json")); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + _store.All().Count(o=>o.RequestId!=null && o.Id!=id).Should().BeGreaterThan(0); _access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + foreach (var reportPage in new[] { "/User/WorkOrders/Reports", "/User/WorkOrders/History", "/api/v4/WorkOrders/GetWorkOrderStats", "/api/v4/WorkOrders/GetWorkOrderServiceHistory", "/api/v4/WorkOrders/GetWorkOrderActivity?id=" + id, "/api/v4/WorkOrders/GetWorkOrderHolds?id=" + id }) + { response = await client.GetAsync(reportPage); response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); response.Headers.CacheControl.NoStore.Should().BeTrue(); } + response = await client.GetAsync("/User/WorkOrders/Reports"); html = WebUtility.HtmlDecode(await response.Content.ReadAsStringAsync()); html.Should().Contain("Rapports de maintenance").And.NotContain("Maintenance reports"); + (await client.PostAsync("/User/WorkOrders/ExportCsv", new FormUrlEncodedContent(new Dictionary()))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + response = await client.PostAsync("/User/WorkOrders/ExportCsv", new FormUrlEncodedContent(new Dictionary { ["__RequestVerificationToken"] = csrf })); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); response.Content.Headers.ContentType.MediaType.Should().Be("text/csv"); + response = await client.PostAsync("/api/v4/WorkOrders/ExportWorkOrderHistory", new StringContent("{}", Encoding.UTF8, "application/json")); response.StatusCode.Should().Be(HttpStatusCode.OK); response=await client.GetAsync("/api/v4/WorkOrders/GetWorkOrder?id="+id); response.StatusCode.Should().Be(HttpStatusCode.OK); response=await client.PostAsync("/api/v4/WorkOrders/NewWorkOrder",new StringContent(JsonConvert.SerializeObject(Input()),Encoding.UTF8,"application/json")); response.StatusCode.Should().Be(HttpStatusCode.PaymentRequired); _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult {RedactedFields={"workorders.content"}})); + response = await client.GetAsync("/api/v4/WorkOrders/GetWorkOrderStats"); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + response = await client.GetAsync("/User/WorkOrders/Reports?FromUtc=2026-08-01T00:00:00Z&UntilUtc=2026-10-01T00:00:00Z"); + html = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, html); html.Should().Contain("name=\"FromUtc\" value=\"2026-08-01").And.NotContain("HTTP synthetic repair"); response=await client.GetAsync("/api/v4/WorkOrders/GetWorkOrder?id="+id); var protectedError=await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.Forbidden); protectedError.Should().Contain("protected_data_required").And.NotContain("HTTP synthetic repair"); response=await client.GetAsync("/User/WorkOrders/Index?Status=Accepted&Page=0"); html=await response.Content.ReadAsStringAsync(); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderOperationsTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderOperationsTests.cs new file mode 100644 index 00000000..2038b571 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderOperationsTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderP2M1Tests + { + private static WorkOrderPolicyInput OperationsPolicy(bool approvals = false) => new() { + ApprovalsEnabled = approvals, SpendingRules = new() { new() { Currency = "USD", Threshold = 100 } }, + Calendar = new() { Targets = new() { new() { Priority = WorkOrderPriority.Normal, ResponseMinutes = 60, RepairMinutes = 480 } } } + }; + [Test] + public void Operations_business_minutes_skip_weekends_holidays_and_observe_DST() + { + var c = OperationsPolicy().Calendar; c.Holidays.Add("2026-09-14"); + WorkOrdersService.AddBusinessMinutes(new(2026,9,11,16,30,0,DateTimeKind.Utc),60,c).Should().Be(new DateTime(2026,9,15,9,30,0,DateTimeKind.Utc)); + c.TimeZoneId = "America/New_York"; c.Weekdays = 127; c.StartMinute = 0; c.EndMinute = 1440; + WorkOrdersService.AddBusinessMinutes(new(2026,3,8,6,30,0,DateTimeKind.Utc),120,c).Should().Be(new DateTime(2026,3,8,8,30,0,DateTimeKind.Utc)); + WorkOrdersService.AddBusinessMinutes(new(2026,11,1,4,30,0,DateTimeKind.Utc),180,c).Should().Be(new DateTime(2026,11,1,7,30,0,DateTimeKind.Utc)); + } + [TestCase(0,540,1020), TestCase(128,540,1020), TestCase(62,1020,540), TestCase(62,0,1441)] + public void Operations_invalid_calendars_are_rejected(int days,int start,int end) + { + FluentActions.Invoking(()=>WorkOrdersService.ValidateBusinessCalendar(new() {Weekdays=days,StartMinute=start,EndMinute=end})).Should().Throw(); + } + [Test] + public async Task Operations_policy_is_revision_checked_scoped_and_keeps_safe_event_history() + { + Maintenance(); var policy=OperationsPolicy(true); await _service.SavePolicyAsync(_actor,policy); + (await _service.PolicyAsync(_actor)).Revision.Should().Be(1); + _store.All().Should().ContainSingle(); + await FluentActions.Awaiting(()=>_service.SavePolicyAsync(_actor,policy)).Should().ThrowAsync(); + await FluentActions.Awaiting(()=>_service.PolicyAsync(new() {DepartmentId=77,UserId="technician"})).Should().ThrowAsync(); + JsonConvert.SerializeObject(_events.Single().Payload).Should().NotContain("Threshold").And.NotContain("100"); + _events.Single().Trigger.Should().Be(WorkflowTriggerEventType.WorkOrderPolicyChanged); + } + [Test] + public async Task Operations_SLA_is_pinned_response_stops_and_worker_breach_emits_once_without_decryption() + { + Maintenance(); await _service.SavePolicyAsync(_actor,OperationsPolicy()); + var input=Input(); input.Priority=WorkOrderPriority.Normal; var detail=await _service.CreateAsync(_actor,input); + detail.ResponseDueOn.Should().Be(_maintenanceClock.Utc.AddHours(1)); detail.RepairDueOn.Should().Be(_maintenanceClock.Utc.AddDays(1)); + _maintenanceClock.Utc=_maintenanceClock.Utc.AddMinutes(30); await Transition(detail.Order.Id,WorkOrderStatus.Accepted); + _maintenanceClock.Utc=_maintenanceClock.Utc.AddDays(2); _read.Invocations.Clear(); _write.Invocations.Clear(); + (await _service.EscalateMaintenanceAsync(77)).Errors.Should().Be(0); await _service.EscalateMaintenanceAsync(77); + _read.Invocations.Should().BeEmpty(); _write.Invocations.Should().BeEmpty(); + var row=_store.All().Single(); row.ResponseBreachedOn.Should().BeNull(); row.RepairBreachedOn.Should().NotBeNull(); + _events.Count(e=>e.Trigger==WorkflowTriggerEventType.WorkOrderSlaBreached).Should().Be(1); + } + [Test] + public async Task Operations_bulk_import_preview_has_row_errors_no_writes_and_retry_creates_each_valid_row_once() + { + Maintenance(); var batch=new WorkOrderBulkInput {RequestId=Guid.NewGuid().ToString("D"),Rows=new() { + new() {RowNumber=1,Import=Input()}, new() {RowNumber=2,Import=Input()}}}; batch.Rows[1].Import.Content.Title=""; + var preview=await _service.PreviewBulkAsync(_actor,batch); preview.Rows[0].ErrorCode.Should().BeNull(); preview.Rows[1].ErrorCode.Should().NotBeNull(); + _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); batch.PreviewHash=preview.PreviewHash; + (await _service.ApplyBulkAsync(_actor,batch)).Rows[0].Applied.Should().BeTrue(); + (await _service.ApplyBulkAsync(_actor,batch)).Rows[0].Applied.Should().BeTrue(); _store.All().Should().ContainSingle(); + batch.Rows[0].Import.Content.Title="Changed after review"; + (await FluentActions.Awaiting(()=>_service.ApplyBulkAsync(_actor,batch)).Should().ThrowAsync()).Which.Code.Should().Be("BulkPreviewChanged"); + } + [Test] + public async Task Operations_bulk_assignment_replays_success_and_reports_stale_rows() + { + Maintenance(); var id=await Assigned(); var row=await _service.GetAsync(_actor,id); + var batch=new WorkOrderBulkInput {RequestId=Guid.NewGuid().ToString("D"),Rows=new() { new() {RowNumber=1,WorkOrderId=id,Assignment=new() {Revision=row.Order.Revision,UserId="technician"}} }}; + batch.PreviewHash=(await _service.PreviewBulkAsync(_actor,batch)).PreviewHash; + (await _service.ApplyBulkAsync(_actor,batch)).Rows.Single().Applied.Should().BeTrue(); + (await _service.ApplyBulkAsync(_actor,batch)).Rows.Single().Applied.Should().BeTrue(); + _store.All().Should().ContainSingle(); + batch.RequestId=Guid.NewGuid().ToString("D"); (await _service.PreviewBulkAsync(_actor,batch)).Rows.Single().ErrorCode.Should().Be("Conflict"); + } + [Test] + public async Task Operations_bulk_rechecks_protection_before_exposing_preview() + { + Maintenance(); _write.SetReturnsDefault(Task.FromResult(new ProtectedWriteResult {Success=false})); + await FluentActions.Awaiting(()=>_service.PreviewBulkAsync(_actor,new() {RequestId=Guid.NewGuid().ToString("D"),Rows=new() {new() {RowNumber=1,Import=Input()}}})).Should().ThrowAsync(); + _store.All().Should().BeEmpty(); + } + [Test] + public void Operations_CSV_preserves_quoted_text_and_reports_bad_dates_and_columns_per_row() + { + var csv=WorkOrderCsvImport.Header+"\n\"Valve, pump\",\"Line one\nLine two\",0,1,,,,,USD,12.50,,,,\nBroken,,0,1,,,,,USD,2,not-a-date,,\nToo,few"; + // Exact column count is independently checked for each record. + var parsed=WorkOrderCsvImport.Parse(csv,Guid.NewGuid().ToString("D")); parsed.Rows.Should().HaveCount(3); + parsed.Rows[1].ParseError.Should().NotBeNull(); parsed.Rows[2].ParseError.Should().NotBeNull(); + var valid=WorkOrderCsvImport.Parse(WorkOrderCsvImport.Header+"\n\"Valve, pump\",\"Line one\nLine two\",0,1,,,,,USD,12.50,,",Guid.NewGuid().ToString("D")); + valid.Rows.Single().ParseError.Should().BeNull(); valid.Rows.Single().Import.Content.Title.Should().Be("Valve, pump"); + valid.Rows.Single().Import.Content.Description.Should().Be("Line one\nLine two"); + } + [Test] + public async Task Operations_financial_approval_is_independent_and_enforces_total_commitment() + { + Maintenance(); await _service.SavePolicyAsync(_actor,OperationsPolicy(true)); var id=await Assigned(); var detail=await _service.GetAsync(_actor,id); + var charge=new WorkOrderVendorChargeInput {RequestId=Guid.NewGuid().ToString("D"),Revision=detail.Order.Revision,Content=new() {VendorName="Synthetic vendor",InvoiceReference="INV-001",Description="PII-PHI-CANARY",Amount=125,Currency="USD",ServiceDate=_maintenanceClock.Utc}}; + (await FluentActions.Awaiting(()=>_service.AddVendorChargeAsync(_actor,id,charge)).Should().ThrowAsync()).Which.Code.Should().Be("SpendingApprovalRequired"); + await _service.RequestApprovalAsync(_actor,id,new() {Revision=detail.Order.Revision,Amount=200,Reason="Planned repair"}); detail=await _service.GetAsync(_actor,id); + var decision=new WorkOrderApprovalInput {Revision=detail.Order.Revision,Approve=true,Reason="Reviewed quote"}; + (await FluentActions.Awaiting(()=>_service.DecideApprovalAsync(_actor,id,decision)).Should().ThrowAsync()).Which.Code.Should().Be("IndependentApprovalRequired"); + await _service.DecideApprovalAsync(new() {DepartmentId=77,UserId="verifier"},id,decision); + charge.Revision=(await _service.GetAsync(_actor,id)).Order.Revision; await _service.AddVendorChargeAsync(_actor,id,charge); await _service.AddVendorChargeAsync(_actor,id,charge); + _store.All().Should().ContainSingle(); + var stats=await _service.GetWorkOrderStatsAsync(_actor,new()); stats.Costs.Single().Vendor.Should().Be(125); + detail=await _service.GetAsync(_actor,id); + (await FluentActions.Awaiting(()=>_service.AddLaborAsync(_actor,id,new() {Revision=detail.Order.Revision,WorkDate=_maintenanceClock.Utc,Content=new() {Hours=2,RatePerHour=50}})).Should().ThrowAsync()).Which.Code.Should().Be("SpendingApprovalRequired"); + await _service.VoidVendorChargeAsync(_actor,id,_store.All().Single().Id,detail.Order.Revision,"Invoice withdrawn"); + (await _service.GetWorkOrderStatsAsync(_actor,new())).Costs.Should().BeEmpty(); + JsonConvert.SerializeObject(_events).Should().NotContain("CANARY").And.NotContain("INV-001").And.NotContain("Planned repair"); + } + [Test] + public async Task Operations_approval_cannot_be_forged_through_create_or_update() + { + Maintenance(); await _service.SavePolicyAsync(_actor,OperationsPolicy(true)); var input=Input(); input.Content.ApprovedCost=1000; + await FluentActions.Awaiting(()=>_service.CreateAsync(_actor,input)).Should().ThrowAsync(); + input.Content.ApprovedCost=null; var order=await _service.CreateAsync(_actor,input); order.Input.Content.ApprovedCost=1000; + await FluentActions.Awaiting(()=>_service.UpdateAsync(_actor,order.Order.Id,order.Input)).Should().ThrowAsync(); + (await _service.GetAsync(_actor,order.Order.Id)).Input.Content.ApprovedCost.Should().BeNull(); + } + [Test] + public async Task Operations_analytics_separate_active_waiting_hold_union_and_original_due_compliance() + { + Maintenance(); var start=_maintenanceClock.Utc; var id=await Assigned(); + _maintenanceClock.Utc=start.AddHours(1); await Transition(id,WorkOrderStatus.InProgress); + _maintenanceClock.Utc=start.AddHours(3); await Transition(id,WorkOrderStatus.OnHold,reason:"Awaiting part"); + _maintenanceClock.Utc=start.AddHours(6); await Transition(id,WorkOrderStatus.InProgress); + _maintenanceClock.Utc=start.AddHours(7); await Transition(id,WorkOrderStatus.Completed); + foreach(var h in new[] {new WorkOrderSafetyHold {CreatedOn=start,ReleasedOn=start.AddHours(4)},new WorkOrderSafetyHold {CreatedOn=start.AddHours(2),ReleasedOn=start.AddHours(5)}}) {h.DepartmentId=77;h.WorkOrderId=id;await _store.AllocateAsync(h);} + var row=_store.All().Single(); row.Type=1; row.OriginalDueOn=start.AddHours(6); row.DueOn=start.AddDays(2); await _store.WriteAsync(row); + var entry=(await _service.GetWorkOrderHistoryAsync(_actor,new())).Items.Single(); entry.ActiveRepairHours.Should().Be(3); entry.WaitingHours.Should().Be(4); entry.DowntimeHours.Should().Be(5); + var stats=await _service.GetWorkOrderStatsAsync(_actor,new()); stats.PreventiveDue.Should().Be(1); stats.PreventiveOnTime.Should().Be(0); stats.PreventiveCompliancePercent.Should().Be(0); + } + private sealed partial class Store + { + public Task> AllocatedPartsAsync(int d,string item,string location,string lot,string asset) => Task.FromResult(All().Where(p=>p.DepartmentId==d && p.Staged && p.InventoryItemId==item && p.ReservedLotId==lot && p.ReservedAssetId==asset && (p.ReservedLocationId==location && p.ReservedQuantity>0 || p.IssuedLocationId==location && p.IssuedQuantity>0)).ToList()); + public Task> SlaDueAsync(int d,DateTime now) => Task.FromResult(All().Where(r=>r.DepartmentId==d && !r.IsDeleted && r.Status<5 && (r.ResponseOn==null && r.ResponseBreachedOn==null && r.ResponseDueOn> _before; private static T Copy(T value) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); public IEnumerable All() where T : WorkOrderRow => _rows.TryGetValue(typeof(T), out var rows) ? rows.Cast().Select(Copy) : Enumerable.Empty(); - public void Begin() => _before = _rows.ToDictionary(p => p.Key, p => p.Value.Select(v => (WorkOrderRow)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(v), p.Key)).ToList()); - public void Rollback() { if (_before != null) _rows = _before; } + public void Begin() { BeginReporting(); _before = _rows.ToDictionary(p => p.Key, p => p.Value.Select(v => (WorkOrderRow)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(v), p.Key)).ToList()); } + public void Rollback() { RollbackReporting(); if (_before != null) _rows = _before; } public Task LockDepartmentAsync(int departmentId) => Task.CompletedTask; public Task GetAsync(int departmentId, int id, bool includeData = true) where T : WorkOrderRow => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.Id == id)); public Task RequestAsync(int departmentId, string requestId) => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.RequestId == requestId)); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs index b32025cf..6eeb3c45 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderPr505Tests.cs @@ -53,8 +53,11 @@ public void Workflow_dates_keep_the_same_UTC_instant_for_typed_and_string_values 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); + foreach (var field in new[] { "DueOn", "ResponseDueOn", "RepairDueOn", "ResponseBreachedOn", "RepairBreachedOn" }) + { + var result = JObject.Parse(WorkOrderWorkflowPayload.Routing(new JObject { [field] = value })); + ((DateTimeOffset)result[field]).UtcDateTime.Should().Be(expected); + } } } finally { CultureInfo.CurrentCulture = previous; } diff --git a/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs index e659bf98..28e34670 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs @@ -21,7 +21,7 @@ namespace Resgrid.Tests.Services { public partial class DepartmentDataMigrationEngineTests { - [TestCase(17,18,5,6),TestCase(22,23,2,2),TestCase(23,24,4,4)] + [TestCase(17,18,5,6),TestCase(22,23,2,2),TestCase(23,24,4,4),TestCase(24,25,4,4)] public async Task Work_order_catalog_upgrade_is_retry_safe_and_offboards_all_text_and_binary_fields(int from, int to, int tables, int columns) { _bulk.Seed("Calls","CallId"); @@ -56,7 +56,7 @@ public async Task Work_order_catalog_upgrade_is_retry_safe_and_offboards_all_tex } public partial class ChecklistEventDeliveryTests { - [TestCase(70),TestCase(71),TestCase(72),TestCase(73),TestCase(167),TestCase(168),TestCase(169),TestCase(170),TestCase(171),TestCase(172)] + [TestCase(70),TestCase(71),TestCase(72),TestCase(73),TestCase(167),TestCase(168),TestCase(169),TestCase(170),TestCase(171),TestCase(172),TestCase(173),TestCase(174),TestCase(175),TestCase(176)] public async Task Work_order_events_encrypt_history_and_replay_only_safe_Workflow_routing(int trigger) { var notifications=new Mock(); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderReportingDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderReportingDatabaseTests.cs new file mode 100644 index 00000000..1c4b24b9 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderReportingDatabaseTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using FluentMigrator.Runner; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderDatabaseTests + { + private WorkOrdersService ReportService(Resgrid.Repositories.DataRepository.WorkOrderRepository repository, Resgrid.Model.Repositories.Queries.IUnitOfWork uow) + { + var auth = new Mock(); + auth.Setup(a => a.ScopeAsync(It.IsAny())).ReturnsAsync((ChecklistActor a) => new WorkOrderReadScope { All = true, UserId = a.UserId }); + var read = new Mock(); read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + return new WorkOrdersService(repository, auth.Object, Mock.Of(), uow, Mock.Of(), Mock.Of(), + new Lazy(() => read.Object), new Lazy(() => Mock.Of()), Mock.Of()); + } + [Test, Order(12)] + public async Task P2M4_twelve_month_report_matches_independent_SQL_and_filters_pages_before_projection() + { + var start = new DateTime(2011, 1, 1, 0, 0, 0, DateTimeKind.Utc); var asset = Guid.NewGuid().ToString("D"); + using var uow = new UnitOfWork(Connections()); var store = Orders(uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + for (var month = 0; month < 12; month++) + { + var row = await Insert(store); row.CreatedOn = start.AddMonths(month); row.UpdatedOn = row.CreatedOn; row.TargetUnitId = 10; row.InventoryAssetId = asset; + row.Status = month % 2 == 0 ? 5 : 6; row.StartedOn = row.CreatedOn.AddHours(1); row.CompletedOn = row.StartedOn.Value.AddHours(month + 1); + row.Content = JsonConvert.SerializeObject(new { Fields = new WorkOrderContent { Title = "Synthetic yearly repair", Currency = "USD" } }); await store.WriteAsync(row); + var labor = new WorkOrderLabor { DepartmentId = 77, WorkOrderId = row.Id, CreatedBy = "author", UserId = "author", CreatedOn = row.CreatedOn, UpdatedOn = row.CreatedOn, WorkDate = row.CreatedOn }; + await store.AllocateAsync(labor); labor.Content = "{\"Hours\":2,\"RatePerHour\":10,\"Currency\":\"USD\"}"; await store.WriteAsync(labor); + var part = new WorkOrderPart { DepartmentId = 77, WorkOrderId = row.Id, CreatedBy = "author", CreatedOn = row.CreatedOn, UpdatedOn = row.CreatedOn }; + await store.AllocateAsync(part); part.Content = "{\"Quantity\":3,\"UnitCost\":4,\"Currency\":\"EUR\"}"; await store.WriteAsync(part); + } + uow.CommitChanges(); + var service = ReportService(store, uow); var actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; + var query = new WorkOrderReportQuery { FromUtc = start, UntilUtc = start.AddYears(1), AssetId = asset }; + var stats = await service.GetWorkOrderStatsAsync(actor, query); + await using var db = Connect(_connection); + var duration = _type == DatabaseTypes.Postgres ? "EXTRACT(EPOCH FROM (completedon-startedon))/3600.0" : "DATEDIFF_BIG(second,StartedOn,CompletedOn)/3600.0"; + var mttr = await db.ExecuteScalarAsync($"SELECT AVG({duration}) FROM {Q("WorkOrders")} WHERE {Q("DepartmentId")}=77 AND {Q("InventoryAssetId")}=@Asset", new { Asset = asset }); + string Value(string name) => _type == DatabaseTypes.Postgres ? $"CAST(l.content::jsonb->>'{name}' AS numeric)" : $"CAST(JSON_VALUE(l.Content,'$.{name}') AS decimal(18,2))"; + async Task Cost(string table, string quantity, string price) => await db.ExecuteScalarAsync($"SELECT SUM({Value(quantity)}*{Value(price)}) FROM {Q(table)} l JOIN {Q("WorkOrders")} o ON o.{Q("DepartmentId")}=l.{Q("DepartmentId")} AND o.{Q("Id")}=l.{Q("WorkOrderId")} WHERE o.{Q("DepartmentId")}=77 AND o.{Q("InventoryAssetId")}=@Asset", new { Asset = asset }); + stats.Total.Should().Be(12); stats.Open.Should().Be(6); stats.MeanTimeToRepairHours.Should().Be(mttr).And.Be(6.5m); + stats.Costs.Single(c => c.Currency == "USD").Labor.Should().Be(await Cost("WorkOrderLabors", "Hours", "RatePerHour")).And.Be(240); + stats.Costs.Single(c => c.Currency == "EUR").Parts.Should().Be(await Cost("WorkOrderParts", "Quantity", "UnitCost")).And.Be(144); + var scope = new WorkOrderReadScope { UserId = "author", All = true }; var first = await store.ReportOrdersAsync(77, scope, query, 7); + query.AfterId = first.Last().Id; var last = await store.ReportOrdersAsync(77, scope, query, 7); + first.Should().HaveCount(7); last.Should().HaveCount(5); first.Concat(last).Select(r => r.Id).Should().OnlyHaveUniqueItems(); + (await store.ReportOrdersAsync(88, scope, query, 7)).Should().BeEmpty(); + var csv = await service.ExportWorkOrdersAsync(actor, new WorkOrderReportQuery { AssetId = asset }); System.Text.Encoding.UTF8.GetString(csv).Split('\n').Length.Should().Be(26); + } + [Test, Order(13)] + public async Task P2M4_snapshot_history_is_tenant_bound_atomic_immutable_and_survives_upgrade_replay() + { + using var uow = new UnitOfWork(Connections()); var store = Orders(uow); var at = new DateTime(2012, 1, 1, 0, 0, 0, DateTimeKind.Utc); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); var row = await Insert(store); + var activity = new WorkOrderActivity { DepartmentId = 77, WorkOrderId = row.Id, CreatedBy = "author", CreatedOn = at, UpdatedOn = at }; + await store.AllocateAsync(activity); activity.Content = "{\"Snapshot\":{\"Title\":\"Historical title\"}}"; await store.WriteAsync(activity); + row.SlaPolicyRevision = 1; row.ResponseDueOn = at.AddHours(1); row.RepairDueOn = at.AddDays(1); + row.TargetUnitId = 10; await store.CaptureReportSnapshotAsync(row, at, activity.Id); await store.CaptureReportSnapshotAsync(row, at, activity.Id); + row.Revision++; row.TargetUnitId = 20; await store.WriteAsync(row); await store.CaptureReportSnapshotAsync(row, at.AddDays(1), null); uow.CommitChanges(); + var first = (await store.ReportSnapshotsAsync(77, new[] { row.Id }, at)).Single(); first.Revision.Should().Be(1); first.TargetUnitId.Should().Be(10); first.SourceActivityId.Should().Be(activity.Id); + var latest = (await store.ReportSnapshotsAsync(77, new[] { row.Id }, at.AddDays(2))).Single(); latest.Revision.Should().Be(2); latest.SourceActivityId.Should().Be(activity.Id); + (await store.ReportSnapshotsAsync(88, new[] { row.Id }, at)).Should().BeEmpty(); + await uow.CreateOrGetConnectionAsync(); row.Revision++; await store.CaptureReportSnapshotAsync(row, at.AddDays(2), null); uow.DiscardChanges(); + (await store.ReportSnapshotsAsync(77, new[] { row.Id }, at.AddDays(3))).Single().Revision.Should().Be(2); + _runner.GetRequiredService().MigrateUp(); + (await store.ReportSnapshotsAsync(77, new[] { row.Id }, at)).Single().Should().BeEquivalentTo(first); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"INSERT INTO {Q("WorkOrderReportSnapshots")} ({Q("DepartmentId")},{Q("WorkOrderId")},{Q("Revision")},{Q("RecordedOn")},{Q("SourceType")},{Q("Status")},{Q("Priority")}) VALUES(88,@Id,1,@At,0,0,0)", new { Id = row.Id, At = at })).Should().ThrowAsync(); + first.ResponseDueOn.Should().Be(at.AddHours(1)); first.SlaPolicyRevision.Should().Be(1); + // The historical clock must prevent rollback even after the current policy target is disabled. + await uow.CreateOrGetConnectionAsync(); row.SlaPolicyRevision = null; row.ResponseDueOn = null; row.RepairDueOn = null; await store.WriteAsync(row); uow.CommitChanges(); + FluentActions.Invoking(() => _runner.GetRequiredService().MigrateDown(205)).Should().Throw(); + (await store.ReportSnapshotsAsync(77, new[] { row.Id }, at)).Single().Should().BeEquivalentTo(first); + } + [Test, Order(14)] + public async Task P2M4_large_organization_selective_history_and_date_queries_use_indexes() + { + await using var db = Connect(_connection); var asset = Guid.NewGuid().ToString("D"); + var source = _type == DatabaseTypes.Postgres ? "SELECT generate_series(1,50000) AS n" : "SELECT TOP (50000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n FROM sys.all_objects a CROSS JOIN sys.all_objects b"; + var guid = _type == DatabaseTypes.Postgres ? "md5(n::text || 'readiness-p2m4')::uuid::text" : "CONVERT(varchar(36),NEWID())"; + var date = _type == DatabaseTypes.Postgres ? "timestamp '2010-01-01' + (n % 365) * interval '1 day'" : "DATEADD(day,CAST(n % 365 AS int),CAST('2010-01-01' AS datetime2))"; + await db.ExecuteAsync($"INSERT INTO {Q("WorkOrders")} ({Q("DepartmentId")},{Q("CreatedBy")},{Q("RequestId")},{Q("NumberYear")},{Q("NumberSequence")},{Q("CreatedOn")},{Q("UpdatedOn")},{Q("Content")},{Q("InventoryAssetId")},{Q("TargetUnitId")},{Q("Type")},{Q("Status")},{Q("Priority")},{Q("SourceType")}) SELECT 77,'scale-author',{guid},2010,n,{date},{date},'{{\"Fields\":{{\"Title\":\"synthetic scale row\"}}}}',CASE WHEN n<=12 THEN @Asset ELSE NULL END,CASE WHEN n<=12 THEN 999 ELSE NULL END,0,0,1,0 FROM ({source}) seed", new { Asset = asset }, commandTimeout: 120); + await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "ANALYZE workorders" : "UPDATE STATISTICS WorkOrders WITH FULLSCAN"); + using (var uow = new UnitOfWork(Connections())) + { + var store = Orders(uow); var scope = new WorkOrderReadScope { UserId = "scale-author", All = true }; + var page = await store.ReportPacketOrdersAsync(77, scope, new DateTime(2011, 1, 1), new[] { 999 }, Array.Empty(), 0); + page.Should().HaveCount(12, "a small call must not load the full department history"); + (await store.ReportPacketOrdersAsync(88, scope, new DateTime(2011, 1, 1), new[] { 999 }, Array.Empty(), 0)).Should().BeEmpty(); + (await store.HasUnrecoverableReportHistoryAsync(77, scope, new DateTime(2011, 1, 1))).Should().BeFalse(); + var packet = await ReportService(store, uow).ReadinessEvidenceAsync(new ChecklistActor { DepartmentId = 77, UserId = "scale-author" }, new DateTime(2010, 12, 1), new DateTime(2011, 1, 1), new[] { 999 }, Array.Empty()); + packet.Items.Should().HaveCount(12); packet.HistoryUnavailable.Should().BeFalse(); + } + var predicates = new[] { ($"{Q("InventoryAssetId")}='{asset}'", "ix_workorders_asset"), ($"{Q("TargetUnitId")}=999", "ix_workorders_unit"), ($"{Q("CreatedOn")}>='2011-01-01' AND {Q("CreatedOn")}<'2012-01-01'", "ix_workorders_reportcreated") }; + foreach (var (predicate, index) in predicates) + { + var sql = $"SELECT * FROM {Q("WorkOrders")} WHERE {Q("DepartmentId")}=77 AND {Q("IsDeleted")}={(_type == DatabaseTypes.Postgres ? "false" : "0")} AND {Q("Id")}>0 AND {predicate} ORDER BY {Q("Id")} " + (_type == DatabaseTypes.Postgres ? "LIMIT 51 OFFSET 0" : "OFFSET 0 ROWS FETCH NEXT 51 ROWS ONLY"); + string plan; + if (_type == DatabaseTypes.Postgres) plan = await db.ExecuteScalarAsync("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + sql); + else + { + await db.OpenAsync(); + try { await db.ExecuteAsync("SET SHOWPLAN_XML ON"); plan = await db.ExecuteScalarAsync(sql); } + finally { await db.ExecuteAsync("SET SHOWPLAN_XML OFF"); await db.CloseAsync(); } + } + plan.ToLowerInvariant().Should().Contain(index); + TestContext.Out.WriteLine(_type + ": 50,000-row selective report uses " + index); + } + } + [Test, Order(99)] + public async Task P2M4_retained_snapshots_follow_legal_hold_transaction_rollback_and_department_purge() + { + using (var uow = new UnitOfWork(Connections())) + { + var store = Orders(uow); await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(77); + foreach (var department in new[] { 77, 88 }) { var row = await Insert(store, department); await store.CaptureReportSnapshotAsync(row, DateTime.UtcNow, null); } + uow.CommitChanges(); + } + await using var db = Connect(_connection); await db.OpenAsync(); + var date = _type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + await db.ExecuteAsync($"CREATE TABLE {Q("ChecklistDefinitions")} ({Q("DepartmentId")} int); CREATE TABLE {Q("RmsRecordLegalHolds")} ({Q("DepartmentId")} int,{Q("ReleasedOn")} {date}); INSERT INTO {Q("RmsRecordLegalHolds")} VALUES(77,NULL);"); + async Task Count(int department) => await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("WorkOrderReportSnapshots")} WHERE {Q("DepartmentId")}=@Department", new { Department = department }); + var before = await Count(77); var foreignBefore = await Count(88); before.Should().BePositive(); foreignBefore.Should().BePositive(); + await using (var hold = await db.BeginTransactionAsync()) + { + await FluentActions.Awaiting(() => Resgrid.Repositories.DataRepository.ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, hold, 77, _type)).Should().ThrowAsync().WithMessage("*legal hold*"); + await hold.CommitAsync(); + } + (await Count(77)).Should().Be(before); + await db.ExecuteAsync($"UPDATE {Q("RmsRecordLegalHolds")} SET {Q("ReleasedOn")}=@Now WHERE {Q("DepartmentId")}=77", new { Now = DateTime.UtcNow }); + await using (var rollback = await db.BeginTransactionAsync()) + { + await Resgrid.Repositories.DataRepository.ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, rollback, 77, _type); + await rollback.RollbackAsync(); + } + (await Count(77)).Should().Be(before); + await using (var commit = await db.BeginTransactionAsync()) + { + await Resgrid.Repositories.DataRepository.ChecklistDepartmentCleanup.DeleteWithinTransactionAsync(db, commit, 77, _type); await commit.CommitAsync(); + } + (await Count(77)).Should().Be(0); (await Count(88)).Should().Be(foreignBefore); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderReportingTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderReportingTests.cs new file mode 100644 index 00000000..0a61718f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderReportingTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +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.Checklists; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderP2M1Tests + { + private async Task ReportSeed(DateTime created, int status = 0, int priority = 1, int? unit = 10, string asset = null) + { + var row = new WorkOrder { DepartmentId = 77, CreatedBy = "manager", RequestId = Guid.NewGuid().ToString("D"), NumberYear = created.Year, CreatedOn = created, UpdatedOn = created, + Status = status, Priority = priority, TargetUnitId = unit, InventoryAssetId = asset }; + await _store.AllocateAsync(row); row.NumberSequence = row.Id; + row.Content = JsonConvert.SerializeObject(new { Fields = new WorkOrderContent { Title = "Synthetic repair " + row.Id, Currency = "USD" } }); + await _store.WriteAsync(row); return row; + } + private async Task ReportLabor(WorkOrder row, decimal hours, decimal? rate, string currency) + { + var labor = new WorkOrderLabor { DepartmentId = row.DepartmentId, WorkOrderId = row.Id, CreatedBy = "manager", UserId = "manager", CreatedOn = row.CreatedOn, UpdatedOn = row.CreatedOn, WorkDate = row.CreatedOn }; + await _store.AllocateAsync(labor); labor.Content = JsonConvert.SerializeObject(new WorkOrderLaborContent { Hours = hours, RatePerHour = rate, Currency = currency }); await _store.WriteAsync(labor); + } + private async Task ReportPart(WorkOrder row, decimal quantity, decimal? price, string currency, bool voided = false, bool pending = false) + { + var part = new WorkOrderPart { DepartmentId = row.DepartmentId, WorkOrderId = row.Id, CreatedBy = "manager", CreatedOn = row.CreatedOn, UpdatedOn = row.CreatedOn, + VoidedOn = voided ? row.CreatedOn : null, InventoryItemId = pending ? Guid.NewGuid().ToString("D") : null }; + await _store.AllocateAsync(part); part.Content = JsonConvert.SerializeObject(new WorkOrderPartContent { Quantity = quantity, UnitCost = price, Currency = currency }); await _store.WriteAsync(part); + } + [Test] + public async Task P2M4_yearly_statistics_preserve_currency_unknown_costs_and_elapsed_repair_semantics() + { + var start = DateTime.UtcNow.Date.AddYears(-1); var end = DateTime.UtcNow.Date; + for (var month = 0; month < 12; month++) + { + var row = await ReportSeed(start.AddMonths(month), month % 2 == 0 ? 5 : 6); + row.StartedOn = row.CreatedOn.AddHours(1); row.CompletedOn = row.StartedOn.Value.AddHours(month + 1); await _store.WriteAsync(row); + await ReportLabor(row, 2, 10, "USD"); await ReportPart(row, 3, 4, "EUR"); + await ReportPart(row, 1, 1000, "USD", voided: true); await ReportPart(row, 1, 1000, "USD", pending: true); + } + var unknown = await ReportSeed(start.AddDays(1), 6); await ReportLabor(unknown, 1, 999, null); await ReportLabor(unknown, 1, null, "EUR"); await ReportPart(unknown, 1, null, "USD"); + var outside = await ReportSeed(start.AddTicks(-1)); await ReportLabor(outside, 1, 9999, "USD"); + await ReportSeed(end); // Upper bound is exclusive. + _access.Setup(a => a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + var stats = await _service.GetWorkOrderStatsAsync(_actor, new WorkOrderReportQuery { FromUtc = start, UntilUtc = end }); + stats.Total.Should().Be(13); stats.Open.Should().Be(6); stats.RepairSamples.Should().Be(12); stats.MissingRepairTimes.Should().Be(1); + stats.MeanTimeToRepairHours.Should().Be(6.5m); + stats.Costs.Single(c => c.Currency == "USD").Labor.Should().Be(240); + stats.Costs.Single(c => c.Currency == "EUR").Parts.Should().Be(144); + stats.Costs.Single(c => c.Currency == null).UnknownLabor.Should().Be(1); + stats.Costs.Single(c => c.Currency == "EUR").UnknownLabor.Should().Be(1); + stats.Costs.Single(c => c.Currency == "USD").UnknownParts.Should().Be(1); + } + [Test] + public async Task P2M4_history_pages_all_years_filters_before_paging_and_excludes_deleted_or_unscoped_orders() + { + var asset = Guid.NewGuid().ToString("D"); + for (var i = 0; i < 55; i++) await ReportSeed(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), asset: asset); + await ReportSeed(DateTime.UtcNow.AddDays(-1), asset: Guid.NewGuid().ToString("D")); + var deleted = await ReportSeed(DateTime.UtcNow.AddDays(-1), asset: asset); deleted.IsDeleted = true; await _store.WriteAsync(deleted); + var page = await _service.GetWorkOrderHistoryAsync(_actor, new WorkOrderReportQuery { AssetId = asset }); + page.Items.Should().HaveCount(50); page.NextAfterId.Should().HaveValue(); + var next = await _service.GetWorkOrderHistoryAsync(_actor, new WorkOrderReportQuery { AssetId = asset, AfterId = page.NextAfterId.Value }); + next.Items.Should().HaveCount(5); next.NextAfterId.Should().BeNull(); page.Items.Concat(next.Items).Select(e => e.Order.Id).Should().OnlyHaveUniqueItems(); + (await _service.GetWorkOrderHistoryAsync(new ChecklistActor { DepartmentId = 77, UserId = "outsider" }, new WorkOrderReportQuery())).Items.Should().BeEmpty(); + (await _service.GetWorkOrderHistoryAsync(new ChecklistActor { DepartmentId = 88, UserId = "manager" }, new WorkOrderReportQuery())).Items.Should().BeEmpty(); + await FluentActions.Awaiting(() => _service.GetAsync(_actor, deleted.Id)).Should().ThrowAsync(); + } + [Test] + public async Task P2M4_exports_fail_closed_on_grant_revocation_or_access_changes_during_projection() + { + await ReportSeed(DateTime.UtcNow.AddDays(-1)); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult { RedactedFields = { "workorders.content" } })); + await FluentActions.Awaiting(() => _service.ExportWorkOrdersAsync(_actor, new WorkOrderReportQuery())).Should().ThrowAsync(); + _read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + var calls = 0; + _auth.Setup(a => a.ScopeAsync(_actor)).ReturnsAsync(() => new WorkOrderReadScope { UserId = ++calls == 1 ? "manager" : "outsider", All = calls == 1 }); + await FluentActions.Awaiting(() => _service.ExportWorkOrdersAsync(_actor, new WorkOrderReportQuery())).Should().ThrowAsync(); + } + [Test] + public async Task P2M4_call_packet_pins_title_target_status_and_revision_before_later_edits() + { + var input = Input(); input.TargetUnitId = 10; + var detail = await _service.CreateAsync(_actor, input); var at = DateTime.UtcNow; + var edit = detail.Input; edit.Content.Title = "Future title must not leak"; edit.TargetUnitId = 20; + await _service.UpdateAsync(_actor, detail.Order.Id, edit); + var packet = await _service.ReadinessEvidenceAsync(_actor, at.AddDays(-30), at, new[] { 10 }, Array.Empty()); + packet.Items.Should().ContainSingle(); packet.Items[0].Title.Should().Be(input.Content.Title); packet.Items[0].Revision.Should().Be(1); packet.Items[0].UnitId.Should().Be(10); + packet.Items[0].SourceActivityId.Should().HaveValue(); packet.Items[0].SnapshotId.Should().HaveValue(); packet.HistoryUnavailable.Should().BeFalse(); + var other = await _service.ReadinessEvidenceAsync(_actor, at.AddDays(-30), at, new[] { 20 }, Array.Empty()); other.Items.Should().BeEmpty(); + var manifest = new ReadinessEvidenceManifestV1 { DepartmentId = 77, CallId = 5, WorkOrders = packet.Items }; + var current = new ReadinessEvidenceManifestV1 { DepartmentId = 77, CallId = 5 }; + FluentActions.Invoking(() => ChecklistReportDocuments.EnsureStillAuthorized(manifest, current)).Should().Throw(); + var html = ChecklistReportDocuments.Packet(manifest); html.Should().Contain(input.Content.Title).And.NotContain("Future title"); + } + [Test] + public async Task P2M4_legacy_mutations_report_unavailable_instead_of_inventing_past_state() + { + var at = DateTime.UtcNow.AddDays(-2); var row = await ReportSeed(at.AddDays(-1)); + row.UpdatedOn = at.AddDays(1); await _store.WriteAsync(row); + var section = await _service.ReadinessEvidenceAsync(_actor, at.AddDays(-30), at, new[] { 10 }, Array.Empty()); + section.Items.Should().BeEmpty(); section.HistoryUnavailable.Should().BeTrue(); + } + [Test] + public async Task P2M4_restored_writer_revision_gaps_are_unavailable_instead_of_stale_call_evidence() + { + var input = Input(); input.TargetUnitId = 10; + var created = await _service.CreateAsync(_actor, input); + var row = await _store.GetAsync(77, created.Order.Id); + row.Revision++; row.Status = (int)WorkOrderStatus.Accepted; row.UpdatedOn = DateTime.UtcNow; + await _store.WriteAsync(row); // Simulate an older application writing after a database restore. + var at = DateTime.UtcNow.AddMinutes(1); + var section = await _service.ReadinessEvidenceAsync(_actor, at.AddDays(-30), at, new[] { 10 }, Array.Empty()); + section.Items.Should().BeEmpty(); section.HistoryUnavailable.Should().BeTrue(); + } + [Test] + public async Task P2M4_labor_pins_server_currency_and_snapshot_creation_rolls_back_with_event_failure() + { + var id = await Assigned(); var detail = await _service.GetAsync(_actor, id); + await _service.AddLaborAsync(_actor, id, new WorkOrderLaborInput { Revision = detail.Order.Revision, WorkDate = DateTime.UtcNow, Content = new WorkOrderLaborContent { Hours = 2, RatePerHour = 3, Currency = "EUR" } }); + JsonConvert.DeserializeObject(_store.All().Single().Content).Currency.Should().Be("USD"); + var before = _store.Snapshots.Count; + _outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), default)).ThrowsAsync(new InvalidOperationException("synthetic failure")); + await FluentActions.Awaiting(() => _service.CreateAsync(_actor, Input())).Should().ThrowAsync(); + _store.Snapshots.Should().HaveCount(before); + } + [Test] + public void P2M4_csv_and_packet_encode_untrusted_text_and_preserve_v4_numeric_enums() + { + var entry = new WorkOrderReportEntry { Order = new WorkOrderSummary { Title = " =SUM(1,2)\"`); await page.evaluate(() => { window.$ = f => f(); window.requests = []; window.navigations = []; @@ -26,6 +26,8 @@ const launch = require('./browser-launch.cjs'); HTMLFormElement.prototype.submit = function () { navigations.push(Object.fromEntries(new FormData(this))); }; }); await page.addScriptTag({path:path.resolve(__dirname,'../../../Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js')}); + await page.click('#allocation-destination button'); + assert.equal(await page.locator('#allocation-destination select option').count(),2); await page.click('#work-order-add-step'); assert.equal(await page.locator('[name="Input.Template.Content.Steps[0].Text"]').count(),1); for (const checkbox of await page.locator('.maintenance-weekday').all()) await checkbox.check(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs index f1b9f9dd..fe71471f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs @@ -76,6 +76,15 @@ public async Task> Accreditation(DateTi } /// Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. + /// Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules (each authorizes the caller itself) plus readiness-packet evidence; the unit board joins them on the unit id. + [HttpGet("Readiness")] + public async Task> Readiness(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsReadinessResult { Data = await _analytics.GetReadinessAsync(DepartmentId, UserId, Query(start, end, stationGroupId, null, 80, 240), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + [HttpGet("CommunityRisk")] public async Task> CommunityRisk(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, CancellationToken cancellationToken = default) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs index 6766a98d..c139fde2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs @@ -14,7 +14,7 @@ public sealed partial class WorkOrdersController [HttpGet("GetWorkOrderInventoryChoices")] public async Task GetWorkOrderInventoryChoices(string kind, string itemId = null, int page = 0) { MaintenanceAvailable(); return Reply(await _maintenance.InventoryChoicesAsync(Actor, kind, itemId, page)); } [HttpGet("GetWorkOrderHolds")] - public async Task GetWorkOrderHolds(int id) { MaintenanceAvailable(); return Reply(await _maintenance.HoldsAsync(Actor, id)); } + public async Task GetWorkOrderHolds(int id, int afterId = 0) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderHoldsAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId.HasValue); } [HttpPost("AddWorkOrderHold")] public async Task AddWorkOrderHold([FromBody] WorkOrderMaintenanceCommand command) { MaintenanceAvailable(); Required(command); await _maintenance.AddHoldAsync(Actor, command.Id, Required(command.Input)); return Reply(await _maintenance.HoldsAsync(Actor, command.Id)); } [HttpPost("ReleaseWorkOrderHold")] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs new file mode 100644 index 00000000..8a45a7db --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderOperationsController.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; +using Resgrid.Web.Services.Models.v4.WorkOrders; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed partial class WorkOrdersController + { + private IWorkOrderOperationsService OperationsService => _orders as IWorkOrderOperationsService ?? throw new WorkOrderException(503, "MaintenanceUnavailable"); + [HttpGet("GetWorkOrderPolicy")] + public async Task GetWorkOrderPolicy() => Reply(await OperationsService.PolicyAsync(Actor)); + [HttpPost("SaveWorkOrderPolicy")] + public async Task SaveWorkOrderPolicy([FromBody] WorkOrderPolicyInput input) { await OperationsService.SavePolicyAsync(Actor, Required(input)); return Reply(await OperationsService.PolicyAsync(Actor)); } + [HttpPost("PreviewWorkOrderBatch")] + public async Task PreviewWorkOrderBatch([FromBody] WorkOrderBulkInput input) => Reply(await OperationsService.PreviewBulkAsync(Actor, Required(input))); + [HttpPost("PreviewWorkOrderImport")] + public async Task PreviewWorkOrderImport([FromBody] WorkOrderCsvInput input) { Required(input); var batch = WorkOrderCsvImport.Parse(input.Csv, input.RequestId); var preview = await OperationsService.PreviewBulkAsync(Actor, batch); batch.PreviewHash = preview.PreviewHash; return Reply(new { Input = batch, Preview = preview }); } + [HttpPost("ApplyWorkOrderBatch")] + public async Task ApplyWorkOrderBatch([FromBody] WorkOrderBulkInput input) => Reply(await OperationsService.ApplyBulkAsync(Actor, Required(input))); + [HttpPost("RequestWorkOrderApproval")] + public async Task RequestWorkOrderApproval([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.RequestApprovalAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("DecideWorkOrderApproval")] + public async Task DecideWorkOrderApproval([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.DecideApprovalAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpGet("GetWorkOrderVendorCharges")] + public async Task GetWorkOrderVendorCharges(int id, int afterId = 0) { var rows = await OperationsService.VendorChargesAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } + [HttpPost("AddWorkOrderVendorCharge")] + public async Task AddWorkOrderVendorCharge([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.AddVendorChargeAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("VoidWorkOrderVendorCharge")] + public async Task VoidWorkOrderVendorCharge([FromBody] WorkOrderCommandInput input) { Required(input); await OperationsService.VoidVendorChargeAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("ReserveWorkOrderPart")] + public async Task ReserveWorkOrderPart([FromBody] WorkOrderApiPartInput input) { Required(input); await OperationsService.ReservePartAsync(Actor, input.Id, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpPost("MoveWorkOrderPart")] + public async Task MoveWorkOrderPart([FromBody] WorkOrderOperationsInput input) { Required(input); await OperationsService.MovePartAsync(Actor, input.Id, input.ChildId, Required(input.Input)); return Reply(await _orders.GetAsync(Actor, input.Id)); } + [HttpGet("GetWorkOrderPartMovements")] + public async Task GetWorkOrderPartMovements(int id, int afterId = 0) { var rows = await OperationsService.PartMovementsAsync(Actor, id, afterId); return Reply(rows, rows.Count, rows.Count == 50); } + [HttpPost("CancelWorkOrderPartMovement")] + public async Task CancelWorkOrderPartMovement([FromBody] WorkOrderCommandInput input) { Required(input); await OperationsService.CancelPartMovementAsync(Actor, input.Id, input.ChildId, input.Revision, input.Note); return Reply(await _orders.GetAsync(Actor, input.Id)); } + } + public sealed class WorkOrderOperationsInput where T : class { public int Id { get; set; } public int ChildId { get; set; } public T Input { get; set; } } + public sealed class WorkOrderCsvInput { public string RequestId { get; set; } public string Csv { get; set; } } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs new file mode 100644 index 00000000..d03f98a6 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderReportingController.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed partial class WorkOrdersController + { + [HttpGet("GetWorkOrderStats")] + public async Task GetWorkOrderStats([FromQuery] WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + return Reply(await _reports.GetWorkOrderStatsAsync(Actor, query)); + } + [HttpGet("GetWorkOrderServiceHistory")] + public async Task GetWorkOrderServiceHistory([FromQuery] WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + var page = await _reports.GetWorkOrderHistoryAsync(Actor, query); + return Reply(page, page.Items.Count, page.NextAfterId.HasValue); + } + [HttpPost("ExportWorkOrderHistory")] + public async Task ExportWorkOrderHistory([FromBody] WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + Response.Headers["X-Content-Type-Options"] = "nosniff"; + return File(await _reports.ExportWorkOrdersAsync(Actor, Required(query)), "text/csv; charset=utf-8", "work-order-history.csv"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs index 270de5b4..97fb12b5 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs @@ -19,8 +19,9 @@ public sealed partial class WorkOrdersController : V4AuthenticatedApiControllerb { private readonly IWorkOrdersService _orders; private readonly IWorkOrderMaintenanceService _maintenance; + private readonly IWorkOrderReportingService _reports; private readonly IStringLocalizer _strings; - public WorkOrdersController(IWorkOrdersService orders, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null) { _orders = orders; _strings = strings; _maintenance = maintenance; } + public WorkOrdersController(IWorkOrdersService orders, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null, IWorkOrderReportingService reports = null) { _orders = orders; _strings = strings; _maintenance = maintenance; _reports = reports; } private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = Request.Headers[DataProtectionController.GrantHeader].ToString() }; private OkObjectResult Reply(T value, int count = 1, bool more = false) { var response = new WorkOrderApiResult { Data = value, Status = ResponseHelper.Success, PageSize = count, HasMore = more }; ResponseHelper.PopulateV4ResponseData(response); return Ok(response); } private static T Required(T input) where T : class => input ?? throw new WorkOrderException(400, "InvalidInput"); @@ -43,7 +44,7 @@ private ObjectResult Failure(WorkOrderException ex) [HttpGet("GetWorkOrderChoices")] public async Task GetWorkOrderChoices() => Reply(await _orders.ChoicesAsync(Actor)); [HttpGet("GetWorkOrderActivity")] - public async Task GetWorkOrderActivity(int id) { var detail = await _orders.GetAsync(Actor, id); return Reply(detail.Activities, detail.Activities.Count); } + public async Task GetWorkOrderActivity(int id, int afterId = 0) { if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); var page = await _reports.GetWorkOrderActivityAsync(Actor, id, afterId); return Reply(page.Items, page.Items.Count, page.NextAfterId.HasValue); } [HttpGet("GetWorkOrderHistoryForAsset")] public Task GetWorkOrderHistoryForAsset(string assetId, int page = 0) { if (string.IsNullOrWhiteSpace(assetId)) throw new WorkOrderException(400, "InvalidInput"); return GetWorkOrders(new WorkOrderFilter { AssetId = assetId, Page = page }); } [HttpPost("NewWorkOrder")] diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs index 59644a82..a0a719c2 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs @@ -10,4 +10,5 @@ public class RecordsWorkloadResult : StandardApiResponseV4Base { public RecordsW public class RecordsExecutiveSummaryResult : StandardApiResponseV4Base { public RecordsExecutiveSummary Data { get; set; } } public class RecordsAccreditationResult : StandardApiResponseV4Base { public RecordsAccreditation Data { get; set; } } public class RecordsCommunityRiskResult : StandardApiResponseV4Base { public RecordsCommunityRisk Data { get; set; } } + public class RecordsReadinessResult : StandardApiResponseV4Base { public RecordsReadiness Data { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index dc906449..bea1ca81 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2814,8 +2814,9 @@ Inspection, violation, permit, hydrant, occupancy, training and response evidence; prevention sections only when their module is on. - + Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. + Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules (each authorizes the caller itself) plus readiness-packet evidence; the unit board joins them on the unit id. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs index 11150fc8..00739921 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs @@ -57,6 +57,11 @@ public Task Accreditation(string start = null, string end = null, => Page(new RecordsAccreditationView { Action = nameof(Accreditation), ShowTargets = true }, start, end, stationGroupId, null, turnoutTargetSeconds, travelTargetSeconds, async (m, q, ct) => m.Accreditation = await _analytics.GetAccreditationAsync(DepartmentId, UserId, q, ct)); + [HttpGet] + public Task Readiness(string start = null, string end = null, int? stationGroupId = null) + => Page(new RecordsReadinessView { Action = nameof(Readiness) }, start, end, stationGroupId, null, null, null, + async (m, q, ct) => m.Readiness = await _analytics.GetReadinessAsync(DepartmentId, UserId, q, ct)); + [HttpGet] public Task CommunityRisk(string start = null, string end = null, int? stationGroupId = null) => Page(new RecordsCommunityRiskView { Action = nameof(CommunityRisk) }, start, end, stationGroupId, null, null, null, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormValuesAttribute.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormValuesAttribute.cs new file mode 100644 index 00000000..842f46a8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderFormValuesAttribute.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Resgrid.Web.Areas.User.Controllers +{ + // HTML number/date controls submit invariant values even when their display is localized. + // Keep this at the work-order form boundary; API JSON and other controllers retain their binders. + [AttributeUsage(AttributeTargets.Class)] + public sealed class WorkOrderFormValuesAttribute : Attribute, IResourceFilter + { + public void OnResourceExecuting(ResourceExecutingContext context) + { + if (!context.HttpContext.Request.HasFormContentType) return; + for (var i = 0; i < context.ValueProviderFactories.Count; i++) + if (context.ValueProviderFactories[i] is FormValueProviderFactory) + context.ValueProviderFactories[i] = new InvariantFormFactory(); + } + public void OnResourceExecuted(ResourceExecutedContext context) { } + private sealed class InvariantFormFactory : IValueProviderFactory + { + public async Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + // Reuse the framework's size/error handling before replacing only the form culture. + var start = context.ValueProviders.Count; + await new FormValueProviderFactory().CreateValueProviderAsync(context); + for (var i = context.ValueProviders.Count - 1; i >= start; i--) + if (context.ValueProviders[i] is FormValueProvider) + context.ValueProviders[i] = new FormValueProvider(BindingSource.Form, context.ActionContext.HttpContext.Request.Form, CultureInfo.InvariantCulture); + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs new file mode 100644 index 00000000..d67ff651 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderOperationsController.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Globalization; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; +using Resgrid.Web.Areas.User.Models.WorkOrders; + +namespace Resgrid.Web.Areas.User.Controllers +{ + [WorkOrderFormValues] + public sealed partial class WorkOrdersController + { + [HttpGet] + public async Task Operations(int id, int vendorAfterId = 0, int movementAfterId = 0) => View("Operations", new WorkOrderOperationsView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor), VendorCharges = await OperationsService.VendorChargesAsync(Actor, id, vendorAfterId), Movements = await OperationsService.PartMovementsAsync(Actor, id, movementAfterId) }); + [HttpGet] + public async Task Policy() => View("Policy", new WorkOrderPolicyView { Input = await OperationsService.PolicyAsync(Actor), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); + [HttpPost, ValidateAntiForgeryToken] + public async Task SavePolicy(WorkOrderPolicyInput input, string holidays, string businessStart, string businessEnd, List weekdays) + { + if (input?.Calendar == null || input.SpendingRules == null || input.Calendar.Targets == null) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + if (!TimeOnly.TryParseExact(businessStart, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) || !TimeOnly.TryParseExact(businessEnd, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end) + || weekdays == null || weekdays.Any(d => !new[] { 1, 2, 4, 8, 16, 32, 64 }.Contains(d))) throw new WorkOrderException(400, "OperationsPolicyInvalid"); + input.Calendar.StartMinute = start.Hour * 60 + start.Minute; input.Calendar.EndMinute = end == TimeOnly.MinValue ? 1440 : end.Hour * 60 + end.Minute; + input.Calendar.Weekdays = weekdays.Aggregate(0, (mask, day) => mask | day); + input.SpendingRules = input.SpendingRules.Where(r => !string.IsNullOrWhiteSpace(r.Currency)).ToList(); + input.Calendar.Targets = input.Calendar.Targets.Where(t => t.ResponseMinutes != 0 || t.RepairMinutes != 0).ToList(); + input.Calendar.Holidays = (holidays ?? "").Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToList(); + await OperationsService.SavePolicyAsync(Actor, input); return Json(new { id = 0 }); + } + [HttpGet] + public async Task Bulk(int page = 0) + { + if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); + return View("Bulk", new WorkOrderBulkView { Orders = await _orders.ListAsync(Actor, new WorkOrderFilter { Page = page }), Choices = await _orders.ChoicesAsync(Actor), Page = page }); + } + [HttpGet] + public IActionResult ImportTemplate() => File(Encoding.UTF8.GetBytes(WorkOrderCsvImport.Header + "\r\n"), "text/csv; charset=utf-8", "work-orders-template.csv"); + [HttpPost, ValidateAntiForgeryToken] + public async Task PreviewBulk(WorkOrderBulkForm form) + { + if (form == null || form.Selections == null) throw new WorkOrderException(400, "BulkInputInvalid"); + var input = !string.IsNullOrWhiteSpace(form.Csv) ? WorkOrderCsvImport.Parse(form.Csv, form.RequestId) : new WorkOrderBulkInput { RequestId = form.RequestId, + Rows = form.Selections.Where(s => s.Selected).Select((s, i) => new WorkOrderBulkRow { RowNumber = i + 1, WorkOrderId = s.Id, Assignment = new WorkOrderAssignment { Revision = s.Revision, UserId = form.UserId, RoleId = form.RoleId } }).ToList() }; + var result = await OperationsService.PreviewBulkAsync(Actor, input); input.PreviewHash = result.PreviewHash; + return View("Bulk", new WorkOrderBulkView { Input = input, Result = result }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task ApplyBulk(string payload) + { + WorkOrderBulkInput input; + try { input = JsonConvert.DeserializeObject(payload ?? "", new JsonSerializerSettings { MaxDepth = 16 }); } + catch (JsonException) { throw new WorkOrderException(400, "BulkInputInvalid"); } + return View("Bulk", new WorkOrderBulkView { Input = input, Result = await OperationsService.ApplyBulkAsync(Actor, input), Applied = true }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task RequestApproval(int id, WorkOrderApprovalInput input) { await OperationsService.RequestApprovalAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task DecideApproval(int id, WorkOrderApprovalInput input) { await OperationsService.DecideApprovalAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task AddVendorCharge(int id, WorkOrderVendorChargeInput input) { await OperationsService.AddVendorChargeAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task VoidVendorCharge(int id, int chargeId, int revision, string reason) { await OperationsService.VoidVendorChargeAsync(Actor, id, chargeId, revision, reason); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task ReservePart(int id, WorkOrderPartInput input) { await OperationsService.ReservePartAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task MovePart(int id, int partId, WorkOrderPartMovementInput input) { await OperationsService.MovePartAsync(Actor, id, partId, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task CancelPartMovement(int id, int movementId, int revision, string reason) { await OperationsService.CancelPartMovementAsync(Actor, id, movementId, revision, reason); return Json(new { id }); } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs new file mode 100644 index 00000000..9ff65cc8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderReportingController.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.WorkOrders; +using Resgrid.Web.Areas.User.Models.WorkOrders; + +namespace Resgrid.Web.Areas.User.Controllers +{ + public sealed partial class WorkOrdersController + { + [HttpGet] + public async Task Reports(WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + var stats = await _reports.GetWorkOrderStatsAsync(Actor, query); + query.FromUtc = stats.FromUtc; query.UntilUtc = stats.UntilUtc; + return View("Reports", new WorkOrderReportView { Query = query, Stats = stats, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor) }); + } + [HttpGet] + public async Task History(WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + return View("Reports", new WorkOrderReportView { Query = query, History = await _reports.GetWorkOrderHistoryAsync(Actor, query), Choices = await _orders.ChoicesAsync(Actor) }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task ExportCsv(WorkOrderReportQuery query) + { + if (_reports == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + Response.Headers["X-Content-Type-Options"] = "nosniff"; + return File(await _reports.ExportWorkOrdersAsync(Actor, query), "text/csv; charset=utf-8", "work-order-history.csv"); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs index acf5ce05..03003790 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs @@ -22,14 +22,16 @@ public sealed partial class WorkOrdersController : SecureBaseController { private readonly IWorkOrdersService _orders; private readonly IWorkOrderMaintenanceService _maintenance; + private readonly IWorkOrderReportingService _reports; + private IWorkOrderOperationsService OperationsService => _orders as IWorkOrderOperationsService ?? throw new WorkOrderException(503, "MaintenanceUnavailable"); private readonly IWorkOrderAuthorizationService _authorization; private readonly IReadinessAccessService _access; private readonly IProtectedGrantContext _grant; private readonly IDepartmentDataProtectionService _protection; private readonly IStringLocalizer _strings; public WorkOrdersController(IWorkOrdersService orders, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IProtectedGrantContext grant, - IDepartmentDataProtectionService protection, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null) - { _orders = orders; _authorization = authorization; _access = access; _grant = grant; _protection = protection; _strings = strings; _maintenance = maintenance; } + IDepartmentDataProtectionService protection, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null, IWorkOrderReportingService reports = null) + { _orders = orders; _authorization = authorization; _access = access; _grant = grant; _protection = protection; _strings = strings; _maintenance = maintenance; _reports = reports; } private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { @@ -44,7 +46,7 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context if (HttpMethods.IsGet(Request.Method) && ex.Code == "ProtectedDataRequired") { int.TryParse(Request.Query["id"].ToString() is { Length: > 0 } id ? id : context.RouteData.Values["id"]?.ToString(), out var orderId); - executed.Result = View("Locked", new WorkOrderLockedView { Page = context.RouteData.Values["action"]?.ToString(), Id = orderId, Filter = context.ActionArguments.TryGetValue("filter", out var filters) && filters is WorkOrderFilter workOrderFilter ? workOrderFilter : new WorkOrderFilter() }); + executed.Result = View("Locked", new WorkOrderLockedView { Query = context.ActionArguments.TryGetValue("query", out var reportQuery) && reportQuery is WorkOrderReportQuery rq ? rq : new WorkOrderReportQuery(), Page = context.RouteData.Values["action"]?.ToString(), Id = orderId, Filter = context.ActionArguments.TryGetValue("filter", out var filters) && filters is WorkOrderFilter workOrderFilter ? workOrderFilter : new WorkOrderFilter() }); } else executed.Result = StatusCode(ex.StatusCode, new { message = _strings[ex.Code].Value, code = ex.Code }); } @@ -97,6 +99,6 @@ public async Task Upload(int id, int revision, IFormFile file) [HttpPost, ValidateAntiForgeryToken] public async Task Export(int id) { var detail = await _orders.GetAsync(Actor, id); return File(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(detail, Formatting.Indented)), "application/json", "work-order-" + id + ".json"); } [HttpPost, ValidateAntiForgeryToken] - public Task Reopen(string destination, int id, WorkOrderFilter filter) => destination switch { "Detail" => Detail(id), "Edit" => Edit(id), "New" => New(), "Recurrence" => Recurrence(id), "EditRecurrence" => EditRecurrence(id), "NewRecurrence" => NewRecurrence(), "Recurrences" => Recurrences(filter?.Page ?? 0), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; + public Task Reopen(string destination, int id, WorkOrderFilter filter, WorkOrderReportQuery query = null) => destination switch { "Operations" => Operations(id), "Policy" => Policy(), "Bulk" => Bulk(filter?.Page ?? 0), "Reports" => Reports(query ?? new WorkOrderReportQuery()), "History" => History(query ?? new WorkOrderReportQuery()), "ExportCsv" => ExportCsv(query ?? new WorkOrderReportQuery()), "Detail" => Detail(id), "Edit" => Edit(id), "New" => New(), "Recurrence" => Recurrence(id), "EditRecurrence" => EditRecurrence(id), "NewRecurrence" => NewRecurrence(), "Recurrences" => Recurrences(filter?.Page ?? 0), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs index 36a2c5fb..99eef868 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs @@ -75,4 +75,10 @@ public class RecordsCommunityRiskView : RecordsAnalyticsBaseView public RecordsCommunityRisk Risk { get; set; } public override RecordsAnalyticsBase Result => Risk; } + + public class RecordsReadinessView : RecordsAnalyticsBaseView + { + public RecordsReadiness Readiness { get; set; } + public override RecordsAnalyticsBase Result => Readiness; + } } diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs new file mode 100644 index 00000000..0873df91 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderOperationsViews.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Web.Areas.User.Models.WorkOrders +{ + public sealed class WorkOrderOperationsView + { + public WorkOrderDetail Detail { get; set; } + public WorkOrderChoices Choices { get; set; } + public List VendorCharges { get; set; } = new(); + public List Movements { get; set; } = new(); + } + public sealed class WorkOrderBulkSelection { public bool Selected { get; set; } public int Id { get; set; } public int Revision { get; set; } } + public sealed class WorkOrderBulkForm + { + public string RequestId { get; set; } = Guid.NewGuid().ToString("D"); + public string Csv { get; set; } + public string UserId { get; set; } + public int? RoleId { get; set; } + public List Selections { get; set; } = new(); + } + public sealed class WorkOrderBulkView + { + public WorkOrderBulkForm Form { get; set; } = new(); + public WorkOrderPage Orders { get; set; } + public WorkOrderChoices Choices { get; set; } + public WorkOrderBulkInput Input { get; set; } + public WorkOrderBulkResult Result { get; set; } + public int Page { get; set; } + public bool Applied { get; set; } + } + public sealed class WorkOrderPolicyView { public WorkOrderPolicyInput Input { get; set; } public bool CanWrite { get; set; } } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs index e7c7ed74..d0096c7d 100644 --- a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs @@ -5,7 +5,8 @@ namespace Resgrid.Web.Areas.User.Models.WorkOrders public sealed class WorkOrderIndexView { public WorkOrderPage Orders { get; set; } public WorkOrderFilter Filter { get; set; } public WorkOrderChoices Choices { get; set; } } public sealed class WorkOrderEditView { public int Id { get; set; } public WorkOrderInput Input { get; set; } public WorkOrderChoices Choices { get; set; } public bool CanManage { get; set; } } public sealed class WorkOrderDetailView { public System.Collections.Generic.List Holds { get; set; } = new(); public bool CanRelease { get; set; } public WorkOrderDetail Detail { get; set; } public WorkOrderChoices Choices { get; set; } } - public sealed class WorkOrderLockedView { public WorkOrderFilter Filter { get; set; } = new WorkOrderFilter(); public string Page { get; set; } public int Id { get; set; } } + public sealed class WorkOrderReportView { public WorkOrderReportQuery Query { get; set; } public WorkOrderStats Stats { get; set; } public WorkOrderHistoryPage History { get; set; } public WorkOrderChoices Choices { get; set; } } + public sealed class WorkOrderLockedView { public WorkOrderReportQuery Query { get; set; } = new(); public WorkOrderFilter Filter { get; set; } = new WorkOrderFilter(); public string Page { get; set; } public int Id { get; set; } } public sealed class WorkOrderRecurrencePageView { public System.Collections.Generic.List Rows { get; set; } = new(); public int Page { get; set; } public bool CanWrite { get; set; } } public sealed class WorkOrderRecurrenceEditView { public WorkOrderRecurrenceInput Input { get; set; } = new(); public WorkOrderChoices Choices { get; set; } = new(); } public sealed class WorkOrderRecurrenceDetailView { public WorkOrderRecurrenceView Detail { get; set; } public bool CanWrite { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml new file mode 100644 index 00000000..24ab8c92 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml @@ -0,0 +1,168 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsReadinessView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["ReadinessHeader"]; + var r = Model.Readiness; + string StateLabel(int? state) => !state.HasValue ? "-" : System.Enum.IsDefined(typeof(UnitStateTypes), state.Value) ? RmsEnumDisplay.Label(state.Value) : state.Value.ToString(); + var trendId = "t" + Guid.NewGuid().ToString("N"); +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (r != null) + { +

@L["ReadinessIntro"]

+
@L["ReadinessUnits"]
@r.Units.Count(u => u.NeedsAttention) @L["ReadinessNeedsAttention"]
+ @if (r.Units.Count == 0) {

@L["AnalyticsNoData"]

} + else + { +
+ + + + + + + + + @foreach (var u in r.Units) + { + + + + + + + + + + + + + + + + } + +
@L["ReadinessUnit"]@L["Station"]@L["ReadinessState"]@L["ReadinessChecks"] (@L["AnalyticsCompleted"]/@L["ReadinessChecksExpected"])@L["ReadinessMissed"]@L["ReadinessCompletionRate"]@L["ReadinessWorkOrders"] @L["Open"]@L["Overdue"]@L["ReadinessOnHold"]@L["ReadinessAssetsAssigned"]@L["ReadinessOutForRepair"]@L["AnalyticsExpired"]@L["ReadinessPackets"]
@if (u.NeedsAttention) { } @u.Label@(u.StationGroupLabel ?? "-")@StateLabel(u.State) @if (u.StateOn.HasValue) { (@RmsEnumDisplay.Utc(u.StateOn)) }@(r.Checklists == null ? "-" : u.ChecklistCompleted + "/" + u.ChecklistExpected)@(r.Checklists == null ? "-" : u.ChecklistMissed.ToString())@(r.Checklists == null ? "-" : RmsAnalyticsDisplay.Percent(u.ChecklistCompletionRatePercent))@(r.WorkOrders == null ? "-" : u.OpenWorkOrders.ToString())@(r.WorkOrders == null ? "-" : u.OverdueWorkOrders.ToString())@(r.WorkOrders == null ? "-" : u.OnHoldWorkOrders.ToString())@(r.Equipment == null ? "-" : u.AssetsAssigned.ToString())@(r.Equipment == null ? "-" : u.AssetsOutForRepair.ToString())@(r.Equipment == null ? "-" : u.AssetsExpired.ToString())@u.ReadinessPackets
+
+ } +
+
+
+
@L["ReadinessChecklists"]
+ @if (!r.ChecklistsEnabled) {

@L["AnalyticsModuleOff"]

} + else if (r.ChecklistsForbidden || r.Checklists == null) {

@L["ReadinessForbidden"]

} + else + { + var ck = r.Checklists; +

@L["ReadinessCohort"]: @ck.CohortStart.ToString("yyyy-MM-dd") - @ck.CohortEnd.AddDays(-1).ToString("yyyy-MM-dd")

+ @if (ck.IsRedacted) {
@L["ReadinessRedacted"]
} + @foreach (var s in ck.UnavailableSources) {
@L["ReadinessUnavailableSources"]: @s
} +
+
@RmsAnalyticsDisplay.Percent(ck.CompletionRatePercent)
@L["ReadinessCompletionRate"]
@ck.Completed / @ck.Expected
+
@RmsAnalyticsDisplay.Percent(ck.OnTimePercent)
@L["ReadinessOnTimeRate"]
@ck.OnTime @L["ReadinessOnTime"]
+
@ck.Missed
@L["ReadinessMissed"]
@ck.Skipped @L["ReadinessSkipped"]
+
+ @if (ck.Trend.Count > 0) + { +
@L["ReadinessTrend"]
+
+ + } + @foreach (var block in new[] { (L["ByUnit"].Value, ck.ByUnit), (L["AnalyticsByStationGroup"].Value, ck.ByStationGroup), (L["AnalyticsByPerson"].Value, ck.ByPerson), (L["ReadinessAssets"].Value, ck.ByAsset) }) + { + if (block.Item2.Count == 0) { continue; } +
@block.Item1
+ + + @foreach (var row in block.Item2.Take(25)) { } +
@L["ReadinessChecksExpected"]@L["AnalyticsCompleted"]@L["ReadinessOnTime"]@L["ReadinessMissed"]@L["ReadinessSkipped"]@L["ReadinessCompletionRate"]
@row.Label@row.Expected@row.Completed@row.OnTime@row.Missed@row.Skipped@RmsAnalyticsDisplay.Percent(row.CompletionRatePercent)
+ } + } +
+
+
+
@L["ReadinessWorkOrders"]
+ @if (!r.MaintenanceEnabled) {

@L["AnalyticsModuleOff"]

} + else if (r.MaintenanceForbidden || r.WorkOrders == null) {

@L["ReadinessForbidden"]

} + else + { + var wo = r.WorkOrders; +

@L["ReadinessCohort"]: @wo.CohortStart.ToString("yyyy-MM-dd") - @wo.CohortEnd.AddDays(-1).ToString("yyyy-MM-dd")

+
+
@wo.Total
@L["AnalyticsTotal"]
+
@wo.Open
@L["Open"]
+
@wo.Overdue
@L["Overdue"]
+
@RmsAnalyticsDisplay.Number(wo.MeanTimeToRepairHours)
@L["ReadinessMttr"]
@wo.RepairSamples @L["ReadinessRepairSamples"] · @wo.MissingRepairTimes @L["ReadinessMissingRepairTimes"]
+
+
+
@await Html.PartialAsync("_AnalyticsCounts", wo.OpenByPriority, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ReadinessOpenByPriority"].Value }, { "Chart", "doughnut" } })
+
@await Html.PartialAsync("_AnalyticsCounts", wo.OpenByAge, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ReadinessOpenByAge"].Value }, { "Chart", "bar" } })
+
+ @if (wo.Costs.Count > 0) + { +
@L["ReadinessCosts"]
+ + + @foreach (var cost in wo.Costs) { } +
@L["ReadinessLabor"]@L["ReadinessParts"]@L["AnalyticsTotal"]@L["ReadinessUnknownCosts"]
@cost.Currency@cost.Labor.ToString("N2")@cost.Parts.ToString("N2")@cost.Total.ToString("N2")@cost.Unknown
+ } + @if (wo.ByUnit.Count > 0) + { +
@L["ByUnit"]
+ + + @foreach (var row in wo.ByUnit.Take(25)) { } +
@L["AnalyticsTotal"]@L["Open"]@L["Overdue"]@L["ReadinessOnHold"]@L["ReadinessEmergency"]
@row.Label@row.Total@row.Open@row.Overdue@row.OnHold@row.Emergency
+ } + } +
+
+
+
+
+
@L["ReadinessEquipment"]
+ @if (!r.InventoryEnabled) {

@L["AnalyticsModuleOff"]

} + else if (r.InventoryForbidden || r.Equipment == null) {

@L["ReadinessForbidden"]

} + else + { + var eq = r.Equipment; +
+
@L["ReadinessAssets"]
@eq.Assets (@L["AnalyticsInService"] @eq.InService · @L["ReadinessIssued"] @eq.Issued)
+
@L["ReadinessOutForRepair"]
@eq.OutForRepair · @L["ReadinessDamaged"] @eq.Damaged
+
@L["AnalyticsExpired"]
@eq.Expired · @L["ReadinessExpiringSoon"] @eq.ExpiringWithin30Days
+
@L["ReadinessIssuancesOutstanding"]
@eq.IssuancesOutstanding · @L["ReadinessOverdueReturns"] @eq.IssuancesOverdueReturn
+
+ @await Html.PartialAsync("_AnalyticsCounts", eq.ByStatus, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ReadinessByStatus"].Value }, { "Chart", "doughnut" } }) + @if (eq.ByUnit.Count > 0) + { +
@L["ByUnit"]
+ + + @foreach (var row in eq.ByUnit.Take(25)) { } +
@L["ReadinessAssets"]@L["ReadinessIssued"]@L["ReadinessOutForRepair"]@L["ReadinessDamaged"]@L["AnalyticsExpired"]@L["ReadinessOverdueReturns"]
@row.Label@row.Assets@row.Issued@row.OutForRepair@row.Damaged@row.Expired@row.OverdueReturns
+ } + } +
+
+
+
@L["ReadinessEvidence"]
+
+
@r.Evidence.PacketsCaptured
@L["ReadinessPacketsCaptured"]
+
@r.Evidence.RecordsWithPacket / @r.Evidence.CallLinkedRecords
@L["ReadinessRecordsWithPacket"]
+
@RmsAnalyticsDisplay.Percent(r.Evidence.CoveragePercent)
@L["ReadinessCoverage"]
+
+ @await Html.PartialAsync("_AnalyticsCounts", r.Evidence.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "bar" }, { "Take", 24 } }) +
+
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml index c0204071..ef5addfd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml @@ -11,7 +11,8 @@ ("ResponsePerformance", "fa-clock-o", L["AnalyticsResponse"].Value, true, true), ("Workload", "fa-tasks", L["AnalyticsWorkload"].Value, true, false), ("Accreditation", "fa-certificate", L["AnalyticsAccreditation"].Value, false, true), - ("CommunityRisk", "fa-shield", L["AnalyticsCommunityRisk"].Value, false, false) + ("CommunityRisk", "fa-shield", L["AnalyticsCommunityRisk"].Value, false, false), + ("Readiness", "fa-truck", L["ReadinessHeader"].Value, false, false) }; var result = Model.Result; } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml new file mode 100644 index 00000000..6affd16e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Bulk.cshtml @@ -0,0 +1,35 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderBulkView +@using Newtonsoft.Json +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["BulkOperations"]; } +
+

@localizer["BulkOperations"]

@localizer["WorkOrders"] +@await Html.PartialAsync("_Protection") +

@localizer["BulkHelp"]

+@if (Model.Result != null) { +

@localizer[Model.Applied ? "BulkResults" : "BulkPreview"]

+ +@foreach (var row in Model.Result.Rows) { } +
@localizer["RowNumber"]@localizer["Number"]@localizer["Title"]@localizer["Status"]
@row.RowNumber@row.WorkOrderId@row.Title@localizer[row.ErrorCode ?? (row.Applied ? "BulkApplied" : "BulkReady")]
+@if (Model.Result.Rows.Any(r => r.ErrorCode == null && !r.Applied)) { +
@Html.AntiForgeryToken() +
+} +@localizer["BulkOperations"] +} else { +

@localizer["ImportWorkOrders"]

@localizer["ImportHelp"]

@localizer["ImportTemplate"] +
@Html.AntiForgeryToken() + +
+

@localizer["BulkAssignment"]

+
@Html.AntiForgeryToken() + + + +@for (var i = 0; i < Model.Orders.Items.Count; i++) { var row = Model.Orders.Items[i]; } +
@localizer["Select"]@localizer["Number"]@localizer["Title"]@localizer["Status"]
@row.Number@row.Title@localizer["Status" + row.Status]
+@if (Model.Page > 0) { @localizer["Previous"] } +@if (Model.Orders.HasMore) { @localizer["Next"] } +} +
+@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml index 437503e3..7bca3d89 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -5,17 +5,18 @@
@await Html.PartialAsync("_Protection")
-

@o.Number — @o.Title

@localizer["Status" + o.Status] · @localizer["Priority" + o.Priority] · @localizer["DueOn"]: @o.DueOn?.ToString("u")

+

@o.Number — @o.Title

@localizer["Status" + o.Status] · @localizer["Priority" + o.Priority] · @localizer["DueOn"]: @o.DueOn?.ToString("u")

@if (!d.CanWrite) {

@localizer["ReadOnlyHistory"]

}

@c.Description

@c.LocationText

-

@localizer["ReportedBy"]: @d.ReportedBy · @localizer["AssignedTo"]: @(Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name ?? localizer["None"].Value)

-

@localizer["Currency"]: @c.Currency · @localizer["EstimatedCost"]: @c.EstimatedCost · @localizer["ApprovedCost"]: @c.ApprovedCost · @localizer["CostCenter"]: @c.CostCenter

+

@localizer["ReportedBy"]: @d.ReportedBy · @localizer["AssignedTo"]: @(Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name ?? localizer["None"].Value)

+

@localizer["Currency"]: @c.Currency · @localizer["EstimatedCost"]: @c.EstimatedCost · @localizer["ApprovedCost"]: @c.ApprovedCost · @localizer["CostCenter"]: @c.CostCenter

@localizer["VendorDetails"]: @c.VendorDetails

@localizer["WarrantyReference"]: @c.WarrantyReference

-@if (c.SafetyCritical || c.HazardousWork) {

@localizer[c.HazardousWork ? "HazardousWork" : "SafetyCritical"] — @localizer["SafetyNotice"]

} +

@localizer["Operations"]

+@if (c.SafetyCritical || c.HazardousWork) {

@localizer[c.HazardousWork ? "HazardousWork" : "SafetyCritical"] — @localizer["SafetyNotice"]

}
@localizer["ProcedureReference"] / @localizer["ProcedureVersion"]
@c.ProcedureReference / @c.ProcedureVersion
@localizer["PermitReference"]
@c.PermitReference
@localizer["IsolationReference"]
@c.IsolationReference
@localizer["QualifiedPersonnel"]
@c.QualifiedPersonnel
-@if (c.Steps.Count > 0) {

@localizer["TaskSteps"]

    @foreach (var step in c.Steps) {
  1. @step.Text — @localizer[step.Completed ? "Done" : "Pending"]
  2. }
} +@if (c.Steps.Count > 0) {

@localizer["TaskSteps"]

    @foreach (var step in c.Steps) {
  1. @step.Text — @localizer[step.Completed ? "Done" : "Pending"]
  2. }
} @if (d.CompletedOn.HasValue) {

@localizer["Resolution"]

@c.Resolution

@localizer["Cause"]: @c.Cause

} -@if (d.ClosedOn.HasValue) {

@localizer["VerificationEvidence"]: @c.VerificationEvidence

@localizer["VerifiedBy"]: @d.VerifiedBy · @d.ClosedOn?.ToString("u")

} +@if (d.ClosedOn.HasValue) {

@localizer["VerificationEvidence"]: @c.VerificationEvidence

@localizer["VerifiedBy"]: @d.VerifiedBy · @d.ClosedOn?.ToString("u")

} @if (d.CanEdit) { @localizer["EditWorkOrder"] }
@Html.AntiForgeryToken()
@if (d.CanAccept) {
@Html.AntiForgeryToken()
} @@ -40,9 +41,9 @@ } @await Html.PartialAsync("_Maintenance", Model) -

@localizer["Activity"]

    @foreach (var a in d.Activities) {
  1. @localizer["Activity" + a.Type] · @a.CreatedOn.ToString("u") · @a.UserId @if (a.NewStatus.HasValue) { → @localizer["Status" + (WorkOrderStatus)a.NewStatus.Value] }

    @a.Note

    +

    @localizer["Activity"]

      @foreach (var a in d.Activities) {
    1. @localizer["Activity" + a.Type] · @a.CreatedOn.ToString("u") · @a.UserId @if (a.NewStatus.HasValue) { → @localizer["Status" + (WorkOrderStatus)a.NewStatus.Value] }

      @a.Note

      @if (a.AssignedToUserId != null || a.AssignedToRoleId.HasValue) {

      @localizer["AssignedTo"]: @(a.AssignedToUserId ?? a.AssignedToRoleId.ToString())

      } -@if (a.RevisedDueOn.HasValue) {

      @localizer["OriginalDue"]: @a.OriginalDueOn?.ToString("u") → @localizer["RevisedDue"]: @a.RevisedDueOn.Value.ToString("u")

      } +@if (a.RevisedDueOn.HasValue) {

      @localizer["OriginalDue"]: @a.OriginalDueOn?.ToString("u") → @localizer["RevisedDue"]: @a.RevisedDueOn.Value.ToString("u")

      } @if (a.Snapshot != null) {
      @localizer["Description"]: @a.Snapshot.Title

      @a.Snapshot.Description

      @localizer["Resolution"]: @a.Snapshot.Resolution

      @localizer["Cause"]: @a.Snapshot.Cause

      @localizer["VerificationEvidence"]: @a.Snapshot.VerificationEvidence

      @localizer["ProcedureReference"]: @a.Snapshot.ProcedureReference (@a.Snapshot.ProcedureVersion)

      @@ -61,7 +62,7 @@ }

      @localizer["Parts"]

      @foreach (var p in d.Parts) { } +@if (d.CanContribute) { @Html.AntiForgeryToken() } } else if (p.Staged) { @localizer["PartsAllocation"] } else if (d.CanContribute) { @Html.AntiForgeryToken() } }
      @localizer["Description"]@localizer["Quantity"]@localizer["UnitCost"]@localizer["Actions"]
      @p.Content.Description @if (p.InventoryItemId != null) {

      @localizer["InventoryParts"]@p.InventoryTransactionId

      }
      @p.Content.Quantity@p.Content.UnitCost @(p.Content.Currency ?? c.Currency)@if (p.VoidedOn.HasValue) { @localizer["Voided"]: @p.Content.VoidReason } else if (p.AwaitingWitness) { @localizer["InventoryWitnessPending"]@p.InventoryWitnessRequestId@localizer["InventoryParts"] -@if (d.CanContribute) {
      @Html.AntiForgeryToken()
      } } else if (d.CanContribute) {
      @Html.AntiForgeryToken()
      }
      @if (d.CanContribute) {
      @Html.AntiForgeryToken()@await Html.PartialAsync("_PartInventory")
      }

      @localizer["Files"]

        @foreach (var f in d.Files) {
      • @f.Name (@f.Size) @if (f.WithdrawnOn.HasValue) { @localizer["Withdrawn"] } else if (d.CanContribute) {
        @Html.AntiForgeryToken()
        }
      • }
      diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml index 7b9fc384..841c50e1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml @@ -5,7 +5,9 @@

      @localizer["WorkOrders"]

      @localizer["PreventiveMaintenance"]

      Readiness Pro

      @await Html.PartialAsync("_Protection") -@if (Model.Orders.CanWrite) { @localizer["NewWorkOrder"] } +@localizer["MaintenanceReports"] +@localizer["ServiceHistory"] +@if (Model.Orders.CanWrite) { @localizer["NewWorkOrder"] @localizer["BulkOperations"] @localizer["OperationsPolicy"] } else {

      @localizer["ReadOnlyHistory"] Readiness Pro

      }
      @Html.AntiForgeryToken() @@ -26,6 +28,6 @@ else {

      @localizer["ReadOnlyHistory"]

      @if (Model.Orders.Items.Count == 0) {

      @localizer["NoWorkOrders"]

      } @if (Model.Filter.Page > 0) {
      @localizer["Previous"] } -@if (Model.Orders.HasMore) { @localizer["Next"] } +@if (Model.Orders.HasMore) { @localizer["Next"] }
@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml index b7c95290..0aec511b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Locked.cshtml @@ -6,7 +6,8 @@ @await Html.PartialAsync("_Protection") @Html.AntiForgeryToken() - @await Html.PartialAsync("_FilterFields", Model.Filter) + @if (Model.Page is "Reports" or "History" or "ExportCsv") { @await Html.PartialAsync("_ReportFields", Model.Query) } + else { @await Html.PartialAsync("_FilterFields", Model.Filter) }
diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml new file mode 100644 index 00000000..55157de8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Operations.cshtml @@ -0,0 +1,55 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderOperationsView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ var d = Model.Detail; var id = d.Order.Id; var c = d.Input.Content; ViewBag.Title = "Resgrid | " + localizer["Operations"]; } +
+

@localizer["Operations"]

@d.Order.Number +@await Html.PartialAsync("_Protection") +

@d.Order.Title

+
@localizer["ResponseDue"]
@d.ResponseDueOn?.ToString("u")
@localizer["RepairDue"]
@d.RepairDueOn?.ToString("u")
@localizer["RespondedOn"]
@d.ResponseOn?.ToString("u")
+

@localizer["SpendingApprovals"]

+@if (c.Approval != null) {

@localizer["ApprovalState" + c.Approval.State]: @c.Approval.Amount @c.Approval.Currency

@c.Approval.Reason

} +@if (d.CanContribute || d.CanEdit) { +
@Html.AntiForgeryToken() +
+} +@if (d.CanManage && c.Approval?.State == WorkOrderApprovalState.Requested) { +
@Html.AntiForgeryToken() +

@localizer["IndependentApprovalHelp"]

+} +

@localizer["VendorCharges"]

+ +@foreach (var charge in Model.VendorCharges) { } +
@localizer["VendorDetails"]@localizer["InvoiceReference"]@localizer["Description"]@localizer["TotalCost"]@localizer["Actions"]
@charge.Content.VendorName@charge.Content.InvoiceReference@charge.Content.Description@charge.Content.Amount @charge.Content.Currency @if (charge.VoidedOn.HasValue) { @localizer["Voided"] }@if (d.CanContribute && d.CanManage && !charge.VoidedOn.HasValue) {
@Html.AntiForgeryToken()
}
+@if (Model.VendorCharges.Count == 50) { @localizer["Next"] } +@if (d.CanContribute) { +
@Html.AntiForgeryToken() + + + +
+} +

@localizer["PartsAllocation"]

@localizer["PartsAllocationHelp"]

+ +@foreach (var part in d.Parts.Where(p => p.Staged)) { } +
@localizer["Description"]@localizer["ReservedParts"]@localizer["IssuedParts"]@localizer["ConsumedParts"]@localizer["ReturnedParts"]@localizer["Actions"]
@part.Content.Description@part.ReservedQuantity@part.IssuedQuantity@part.ConsumedQuantity@part.ReturnedQuantity +@if (d.CanContribute && (part.ReservedQuantity > 0 || part.IssuedQuantity > 0)) { +
@Html.AntiForgeryToken() + +
+
+} +
+@if (d.CanContribute) { +
@Html.AntiForgeryToken() +@await Html.PartialAsync("_PartInventory") +
+} +

@localizer["PartMovements"]

+@foreach (var movement in Model.Movements) { } +
@localizer["Parts"]@localizer["Actions"]@localizer["Quantity"]@localizer["CreatedOn"]@localizer["Status"]
@movement.PartId@localizer["PartMovement" + (WorkOrderPartMovementKind)movement.Kind]@movement.Quantity@movement.CreatedOn.ToString("u")@localizer[movement.Cancelled ? "StatusCancelled" : movement.InventoryOperationId != null && movement.InventoryTransactionId == null ? "InventoryWitnessPending" : "Done"] +@if (!movement.Cancelled && movement.InventoryOperationId != null && movement.InventoryTransactionId == null) { @movement.RequestId@localizer["InventoryParts"] } +@if (d.CanContribute && !movement.Cancelled && movement.InventoryOperationId != null && movement.InventoryTransactionId == null) {
@Html.AntiForgeryToken()
} +
@if (Model.Movements.Count == 50) { @localizer["Next"] } +
+@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml new file mode 100644 index 00000000..507557cd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Policy.cshtml @@ -0,0 +1,25 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderPolicyView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["OperationsPolicy"]; var p = Model.Input; } +
+

@localizer["OperationsPolicy"]

@localizer["WorkOrders"] +@await Html.PartialAsync("_Protection") +

@localizer["PolicyHelp"]

+
@Html.AntiForgeryToken() +
+@localizer["SpendingApprovals"] + +@for (var i = 0; i < Math.Max(4, p.SpendingRules.Count + 1); i++) { var r = p.SpendingRules.ElementAtOrDefault(i); } +
@localizer["Currency"]@localizer["SpendingThreshold"]
+

@localizer["BusinessCalendar"]

@localizer["BusinessCalendarHelp"]

+ +
@localizer["ServiceWeekdays"]@for (var d = 0; d < 7; d++) { var mask = 1 << d; }
+ + + + +@for (var i = 0; i < 4; i++) { var r = p.Calendar.Targets.SingleOrDefault(t => (int)t.Priority == i); } +
@localizer["Priority"]@localizer["ResponseMinutes"]@localizer["RepairMinutes"]
@localizer["Priority" + (WorkOrderPriority)i]
+
+@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml new file mode 100644 index 00000000..b3b3c604 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Reports.cshtml @@ -0,0 +1,44 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderReportView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ var destination = Model.Stats == null ? "History" : "Reports"; ViewBag.Title = "Resgrid | " + localizer[Model.Stats == null ? "ServiceHistory" : "MaintenanceReports"]; } +
+

@localizer[Model.Stats == null ? "ServiceHistory" : "MaintenanceReports"]

+@localizer["WorkOrders"] | @localizer["MaintenanceReports"] | @localizer["ServiceHistory"] +@await Html.PartialAsync("_Protection") +

@localizer[Model.Stats == null ? "HistoryMethod" : "ReportMethod"]

+
+@Html.AntiForgeryToken() + + + + + + + +@foreach (var a in Model.Choices.Assets) { } + +
+
+@if (Model.Stats != null) { +var stats = Model.Stats; +

@localizer["AsOfUtc"]: @stats.AsOfUtc.ToString("u")

+ + + + + + +
@localizer["TotalOrders"]@stats.Total@localizer["OpenOrders"]@stats.Open@localizer["OverdueOrders"]@stats.Overdue
@localizer["MeanRepairHours"]@(stats.MeanTimeToRepairHours?.ToString("0.##") ?? "—")@localizer["RepairSamples"]@stats.RepairSamples@localizer["MissingRepairTimes"]@stats.MissingRepairTimes
@localizer["ActiveRepairHours"]@stats.ActiveRepairHours.ToString("0.##")@localizer["WaitingHours"]@stats.WaitingHours.ToString("0.##")@localizer["DowntimeHours"]@stats.DowntimeHours.ToString("0.##")
@localizer["PreventiveCompliance"]@(stats.PreventiveCompliancePercent?.ToString("0.##") ?? "—")%@localizer["PreventiveOnTime"]@stats.PreventiveOnTime / @stats.PreventiveDue@localizer["RepeatedFailures"]@stats.RepeatedFailures
@localizer["MissingTimeHistory"]@stats.MissingTimeHistory@localizer["ResponseSlaBreaches"]@stats.ResponseSlaBreaches@localizer["RepairSlaBreaches"]@stats.RepairSlaBreaches

@localizer["OperationsAnalyticsHelp"]

+@foreach (var p in Enum.GetValues()) { }
@localizer["OpenByPriority"]
@localizer["Priority" + p]@stats.OpenByPriority[(int)p]
+@for (var i = 0; i < 4; i++) { }
@localizer["OpenByAge"]
@localizer["AgeBucket" + i]@stats.OpenByAge[i]
+@await Html.PartialAsync("_ReportCosts", stats.Costs) +} +
@Html.AntiForgeryToken()@await Html.PartialAsync("_ReportFields", Model.Query)
+ +@foreach (var entry in Model.History.Items) { } +
@localizer["ServiceHistory"]
@localizer["Number"]@localizer["Title"]@localizer["Status"]@localizer["Priority"]@localizer["CreatedOn"]@localizer["RepairHours"]@localizer["ActiveRepairHours"]@localizer["WaitingHours"]@localizer["DowntimeHours"]@localizer["Costs"]
@entry.Order.Number@entry.Order.Title@localizer["Status" + entry.Order.Status]@localizer["Priority" + entry.Order.Priority]@entry.Order.CreatedOn.ToString("u")@(entry.RepairHours?.ToString("0.##") ?? "—")@(entry.ActiveRepairHours?.ToString("0.##") ?? "—")@(entry.WaitingHours?.ToString("0.##") ?? "—")@entry.DowntimeHours.ToString("0.##")@await Html.PartialAsync("_ReportCosts", entry.Costs)
+@if (Model.History.Items.Count == 0) {

@localizer["NoWorkOrders"]

} +@if (Model.History.NextAfterId.HasValue) { @localizer["Next"] } +
+@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportCosts.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportCosts.cshtml new file mode 100644 index 00000000..22b0d11e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportCosts.cshtml @@ -0,0 +1,5 @@ +@model System.Collections.Generic.List +@inject IStringLocalizer localizer + +@foreach (var cost in Model) { } +
@localizer["Currency"]@localizer["Labor"]@localizer["Parts"]@localizer["VendorCharges"]@localizer["TotalCost"]@localizer["UnknownLabor"]@localizer["UnknownParts"]
@(cost.Currency ?? localizer["UnknownCurrency"].Value)@cost.Labor.ToString("0.00")@cost.Parts.ToString("0.00")@cost.Vendor.ToString("0.00")@cost.Total.ToString("0.00")@cost.UnknownLabor@cost.UnknownParts
diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportFields.cshtml new file mode 100644 index 00000000..8f50a3f0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_ReportFields.cshtml @@ -0,0 +1,9 @@ +@model Resgrid.Model.WorkOrders.WorkOrderReportQuery + + + + + + + + diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js index 5b34a85d..e17eeae1 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/workorders/work-orders.js @@ -64,7 +64,8 @@ button.addEventListener('click', async function () { var root = button.closest('.work-order-inventory'), form = root.closest('form'); var kind = button.dataset.kind, select = root.querySelector('select[data-kind="' + kind + '"]'); - var item = root.querySelector('select[data-kind="item"]').value; + var itemSelect = root.querySelector('select[data-kind="item"]'); + var item = itemSelect ? itemSelect.value : ''; if ((kind === 'lot' || kind === 'asset') && !item) return; var data = new FormData(); ['__RequestVerificationToken', '__ResgridProtectedGrant', '__ResgridProtectedGrantExpiresOn'].forEach(function (name) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs index e6dd1450..74be3f88 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs @@ -23,7 +23,8 @@ public async Task> Process(CancellationToken ct) ct.ThrowIfCancellationRequested(); using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); try { var result = await scope.Resolve().EscalateMaintenanceAsync(id); errors += result.Errors; generated += result.Generated; escalated += result.Escalated; } - catch { errors++; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { errors++; Resgrid.Framework.Logging.LogError($"Maintenance escalation failed for department {id}: {ex.GetType().FullName}."); } after = id; } if (departments.Count < 200) break; @@ -31,7 +32,7 @@ public async Task> Process(CancellationToken ct) return Tuple.Create(errors == 0, $"Maintenance: generated={generated}, escalated={escalated}, errors={errors}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { return Tuple.Create(false, "Maintenance sweep failed."); } + catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Maintenance escalation worker failed: {ex.GetType().FullName}."); return Tuple.Create(false, "Maintenance sweep failed."); } } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs index cf2a88ce..e76e564f 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs @@ -23,7 +23,8 @@ public async Task> Process(CancellationToken ct) ct.ThrowIfCancellationRequested(); using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); try { var result = await scope.Resolve().GenerateMaintenanceAsync(id); errors += result.Errors; generated += result.Generated; escalated += result.Escalated; } - catch { errors++; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { errors++; Resgrid.Framework.Logging.LogError($"Maintenance generation failed for department {id}: {ex.GetType().FullName}."); } after = id; } if (departments.Count < 200) break; @@ -31,7 +32,7 @@ public async Task> Process(CancellationToken ct) return Tuple.Create(errors == 0, $"Maintenance: generated={generated}, escalated={escalated}, errors={errors}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { return Tuple.Create(false, "Maintenance sweep failed."); } + catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Maintenance generation worker failed: {ex.GetType().FullName}."); return Tuple.Create(false, "Maintenance sweep failed."); } } } } From dc2e09645c8e0acae77bce707579db2d21ec343f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 9 Sep 2026 22:48:18 -0700 Subject: [PATCH 2/2] RG-T66 PR #509 fixes --- AGENTS.md | 39 ++++++++++++++++--- .../Areas/User/Checklists/Checklists.ar.resx | 1 + .../Areas/User/Checklists/Checklists.de.resx | 1 + .../Areas/User/Checklists/Checklists.el.resx | 1 + .../Areas/User/Checklists/Checklists.en.resx | 1 + .../Areas/User/Checklists/Checklists.es.resx | 1 + .../Areas/User/Checklists/Checklists.fr.resx | 1 + .../Areas/User/Checklists/Checklists.it.resx | 1 + .../Areas/User/Checklists/Checklists.pl.resx | 1 + .../Areas/User/Checklists/Checklists.resx | 1 + .../Areas/User/Checklists/Checklists.sv.resx | 1 + .../Areas/User/Checklists/Checklists.uk.resx | 1 + Core/Resgrid.Services/ChecklistReporting.cs | 7 +++- .../InventoryWorkOrderAllocations.cs | 2 +- .../WorkOrderRecurrenceService.cs | 2 +- Core/Resgrid.Services/WorkOrderReporting.cs | 2 +- .../WorkOrderVendorCharges.cs | 2 + Core/Resgrid.Services/WorkOrdersService.cs | 9 ++++- .../WorkflowSampleDataGenerator.cs | 4 +- .../WorkflowTemplateContextBuilder.cs | 4 +- .../Services/ProtectedReadServiceTests.cs | 22 +++++------ .../v4/ChecklistApiControllerBase.cs | 4 +- .../v4/RecordAnalyticsController.cs | 2 +- .../Resgrid.Web.Services.xml | 4 +- .../Areas/User/Views/WorkOrders/Detail.cshtml | 16 ++++---- 25 files changed, 94 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb4fdee5..337550c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,15 +78,42 @@ Each layer depends only on the layer(s) to its left: - **Providers** (`Resgrid.Providers.*`): External integrations — depends on Model - **Web/Workers**: Entry points — depend on everything -### Dependency Injection (Autofac + Service Locator) +### Dependency Injection (Autofac) -This codebase uses **Service Locator** pattern, NOT constructor injection: +**Constructor injection is the convention.** Services, repositories, providers, controllers +(MVC and v4 API), and hubs all declare their dependencies as constructor parameters and let +Autofac supply them. When a type needs a new dependency, add a constructor parameter — do NOT +reach for the service locator. Existing constructors are large (e.g. `CommunicationTestService` +takes 18 parameters, `DispatchController` 30); that is deliberate and expected, and it is what +keeps these types unit-testable with mocks. ```csharp -// How services are resolved throughout the codebase: -var service = Bootstrapper.GetKernel().Resolve(); +// The convention — constructor injection: +public class SomethingService : ISomethingService +{ + private readonly IDepartmentsService _departmentsService; + + public SomethingService(IDepartmentsService departmentsService) + { + _departmentsService = departmentsService; + } +} ``` +**Service Locator is the exception, not the rule.** `Bootstrapper.GetKernel().Resolve()` is +reserved for the specific places where no DI container is available at the call site or where a +container-managed constructor cannot be used: + +- Worker logic under `Workers/Resgrid.Workers.Framework/Logic/` (queue consumers constructed by + the job host, not by Autofac). +- Static helpers and extension methods that have no constructor to inject into. +- Deliberate lazy escapes from a construction-time dependency cycle — and even then prefer + `Lazy` as a constructor parameter (see `CallsService`'s `Lazy`) + over a service-locator call. + +If you find yourself adding `Bootstrapper.GetKernel().Resolve()` anywhere else, use a +constructor parameter instead. + The `Bootstrapper` class (in `Resgrid.Workers.Framework/Bootstrapper.cs`) initializes Autofac with module-based registration: ```csharp var builder = new ContainerBuilder(); @@ -193,9 +220,9 @@ Task type discrimination uses `(int)TaskTypes.SomeEnum`. When Billing API is configured but returns a response where `Data.Data` is null, `GetCurrentPlanForDepartmentAsync` returns null instead of the free plan fallback. Callers that access `plan.PlanId` or `plan.GetLimitForTypeAsInt()` will NRE. -### 3. Service Locator in Constructors +### 3. Injected Dependencies Are Never Null -Unlike modern DI, this codebase resolves dependencies explicitly in constructors via `Bootstrapper.GetKernel().Resolve()`. When examining stack traces, dependencies are never null due to constructor injection failures — the Bootstrapper would fail at app start. If a NullReferenceException occurs on a service call, the issue is typically in the return value of the called method, not the service reference itself. +Dependencies come from Autofac constructor injection, so they are never null at a call site — a missing registration fails at container build (app start), not at the point of use. If a NullReferenceException occurs on a service call, the issue is almost always in the **return value** of the called method, not the service reference itself. The same holds for the worker paths that use `Bootstrapper.GetKernel().Resolve()`: an unregistered type throws a resolution exception rather than handing back null. ### 4. Async State Machine Line Numbers diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index b8e6fb4a..9a59904f 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -693,4 +693,5 @@ اختر هدفًا متاحًا في قسمك. تعذّر إعادة بناء بعض الحالات التاريخية لأوامر العمل. لا تدّعي الحزمة تغطية الصيانة بالكامل. تقتصر أدلة أوامر العمل على النطاق المصرّح للقارئ بالوصول إليه حاليًا. + يجب إلغاء قفل البيانات المحمية للمتابعة. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index 99b395dd..91850052 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -693,4 +693,5 @@ Wählen Sie ein verfügbares Ziel in Ihrer Organisation. Einige historische Arbeitsauftragszustände konnten nicht rekonstruiert werden. Das Paket beansprucht keine vollständige Wartungsabdeckung. Arbeitsauftragsnachweise sind auf den aktuell berechtigten Lesebereich beschränkt. + Geschützte Daten müssen zum Fortfahren entsperrt werden. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 8cf5714b..a9003798 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -693,4 +693,5 @@ Επιλέξτε έναν διαθέσιμο στόχο στο τμήμα σας. Δεν ήταν δυνατή η ανακατασκευή ορισμένων ιστορικών καταστάσεων εντολών. Το πακέτο δεν εγγυάται πλήρη κάλυψη συντήρησης. Τα τεκμήρια εντολών περιορίζονται στο τρέχον εξουσιοδοτημένο πεδίο πρόσβασης του αναγνώστη. + Τα προστατευμένα δεδομένα πρέπει να ξεκλειδωθούν για να συνεχίσετε. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index a78f1888..7233cc24 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -693,4 +693,5 @@ Select an available target in your department. Some historical work-order states could not be reconstructed. The packet does not claim complete maintenance coverage. Work-order evidence is limited to the reader’s current authorized scope. + Protected data must be unlocked to continue. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index c4a6cbb1..d9e49b34 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -693,4 +693,5 @@ Seleccione un destino disponible en su departamento. No se pudieron reconstruir algunos estados históricos de las órdenes. El paquete no garantiza una cobertura completa del mantenimiento. Las pruebas de las órdenes se limitan al ámbito autorizado actual del lector. + Debe desbloquear los datos protegidos para continuar. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index 2334de67..f71745cc 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -693,4 +693,5 @@ Sélectionnez une cible disponible dans votre service. Certains états historiques des ordres n’ont pas pu être reconstitués. Le dossier ne garantit pas une couverture complète de la maintenance. Les preuves des ordres sont limitées au périmètre actuellement autorisé du lecteur. + Les données protégées doivent être déverrouillées pour continuer. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index 652caff9..6b083499 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -693,4 +693,5 @@ Seleziona un destinatario disponibile nel tuo dipartimento. Non è stato possibile ricostruire alcuni stati storici degli ordini. Il pacchetto non garantisce una copertura completa della manutenzione. Le prove degli ordini sono limitate all’ambito attualmente autorizzato del lettore. + È necessario sbloccare i dati protetti per continuare. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 83fc3279..69fbf729 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -693,4 +693,5 @@ Wybierz dostępny obiekt w swoim dziale. Nie udało się odtworzyć niektórych historycznych stanów zleceń. Pakiet nie gwarantuje pełnego pokrycia prac konserwacyjnych. Dowody zleceń są ograniczone do bieżącego zakresu uprawnień odbiorcy. + Aby kontynuować, należy odblokować dane chronione. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index a78f1888..7233cc24 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -693,4 +693,5 @@ Select an available target in your department. Some historical work-order states could not be reconstructed. The packet does not claim complete maintenance coverage. Work-order evidence is limited to the reader’s current authorized scope. + Protected data must be unlocked to continue. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index 0afbb9f1..12d288ee 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -693,4 +693,5 @@ Välj ett tillgängligt mål inom din avdelning. Vissa historiska tillstånd för arbetsorder kunde inte återskapas. Paketet garanterar inte fullständig underhållstäckning. Arbetsorderbevisen begränsas till läsarens nuvarande behörighet. + Skyddade data måste låsas upp för att fortsätta. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index d1c8cfac..b9d04c0a 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -693,4 +693,5 @@ Виберіть доступний об’єкт у своєму підрозділі. Деякі історичні стани нарядів не вдалося відновити. Пакет не гарантує повного охоплення технічного обслуговування. Докази нарядів обмежені поточним дозволеним обсягом доступу читача. + Щоб продовжити, потрібно розблокувати захищені дані. diff --git a/Core/Resgrid.Services/ChecklistReporting.cs b/Core/Resgrid.Services/ChecklistReporting.cs index f879064d..d869e675 100644 --- a/Core/Resgrid.Services/ChecklistReporting.cs +++ b/Core/Resgrid.Services/ChecklistReporting.cs @@ -155,7 +155,12 @@ public async Task GetReadinessPacketForCallAsync(Ch throw new ChecklistException(ex.StatusCode, ex.Code == "ProtectedDataRequired" ? "Unlock protected data to use this checklist." : ex.Code == "ReportTooLarge" ? "ReportTooLarge" : "ReadinessCallUnavailable"); } } - packet.Checklists = await ReportEntriesAsync(actor, new ChecklistReportQuery { FromUtc = packet.CoverageStartUtc, UntilUtc = at.AddTicks(1) }, at, packet.UnavailableSources, targets); + // The checklist section is the only part of the packet the checklists module owns, and the sibling report + // methods gate it through RequireWriteAsync. A department with checklists switched off still gets its units, + // issued equipment and work orders; it does not get results the module itself would refuse to serve. + if (await _access.CanUseChecklistsAsync(actor.DepartmentId)) + packet.Checklists = await ReportEntriesAsync(actor, new ChecklistReportQuery { FromUtc = packet.CoverageStartUtc, UntilUtc = at.AddTicks(1) }, at, packet.UnavailableSources, targets); + else AddUnavailable(packet.UnavailableSources, "ChecklistsDisabled"); await _authorization.RequireMemberAsync(actor); if (!await _reportAuthorization.Value.CanUserViewCallAsync(actor.UserId, callId)) throw new ChecklistException(404, "ReadinessCallUnavailable"); return packet; diff --git a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs index ced7349e..9de7cda0 100644 --- a/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs +++ b/Core/Resgrid.Services/InventoryWorkOrderAllocations.cs @@ -30,7 +30,7 @@ public async Task QuotePartAsync(InventoryActor actor, int o else { var asset = await GetAsync(actor, line.AssetId); - if (allocations > 0 || asset.Status != (int)InventoryAssetStatus.InService || asset.CurrentLocationId != line.FromLocationId || asset.LotId != line.LotId || asset.ExpiresOn <= Now) throw new InventoryException(409, "AssetNotAvailable"); + if (allocations > 0 || asset.IsDeleted || asset.Status != (int)InventoryAssetStatus.InService || asset.CurrentLocationId != line.FromLocationId || asset.LotId != line.LotId || asset.ExpiresOn <= Now) throw new InventoryException(409, "AssetNotAvailable"); } return new WorkOrderPartQuote { UnitCost = cost.UnitCost, Currency = cost.CurrencyCode }; } diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs index c5af6a58..5c22f401 100644 --- a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -305,7 +305,7 @@ await WorkerTransactionAsync(departmentId, async events => order.AssignedToUserId = row.AssignedToUserId; order.AssignedToRoleId = row.AssignedToRoleId; order.Status = order.AssignedToUserId != null || order.AssignedToRoleId.HasValue ? (int)WorkOrderStatus.Assigned : (int)WorkOrderStatus.Accepted; if (order.Status == 2) { await _authorization.ValidateAssignmentAsync(owner, order, order.AssignedToUserId, order.AssignedToRoleId); order.AssignedOn = Now; } - await PinSlaAsync(order); order.ResponseOn = Now; + await PinSlaAsync(order); if (order.Status == (int)WorkOrderStatus.Accepted) order.ResponseOn = Now; await _store.AllocateAsync(order); await RecordGeneratedCreationAsync(order); var generated = New(owner, order.Id); generated.RecurrenceId = row.Id; generated.ChangeType = (int)MaintenanceChangeType.Generated; generated.OriginalDueOn = original; generated.RevisedDueOn = due; await _store.AllocateAsync(generated); await EventAsync(order, WorkflowTriggerEventType.WorkOrderCreated, events); diff --git a/Core/Resgrid.Services/WorkOrderReporting.cs b/Core/Resgrid.Services/WorkOrderReporting.cs index 00679216..da9eef6e 100644 --- a/Core/Resgrid.Services/WorkOrderReporting.cs +++ b/Core/Resgrid.Services/WorkOrderReporting.cs @@ -214,7 +214,7 @@ public async Task ReadinessEvidenceAsync(ChecklistAct { var activity = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, snapshot.SourceActivityId.Value)); if (activity.WorkOrderId != row.Id) throw new WorkOrderException(409, "Unavailable"); - fields = JObject.Parse(activity.Content)["Snapshot"]?.ToObject(); + fields = JObject.Parse(activity.Content ?? "{}")["Snapshot"]?.ToObject(); } else if (snapshot.SourceType == 2 && snapshot.RecurrenceVersionId.HasValue) { diff --git a/Core/Resgrid.Services/WorkOrderVendorCharges.cs b/Core/Resgrid.Services/WorkOrderVendorCharges.cs index 685d2278..80387e07 100644 --- a/Core/Resgrid.Services/WorkOrderVendorCharges.cs +++ b/Core/Resgrid.Services/WorkOrderVendorCharges.cs @@ -23,6 +23,8 @@ public async Task AddVendorChargeAsync(ChecklistActor actor, int id, WorkOrderVe { if (input?.Content == null || !Guid.TryParseExact(input.RequestId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var c = input.Content; Money(c.Amount); Text(c.VendorName, 200, true); Text(c.InvoiceReference, 200, true); Text(c.Description, 4000, true); + // Newtonsoft binds an offset-bearing value as Local; compare and store the instant, not the wall clock. + c.ServiceDate = c.ServiceDate.Kind == DateTimeKind.Local ? c.ServiceDate.ToUniversalTime() : DateTime.SpecifyKind(c.ServiceDate, DateTimeKind.Utc); if (!ValidCurrency(c.Currency) || c.Amount <= 0 || c.ServiceDate.Year < 2000 || c.ServiceDate > Now.AddDays(1) || c.VendorId != null && !Guid.TryParseExact(c.VendorId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { c.VendorId, c.VendorName, c.InvoiceReference, c.Description, c.Currency, c.Amount, c.ServiceDate })))); await TransactionAsync(actor, async events => diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index 1fd1302f..862d6005 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -180,7 +180,14 @@ await TransactionAsync(actor, async events => if (input.Content.Currency != document.Fields.Currency) { input.Content.Approval = null; input.Content.ApprovedCost = null; } var oldPriority = row.Priority; document.Fields = input.Content; row.Content = JsonConvert.SerializeObject(document); Apply(row, input); - if (oldPriority != row.Priority) await PinSlaAsync(row); + if (oldPriority != row.Priority) + { + // Re-pinning recomputes the due dates for the new priority. A breach that already happened stays + // recorded, so history keeps it and SlaDueAsync does not pick the order up and emit it a second time. + var responseBreachedOn = row.ResponseBreachedOn; var repairBreachedOn = row.RepairBreachedOn; + await PinSlaAsync(row); + row.ResponseBreachedOn = responseBreachedOn; row.RepairBreachedOn = repairBreachedOn; + } await ChangedAsync(actor, row, WorkOrderActivityType.Updated, events); return true; }); } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index db3a8d4a..f584f6fc 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -764,7 +764,7 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType r["revision_id"] = revisionId ?? ""; r["revision_number"] = revisionNumber; r["checksum"] = revisionId == null ? "" : "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"; - r["summary"] = "Pump Operations — Hose evolutions"; + r["summary"] = "Pump Operations — Hose evolutions"; r["url"] = "https://resgrid.local/User/Records/Details/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; obj["record"] = r; @@ -843,7 +843,7 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType if (!isDefinitionTrigger) { var fields = new ScriptObject(); - fields["client_site"] = "Harbor Logistics — Pier 4"; + fields["client_site"] = "Harbor Logistics — Pier 4"; fields["officer"] = "J. Alvarez"; fields["exception_reported"] = true; var checkpoint = new ScriptObject(); diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 0ee670cd..23d94583 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -436,7 +436,9 @@ public async Task BuildContextAsync( var orderEvent = TryDeserialize(eventPayloadJson); var payload = orderEvent?.Payload ?? new JObject(); var order = new ScriptObject(); foreach (var pair in Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Variables) order[pair.Variable] = ToScriptValue(payload[pair.Property]); - order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/{(payload["PolicyId"] != null ? "Policy" : payload["WorkOrderId"] != null ? "Detail/" + payload["WorkOrderId"]?.Value() : "Recurrence/" + payload["RecurrenceId"]?.Value())}"; + // A JSON null still yields a token, so route off the parsed identifier rather than token presence. + var policyId = payload["PolicyId"]?.Value(); var routedOrderId = payload["WorkOrderId"]?.Value(); var routedRecurrenceId = payload["RecurrenceId"]?.Value(); + order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/{(policyId.HasValue ? "Policy" : routedOrderId.HasValue ? "Detail/" + routedOrderId.Value : routedRecurrenceId.HasValue ? "Recurrence/" + routedRecurrenceId.Value : string.Empty)}"; scriptObject["work_order"] = order; scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.OperationsCatalogVersion }; break; diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 1903c90d..104f8a4f 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -19,7 +19,7 @@ namespace Resgrid.Tests.Services /// /// Attended protected-read pipeline (plan section 7.1): unprotected passthrough, redaction with /// machine-readable reasons for every grant failure mode, one batched broker round trip on a - /// valid grant, and fail-closed behavior on broker faults — a client never sees ciphertext. + /// valid grant, and fail-closed behavior on broker faults — a client never sees ciphertext. /// [TestFixture] public class ProtectedReadServiceTests @@ -322,7 +322,7 @@ public void Child_accessor_maps_cover_every_catalog_v1_child_column() /// /// The per-table assertions above enumerate tables by hand, so a NEW binding added without a - /// read accessor map would slip past them silently — and a bound-but-unreadable column is + /// read accessor map would slip past them silently — and a bound-but-unreadable column is /// exactly how an envelope reaches a client as ciphertext. This pins the whole set: every /// bound table either has accessor coverage or is on the explicit exclusion list below. /// @@ -662,7 +662,7 @@ public async Task Redacted_sentinel_on_an_edit_restores_the_stored_envelope() var result = await _service.PrepareCallWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, workloadCaller: false); result.Success.Should().BeTrue(); - edited.Name.Should().Be("rgdp:1:1:storedname==", "REDACTED means unchanged — the stored envelope survives"); + edited.Name.Should().Be("rgdp:1:1:storedname==", "REDACTED means unchanged — the stored envelope survives"); edited.NatureOfCall.Should().Be("rgdp:1:1:calls.natureofcall==", "genuinely changed fields encrypt"); } @@ -693,7 +693,7 @@ public async Task Redacted_sentinel_without_a_stored_row_is_neutralized_not_stor result.Success.Should().BeTrue(); sentItems.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "calls.natureofcall" }, - "the placeholder must never be enveloped — that would destroy the original"); + "the placeholder must never be enveloped — that would destroy the original"); // And it must not survive as the literal word either. These nets run AFTER the row was // saved, so leaving "REDACTED" in the field means it is what sits in the database, and a @@ -718,7 +718,7 @@ public async Task Fields_added_after_the_departments_pinned_catalog_version_are_ Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = $"rgdp:1:1:{i.FieldId}==" }).ToList() }); - // The department is stamped at catalog v0 — it owns nothing yet, so no cataloged field + // The department is stamped at catalog v0 — it owns nothing yet, so no cataloged field // may be encrypted under its (older) AAD until an upgrade sweeps it. _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 0 }); @@ -751,7 +751,7 @@ public async Task Fields_at_or_below_the_pinned_catalog_version_still_encrypt() } /// - /// Stamps the department at the CURRENT catalog version — catalog-v2 fields are only written + /// Stamps the department at the CURRENT catalog version — catalog-v2 fields are only written /// for a department that has taken the upgrade (EncryptSlotsAsync skips fields the department /// does not yet own), so the operational-family write tests must model an upgraded tenant. /// @@ -777,7 +777,7 @@ public async Task Certifications_redact_without_a_grant_and_strip_the_enveloped_ certification.Data.Should().BeNull("enveloped document bytes must never ride out through a serializer"); // Filetype is not cataloged, and the list view uses it to decide whether a document - // exists — concealing a file must not look like deleting it. + // exists — concealing a file must not look like deleting it. certification.Filetype.Should().Be("application/pdf"); } @@ -883,7 +883,7 @@ public async Task Certification_write_restores_a_redacted_field_from_the_stored_ public async Task Certifications_are_skipped_by_a_department_pinned_below_catalog_v6() { // The department has not been swept for v6 yet, so its certification columns are still - // plaintext by design — encrypting them now would leave rows the sweep never migrated. + // plaintext by design — encrypting them now would leave rows the sweep never migrated. SetupWriteEnforced(); SetupEncryptEcho(); _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) @@ -917,7 +917,7 @@ public async Task Certifications_are_skipped_by_a_department_pinned_below_catalo [Test] public async Task Call_log_narratives_redact_without_a_grant() { - // CallLogs is a different table and entity from the Log family — both call their text + // CallLogs is a different table and entity from the Log family — both call their text // column Narrative, and only the Log family had a read path. These rows were being // enveloped by the sweep and then rendered raw by the call export and the logs index. var log = new CallLog @@ -995,8 +995,8 @@ public async Task Certification_sentinel_restore_reports_changed_so_the_caller_r } /// - /// The sentinel has destroyed data four times in this codebase — member addresses, emergency - /// contacts, UDF values and certifications — because nine of the twelve write paths only + /// The sentinel has destroyed data four times in this codebase — member addresses, emergency + /// contacts, UDF values and certifications — because nine of the twelve write paths only /// refused to ENCRYPT it and let the literal word stay in the row the service had already /// saved. This pins the policy centrally so a new write path inherits it. /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs index 2b9c4106..ea04bee4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistApiControllerBase.cs @@ -46,7 +46,9 @@ private ObjectResult Failure(ChecklistException ex) { var protectedData = ex.StatusCode == 403 && (ex.Message.Contains("protected", StringComparison.OrdinalIgnoreCase) || ex.Message.StartsWith("Unlock", StringComparison.Ordinal)); var code = protectedData ? "protected_data_required" : ex.StatusCode == 409 ? "checklist_conflict" : ex.StatusCode == 403 ? "checklist_forbidden" : ex.StatusCode == 404 ? "checklist_unavailable" : "checklist_validation"; - var message = _strings[ex.Message]; + // The protected-data message is a routing sentinel the MVC controllers match on, not a resource key. + // Look the localized text up by key so the caller gets a real message instead of the generic fallback. + var message = _strings[protectedData ? "ProtectedDataRequired" : ex.Message]; // Do not return validation excerpts containing item names, entered values or envelopes. var problem = new ProblemDetails { Status = ex.StatusCode, Type = code, Title = message.ResourceNotFound ? _strings["The request could not be completed."].Value : message.Value }; problem.Extensions["IsRedacted"] = protectedData; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs index fe71471f..a03d9521 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs @@ -75,7 +75,6 @@ public async Task> Accreditation(DateTi catch (Exception ex) { return Fail(ex); } } - /// Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. /// Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules (each authorizes the caller itself) plus readiness-packet evidence; the unit board joins them on the unit id. [HttpGet("Readiness")] public async Task> Readiness(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, CancellationToken cancellationToken = default) @@ -85,6 +84,7 @@ public async Task> Readiness(DateTime? star catch (Exception ex) { return Fail(ex); } } + /// Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. [HttpGet("CommunityRisk")] public async Task> CommunityRisk(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, CancellationToken cancellationToken = default) { diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index bea1ca81..65a8440c 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2815,9 +2815,11 @@ Inspection, violation, permit, hydrant, occupancy, training and response evidence; prevention sections only when their module is on. - Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. Apparatus and equipment readiness composed from the checklists, maintenance and inventory modules (each authorizes the caller itself) plus readiness-packet evidence; the unit board joins them on the unit id. + + Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. + Definition management and runtime catalog over v4 (RMS plan section 5.4, RMS-1B): template browse, clone/new diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml index 7bca3d89..8df4994e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -5,18 +5,18 @@
@await Html.PartialAsync("_Protection")
-

@o.Number — @o.Title

@localizer["Status" + o.Status] · @localizer["Priority" + o.Priority] · @localizer["DueOn"]: @o.DueOn?.ToString("u")

+

@o.Number — @o.Title

@localizer["Status" + o.Status] · @localizer["Priority" + o.Priority] · @localizer["DueOn"]: @o.DueOn?.ToString("u")

@if (!d.CanWrite) {

@localizer["ReadOnlyHistory"]

}

@c.Description

@c.LocationText

-

@localizer["ReportedBy"]: @d.ReportedBy · @localizer["AssignedTo"]: @(Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name ?? localizer["None"].Value)

-

@localizer["Currency"]: @c.Currency · @localizer["EstimatedCost"]: @c.EstimatedCost · @localizer["ApprovedCost"]: @c.ApprovedCost · @localizer["CostCenter"]: @c.CostCenter

+

@localizer["ReportedBy"]: @d.ReportedBy · @localizer["AssignedTo"]: @(Model.Choices.Users.FirstOrDefault(x => x.Id == o.AssignedToUserId)?.Name ?? Model.Choices.Roles.FirstOrDefault(x => x.Id == o.AssignedToRoleId?.ToString())?.Name ?? localizer["None"].Value)

+

@localizer["Currency"]: @c.Currency · @localizer["EstimatedCost"]: @c.EstimatedCost · @localizer["ApprovedCost"]: @c.ApprovedCost · @localizer["CostCenter"]: @c.CostCenter

@localizer["VendorDetails"]: @c.VendorDetails

@localizer["WarrantyReference"]: @c.WarrantyReference

@localizer["Operations"]

-@if (c.SafetyCritical || c.HazardousWork) {

@localizer[c.HazardousWork ? "HazardousWork" : "SafetyCritical"] — @localizer["SafetyNotice"]

} +@if (c.SafetyCritical || c.HazardousWork) {

@localizer[c.HazardousWork ? "HazardousWork" : "SafetyCritical"] — @localizer["SafetyNotice"]

}
@localizer["ProcedureReference"] / @localizer["ProcedureVersion"]
@c.ProcedureReference / @c.ProcedureVersion
@localizer["PermitReference"]
@c.PermitReference
@localizer["IsolationReference"]
@c.IsolationReference
@localizer["QualifiedPersonnel"]
@c.QualifiedPersonnel
-@if (c.Steps.Count > 0) {

@localizer["TaskSteps"]

    @foreach (var step in c.Steps) {
  1. @step.Text — @localizer[step.Completed ? "Done" : "Pending"]
  2. }
} +@if (c.Steps.Count > 0) {

@localizer["TaskSteps"]

    @foreach (var step in c.Steps) {
  1. @step.Text — @localizer[step.Completed ? "Done" : "Pending"]
  2. }
} @if (d.CompletedOn.HasValue) {

@localizer["Resolution"]

@c.Resolution

@localizer["Cause"]: @c.Cause

} -@if (d.ClosedOn.HasValue) {

@localizer["VerificationEvidence"]: @c.VerificationEvidence

@localizer["VerifiedBy"]: @d.VerifiedBy · @d.ClosedOn?.ToString("u")

} +@if (d.ClosedOn.HasValue) {

@localizer["VerificationEvidence"]: @c.VerificationEvidence

@localizer["VerifiedBy"]: @d.VerifiedBy · @d.ClosedOn?.ToString("u")

} @if (d.CanEdit) { @localizer["EditWorkOrder"] }
@Html.AntiForgeryToken()
@if (d.CanAccept) {
@Html.AntiForgeryToken()
} @@ -41,9 +41,9 @@ } @await Html.PartialAsync("_Maintenance", Model) -

@localizer["Activity"]

    @foreach (var a in d.Activities) {
  1. @localizer["Activity" + a.Type] · @a.CreatedOn.ToString("u") · @a.UserId @if (a.NewStatus.HasValue) { → @localizer["Status" + (WorkOrderStatus)a.NewStatus.Value] }

    @a.Note

    +

    @localizer["Activity"]

      @foreach (var a in d.Activities) {
    1. @localizer["Activity" + a.Type] · @a.CreatedOn.ToString("u") · @a.UserId @if (a.NewStatus.HasValue) { → @localizer["Status" + (WorkOrderStatus)a.NewStatus.Value] }

      @a.Note

      @if (a.AssignedToUserId != null || a.AssignedToRoleId.HasValue) {

      @localizer["AssignedTo"]: @(a.AssignedToUserId ?? a.AssignedToRoleId.ToString())

      } -@if (a.RevisedDueOn.HasValue) {

      @localizer["OriginalDue"]: @a.OriginalDueOn?.ToString("u") → @localizer["RevisedDue"]: @a.RevisedDueOn.Value.ToString("u")

      } +@if (a.RevisedDueOn.HasValue) {

      @localizer["OriginalDue"]: @a.OriginalDueOn?.ToString("u") → @localizer["RevisedDue"]: @a.RevisedDueOn.Value.ToString("u")

      } @if (a.Snapshot != null) {
      @localizer["Description"]: @a.Snapshot.Title

      @a.Snapshot.Description

      @localizer["Resolution"]: @a.Snapshot.Resolution

      @localizer["Cause"]: @a.Snapshot.Cause

      @localizer["VerificationEvidence"]: @a.Snapshot.VerificationEvidence

      @localizer["ProcedureReference"]: @a.Snapshot.ProcedureReference (@a.Snapshot.ProcedureVersion)