From 507e69f9e56ac09c63d5dadb8ee294fbad3822cb Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 9 Sep 2026 16:05:19 -0700 Subject: [PATCH] RG-T66 add readiness integrations and preventive maintenance --- .../Areas/User/Checklists/Checklists.ar.resx | 13 + .../Areas/User/Checklists/Checklists.de.resx | 13 + .../Areas/User/Checklists/Checklists.el.resx | 13 + .../Areas/User/Checklists/Checklists.en.resx | 13 + .../Areas/User/Checklists/Checklists.es.resx | 13 + .../Areas/User/Checklists/Checklists.fr.resx | 13 + .../Areas/User/Checklists/Checklists.it.resx | 13 + .../Areas/User/Checklists/Checklists.pl.resx | 13 + .../Areas/User/Checklists/Checklists.resx | 13 + .../Areas/User/Checklists/Checklists.sv.resx | 13 + .../Areas/User/Checklists/Checklists.uk.resx | 13 + .../Areas/User/Inventory/Inventory.ar.resx | 3 + .../Areas/User/Inventory/Inventory.de.resx | 3 + .../Areas/User/Inventory/Inventory.el.resx | 3 + .../Areas/User/Inventory/Inventory.en.resx | 3 + .../Areas/User/Inventory/Inventory.es.resx | 3 + .../Areas/User/Inventory/Inventory.fr.resx | 3 + .../Areas/User/Inventory/Inventory.it.resx | 3 + .../Areas/User/Inventory/Inventory.pl.resx | 3 + .../Areas/User/Inventory/Inventory.sv.resx | 3 + .../Areas/User/Inventory/Inventory.uk.resx | 3 + .../Areas/User/Profile/Profile.ar.resx | 3 + .../Areas/User/Profile/Profile.de.resx | 3 + .../Areas/User/Profile/Profile.el.resx | 3 + .../Areas/User/Profile/Profile.en.resx | 3 + .../Areas/User/Profile/Profile.es.resx | 3 + .../Areas/User/Profile/Profile.fr.resx | 3 + .../Areas/User/Profile/Profile.it.resx | 3 + .../Areas/User/Profile/Profile.pl.resx | 3 + .../Areas/User/Profile/Profile.sv.resx | 3 + .../Areas/User/Profile/Profile.uk.resx | 3 + .../Areas/User/WorkOrders/WorkOrders.ar.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.de.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.el.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.en.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.es.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.fr.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.it.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.pl.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.sv.resx | 106 ++++++ .../Areas/User/WorkOrders/WorkOrders.uk.resx | 106 ++++++ .../Checklists/ChecklistContracts.cs | 4 + .../Checklists/ChecklistWorkflowPayload.cs | 6 +- .../Inventories/InventoryContracts.cs | 1 + .../Inventories/InventoryModels.cs | 1 + .../Repositories/IInventoryStore.cs | 2 + .../IWorkOrderMaintenanceRepository.cs | 20 + .../IInventoryModernizationService.cs | 1 + .../Services/IWorkOrdersService.cs | 12 +- .../WorkOrders/WorkOrderMaintenance.cs | 161 ++++++++ .../WorkOrders/WorkOrderModels.cs | 21 +- .../WorkOrders/WorkOrderWorkflowPayload.cs | 9 +- .../WorkflowTemplateVariableCatalog.cs | 7 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 7 + Core/Resgrid.Services/ChecklistsService.cs | 29 +- .../Resgrid.Services/GdprDataExportService.cs | 6 +- Core/Resgrid.Services/InventoryAlerts.cs | 61 +++- Core/Resgrid.Services/InventoryCatalog.cs | 2 +- .../InventoryModernizationService.cs | 9 +- Core/Resgrid.Services/InventoryPosting.cs | 19 +- Core/Resgrid.Services/InventoryPurchasing.cs | 12 +- Core/Resgrid.Services/InventoryQueries.cs | 18 + Core/Resgrid.Services/InventoryWorkOrders.cs | 94 +++++ .../Resgrid.Services/ProtectedFieldCatalog.cs | 2 +- .../Records/RecordInventoryUsageService.cs | 20 +- .../Records/RecordsEvidenceService.cs | 5 +- Core/Resgrid.Services/ServicesModule.cs | 4 +- .../WorkOrderInventoryChoices.cs | 37 ++ .../WorkOrderInventoryParts.cs | 131 +++++++ .../WorkOrderMaintenanceCore.cs | 210 +++++++++++ .../WorkOrderMaintenanceGdprExport.cs | 39 ++ .../WorkOrderNotificationService.cs | 4 +- .../WorkOrderRecurrenceService.cs | 344 ++++++++++++++++++ Core/Resgrid.Services/WorkOrdersService.cs | 73 ++-- .../WorkflowSampleDataGenerator.cs | 7 + .../WorkflowTemplateContextBuilder.cs | 11 +- .../M0198_AddInventoryModernization.cs | 14 +- .../M0203_AddWorkOrderIntegrations.cs | 88 +++++ .../M0204_AddWorkOrderRecurrences.cs | 142 ++++++++ .../M0205_EnforceInventoryTenantHolders.cs | 44 +++ .../M0198_AddInventoryModernizationPg.cs | 14 +- .../M0203_AddWorkOrderIntegrationsPg.cs | 88 +++++ .../M0204_AddWorkOrderRecurrencesPg.cs | 142 ++++++++ .../M0205_EnforceInventoryTenantHoldersPg.cs | 44 +++ .../ChecklistDepartmentCleanup.cs | 2 +- .../InventoryStore.cs | 24 ++ .../Modules/ApiDataModule.cs | 2 +- .../Modules/DataModule.cs | 2 +- .../Modules/NonWebDataModule.cs | 2 +- .../Modules/TestingDataModule.cs | 2 +- .../WorkOrderMaintenanceRepository.cs | 53 +++ .../WorkOrderRepository.cs | 8 +- .../Allocations/trigger-baseline.json | 9 +- .../Rms/ChecklistReadinessEvidenceTests.cs | 2 +- .../Rms/IncidentOfficerJourneyTests.cs | 2 +- .../Rms/RecordsEvidenceServiceTests.cs | 14 +- .../Rms/RmsIdentifierPinTests.cs | 2 +- .../Services/ChecklistGdprTests.cs | 2 +- .../Services/ChecklistPr504SecurityTests.cs | 4 +- .../Services/GdprExportProtectedDataTests.cs | 2 +- .../Services/InventoryDatabaseFixture.cs | 8 +- .../Services/InventoryGdprTests.cs | 2 +- .../Services/InventoryM5HttpTests.cs | 23 ++ .../Services/InventoryModernizationTests.cs | 5 + .../Services/InventoryPr507DatabaseTests.cs | 78 ++++ .../Services/InventoryPr507Tests.cs | 74 ++++ .../InventoryWorkOrderDatabaseTests.cs | 167 +++++++++ .../Services/ProtectedReadServiceTests.cs | 3 +- .../Services/RmsInventoryModernUsageTests.cs | 6 + .../Services/WorkOrderDatabaseTests.cs | 11 +- .../Services/WorkOrderGdprTests.cs | 27 +- .../Services/WorkOrderHttpTests.cs | 17 +- .../Services/WorkOrderLocalizationTests.cs | 19 +- .../WorkOrderMaintenanceDatabaseTests.cs | 89 +++++ .../Services/WorkOrderP2M1Tests.cs | 2 +- .../Services/WorkOrderP2M23Tests.cs | 238 ++++++++++++ .../Services/WorkOrderProtectionTests.cs | 16 +- .../ProfileReportScheduleSecurityTests.cs | 44 ++- .../Web/inventory-pr507.test.cjs | 54 +++ .../Web/work-orders-maintenance.test.cjs | 55 +++ .../v4/RecordInventoryController.cs | 6 +- .../v4/WorkOrderMaintenanceController.cs | 33 ++ .../Controllers/v4/WorkOrdersController.cs | 5 +- .../InventoryOperationsController.cs | 11 +- .../User/Controllers/ProfileController.cs | 12 +- .../Controllers/RecordsInventoryController.cs | 1 + .../WorkOrderMaintenanceController.cs | 64 ++++ .../User/Controllers/WorkOrdersController.cs | 11 +- .../Inventory/InventoryWorkspaceView.cs | 1 + .../User/Models/WorkOrders/WorkOrderViews.cs | 5 +- .../Views/Checklists/CompletionDetail.cshtml | 1 + .../User/Views/Inventory/Operations.cshtml | 3 +- .../User/Views/Inventory/Workspace.cshtml | 4 +- .../Areas/User/Views/Profile/Reporting.cshtml | 1 + .../Areas/User/Views/Reports/Index.cshtml | 2 +- .../Areas/User/Views/WorkOrders/Detail.cshtml | 9 +- .../Areas/User/Views/WorkOrders/Edit.cshtml | 28 +- .../Views/WorkOrders/EditRecurrence.cshtml | 39 ++ .../Areas/User/Views/WorkOrders/Index.cshtml | 6 +- .../User/Views/WorkOrders/Recurrence.cshtml | 30 ++ .../User/Views/WorkOrders/Recurrences.cshtml | 17 + .../Views/WorkOrders/_FilterFields.cshtml | 1 + .../User/Views/WorkOrders/_Maintenance.cshtml | 35 ++ .../User/Views/WorkOrders/_OrderFields.cshtml | 32 ++ .../Views/WorkOrders/_PartInventory.cshtml | 9 + .../js/app/internal/checklists/checklists.js | 10 +- .../inventory/inventory-purchasing.js | 8 +- .../profile/resgrid.profile.reporting.js | 10 +- .../js/app/internal/workorders/work-orders.js | 46 ++- .../Commands/MaintenanceEscalationCommand.cs | 14 + .../Commands/MaintenanceGenerationCommand.cs | 14 + Workers/Resgrid.Workers.Console/Program.cs | 2 + .../Tasks/MaintenanceEscalationTask.cs | 22 ++ .../Tasks/MaintenanceGenerationTask.cs | 22 ++ .../Logic/MaintenanceEscalationLogic.cs | 37 ++ .../Logic/MaintenanceGenerationLogic.cs | 37 ++ 157 files changed, 4771 insertions(+), 221 deletions(-) create mode 100644 Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs create mode 100644 Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs create mode 100644 Core/Resgrid.Services/InventoryWorkOrders.cs create mode 100644 Core/Resgrid.Services/WorkOrderInventoryChoices.cs create mode 100644 Core/Resgrid.Services/WorkOrderInventoryParts.cs create mode 100644 Core/Resgrid.Services/WorkOrderMaintenanceCore.cs create mode 100644 Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs create mode 100644 Core/Resgrid.Services/WorkOrderRecurrenceService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0205_EnforceInventoryTenantHolders.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0205_EnforceInventoryTenantHoldersPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryPr507Tests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderMaintenanceDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs create mode 100644 Tests/Resgrid.Tests/Web/inventory-pr507.test.cjs create mode 100644 Tests/Resgrid.Tests/Web/work-orders-maintenance.test.cjs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/WorkOrders/_PartInventory.cshtml create mode 100644 Workers/Resgrid.Workers.Console/Commands/MaintenanceEscalationCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Commands/MaintenanceGenerationCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/MaintenanceEscalationTask.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/MaintenanceGenerationTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx index c57e1e43..ff30b709 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.ar.resx @@ -678,4 +678,17 @@ نوع الهدف المحطة / المجموعة قوائم التحقق معطلة لهذا القسم. +إنشاء أمر عمل Readiness Pro عند الإخفاق +تبقى قائمة التحقق مجانية. تُعاد محاولة المتابعة المدفوعة عند توافر Readiness Pro؛ وتتطلب حظورات السلامة رفعًا مستقلاً منفصلاً. +أوامر العمل المرتبطة +الأولوية +منخفضة +عادية +عالية +طارئة +إخراج الوحدة من الخدمة +إخراج المعدة للإصلاح +تخزين الصيانة غير متاح. +ليس لديك إذن لتنفيذ هذا الإجراء. +اختر هدفًا متاحًا في قسمك. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx index 6ad6d747..61f4f4bd 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.de.resx @@ -678,4 +678,17 @@ Zieltyp Wache / Gruppe Checklisten sind für diese Abteilung deaktiviert. +Bei Fehler einen Readiness Pro-Arbeitsauftrag erstellen +Die Prüfliste bleibt kostenlos. Kostenpflichtige Folgeschritte werden bei verfügbarem Readiness Pro erneut versucht; Sicherheitssperren erfordern eine separate unabhängige Freigabe. +Verknüpfte Arbeitsaufträge +Priorität +Niedrig +Normale Priorität +Hoch +Notfall +Einheit außer Dienst setzen +Gerät zur Reparatur sperren +Wartungsspeicher nicht verfügbar. +Sie haben keine Berechtigung für diese Aktion. +Wählen Sie ein verfügbares Ziel in Ihrer Organisation. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx index 9621352c..883236b8 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.el.resx @@ -678,4 +678,17 @@ Τύπος στόχου Σταθμός / ομάδα Οι λίστες ελέγχου είναι απενεργοποιημένες για αυτό το τμήμα. +Δημιουργία εντολής εργασίας Readiness Pro σε αποτυχία +Η λίστα ελέγχου παραμένει δωρεάν. Η επί πληρωμή συνέχεια επαναλαμβάνεται όταν διατίθεται το Readiness Pro· οι δεσμεύσεις ασφαλείας απαιτούν χωριστή ανεξάρτητη άρση. +Σχετικές εντολές εργασίας +Προτεραιότητα +Χαμηλή +Κανονική +Υψηλή +Έκτακτη ανάγκη +Θέση μονάδας εκτός υπηρεσίας +Θέση εξοπλισμού εκτός χρήσης για επισκευή +Ο χώρος αποθήκευσης συντήρησης δεν είναι διαθέσιμος. +Δεν έχετε άδεια για αυτή την ενέργεια. +Επιλέξτε έναν διαθέσιμο στόχο στο τμήμα σας. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx index 590f1350..bed28f45 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.en.resx @@ -678,4 +678,17 @@ Target type Station / group Checklists are disabled for this department. +Create a Readiness Pro work order on failure +The checklist remains free. Paid follow-up retries when Readiness Pro is available; configured safety holds require a separate independent release. +Related work orders +Priority +Low +Normal +High +Emergency +Place unit out of service +Place equipment out for repair +Maintenance storage is unavailable. +You do not have permission to perform this action. +Select an available target in your department. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index 46c81bcc..4bdb95d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -678,4 +678,17 @@ Tipo de objetivo Estación / grupo Las listas de verificación están deshabilitadas para este departamento. +Crear una orden Readiness Pro al fallar +La lista sigue siendo gratuita. El seguimiento de pago se reintenta cuando Readiness Pro está disponible; los bloqueos configurados requieren una liberación independiente separada. +Órdenes de trabajo relacionadas +Prioridad +Baja +Prioridad normal +Alta +Emergencia +Poner la unidad fuera de servicio +Retirar el equipo para reparación +El almacenamiento de mantenimiento no está disponible. +No tiene permiso para realizar esta acción. +Seleccione un destino disponible en su departamento. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx index f82a6564..57f21287 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.fr.resx @@ -678,4 +678,17 @@ Type de cible Caserne / groupe Les listes de contrôle sont désactivées pour ce département. +Créer un ordre Readiness Pro en cas d'échec +La liste reste gratuite. Le suivi payant est réessayé lorsque Readiness Pro est disponible ; les blocages configurés exigent une levée indépendante distincte. +Ordres de travail associés +Priorité +Basse +Normale +Haute +Urgence +Mettre l'unité hors service +Retirer le matériel pour réparation +Le stockage de maintenance est indisponible. +Vous n’avez pas l’autorisation d’effectuer cette action. +Sélectionnez une cible disponible dans votre service. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx index d5936258..cc6fc7b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.it.resx @@ -678,4 +678,17 @@ Tipo di destinatario Stazione / gruppo Le liste di controllo sono disabilitate per questo reparto. +Crea un ordine Readiness Pro in caso di esito negativo +La lista rimane gratuita. Il seguito a pagamento viene ritentato quando Readiness Pro è disponibile; i blocchi richiedono un rilascio indipendente separato. +Ordini di lavoro correlati +Priorità +Bassa +Normale +Alta +Emergenza +Metti l'unità fuori servizio +Metti l'attrezzatura fuori uso per riparazione +L'archivio di manutenzione non è disponibile. +Non hai l’autorizzazione per eseguire questa azione. +Seleziona un destinatario disponibile nel tuo dipartimento. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx index 0d635ffd..8c6241a6 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.pl.resx @@ -678,4 +678,17 @@ Typ celu Stacja / grupa Listy kontrolne są wyłączone dla tego działu. +Utwórz zlecenie Readiness Pro przy niepowodzeniu +Lista pozostaje bezpłatna. Płatne działania są ponawiane, gdy Readiness Pro jest dostępny; blokady wymagają oddzielnego niezależnego zwolnienia. +Powiązane zlecenia +Priorytet +Niski +Normalny +Wysoki +Nagły wypadek +Wyłącz jednostkę ze służby +Wyłącz sprzęt do naprawy +Magazyn danych konserwacji jest niedostępny. +Nie masz uprawnień do wykonania tej czynności. +Wybierz dostępny obiekt w swoim dziale. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx index 590f1350..bed28f45 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.resx @@ -678,4 +678,17 @@ Target type Station / group Checklists are disabled for this department. +Create a Readiness Pro work order on failure +The checklist remains free. Paid follow-up retries when Readiness Pro is available; configured safety holds require a separate independent release. +Related work orders +Priority +Low +Normal +High +Emergency +Place unit out of service +Place equipment out for repair +Maintenance storage is unavailable. +You do not have permission to perform this action. +Select an available target in your department. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx index fd9f460d..56410f8b 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.sv.resx @@ -678,4 +678,17 @@ Måltyp Station / grupp Checklistor är inaktiverade för den här avdelningen. +Skapa en Readiness Pro-arbetsorder vid fel +Checklistan förblir kostnadsfri. Betald uppföljning försöks igen när Readiness Pro är tillgängligt; spärrar kräver en separat oberoende frigivning. +Relaterade arbetsorder +Prioritet +Låg +Normal prioritet +Hög +Nödläge +Ta enheten ur tjänst +Ta utrustning ur drift för reparation +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. diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx index c456c362..39c980e1 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.uk.resx @@ -678,4 +678,17 @@ Тип цілі Станція / група Контрольні списки вимкнено для цього підрозділу. +Створити наряд Readiness Pro у разі невдачі +Контрольний список залишається безкоштовним. Платні дії повторюються за доступності Readiness Pro; налаштовані блокування потребують окремого незалежного зняття. +Пов’язані наряди +Пріоритет +Низький +Звичайний +Високий +Надзвичайний +Вивести підрозділ зі служби +Вивести обладнання з експлуатації для ремонту +Сховище технічного обслуговування недоступне. +Ви не маєте дозволу на цю дію. +Виберіть доступний об’єкт у своєму підрозділі. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx index ecf8d657..6ee19442 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx @@ -523,4 +523,7 @@ تم إرجاع المخزون تغيرت حالة أصل المخزون تم تسجيل مادة خاضعة للرقابة + سجل هذه القطعة أو اعكسها من خلال أمر العمل المرتبط. + يمنع حظر سلامة الصيانة إعادة هذه المعدة إلى الخدمة. + يلزم Readiness Pro لأعمال الصيانة الجديدة. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx index 7b8ed13c..047f3dcc 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx @@ -647,4 +647,7 @@ Bestand zurückgegeben Status des Inventargegenstands geändert Kontrollierte Substanz erfasst + Dieses Teil über seinen Arbeitsauftrag buchen oder stornieren. + Eine Wartungssicherheitssperre verhindert die Wiederinbetriebnahme dieses Geräts. + Für neue Wartungsarbeiten ist Readiness Pro erforderlich. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx index 535a9d5c..9d534911 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx @@ -706,4 +706,7 @@ Το απόθεμα επιστράφηκε Η κατάσταση του εξοπλισμού άλλαξε Η ελεγχόμενη ουσία καταγράφηκε + Καταχωρίστε ή αντιλογίστε το ανταλλακτικό μέσω της εντολής εργασίας του. + Δέσμευση ασφαλείας συντήρησης εμποδίζει την επαναφορά του εξοπλισμού. + Απαιτείται Readiness Pro για νέες εργασίες συντήρησης. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx index 70991670..aa1fa5fa 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx @@ -706,4 +706,7 @@ Inventory returned Inventory asset status changed Controlled substance recorded + Post or reverse this part through its work order. + A maintenance safety hold prevents returning this equipment to service. + Readiness Pro is required for new maintenance work. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx index 98ce8ab4..abe8f78e 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx @@ -647,4 +647,7 @@ Inventario devuelto Estado del activo de inventario actualizado Sustancia controlada registrada + Registre o revierta esta pieza mediante su orden de trabajo. + Un bloqueo de seguridad de mantenimiento impide devolver este equipo al servicio. + Readiness Pro es necesario para nuevos trabajos de mantenimiento. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx index cc15c2db..f2668a83 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx @@ -647,4 +647,7 @@ Stock retourné Statut du bien en stock modifié Substance contrôlée enregistrée + Comptabilisez ou annulez cette pièce depuis son ordre de travail. + Un blocage de sécurité de maintenance empêche la remise en service de ce matériel. + Readiness Pro est requis pour les nouveaux travaux de maintenance. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx index 8e5289d8..74c36471 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx @@ -647,4 +647,7 @@ Inventario restituito Stato del bene inventariato modificato Sostanza controllata registrata + Registra o storna questo ricambio tramite il relativo ordine di lavoro. + Un blocco di sicurezza di manutenzione impedisce il ritorno in servizio dell'attrezzatura. + Readiness Pro è necessario per nuovi lavori di manutenzione. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx index 93831026..00136901 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx @@ -647,4 +647,7 @@ Zwrócono zapasy Zmieniono status składnika wyposażenia Zarejestrowano substancję kontrolowaną + Zaksięguj lub odwróć tę część przez jej zlecenie. + Blokada bezpieczeństwa konserwacji uniemożliwia przywrócenie sprzętu do użytku. + Readiness Pro jest wymagany do nowych prac konserwacyjnych. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx index 20d2ce2f..74adfce3 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx @@ -647,4 +647,7 @@ Lager återlämnat Status för inventariet ändrad Kontrollerad substans registrerad + Bokför eller återför delen via dess arbetsorder. + En säkerhetsspärr för underhåll hindrar att utrustningen återtas i drift. + Readiness Pro krävs för nya underhållsarbeten. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx index f30c4e65..410437e0 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx @@ -647,4 +647,7 @@ Запаси повернуто Стан інвентарного активу змінено Контрольовану речовину зареєстровано + Проведіть або сторнуйте цю деталь через її наряд. + Блокування безпеки обслуговування перешкоджає поверненню обладнання в експлуатацію. + Для нових робіт з обслуговування потрібен Readiness Pro. diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx index 1a85f85f..a5d3a642 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.ar.resx @@ -75,4 +75,7 @@ إرسال بريد إلكتروني لإعادة تعيين كلمة المرور إرسال بريد إلكتروني لإعادة تعيين كلمة المرور سيرسل Resgrid إلى هذا المستخدم رابطًا قصير الصلاحية يُستخدم مرة واحدة إلى عنوان بريده الإلكتروني المؤكد. لن يختار المسؤول كلمة المرور الجديدة ولن يراها. + + تعذّر تحديث الجدول. حدّث الصفحة وحاول مرة أخرى. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx index 4c5c906b..f23de716 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx @@ -254,4 +254,7 @@ E-Mail zum Zurücksetzen des Passworts senden E-Mail zum Zurücksetzen des Passworts senden Resgrid sendet diesem Benutzer einen kurzlebigen, einmal verwendbaren Link an seine bestätigte E-Mail-Adresse. Der Administrator wählt oder sieht das neue Passwort nicht. + + Der Zeitplan konnte nicht aktualisiert werden. Laden Sie die Seite neu und versuchen Sie es erneut. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx index 0edd3105..acd04958 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.el.resx @@ -303,4 +303,7 @@ Αποστολή email επαναφοράς κωδικού πρόσβασης Αποστολή email επαναφοράς κωδικού πρόσβασης Το Resgrid θα στείλει σε αυτόν τον χρήστη έναν σύνδεσμο σύντομης διάρκειας και μίας χρήσης στην επιβεβαιωμένη διεύθυνση email του. Ο διαχειριστής δεν θα επιλέξει ούτε θα δει τον νέο κωδικό πρόσβασης. + + Δεν ήταν δυνατή η ενημέρωση του προγράμματος. Ανανεώστε τη σελίδα και δοκιμάστε ξανά. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx index 2abe6c26..d3665b3a 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.en.resx @@ -303,4 +303,7 @@ Send Password Reset Email Send Password Reset Email Resgrid will send this user a short-lived, single-use link at their confirmed email address. The administrator will not choose or see the new password. + + The schedule could not be updated. Refresh the page and try again. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx index 0aabbe91..ece0af80 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.es.resx @@ -303,4 +303,7 @@ Enviar correo de restablecimiento de contraseña Enviar correo de restablecimiento de contraseña Resgrid enviará a este usuario un enlace de corta duración y de un solo uso a su dirección de correo confirmada. El administrador no elegirá ni verá la nueva contraseña. + + No se pudo actualizar la programación. Actualice la página e inténtelo de nuevo. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx index 038059d5..4f230809 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx @@ -254,4 +254,7 @@ Envoyer l’e-mail de réinitialisation du mot de passe Envoyer l’e-mail de réinitialisation du mot de passe Resgrid enverra à cet utilisateur un lien à courte durée de vie et à usage unique à son adresse e-mail confirmée. L’administrateur ne choisira ni ne verra le nouveau mot de passe. + + Le planning n’a pas pu être mis à jour. Actualisez la page et réessayez. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx index d5ad592a..3c661109 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx @@ -254,4 +254,7 @@ Invia email di reimpostazione password Invia email di reimpostazione password Resgrid invierà a questo utente un link di breve durata e monouso al suo indirizzo email confermato. L’amministratore non sceglierà né vedrà la nuova password. + + Impossibile aggiornare la pianificazione. Aggiorna la pagina e riprova. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx index fdb8f5f0..8308202e 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx @@ -254,4 +254,7 @@ Wyślij wiadomość e-mail do resetowania hasła Wyślij wiadomość e-mail do resetowania hasła Resgrid wyśle temu użytkownikowi krótkotrwały, jednorazowy link na potwierdzony adres e-mail. Administrator nie wybierze ani nie zobaczy nowego hasła. + + Nie udało się zaktualizować harmonogramu. Odśwież stronę i spróbuj ponownie. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx index b7d74729..12549101 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx @@ -254,4 +254,7 @@ Skicka e-post för lösenordsåterställning Skicka e-post för lösenordsåterställning Resgrid skickar en kortlivad engångslänk till användarens bekräftade e-postadress. Administratören väljer eller ser inte det nya lösenordet. + + Schemat kunde inte uppdateras. Uppdatera sidan och försök igen. + diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx index 51086b99..35de6fdc 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx @@ -254,4 +254,7 @@ Надіслати електронний лист для скидання пароля Надіслати електронний лист для скидання пароля Resgrid надішле цьому користувачеві короткочасне одноразове посилання на підтверджену електронну адресу. Адміністратор не вибиратиме й не бачитиме новий пароль. + + Не вдалося оновити розклад. Оновіть сторінку та спробуйте ще раз. + diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx index 599b4358..b1f5e8fb 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.ar.resx @@ -154,4 +154,110 @@ تم إبطال قطعة تمت إضافة ملف تم سحب ملف +جدول نشط +تم تأجيل الموعد +تصعيد التأخير +تم فرض حظر السلامة +تم رفع حظر السلامة +فرض حظر السلامة +الموافقة على التأجيل +حظر سلامة المعدة +الإسناد والتصعيد +بداية فترة الاستبعاد +نهاية فترة الاستبعاد +تكرار التقويم +يومي +شهري +بلا محفز تقويمي +ربع سنوي +أسبوعي +سنوي +حساب الفترة التالية من الإغلاق المعتمد +محفز الحالة +عند العتبة أو أعلى منها +عند العتبة أو أقل منها +بلا محفز حالة +عتبة الحالة +وحدة قياس الحالة +قراءة الحالة +سبب التأجيل +تعديل الجدول +تاريخ الانتهاء +التصعيد بعد عدد دقائق التأخير +دور مستلم التصعيد +أول موعد استحقاق بالتوقيت المحلي +متابعة خلل قائمة التحقق +إخراج المعدة للإصلاح +إخراج الوحدة من الخدمة +يجب أن يسجل مراجع مستقل ومخول مؤهلاته وأدلة رفع الحظر. تتطلب استعادة الخدمة رفع آخر حظر وعدم تغير الحالة؛ ولا يكفي الإغلاق وحده. +يجب أن يرفع هذا الحظر مراجع مخول آخر. +فترة الجدولة +تعذر إكمال عملية المخزون. راجع التوافر والصلاحيات. +اختر صنف مخزون وموقع مصدر صالحين. +قطع مرتبطة بالمخزون +اترك الصنف فارغًا لقطعة بنص حر. تستخدم قطع المخزون تكاليف السجل وقد تتطلب شاهدًا مستقلاً في المخزون. +يلزم إذن المخزون أو التحقق من البيانات المحمية. +شاهد المخزون قيد الانتظار؛ أكمل الشهادة قبل إغلاق أمر العمل. +المهلة بالأيام +تحميل مزيد من الخيارات +تخزين الصيانة غير متاح. +خط أساس فترة العداد +دورات التشغيل +جيل العداد +ساعات التشغيل +فترة العداد +كيلومترات +الأميال المقطوعة +بلا محفز عداد +وحدة قياس العداد +قراءة العداد +جدول وقائي جديد +موعد الاستحقاق التالي +وقت الرصد +موعد الاستحقاق الأصلي +جدول متوقف +الصيانة الوقائية +تحقق من القراءة ووقت الرصد ومتطلبات إعادة ضبط العداد. +مصدر القراءة +سجل القراءات +تسجيل القراءة +تُستخدم محفزات التقويم والعداد والحالة بحسب الأسبق. يُسمح بأمر معلق واحد لكل جدول. يؤدي إلغاء أمر مُنشأ إلى إيقاف الجدول للمراجعة. +تحقق من الجدول والمنطقة الزمنية ونافذة الخدمة وإعدادات المحفزات. +أكمل الأمر المعلق أو ألغِه قبل تغيير الهدف أو خط أساس العداد. +أدلة التحقق لرفع الحظر +رفع حظر السلامة +مؤهل المراجع +تم استبدال العداد أو إعادة ضبطه (السبب مطلوب) +استعادة الحالة السابقة فقط إذا لم تتغير ولم تبقَ حظورات أخرى +موعد الاستحقاق المعدل +حظورات السلامة +جدول الصيانة +نهاية نافذة الخدمة +بداية نافذة الخدمة +نافذة الخدمة +إرسال قائمة التحقق المصدر +تم الحفاظ على الحالة الحالية +تمت استعادة الحالة السابقة +هدف الصيانة +المنطقة الزمنية +حظر سلامة الوحدة +الاستخدام والحالة +استخدم إجراء التأجيل المعتمد لتغيير موعد الاستحقاق. +سجل إعادة ضبط موثقة لتغيير خط الأساس؛ واستخدم جدولًا جديدًا لوحدات قياس مختلفة. +صنف المخزون +موقع المخزون المصدر +دفعة المخزون +تم تغيير الجدول الوقائي +تم بلوغ عتبة الصيانة +تم تغيير قطعة أمر العمل +تم إعداد الجدول +تم تسجيل القراءة +تم تسجيل إعادة ضبط العداد +تم تأجيل الموعد +تم إنشاء أمر العمل +تصعيد التأخير +تصعيد التأخير +تم فرض حظر السلامة +تم رفع حظر السلامة +تم تأجيل الموعد diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx index 20388893..8229f257 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.de.resx @@ -154,4 +154,110 @@ Teil storniert Datei hinzugefügt Datei zurückgezogen +Aktiver Zeitplan +Fälligkeit verschoben +Überfälligkeit eskaliert +Sicherheitssperre gesetzt +Sicherheitssperre aufgehoben +Sicherheitssperre setzen +Verschiebung genehmigen +Gerätesicherheitssperre +Zuweisung und Eskalation +Sperrzeit ab +Sperrzeit bis +Kalenderhäufigkeit +Täglich +Monatlich +Kein Kalenderauslöser +Vierteljährlich +Wöchentlich +Jährlich +Nächstes Intervall ab geprüftem Abschluss berechnen +Zustandsauslöser +Ab dem Schwellenwert +Bis zum Schwellenwert +Kein Zustandsauslöser +Zustandsschwellenwert +Maßeinheit des Zustands +Zustandsmesswert +Grund der Verschiebung +Zeitplan bearbeiten +Enddatum +Eskalation nach überfälligen Minuten +Empfängerrolle für Eskalationen +Erste Fälligkeit in Ortszeit +Nachbearbeitung eines Prüflistenmangels +Gerät zur Reparatur sperren +Einheit außer Dienst setzen +Ein unabhängiger berechtigter Prüfer muss Qualifikation und Freigabenachweis dokumentieren. Wiederherstellung erfordert die letzte Sperre und einen unveränderten Zustand; der Abschluss allein stellt den Dienst nicht wieder her. +Ein anderer berechtigter Prüfer muss diese Sperre aufheben. +Planungsintervall +Der Bestandsvorgang konnte nicht abgeschlossen werden. Verfügbarkeit und Berechtigungen prüfen. +Gültigen Bestandsartikel und Quellort auswählen. +Mit dem Bestand verknüpfte Teile +Artikel für eine Freitextposition leer lassen. Bestandsteile verwenden Buchungskosten und können einen unabhängigen Zeugen im Bestand erfordern. +Bestandsberechtigung oder Prüfung geschützter Daten erforderlich. +Bestandszeuge ausstehend; Bezeugung vor dem Abschluss dieses Arbeitsauftrags abschließen. +Vorlauf in Tagen +Weitere Auswahl laden +Wartungsspeicher nicht verfügbar. +Zählerintervall-Basiswert +Betriebszyklen +Zählergeneration +Betriebsstunden +Zählerintervall +Kilometer +Gefahrene Meilen +Kein Zählerauslöser +Zählermaßeinheit +Zählerstand +Neuer vorbeugender Wartungsplan +Nächste Fälligkeit +Beobachtungszeit +Ursprüngliche Fälligkeit +Pausierter Zeitplan +Vorbeugende Wartung +Messwert, Beobachtungszeit und Anforderungen zum Zählerwechsel prüfen. +Quelle des Messwerts +Messwerthistorie +Messwert erfassen +Kalender, Zähler und Zustand lösen beim zuerst erreichten Grenzwert aus. Pro Zeitplan ist ein offener Auftrag erlaubt. Das Stornieren eines erzeugten Auftrags pausiert den Zeitplan zur Prüfung. +Zeitplan, Zeitzone, Servicefenster und Auslöser prüfen. +Offenen Auftrag abschließen oder stornieren, bevor Ziel oder Zählerbasis geändert werden. +Prüfnachweis zur Freigabe +Sicherheitssperre aufheben +Qualifikation des Prüfers +Zähler ersetzt oder zurückgesetzt (Grund erforderlich) +Vorherigen Zustand nur bei unverändertem Zustand und ohne weitere Sperren herstellen +Geänderte Fälligkeit +Sicherheitssperren +Wartungsplan +Ende des Servicefensters +Beginn des Servicefensters +Servicefenster +Ursprüngliche Prüflisteneinreichung +Aktueller Zustand beibehalten +Vorheriger Zustand wiederhergestellt +Wartungsziel +Zeitzone +Einheitssicherheitssperre +Nutzung und Zustand +Zum Ändern der Fälligkeit die genehmigte Verschiebung verwenden. +Basiswert durch dokumentiertes Zurücksetzen ändern; für andere Maßeinheiten einen neuen Zeitplan verwenden. +Bestandsartikel +Quelllagerort +Bestandscharge +Vorbeugender Wartungsplan geändert +Wartungsschwellenwert erreicht +Arbeitsauftragsteil geändert +Zeitplan eingerichtet +Messwert erfasst +Zählerrücksetzung erfasst +Fälligkeit verschoben +Arbeitsauftrag erstellt +Überfälligkeit eskaliert +Überfälligkeit eskaliert +Sicherheitssperre gesetzt +Sicherheitssperre aufgehoben +Fälligkeit verschoben diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx index 13962c81..768aa7db 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.el.resx @@ -154,4 +154,110 @@ Ακυρώθηκε ανταλλακτικό Προστέθηκε αρχείο Αποσύρθηκε αρχείο +Ενεργό πρόγραμμα +Αναβολή προθεσμίας +Κλιμάκωση καθυστέρησης +Επιβολή δέσμευσης ασφαλείας +Άρση δέσμευσης ασφαλείας +Επιβολή δέσμευσης ασφαλείας +Έγκριση αναβολής +Δέσμευση ασφαλείας εξοπλισμού +Ανάθεση και κλιμάκωση +Έναρξη αποκλεισμού +Λήξη αποκλεισμού +Ημερολογιακή συχνότητα +Καθημερινά +Μηνιαία +Χωρίς ημερολογιακό έναυσμα +Τριμηνιαία +Εβδομαδιαία +Ετησίως +Υπολογισμός επόμενου διαστήματος από την επαληθευμένη ολοκλήρωση +Έναυσμα κατάστασης +Στο όριο ή υψηλότερα +Στο όριο ή χαμηλότερα +Χωρίς έναυσμα κατάστασης +Όριο κατάστασης +Μονάδα μέτρησης κατάστασης +Ένδειξη κατάστασης +Αιτία αναβολής +Επεξεργασία προγράμματος +Ημερομηνία λήξης +Κλιμάκωση μετά από λεπτά καθυστέρησης +Ρόλος παραλήπτη κλιμάκωσης +Πρώτη προθεσμία σε τοπική ώρα +Παρακολούθηση ελαττώματος λίστας ελέγχου +Θέση εξοπλισμού εκτός χρήσης για επισκευή +Θέση μονάδας εκτός υπηρεσίας +Ανεξάρτητος εξουσιοδοτημένος ελεγκτής καταγράφει προσόντα και τεκμήρια άρσης. Η επαναφορά απαιτεί την τελευταία δέσμευση και αμετάβλητη κατάσταση· η ολοκλήρωση μόνη της δεν επαναφέρει τη λειτουργία. +Άλλος εξουσιοδοτημένος ελεγκτής πρέπει να άρει τη δέσμευση. +Διάστημα προγραμματισμού +Η πράξη αποθέματος δεν ολοκληρώθηκε. Ελέγξτε διαθεσιμότητα και δικαιώματα. +Επιλέξτε έγκυρο είδος αποθέματος και θέση προέλευσης. +Ανταλλακτικά συνδεδεμένα με απόθεμα +Αφήστε κενό το είδος για ελεύθερο κείμενο. Τα ανταλλακτικά αποθέματος χρησιμοποιούν κόστος καθολικού και ενδέχεται να απαιτούν ανεξάρτητο μάρτυρα στο απόθεμα. +Απαιτείται δικαίωμα αποθέματος ή επαλήθευση προστατευμένων δεδομένων. +Εκκρεμεί μάρτυρας αποθέματος· ολοκληρώστε την επιβεβαίωση πριν κλείσετε την εντολή εργασίας. +Χρόνος προειδοποίησης σε ημέρες +Φόρτωση περισσότερων επιλογών +Ο χώρος αποθήκευσης συντήρησης δεν είναι διαθέσιμος. +Βασική τιμή διαστήματος μετρητή +Κύκλοι λειτουργίας +Γενιά μετρητή +Ώρες λειτουργίας +Διάστημα μετρητή +Χιλιόμετρα +Διανυθέντα μίλια +Χωρίς έναυσμα μετρητή +Μονάδα μέτρησης μετρητή +Ένδειξη μετρητή +Νέο πρόγραμμα προληπτικής συντήρησης +Επόμενη προθεσμία +Χρόνος παρατήρησης +Αρχική προθεσμία +Πρόγραμμα σε παύση +Προληπτική συντήρηση +Ελέγξτε την ένδειξη, τον χρόνο παρατήρησης και τις απαιτήσεις μηδενισμού μετρητή. +Πηγή ένδειξης +Ιστορικό ενδείξεων +Καταγραφή ένδειξης +Τα εναύσματα ημερολογίου, μετρητή και κατάστασης ενεργοποιούνται με όποιο επιτευχθεί πρώτο. Επιτρέπεται μία εκκρεμής εντολή ανά πρόγραμμα. Η ακύρωση παραγόμενης εντολής θέτει το πρόγραμμα σε παύση για έλεγχο. +Ελέγξτε πρόγραμμα, ζώνη ώρας, παράθυρο συντήρησης και εναύσματα. +Ολοκληρώστε ή ακυρώστε την εκκρεμή εντολή πριν αλλάξετε στόχο ή βάση μετρητή. +Τεκμήρια επαλήθευσης άρσης +Άρση δέσμευσης ασφαλείας +Προσόντα ελεγκτή +Αντικατάσταση ή μηδενισμός μετρητή (απαιτείται αιτία) +Επαναφορά προηγούμενης κατάστασης μόνο αν παραμένει αμετάβλητη και δεν υπάρχουν άλλες δεσμεύσεις +Αναθεωρημένη προθεσμία +Δεσμεύσεις ασφαλείας +Πρόγραμμα συντήρησης +Λήξη παραθύρου συντήρησης +Έναρξη παραθύρου συντήρησης +Παράθυρο συντήρησης +Αρχική υποβολή λίστας ελέγχου +Διατήρηση τρέχουσας κατάστασης +Επαναφορά προηγούμενης κατάστασης +Στόχος συντήρησης +Ζώνη ώρας +Δέσμευση ασφαλείας μονάδας +Χρήση και κατάσταση +Χρησιμοποιήστε την εγκεκριμένη αναβολή για αλλαγή προθεσμίας. +Καταγράψτε ελεγχόμενο μηδενισμό για αλλαγή βάσης· χρησιμοποιήστε νέο πρόγραμμα για άλλες μονάδες. +Είδος αποθέματος +Θέση αποθέματος προέλευσης +Παρτίδα αποθέματος +Αλλαγή προγράμματος προληπτικής συντήρησης +Επίτευξη ορίου συντήρησης +Αλλαγή ανταλλακτικού εντολής εργασίας +Ρύθμιση προγράμματος +Καταγραφή ένδειξης +Καταγραφή μηδενισμού μετρητή +Αναβολή προθεσμίας +Δημιουργήθηκε εντολή εργασίας +Κλιμάκωση καθυστέρησης +Κλιμάκωση καθυστέρησης +Επιβολή δέσμευσης ασφαλείας +Άρση δέσμευσης ασφαλείας +Αναβολή προθεσμίας diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx index a5cdf33c..e3441bfe 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.en.resx @@ -154,4 +154,110 @@ Part voided File added File withdrawn +Active schedule +Due date deferred +Overdue escalation +Safety hold applied +Safety hold released +Apply safety hold +Approve deferral +Equipment safety hold +Assignment and escalation +Blackout starts +Blackout ends +Calendar frequency +Daily +Monthly +No calendar trigger +Quarterly +Weekly +Yearly +Measure the next interval from verified closure +Condition trigger +At or above threshold +At or below threshold +No condition trigger +Condition threshold +Condition measurement unit +Condition reading +Reason for deferral +Edit schedule +End date +Escalate after overdue minutes +Escalation recipient role +First due date and local time +Checklist defect follow-up +Place equipment out for repair +Place unit out of service +An independent authorized reviewer must record qualifications and release evidence. Restoration requires the last hold and an unchanged state; closure alone never restores service. +A different authorized reviewer must release this hold. +Scheduling interval +The inventory operation could not be completed. Review inventory availability and permissions. +Choose a valid inventory item and source location. +Inventory-linked parts +Leave the item blank for a free-text part. Inventory parts use ledger costs and may require an independent witness in Inventory. +Inventory permission or protected-data verification is required. +Inventory witness pending; complete witnessing before closing this work order. +Lead time in days +Load more choices +Maintenance storage is unavailable. +Meter interval baseline +Operating cycles +Meter generation +Operating hours +Meter interval +Kilometers +Miles travelled +No meter trigger +Meter measurement unit +Meter reading +New preventive schedule +Next due date +Observation time +Original due date +Paused schedule +Preventive maintenance +Check the reading, observation time and meter reset requirements. +Reading source +Reading history +Record reading +Calendar, meter and condition triggers use whichever is reached first. One pending order is allowed per schedule. Cancelling a generated order pauses the schedule for review. +Check the schedule, time zone, service window and trigger settings. +Complete or cancel the pending order before changing this target or meter baseline. +Release verification evidence +Release safety hold +Reviewer qualification +Meter replaced or reset (reason required) +Restore previous state only if unchanged and no other holds remain +Revised due date +Safety holds +Maintenance schedule +Service window ends +Service window starts +Service window +Source checklist submission +Current state preserved +Previous state restored +Maintenance target +Time zone +Unit safety hold +Usage and condition +Use the approved deferral action to change the due date. +Record an audited meter reset to change the baseline; use a new schedule for different measurement units. +Inventory item +Source stock location +Inventory lot +Preventive schedule changed +Maintenance threshold reached +Work order part changed +Schedule configured +Reading recorded +Meter reset recorded +Due date deferred +Work order created +Overdue escalation +Overdue escalation +Safety hold applied +Safety hold released +Due date deferred diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx index ccf5b396..08e439a1 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.es.resx @@ -154,4 +154,110 @@ Pieza anulada Archivo añadido Archivo retirado +Programa activo +Fecha aplazada +Escalamiento por retraso +Bloqueo de seguridad aplicado +Bloqueo de seguridad liberado +Aplicar bloqueo de seguridad +Aprobar aplazamiento +Bloqueo de seguridad del equipo +Asignación y escalamiento +Inicio del período excluido +Fin del período excluido +Frecuencia del calendario +Diaria +Mensual +Sin activación por calendario +Trimestral +Semanal +Anual +Calcular el siguiente intervalo desde el cierre verificado +Activación por condición +Igual o superior al umbral +Igual o inferior al umbral +Sin activación por condición +Umbral de condición +Unidad de medida de condición +Lectura de condición +Motivo del aplazamiento +Editar programa +Fecha de finalización +Escalar tras los minutos de retraso +Rol destinatario del escalamiento +Primera fecha y hora local de vencimiento +Seguimiento de defecto de lista de verificación +Retirar el equipo para reparación +Poner la unidad fuera de servicio +Un revisor independiente autorizado debe registrar su cualificación y las pruebas de liberación. La restauración exige que sea el último bloqueo y que el estado no haya cambiado; el cierre por sí solo no restablece el servicio. +Otro revisor autorizado debe liberar este bloqueo. +Intervalo de programación +No se pudo completar la operación de inventario. Revise disponibilidad y permisos. +Seleccione un artículo y una ubicación de origen válidos. +Piezas vinculadas al inventario +Deje el artículo vacío para una pieza de texto libre. Las piezas de inventario usan costes del registro y pueden requerir un testigo independiente en Inventario. +Se requiere permiso de inventario o verificación de datos protegidos. +Testigo de inventario pendiente; complete la atestación antes de cerrar esta orden de trabajo. +Antelación en días +Cargar más opciones +El almacenamiento de mantenimiento no está disponible. +Valor base del intervalo del contador +Ciclos de funcionamiento +Generación del contador +Horas de funcionamiento +Intervalo del contador +Kilómetros +Millas recorridas +Sin activación por contador +Unidad de medida del contador +Lectura del contador +Nuevo programa preventivo +Próximo vencimiento +Hora de observación +Vencimiento original +Programa pausado +Mantenimiento preventivo +Revise la lectura, la hora y los requisitos de reinicio del contador. +Fuente de la lectura +Historial de lecturas +Registrar lectura +Se activa el primer criterio alcanzado de calendario, contador o condición. Se permite una orden pendiente por programa. Cancelar una orden generada pausa el programa para revisión. +Revise el programa, la zona horaria, la ventana de servicio y los criterios. +Complete o cancele la orden pendiente antes de cambiar el objetivo o la base del contador. +Pruebas de verificación de liberación +Liberar bloqueo de seguridad +Cualificación del revisor +Contador reemplazado o reiniciado (motivo obligatorio) +Restaurar el estado anterior solo si no ha cambiado y no quedan otros bloqueos +Vencimiento revisado +Bloqueos de seguridad +Programa de mantenimiento +Fin de la ventana de servicio +Inicio de la ventana de servicio +Ventana de servicio +Envío de la lista de verificación de origen +Estado actual conservado +Estado anterior restaurado +Objetivo de mantenimiento +Zona horaria +Bloqueo de seguridad de la unidad +Uso y condición +Use la acción de aplazamiento aprobado para cambiar el vencimiento. +Registre un reinicio auditado para cambiar la base; use un programa nuevo para unidades diferentes. +Artículo de inventario +Ubicación del stock de origen +Lote de inventario +Programa preventivo modificado +Umbral de mantenimiento alcanzado +Pieza de orden de trabajo modificada +Programa configurado +Lectura registrada +Reinicio del contador registrado +Fecha aplazada +Orden de trabajo creada +Escalamiento por retraso +Escalamiento por retraso +Bloqueo de seguridad aplicado +Bloqueo de seguridad liberado +Fecha aplazada diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx index 6ef23cd7..b310c4ad 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.fr.resx @@ -154,4 +154,110 @@ Pièce annulée Fichier ajouté Fichier retiré +Programme actif +Échéance reportée +Signalement du retard +Blocage de sécurité appliqué +Blocage de sécurité levé +Appliquer un blocage de sécurité +Approuver le report +Blocage de sécurité du matériel +Affectation et signalement +Début de la période exclue +Fin de la période exclue +Fréquence calendaire +Quotidienne +Mensuelle +Sans déclenchement calendaire +Trimestrielle +Hebdomadaire +Annuelle +Calculer le prochain intervalle depuis la clôture vérifiée +Déclenchement selon l'état +Au seuil ou au-dessus +Au seuil ou en dessous +Sans déclenchement selon l'état +Seuil d'état +Unité de mesure de l'état +Mesure de l'état +Motif du report +Modifier le programme +Date de fin +Signaler après le nombre de minutes de retard +Rôle destinataire du signalement +Première échéance en heure locale +Suivi d'un défaut de liste de contrôle +Retirer le matériel pour réparation +Mettre l'unité hors service +Un contrôleur indépendant autorisé consigne ses qualifications et les preuves de levée. Le rétablissement exige le dernier blocage et un état inchangé ; la clôture seule ne remet jamais en service. +Un autre contrôleur autorisé doit lever ce blocage. +Intervalle de planification +L'opération de stock n'a pas abouti. Vérifiez la disponibilité et les autorisations. +Choisissez un article et un emplacement source valides. +Pièces liées au stock +Laissez l'article vide pour une pièce en texte libre. Les pièces de stock utilisent les coûts du registre et peuvent nécessiter un témoin indépendant dans le stock. +Une autorisation de stock ou une vérification des données protégées est requise. +Témoin de stock en attente ; terminez l'attestation avant de clôturer cet ordre de travail. +Délai d'anticipation en jours +Charger davantage de choix +Le stockage de maintenance est indisponible. +Valeur de référence de l'intervalle du compteur +Cycles de fonctionnement +Génération du compteur +Heures de fonctionnement +Intervalle du compteur +Kilomètres +Milles parcourus +Sans déclenchement par compteur +Unité de mesure du compteur +Relevé du compteur +Nouveau programme préventif +Prochaine échéance +Heure d'observation +Échéance initiale +Programme suspendu +Maintenance préventive +Vérifiez le relevé, l'heure et les conditions de remise à zéro du compteur. +Source du relevé +Historique des relevés +Enregistrer le relevé +Le premier critère atteint parmi calendrier, compteur et état déclenche l'ordre. Un seul ordre en attente est permis par programme. Annuler un ordre généré suspend le programme pour révision. +Vérifiez le programme, le fuseau horaire, la plage de service et les critères. +Terminez ou annulez l'ordre en attente avant de modifier la cible ou la référence du compteur. +Preuves de vérification de la levée +Lever le blocage de sécurité +Qualification du contrôleur +Compteur remplacé ou remis à zéro (motif requis) +Rétablir l'état précédent uniquement s'il est inchangé et sans autre blocage +Échéance révisée +Blocages de sécurité +Programme de maintenance +Fin de la plage de service +Début de la plage de service +Plage de service +Soumission de liste de contrôle source +État actuel conservé +État précédent rétabli +Cible de maintenance +Fuseau horaire +Blocage de sécurité de l'unité +Utilisation et état +Utilisez l'action de report approuvé pour modifier l'échéance. +Consignez une remise à zéro auditée pour modifier la référence ; utilisez un nouveau programme pour d'autres unités. +Article en stock +Emplacement du stock source +Lot en stock +Programme préventif modifié +Seuil de maintenance atteint +Pièce d'ordre de travail modifiée +Programme configuré +Relevé enregistré +Remise à zéro du compteur enregistrée +Échéance reportée +Ordre de travail créé +Signalement du retard +Signalement du retard +Blocage de sécurité appliqué +Blocage de sécurité levé +Échéance reportée diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx index 5d251f3d..e70ba4a1 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.it.resx @@ -154,4 +154,110 @@ Ricambio annullato File aggiunto File ritirato +Programma attivo +Scadenza rinviata +Segnalazione del ritardo +Blocco di sicurezza applicato +Blocco di sicurezza rimosso +Applicare un blocco di sicurezza +Approvare il rinvio +Blocco di sicurezza dell'attrezzatura +Assegnazione e segnalazione +Inizio del periodo escluso +Fine del periodo escluso +Frequenza del calendario +Giornaliera +Mensile +Senza attivazione da calendario +Trimestrale +Settimanale +Annuale +Calcolare l'intervallo successivo dalla chiusura verificata +Attivazione in base alla condizione +Alla soglia o al di sopra +Alla soglia o al di sotto +Senza attivazione per condizione +Soglia di condizione +Unità di misura della condizione +Lettura della condizione +Motivo del rinvio +Modifica programma +Data di fine +Segnalare dopo i minuti di ritardo +Ruolo destinatario della segnalazione +Prima scadenza in ora locale +Verifica del difetto della lista di controllo +Metti l'attrezzatura fuori uso per riparazione +Metti l'unità fuori servizio +Un revisore indipendente autorizzato deve registrare qualifiche e prove di rilascio. Il ripristino richiede l'ultimo blocco e uno stato invariato; la sola chiusura non ripristina il servizio. +Un altro revisore autorizzato deve rimuovere questo blocco. +Intervallo di pianificazione +Impossibile completare l'operazione di magazzino. Verificare disponibilità e permessi. +Selezionare un articolo e un'ubicazione di origine validi. +Ricambi collegati al magazzino +Lasciare l'articolo vuoto per un ricambio a testo libero. I ricambi di magazzino usano i costi del registro e possono richiedere un testimone indipendente nel magazzino. +È richiesta l'autorizzazione al magazzino o la verifica dei dati protetti. +Testimone di magazzino in attesa; completare l'attestazione prima di chiudere l'ordine di lavoro. +Anticipo in giorni +Carica altre opzioni +L'archivio di manutenzione non è disponibile. +Valore di riferimento dell'intervallo del contatore +Cicli operativi +Generazione del contatore +Ore di funzionamento +Intervallo del contatore +Chilometri +Miglia percorse +Senza attivazione da contatore +Unità di misura del contatore +Lettura del contatore +Nuovo programma preventivo +Prossima scadenza +Ora di osservazione +Scadenza originale +Programma sospeso +Manutenzione preventiva +Verificare lettura, ora di osservazione e requisiti di azzeramento del contatore. +Fonte della lettura +Cronologia delle letture +Registra lettura +Si attiva il primo criterio raggiunto tra calendario, contatore e condizione. È consentito un ordine in sospeso per programma. L'annullamento di un ordine generato sospende il programma per revisione. +Verificare programma, fuso orario, finestra di servizio e criteri. +Completare o annullare l'ordine in sospeso prima di modificare destinazione o base del contatore. +Prove di verifica del rilascio +Rimuovi blocco di sicurezza +Qualifica del revisore +Contatore sostituito o azzerato (motivo obbligatorio) +Ripristina lo stato precedente solo se invariato e senza altri blocchi +Scadenza modificata +Blocchi di sicurezza +Programma di manutenzione +Fine della finestra di servizio +Inizio della finestra di servizio +Finestra di servizio +Invio della lista di controllo di origine +Stato attuale conservato +Stato precedente ripristinato +Destinazione della manutenzione +Fuso orario +Blocco di sicurezza dell'unità +Utilizzo e condizione +Utilizzare l'azione di rinvio approvato per modificare la scadenza. +Registrare un azzeramento tracciato per cambiare la base; usare un nuovo programma per unità diverse. +Articolo di magazzino +Ubicazione di magazzino di origine +Lotto di magazzino +Programma preventivo modificato +Soglia di manutenzione raggiunta +Ricambio dell'ordine di lavoro modificato +Programma configurato +Lettura registrata +Azzeramento del contatore registrato +Scadenza rinviata +Ordine di lavoro creato +Segnalazione del ritardo +Segnalazione del ritardo +Blocco di sicurezza applicato +Blocco di sicurezza rimosso +Scadenza rinviata diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx index 8d625361..c8838187 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.pl.resx @@ -154,4 +154,110 @@ Unieważniono część Dodano plik Wycofano plik +Aktywny harmonogram +Termin odroczony +Eskalacja opóźnienia +Nałożono blokadę bezpieczeństwa +Zwolniono blokadę bezpieczeństwa +Nałóż blokadę bezpieczeństwa +Zatwierdź odroczenie +Blokada bezpieczeństwa sprzętu +Przypisanie i eskalacja +Początek okresu wyłączenia +Koniec okresu wyłączenia +Częstotliwość kalendarzowa +Codziennie +Co miesiąc +Bez wyzwalacza kalendarzowego +Co kwartał +Co tydzień +Co rok +Licz następny okres od zweryfikowanego zamknięcia +Wyzwalacz stanu +Na progu lub powyżej +Na progu lub poniżej +Bez wyzwalacza stanu +Próg stanu +Jednostka pomiaru stanu +Odczyt stanu +Powód odroczenia +Edytuj harmonogram +Data końcowa +Eskaluj po upływie minut opóźnienia +Rola odbiorcy eskalacji +Pierwszy termin w czasie lokalnym +Działania po usterce z listy kontrolnej +Wyłącz sprzęt do naprawy +Wyłącz jednostkę ze służby +Niezależny uprawniony kontroler musi zapisać kwalifikacje i dowody zwolnienia. Przywrócenie wymaga ostatniej blokady i niezmienionego stanu; samo zamknięcie nie przywraca gotowości. +Inny uprawniony kontroler musi zwolnić tę blokadę. +Odstęp harmonogramu +Nie udało się zakończyć operacji magazynowej. Sprawdź dostępność i uprawnienia. +Wybierz prawidłowy artykuł i lokalizację źródłową. +Części powiązane z magazynem +Pozostaw artykuł pusty, aby opisać część dowolnym tekstem. Części magazynowe używają kosztów ewidencji i mogą wymagać niezależnego świadka w magazynie. +Wymagane uprawnienie magazynowe lub weryfikacja danych chronionych. +Oczekiwanie na świadka magazynowego; zakończ poświadczenie przed zamknięciem zlecenia. +Wyprzedzenie w dniach +Wczytaj więcej opcji +Magazyn danych konserwacji jest niedostępny. +Baza okresu licznika +Cykle pracy +Generacja licznika +Godziny pracy +Okres licznika +Kilometry +Przejechane mile +Bez wyzwalacza licznika +Jednostka licznika +Odczyt licznika +Nowy harmonogram prewencyjny +Następny termin +Czas obserwacji +Pierwotny termin +Wstrzymany harmonogram +Konserwacja zapobiegawcza +Sprawdź odczyt, czas obserwacji i wymagania zerowania licznika. +Źródło odczytu +Historia odczytów +Zapisz odczyt +Wyzwala pierwszy osiągnięty warunek kalendarza, licznika lub stanu. Dozwolone jest jedno oczekujące zlecenie na harmonogram. Anulowanie wygenerowanego zlecenia wstrzymuje harmonogram do przeglądu. +Sprawdź harmonogram, strefę czasową, okno serwisowe i wyzwalacze. +Zakończ lub anuluj oczekujące zlecenie przed zmianą celu lub bazy licznika. +Dowody weryfikacji zwolnienia +Zwolnij blokadę bezpieczeństwa +Kwalifikacje kontrolera +Licznik wymieniony lub wyzerowany (wymagany powód) +Przywróć poprzedni stan tylko bez zmian i innych blokad +Zmieniony termin +Blokady bezpieczeństwa +Harmonogram konserwacji +Koniec okna serwisowego +Początek okna serwisowego +Okno serwisowe +Źródłowe zgłoszenie listy kontrolnej +Zachowano bieżący stan +Przywrócono poprzedni stan +Cel konserwacji +Strefa czasowa +Blokada bezpieczeństwa jednostki +Użytkowanie i stan +Użyj zatwierdzonego odroczenia, aby zmienić termin. +Zapisz audytowane zerowanie, aby zmienić bazę; dla innych jednostek utwórz nowy harmonogram. +Artykuł magazynowy +Źródłowa lokalizacja magazynowa +Partia magazynowa +Zmieniono harmonogram prewencyjny +Osiągnięto próg konserwacji +Zmieniono część zlecenia +Skonfigurowano harmonogram +Zapisano odczyt +Zapisano zerowanie licznika +Termin odroczony +Utworzono zlecenie pracy +Eskalacja opóźnienia +Eskalacja opóźnienia +Nałożono blokadę bezpieczeństwa +Zwolniono blokadę bezpieczeństwa +Termin odroczony diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx index a5cdf33c..e3441bfe 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.resx @@ -154,4 +154,110 @@ Part voided File added File withdrawn +Active schedule +Due date deferred +Overdue escalation +Safety hold applied +Safety hold released +Apply safety hold +Approve deferral +Equipment safety hold +Assignment and escalation +Blackout starts +Blackout ends +Calendar frequency +Daily +Monthly +No calendar trigger +Quarterly +Weekly +Yearly +Measure the next interval from verified closure +Condition trigger +At or above threshold +At or below threshold +No condition trigger +Condition threshold +Condition measurement unit +Condition reading +Reason for deferral +Edit schedule +End date +Escalate after overdue minutes +Escalation recipient role +First due date and local time +Checklist defect follow-up +Place equipment out for repair +Place unit out of service +An independent authorized reviewer must record qualifications and release evidence. Restoration requires the last hold and an unchanged state; closure alone never restores service. +A different authorized reviewer must release this hold. +Scheduling interval +The inventory operation could not be completed. Review inventory availability and permissions. +Choose a valid inventory item and source location. +Inventory-linked parts +Leave the item blank for a free-text part. Inventory parts use ledger costs and may require an independent witness in Inventory. +Inventory permission or protected-data verification is required. +Inventory witness pending; complete witnessing before closing this work order. +Lead time in days +Load more choices +Maintenance storage is unavailable. +Meter interval baseline +Operating cycles +Meter generation +Operating hours +Meter interval +Kilometers +Miles travelled +No meter trigger +Meter measurement unit +Meter reading +New preventive schedule +Next due date +Observation time +Original due date +Paused schedule +Preventive maintenance +Check the reading, observation time and meter reset requirements. +Reading source +Reading history +Record reading +Calendar, meter and condition triggers use whichever is reached first. One pending order is allowed per schedule. Cancelling a generated order pauses the schedule for review. +Check the schedule, time zone, service window and trigger settings. +Complete or cancel the pending order before changing this target or meter baseline. +Release verification evidence +Release safety hold +Reviewer qualification +Meter replaced or reset (reason required) +Restore previous state only if unchanged and no other holds remain +Revised due date +Safety holds +Maintenance schedule +Service window ends +Service window starts +Service window +Source checklist submission +Current state preserved +Previous state restored +Maintenance target +Time zone +Unit safety hold +Usage and condition +Use the approved deferral action to change the due date. +Record an audited meter reset to change the baseline; use a new schedule for different measurement units. +Inventory item +Source stock location +Inventory lot +Preventive schedule changed +Maintenance threshold reached +Work order part changed +Schedule configured +Reading recorded +Meter reset recorded +Due date deferred +Work order created +Overdue escalation +Overdue escalation +Safety hold applied +Safety hold released +Due date deferred diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx index 26b7823b..1b1afcd9 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.sv.resx @@ -154,4 +154,110 @@ Del makulerad Fil tillagd Fil återkallad +Aktivt schema +Förfallodatum uppskjutet +Försening eskalerad +Säkerhetsspärr införd +Säkerhetsspärr hävd +Inför säkerhetsspärr +Godkänn uppskjutning +Säkerhetsspärr för utrustning +Tilldelning och eskalering +Spärrperiodens början +Spärrperiodens slut +Kalenderfrekvens +Dagligen +Månadsvis +Ingen kalenderutlösare +Kvartalsvis +Veckovis +Årligen +Beräkna nästa intervall från verifierad stängning +Tillståndsutlösare +Vid eller över tröskeln +Vid eller under tröskeln +Ingen tillståndsutlösare +Tillståndströskel +Mätenhet för tillstånd +Tillståndsmätvärde +Orsak till uppskjutning +Redigera schema +Slutdatum +Eskalera efter antal försenade minuter +Mottagarroll för eskalering +Första förfallodatum och lokal tid +Uppföljning av checklistfel +Ta utrustning ur drift för reparation +Ta enheten ur tjänst +En oberoende behörig granskare måste registrera kvalifikationer och frigivningsunderlag. Återställning kräver den sista spärren och oförändrat tillstånd; enbart stängning återställer aldrig drift. +En annan behörig granskare måste häva spärren. +Schemaläggningsintervall +Lageråtgärden kunde inte slutföras. Kontrollera tillgänglighet och behörigheter. +Välj en giltig lagerartikel och källplats. +Lagerkopplade delar +Lämna artikeln tom för en del i fritext. Lagerdelar använder bokförda kostnader och kan kräva ett oberoende vittne i lagret. +Lagerbehörighet eller verifiering av skyddade data krävs. +Lagervittne inväntas; slutför bevittningen innan arbetsordern stängs. +Framförhållning i dagar +Läs in fler alternativ +Underhållslagringen är inte tillgänglig. +Basvärde för mätarintervall +Driftcykler +Mätargeneration +Drifttimmar +Mätarintervall +Kilometer +Körda engelska mil +Ingen mätarutlösare +Mätarens måttenhet +Mätarställning +Nytt förebyggande schema +Nästa förfallodatum +Observationstid +Ursprungligt förfallodatum +Pausat schema +Förebyggande underhåll +Kontrollera mätvärde, observationstid och krav för nollställning. +Mätvärdets källa +Mätvärdeshistorik +Registrera mätvärde +Det första uppnådda villkoret för kalender, mätare eller tillstånd utlöser ordern. En väntande order tillåts per schema. Avbruten genererad order pausar schemat för granskning. +Kontrollera schema, tidszon, servicefönster och utlösare. +Slutför eller avbryt väntande order innan mål eller mätarbas ändras. +Verifieringsunderlag för frigivning +Häv säkerhetsspärr +Granskarens kvalifikation +Mätare bytt eller nollställd (orsak krävs) +Återställ tidigare tillstånd endast om det är oförändrat och inga andra spärrar finns +Ändrat förfallodatum +Säkerhetsspärrar +Underhållsschema +Servicefönstrets slut +Servicefönstrets början +Servicefönster +Ursprunglig checklistinlämning +Aktuellt tillstånd bevarat +Tidigare tillstånd återställt +Underhållsmål +Tidszon +Säkerhetsspärr för enhet +Användning och tillstånd +Använd godkänd uppskjutning för att ändra förfallodatum. +Registrera en spårbar nollställning för att ändra basvärdet; använd ett nytt schema för andra måttenheter. +Lagerartikel +Källplats i lagret +Lagerparti +Förebyggande schema ändrat +Underhållströskel uppnådd +Arbetsorderdel ändrad +Schema konfigurerat +Mätvärde registrerat +Mätarnollställning registrerad +Förfallodatum uppskjutet +Arbetsorder skapad +Försening eskalerad +Försening eskalerad +Säkerhetsspärr införd +Säkerhetsspärr hävd +Förfallodatum uppskjutet diff --git a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx index b7fa630d..237d37c5 100644 --- a/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/WorkOrders/WorkOrders.uk.resx @@ -154,4 +154,110 @@ Анульовано деталь Додано файл Відкликано файл +Активний розклад +Термін відкладено +Ескалація прострочення +Накладено блокування безпеки +Блокування безпеки знято +Накласти блокування безпеки +Затвердити відкладення +Блокування безпеки обладнання +Призначення та ескалація +Початок періоду виключення +Кінець періоду виключення +Календарна періодичність +Щодня +Щомісяця +Без календарного тригера +Щокварталу +Щотижня +Щороку +Відраховувати наступний інтервал від підтвердженого закриття +Тригер за станом +На рівні порога або вище +На рівні порога або нижче +Без тригера за станом +Порогове значення стану +Одиниця вимірювання стану +Показник стану +Причина відкладення +Редагувати розклад +Дата завершення +Ескалювати через вказану кількість хвилин прострочення +Роль одержувача ескалації +Перший термін за місцевим часом +Усунення дефекту з контрольного списку +Вивести обладнання з експлуатації для ремонту +Вивести підрозділ зі служби +Незалежний уповноважений перевіряльник має записати кваліфікацію та докази зняття блокування. Відновлення можливе лише для останнього блокування за незмінного стану; саме закриття не відновлює експлуатацію. +Це блокування має зняти інший уповноважений перевіряльник. +Інтервал планування +Не вдалося завершити складську операцію. Перевірте наявність та дозволи. +Виберіть коректну складську позицію та місце походження. +Деталі, пов’язані зі складом +Залиште позицію порожньою для довільного опису деталі. Складські деталі використовують витрати з обліку та можуть потребувати незалежного свідка в розділі складу. +Потрібен дозвіл на склад або перевірка захищених даних. +Очікується складський свідок; завершіть засвідчення перед закриттям наряду. +Випередження в днях +Завантажити більше варіантів +Сховище технічного обслуговування недоступне. +Базове значення інтервалу лічильника +Робочі цикли +Покоління лічильника +Мотогодини +Інтервал лічильника +Кілометри +Пройдені милі +Без тригера лічильника +Одиниця вимірювання лічильника +Показник лічильника +Новий профілактичний розклад +Наступний термін +Час спостереження +Первісний термін +Призупинений розклад +Профілактичне обслуговування +Перевірте показник, час спостереження та вимоги скидання лічильника. +Джерело показника +Історія показників +Записати показник +Спрацьовує перший досягнутий критерій календаря, лічильника або стану. Дозволено один незавершений наряд на розклад. Скасування створеного наряду призупиняє розклад для перегляду. +Перевірте розклад, часовий пояс, вікно обслуговування та тригери. +Завершіть або скасуйте незавершений наряд перед зміною цілі чи бази лічильника. +Докази перевірки для зняття блокування +Зняти блокування безпеки +Кваліфікація перевіряльника +Лічильник замінено або скинуто (потрібна причина) +Відновити попередній стан лише за відсутності змін та інших блокувань +Змінений термін +Блокування безпеки +Розклад обслуговування +Кінець вікна обслуговування +Початок вікна обслуговування +Вікно обслуговування +Вихідне подання контрольного списку +Поточний стан збережено +Попередній стан відновлено +Об’єкт обслуговування +Часовий пояс +Блокування безпеки підрозділу +Використання та стан +Щоб змінити термін, скористайтеся затвердженим відкладенням. +Запишіть контрольоване скидання для зміни бази; для інших одиниць вимірювання створіть новий розклад. +Складська позиція +Вихідне місце зберігання +Складська партія +Профілактичний розклад змінено +Досягнуто порога обслуговування +Деталь наряду змінено +Розклад налаштовано +Показник записано +Скидання лічильника записано +Термін відкладено +Створено наряд на роботи +Ескалація прострочення +Ескалація прострочення +Накладено блокування безпеки +Блокування безпеки знято +Термін відкладено diff --git a/Core/Resgrid.Model/Checklists/ChecklistContracts.cs b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs index 698a3d09..77fd5a6f 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistContracts.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistContracts.cs @@ -57,6 +57,10 @@ public sealed class ChecklistItem public bool AllowNotApplicable { get; set; } public bool RequireNoteOnFail { get; set; } = true; public bool RequirePhotoOnFail { get; set; } + public bool CreateWorkOrderOnFail { get; set; } + public Resgrid.Model.WorkOrders.WorkOrderPriority WorkOrderPriority { get; set; } = Resgrid.Model.WorkOrders.WorkOrderPriority.Normal; + public bool SetUnitStateOnFail { get; set; } + public bool HoldAssetOnFail { get; set; } public decimal Weight { get; set; } = 1; public string Units { get; set; } public decimal? Minimum { get; set; } diff --git a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs index e764ddb4..46abfbc9 100644 --- a/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs +++ b/Core/Resgrid.Model/Checklists/ChecklistWorkflowPayload.cs @@ -16,7 +16,7 @@ public static class ChecklistWorkflowPayload (int)WorkflowTriggerEventType.ChecklistMissed, (int)WorkflowTriggerEventType.WorkOrderCreated, (int)WorkflowTriggerEventType.WorkOrderStatusChanged, (int)WorkflowTriggerEventType.WorkOrderAssigned, (int)WorkflowTriggerEventType.ChecklistScheduleChanged, (int)WorkflowTriggerEventType.ChecklistOccurrenceSkipped - }.Concat(Resgrid.Model.Inventories.InventoryWorkflowPayload.Triggers).ToArray()); + }.Concat(Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Triggers).Distinct().Concat(Resgrid.Model.Inventories.InventoryWorkflowPayload.Triggers).ToArray()); private static readonly string[] Identifiers = { "CompletionId", "DefinitionId", "VersionId", "ItemId", "ScheduleId", "OccurrenceId" }; private static bool IsStructuralTarget(int type, string target) => type >= 0 && type <= 2 && int.TryParse(target, out var numeric) && numeric > 0 @@ -32,7 +32,7 @@ public static string Routing(string payloadJson, string aggregateId) { var source = Resgrid.Model.Inventories.InventoryWorkflowPayload.Parse(payloadJson); if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(source)) return Resgrid.Model.Inventories.InventoryWorkflowPayload.Routing(source); - if (source["WorkOrderId"] != null) return Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Routing(source); + if (source["WorkOrderId"] != null || source["RecurrenceId"] != null) return Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.Routing(source); var safe = new JObject(); foreach (var name in Identifiers) if (source[name]?.Type == JTokenType.String && Guid.TryParse(source[name].Value(), out var id)) safe[name] = id.ToString("D"); @@ -57,7 +57,7 @@ public static async Task ProjectAsync(int departmentId, object value, IP var source = value as JObject ?? (value == null ? new JObject() : JObject.FromObject(value)); var payload = wrapped ? source["Payload"] as JObject ?? new JObject() : source; if (Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory(payload)) return await Resgrid.Model.Inventories.InventoryWorkflowPayload.ProjectAsync(departmentId, source, protection, wrapped); - if (payload["WorkOrderId"] != null) return await Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); + if (payload["WorkOrderId"] != null || payload["RecurrenceId"] != null) return await Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.ProjectAsync(departmentId, payload, protection, wrapped); var safe = new JObject(); foreach (var name in Identifiers) if (Guid.TryParse(payload[name]?.Value(), out var id)) safe[name] = id.ToString("D"); diff --git a/Core/Resgrid.Model/Inventories/InventoryContracts.cs b/Core/Resgrid.Model/Inventories/InventoryContracts.cs index 9617f682..f21048af 100644 --- a/Core/Resgrid.Model/Inventories/InventoryContracts.cs +++ b/Core/Resgrid.Model/Inventories/InventoryContracts.cs @@ -39,6 +39,7 @@ public sealed class InventoryPosting [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public InventoryUsageType? UsageType { get; set; } [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string PurchaseOrderItemId { get; set; } [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string CountItemId { get; set; } + [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int? WorkOrderPartId { get; set; } public string Note { get; set; } public decimal? UnitCost { get; set; } } diff --git a/Core/Resgrid.Model/Inventories/InventoryModels.cs b/Core/Resgrid.Model/Inventories/InventoryModels.cs index 06ec1e87..0c349217 100644 --- a/Core/Resgrid.Model/Inventories/InventoryModels.cs +++ b/Core/Resgrid.Model/Inventories/InventoryModels.cs @@ -94,6 +94,7 @@ public sealed class InventoryTransaction : InventoryRow public string IssuanceId { get; set; } public string PurchaseOrderItemId { get; set; } public string CountItemId { get; set; } + public int? WorkOrderPartId { get; set; } public int? LegacyInventoryId { get; set; } public DateTime OccurredOn { get; set; } } diff --git a/Core/Resgrid.Model/Repositories/IInventoryStore.cs b/Core/Resgrid.Model/Repositories/IInventoryStore.cs index 27d63619..7bd34f86 100644 --- a/Core/Resgrid.Model/Repositories/IInventoryStore.cs +++ b/Core/Resgrid.Model/Repositories/IInventoryStore.cs @@ -13,10 +13,12 @@ public interface IInventoryStore Task OpenAlertAsync(int departmentId, string dedupKey); Task AlertDeliveryAsync(int departmentId, string alertId, string userId); Task> OpenAlertsAsync(int departmentId, int skip = 0); + Task> ClaimableAlertsAsync(int departmentId, string userId, System.DateTime now, int skip = 0); Task GetAsync(int departmentId, string id) where T : InventoryRow; Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow; Task> QueryAsync(int departmentId, InventoryQuery filter, int skip = 0) where T : InventoryRow; Task> RelatedAsync(int departmentId, string column, string id) where T : InventoryRow; + Task> RelatedManyAsync(int departmentId, string column, IReadOnlyCollection ids) where T : InventoryRow; Task InsertAsync(T row) where T : InventoryRow; Task UpdateAsync(T row, int expectedRevision) where T : InventoryRow; Task RequestAsync(int departmentId, string requestId); diff --git a/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs b/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs new file mode 100644 index 00000000..1d31da0f --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWorkOrderMaintenanceRepository.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Model.Repositories +{ + public interface IWorkOrderMaintenanceRepository + { + 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); + Task FailureAsync(int departmentId, string completionId, string itemId); + Task> ActiveHoldsAsync(int departmentId, int? unitId, string assetId); + Task LockUnitAsync(int departmentId, int unitId); + Task LatestUnitStateAsync(int departmentId, int unitId); + Task LastAppendedUnitStateIdAsync(int departmentId, int unitId); + Task AppendUnitStateAsync(int departmentId, int unitId, int state, DateTime now); + } +} diff --git a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs index 8f2e30e8..cb34fb40 100644 --- a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs +++ b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs @@ -14,6 +14,7 @@ public interface IInventoryCatalogService Task SaveLotAsync(InventoryActor actor, InventoryLot lot, InventoryLotContent details); Task ArchiveAsync(InventoryActor actor, string id, int revision) where T : InventoryMutableRow; Task GetAsync(InventoryActor actor, string id) where T : InventoryRow; + Task> GetManyAsync(InventoryActor actor, IReadOnlyCollection ids) where T : InventoryRow; Task GetLegacyTransactionAsync(InventoryActor actor, int inventoryId); Task> ListAsync(InventoryActor actor, int page = 0) where T : InventoryRow; Task> QueryAsync(InventoryActor actor, InventoryQuery filter, int page = 0) where T : InventoryRow; diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs index a9ecaed8..30ba39c8 100644 --- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -47,6 +47,7 @@ public sealed class WorkOrderException : Exception } public sealed class WorkOrderFilter { + public string ChecklistCompletionId { get; set; } public int Page { get; set; } public WorkOrderStatus? Status { get; set; } public WorkOrderPriority? Priority { get; set; } @@ -88,7 +89,7 @@ public sealed class WorkOrderTransition } public sealed class WorkOrderAssignment { public int Revision { get; set; } public string UserId { get; set; } public int? RoleId { get; set; } } public sealed class WorkOrderLaborInput { public int Revision { get; set; } public string UserId { get; set; } public DateTime WorkDate { get; set; } public WorkOrderLaborContent Content { get; set; } = new WorkOrderLaborContent(); } - public sealed class WorkOrderPartInput { public int Revision { get; set; } public WorkOrderPartContent Content { get; set; } = new WorkOrderPartContent(); } + public sealed class WorkOrderPartInput { public string RequestId { get; set; } public string InventoryItemId { get; set; } public string InventoryAssetId { get; set; } public string InventoryLotId { get; set; } public string InventoryLocationId { get; set; } public int Revision { get; set; } public WorkOrderPartContent Content { get; set; } = new WorkOrderPartContent(); } public sealed class WorkOrderSummary { public WorkOrderStatus? QuickStatus { get; set; } @@ -122,14 +123,19 @@ public sealed class WorkOrderDetail public string VerifiedBy { get; set; } public DateTime? CompletedOn { get; set; } public DateTime? ClosedOn { get; set; } + public string SourceChecklistCompletionId { get; set; } + public string SourceChecklistItemId { get; set; } + public string RecurrenceId { get; set; } + public DateTime? OriginalDueOn { get; set; } + public DateTime? EscalatedOn { get; set; } public List Activities { get; set; } = new List(); public List Labor { get; set; } = new List(); public List Parts { get; set; } = new List(); public List Files { get; set; } = new List(); } - public sealed class WorkOrderActivityView { 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 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 int Id { get; set; } public DateTime? VoidedOn { get; set; } public WorkOrderPartContent 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 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 new file mode 100644 index 00000000..15cbc548 --- /dev/null +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderMaintenance.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; + +namespace Resgrid.Model.WorkOrders +{ + public enum MaintenanceCalendar { None = 0, Daily = 1, Weekly = 2, Monthly = 3, Quarterly = 4, Yearly = 5 } + public enum MaintenanceMeterUnit { None = 0, Hours = 1, Kilometers = 2, Miles = 3, Cycles = 4 } + public enum MaintenanceCondition { None = 0, AtOrAbove = 1, AtOrBelow = 2 } + public enum MaintenanceChangeType { Configured = 0, Reading = 1, MeterReset = 2, Deferred = 3, Generated = 4, Overdue = 5 } + + /// Routing only. Never persist findings, answers or a grant in an unattended intent. + public sealed class WorkOrderFailureIntent : WorkOrderRow + { + public string CompletionId { get; set; } + public string ItemId { get; set; } + public string VersionId { get; set; } + public string OccurrenceId { get; set; } + public string AuthorizedBy { get; set; } + public int TargetType { get; set; } + public string TargetId { get; set; } + public int? TargetGroupId { get; set; } + public int Priority { get; set; } + public bool HoldUnit { get; set; } + public bool HoldAsset { get; set; } + public DateTime? ProcessedOn { get; set; } + } + public sealed class WorkOrderSafetyHold : WorkOrderRow + { + public int? UnitId { get; set; } + public string AssetId { get; set; } + public int? PreviousState { get; set; } + public int? AppliedStateId { get; set; } + public int? AppliedAssetRevision { get; set; } + public DateTime? ReleasedOn { get; set; } + public string ReleasedBy { get; set; } + public bool StateRestored { get; set; } + } + public sealed class WorkOrderHoldContent { public string Reason { get; set; } public string ReleaseEvidence { get; set; } public string Qualification { get; set; } } + public sealed class WorkOrderHoldInput { public int Revision { get; set; } public bool Unit { get; set; } public bool Asset { get; set; } public string Reason { get; set; } } + public sealed class WorkOrderReleaseInput { public int Revision { get; set; } public string Evidence { get; set; } public string Qualification { get; set; } public bool RestoreState { get; set; } } + public sealed class WorkOrderHoldView { public WorkOrderSafetyHold Hold { get; set; } public WorkOrderHoldContent Content { get; set; } } + + public sealed class WorkOrderRecurrence : WorkOrderRow + { + public string RequestId { get; set; } + public int CurrentVersionId { get; set; } + public int? TargetUnitId { get; set; } + public int? TargetGroupId { get; set; } + public string InventoryAssetId { get; set; } + public string AssignedToUserId { get; set; } + public int? AssignedToRoleId { get; set; } + public int Priority { get; set; } + public bool IsActive { get; set; } + public int Calendar { get; set; } + public int Interval { get; set; } + public string TimeZoneId { get; set; } + public DateTime AnchorLocal { get; set; } + public DateTime? EndOn { get; set; } + public int LeadDays { get; set; } + public bool CompletionBased { get; set; } + public int ServiceWeekdays { get; set; } + public int ServiceStartMinute { get; set; } + public int ServiceEndMinute { get; set; } + public DateTime? BlackoutFrom { get; set; } + public DateTime? BlackoutUntil { get; set; } + public DateTime? NextDueOn { get; set; } + public long Cycle { get; set; } + public int? PendingWorkOrderId { get; set; } + public bool ReadingDue { get; set; } + public DateTime? ReadingDueOn { get; set; } + public int MeterUnit { get; set; } + public int MeterEpoch { get; set; } + public decimal? MeterInterval { get; set; } + public decimal? MeterBaseline { get; set; } + public decimal? LastMeterValue { get; set; } + public DateTime? LastReadingOn { get; set; } + public int Condition { get; set; } + public decimal? ConditionThreshold { get; set; } + public bool ConditionLatched { get; set; } + public int EscalateAfterMinutes { get; set; } + public int? EscalationRoleId { get; set; } + } + /// Immutable protected instructions/configuration; generated orders retain this identity. + public sealed class WorkOrderRecurrenceVersion : WorkOrderRow { public int RecurrenceId { get; set; } } + public sealed class WorkOrderMeterReading : WorkOrderRow + { + public int RecurrenceId { get; set; } + public string RequestId { get; set; } + public int MeterEpoch { get; set; } + public DateTime ObservedOn { get; set; } + } + public sealed class WorkOrderRecurrenceChange : WorkOrderRow { public int RecurrenceId { get; set; } public int ChangeType { get; set; } public DateTime? OriginalDueOn { get; set; } public DateTime? RevisedDueOn { get; set; } } + public sealed class WorkOrderRecurrenceInput + { + public int Id { get; set; } + public int Revision { get; set; } + public bool IsActive { get; set; } = true; + public WorkOrderInput Template { get; set; } = new(); + public string AssignedToUserId { get; set; } + public int? AssignedToRoleId { get; set; } + public MaintenanceCalendar Calendar { get; set; } = MaintenanceCalendar.Monthly; + public int Interval { get; set; } = 1; + public string TimeZoneId { get; set; } = "UTC"; + public DateTime AnchorLocal { get; set; } + public DateTime? EndOn { get; set; } + public int LeadDays { get; set; } = 3; + public bool CompletionBased { get; set; } + public int ServiceWeekdays { get; set; } = 127; + public int ServiceStartMinute { get; set; } + public int ServiceEndMinute { get; set; } = 1439; + public DateTime? BlackoutFrom { get; set; } + public DateTime? BlackoutUntil { get; set; } + public MaintenanceMeterUnit MeterUnit { get; set; } + public decimal? MeterInterval { get; set; } + public decimal? MeterBaseline { get; set; } + public MaintenanceCondition Condition { get; set; } + public decimal? ConditionThreshold { get; set; } + public string ConditionUnit { get; set; } + public int EscalateAfterMinutes { get; set; } + public int? EscalationRoleId { get; set; } + public string Reason { get; set; } + } + public sealed class WorkOrderReadingInput { public int Revision { get; set; } public string RequestId { get; set; } public DateTime ObservedOn { get; set; } public decimal? MeterValue { get; set; } public decimal? ConditionValue { get; set; } public bool ResetMeter { get; set; } public string Source { get; set; } public string Note { get; set; } } + public sealed class WorkOrderDeferralInput { public int Revision { get; set; } public DateTime DueOn { get; set; } public string Reason { get; set; } } + public sealed class WorkOrderRecurrenceView { public int HistoryPage { get; set; } public bool HasMoreHistory { get; set; } public WorkOrderRecurrence Schedule { get; set; } public WorkOrderRecurrenceInput Settings { get; set; } public List Changes { get; set; } = new(); public List Readings { get; set; } = new(); } + public sealed class WorkOrderMaintenanceSweep { public int Generated { get; set; } public int Held { get; set; } public int Escalated { get; set; } public int Deferred { get; set; } public int Errors { get; set; } } + public sealed class WorkOrderAssetState { public List OutboxIds { get; set; } = new(); public int State { get; set; } public int Revision { get; set; } } +} + +namespace Resgrid.Model.Services +{ + using Resgrid.Model.WorkOrders; + public interface IWorkOrderMaintenanceService + { + Task> InventoryChoicesAsync(ChecklistActor actor, string kind, string itemId = null, int page = 0); + Task> HoldsAsync(ChecklistActor actor, int orderId); + Task CancelPartWitnessAsync(ChecklistActor actor, int orderId, int partId, int revision, string reason); + Task AddHoldAsync(ChecklistActor actor, int orderId, WorkOrderHoldInput input); + Task ReleaseHoldAsync(ChecklistActor actor, int holdId, WorkOrderReleaseInput input); + Task> RecurrencesAsync(ChecklistActor actor, int page = 0); + Task RecurrenceAsync(ChecklistActor actor, int id, int historyPage = 0); + Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input); + Task RecordReadingAsync(ChecklistActor actor, int id, WorkOrderReadingInput input); + Task DeferAsync(ChecklistActor actor, int orderId, WorkOrderDeferralInput input); + Task GenerateMaintenanceAsync(int departmentId); + Task EscalateMaintenanceAsync(int departmentId); + Task ValidateFailureOptionsAsync(ChecklistActor actor, ChecklistForm form); + Task RecordFailureIntentAsync(ChecklistActor actor, ChecklistCompletion completion, ChecklistDefinitionVersion version, ChecklistItem item); + Task CompleteInventoryPartAsync(InventoryActor actor, int partId, string transactionId, bool reversal, List events); + } + /// Inventory owns its permissions, controlled witnesses, ledger and asset state. Caller owns the shared transaction. + public interface IInventoryWorkOrderAdapter + { + 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 da2a1480..7b8d02a7 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs @@ -8,7 +8,7 @@ namespace Resgrid.Model.WorkOrders public enum WorkOrderStatus { Requested = 0, Accepted = 1, Assigned = 2, InProgress = 3, OnHold = 4, Completed = 5, Closed = 6, Rejected = 7, Duplicate = 8, Cancelled = 9 } public enum WorkOrderPriority { Low = 0, Normal = 1, High = 2, Emergency = 3 } public enum WorkOrderType { Corrective = 0, Preventive = 1, Inspection = 2, Facility = 3, Other = 4 } - public enum WorkOrderActivityType { Created = 0, Updated = 1, StatusChanged = 2, Assigned = 3, AssignmentAccepted = 4, Comment = 5, LaborAdded = 6, PartAdded = 7, PartVoided = 8, FileAdded = 9, FileWithdrawn = 10 } + public enum WorkOrderActivityType { Created = 0, Updated = 1, StatusChanged = 2, Assigned = 3, AssignmentAccepted = 4, Comment = 5, LaborAdded = 6, PartAdded = 7, PartVoided = 8, FileAdded = 9, FileWithdrawn = 10, SafetyHold = 11, SafetyReleased = 12, Deferred = 13, Escalated = 14 } /// Free text, monetary details and file names live only in cataloged Content. Numeric identities are allocated with empty content before sealing. public abstract class WorkOrderRow : IEntity { @@ -59,6 +59,12 @@ public sealed class WorkOrder : WorkOrderRow public bool RestoreUnitStateOnClose { get; set; } public int? PreviousUnitStateType { get; set; } public string WorkOrderRecurrenceId { get; set; } + public int? RecurrenceVersionId { get; set; } + public long? RecurrenceCycle { get; set; } + public DateTime? OriginalDueOn { get; set; } + public DateTime? EscalatedOn { get; set; } + public int EscalateAfterMinutes { get; set; } + public int? EscalationRoleId { get; set; } public bool IsDeleted { get; set; } } public sealed class WorkOrderActivity : WorkOrderRow @@ -72,6 +78,9 @@ public sealed class WorkOrderPart : WorkOrderRow { public string InventoryItemId { get; set; } public string InventoryTransactionId { get; set; } + public string InventoryOperationId { get; set; } + public string InventoryReversalId { get; set; } + public string InventoryRequestId { get; set; } public DateTime? VoidedOn { get; set; } } public sealed class WorkOrderFile : WorkOrderRow @@ -108,14 +117,20 @@ public sealed class WorkOrderContent } 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 { public string Description { get; set; } public decimal Quantity { get; set; } public decimal? UnitCost { get; set; } public string VoidReason { 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 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 static readonly IReadOnlyDictionary All = new Dictionary { [typeof(WorkOrder)] = "WorkOrders", [typeof(WorkOrderActivity)] = "WorkOrderActivities", [typeof(WorkOrderLabor)] = "WorkOrderLabors", - [typeof(WorkOrderPart)] = "WorkOrderParts", [typeof(WorkOrderFile)] = "WorkOrderFiles" + [typeof(WorkOrderPart)] = "WorkOrderParts", [typeof(WorkOrderFile)] = "WorkOrderFiles", + [typeof(WorkOrderFailureIntent)] = "WorkOrderFailureIntents", [typeof(WorkOrderSafetyHold)] = "WorkOrderSafetyHolds", + [typeof(WorkOrderRecurrence)] = "WorkOrderRecurrences", [typeof(WorkOrderRecurrenceVersion)] = "WorkOrderRecurrenceVersions", + [typeof(WorkOrderMeterReading)] = "WorkOrderMeterReadings", [typeof(WorkOrderRecurrenceChange)] = "WorkOrderRecurrenceChanges" }; public static IReadOnlyDictionary Get, Action Set)> Fields() where T : WorkOrderRow => new Dictionary, Action)> { [All[typeof(T)].ToLowerInvariant() + ".content"] = (x => x.Content, (x, v) => x.Content = v) }; diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs index 9ddf6a8b..9d8099f8 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderWorkflowPayload.cs @@ -9,12 +9,13 @@ 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") }; - public static bool IsWorkOrder(int trigger) => trigger is (int)WorkflowTriggerEventType.WorkOrderCreated or (int)WorkflowTriggerEventType.WorkOrderStatusChanged or (int)WorkflowTriggerEventType.WorkOrderAssigned; + 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 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" }) + foreach (var field in new[] { "WorkOrderId", "Revision", "Status", "Priority", "TargetUnitId", "TargetGroupId", "AssignedToRoleId", "RecurrenceId", "HoldId", "OldStatus" }) 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) @@ -25,7 +26,7 @@ public static string Routing(JObject payload) safe["DueOn"] = utc.ToString("O"); } else if (payload["DueOn"]?.Type == JTokenType.String && DateTimeOffset.TryParse(payload["DueOn"].Value(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var due)) safe["DueOn"] = due.UtcDateTime.ToString("O"); - safe["Title"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray("Title"); safe["catalog_version"] = WorkOrderTables.CatalogVersion; + safe["Title"] = ProtectedDataEnvelope.RedactionValue; safe["is_redacted"] = true; safe["redacted_fields"] = new JArray("Title"); safe["catalog_version"] = WorkOrderTables.RecurrenceCatalogVersion; 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 ac372acb..6c2b0142 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -389,6 +389,13 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: + case WorkflowTriggerEventType.WorkOrderOverdue: + case WorkflowTriggerEventType.WorkOrderSafetyHoldApplied: + case WorkflowTriggerEventType.WorkOrderSafetyHoldReleased: + case WorkflowTriggerEventType.WorkOrderRecurrenceChanged: + case WorkflowTriggerEventType.WorkOrderThresholdReached: + case WorkflowTriggerEventType.WorkOrderDeferred: + case WorkflowTriggerEventType.WorkOrderPartChanged: 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.url", "Authenticated work-order link", "string", false)); diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 1f962201..9b0ec643 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -171,6 +171,13 @@ public enum WorkflowTriggerEventType WorkOrderCreated = 70, WorkOrderStatusChanged = 71, WorkOrderAssigned = 72, + WorkOrderOverdue = 73, + WorkOrderSafetyHoldApplied = 167, + WorkOrderSafetyHoldReleased = 168, + WorkOrderRecurrenceChanged = 169, + WorkOrderThresholdReached = 170, + WorkOrderDeferred = 171, + WorkOrderPartChanged = 172, // Inventory modernization: persisted registry allocations; InventoryAdjusted retains value 22. InventoryTransferCompleted = 58, diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs index 00249c89..d6e987f5 100644 --- a/Core/Resgrid.Services/ChecklistsService.cs +++ b/Core/Resgrid.Services/ChecklistsService.cs @@ -35,13 +35,21 @@ public partial class ChecklistsService : IChecklistsService private readonly Lazy _reportCalls; private readonly Lazy _reportAuthorization; private readonly IChecklistHistoricalAssetSource _historicalAssets; + private readonly Lazy _failureMaintenance; 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) - { _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; } + 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; } - public Task CanManageAsync(ChecklistActor actor) => _authorization.CanManageAsync(actor); + private async Task ValidateMaintenanceOptionsAsync(ChecklistActor actor, ChecklistForm form) + { + if (!form.Sections.SelectMany(s => s.Items).Any(i => i.CreateWorkOrderOnFail || i.SetUnitStateOnFail || i.HoldAssetOnFail)) return; + if (_failureMaintenance == null) throw new ChecklistException(503, "MaintenanceUnavailable"); + try { await _failureMaintenance.Value.ValidateFailureOptionsAsync(actor, form); } + catch (Resgrid.Model.WorkOrders.WorkOrderException ex) { throw new ChecklistException(ex.StatusCode, ex.Code); } + } + public Task CanManageAsync(ChecklistActor actor) => _authorization.CanManageAsync(actor); private async Task RequireWriteAsync(ChecklistActor actor, bool manage = false) { await _authorization.RequireMemberAsync(actor); @@ -146,7 +154,7 @@ public async Task GetDefinitionAsync(ChecklistActor act } public async Task SaveDefinitionAsync(ChecklistActor actor, string id, int revision, ChecklistForm form) { - await RequireWriteAsync(actor, true); Valid(ChecklistValidation.Validate(form)); if (id != null) Id(id); + await RequireWriteAsync(actor, true); Valid(ChecklistValidation.Validate(form)); await ValidateMaintenanceOptionsAsync(actor, form); if (id != null) Id(id); if (form.TargetType == ChecklistTargetType.InventoryAsset && !await AssetTargetsAvailableAsync(actor)) throw new ChecklistException(400, "AssetUnavailable"); foreach (var section in form.Sections) { @@ -176,7 +184,7 @@ await TransactionAsync(actor, async events => { var row = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, id)); Revision(row, revision); if (row.DeletedOn.HasValue) throw new ChecklistException(409, "Deleted definitions cannot be published."); - Valid(ChecklistValidation.Validate(Decode(row.Content))); + Valid(ChecklistValidation.Validate(Decode(row.Content))); await ValidateMaintenanceOptionsAsync(actor, Decode(row.Content)); var version = New(actor, row.Id); version.Version = row.PublishedVersion + 1; version.Content = row.Content; await PersistAsync(actor, version, true); row.CurrentVersionId = version.Id; row.PublishedVersion = version.Version; row.Retired = false; row.Revision++; @@ -333,7 +341,16 @@ public async Task SaveRunAsync(ChecklistActor actor, string id, ChecklistRu row.SubmittedOn = DateTime.UtcNow; row.Score = evaluation.Score; row.Passed = evaluation.Passed; row.SubmissionHash = hash; row.State = (int)(view.Form.RequiresIndependentWitness ? ChecklistRunState.AwaitingWitness : ChecklistRunState.Submitted); await AdvanceOccurrenceAsync(actor, row); - foreach (var failed in evaluation.FailedItemIds) await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistFailed, failed, events); + foreach (var failed in evaluation.FailedItemIds) + { + await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistFailed, failed, events); + var option = view.Form.Sections.SelectMany(s => s.Items).Single(i => i.Id == failed); + if (option.CreateWorkOrderOnFail) + { + if (_failureMaintenance == null) throw new ChecklistException(503, "MaintenanceUnavailable"); + await _failureMaintenance.Value.RecordFailureIntentAsync(actor, row, await _store.GetAsync(actor.DepartmentId, row.VersionId), option); + } + } if (row.State == (int)ChecklistRunState.Submitted) await EventAsync(actor, row, WorkflowTriggerEventType.ChecklistCompleted, null, events); } await PersistAsync(actor, row, false); await AuditAsync(actor, row, submit ? AuditLogTypes.ChecklistCompletionSubmitted : AuditLogTypes.ChecklistProgressSaved, diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index df557ca8..91f1191e 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -53,10 +53,11 @@ public GdprDataExportService( ICertificationService certificationService, ITrainingService trainingService, IShiftsService shiftsService, - IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders, IInventoryStore inventoryStore) + IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders, IInventoryStore inventoryStore, IWorkOrderMaintenanceRepository maintenance) { _repository = repository; - _workOrders = workOrders ?? throw new ArgumentNullException(nameof(workOrders)); + _workOrderMaintenance = maintenance ?? throw new ArgumentNullException(nameof(maintenance)); + _workOrders = workOrders ?? throw new ArgumentNullException(nameof(workOrders)); _inventoryStore = inventoryStore ?? throw new ArgumentNullException(nameof(inventoryStore)); _checklistReminders = checklistReminders ?? throw new ArgumentNullException(nameof(checklistReminders)); _userProfileService = userProfileService; @@ -190,6 +191,7 @@ private async Task BuildExportZipAsync(string userId, int departmentId) await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger); await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(userId, departmentId), ledger); await AddJsonEntry(archive, "workorders.json", await BuildWorkOrderDataAsync(userId, departmentId), ledger); + await AddJsonEntry(archive, "maintenance.json", await BuildMaintenanceDataAsync(userId, departmentId), ledger); await AddJsonEntry(archive, "inventory.json", await BuildInventoryDataAsync(userId, departmentId), ledger); // Written last, so it can report what every other entry withheld. Only present when diff --git a/Core/Resgrid.Services/InventoryAlerts.cs b/Core/Resgrid.Services/InventoryAlerts.cs index f50b71ab..84219edc 100644 --- a/Core/Resgrid.Services/InventoryAlerts.cs +++ b/Core/Resgrid.Services/InventoryAlerts.cs @@ -45,13 +45,13 @@ private async Task RequireAlertAccessAsync(InventoryActor actor, InventoryAlert if (item == null) throw new InventoryException(404, "Unavailable"); if (item.IsControlledSubstance) await _auth.RequireAsync(actor, false, PermissionTypes.ManageControlledSubstances); } - private async Task SetAlertAsync(int departmentId, InventoryAlertType type, string itemId, string locationId, string lotId, string assetId, string issuanceId, bool active, DateTime? due, decimal? quantity, List events) + private async Task SetAlertAsync(int departmentId, InventoryAlertType type, string itemId, string locationId, string lotId, string assetId, string issuanceId, bool active, DateTime? due, decimal? quantity, List events, Dictionary openAlerts = null) { var key = Fingerprint(new { type, itemId, locationId, lotId, assetId, issuanceId }); - var current = await _store.OpenAlertAsync(departmentId, key); + var current = openAlerts == null ? await _store.OpenAlertAsync(departmentId, key) : openAlerts.GetValueOrDefault(key); if (!active) { - if (current != null) { current.Status = 1; current.ResolvedOn = Now; await UpdateAlertMetadataAsync(current); } + if (current != null) { current.Status = 1; current.ResolvedOn = Now; await UpdateAlertMetadataAsync(current); openAlerts?.Remove(key); } return; } if (current != null) @@ -62,6 +62,7 @@ private async Task SetAlertAsync(int departmentId, InventoryAlertType type, stri var row = New(new InventoryActor { DepartmentId = departmentId }); row.AlertType = (int)type; row.DedupKey = key; row.ItemId = itemId; row.LocationId = locationId; row.LotId = lotId; row.AssetId = assetId; row.IssuanceId = issuanceId; row.OpenedOn = Now; row.DueOn = due; row.Quantity = quantity; await _store.InsertAsync(row); + if (openAlerts != null) openAlerts[key] = row; var trigger = type == InventoryAlertType.LowStock ? WorkflowTriggerEventType.InventoryLowStock : type == InventoryAlertType.OverdueReturn ? WorkflowTriggerEventType.InventoryReturnOverdue : WorkflowTriggerEventType.InventoryExpiring; var entry = await _outbox.EnqueueAsync(departmentId, "Inventory", new DomainEventEnvelope { EventName = trigger.ToString(), Trigger = trigger, SchemaVersion = 1, AggregateType = "InventoryAlert", AggregateId = row.Id, OccurredOn = Now, @@ -110,10 +111,37 @@ private async Task RefreshPostingAlertsAsync(InventoryActor actor, InventoryComm } foreach (var item in items) await RefreshLowStockAsync(actor, item, events); } + private async Task RefreshAllLowStockAsync(InventoryActor actor, List events) + { + // One department snapshot under the posting lock; reuse holder liveness across items and lots. + var openAlerts = (await AllOpenAlertsAsync(actor.DepartmentId)).ToDictionary(a => a.DedupKey); + var stocks = (await AllAsync(actor.DepartmentId)).Where(s => !s.IsDeleted).ToLookup(s => s.ItemId); + var assets = (await AllAsync(actor.DepartmentId)).Where(a => !a.IsDeleted && a.Status is 0 or 1 or 2 or 3).ToLookup(a => a.ItemId); + var locations = new Dictionary(); + async Task Live(string id) + { + if (id == null) return false; + if (!locations.TryGetValue(id, out var live)) locations[id] = live = await LiveAlertLocationAsync(actor.DepartmentId, id); + return live; + } + foreach (var row in await AllAsync(actor.DepartmentId)) + { + await AuthorizeRowAsync(actor, row); + var item = await RevealAsync(actor, row); var content = Decode(item); decimal quantity = 0; + if (item.TrackingMode == 0) + { + foreach (var stock in stocks[item.Id]) if (await Live(stock.LocationId)) quantity += stock.Quantity; + } + else foreach (var asset in assets[item.Id]) if (await Live(asset.CurrentLocationId)) quantity++; + var threshold = content.ReorderPoint ?? content.MinLevel; + await SetAlertAsync(actor.DepartmentId, InventoryAlertType.LowStock, item.Id, null, null, null, null, + !item.IsDeleted && item.IsActive && threshold.HasValue && quantity <= threshold.Value, null, quantity, events, openAlerts); + } + } public Task RefreshAlertsAsync(InventoryActor actor) => TransactionAsync(actor, async events => { await _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory); - foreach (var item in await AllAsync(actor.DepartmentId)) await RefreshLowStockAsync(actor, item.Id, events); + await RefreshAllLowStockAsync(actor, events); await EvaluateDatedAlertsAsync(actor.DepartmentId, events); return true; }); public Task SweepAlertsAsync(int departmentId) => AlertTransactionAsync(departmentId, async events => { await EvaluateDatedAlertsAsync(departmentId, events); return true; }); @@ -188,20 +216,25 @@ public async Task CanReceiveAlertAsync(int departmentId, string userId, st } public Task ClaimAlertAsync(int departmentId, string userId) => AlertTransactionAsync(departmentId, async events => { - foreach (var alert in await AllOpenAlertsAsync(departmentId)) + for (var skip = 0; skip <= 100000; skip += 500) { - var delivery = await _store.AlertDeliveryAsync(departmentId, alert.Id, userId); - if (delivery != null && (delivery.State is 2 or 3 || delivery.NextAttemptOn > Now || delivery.LeaseUntil > Now)) continue; - if (!await CanReceiveAlertAsync(departmentId, userId, alert.Id)) continue; - if (delivery == null) + var candidates = await _store.ClaimableAlertsAsync(departmentId, userId, Now, skip); + foreach (var alert in candidates.Take(500)) { - delivery = New(new InventoryActor { DepartmentId = departmentId }); delivery.AlertId = alert.Id; delivery.UserId = userId; delivery.NextAttemptOn = Now; - await _store.InsertAsync(delivery); + if (!await CanReceiveAlertAsync(departmentId, userId, alert.Id)) continue; + var delivery = await _store.AlertDeliveryAsync(departmentId, alert.Id, userId); + if (delivery != null && (delivery.State is 2 or 3 || delivery.NextAttemptOn > Now || delivery.LeaseUntil > Now)) continue; + if (delivery == null) + { + delivery = New(new InventoryActor { DepartmentId = departmentId }); delivery.AlertId = alert.Id; delivery.UserId = userId; delivery.NextAttemptOn = Now; + await _store.InsertAsync(delivery); + } + delivery.State = 1; delivery.ClaimToken = Guid.NewGuid().ToString("D"); delivery.LeaseUntil = Now.AddMinutes(30); delivery.AttemptCount++; + await UpdateAlertMetadataAsync(delivery); return delivery; } - delivery.State = 1; delivery.ClaimToken = Guid.NewGuid().ToString("D"); delivery.LeaseUntil = Now.AddMinutes(30); delivery.AttemptCount++; - await UpdateAlertMetadataAsync(delivery); return delivery; + if (candidates.Count <= 500) return null; } - return null; + throw new InventoryException(409, "InventoryTooLarge"); }); private async Task> AllOpenAlertsAsync(int departmentId) { diff --git a/Core/Resgrid.Services/InventoryCatalog.cs b/Core/Resgrid.Services/InventoryCatalog.cs index 0ee7bb30..67f94fa9 100644 --- a/Core/Resgrid.Services/InventoryCatalog.cs +++ b/Core/Resgrid.Services/InventoryCatalog.cs @@ -110,7 +110,7 @@ public Task ArchiveAsync(InventoryActor actor, string id, int revision) where }); public Task RebuildStocksAsync(InventoryActor actor) => TransactionAsync(actor, async events => { await _auth.RequireAsync(actor, true); await _store.RebuildStocksAsync(actor.DepartmentId); - foreach (var item in await AllAsync(actor.DepartmentId)) await RefreshLowStockAsync(actor, item.Id, events); + await RefreshAllLowStockAsync(actor, events); return true; }); public async Task> GetByReferenceAsync(InventoryActor actor, InventoryReferenceType type, string id) diff --git a/Core/Resgrid.Services/InventoryModernizationService.cs b/Core/Resgrid.Services/InventoryModernizationService.cs index aa958043..19aaa55f 100644 --- a/Core/Resgrid.Services/InventoryModernizationService.cs +++ b/Core/Resgrid.Services/InventoryModernizationService.cs @@ -15,7 +15,7 @@ namespace Resgrid.Services { - public sealed partial class InventoryModernizationService : IInventoryCatalogService, IInventoryStockService, IInventoryTransferService, IInventoryIssuanceService, IInventoryMigrationService, IInventoryPurchasingService, IInventoryOperationsService, IInventoryAlertService, IChecklistAssetSource, IChecklistHistoricalAssetSource + public sealed partial class InventoryModernizationService : IInventoryCatalogService, IInventoryStockService, IInventoryTransferService, IInventoryIssuanceService, IInventoryMigrationService, IInventoryPurchasingService, IInventoryOperationsService, IInventoryAlertService, IInventoryWorkOrderAdapter, IChecklistAssetSource, IChecklistHistoricalAssetSource { private readonly IInventoryStore _store; private readonly IInventoryAuthorizationService _auth; @@ -34,12 +34,15 @@ public sealed partial class InventoryModernizationService : IInventoryCatalogSer private readonly Lazy _recordsAuthorization; private readonly Lazy _recordUsage; private readonly IContactsService _contacts; + private readonly IWorkOrderMaintenanceRepository _maintenanceOrders; + private readonly Lazy _workOrderMaintenance; + private readonly IReadinessAccessService _readinessAccess; public InventoryModernizationService(IInventoryStore store, IInventoryAuthorizationService auth, IUnitOfWork uow, IProtectedReadService read, IProtectedWriteService write, IDomainEventOutboxService outbox, IAuditLogsRepository audit, IUnitsService units, IDepartmentGroupsService groups, TimeProvider clock = null, IInventoryRepository legacyInventory = null, IInventoryTypesRepository legacyTypes = null, IWorkOrderRepository workOrders = null, Lazy workOrderAuthorization = null, - Lazy recordsAuthorization = null, Lazy recordUsage = null, IContactsService contacts = null) - { _store = store; _auth = auth; _uow = uow; _read = read; _write = write; _outbox = outbox; _audit = audit; _units = units; _groups = groups; _clock = clock ?? TimeProvider.System; _legacyInventory = legacyInventory; _legacyTypes = legacyTypes; _workOrders = workOrders; _workOrderAuthorization = workOrderAuthorization; _recordsAuthorization = recordsAuthorization; _recordUsage = recordUsage; _contacts = contacts; } + Lazy recordsAuthorization = null, Lazy recordUsage = null, IContactsService contacts = null, IWorkOrderMaintenanceRepository maintenanceOrders = null, Lazy workOrderMaintenance = null, IReadinessAccessService readinessAccess = null) + { _store = store; _auth = auth; _uow = uow; _read = read; _write = write; _outbox = outbox; _audit = audit; _units = units; _groups = groups; _clock = clock ?? TimeProvider.System; _legacyInventory = legacyInventory; _legacyTypes = legacyTypes; _workOrders = workOrders; _workOrderAuthorization = workOrderAuthorization; _recordsAuthorization = recordsAuthorization; _recordUsage = recordUsage; _contacts = contacts; _maintenanceOrders = maintenanceOrders; _workOrderMaintenance = workOrderMaintenance; _readinessAccess = readinessAccess; } public Task IsMigratedAsync(int departmentId) => _store.HasLegacyMigrationAsync(departmentId); private DateTime Now => _clock.GetUtcNow().UtcDateTime; private static void Id(string id) { if (!Guid.TryParseExact(id, "D", out var value) || value == Guid.Empty) throw new InventoryException(400, "InvalidIdentifier"); } diff --git a/Core/Resgrid.Services/InventoryPosting.cs b/Core/Resgrid.Services/InventoryPosting.cs index 8968d48e..960a5e22 100644 --- a/Core/Resgrid.Services/InventoryPosting.cs +++ b/Core/Resgrid.Services/InventoryPosting.cs @@ -36,6 +36,8 @@ 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?.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"); if (line?.PurchaseOrderItemId != null && !purchasing) throw new InventoryException(400, "PurchaseReceiptRequired"); if (line != null && (line.UsageId != null || line.UsageType.HasValue)) @@ -207,7 +209,16 @@ public Task WitnessAsync(InventoryActor actor, string requestId await RequirePurchaseReceiptAccessAsync(performer, receipt.PendingPurchaseReceipt); result = await CompletePurchaseReceiptAsync(actor, operation, receipt.PendingPurchaseReceipt, events, performer.UserId, actor.UserId, attestation); break; - case "Post": + case "WorkOrder": + if (_workOrderMaintenance == null || receipt.PendingCommand?.Lines?.Count != 1) throw new InventoryException(409, "ReferenceUnavailable"); + await RequireCommandAccessAsync(performer, receipt.PendingCommand, joined: true); + await RequireWorkOrderPartAsync(performer, receipt.PendingCommand.Lines[0]); + await RequireCommandAccessAsync(actor, receipt.PendingCommand, joined: true); + await ValidateCommandAsync(actor, receipt.PendingCommand, joined: true); + result = await PostLinesAsync(actor, operation, receipt.PendingCommand, events, performer.UserId, actor.UserId, attestation); + await _workOrderMaintenance.Value.CompleteInventoryPartAsync(actor, receipt.PendingCommand.Lines[0].WorkOrderPartId.Value, result.TransactionIds.Single(), receipt.PendingCommand.Lines[0].ReversesTransactionId != null, events); + break; + case "Post": case "Transfer": case "Receive": await RequireCommandAccessAsync(performer, receipt.PendingCommand); @@ -247,11 +258,12 @@ private async Task PostLinesAsync(InventoryActor actor, Invento transaction.FromLocationId = line.FromLocationId; transaction.ToLocationId = line.ToLocationId; transaction.TransactionType = (int)line.Type; transaction.ReferenceType = (int)line.ReferenceType; transaction.ReferenceId = line.ReferenceId; transaction.OccurredOn = Now; transaction.ReversesTransactionId = line.ReversesTransactionId; transaction.IssuanceId = line.IssuanceId; transaction.PurchaseOrderItemId = line.PurchaseOrderItemId; - transaction.CountItemId = line.CountItemId; + transaction.CountItemId = line.CountItemId; transaction.WorkOrderPartId = line.WorkOrderPartId; if (line.ReversesTransactionId != null) { var original = await GetAsync(actor, line.ReversesTransactionId); if (original.PurchaseOrderItemId != null) throw new InventoryException(409, "PurchaseReceiptImmutable"); + if (original.WorkOrderPartId.HasValue && original.WorkOrderPartId != line.WorkOrderPartId) throw new InventoryException(409, "WorkOrderPostingRequired"); if (original.CountItemId != null) throw new InventoryException(409, "CountReceiptImmutable"); if (original.ReferenceType == (int)InventoryReferenceType.RmsRecord && (line.ReferenceType != InventoryReferenceType.RmsRecord || line.ReferenceId != original.ReferenceId)) throw new InventoryException(409, "RecordUsageCorrectionRequired"); if (original.ReversesTransactionId != null || original.ItemId != item.Id || original.AssetId != line.AssetId || original.LotId != line.LotId || original.Quantity != line.Quantity @@ -319,7 +331,8 @@ private async Task MoveAssetAsync(InventoryActor actor, InventoryPosting line, I if (issuance != null && line.Type is not (InventoryTransactionType.Return or InventoryTransactionType.StatusChange) && !(line.Type == InventoryTransactionType.Issue && issuance.Id == line.IssuanceId)) throw new InventoryException(409, "ReturnAssetFirst"); var status = line.Type switch { InventoryTransactionType.Issue => InventoryAssetStatus.Issued, InventoryTransactionType.Consume => InventoryAssetStatus.Consumed, InventoryTransactionType.WriteOff or InventoryTransactionType.Count => InventoryAssetStatus.Lost, InventoryTransactionType.Return or InventoryTransactionType.StatusChange => line.Status ?? InventoryAssetStatus.InService, _ => (InventoryAssetStatus)asset.Status }; - if (line.Type == InventoryTransactionType.StatusChange) + if (status is InventoryAssetStatus.InService or InventoryAssetStatus.Issued && _maintenanceOrders != null && (await _maintenanceOrders.ActiveHoldsAsync(actor.DepartmentId, null, asset.Id)).Count > 0) throw new InventoryException(409, "AssetSafetyHold"); + if (line.Type == InventoryTransactionType.StatusChange) { if (asset.Status == (int)status) throw new InventoryException(409, "StatusUnchanged"); if (status == InventoryAssetStatus.Issued && issuance == null || status == InventoryAssetStatus.InService && issuance != null) throw new InventoryException(409, "IssuanceStatusConflict"); diff --git a/Core/Resgrid.Services/InventoryPurchasing.cs b/Core/Resgrid.Services/InventoryPurchasing.cs index dfc99b2f..751d08a2 100644 --- a/Core/Resgrid.Services/InventoryPurchasing.cs +++ b/Core/Resgrid.Services/InventoryPurchasing.cs @@ -40,11 +40,15 @@ public async Task> GetVendorContactsAsync(InventoryA { await RequirePurchasingAccessAsync(actor, false); if (_contacts == null) throw new InventoryException(404, "VendorContactUnavailable"); - var contacts = (await _contacts.GetAllContactsForDepartmentAsync(actor.DepartmentId)).Where(c => c.DepartmentId == actor.DepartmentId && c.ContactType == 1 && !c.IsDeleted).ToList(); + var contacts = (await _contacts.GetAllContactsForDepartmentAsync(actor.DepartmentId)).Where(c => c.DepartmentId == actor.DepartmentId && c.ContactType == 1 && !c.IsDeleted) + .Select(c => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(c))).ToList(); if (contacts.Count > 5000) throw new InventoryException(409, "InventoryTooLarge"); - var result = new List(); - foreach (var contact in contacts) result.Add(new() { Id = contact.ContactId, Name = (await VendorContactAsync(actor, contact.ContactId, true)).CompanyName }); - return result; + if (contacts.Count == 0) return new List(); + var wasPlain = contacts.Any(c => !string.IsNullOrEmpty(c.CompanyName) && !ProtectedDataEnvelope.HasEnvelopePrefix(c.CompanyName)); + var result = await _read.ResolveContactsForReadAsync(actor.DepartmentId, contacts, actor.GrantToken, actor.UserId); + if (result == null || result.RedactedFields.Count > 0 || result.IsProtected && wasPlain || contacts.Any(c => ProtectedDataEnvelope.HasEnvelopePrefix(c.CompanyName))) + throw new InventoryException(403, "ProtectedDataRequired"); + return contacts.Select(c => new InventoryVendorChoice { Id = c.ContactId, Name = c.CompanyName }).ToList(); } public Task SaveVendorAsync(InventoryActor actor, InventoryVendorInput input) => TransactionAsync(actor, async events => { diff --git a/Core/Resgrid.Services/InventoryQueries.cs b/Core/Resgrid.Services/InventoryQueries.cs index 1d580a6b..5f599ab3 100644 --- a/Core/Resgrid.Services/InventoryQueries.cs +++ b/Core/Resgrid.Services/InventoryQueries.cs @@ -6,6 +6,24 @@ namespace Resgrid.Services { public sealed partial class InventoryModernizationService { + public async Task> GetManyAsync(InventoryActor actor, System.Collections.Generic.IReadOnlyCollection ids) where T : InventoryRow + { + await _auth.RequireAsync(actor); + if (ids == null || ids.Count > 5000) throw new InventoryException(400, "InvalidPage"); + foreach (var id in ids) Id(id); + var result = new System.Collections.Generic.Dictionary(); + foreach (var batch in ids.Distinct().Chunk(500)) + { + var rows = await _store.RelatedManyAsync(actor.DepartmentId, "Id", batch); + if (rows.Count != batch.Length) throw new InventoryException(404, "Unavailable"); + foreach (var row in rows) + { + await AuthorizeRowAsync(actor, row); + result.Add(row.Id, await RevealAsync(actor, row)); + } + } + return result; + } public async Task GetLegacyTransactionAsync(InventoryActor actor, int inventoryId) { await _auth.RequireAsync(actor); diff --git a/Core/Resgrid.Services/InventoryWorkOrders.cs b/Core/Resgrid.Services/InventoryWorkOrders.cs new file mode 100644 index 00000000..227d7ab4 --- /dev/null +++ b/Core/Resgrid.Services/InventoryWorkOrders.cs @@ -0,0 +1,94 @@ +using System; +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 CancelPendingPartAsync(InventoryActor actor, int partId, string operationId) + { + if (_uow.Transaction == null) throw new InvalidOperationException("The source transaction owns cancellation."); + await _store.LockDepartmentAsync(actor.DepartmentId); + var operation = await GetAsync(actor, operationId); + var receipt = Decode(operation); + if (operation.State != 1 || receipt.PendingKind != "WorkOrder" || receipt.PendingCommand?.Lines?.Count != 1 || receipt.PendingCommand.Lines[0].WorkOrderPartId != partId) + throw new InventoryException(409, "ReferenceUnavailable"); + await RequireWorkOrderPartAsync(actor, receipt.PendingCommand.Lines[0]); + await RequireCommandAccessAsync(actor, receipt.PendingCommand, joined: true); + operation.State = 3; await SaveAsync(actor, operation, false); + } + public async Task PostPartAsync(InventoryActor actor, int partId, InventoryCommand command) + { + if (_uow.Transaction == null || _workOrders == null || _maintenanceOrders == null || _readinessAccess == null) throw new InvalidOperationException("Work-order inventory requires its source transaction."); + await _store.LockDepartmentAsync(actor.DepartmentId); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId) || !await _auth.IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled"); + if (!await _readinessAccess.CanUseMaintenanceAsync(actor.DepartmentId)) throw new InventoryException(402, "ReadinessProRequired"); + if (command?.Lines?.Count != 1 || command.Lines[0].WorkOrderPartId != partId) throw new InventoryException(400, "ReferenceUnavailable"); + var preflight = await _write.PreflightWriteAsync(actor.DepartmentId, actor.GrantToken, actor.UserId, false); + if (preflight?.Success != true) throw new InventoryException(403, "ProtectedDataRequired"); + await RequireCommandAccessAsync(actor, command, joined: true); + var events = new System.Collections.Generic.List(); + return await OperationAsync(actor, command.RequestId, new { Kind = "WorkOrder", command.Lines }, async (operation, pending) => + { + await ValidateCommandAsync(actor, command, joined: true); + if (await RequiresWitnessAsync(actor, command.Lines.Select(l => l.ItemId))) return await AwaitWitnessAsync(actor, operation, "WorkOrder", command); + return await PostLinesAsync(actor, operation, command, pending); + }, events); + } + private async Task RequireWorkOrderPartAsync(InventoryActor actor, InventoryPosting line) + { + if (!line.WorkOrderPartId.HasValue || _workOrders == null || _workOrderAuthorization == null || _readinessAccess == null) throw new InventoryException(400, "ReferenceUnavailable"); + 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"); + 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"); + } + public async Task ApplyHoldAsync(InventoryActor actor, int orderId, string assetId, int? restoreState = null, int? expectedRevision = null, bool safetyRelease = false) + { + if (_uow.Transaction == null || _maintenanceOrders == null || _workOrders == null || _workOrderAuthorization == null) throw new InvalidOperationException("Safety changes require the work-order transaction."); + await _store.LockDepartmentAsync(actor.DepartmentId); + if (!await _store.HasLegacyMigrationAsync(actor.DepartmentId)) throw new InventoryException(409, "MigrationRequired"); + var asset = await _store.GetAsync(actor.DepartmentId, assetId); + if (asset == null || asset.IsDeleted || asset.Status is 4 or 5 or 6 || asset.CurrentLocationId == null) { if (safetyRelease) return null; throw new InventoryException(409, "AssetNotAvailable"); } + var location = await LocationAsync(actor, asset.CurrentLocationId, write: !safetyRelease, permission: PermissionTypes.AdjustInventory); + var item = await _store.GetAsync(actor.DepartmentId, asset.ItemId); + if (item?.IsControlledSubstance == true) await _auth.RequireAsync(actor, !safetyRelease, PermissionTypes.ManageControlledSubstances); + var order = await _workOrders.GetAsync(actor.DepartmentId, orderId); + var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; + if (order?.InventoryAssetId != assetId || !await _workOrderAuthorization.Value.CanManageAsync(principal, order.TargetGroupId)) throw new InventoryException(403, "PermissionRequired"); + var old = asset.Status; + if (safetyRelease) + { + if (!restoreState.HasValue || restoreState is < 0 or > 3 || asset.Revision != expectedRevision || asset.Status != (int)InventoryAssetStatus.OutForRepair) return null; + var active = await _maintenanceOrders.ActiveHoldsAsync(actor.DepartmentId, null, assetId); + if (active.Count != 1 || active[0].WorkOrderId != orderId) return null; + var issuance = (await _store.RelatedAsync(actor.DepartmentId, "AssetId", assetId)).SingleOrDefault(i => i.Status is 0 or 2); + if (restoreState == (int)InventoryAssetStatus.InService && issuance != null || restoreState == (int)InventoryAssetStatus.Issued && issuance == null) return null; + asset.Status = restoreState.Value; + } + else + { + if (_readinessAccess == null || !await _readinessAccess.CanUseMaintenanceAsync(actor.DepartmentId)) throw new InventoryException(402, "ReadinessProRequired"); + if (asset.Status == (int)InventoryAssetStatus.OutForRepair) return new WorkOrderAssetState { State = old, Revision = asset.Revision }; + asset.Status = (int)InventoryAssetStatus.OutForRepair; + } + // Only reviewed state metadata changes. Protected Content is preserved byte-for-byte; no worker read grant exists. + var expected = asset.Revision++; asset.ModifiedOn = Now; await _store.UpdateAsync(asset, expected); + var ledger = New(actor); ledger.ItemId = asset.ItemId; ledger.AssetId = asset.Id; ledger.LotId = asset.LotId; + ledger.TransactionType = (int)InventoryTransactionType.StatusChange; ledger.OldStatus = old; ledger.NewStatus = asset.Status; + ledger.FromLocationId = asset.CurrentLocationId; ledger.ToLocationId = asset.CurrentLocationId; ledger.OccurredOn = Now; + ledger.ReferenceType = (int)InventoryReferenceType.WorkOrder; ledger.ReferenceId = orderId.ToString(System.Globalization.CultureInfo.InvariantCulture); + await _store.InsertAsync(ledger); + var events = new System.Collections.Generic.List(); await EventAsync(ledger, WorkflowTriggerEventType.InventoryAssetStatusChanged, events); + return new WorkOrderAssetState { State = old, Revision = asset.Revision, OutboxIds = events }; + } + } +} diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 161b9f17..f89baf70 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -700,7 +700,7 @@ void Prevention(string table, string column, ProtectedFieldClassification classi ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, ChecklistContentCatalogVersion)); foreach (var table in Resgrid.Model.WorkOrders.WorkOrderTables.All.Values) list.Add(new ProtectedFieldDefinition(table.ToLowerInvariant() + ".content", OperationalFamily, table, "Content", ProtectedFieldStorageKind.Text, - ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.WorkOrders.WorkOrderTables.CatalogVersion)); + ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.WorkOrders.WorkOrderTables.VersionFor(table))); list.Add(new ProtectedFieldDefinition("workorderfiles.data", OperationalFamily, "WorkOrderFiles", "Data", ProtectedFieldStorageKind.Binary, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, Resgrid.Model.WorkOrders.WorkOrderTables.CatalogVersion)); foreach (var table in Resgrid.Model.Inventories.InventoryTables.All.Values) diff --git a/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs b/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs index a61bda4d..f25ec45e 100644 --- a/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs +++ b/Core/Resgrid.Services/Records/RecordInventoryUsageService.cs @@ -274,22 +274,32 @@ async Task Authorize() // This endpoint must not reveal a copied legacy snapshot without an Inventory protection boundary. legacy.Note = legacy.ItemName = legacy.UnitOfMeasure = ProtectedDataEnvelope.RedactionValue; } - foreach (var usage in result.Where(x => x.TransactionId != null)) + var modern = result.Where(x => x.TransactionId != null).ToList(); + var transactions = modern.Count == 0 ? new Dictionary() + : await _modernCatalog.GetManyAsync(actor, modern.Select(x => x.TransactionId).Distinct().ToArray()); + var correctionIds = modern.Where(x => x.ReversesUsageId != null).Select(x => x.UsageId).Distinct().ToArray(); + var corrections = correctionIds.Length == 0 ? new Dictionary() + : await _modernCatalog.GetManyAsync(actor, correctionIds); + var reversals = new List(); + foreach (var batch in modern.Where(x => x.ReversesUsageId == null).Select(x => x.TransactionId).Distinct().Chunk(500)) + reversals.AddRange(await _modernStore.RelatedManyAsync(actor.DepartmentId, "ReversesTransactionId", batch)); + var reversalsByTransaction = reversals.ToLookup(t => t.ReversesTransactionId); + var attachedCorrections = result.Where(x => x.ReversesUsageId != null).Select(x => (x.ReversesUsageId, x.TransactionId)).ToHashSet(); + foreach (var usage in modern) { - var transaction = await _modernCatalog.GetAsync(actor, usage.TransactionId); + var transaction = transactions[usage.TransactionId]; await AuthorizeModernSourceAsync(actor, transaction.FromLocationId ?? transaction.ToLocationId, historical: true); var details = JObject.Parse(transaction.Content ?? "{}"); usage.Note = details.Value("Note"); usage.ItemName = details.Value("ItemName"); usage.UnitOfMeasure = details.Value("UnitOfMeasure"); usage.AssetId = transaction.AssetId; usage.LotId = transaction.LotId; usage.LocationId = transaction.FromLocationId ?? transaction.ToLocationId; if (usage.ReversesUsageId != null) { - var row = await _modernCatalog.GetAsync(actor, usage.UsageId); + var row = corrections[usage.UsageId]; usage.Note = JsonConvert.DeserializeObject(row.Content ?? "{}")?.Note; } else { - var reversedTransactions = await _modernStore.RelatedAsync(actor.DepartmentId, "ReversesTransactionId", usage.TransactionId); - usage.PendingReversalTransactionId = reversedTransactions.FirstOrDefault(t => !result.Any(r => r.ReversesUsageId == usage.UsageId && r.TransactionId == t.Id))?.Id; + usage.PendingReversalTransactionId = reversalsByTransaction[usage.TransactionId].FirstOrDefault(t => !attachedCorrections.Contains((usage.UsageId, t.Id)))?.Id; } } var reversed = result.Where(x => x.ReversesUsageId != null).Select(x => x.ReversesUsageId).ToHashSet(); diff --git a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs index 23e65ae9..75630121 100644 --- a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs +++ b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs @@ -41,9 +41,9 @@ public class RecordsEvidenceService : IRecordsEvidenceService public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, IEnumerable adapters, IRecordsAuthorizationService authorization, ICallsService calls, IRmsExternalReferencesRepository references, - IRecordsProtectionService protection, IDomainEventOutboxService outbox, IInventoryStore inventoryStore = null) + IRecordsProtectionService protection, IDomainEventOutboxService outbox, IInventoryStore inventoryStore) { - _inventoryStore = inventoryStore; + _inventoryStore = inventoryStore ?? throw new ArgumentNullException(nameof(inventoryStore)); _protection = protection; _outbox = outbox; _artifacts = artifacts; @@ -67,7 +67,6 @@ public async Task RequireInventoryCoverageAsync(int departmentId, string recordI // Never finalize an apparently balanced evidence snapshot while that source movement is unaccounted for. foreach (var reference in references.Where(r => r.SourceEntityType == "RecordInventoryUsage")) { - if (_inventoryStore == null) throw new InvalidOperationException("Inventory evidence verification is unavailable."); var usage = await _inventoryStore.GetAsync(departmentId, reference.SourceEntityId); if (usage == null || usage.SourceId != recordId || usage.RecordKind != reference.RecordKind) throw new InvalidOperationException("Inventory usage provenance is unavailable."); if (usage.ReversesUsageId != null) continue; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 01059dde..a8637bc0 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -16,7 +16,7 @@ public class ServicesModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().AsSelf().As().As().As() + builder.RegisterType().AsSelf().As().As().As().As() .As().As().As().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -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().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkOrderInventoryChoices.cs b/Core/Resgrid.Services/WorkOrderInventoryChoices.cs new file mode 100644 index 00000000..b8a5482d --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderInventoryChoices.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + public async Task> InventoryChoicesAsync(ChecklistActor actor, string kind, string itemId = null, int page = 0) + { + await _authorization.RequireMemberAsync(actor); + if (_inventoryCatalog == null || page < 0 || page > 10000 || kind is not ("item" or "location" or "lot" or "asset") || (kind is "lot" or "asset") && !Guid.TryParseExact(itemId, "D", out _)) + throw new WorkOrderException(400, "InventoryPartInvalid"); + try + { + return kind switch + { + "item" => await InventoryChoicesPageAsync(actor, new(), page, "Name"), + "location" => await InventoryChoicesPageAsync(actor, new(), page, "Name"), + "lot" => await InventoryChoicesPageAsync(actor, new() { ItemId = itemId }, page, "LotNumber"), + _ => await InventoryChoicesPageAsync(actor, new() { ItemId = itemId }, page, "SerialNumber") + }; + } + catch (InventoryException ex) { throw new WorkOrderException(ex.StatusCode, ex.StatusCode == 403 ? "InventoryPermissionRequired" : "InventoryOperationFailed"); } + } + private async Task> InventoryChoicesPageAsync(ChecklistActor actor, InventoryQuery query, int page, string label) where T : InventoryRow + { + var result = await _inventoryCatalog.Value.QueryAsync(InventoryActor(actor), query, page); + return new InventoryPage { HasMore = result.HasMore, Items = result.Items.Where(row => row is not InventoryMutableRow mutable || !mutable.IsDeleted) + .Select(row => new WorkOrderChoice { Id = row.Id, Name = JObject.Parse(row.Content ?? "{}").Value(label) ?? row.Id }).ToList() }; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderInventoryParts.cs b/Core/Resgrid.Services/WorkOrderInventoryParts.cs new file mode 100644 index 00000000..e0764f0a --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderInventoryParts.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + public async Task CancelPartWitnessAsync(ChecklistActor actor, int orderId, int partId, int revision, string reason) + { + Text(reason, 4000, true); + 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) + 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"); + await _inventoryMaintenance.Value.CancelPendingPartAsync(InventoryActor(actor), part.Id, part.InventoryOperationId); + if (part.InventoryTransactionId == null) { part.VoidedOn = Now; content.VoidReason = reason; } + else { content.VoidReason = null; part.InventoryOperationId = (await _inventoryCatalog.Value.GetAsync(InventoryActor(actor), part.InventoryTransactionId)).OperationId; } + part.Content = JsonConvert.SerializeObject(content); part.Revision++; await SaveAsync(actor, part); + await ChangedAsync(actor, order, WorkOrderActivityType.PartVoided, events, reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + }); + } + private async Task RequireNoPendingPartsAsync(ChecklistActor actor, int id) + { + 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"); + } + public async Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) + { + if (input?.Content == null || input.Content.Quantity <= 0 || input.Content.Quantity > 100000 || decimal.Round(input.Content.Quantity, 6) != input.Content.Quantity) throw new WorkOrderException(400, "InvalidInput"); + Text(input.Content.Description, 2000, true); Money(input.Content.UnitCost); input.Content.VoidReason = null; + var linked = !string.IsNullOrWhiteSpace(input.InventoryItemId); + var fingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { input.Content.Description, input.Content.Quantity, input.Content.UnitCost, input.InventoryItemId, input.InventoryAssetId, input.InventoryLotId, input.InventoryLocationId })))); + if (linked && (!Guid.TryParseExact(input.RequestId, "D", out _) || !Guid.TryParseExact(input.InventoryItemId, "D", out _) || !Guid.TryParseExact(input.InventoryLocationId, "D", out _) || _inventoryMaintenance == null)) throw new WorkOrderException(400, "InventoryPartInvalid"); + await TransactionAsync(actor, async events => + { + if (linked) + { + RequireMaintenanceStore(); + var prior = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "InventoryRequestId", input.RequestId)).SingleOrDefault(); + if (prior != null) + { + 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"); + 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; + part.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, part, true); + if (linked) + { + 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 = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = part.Id } } }; + var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), part.Id, command); + events.AddRange(result.OutboxIds); part.InventoryOperationId = result.OperationId; part.Revision++; + await RevealAsync(actor, part); await SaveAsync(actor, part); + if (!result.AwaitingWitness) { await CompleteInventoryPartAsync(InventoryActor(actor), part.Id, result.TransactionIds.Single(), false, events); return true; } + } + await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + }); + } + public async Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason) + { + Text(reason, 4000, true); + 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"); + 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++; + if (part.InventoryItemId != null) + { + if (part.InventoryTransactionId == null || _inventoryCatalog == null || _inventoryMaintenance == null) throw new WorkOrderException(409, "InventoryWitnessPending"); + var original = await _inventoryCatalog.Value.GetAsync(InventoryActor(actor), part.InventoryTransactionId); + if (original.WorkOrderPartId != partId || original.ReversesTransactionId != null) throw new WorkOrderException(409, "InventoryPartInvalid"); + await SaveAsync(actor, part); + var command = new InventoryCommand { RequestId = MaintenanceIdentity("part-reversal:" + actor.DepartmentId + ":" + part.Id + ":" + part.Revision), Lines = new() { new InventoryPosting { + ItemId = original.ItemId, AssetId = original.AssetId, LotId = original.LotId, ToLocationId = original.FromLocationId, Quantity = original.Quantity, Type = InventoryTransactionType.Adjust, + ReferenceType = InventoryReferenceType.WorkOrder, ReferenceId = id.ToString(CultureInfo.InvariantCulture), WorkOrderPartId = partId, ReversesTransactionId = original.Id, Note = reason } } }; + var result = await _inventoryMaintenance.Value.PostPartAsync(InventoryActor(actor), partId, command); events.AddRange(result.OutboxIds); + if (!result.AwaitingWitness) { await CompleteInventoryPartAsync(InventoryActor(actor), partId, result.TransactionIds.Single(), true, events); return true; } + part.InventoryOperationId = result.OperationId; await RevealAsync(actor, part); await SaveAsync(actor, part); + } + else { part.VoidedOn = Now; await SaveAsync(actor, part); } + await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, reason, trigger: WorkflowTriggerEventType.WorkOrderPartChanged); return true; + }); + } + public async Task CompleteInventoryPartAsync(InventoryActor actor, int partId, string transactionId, bool reversal, List events) + { + if (_uow.Transaction == null || _inventoryCatalog == null) throw new InvalidOperationException("The inventory posting transaction owns completion."); + var principal = new ChecklistActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; + await RequireWriteAsync(principal); + var part = await _store.GetAsync(actor.DepartmentId, partId); + if (part?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); + var row = await ReadOrderAsync(principal, part.WorkOrderId.Value); + if (row.Status >= 5 || !await _authorization.CanContributeAsync(principal, row)) throw new WorkOrderException(403, "PermissionRequired"); + 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"); + 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; } + else + { + if (part.InventoryTransactionId != null) throw new WorkOrderException(409, "Conflict"); + part.InventoryTransactionId = ledger.Id; + var details = JObject.Parse(ledger.Content ?? "{}"); content.UnitCost = details["UnitCost"]?.Value(); content.Currency = details["CurrencyCode"]?.Value(); + part.Content = JsonConvert.SerializeObject(content); + } + part.Revision++; await SaveAsync(principal, part); + 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 new file mode 100644 index 00000000..182d3646 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Resources; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Inventories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + private static string MaintenanceText(string key) => new ResourceManager("Resgrid.Localization.Areas.User.WorkOrders.WorkOrders", typeof(Resgrid.Localization.SupportedLocales).Assembly).GetString(key, CultureInfo.CurrentUICulture) ?? key; + private void RequireMaintenanceStore() { if (_maintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); } + private static InventoryActor InventoryActor(ChecklistActor actor) => new() { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken }; + private static string MaintenanceIdentity(string source) => new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(source)).Take(16).ToArray()).ToString("D"); + private async Task RevealOrderAsync(ChecklistActor actor, WorkOrder row) + { + await RevealAsync(actor, row); + if (!string.IsNullOrEmpty(row.Content)) return row; + WorkOrderContent fields; + if (row.SourceType == 2 && row.RecurrenceVersionId.HasValue) + { + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.RecurrenceVersionId.Value)); + var settings = Decode(version.Content); + fields = settings?.Template?.Content ?? throw new WorkOrderException(409, "MaintenanceUnavailable"); + } + else if (row.SourceType == 1 && row.SourceChecklistCompletionId != null) fields = new WorkOrderContent { Title = MaintenanceText("GeneratedFailureTitle"), SafetyCritical = true }; + else throw new WorkOrderException(409, "MaintenanceUnavailable"); + // An attended projection only. First mutation seals a row-specific copy with its numeric AAD. + row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = fields }); return row; + } + public async Task ValidateFailureOptionsAsync(ChecklistActor actor, ChecklistForm form) + { + var items = form.Sections.SelectMany(s => s.Items).ToList(); + if (items.Any(i => !Enum.IsDefined(i.WorkOrderPriority) || !i.CreateWorkOrderOnFail && (i.SetUnitStateOnFail || i.HoldAssetOnFail))) throw new WorkOrderException(400, "InvalidInput"); + if (!items.Any(i => i.CreateWorkOrderOnFail)) return; + await _authorization.RequireMemberAsync(actor); + if (!await _authorization.CanManageAsync(actor, null)) throw new WorkOrderException(403, "PermissionRequired"); + if (items.Any(i => i.SetUnitStateOnFail) && form.TargetType is not (ChecklistTargetType.Unit or ChecklistTargetType.InventoryAsset) + || items.Any(i => i.HoldAssetOnFail) && form.TargetType != ChecklistTargetType.InventoryAsset) throw new WorkOrderException(400, "TargetUnavailable"); + // Configuring the failure routing does not call billing from free Checklists. + } + public async Task RecordFailureIntentAsync(ChecklistActor actor, ChecklistCompletion completion, ChecklistDefinitionVersion version, ChecklistItem item) + { + if (!item.CreateWorkOrderOnFail) return; + RequireMaintenanceStore(); + if (_uow.Transaction == null || completion.DepartmentId != actor.DepartmentId || version.DepartmentId != actor.DepartmentId || version.Id != completion.VersionId) throw new InvalidOperationException("A checklist failure intent requires its source transaction."); + if (await _maintenance.FailureAsync(actor.DepartmentId, completion.Id, item.Id) != null) return; + var intent = New(actor); + intent.CompletionId = completion.Id; intent.ItemId = item.Id; intent.VersionId = version.Id; intent.OccurrenceId = completion.OccurrenceId; + intent.AuthorizedBy = version.CreatedBy; intent.TargetType = completion.TargetType; intent.TargetId = completion.TargetId; intent.TargetGroupId = completion.TargetGroupId; + intent.Priority = (int)item.WorkOrderPriority; intent.HoldUnit = item.SetUnitStateOnFail; intent.HoldAsset = item.HoldAssetOnFail; + await _store.AllocateAsync(intent); // No answers, findings, copied text, billing lookup or paid side effect. + } + private async Task AutomatedTargetAsync(ChecklistActor owner, int? unitId, int? groupId, string assetId) + { + await _authorization.RequireMemberAsync(owner); + var target = new WorkOrderInput { TargetUnitId = unitId, TargetGroupId = groupId, InventoryAssetId = assetId }; + if (assetId != null) + { + var asset = _maintenanceAssets == null ? null : await _maintenanceAssets.RoutingAsync(owner.DepartmentId, assetId); + if (asset?.DepartmentId != owner.DepartmentId || !await _maintenanceAssets.CanReceiveReminderAsync(owner.DepartmentId, owner.UserId, assetId)) throw new WorkOrderException(404, "TargetUnavailable"); + target.TargetUnitId = asset.UnitId; target.TargetGroupId = asset.GroupId; + } + else if (unitId.HasValue) + { + var unit = _maintenanceUnits == null ? null : await _maintenanceUnits.GetUnitByIdAsync(unitId.Value); + if (unit?.DepartmentId != owner.DepartmentId) throw new WorkOrderException(404, "TargetUnavailable"); + target.TargetGroupId = unit.StationGroupId; + } + if (!await _authorization.CanManageAsync(owner, target.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + return target; + } + private async Task> WorkerTransactionAsync(int departmentId, Func, Task> work) + { + RequireMaintenanceStore(); + if (_uow.Transaction != null) throw new InvalidOperationException("Maintenance workers own their transaction."); + var events = new List(); + try + { + await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _store.LockDepartmentAsync(departmentId); + if (await _access.CanUseMaintenanceAsync(departmentId)) await work(events); + _uow.CommitChanges(); + } + catch { _uow.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(events); return events; + } + private async Task ProcessFailureAsync(int departmentId, int intentId, WorkOrderMaintenanceSweep result) + { + await WorkerTransactionAsync(departmentId, async events => + { + var intent = await _store.GetAsync(departmentId, intentId); + if (intent == null || intent.ProcessedOn.HasValue) return; + var completion = _checklists == null ? null : await _checklists.GetAsync(departmentId, intent.CompletionId); + // A submitted failure requires prompt restriction even while its independent checklist witness is pending. + if ((completion == null || completion.State != (int)ChecklistRunState.Submitted && completion.State != (int)ChecklistRunState.AwaitingWitness) || completion.VersionId != intent.VersionId) { result.Deferred++; return; } + var owner = new ChecklistActor { DepartmentId = departmentId, UserId = intent.AuthorizedBy }; + var unit = intent.TargetType == (int)ChecklistTargetType.Unit && int.TryParse(intent.TargetId, out var u) ? (int?)u : null; + var target = await AutomatedTargetAsync(owner, unit, intent.TargetGroupId, intent.TargetType == (int)ChecklistTargetType.InventoryAsset ? intent.TargetId : null); + var request = MaintenanceIdentity("failure:" + departmentId + ":" + intent.CompletionId + ":" + intent.ItemId); + var order = await _store.RequestAsync(departmentId, request); + if (order == null) + { + order = New(new ChecklistActor { DepartmentId = departmentId, UserId = completion.CreatedBy }); + 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++; + } + 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++; } + intent.WorkOrderId = order.Id; intent.ProcessedOn = Now; intent.UpdatedOn = Now; intent.Revision++; await _store.WriteAsync(intent); + }); + } + public async Task> HoldsAsync(ChecklistActor actor, int orderId) + { + RequireMaintenanceStore(); await ReadOrderAsync(actor, orderId); + return (await ChildrenAsync(actor, orderId)).Select(h => new WorkOrderHoldView { Hold = h, Content = Decode(h.Content) }).ToList(); + } + public async Task AddHoldAsync(ChecklistActor actor, int orderId, WorkOrderHoldInput input) + { + if (input == null || !input.Unit && !input.Asset) throw new WorkOrderException(400, "InvalidInput"); + Text(input.Reason, 4000, true); RequireMaintenanceStore(); + await TransactionAsync(actor, async events => + { + var order = await ReadOrderAsync(actor, orderId); Revision(order, input.Revision); + if (Terminal(order) || !await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + if (input.Unit) await CreateHoldAsync(actor, order, true, input.Reason, events, false); + if (input.Asset) await CreateHoldAsync(actor, order, false, input.Reason, events, false); + await ChangedAsync(actor, order, WorkOrderActivityType.SafetyHold, events, input.Reason); return true; + }); + } + private async Task CreateHoldAsync(ChecklistActor actor, WorkOrder order, bool unit, string reason, List events, bool unattended) + { + if (unit && !order.TargetUnitId.HasValue || !unit && order.InventoryAssetId == null) throw new WorkOrderException(400, "TargetUnavailable"); + var holds = await _maintenance.ActiveHoldsAsync(actor.DepartmentId, unit ? order.TargetUnitId : null, unit ? null : order.InventoryAssetId); + if (holds.Any(h => h.WorkOrderId == order.Id)) return; + var hold = New(actor, order.Id); + hold.UnitId = unit ? order.TargetUnitId : null; hold.AssetId = unit ? null : order.InventoryAssetId; + if (unit) + { + await AutomatedTargetAsync(actor, order.TargetUnitId, order.TargetGroupId, null); + var previous = await _maintenance.LatestUnitStateAsync(actor.DepartmentId, order.TargetUnitId.Value); + var inherited = holds.FirstOrDefault(h => h.AppliedStateId == previous?.UnitStateId); + hold.PreviousState = inherited?.PreviousState ?? previous?.State; + if (previous?.State == (int)UnitStateTypes.OutOfService) hold.AppliedStateId = inherited?.AppliedStateId; // Never take ownership of a manual restriction. + else hold.AppliedStateId = await _maintenance.AppendUnitStateAsync(actor.DepartmentId, order.TargetUnitId.Value, (int)UnitStateTypes.OutOfService, Now); + } + else + { + if (_inventoryMaintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); + var applied = await _inventoryMaintenance.Value.ApplyHoldAsync(InventoryActor(actor), order.Id, order.InventoryAssetId); + events.AddRange(applied.OutboxIds); + var inherited = holds.FirstOrDefault(h => h.AppliedAssetRevision == applied.Revision); + hold.PreviousState = inherited?.PreviousState ?? applied.State; + hold.AppliedAssetRevision = applied.State == (int)InventoryAssetStatus.OutForRepair ? inherited?.AppliedAssetRevision : applied.Revision; + } + if (unattended) await _store.AllocateAsync(hold); + else { hold.Content = JsonConvert.SerializeObject(new WorkOrderHoldContent { Reason = reason }); await SaveAsync(actor, hold, true); } + await MaintenanceEventAsync(order, WorkflowTriggerEventType.WorkOrderSafetyHoldApplied, events, holdId: hold.Id); + } + public async Task ReleaseHoldAsync(ChecklistActor actor, int holdId, WorkOrderReleaseInput input) + { + if (input == null) throw new WorkOrderException(400, "InvalidInput"); + Text(input.Evidence, 4000, true); Text(input.Qualification, 2000, true); RequireMaintenanceStore(); + await TransactionAsync(actor, async events => + { + var hold = await _store.GetAsync(actor.DepartmentId, holdId); + if (hold?.WorkOrderId == null) throw new WorkOrderException(404, "Unavailable"); + var order = await ReadOrderAsync(actor, hold.WorkOrderId.Value); await RevealAsync(actor, hold); Revision(hold, input.Revision); + if (hold.ReleasedOn.HasValue || !await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(409, "PermissionRequired"); + if (actor.UserId == order.CompletedBy || actor.UserId == hold.CreatedBy) throw new WorkOrderException(403, "IndependentReleaseRequired"); + var others = (await _maintenance.ActiveHoldsAsync(actor.DepartmentId, hold.UnitId, hold.AssetId)).Where(h => h.Id != hold.Id).ToList(); + if (input.RestoreState && others.Count == 0 && hold.PreviousState.HasValue) + { + if (hold.UnitId.HasValue && hold.AppliedStateId.HasValue) + { + var current = await _maintenance.LatestUnitStateAsync(actor.DepartmentId, hold.UnitId.Value); + if (current?.UnitStateId == hold.AppliedStateId && current.State == (int)UnitStateTypes.OutOfService && await _maintenance.LastAppendedUnitStateIdAsync(actor.DepartmentId, hold.UnitId.Value) == hold.AppliedStateId) + { await AutomatedTargetAsync(actor, hold.UnitId, order.TargetGroupId, null); await _maintenance.AppendUnitStateAsync(actor.DepartmentId, hold.UnitId.Value, hold.PreviousState.Value, Now); hold.StateRestored = true; } + } + else if (hold.AssetId != null && hold.AppliedAssetRevision.HasValue && _inventoryMaintenance != null) + { + var restored = await _inventoryMaintenance.Value.ApplyHoldAsync(InventoryActor(actor), order.Id, hold.AssetId, hold.PreviousState, hold.AppliedAssetRevision, true); + hold.StateRestored = restored != null; if (restored != null) events.AddRange(restored.OutboxIds); + } + } + var content = Decode(hold.Content); content.ReleaseEvidence = input.Evidence; content.Qualification = input.Qualification; + hold.Content = JsonConvert.SerializeObject(content); hold.ReleasedOn = Now; hold.ReleasedBy = actor.UserId; hold.Revision++; await SaveAsync(actor, hold); + await ChangedAsync(actor, order, WorkOrderActivityType.SafetyReleased, events, input.Evidence); + await MaintenanceEventAsync(order, WorkflowTriggerEventType.WorkOrderSafetyHoldReleased, events, holdId: hold.Id); return true; + }, safetyRelease: true); + } + private async Task MaintenanceEventAsync(WorkOrder order, WorkflowTriggerEventType trigger, List events, int? holdId = null, int? recurrenceId = null) + { + var entry = await _outbox.EnqueueAsync(order.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger.ToString(), AggregateType = "WorkOrder", AggregateId = Key(order), AggregateVersion = order.Revision, Trigger = trigger, OccurredOn = Now, + Payload = new { WorkOrderId = order.Id, order.Revision, order.Status, order.Priority, order.TargetUnitId, order.TargetGroupId, order.InventoryAssetId, order.AssignedToRoleId, order.DueOn, HoldId = holdId, RecurrenceId = recurrenceId } }); + events.Add(entry.DomainEventOutboxId); + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs new file mode 100644 index 00000000..8756c990 --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderMaintenanceGdprExport.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public partial class GdprDataExportService + { + private readonly IWorkOrderMaintenanceRepository _workOrderMaintenance; + private async Task BuildMaintenanceDataAsync(string userId, int departmentId) + { + async Task> Rows(Func subject) where T : WorkOrderRow + { + var result = new List(); + for (var skip = 0; ; skip += 500) + { + var batch = await _workOrderMaintenance.QueryMaintenanceAsync(departmentId, skip: skip); + foreach (var row in batch.Where(subject)) + result.Add(await _checklistProtection.Value.ForDisplayAsync(departmentId, row, WorkOrderTables.Fields())); + if (batch.Count < 500) return result; + } + } + // Export subject-authored facts only; holding a maintenance role does not imply ownership of others' histories. + var versions = await Rows(r => r.CreatedBy == userId); + var readings = await Rows(r => r.CreatedBy == userId); + 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 { + 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)), + Versions = versions, Readings = readings, Changes = changes + }; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index 663e0915..ca5c11d1 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -35,7 +35,7 @@ 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) || !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) || !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; @@ -85,6 +85,8 @@ private async Task IsRecipientAsync(int departmentId, WorkOrder row, strin private async Task> RecipientsAsync(int departmentId, WorkOrder row) { var result = new System.Collections.Generic.HashSet(await _authorization.RecipientsAsync(departmentId, row)); + if (row.EscalatedOn.HasValue && row.EscalationRoleId.HasValue) + result.UnionWith(await _authorization.RecipientsAsync(departmentId, new WorkOrder { DepartmentId = departmentId, AssignedToRoleId = row.EscalationRoleId })); var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true); foreach (var member in members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true)) { diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs new file mode 100644 index 00000000..07276a2d --- /dev/null +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Services +{ + public sealed partial class WorkOrdersService + { + public static TimeZoneInfo MaintenanceZone(string id) + { + try { return TimeZoneInfo.FindSystemTimeZoneById(id ?? "UTC"); } + catch (TimeZoneNotFoundException) { throw new WorkOrderException(400, "RecurrenceInvalid"); } + catch (InvalidTimeZoneException) { throw new WorkOrderException(400, "RecurrenceInvalid"); } + } + public static DateTime MaintenanceUtc(DateTime local, string zoneId) + { + var zone = MaintenanceZone(zoneId); local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); + // Gaps advance to the first valid minute; folds choose the later UTC occurrence once. + for (var i = 0; zone.IsInvalidTime(local) && i < 180; i++) local = local.AddMinutes(1); + if (zone.IsInvalidTime(local)) throw new WorkOrderException(400, "RecurrenceInvalid"); + if (zone.IsAmbiguousTime(local)) return new DateTimeOffset(local, zone.GetAmbiguousTimeOffsets(local).Min()).UtcDateTime; + return TimeZoneInfo.ConvertTimeToUtc(local, zone); + } + private static DateTime CalendarAt(WorkOrderRecurrence row, int index) => (MaintenanceCalendar)row.Calendar switch + { + MaintenanceCalendar.Daily => row.AnchorLocal.AddDays((long)row.Interval * index), + MaintenanceCalendar.Weekly => row.AnchorLocal.AddDays((long)row.Interval * index * 7), + MaintenanceCalendar.Monthly => row.AnchorLocal.AddMonths(checked(row.Interval * index)), + MaintenanceCalendar.Quarterly => row.AnchorLocal.AddMonths(checked(row.Interval * index * 3)), + MaintenanceCalendar.Yearly => row.AnchorLocal.AddYears(checked(row.Interval * index)), + _ => throw new WorkOrderException(400, "RecurrenceInvalid") + }; + public static DateTime? NextMaintenanceDue(WorkOrderRecurrence row, DateTime? after) + { + if (row.Calendar == 0) return null; + for (var cycle = 0; cycle < 80000; cycle++) + { + DateTime local; + try { local = CalendarAt(row, cycle); } catch (ArgumentOutOfRangeException) { return null; } + if (local.Year > 2200 || row.EndOn.HasValue && local.Date > row.EndOn.Value.Date) return null; + var utc = MaintenanceUtc(local, row.TimeZoneId); + if (!after.HasValue || utc > after) return utc; + } + throw new WorkOrderException(400, "RecurrenceInvalid"); + } + public static DateTime ServiceDue(WorkOrderRecurrence row, DateTime originalUtc) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(originalUtc, DateTimeKind.Utc), MaintenanceZone(row.TimeZoneId)); + for (var i = 0; i < 732; i++) + { + if (row.BlackoutFrom.HasValue && local.Date >= row.BlackoutFrom.Value.Date && local.Date <= row.BlackoutUntil.Value.Date) { local = row.BlackoutUntil.Value.Date.AddDays(1); continue; } + var minute = local.Hour * 60 + local.Minute; + if ((row.ServiceWeekdays & (1 << (int)local.DayOfWeek)) == 0 || minute > row.ServiceEndMinute) { local = local.Date.AddDays(1); continue; } + if (minute < row.ServiceStartMinute) local = local.Date.AddMinutes(row.ServiceStartMinute); + return MaintenanceUtc(local, row.TimeZoneId); + } + throw new WorkOrderException(400, "RecurrenceInvalid"); + } + private async Task RequireRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrence row) + { + await _authorization.RequireMemberAsync(actor); + if (row == null || row.DepartmentId != actor.DepartmentId || !await _authorization.CanManageAsync(actor, row.TargetGroupId)) throw new WorkOrderException(404, "Unavailable"); + } + private async Task RecurrenceViewAsync(ChecklistActor actor, WorkOrderRecurrence row, bool history, int historyPage = 0) + { + await RequireRecurrenceAsync(actor, row); await RevealAsync(actor, row); + var version = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.CurrentVersionId)); + var settings = Decode(version.Content); settings.Id = row.Id; settings.Revision = row.Revision; settings.IsActive = row.IsActive; settings.MeterBaseline = row.MeterBaseline; settings.AnchorLocal = row.AnchorLocal; + var view = new WorkOrderRecurrenceView { HistoryPage = historyPage, Schedule = row, Settings = settings }; + if (history) + { + foreach (var change in await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RecurrenceId", row.Id, historyPage * 500)) view.Changes.Add(await RevealAsync(actor, change)); + foreach (var reading in await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RecurrenceId", row.Id, historyPage * 500)) view.Readings.Add(await RevealAsync(actor, reading)); + } + view.HasMoreHistory = view.Changes.Count == 500 || view.Readings.Count == 500; + return view; + } + public async Task RecurrenceAsync(ChecklistActor actor, int id, int historyPage = 0) + { + RequireMaintenanceStore(); if (historyPage < 0 || historyPage > 10000) throw new WorkOrderException(400, "RecurrenceInvalid"); + return await RecurrenceViewAsync(actor, await _store.GetAsync(actor.DepartmentId, id), true, historyPage); + } + public async Task> RecurrencesAsync(ChecklistActor actor, int page = 0) + { + RequireMaintenanceStore(); await _authorization.RequireMemberAsync(actor); + if (page < 0 || page > 10000) throw new WorkOrderException(400, "RecurrenceInvalid"); + var result = new List(); var remaining = page * 50; + for (var skip = 0; ; skip += 500) + { + var rows = await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, skip: skip); + foreach (var row in rows) + { + if (!await _authorization.CanManageAsync(actor, row.TargetGroupId)) continue; + if (remaining-- > 0) continue; + result.Add(await RecurrenceViewAsync(actor, row, false)); if (result.Count == 51) return result; + } + if (rows.Count < 500) return result; + } + } + public async Task SaveRecurrenceAsync(ChecklistActor actor, WorkOrderRecurrenceInput input) + { + RequireMaintenanceStore(); + if (input?.Template == null) throw new WorkOrderException(400, "RecurrenceInvalid"); + input.Template.Content ??= new WorkOrderContent(); + input.Template.Content.Resolution = input.Template.Content.Cause = input.Template.Content.VerificationEvidence = null; + foreach (var step in input.Template.Content.Steps ?? new()) step.Completed = false; + input.Template.DueOn = null; + input.Template.Type = WorkOrderType.Preventive; Validate(input.Template); + if (!Enum.IsDefined(input.Calendar) || !Enum.IsDefined(input.MeterUnit) || !Enum.IsDefined(input.Condition) || input.Interval < 1 || input.Interval > 120 + || input.Calendar == 0 && input.MeterUnit == 0 && input.Condition == 0 || input.AnchorLocal.Year < 2000 || input.AnchorLocal.Year > 2200 + || input.EndOn?.Date < input.AnchorLocal.Date || input.EndOn?.Year > 2200 || input.LeadDays < 0 || input.LeadDays > 90 + || input.ServiceWeekdays < 1 || input.ServiceWeekdays > 127 || input.ServiceStartMinute < 0 || input.ServiceEndMinute > 1439 || input.ServiceStartMinute > input.ServiceEndMinute + || input.BlackoutFrom.HasValue != input.BlackoutUntil.HasValue || input.BlackoutUntil < input.BlackoutFrom || input.BlackoutUntil - input.BlackoutFrom > TimeSpan.FromDays(366) + || input.MeterUnit != 0 && (input.MeterInterval is null or <= 0 || input.MeterInterval > 100000000 || input.MeterBaseline is null or < 0 or > 1000000000000m) + || new[] { input.MeterInterval, input.MeterBaseline, input.ConditionThreshold }.Any(v => v.HasValue && decimal.Round(v.Value, 6) != v) + || input.ConditionThreshold < -1000000000m || input.ConditionThreshold > 1000000000m + || input.Condition != 0 && (!input.ConditionThreshold.HasValue || string.IsNullOrWhiteSpace(input.ConditionUnit)) + || input.EscalateAfterMinutes < 0 || input.EscalateAfterMinutes > 525600) throw new WorkOrderException(400, "RecurrenceInvalid"); + MaintenanceZone(input.TimeZoneId); Text(input.ConditionUnit, 100); Text(input.Reason, 4000, input.Id != 0); + return await TransactionAsync(actor, async events => + { + await _authorization.ValidateTargetAsync(actor, input.Template); + if (!await _authorization.CanManageAsync(actor, input.Template.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + if (input.Id == 0) + { + var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.Template.RequestId)).SingleOrDefault(); + if (existing != null) + { + await RequireRecurrenceAsync(actor, existing); + var pinned = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, existing.CurrentVersionId)); + if (existing.CreatedBy != actor.UserId || pinned.Content != JsonConvert.SerializeObject(input)) throw new WorkOrderException(409, "Conflict"); + return existing.Id; + } + } + var row = input.Id == 0 ? New(actor) : await _store.GetAsync(actor.DepartmentId, input.Id); + if (row == null) throw new WorkOrderException(404, "Unavailable"); + row.RequestId ??= input.Template.RequestId; + var calendarChanged = row.Calendar != (int)input.Calendar || row.Interval != input.Interval || row.TimeZoneId != input.TimeZoneId || row.AnchorLocal != input.AnchorLocal; + if (input.Id != 0) + { + await RequireRecurrenceAsync(actor, row); await RevealAsync(actor, row); Revision(row, input.Revision); + if (row.TargetUnitId != input.Template.TargetUnitId || row.InventoryAssetId != input.Template.InventoryAssetId) throw new WorkOrderException(409, "TargetUnavailable"); + if (row.PendingWorkOrderId.HasValue && (row.TargetUnitId != input.Template.TargetUnitId || row.InventoryAssetId != input.Template.InventoryAssetId || row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "RecurrencePending"); + row.Revision++; + } + var assignment = new WorkOrder { DepartmentId = actor.DepartmentId, TargetGroupId = input.Template.TargetGroupId }; + if (input.AssignedToUserId != null || input.AssignedToRoleId.HasValue) await _authorization.ValidateAssignmentAsync(actor, assignment, input.AssignedToUserId, input.AssignedToRoleId); + if (input.EscalationRoleId.HasValue) await _authorization.ValidateAssignmentAsync(actor, assignment, null, input.EscalationRoleId); + row.TargetUnitId = input.Template.TargetUnitId; row.TargetGroupId = input.Template.TargetGroupId; row.InventoryAssetId = input.Template.InventoryAssetId; + row.AssignedToUserId = input.AssignedToUserId; row.AssignedToRoleId = input.AssignedToRoleId; row.Priority = (int)input.Template.Priority; + row.IsActive = input.IsActive; row.Calendar = (int)input.Calendar; row.Interval = input.Interval; row.TimeZoneId = input.TimeZoneId; + row.AnchorLocal = DateTime.SpecifyKind(input.AnchorLocal, DateTimeKind.Unspecified); row.EndOn = input.EndOn?.Date; row.LeadDays = input.LeadDays; row.CompletionBased = input.CompletionBased; + row.ServiceWeekdays = input.ServiceWeekdays; row.ServiceStartMinute = input.ServiceStartMinute; row.ServiceEndMinute = input.ServiceEndMinute; + row.BlackoutFrom = input.BlackoutFrom?.Date; row.BlackoutUntil = input.BlackoutUntil?.Date; + if (input.Id != 0 && (row.MeterUnit != (int)input.MeterUnit || row.MeterBaseline != input.MeterBaseline)) throw new WorkOrderException(409, "UseMeterReset"); + row.MeterUnit = (int)input.MeterUnit; row.MeterInterval = input.MeterInterval; row.MeterBaseline = input.MeterBaseline; row.LastMeterValue ??= input.MeterBaseline; + row.Condition = (int)input.Condition; row.ConditionThreshold = input.ConditionThreshold; + row.EscalateAfterMinutes = input.EscalateAfterMinutes; row.EscalationRoleId = input.EscalationRoleId; + if (input.Id == 0 || calendarChanged) row.NextDueOn = NextMaintenanceDue(row, input.Id == 0 ? null : Now); + // Meter provenance changes only through a separately audited reset command. + row.Content = JsonConvert.SerializeObject(new { Name = input.Template.Content.Title }); + if (input.Id == 0) await SaveAsync(actor, row, true); + var version = New(actor); version.RecurrenceId = row.Id; version.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, version, true); + row.CurrentVersionId = version.Id; await SaveAsync(actor, row); + await RecurrenceChangeAsync(actor, row, MaintenanceChangeType.Configured, input.Reason, null, null); + await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderRecurrenceChanged, events); return row.Id; + }); + } + private async Task RecurrenceChangeAsync(ChecklistActor actor, WorkOrderRecurrence row, MaintenanceChangeType type, string note, DateTime? original, DateTime? revised) + { + var change = New(actor); change.RecurrenceId = row.Id; change.ChangeType = (int)type; change.OriginalDueOn = original; change.RevisedDueOn = revised; + change.Content = JsonConvert.SerializeObject(new { Note = note, row.CurrentVersionId }); await SaveAsync(actor, change, true); + } + private async Task RecurrenceEventAsync(WorkOrderRecurrence row, WorkflowTriggerEventType trigger, List events) + { + var entry = await _outbox.EnqueueAsync(row.DepartmentId, "WorkOrders", new DomainEventEnvelope { EventName = trigger.ToString(), AggregateType = "WorkOrderRecurrence", AggregateId = "recurrence:" + row.Id, AggregateVersion = row.Revision, Trigger = trigger, OccurredOn = Now, + Payload = new { RecurrenceId = row.Id, row.Revision, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, DueOn = row.NextDueOn } }); events.Add(entry.DomainEventOutboxId); + } + public async Task RecordReadingAsync(ChecklistActor actor, int id, WorkOrderReadingInput input) + { + RequireMaintenanceStore(); + if (input == null || !Guid.TryParseExact(input.RequestId, "D", out _) || input.ObservedOn.Year < 2000 || input.ObservedOn > Now.AddMinutes(5) + || input.MeterValue < 0 || input.MeterValue > 1000000000000m || input.ConditionValue < -1000000000m || input.ConditionValue > 1000000000m + || new[] { input.MeterValue, input.ConditionValue }.Any(v => v.HasValue && decimal.Round(v.Value, 6) != v) + || !input.MeterValue.HasValue && !input.ConditionValue.HasValue) throw new WorkOrderException(400, "ReadingInvalid"); + input.ObservedOn = DateTime.SpecifyKind(input.ObservedOn, DateTimeKind.Utc); Text(input.Source, 1000, true); Text(input.Note, 4000, input.ResetMeter); + await TransactionAsync(actor, async events => + { + var row = await _store.GetAsync(actor.DepartmentId, id); await RequireRecurrenceAsync(actor, row); await RevealAsync(actor, row); + var existing = (await _maintenance.QueryMaintenanceAsync(actor.DepartmentId, "RequestId", input.RequestId)).SingleOrDefault(); + if (existing != null) + { + await RevealAsync(actor, existing); + if (existing.RecurrenceId != id || existing.CreatedBy != actor.UserId || existing.Content != JsonConvert.SerializeObject(input)) throw new WorkOrderException(409, "Conflict"); + return true; + } + Revision(row, input.Revision); + if (row.LastReadingOn.HasValue && input.ObservedOn <= row.LastReadingOn || input.MeterValue.HasValue && row.MeterUnit == 0 || input.ConditionValue.HasValue && row.Condition == 0 + || input.ResetMeter && (!input.MeterValue.HasValue || row.PendingWorkOrderId.HasValue) || !input.ResetMeter && input.MeterValue < row.LastMeterValue) throw new WorkOrderException(409, "ReadingInvalid"); + if (input.ResetMeter) { row.MeterEpoch++; row.MeterBaseline = input.MeterValue; row.ReadingDue = false; row.ReadingDueOn = null; } + var reading = New(actor); reading.RecurrenceId = id; reading.RequestId = input.RequestId; reading.MeterEpoch = row.MeterEpoch; reading.ObservedOn = input.ObservedOn; + reading.Content = JsonConvert.SerializeObject(input); await SaveAsync(actor, reading, true); + var before = row.ReadingDue; row.LastReadingOn = input.ObservedOn; if (input.MeterValue.HasValue) row.LastMeterValue = input.MeterValue; + if (row.MeterInterval.HasValue && input.MeterValue >= row.MeterBaseline + row.MeterInterval) row.ReadingDue = true; + if (input.ConditionValue.HasValue) + { + var reached = row.Condition == (int)MaintenanceCondition.AtOrAbove ? input.ConditionValue >= row.ConditionThreshold : input.ConditionValue <= row.ConditionThreshold; + if (reached && !row.ConditionLatched) row.ReadingDue = true; + row.ConditionLatched = reached; + } + if (!before && row.ReadingDue) row.ReadingDueOn = input.ObservedOn; + row.Revision++; await SaveAsync(actor, row); await RecurrenceChangeAsync(actor, row, input.ResetMeter ? MaintenanceChangeType.MeterReset : MaintenanceChangeType.Reading, input.Note, null, null); + if (!before && row.ReadingDue) await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderThresholdReached, events); return true; + }); + } + public async Task DeferAsync(ChecklistActor actor, int orderId, WorkOrderDeferralInput input) + { + RequireMaintenanceStore(); + if (input == null || input.DueOn <= Now || input.DueOn > Now.AddDays(366)) throw new WorkOrderException(400, "RecurrenceInvalid"); + Text(input.Reason, 4000, true); input.DueOn = DateTime.SpecifyKind(input.DueOn, DateTimeKind.Utc); + await TransactionAsync(actor, async events => + { + var order = await ReadOrderAsync(actor, orderId); Revision(order, input.Revision); + if (Terminal(order) || order.Status == 5 || !await _authorization.CanManageAsync(actor, order.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); + order.OriginalDueOn ??= order.DueOn; var original = order.DueOn; order.DueOn = input.DueOn; order.EscalatedOn = null; + await ChangedAsync(actor, order, WorkOrderActivityType.Deferred, events, input.Reason, trigger: WorkflowTriggerEventType.WorkOrderDeferred, previousDue: original); + if (int.TryParse(order.WorkOrderRecurrenceId, out var scheduleId)) + { + var recurrence = await _store.GetAsync(actor.DepartmentId, scheduleId); + await RecurrenceChangeAsync(actor, recurrence, MaintenanceChangeType.Deferred, input.Reason, original, input.DueOn); + } + return true; + }); + } + private async Task RecurrenceReopenedAsync(ChecklistActor actor, WorkOrder order, string reason, List events) + { + if (!int.TryParse(order.WorkOrderRecurrenceId, out var id) || _maintenance == null) return; + Text(reason, 4000, true); + var row = await _store.GetAsync(actor.DepartmentId, id); + if (row == null || row.PendingWorkOrderId.HasValue && row.PendingWorkOrderId != order.Id || row.Cycle != order.RecurrenceCycle) throw new WorkOrderException(409, "RecurrencePending"); + row.IsActive = false; row.PendingWorkOrderId = order.Id; row.UpdatedOn = Now; row.Revision++; await _store.WriteAsync(row); + await RecurrenceChangeAsync(actor, row, MaintenanceChangeType.Configured, reason, order.OriginalDueOn, row.NextDueOn); + await RecurrenceEventAsync(row, WorkflowTriggerEventType.WorkOrderRecurrenceChanged, events); + } + private async Task RecurrenceCompletedAsync(WorkOrder order) + { + if (!Terminal(order) || !int.TryParse(order.WorkOrderRecurrenceId, out var id) || _maintenance == null) return; + var row = await _store.GetAsync(order.DepartmentId, id); + if (row?.PendingWorkOrderId != order.Id) return; + if (order.Status != (int)WorkOrderStatus.Closed) + { + // An uncompleted cycle stays due. Pause for an attended rescheduling decision. + row.IsActive = false; row.PendingWorkOrderId = null; row.UpdatedOn = Now; row.Revision++; await _store.WriteAsync(row); return; + } + if (row.CompletionBased) row.AnchorLocal = TimeZoneInfo.ConvertTimeFromUtc(Now, MaintenanceZone(row.TimeZoneId)); + row.NextDueOn = NextMaintenanceDue(row, row.CompletionBased ? Now : order.OriginalDueOn ?? order.DueOn); + row.PendingWorkOrderId = null; row.MeterBaseline = row.LastMeterValue ?? row.MeterBaseline; row.ReadingDue = false; row.ReadingDueOn = null; + row.UpdatedOn = Now; row.Revision++; await _store.WriteAsync(row); // Preserve ciphertext; only reviewed cursor metadata changes. + } + public async Task GenerateMaintenanceAsync(int departmentId) + { + RequireMaintenanceStore(); var result = new WorkOrderMaintenanceSweep(); + if (!await _access.CanUseMaintenanceAsync(departmentId)) return result; + for (var skip = 0; ; skip += 500) + { + var intents = await _maintenance.QueryMaintenanceAsync(departmentId, skip: skip); + foreach (var intent in intents.Where(i => !i.ProcessedOn.HasValue)) + try { await ProcessFailureAsync(departmentId, intent.Id, result); } catch { result.Errors++; } + if (intents.Count < 500) break; + } + for (var skip = 0; ; skip += 500) + { + var rows = await _maintenance.QueryMaintenanceAsync(departmentId, skip: skip, pendingOnly: true); + foreach (var candidate in rows) + { + try + { + await WorkerTransactionAsync(departmentId, async events => + { + var row = await _store.GetAsync(departmentId, candidate.Id); + if (row?.IsActive != true || row.PendingWorkOrderId.HasValue || !row.ReadingDue && (!row.NextDueOn.HasValue || ServiceDue(row, row.NextDueOn.Value).AddDays(-row.LeadDays) > Now)) return; + if (row.EndOn.HasValue && TimeZoneInfo.ConvertTimeFromUtc(Now, MaintenanceZone(row.TimeZoneId)).Date > row.EndOn.Value.Date) return; + var version = await _store.GetAsync(departmentId, row.CurrentVersionId); + if (version?.RecurrenceId != row.Id) throw new WorkOrderException(409, "RecurrenceInvalid"); + var owner = new ChecklistActor { DepartmentId = departmentId, UserId = version.CreatedBy }; + var target = await AutomatedTargetAsync(owner, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId); + if (target.TargetGroupId != row.TargetGroupId || target.TargetUnitId != row.TargetUnitId) throw new WorkOrderException(409, "TargetUnavailable"); + var readingDue = row.ReadingDueOn ?? row.LastReadingOn ?? Now; + var original = row.ReadingDue && (!row.NextDueOn.HasValue || row.NextDueOn > readingDue) ? readingDue : row.NextDueOn.Value; + var due = ServiceDue(row, original); + if (ServiceDue(row, Now) > Now.AddMinutes(1)) return; + var request = MaintenanceIdentity("recurrence:" + departmentId + ":" + row.Id + ":" + (row.Cycle + 1)); + var order = await _store.RequestAsync(departmentId, request); + if (order == null) + { + order = New(owner); order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); + order.SourceType = 2; order.Type = (int)WorkOrderType.Preventive; order.Priority = row.Priority; order.TargetUnitId = row.TargetUnitId; order.TargetGroupId = row.TargetGroupId; order.InventoryAssetId = row.InventoryAssetId; + order.WorkOrderRecurrenceId = row.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); order.RecurrenceVersionId = row.CurrentVersionId; order.RecurrenceCycle = row.Cycle + 1; + order.DueOn = due; order.OriginalDueOn = original; order.EscalateAfterMinutes = row.EscalateAfterMinutes; order.EscalationRoleId = row.EscalationRoleId; + 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); + 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); + result.Generated++; + } + row.Cycle++; row.PendingWorkOrderId = order.Id; row.ReadingDue = false; row.ReadingDueOn = null; row.UpdatedOn = Now; row.Revision++; await _store.WriteAsync(row); + }); + } + catch { result.Errors++; } + } + if (rows.Count < 500) break; + } + return result; + } + public async Task EscalateMaintenanceAsync(int departmentId) + { + RequireMaintenanceStore(); var result = new WorkOrderMaintenanceSweep(); + if (!await _access.CanUseMaintenanceAsync(departmentId)) return result; + foreach (var candidate in await _maintenance.OverdueAsync(departmentId, Now)) + { + try + { + await WorkerTransactionAsync(departmentId, async events => + { + var order = await _store.GetAsync(departmentId, candidate.Id); + if (order == null || order.Status >= 5 || order.EscalatedOn.HasValue || !order.DueOn.HasValue || order.DueOn.Value.AddMinutes(order.EscalateAfterMinutes) >= Now) return; + order.EscalatedOn = Now; order.UpdatedOn = Now; order.Revision++; await _store.WriteAsync(order); + await EventAsync(order, WorkflowTriggerEventType.WorkOrderOverdue, events); result.Escalated++; + }); + } + catch { result.Errors++; } + } + return result; + } + } +} diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index f8c4ca02..cca9d73a 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 + public sealed partial class WorkOrdersService : IWorkOrdersService, IWorkOrderMaintenanceService { private readonly IWorkOrderRepository _store; private readonly IWorkOrderAuthorizationService _authorization; @@ -29,9 +29,18 @@ public sealed partial class WorkOrdersService : IWorkOrdersService private readonly Lazy _write; private readonly IRecordAttachmentScanner _scanner; private readonly TimeProvider _clock; + private readonly IWorkOrderMaintenanceRepository _maintenance; + private readonly Lazy _inventoryMaintenance; + private readonly IChecklistRepository _checklists; + private readonly IChecklistAssetSource _maintenanceAssets; + private readonly IUnitsService _maintenanceUnits; + private readonly Lazy _inventoryCatalog; public WorkOrdersService(IWorkOrderRepository store, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, - IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null) - { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; } + IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, + IWorkOrderMaintenanceRepository maintenance = null, Lazy inventoryMaintenance = null, IChecklistRepository checklists = null, + IChecklistAssetSource maintenanceAssets = null, IUnitsService maintenanceUnits = null, Lazy inventoryCatalog = null) + { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; + _maintenance = maintenance; _inventoryMaintenance = inventoryMaintenance; _checklists = checklists; _maintenanceAssets = maintenanceAssets; _maintenanceUnits = maintenanceUnits; _inventoryCatalog = inventoryCatalog; } private DateTime Now => _clock.GetUtcNow().UtcDateTime; private sealed class StoredContent { public WorkOrderContent Fields { get; set; } public string RequestHash { get; set; } } private static T Decode(string value) => JsonConvert.DeserializeObject(value ?? "{}"); @@ -50,7 +59,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"); - return await RevealAsync(actor, row); + return await RevealOrderAsync(actor, row); } private async Task RevealAsync(ChecklistActor actor, T row) where T : WorkOrderRow { @@ -69,23 +78,24 @@ private async Task SaveAsync(ChecklistActor actor, T row, bool insert = false await _store.WriteAsync(row); } private T New(ChecklistActor actor, int? orderId = null) where T : WorkOrderRow, new() => new T { DepartmentId = actor.DepartmentId, WorkOrderId = orderId, CreatedBy = actor.UserId, CreatedOn = Now, UpdatedOn = Now }; - private async Task TransactionAsync(ChecklistActor actor, Func, Task> command) + private async Task TransactionAsync(ChecklistActor actor, Func, Task> command, bool safetyRelease = false) { - await RequireWriteAsync(actor); + if (safetyRelease) await _authorization.RequireMemberAsync(actor); else await RequireWriteAsync(actor); if (_uow.Transaction != null) throw new InvalidOperationException("Work-order commands own their transaction."); var events = new List(); T result; try { await _uow.CreateOrGetConnectionAsync(CancellationToken.None); await _store.LockDepartmentAsync(actor.DepartmentId); - await RequireWriteAsync(actor); result = await command(events); _uow.CommitChanges(); + if (safetyRelease) await _authorization.RequireMemberAsync(actor); else await RequireWriteAsync(actor); result = await command(events); _uow.CommitChanges(); } + catch (Resgrid.Model.Inventories.InventoryException ex) { _uow.DiscardChanges(); throw new WorkOrderException(ex.StatusCode, ex.StatusCode == 403 ? "InventoryPermissionRequired" : ex.StatusCode == 402 ? "ReadinessProRequired" : "InventoryOperationFailed"); } 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) + 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, 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, 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 }); @@ -93,17 +103,17 @@ private async Task ActivityAsync(ChecklistActor actor, WorkOrder row, WorkOrderA if (protection?.Success != true || protection.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new WorkOrderException(403, "ProtectedDataRequired"); await _audit.UpdateAsync(audit, CancellationToken.None); } - private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events) + private async Task EventAsync(WorkOrder row, WorkflowTriggerEventType? trigger, List events, int? oldStatus = null) { 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 } }); + Payload = new { WorkOrderId = row.Id, row.Revision, row.Status, row.Priority, row.TargetUnitId, row.TargetGroupId, row.InventoryAssetId, row.AssignedToRoleId, row.DueOn, 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) + 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); await EventAsync(row, trigger, events); + 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); } private static void Validate(WorkOrderInput input) { @@ -134,7 +144,7 @@ public async Task CreateAsync(ChecklistActor actor, WorkOrderIn return existing.Id; } 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.Content = JsonConvert.SerializeObject(new StoredContent { Fields = input.Content, RequestHash = hash }); + 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; }); return await GetAsync(actor, id); @@ -149,6 +159,10 @@ await TransactionAsync(actor, async events => await _authorization.ValidateTargetAsync(actor, input); if (manage && !await _authorization.CanManageAsync(actor, input.TargetGroupId)) throw new WorkOrderException(403, "PermissionRequired"); var document = Decode(row.Content); + if (input.DueOn != row.DueOn) throw new WorkOrderException(409, "UseDueDeferral"); + if ((row.TargetUnitId != input.TargetUnitId || row.InventoryAssetId != input.InventoryAssetId) + && (row.RecurrenceVersionId.HasValue || _maintenance != null && (await ChildrenAsync(actor, id)).Any(h => !h.ReleasedOn.HasValue))) + throw new WorkOrderException(409, "TargetUnavailable"); 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; @@ -178,7 +192,8 @@ await TransactionAsync(actor, async events => var manage = await _authorization.CanManageAsync(actor, row.TargetGroupId); var contribute = await _authorization.CanContributeAsync(actor, row); 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; + var document = Decode(row.Content); var c = document.Fields; + 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) { @@ -209,7 +224,8 @@ await TransactionAsync(actor, async events => c.VerificationEvidence = input.VerificationEvidence; row.VerifiedBy = actor.UserId; row.ClosedOn = Now; } row.Status = (int)input.Status; row.Content = JsonConvert.SerializeObject(document); - await ChangedAsync(actor, row, WorkOrderActivityType.StatusChanged, events, input.Reason, old, WorkflowTriggerEventType.WorkOrderStatusChanged); return true; + 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; }); } public async Task AssignAsync(ChecklistActor actor, int id, WorkOrderAssignment input) @@ -261,34 +277,17 @@ await TransactionAsync(actor, async events => await ChangedAsync(actor, row, WorkOrderActivityType.LaborAdded, events); return true; }); } - public async Task AddPartAsync(ChecklistActor actor, int id, WorkOrderPartInput input) - { - if (input?.Content == null || input.Content.Quantity <= 0 || input.Content.Quantity > 100000 || decimal.Round(input.Content.Quantity, 3) != input.Content.Quantity) throw new WorkOrderException(400, "InvalidInput"); - Text(input.Content.Description, 2000, true); Money(input.Content.UnitCost); input.Content.VoidReason = null; - await TransactionAsync(actor, async events => { var row = await ContributionAsync(actor, id, input.Revision); var part = New(actor, id); part.Content = JsonConvert.SerializeObject(input.Content); await SaveAsync(actor, part, true); await ChangedAsync(actor, row, WorkOrderActivityType.PartAdded, events); return true; }); - } - public async Task VoidPartAsync(ChecklistActor actor, int id, int partId, int revision, string reason) - { - Text(reason, 4000, true); - 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 || part.InventoryTransactionId != null) throw new WorkOrderException(409, "Unavailable"); - await RevealAsync(actor, part); var c = Decode(part.Content); c.VoidReason = reason; part.Content = JsonConvert.SerializeObject(c); part.VoidedOn = Now; part.Revision++; await SaveAsync(actor, part); - await ChangedAsync(actor, row, WorkOrderActivityType.PartVoided, events, reason); return true; - }); - } public Task ChoicesAsync(ChecklistActor actor) => _authorization.ChoicesAsync(actor); private static WorkOrderSummary Summary(WorkOrder row, WorkOrderContent c) => new WorkOrderSummary { Id = row.Id, Number = $"WO-{row.NumberYear}-{row.NumberSequence:D6}", Title = c.Title, Status = (WorkOrderStatus)row.Status, Priority = (WorkOrderPriority)row.Priority, Revision = row.Revision, UpdatedOn = row.UpdatedOn, CreatedOn = row.CreatedOn, DueOn = row.DueOn, AssignedToUserId = row.AssignedToUserId, AssignedToRoleId = row.AssignedToRoleId, UnitId = row.TargetUnitId, GroupId = row.TargetGroupId, AssetId = row.InventoryAssetId }; public async Task ListAsync(ChecklistActor actor, WorkOrderFilter filter) { - if (filter == null || filter.Page < 0 || filter.Page > 10000 || filter.Status.HasValue && !Enum.IsDefined(filter.Status.Value) || filter.Priority.HasValue && !Enum.IsDefined(filter.Priority.Value) || filter.AssetId != null && !Guid.TryParseExact(filter.AssetId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); + if (filter == null || filter.Page < 0 || filter.Page > 10000 || filter.Status.HasValue && !Enum.IsDefined(filter.Status.Value) || filter.Priority.HasValue && !Enum.IsDefined(filter.Priority.Value) || filter.AssetId != null && !Guid.TryParseExact(filter.AssetId, "D", out _) || filter.ChecklistCompletionId != null && !Guid.TryParseExact(filter.ChecklistCompletionId, "D", out _)) throw new WorkOrderException(400, "InvalidInput"); var scope = await _authorization.ScopeAsync(actor); var rows = await _store.ListAsync(actor.DepartmentId, scope, filter); var page = new WorkOrderPage { HasMore = rows.Count > 50, CanWrite = await _access.CanUseMaintenanceAsync(actor.DepartmentId) }; foreach (var row in rows.Take(50)) { - await RevealAsync(actor, row); var item = Summary(row, Decode(row.Content).Fields); + await RevealOrderAsync(actor, row); var item = Summary(row, Decode(row.Content).Fields); if (page.CanWrite && row.Status == 0 && await _authorization.CanManageAsync(actor, row.TargetGroupId)) item.QuickStatus = WorkOrderStatus.Accepted; if (page.CanWrite && row.Status == 2 && row.AssignmentAcceptedOn.HasValue && await _authorization.CanContributeAsync(actor, row)) item.QuickStatus = WorkOrderStatus.InProgress; page.Items.Add(item); @@ -306,11 +305,11 @@ public async Task GetAsync(ChecklistActor actor, int id) var write = await _access.CanUseMaintenanceAsync(actor.DepartmentId); var detail = new WorkOrderDetail { Order = Summary(row, c), Input = new WorkOrderInput { RequestId = row.RequestId, Revision = row.Revision, Type = (WorkOrderType)row.Type, Priority = (WorkOrderPriority)row.Priority, TargetUnitId = row.TargetUnitId, TargetGroupId = row.TargetGroupId, InventoryAssetId = row.InventoryAssetId, DueOn = row.DueOn, Content = c }, CanWrite = write, CanManage = manage && write, CanEdit = write && !Terminal(row) && row.Status != 5 && (manage || row.Status == 0 && row.CreatedBy == actor.UserId), CanContribute = write && contribute && !Terminal(row) && row.Status != 5, - CanAccept = write && row.Status == 2 && !row.AssignmentAcceptedOn.HasValue && (await _authorization.RecipientsAsync(actor.DepartmentId, row)).Contains(actor.UserId), ReportedBy = row.CreatedBy, VerifiedBy = row.VerifiedBy, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn }; + CanAccept = write && row.Status == 2 && !row.AssignmentAcceptedOn.HasValue && (await _authorization.RecipientsAsync(actor.DepartmentId, row)).Contains(actor.UserId), ReportedBy = row.CreatedBy, VerifiedBy = row.VerifiedBy, CompletedOn = row.CompletedOn, ClosedOn = row.ClosedOn, SourceChecklistCompletionId = row.SourceChecklistCompletionId, SourceChecklistItemId = row.SourceChecklistItemId, RecurrenceId = row.WorkOrderRecurrenceId, OriginalDueOn = row.OriginalDueOn, EscalatedOn = row.EscalatedOn }; 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 { Id = p.Id, 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, 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(); return detail; } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index ed7a8302..696e624d 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -83,6 +83,13 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: + case WorkflowTriggerEventType.WorkOrderOverdue: + case WorkflowTriggerEventType.WorkOrderSafetyHoldApplied: + case WorkflowTriggerEventType.WorkOrderSafetyHoldReleased: + case WorkflowTriggerEventType.WorkOrderRecurrenceChanged: + case WorkflowTriggerEventType.WorkOrderThresholdReached: + case WorkflowTriggerEventType.WorkOrderDeferred: + case WorkflowTriggerEventType.WorkOrderPartChanged: 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 }; break; diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index cc469e5c..51281a02 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -420,13 +420,20 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: + case WorkflowTriggerEventType.WorkOrderOverdue: + case WorkflowTriggerEventType.WorkOrderSafetyHoldApplied: + case WorkflowTriggerEventType.WorkOrderSafetyHoldReleased: + case WorkflowTriggerEventType.WorkOrderRecurrenceChanged: + case WorkflowTriggerEventType.WorkOrderThresholdReached: + case WorkflowTriggerEventType.WorkOrderDeferred: + case WorkflowTriggerEventType.WorkOrderPartChanged: { 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/Detail/{payload["WorkOrderId"]?.Value()}"; + order["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/WorkOrders/{(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.CatalogVersion }; + scriptObject["protection"] = new ScriptObject { ["is_redacted"] = true, ["redacted_fields"] = ToScriptValue(new JArray("Title")), ["catalog_version"] = Resgrid.Model.WorkOrders.WorkOrderTables.RecurrenceCatalogVersion }; break; } case WorkflowTriggerEventType.ChecklistCompleted: diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs index c7030ef9..63d2a7dc 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0198_AddInventoryModernization.cs @@ -110,8 +110,6 @@ public override void Up() Create.ForeignKey(N("FK_" + child + "_ItemLot")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")).ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); foreach (var child in AssetChildren) Create.ForeignKey(N("FK_" + child + "_ItemAsset")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")).ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); - TenantHolderKey("DepartmentGroups", "DepartmentGroupId"); - TenantHolderKey("Units", "UnitId"); Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); Holder("InventoryLocations", "UnitId", "Units", "UnitId"); Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); @@ -145,16 +143,11 @@ public override void Up() Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); SpecialIndexes(); } - private void TenantHolderKey(string parent, string key) - { - if (!Schema.Table(N(parent)).Exists() || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists()) return; - Create.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); - } private void Holder(string table, string column, string parent, string key) { - // Groups and units belong to one tenant; user identities may belong to multiple departments. + // Existing unit tracking provides a tenant key; older minimal databases still get the typed FK. if (!Schema.Table(N(parent)).Exists()) return; - if (parent is "Units" or "DepartmentGroups") + if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); else Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumn(N(column)).ToTable(N(parent)).PrimaryColumn(N(key)); } @@ -181,9 +174,6 @@ public override void Down() foreach (var child in LotChildren) Delete.ForeignKey(N("FK_" + child + "_ItemLot")).OnTable(N(child)); foreach (var link in Links) Delete.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).OnTable(N(link.Table)); foreach (var table in Tables.Reverse()) Delete.Table(N(table)); - foreach (var parent in new[] { "DepartmentGroups", "Units" }) - if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists()) - Delete.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).FromTable(N(parent)); } } } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs new file mode 100644 index 00000000..e7ba089c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0203_AddWorkOrderIntegrations.cs @@ -0,0 +1,88 @@ +using FluentMigrator; +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(203)] + public class M0203_AddWorkOrderIntegrations : Migration + { + private static string N(string value) => value; + public override void Up() + { + Create.Table(N("WorkOrderFailureIntents")) + .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) + .WithColumn(N("CompletionId")).AsString(36).Nullable() + .WithColumn(N("ItemId")).AsString(36).Nullable() + .WithColumn(N("VersionId")).AsString(36).Nullable() + .WithColumn(N("OccurrenceId")).AsString(36).Nullable() + .WithColumn(N("AuthorizedBy")).AsString(128).Nullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TargetId")).AsString(36).Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("Priority")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("HoldUnit")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("HoldAsset")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ProcessedOn")).AsDateTime2().Nullable(); + Create.ForeignKey(N("FK_WorkOrderFailureIntents_Department")).FromTable(N("WorkOrderFailureIntents")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderFailureIntents", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderFailureIntents", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderFailureIntents", "Order", "WorkOrderId", "WorkOrders"); + Create.Table(N("WorkOrderSafetyHolds")) + .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) + .WithColumn(N("UnitId")).AsInt32().Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("PreviousState")).AsInt32().Nullable() + .WithColumn(N("AppliedStateId")).AsInt32().Nullable() + .WithColumn(N("AppliedAssetRevision")).AsInt32().Nullable() + .WithColumn(N("ReleasedOn")).AsDateTime2().Nullable() + .WithColumn(N("ReleasedBy")).AsString(128).Nullable() + .WithColumn(N("StateRestored")).AsBoolean().NotNullable().WithDefaultValue(false); + Create.ForeignKey(N("FK_WorkOrderSafetyHolds_Department")).FromTable(N("WorkOrderSafetyHolds")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderSafetyHolds", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderSafetyHolds", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderSafetyHolds", "Order", "WorkOrderId", "WorkOrders"); + Index("WorkOrderFailureIntents", "Failure", true, "DepartmentId", "CompletionId", "ItemId"); + Index("WorkOrderFailureIntents", "Pending", false, "DepartmentId", "ProcessedOn", "Id"); + Index("WorkOrderSafetyHolds", "Unit", false, "DepartmentId", "UnitId", "ReleasedOn"); + Index("WorkOrderSafetyHolds", "Asset", false, "DepartmentId", "AssetId", "ReleasedOn"); + foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(column)).AsString(36).Nullable(); + Execute.Sql("CREATE UNIQUE INDEX UX_WorkOrderParts_InventoryRequest ON WorkOrderParts(DepartmentId,InventoryRequestId) WHERE InventoryRequestId IS NOT NULL;"); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsInt32().Nullable(); + Foreign("InventoryTransactions", "WorkOrderPart", "WorkOrderPartId", "WorkOrderParts"); + Index("InventoryTransactions", "WorkOrderPart", false, "DepartmentId", "WorkOrderPartId"); + } + 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 < 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 WorkOrderFailureIntents) OR EXISTS (SELECT 1 FROM WorkOrderSafetyHolds) OR EXISTS (SELECT 1 FROM InventoryTransactions WHERE WorkOrderPartId IS NOT NULL) OR EXISTS (SELECT 1 FROM WorkOrderParts WHERE InventoryOperationId IS NOT NULL OR InventoryRequestId IS NOT NULL) THROW 51000, 'Readiness evidence requires authorized retention before rollback.', 1;"); + Delete.ForeignKey(N("FK_InventoryTransactions_WorkOrderPart")).OnTable(N("InventoryTransactions")); + Delete.Index(N("IX_InventoryTransactions_WorkOrderPart")).OnTable(N("InventoryTransactions")); + Delete.Index(N("UX_WorkOrderParts_InventoryRequest")).OnTable(N("WorkOrderParts")); + Delete.Column(N("WorkOrderPartId")).FromTable(N("InventoryTransactions")); + foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) Delete.Column(N(column)).FromTable(N("WorkOrderParts")); + Delete.Table(N("WorkOrderSafetyHolds")); + Delete.Table(N("WorkOrderFailureIntents")); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs new file mode 100644 index 00000000..ce822b36 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0204_AddWorkOrderRecurrences.cs @@ -0,0 +1,142 @@ +using FluentMigrator; +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(204)] + public class M0204_AddWorkOrderRecurrences : Migration + { + private static string N(string value) => value; + public override void Up() + { + Create.Table(N("WorkOrderRecurrences")).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) + .WithColumn(N("RequestId")).AsString(36).NotNullable() + .WithColumn(N("CurrentVersionId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TargetUnitId")).AsInt32().Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("AssignedToUserId")).AsString(128).Nullable() + .WithColumn(N("AssignedToRoleId")).AsInt32().Nullable() + .WithColumn(N("Priority")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("IsActive")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("Calendar")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("Interval")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TimeZoneId")).AsString(100).Nullable() + .WithColumn(N("AnchorLocal")).AsDateTime2().NotNullable() + .WithColumn(N("EndOn")).AsDateTime2().Nullable() + .WithColumn(N("LeadDays")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CompletionBased")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ServiceWeekdays")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ServiceStartMinute")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ServiceEndMinute")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("BlackoutFrom")).AsDateTime2().Nullable() + .WithColumn(N("BlackoutUntil")).AsDateTime2().Nullable() + .WithColumn(N("NextDueOn")).AsDateTime2().Nullable() + .WithColumn(N("Cycle")).AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn(N("PendingWorkOrderId")).AsInt32().Nullable() + .WithColumn(N("ReadingDue")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ReadingDueOn")).AsDateTime2().Nullable() + .WithColumn(N("MeterUnit")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("MeterInterval")).AsDecimal(24, 6).Nullable() + .WithColumn(N("MeterBaseline")).AsDecimal(24, 6).Nullable() + .WithColumn(N("LastMeterValue")).AsDecimal(24, 6).Nullable() + .WithColumn(N("LastReadingOn")).AsDateTime2().Nullable() + .WithColumn(N("Condition")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ConditionThreshold")).AsDecimal(24, 6).Nullable() + .WithColumn(N("ConditionLatched")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("EscalationRoleId")).AsInt32().Nullable(); + Create.ForeignKey(N("FK_WorkOrderRecurrences_Department")).FromTable(N("WorkOrderRecurrences")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrences", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrences", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrences", "Order", "WorkOrderId", "WorkOrders"); + Create.Table(N("WorkOrderRecurrenceVersions")).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) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0); + Create.ForeignKey(N("FK_WorkOrderRecurrenceVersions_Department")).FromTable(N("WorkOrderRecurrenceVersions")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrenceVersions", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrenceVersions", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrenceVersions", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderRecurrenceVersions", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderRecurrenceVersions", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Create.Table(N("WorkOrderMeterReadings")).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) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RequestId")).AsString(36).Nullable() + .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ObservedOn")).AsDateTime2().NotNullable(); + Create.ForeignKey(N("FK_WorkOrderMeterReadings_Department")).FromTable(N("WorkOrderMeterReadings")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderMeterReadings", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderMeterReadings", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderMeterReadings", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderMeterReadings", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderMeterReadings", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Create.Table(N("WorkOrderRecurrenceChanges")).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) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ChangeType")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("OriginalDueOn")).AsDateTime2().Nullable() + .WithColumn(N("RevisedDueOn")).AsDateTime2().Nullable(); + Create.ForeignKey(N("FK_WorkOrderRecurrenceChanges_Department")).FromTable(N("WorkOrderRecurrenceChanges")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrenceChanges", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrenceChanges", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrenceChanges", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderRecurrenceChanges", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderRecurrenceChanges", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Index("WorkOrderRecurrences", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrderRecurrences", "Due", false, "DepartmentId", "IsActive", "NextDueOn"); + Index("WorkOrderMeterReadings", "Request", true, "DepartmentId", "RequestId"); + foreach (var column in new[] { "RecurrenceVersionId", "EscalationRoleId" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsInt32().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceCycle")).AsInt64().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0); + foreach (var column in new[] { "OriginalDueOn", "EscalatedOn" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsDateTime2().Nullable(); + Foreign("WorkOrders", "RecurrenceVersion", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); + Execute.Sql("CREATE UNIQUE INDEX UX_WorkOrders_Cycle ON WorkOrders(DepartmentId,WorkOrderRecurrenceId,RecurrenceCycle) WHERE RecurrenceCycle IS NOT NULL;"); + Index("WorkOrders", "Overdue", false, "DepartmentId", "EscalatedOn", "DueOn"); + } + 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 < 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 WorkOrderRecurrences) OR EXISTS (SELECT 1 FROM WorkOrders WHERE RecurrenceVersionId IS NOT NULL OR EscalatedOn IS NOT NULL OR OriginalDueOn IS NOT NULL) THROW 51000, 'Readiness evidence requires authorized retention before rollback.', 1;"); + Delete.ForeignKey(N("FK_WorkOrders_RecurrenceVersion")).OnTable(N("WorkOrders")); + Delete.Index(N("UX_WorkOrders_Cycle")).OnTable(N("WorkOrders")); + Delete.Index(N("IX_WorkOrders_Overdue")).OnTable(N("WorkOrders")); + foreach (var column in new[] { "RecurrenceVersionId", "RecurrenceCycle", "OriginalDueOn", "EscalatedOn", "EscalateAfterMinutes", "EscalationRoleId" }) Delete.Column(N(column)).FromTable(N("WorkOrders")); + foreach (var table in new[] { "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences" }) Delete.Table(N(table)); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0205_EnforceInventoryTenantHolders.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0205_EnforceInventoryTenantHolders.cs new file mode 100644 index 00000000..c80cf95e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0205_EnforceInventoryTenantHolders.cs @@ -0,0 +1,44 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(205)] + public class M0205_EnforceInventoryTenantHolders : Migration + { + private static string N(string value) => value; + public override void Up() + { + TenantKey("DepartmentGroups", "DepartmentGroupId"); + TenantKey("Units", "UnitId"); + Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); + Holder("InventoryLocations", "UnitId", "Units", "UnitId"); + Holder("InventoryIssuances", "IssuedToUnitId", "Units", "UnitId"); + } + private void TenantKey(string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder205_" + parent)).Exists()) return; + Create.UniqueConstraint(N("UQ_InventoryHolder205_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); + } + private void Holder(string table, string column, string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() || !Schema.Table(N(table)).Exists() + || Schema.Table(N(table)).Constraint(N("FK_" + table + "_TenantHolder_" + column)).Exists()) return; + // Add the tenant constraint alongside the original holder FK, including on databases that ran the amended M0198. + Create.ForeignKey(N("FK_" + table + "_TenantHolder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); + } + public override void Down() + { + foreach (var link in new[] { ("InventoryLocations", "GroupId"), ("InventoryLocations", "UnitId"), ("InventoryIssuances", "IssuedToUnitId") }) + if (Schema.Table(N(link.Item1)).Exists() && Schema.Table(N(link.Item1)).Constraint(N("FK_" + link.Item1 + "_TenantHolder_" + link.Item2)).Exists()) + Delete.ForeignKey(N("FK_" + link.Item1 + "_TenantHolder_" + link.Item2)).OnTable(N(link.Item1)); + // Leave pre-existing canonical and M0198-owned keys and every inventory row intact. + foreach (var parent in new[] { "DepartmentGroups", "Units" }) + if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder205_" + parent)).Exists()) + Delete.UniqueConstraint(N("UQ_InventoryHolder205_" + parent)).FromTable(N(parent)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs index 6f652ffd..01a093f9 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0198_AddInventoryModernizationPg.cs @@ -110,8 +110,6 @@ public override void Up() Create.ForeignKey(N("FK_" + child + "_ItemLot")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("LotId")).ToTable(N("InventoryLots")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); foreach (var child in AssetChildren) Create.ForeignKey(N("FK_" + child + "_ItemAsset")).FromTable(N(child)).ForeignColumns(N("DepartmentId"), N("ItemId"), N("AssetId")).ToTable(N("InventoryAssets")).PrimaryColumns(N("DepartmentId"), N("ItemId"), N("Id")); - TenantHolderKey("DepartmentGroups", "DepartmentGroupId"); - TenantHolderKey("Units", "UnitId"); Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); Holder("InventoryLocations", "UnitId", "Units", "UnitId"); Holder("InventoryLocations", "UserId", "AspNetUsers", "Id"); @@ -145,16 +143,11 @@ public override void Up() Index("InventoryTransfers", "History", false, "DepartmentId", "CreatedOn"); SpecialIndexes(); } - private void TenantHolderKey(string parent, string key) - { - if (!Schema.Table(N(parent)).Exists() || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists()) return; - Create.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); - } private void Holder(string table, string column, string parent, string key) { - // Groups and units belong to one tenant; user identities may belong to multiple departments. + // Existing unit tracking provides a tenant key; older minimal databases still get the typed FK. if (!Schema.Table(N(parent)).Exists()) return; - if (parent is "Units" or "DepartmentGroups") + if (parent == "Units" && Schema.Table(N(parent)).Constraint(N("UQ_Units_DepartmentId_UnitId")).Exists()) Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)).ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); else Create.ForeignKey(N("FK_" + table + "_Holder_" + column)).FromTable(N(table)).ForeignColumn(N(column)).ToTable(N(parent)).PrimaryColumn(N(key)); } @@ -181,9 +174,6 @@ public override void Down() foreach (var child in LotChildren) Delete.ForeignKey(N("FK_" + child + "_ItemLot")).OnTable(N(child)); foreach (var link in Links) Delete.ForeignKey(N("FK_" + link.Table + "_" + link.Column)).OnTable(N(link.Table)); foreach (var table in Tables.Reverse()) Delete.Table(N(table)); - foreach (var parent in new[] { "DepartmentGroups", "Units" }) - if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists()) - Delete.UniqueConstraint(N("UQ_InventoryHolder_" + parent)).FromTable(N(parent)); } } } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs new file mode 100644 index 00000000..4a9860dd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0203_AddWorkOrderIntegrationsPg.cs @@ -0,0 +1,88 @@ +using FluentMigrator; +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(203)] + public class M0203_AddWorkOrderIntegrationsPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + Create.Table(N("WorkOrderFailureIntents")) + .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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("CompletionId")).AsString(36).Nullable() + .WithColumn(N("ItemId")).AsString(36).Nullable() + .WithColumn(N("VersionId")).AsString(36).Nullable() + .WithColumn(N("OccurrenceId")).AsString(36).Nullable() + .WithColumn(N("AuthorizedBy")).AsString(128).Nullable() + .WithColumn(N("TargetType")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TargetId")).AsString(36).Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("Priority")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("HoldUnit")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("HoldAsset")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ProcessedOn")).AsDateTime().Nullable(); + Create.ForeignKey(N("FK_WorkOrderFailureIntents_Department")).FromTable(N("WorkOrderFailureIntents")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderFailureIntents", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderFailureIntents", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderFailureIntents", "Order", "WorkOrderId", "WorkOrders"); + Create.Table(N("WorkOrderSafetyHolds")) + .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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("UnitId")).AsInt32().Nullable() + .WithColumn(N("AssetId")).AsString(36).Nullable() + .WithColumn(N("PreviousState")).AsInt32().Nullable() + .WithColumn(N("AppliedStateId")).AsInt32().Nullable() + .WithColumn(N("AppliedAssetRevision")).AsInt32().Nullable() + .WithColumn(N("ReleasedOn")).AsDateTime().Nullable() + .WithColumn(N("ReleasedBy")).AsString(128).Nullable() + .WithColumn(N("StateRestored")).AsBoolean().NotNullable().WithDefaultValue(false); + Create.ForeignKey(N("FK_WorkOrderSafetyHolds_Department")).FromTable(N("WorkOrderSafetyHolds")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderSafetyHolds", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderSafetyHolds", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderSafetyHolds", "Order", "WorkOrderId", "WorkOrders"); + Index("WorkOrderFailureIntents", "Failure", true, "DepartmentId", "CompletionId", "ItemId"); + Index("WorkOrderFailureIntents", "Pending", false, "DepartmentId", "ProcessedOn", "Id"); + Index("WorkOrderSafetyHolds", "Unit", false, "DepartmentId", "UnitId", "ReleasedOn"); + Index("WorkOrderSafetyHolds", "Asset", false, "DepartmentId", "AssetId", "ReleasedOn"); + foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) + Alter.Table(N("WorkOrderParts")).AddColumn(N(column)).AsString(36).Nullable(); + Execute.Sql("CREATE UNIQUE INDEX ux_workorderparts_inventoryrequest ON workorderparts(departmentid,inventoryrequestid) WHERE inventoryrequestid IS NOT NULL;"); + Alter.Table(N("InventoryTransactions")).AddColumn(N("WorkOrderPartId")).AsInt32().Nullable(); + Foreign("InventoryTransactions", "WorkOrderPart", "WorkOrderPartId", "WorkOrderParts"); + Index("InventoryTransactions", "WorkOrderPart", false, "DepartmentId", "WorkOrderPartId"); + } + 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 < 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 workorderfailureintents) or exists (select 1 from workordersafetyholds) or exists (select 1 from inventorytransactions where workorderpartid is not null) THEN RAISE EXCEPTION 'Readiness evidence requires authorized retention before rollback.'; END IF; END $$;"); + Delete.ForeignKey(N("FK_InventoryTransactions_WorkOrderPart")).OnTable(N("InventoryTransactions")); + Delete.Index(N("IX_InventoryTransactions_WorkOrderPart")).OnTable(N("InventoryTransactions")); + Delete.Index(N("UX_WorkOrderParts_InventoryRequest")).OnTable(N("WorkOrderParts")); + Delete.Column(N("WorkOrderPartId")).FromTable(N("InventoryTransactions")); + foreach (var column in new[] { "InventoryOperationId", "InventoryReversalId", "InventoryRequestId" }) Delete.Column(N(column)).FromTable(N("WorkOrderParts")); + Delete.Table(N("WorkOrderSafetyHolds")); + Delete.Table(N("WorkOrderFailureIntents")); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs new file mode 100644 index 00000000..256eb725 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0204_AddWorkOrderRecurrencesPg.cs @@ -0,0 +1,142 @@ +using FluentMigrator; +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(204)] + public class M0204_AddWorkOrderRecurrencesPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + Create.Table(N("WorkOrderRecurrences")).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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RequestId")).AsString(36).NotNullable() + .WithColumn(N("CurrentVersionId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TargetUnitId")).AsInt32().Nullable() + .WithColumn(N("TargetGroupId")).AsInt32().Nullable() + .WithColumn(N("InventoryAssetId")).AsString(36).Nullable() + .WithColumn(N("AssignedToUserId")).AsString(128).Nullable() + .WithColumn(N("AssignedToRoleId")).AsInt32().Nullable() + .WithColumn(N("Priority")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("IsActive")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("Calendar")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("Interval")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("TimeZoneId")).AsString(100).Nullable() + .WithColumn(N("AnchorLocal")).AsDateTime().NotNullable() + .WithColumn(N("EndOn")).AsDateTime().Nullable() + .WithColumn(N("LeadDays")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("CompletionBased")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ServiceWeekdays")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ServiceStartMinute")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ServiceEndMinute")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("BlackoutFrom")).AsDateTime().Nullable() + .WithColumn(N("BlackoutUntil")).AsDateTime().Nullable() + .WithColumn(N("NextDueOn")).AsDateTime().Nullable() + .WithColumn(N("Cycle")).AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn(N("PendingWorkOrderId")).AsInt32().Nullable() + .WithColumn(N("ReadingDue")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("ReadingDueOn")).AsDateTime2().Nullable() + .WithColumn(N("MeterUnit")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("MeterInterval")).AsDecimal(24, 6).Nullable() + .WithColumn(N("MeterBaseline")).AsDecimal(24, 6).Nullable() + .WithColumn(N("LastMeterValue")).AsDecimal(24, 6).Nullable() + .WithColumn(N("LastReadingOn")).AsDateTime().Nullable() + .WithColumn(N("Condition")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ConditionThreshold")).AsDecimal(24, 6).Nullable() + .WithColumn(N("ConditionLatched")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("EscalationRoleId")).AsInt32().Nullable(); + Create.ForeignKey(N("FK_WorkOrderRecurrences_Department")).FromTable(N("WorkOrderRecurrences")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrences", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrences", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrences", "Order", "WorkOrderId", "WorkOrders"); + Create.Table(N("WorkOrderRecurrenceVersions")).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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0); + Create.ForeignKey(N("FK_WorkOrderRecurrenceVersions_Department")).FromTable(N("WorkOrderRecurrenceVersions")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrenceVersions", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrenceVersions", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrenceVersions", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderRecurrenceVersions", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderRecurrenceVersions", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Create.Table(N("WorkOrderMeterReadings")).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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("RequestId")).AsString(36).Nullable() + .WithColumn(N("MeterEpoch")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ObservedOn")).AsDateTime().NotNullable(); + Create.ForeignKey(N("FK_WorkOrderMeterReadings_Department")).FromTable(N("WorkOrderMeterReadings")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderMeterReadings", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderMeterReadings", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderMeterReadings", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderMeterReadings", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderMeterReadings", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Create.Table(N("WorkOrderRecurrenceChanges")).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")).AsDateTime().NotNullable() + .WithColumn(N("UpdatedOn")).AsDateTime().NotNullable() + .WithColumn(N("CreatedBy")).AsString(128).NotNullable() + .WithColumn(N("IsProtected")).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn(N("RecurrenceId")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("ChangeType")).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn(N("OriginalDueOn")).AsDateTime().Nullable() + .WithColumn(N("RevisedDueOn")).AsDateTime().Nullable(); + Create.ForeignKey(N("FK_WorkOrderRecurrenceChanges_Department")).FromTable(N("WorkOrderRecurrenceChanges")).ForeignColumn(N("DepartmentId")).ToTable(N("Departments")).PrimaryColumn(N("DepartmentId")); + Index("WorkOrderRecurrenceChanges", "Tenant", true, "DepartmentId", "Id"); + Index("WorkOrderRecurrenceChanges", "Order", false, "DepartmentId", "WorkOrderId", "Id"); + Foreign("WorkOrderRecurrenceChanges", "Order", "WorkOrderId", "WorkOrders"); + Foreign("WorkOrderRecurrenceChanges", "Recurrence", "RecurrenceId", "WorkOrderRecurrences"); + Index("WorkOrderRecurrenceChanges", "Schedule", false, "DepartmentId", "RecurrenceId", "Id"); + Index("WorkOrderRecurrences", "Request", true, "DepartmentId", "RequestId"); + Index("WorkOrderRecurrences", "Due", false, "DepartmentId", "IsActive", "NextDueOn"); + Index("WorkOrderMeterReadings", "Request", true, "DepartmentId", "RequestId"); + foreach (var column in new[] { "RecurrenceVersionId", "EscalationRoleId" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsInt32().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("RecurrenceCycle")).AsInt64().Nullable(); + Alter.Table(N("WorkOrders")).AddColumn(N("EscalateAfterMinutes")).AsInt32().NotNullable().WithDefaultValue(0); + foreach (var column in new[] { "OriginalDueOn", "EscalatedOn" }) Alter.Table(N("WorkOrders")).AddColumn(N(column)).AsDateTime().Nullable(); + Foreign("WorkOrders", "RecurrenceVersion", "RecurrenceVersionId", "WorkOrderRecurrenceVersions"); + Execute.Sql("CREATE UNIQUE INDEX ux_workorders_cycle ON workorders(departmentid,workorderrecurrenceid,recurrencecycle) WHERE recurrencecycle IS NOT NULL;"); + Index("WorkOrders", "Overdue", false, "DepartmentId", "EscalatedOn", "DueOn"); + } + 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 < 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 workorderrecurrences) or exists (select 1 from workorders where recurrenceversionid is not null or escalatedon is not null or originaldueon is not null) THEN RAISE EXCEPTION 'Readiness evidence requires authorized retention before rollback.'; END IF; END $$;"); + Delete.ForeignKey(N("FK_WorkOrders_RecurrenceVersion")).OnTable(N("WorkOrders")); + Delete.Index(N("UX_WorkOrders_Cycle")).OnTable(N("WorkOrders")); + Delete.Index(N("IX_WorkOrders_Overdue")).OnTable(N("WorkOrders")); + foreach (var column in new[] { "RecurrenceVersionId", "RecurrenceCycle", "OriginalDueOn", "EscalatedOn", "EscalateAfterMinutes", "EscalationRoleId" }) Delete.Column(N(column)).FromTable(N("WorkOrders")); + foreach (var table in new[] { "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences" }) Delete.Table(N(table)); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0205_EnforceInventoryTenantHoldersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0205_EnforceInventoryTenantHoldersPg.cs new file mode 100644 index 00000000..6a004691 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0205_EnforceInventoryTenantHoldersPg.cs @@ -0,0 +1,44 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(205)] + public class M0205_EnforceInventoryTenantHoldersPg : Migration + { + private static string N(string value) => value.ToLowerInvariant(); + public override void Up() + { + TenantKey("DepartmentGroups", "DepartmentGroupId"); + TenantKey("Units", "UnitId"); + Holder("InventoryLocations", "GroupId", "DepartmentGroups", "DepartmentGroupId"); + Holder("InventoryLocations", "UnitId", "Units", "UnitId"); + Holder("InventoryIssuances", "IssuedToUnitId", "Units", "UnitId"); + } + private void TenantKey(string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_" + parent + "_DepartmentId_" + key)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder_" + parent)).Exists() + || Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder205_" + parent)).Exists()) return; + Create.UniqueConstraint(N("UQ_InventoryHolder205_" + parent)).OnTable(N(parent)).Columns(N("DepartmentId"), N(key)); + } + private void Holder(string table, string column, string parent, string key) + { + if (!Schema.Table(N(parent)).Exists() || !Schema.Table(N(table)).Exists() + || Schema.Table(N(table)).Constraint(N("FK_" + table + "_TenantHolder_" + column)).Exists()) return; + // Add the tenant constraint alongside the original holder FK, including on databases that ran the amended M0198. + Create.ForeignKey(N("FK_" + table + "_TenantHolder_" + column)).FromTable(N(table)).ForeignColumns(N("DepartmentId"), N(column)) + .ToTable(N(parent)).PrimaryColumns(N("DepartmentId"), N(key)); + } + public override void Down() + { + foreach (var link in new[] { ("InventoryLocations", "GroupId"), ("InventoryLocations", "UnitId"), ("InventoryIssuances", "IssuedToUnitId") }) + if (Schema.Table(N(link.Item1)).Exists() && Schema.Table(N(link.Item1)).Constraint(N("FK_" + link.Item1 + "_TenantHolder_" + link.Item2)).Exists()) + Delete.ForeignKey(N("FK_" + link.Item1 + "_TenantHolder_" + link.Item2)).OnTable(N(link.Item1)); + // Leave pre-existing canonical and M0198-owned keys and every inventory row intact. + foreach (var parent in new[] { "DepartmentGroups", "Units" }) + if (Schema.Table(N(parent)).Exists() && Schema.Table(N(parent)).Constraint(N("UQ_InventoryHolder205_" + parent)).Exists()) + Delete.UniqueConstraint(N("UQ_InventoryHolder205_" + parent)).FromTable(N(parent)); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index 1db4e9a2..a88f791e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -48,7 +48,7 @@ async Task 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[] { "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + 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" }) 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/InventoryStore.cs b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs index 57e180c2..51f5a236 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InventoryStore.cs @@ -76,6 +76,30 @@ public Task GetAsync(int departmentId, string id) where T : InventoryRow = $"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id", new { DepartmentId = departmentId, Id = id }); + public async Task> ClaimableAlertsAsync(int departmentId, string userId, DateTime now, int skip = 0) + { + if (string.IsNullOrWhiteSpace(userId) || skip < 0) throw new ArgumentException("A recipient and valid page are required."); + var columns = string.Join(",", Columns().Select(c => "a." + Col(c))); + return (await QueryAsync($@"SELECT {columns} FROM {Tbl("InventoryAlerts")} a +LEFT JOIN {Tbl("InventoryAlertDeliveries")} d ON d.{Col("DepartmentId")}=a.{Col("DepartmentId")} AND d.{Col("AlertId")}=a.{Col("Id")} AND d.{Col("UserId")}=@UserId +WHERE a.{Col("DepartmentId")}=@DepartmentId AND a.{Col("Status")}=0 +AND (d.{Col("Id")} IS NULL OR (d.{Col("State")} NOT IN (2,3) AND d.{Col("NextAttemptOn")}<=@Now AND (d.{Col("LeaseUntil")} IS NULL OR d.{Col("LeaseUntil")}<=@Now))) +ORDER BY a.{Col("OpenedOn")},a.{Col("Id")} {Paging()}", new { DepartmentId = departmentId, UserId = userId, Now = DatabaseTimestamp(now), Skip = skip, Take = PageSize })).ToList(); + } + + public async Task> RelatedManyAsync(int departmentId, string column, IReadOnlyCollection ids) where T : InventoryRow + { + if (column == null || column != "Id" && !RelationshipColumns.Contains(column) || !Properties().Any(p => p.Name == column && p.PropertyType == typeof(string))) + throw new ArgumentException("Invalid inventory relationship column.", nameof(column)); + if (ids == null || ids.Count > 500) throw new ArgumentException("A bounded set of inventory IDs is required.", nameof(ids)); + if (ids.Count == 0) return new List(); + var predicate = IsPostgres ? $"{Col(column)}=ANY(@Ids)" : $"{Col(column)} IN @Ids"; + var rows = (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl(Table())} WHERE {Col("DepartmentId")}=@DepartmentId AND {predicate} ORDER BY {Col("Id")} {Paging()}", + new { DepartmentId = departmentId, Ids = ids.ToArray(), Skip = 0, Take = MaximumRelatedRows + 1 })).ToList(); + if (rows.Count > MaximumRelatedRows) throw new InvalidOperationException("The inventory relationship exceeds the supported operation size."); + return rows; + } + public async Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow { if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip)); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index 470eaea1..c80eb010 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -16,7 +16,7 @@ public class ApiDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 1c436989..68d376fd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -16,7 +16,7 @@ public class DataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 6d1b5a94..b6eaba43 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -16,7 +16,7 @@ public class NonWebDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 86a3e9e6..dad6b0d0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -16,7 +16,7 @@ public class TestingDataModule : Module protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs new file mode 100644 index 00000000..e2ff78ea --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderMaintenanceRepository.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Repositories.DataRepository +{ + 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."); + var where = $"{Col("DepartmentId")}={P}DepartmentId"; + if (field != null) where += $" AND {Col(field)}={P}Value"; + if (pendingOnly) + where += typeof(T) == typeof(WorkOrderFailureIntent) ? $" AND {Col("ProcessedOn")} IS NULL" : typeof(T) == typeof(WorkOrderRecurrence) ? $" AND {Col("IsActive")}={Bool(true)}" : throw new ArgumentException("Invalid maintenance queue."); + return (await QueryAsync($"SELECT {Cols(Columns(false))} FROM {Tbl(Table())} WHERE {where} ORDER BY {Col("Id")} {Paging()}", new { DepartmentId = departmentId, Value = value, Skip = skip, Take = 500 }, default)).ToList(); + } + public async Task> MaintenanceDepartmentsAsync(int afterDepartmentId) => (await QueryAsync($"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("Departments")} WHERE {Col("DepartmentId")}>{P}After ORDER BY {Col("DepartmentId")} {Paging()}", new { After = afterDepartmentId, Skip = 0, Take = 200 }, default)).ToList(); + public Task FailureAsync(int departmentId, string completionId, string itemId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkOrderFailureIntents")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("CompletionId")}={P}CompletionId AND {Col("ItemId")}={P}ItemId", new { DepartmentId = departmentId, CompletionId = completionId, ItemId = itemId }, default); + public async Task> ActiveHoldsAsync(int departmentId, int? unitId, string assetId) => (await QueryAsync($"SELECT * FROM {Tbl("WorkOrderSafetyHolds")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("ReleasedOn")} IS NULL AND ({Col("UnitId")}={P}UnitId OR {Col("AssetId")}={P}AssetId) ORDER BY {Col("Id")}", new { DepartmentId = departmentId, UnitId = unitId, AssetId = assetId }, default)).ToList(); + public async Task> OverdueAsync(int departmentId, DateTime now) => (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("EscalatedOn")} IS NULL AND {(IsPostgres ? $"{Col("DueOn")} + {Col("EscalateAfterMinutes")} * INTERVAL '1 minute'" : $"DATEADD(minute,{Col("EscalateAfterMinutes")},{Col("DueOn")})")}<{P}Now AND {Col("Status")}<5 AND {Col("IsDeleted")}={Bool(false)} ORDER BY {Col("DueOn")},{Col("Id")} {Paging()}", new { DepartmentId = departmentId, Now = now, Skip = 0, Take = 500 }, default)).ToList(); + private string Bool(bool value) => IsPostgres ? (value ? "true" : "false") : (value ? "1" : "0"); + public async Task LockUnitAsync(int departmentId, int unitId) + { + Transaction(); + var sql = IsPostgres ? "SELECT unitid FROM units WHERE departmentid=@DepartmentId AND unitid=@UnitId FOR UPDATE" : "SELECT UnitId FROM Units WITH (UPDLOCK,HOLDLOCK) WHERE DepartmentId=@DepartmentId AND UnitId=@UnitId"; + if (await ScalarAsync(sql, new { DepartmentId = departmentId, UnitId = unitId }, default) != unitId) throw new InvalidOperationException("Maintenance unit is unavailable."); + } + public async Task LatestUnitStateAsync(int departmentId, int unitId) + { + Transaction(); + var unit = await ScalarAsync(IsPostgres ? "SELECT unitid FROM units WHERE departmentid=@DepartmentId AND unitid=@UnitId FOR UPDATE" : "SELECT UnitId FROM Units WITH (UPDLOCK,HOLDLOCK) WHERE DepartmentId=@DepartmentId AND UnitId=@UnitId", new { DepartmentId = departmentId, UnitId = unitId }, default); + if (unit != unitId) return null; + return await QueryFirstOrDefaultAsync($"SELECT {Cols("UnitStateId", "UnitId", "State", "Timestamp")} FROM {Tbl("UnitStates")} {(IsPostgres ? "" : "WITH (UPDLOCK,HOLDLOCK)")} WHERE {Col("UnitId")}={P}UnitId ORDER BY {Col("Timestamp")} DESC,{Col("UnitStateId")} DESC {Paging()}", new { UnitId = unitId, Skip = 0, Take = 1 }, default); + } + public async Task AppendUnitStateAsync(int departmentId, int unitId, int state, DateTime now) + { + await LockUnitAsync(departmentId, unitId); + var current = await LatestUnitStateAsync(departmentId, unitId); + if (current != null && current.Timestamp >= now) now = current.Timestamp.AddMilliseconds(1); + // Routing only: never copy notes, coordinates or another row's protected envelope. + return await ScalarAsync($"INSERT INTO {Tbl("UnitStates")} ({Cols("UnitId", "State", "Timestamp", "IsProtected")}) {(IsPostgres ? "" : "OUTPUT INSERTED.[UnitStateId]")} VALUES ({P}UnitId,{P}State,{P}Now,{Bool(false)}) {(IsPostgres ? "RETURNING unitstateid" : "")}", new { UnitId = unitId, State = state, Now = now }, default); + } + public async Task LastAppendedUnitStateIdAsync(int departmentId, int unitId) + { + await LockUnitAsync(departmentId, unitId); + return await ScalarAsync($"SELECT COALESCE(MAX({Col("UnitStateId")}),0) FROM {Tbl("UnitStates")} WHERE {Col("UnitId")}={P}UnitId", new { UnitId = unitId }, default); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs index f66432cc..38aa2ed3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -12,7 +12,7 @@ namespace Resgrid.Repositories.DataRepository { - public sealed class WorkOrderRepository : RmsRepositoryBase, IWorkOrderRepository + public sealed partial class WorkOrderRepository : RmsRepositoryBase, IWorkOrderRepository, IWorkOrderMaintenanceRepository { public WorkOrderRepository(IConnectionProvider connection, SqlConfiguration config, IUnitOfWork uow, IQueryFactory queries) : base(connection, config, uow, queries) { } private static string Table() where T : WorkOrderRow => WorkOrderTables.All[typeof(T)]; @@ -42,13 +42,13 @@ public async Task FinishNotificationAsync(WorkOrderNotification row, int s public async Task> ListAsync(int departmentId, WorkOrderReadScope scope, WorkOrderFilter filter) { if (scope == null || string.IsNullOrWhiteSpace(scope.UserId) || filter.Page < 0 || filter.Page > 10000) throw new ArgumentException("Invalid work-order scope."); - var parameters = new DynamicParameters(new { DepartmentId = departmentId, UserId = scope.UserId, AllowedGroup = scope.GroupId, Status = (int?)filter.Status, Priority = (int?)filter.Priority, UnitId = filter.UnitId, GroupId = filter.GroupId, AssetId = filter.AssetId, Skip = filter.Page * 50, Take = 51 }); + 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" }; 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") }) + 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") }) 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")} DESC {Paging()}", parameters, default)).ToList(); } @@ -74,7 +74,7 @@ public async Task WriteAsync(T row) where T : WorkOrderRow { Transaction(); var columns = Columns().Where(c => c != "Id" && c != "DepartmentId"); - var immutable = typeof(T) == typeof(WorkOrderActivity) || typeof(T) == typeof(WorkOrderLabor) ? $" AND {Col("Content")} IS NULL" : ""; + var immutable = typeof(T) == typeof(WorkOrderActivity) || typeof(T) == typeof(WorkOrderLabor) || typeof(T) == typeof(WorkOrderRecurrenceVersion) || typeof(T) == typeof(WorkOrderMeterReading) || typeof(T) == typeof(WorkOrderRecurrenceChange) ? $" AND {Col("Content")} IS NULL" : ""; if (await ExecuteAsync($"UPDATE {Tbl(Table())} SET {string.Join(",", columns.Select(c => Col(c) + "=" + P + c))} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("Id")}={P}Id{immutable}", row, default) != 1) throw new InvalidOperationException("Work-order evidence could not be saved."); } } diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index e1c9772d..852215a9 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -96,5 +96,12 @@ "InventoryLowStock": 61, "InventoryExpiring": 62, "InventoryCountCompleted": 63, - "InventoryReturnOverdue": 166 + "InventoryReturnOverdue": 166, + "WorkOrderOverdue": 73, + "WorkOrderSafetyHoldApplied": 167, + "WorkOrderSafetyHoldReleased": 168, + "WorkOrderRecurrenceChanged": 169, + "WorkOrderThresholdReached": 170, + "WorkOrderDeferred": 171, + "WorkOrderPartChanged": 172 } diff --git a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs index cf39c112..5a81a3c7 100644 --- a/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs +++ b/Tests/Resgrid.Tests/Rms/ChecklistReadinessEvidenceTests.cs @@ -30,7 +30,7 @@ public async Task Readiness_PDF_and_manifest_are_immutable_classified_evidence_a var adapter = new ReadinessPacketEvidenceAdapter(checklists.Object, access.Object, grant, pdf.Object); var service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { adapter }, _authorization.Object, Mock.Of(), _references.Object, - new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), Mock.Of()); 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"); diff --git a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs index d7039bd2..a17324c4 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs @@ -40,7 +40,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w ResultJson = "{\"decision\":\"Engine 5 selected\",\"caller\":\"Private caller identity\"}" } }); var evidence = new RecordsEvidenceService(_store.Shared.EvidenceRepo.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { new RunCardActivationEvidenceAdapter(activations.Object) }, - _authorization.Object, _calls.Object, Mock.Of(), new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object)); + _authorization.Object, _calls.Object, Mock.Of(), new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object), Mock.Of()); _service = BuildService(udf, evidence); var started = await _service.StartFromCallAsync(Dept, "author", CallId); var id = started.Report.RmsIncidentReportId; var sample = Resgrid.Tests.Providers.NerisMappingTests.Snapshot(); diff --git a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs index a81a5e91..31b936f2 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs @@ -98,7 +98,7 @@ public void SetUp() _authorization.Setup(a => a.CanReadSourceCallAsync("author", Dept, It.IsAny())).ReturnsAsync(true); _service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { (IRecordEvidenceAdapter)_adapter }, _authorization.Object, Mock.Of(), _references.Object, - new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), Mock.Of()); } private RecordEvidenceCaptureRequest Request(RmsEvidenceKind kind = RmsEvidenceKind.RunCardActivation) @@ -144,7 +144,7 @@ public async Task Separate_chat_selections_survive_signing_and_only_an_exact_sel var adapter = new ChatPromotionEvidenceAdapter(messages.Object, channels.Object, new Lazy(() => permission.Object)); var service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { adapter }, _authorization.Object, Mock.Of(), _references.Object, - new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), Mock.Of()); var request = Request(RmsEvidenceKind.ChatPromotion); request.SourceIds = new() { "one", "two" }; var first = await service.CaptureAsync(request); var original = first.ManifestJson; request.SourceIds = new() { "three" }; var second = await service.CaptureAsync(request); @@ -230,13 +230,19 @@ public async Task A_correction_from_another_Record_cannot_cover_a_pending_invent } [Test] - public async Task Modern_inventory_evidence_fails_closed_when_source_verification_is_unavailable() + public async Task Modern_inventory_evidence_fails_closed_when_source_usage_is_missing() { var usage = new RecordInventoryUsage { DepartmentId = Dept, SourceId = _record.RmsOperationalRecordId, RecordKind = (int)RmsRecordKind.Operational, TransactionId = Guid.NewGuid().ToString("D"), Quantity = 1 }; var reference = UsageReference(usage); _references.Setup(x => x.GetForRecordAsync(Dept, _record.RmsOperationalRecordId)).ReturnsAsync(new[] { reference }); Func finalize = () => _service.RequireInventoryCoverageAsync(Dept, _record.RmsOperationalRecordId, new[] { UsageArtifact(_record.RmsOperationalRecordId, reference) }); - await finalize.Should().ThrowAsync().WithMessage("Inventory evidence verification is unavailable."); + await finalize.Should().ThrowAsync().WithMessage("Inventory usage provenance is unavailable."); + } + + [Test] + public void Evidence_service_requires_inventory_storage_at_construction() + { + FluentActions.Invoking(() => InventoryCoverageService(null)).Should().Throw().WithParameterName("inventoryStore"); } private RecordsEvidenceService InventoryCoverageService(IInventoryStore inventory) => new(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 61085e72..ddb10ff6 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -107,7 +107,7 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.InventoryPurchaseOrderReceived).Should().Be(65); ((int)WorkflowTriggerEventType.ControlledSubstanceRecorded).Should().Be(66); ((int)WorkflowTriggerEventType.InventoryReturnOverdue).Should().Be(166); - foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(58, 15))) + foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(58, 16))) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs index 1c59c59d..1e383e49 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistGdprTests.cs @@ -45,7 +45,7 @@ public async Task Checklist_export_pages_all_owned_data_and_masks_plaintext_duri _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, - new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory()); + new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory(), EmptyMaintenance()); var files = await RunExportAsync(); var json = files["checklists.json"]; json.Should().NotContain("CANARY").And.NotContain("rgdp:").And.Contain("REDACTED"); var exported = JObject.Parse(json); diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs index 3e0193d8..77a66a79 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs @@ -83,7 +83,7 @@ public void Readiness_trigger_membership_cannot_be_replaced_through_the_public_c var collection = (IList)ChecklistWorkflowPayload.Triggers; Action change = () => collection[0] = 999; change.Should().Throw(); - ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 22, 58, 59, 60, 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, 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166); ChecklistWorkflowPayload.IsChecklist(67).Should().BeTrue(); ChecklistWorkflowPayload.IsChecklist(999).Should().BeFalse(); } @@ -123,7 +123,7 @@ public async Task Checklist_witness_only_export_contains_own_witness_facts_witho _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, store, - new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory()); + new Lazy(() => protection), reminders.Object, EmptyWorkOrders(), EmptyInventory(), EmptyMaintenance()); var json = (await RunExportAsync())["checklists.json"]; var exported = JObject.Parse(json); json.Should().NotContain("CANARY").And.NotContain("AQID"); exported["Witnesses"].Should().HaveCount(105); diff --git a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs index beb752ac..c9fd0bf4 100644 --- a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs +++ b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs @@ -114,7 +114,7 @@ public void SetUp() _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), - new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), Mock.Of())), checklistReminders.Object, EmptyWorkOrders(), EmptyInventory()); + new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), Mock.Of())), checklistReminders.Object, EmptyWorkOrders(), EmptyInventory(), EmptyMaintenance()); } private async Task> RunExportAsync() diff --git a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs index 6aa3fee9..a60df2d2 100644 --- a/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs +++ b/Tests/Resgrid.Tests/Services/InventoryDatabaseFixture.cs @@ -104,11 +104,15 @@ public async Task CreateOnlyAnIsolatedInventoryDatabase() } var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { - _type == DatabaseTypes.Postgres ? new M0198_AddInventoryModernizationPg() : new M0198_AddInventoryModernization(), + _type == DatabaseTypes.Postgres ? new M0197_AddWorkOrdersPg() : new M0197_AddWorkOrders(), + _type == DatabaseTypes.Postgres ? new M0198_AddInventoryModernizationPg() : new M0198_AddInventoryModernization(), _type == DatabaseTypes.Postgres ? new M0199_FenceLegacyInventoryWritesPg() : new M0199_FenceLegacyInventoryWrites(), _type == DatabaseTypes.Postgres ? new M0200_AddRecordInventoryUsagePg() : new M0200_AddRecordInventoryUsage(), _type == DatabaseTypes.Postgres ? new M0201_AddInventoryPurchasingPg() : new M0201_AddInventoryPurchasing(), - _type == DatabaseTypes.Postgres ? new M0202_AddInventoryCountsAndAlertsPg() : new M0202_AddInventoryCountsAndAlerts() + _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() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { diff --git a/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs index 9e7da71f..6f7a5c10 100644 --- a/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryGdprTests.cs @@ -46,7 +46,7 @@ private void UseInventoryExport(IInventoryStore store, bool enforced = false, bo _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), new Lazy(() => new ReadinessHistoryProtectionService(Mock.Of(), policy.Object)), - reminders.Object, EmptyWorkOrders(), store); + reminders.Object, EmptyWorkOrders(), store, EmptyMaintenance()); } private static T InventoryExportRow(string creator = UserId, int department = DeptId, string content = "SUBJECT-EVIDENCE") where T : InventoryRow, new() diff --git a/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs index 315187cd..bac6e089 100644 --- a/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryM5HttpTests.cs @@ -41,6 +41,29 @@ public sealed partial class InventoryApiTests private const string M5CountId = "77777777-7777-7777-7777-777777777777"; private const string M5LineId = "88888888-8888-8888-8888-888888888888"; + [TestCase(InventoryReportKind.Expiration)] + [TestCase(InventoryReportKind.Valuation)] + public async Task Inventory_report_selection_survives_protected_navigation(InventoryReportKind kind) + { + await WithM5Server(M5Operations(), async (client, views) => + { + SignIn(client); client.DefaultRequestHeaders.Add("Test-Reports", "true"); + var response = await client.GetAsync(MvcRoute + "Operations?tab=Reports&kind=" + (int)kind); await Success(response); + var html = await response.Content.ReadAsStringAsync(); + var locked = (InventoryWorkspaceView)views.LastView.Model; + locked.Locked.Should().BeTrue(); locked.ReportKind.Should().Be(kind); + html.Should().Contain("name=\"kind\" value=\"" + (int)kind + "\""); + response = await client.PostAsync(MvcRoute + "ReopenOperations", new FormUrlEncodedContent(new Dictionary + { + ["__RequestVerificationToken"] = PurchasingCsrf(html), ["tab"] = "Reports", ["kind"] = ((int)kind).ToString(), + [HttpProtectedGrantContext.FormFieldName] = "navigation-grant", [HttpProtectedGrantContext.ExpiresOnFormFieldName] = "2030-01-01T00:00:00Z" + })); + await Success(response); + ((InventoryWorkspaceView)views.LastView.Model).ReportKind.Should().Be(kind); + (await response.Content.ReadAsStringAsync()).Should().Contain("value=\"" + (int)kind + "\" selected=\"selected\""); + }, mvc: true, protectedData: true); + } + [Test] public async Task Operations_API_uses_authenticated_tenant_and_header_grant_for_count_commands_and_query_filters() { diff --git a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs index 47a395d8..2605317e 100644 --- a/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryModernizationTests.cs @@ -828,6 +828,11 @@ public T Seed(T row) where T : InventoryRow public Task OpenAlertAsync(int departmentId, string dedupKey) => Task.FromResult(All().SingleOrDefault(a => a.DepartmentId == departmentId && a.DedupKey == dedupKey && a.Status == 0)); public Task AlertDeliveryAsync(int departmentId, string alertId, string userId) => Task.FromResult(All().SingleOrDefault(d => d.DepartmentId == departmentId && d.AlertId == alertId && d.UserId == userId)); public Task> OpenAlertsAsync(int departmentId, int skip = 0) => Task.FromResult(All().Where(a => a.DepartmentId == departmentId && a.Status == 0).OrderBy(a => a.OpenedOn).ThenBy(a => a.Id).Skip(skip).Take(501).ToList()); + public Task> ClaimableAlertsAsync(int departmentId, string userId, DateTime now, int skip = 0) => Task.FromResult(All() + .Where(a => a.DepartmentId == departmentId && a.Status == 0 && !All().Any(d => d.DepartmentId == departmentId && d.AlertId == a.Id && d.UserId == userId + && (d.State is 2 or 3 || d.NextAttemptOn > now || d.LeaseUntil > now))).OrderBy(a => a.OpenedOn).ThenBy(a => a.Id).Skip(skip).Take(501).ToList()); + public Task> RelatedManyAsync(int departmentId, string column, IReadOnlyCollection ids) where T : InventoryRow + => Task.FromResult(All().Where(r => r.DepartmentId == departmentId && ids.Contains((string)typeof(T).GetProperty(column).GetValue(r))).OrderBy(r => r.Id).ToList()); public Task GetAsync(int departmentId, string id) where T : InventoryRow => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == departmentId && r.Id == id)); public Task> ListAsync(int departmentId, int skip = 0) where T : InventoryRow => Task.FromResult(All().Where(r => r.DepartmentId == departmentId).OrderBy(r => r.CreatedOn).ThenBy(r => r.Id).Skip(skip).Take(501).ToList()); public Task> StockQuantitiesAsync(int departmentId, IReadOnlyCollection itemIds) => Task.FromResult(All() diff --git a/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs new file mode 100644 index 00000000..78ad171c --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryPr507DatabaseTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Resgrid.Model.Inventories; +using Resgrid.Repositories.DataRepository.Transactions; + +namespace Resgrid.Tests.Services +{ + public partial class InventoryDatabaseTests + { + [Test] + public async Task Tenant_holder_upgrade_rejects_existing_cross_department_links_without_rewriting_inventory() + { + var runner = _runner.GetRequiredService(); + var seed = await SeedAsync(); + runner.MigrateDown(204); + var invalid = NewRow(); invalid.LocationType = 1; invalid.GroupId = 223; + try + { + // The original, already-applied M0198 permitted this typed but cross-tenant reference. + await WriteAsync(store => store.InsertAsync(invalid)); + FluentActions.Invoking(() => runner.MigrateUp()).Should().Throw(); + await using var db = Connect(_connection); + (await db.ExecuteScalarAsync($"SELECT COUNT(*) FROM {Q("InventoryLocations")} WHERE {Q("Id")}=@Id", new { invalid.Id })).Should().Be(1); + } + finally + { + await using var db = Connect(_connection); + await db.ExecuteAsync($"DELETE FROM {Q("InventoryLocations")} WHERE {Q("Id")}=@Id", new { invalid.Id }); + _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); + } + await RejectAsync(store => store.InsertAsync(invalid)); + using var uow = new UnitOfWork(Connections()); + (await Store(uow).GetAsync(77, seed.Location.Id)).Should().NotBeNull(); + } + + [Test] + public async Task Claim_query_excludes_completed_leased_and_backoff_deliveries_for_only_the_selected_recipient() + { + var seed = await SeedAsync(); var now = DateTime.UtcNow; + var alerts = new List(); + await WriteAsync(async store => + { + for (var index = 0; index < 6; index++) + { + var alert = NewRow(); alert.Content = null; alert.ItemId = seed.Item.Id; alert.DedupKey = index.ToString("x64"); alert.OpenedOn = now; + if (index == 5) alert.Status = 1; + await store.InsertAsync(alert); alerts.Add(alert); + if (index is 0 or 5) continue; + var delivery = NewRow(); delivery.Content = null; delivery.AlertId = alert.Id; delivery.UserId = "inventory-test-author"; + delivery.NextAttemptOn = index == 3 ? now.AddMinutes(5) : now.AddMinutes(-5); + delivery.State = index == 1 ? 2 : 0; delivery.LeaseUntil = index == 2 ? now.AddMinutes(30) : null; + await store.InsertAsync(delivery); + } + }); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.ClaimableAlertsAsync(77, "inventory-test-author", now)).Select(a => a.Id).Should().BeEquivalentTo(alerts[0].Id, alerts[4].Id); + (await repository.ClaimableAlertsAsync(77, "another-recipient", now)).Should().HaveCount(5); + (await repository.ClaimableAlertsAsync(88, "inventory-test-author", now)).Should().BeEmpty(); + } + + [Test] + public async Task Batched_relationship_reads_scope_the_tenant_and_reject_unreviewed_columns() + { + var seed = await SeedAsync(); var foreign = await SeedAsync(88); + using var uow = new UnitOfWork(Connections()); var repository = Store(uow); + (await repository.RelatedManyAsync(77, "Id", new[] { seed.Item.Id, foreign.Item.Id })).Should().ContainSingle().Which.Id.Should().Be(seed.Item.Id); + (await repository.RelatedManyAsync(77, "Id", Array.Empty())).Should().BeEmpty(); + await FluentActions.Awaiting(() => repository.RelatedManyAsync(77, "Content", new[] { "{}" })).Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryPr507Tests.cs b/Tests/Resgrid.Tests/Services/InventoryPr507Tests.cs new file mode 100644 index 00000000..31cbc56f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryPr507Tests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; + +namespace Resgrid.Tests.Services +{ + public sealed partial class InventoryModernizationTests + { + [Test] + public async Task Historical_inventory_reads_continue_past_the_501_row_lookahead() + { + var item = Item(InventoryTrackingMode.Serialized); var first = Location(InventoryLocationType.Unit, 101); var second = Location(InventoryLocationType.Unit, 102); + var asset = await CreateAsset(item, first); + for (var index = 1; index <= 500; index++) + _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, AssetId = asset.Id, EntryId = 1000 + index, + OccurredOn = _clock.Utc.AddSeconds(index), FromLocationId = first.Id, ToLocationId = index == 500 ? second.Id : first.Id, + TransactionType = (int)InventoryTransactionType.Transfer, NewStatus = (int)InventoryAssetStatus.InService, Quantity = 1, Content = "{\"ItemName\":\"Equipment\"}" }); + _clock.Advance(TimeSpan.FromSeconds(600)); + (await _service.AtCallAsync(ChecklistActor(), 25, _clock.Utc, new[] { 101 }, false)).Should().BeEmpty(); + } + + [Test] + public async Task Vendor_archive_already_requires_contact_view_through_the_catalog_read() + { + var vendor = _store.Seed(new InventoryVendor { DepartmentId = Department, ContactId = "company", Content = "{}" }); + _auth.Setup(a => a.RequireAsync(_actor, false, PermissionTypes.ContactView, null)).ThrowsAsync(new InventoryException(403, "PermissionRequired")); + await Fails(() => _service.ArchiveAsync(_actor, vendor.Id, vendor.Revision), "PermissionRequired", 403); + _store.All().Single().IsDeleted.Should().BeFalse(); _audits.Should().BeEmpty(); + } + + [Test] + public async Task Vendor_choices_resolve_one_protected_batch_without_refetching_contacts() + { + _companyContacts["first"] = new Contact { ContactId = "first", DepartmentId = Department, ContactType = 1, CompanyName = "First" }; + _companyContacts["second"] = new Contact { ContactId = "second", DepartmentId = Department, ContactType = 1, CompanyName = "Second" }; + var choices = await _service.GetVendorContactsAsync(_actor); + choices.Select(c => c.Name).Should().BeEquivalentTo("First", "Second"); + _contacts.Verify(c => c.GetContactByIdAsync(It.IsAny()), Times.Never); + _read.Verify(r => r.ResolveContactsForReadAsync(Department, It.Is>(c => c.Count == 2), _actor.GrantToken, _actor.UserId, It.IsAny()), Times.Once); + } + + [Test] + public async Task Bulk_refresh_resolves_deleted_and_inactive_item_alerts_and_preserves_live_totals() + { + var location = Location(); var items = new[] { Item(), Item(), Item() }; + foreach (var item in items) { item.Content = JsonConvert.SerializeObject(new InventoryItemContent { Name = "Stock", MinLevel = 3 }); _store.Seed(item); SeedStock(item, location, 1); } + await _service.RefreshAlertsAsync(_actor); _store.All().Count(a => a.Status == 0).Should().Be(3); + items[0].IsDeleted = true; items[1].IsActive = false; _store.Seed(items[0]); _store.Seed(items[1]); + await _service.RefreshAlertsAsync(_actor); + _store.All().Where(a => a.Status == 0).Should().ContainSingle().Which.ItemId.Should().Be(items[2].Id); + _store.All().Count(a => a.Status == 1).Should().Be(2); + } + + [Test] + public async Task Batch_catalog_read_preserves_missing_row_and_holder_denial_failures() + { + var item = Item(); var first = Location(); var denied = Location(); + var a = _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, FromLocationId = first.Id, Content = "{}" }); + var b = _store.Seed(new InventoryTransaction { DepartmentId = Department, ItemId = item.Id, FromLocationId = denied.Id, Content = "{}" }); + (await _service.GetManyAsync(_actor, new[] { a.Id, b.Id, a.Id })).Should().HaveCount(2); + _deniedLocations.Add(denied.Id); + await Fails(() => _service.GetManyAsync(_actor, new[] { a.Id, b.Id }), "LocationUnavailable", 404); + await Fails(() => _service.GetManyAsync(_actor, new[] { a.Id, Guid.NewGuid().ToString("D") }), "Unavailable", 404); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs new file mode 100644 index 00000000..816d7081 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryWorkOrderDatabaseTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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.Providers; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + 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 }); + 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()); + var access = new Mock(); access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(true); + var invAuth = new Mock(); invAuth.Setup(a=>a.IsEnabledAsync(77)).ReturnsAsync(true); invAuth.Setup(a=>a.CanLocationAsync(It.IsAny(),It.IsAny())).ReturnsAsync(true); + var read=new Mock(); read.SetReturnsDefault(Task.FromResult(new ProtectedReadResult())); + var write=new Mock(); write.SetReturnsDefault(Task.FromResult(ProtectedWriteResult.Allowed())); + var outbox=new Mock(); long sequence=0; + outbox.Setup(o=>o.EnqueueAsync(77,It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync(()=> { uow.Transaction.Should().NotBeNull(); return new DomainEventOutboxEntry {DomainEventOutboxId=++sequence}; }); + outbox.Setup(o=>o.DispatchAfterCommitAsync(It.IsAny>(),It.IsAny())).ReturnsAsync(0); + var audit=new Mock(); audit.Setup(a=>a.InsertAsync(It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync((AuditLog row,CancellationToken c,bool b)=> {row.AuditLogId=1;return row;}); + WorkOrdersService work=null; + var inventory = new InventoryModernizationService(Store(uow),invAuth.Object,uow,read.Object,write.Object,outbox.Object,audit.Object,Mock.Of(),Mock.Of(), + workOrders:orders,workOrderAuthorization:new(()=>auth.Object),maintenanceOrders:orders,workOrderMaintenance:new(()=>work),readinessAccess:access.Object); + work=new WorkOrdersService(orders,auth.Object,access.Object,uow,audit.Object,outbox.Object,new(()=>read.Object),new(()=>write.Object),Mock.Of(), + maintenance:orders,inventoryMaintenance:new(()=>inventory),inventoryCatalog:new(()=>inventory)); + return (work,inventory,access); + } + [TestCase(false), TestCase(true)] + public async Task Asset_holds_require_separate_release_preserve_other_holds_and_never_revive_retirement(bool retire) + { + 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 reviewer=new ChecklistActor {DepartmentId=77,UserId="inventory-test-other"}; + var item=await pair.Inventory.SaveItemAsync(actor,new() {TrackingMode=InventoryTrackingMode.Serialized,Details=new() {Name="Synthetic apparatus",UnitOfMeasure="each"}}); + var location=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic station",Type=InventoryLocationType.Facility}); + var asset=await pair.Inventory.CreateAssetAsync(actor,new() {RequestId=Guid.NewGuid().ToString("D"),ItemId=item.Id,LocationId=location.Id,Details=new() {SerialNumber="SYNTHETIC-001"}}); + var holds=new List(); + for (var i=0;i<2;i++) + { + var order=await pair.Orders.CreateAsync(principal,new() {RequestId=Guid.NewGuid().ToString("D"),InventoryAssetId=asset.Id,Content=new() {Title="Independent defect "+i}}); + await pair.Orders.AddHoldAsync(principal,order.Order.Id,new() {Revision=1,Asset=true,Reason="Inspection defect"}); + holds.Add((await pair.Orders.HoldsAsync(principal,order.Order.Id)).Single()); + } + var release=new WorkOrderReleaseInput {Revision=1,RestoreState=true,Evidence="Independent function test",Qualification="Qualified maintenance reviewer"}; + await FluentActions.Awaiting(()=>pair.Orders.ReleaseHoldAsync(principal,holds[0].Hold.Id,release)).Should().ThrowAsync(); + await pair.Orders.ReleaseHoldAsync(reviewer,holds[0].Hold.Id,release); + asset=await pair.Inventory.GetAsync(actor,asset.Id); asset.Status.Should().Be((int)InventoryAssetStatus.OutForRepair); + var status=new InventoryCommand {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,AssetId=asset.Id,FromLocationId=location.Id,ExpectedAssetRevision=asset.Revision,Type=InventoryTransactionType.StatusChange,Status=InventoryAssetStatus.InService,Note="Unauthorized bypass"}}}; + await FluentActions.Awaiting(()=>pair.Inventory.ChangeAssetStatusAsync(actor,status)).Should().ThrowAsync(); + if(retire) { status.RequestId=Guid.NewGuid().ToString("D");status.Lines[0].Status=InventoryAssetStatus.Retired;status.Lines[0].Note="Asset decommissioned";await pair.Inventory.ChangeAssetStatusAsync(actor,status); } + pair.Access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + await pair.Orders.ReleaseHoldAsync(reviewer,holds[1].Hold.Id,release); + (await pair.Inventory.GetAsync(actor,asset.Id)).Status.Should().Be((int)(retire ? InventoryAssetStatus.Retired : InventoryAssetStatus.InService)); + (await pair.Orders.HoldsAsync(reviewer,holds[1].Hold.WorkOrderId.Value)).Single().Hold.StateRestored.Should().Be(!retire); + } + [Test] + public async Task Cancelling_an_unposted_controlled_part_retains_evidence_and_cannot_move_stock() + { + 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 part", UnitOfMeasure="each" } }); + var location = await pair.Inventory.SaveLocationAsync(actor, new() { Name="Synthetic store", Type=InventoryLocationType.Facility }); + var receive = new InventoryCommand { RequestId=Guid.NewGuid().ToString("D"), Lines=new() { new() { ItemId=item.Id, ToLocationId=location.Id, Quantity=10, Type=InventoryTransactionType.Receive } } }; + await pair.Inventory.PostTransactionAsync(actor, receive); await pair.Inventory.WitnessAsync(witness, receive.RequestId, "Independent receipt"); + 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 { Revision=order.Order.Revision, RequestId=Guid.NewGuid().ToString("D"), InventoryItemId=item.Id, InventoryLocationId=location.Id, Content=new() { Description="Unused proposal", Quantity=2 } }; + await pair.Orders.AddPartAsync(principal, order.Order.Id, input); order = await pair.Orders.GetAsync(principal, order.Order.Id); + var part = order.Parts.Single(); part.InventoryWitnessRequestId.Should().Be(input.RequestId); + await pair.Orders.CancelPartWitnessAsync(principal, order.Order.Id, part.Id, order.Order.Revision, "Part no longer needed"); + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(witness,input.RequestId,"Stale witness")).Should().ThrowAsync(); + await pair.Orders.AddPartAsync(principal, order.Order.Id, input); + part=(await pair.Orders.GetAsync(principal, order.Order.Id)).Parts.Single(); part.VoidedOn.Should().NotBeNull(); part.InventoryTransactionId.Should().BeNull(); part.AwaitingWitness.Should().BeFalse(); + (await Store(uow).ListAsync(77,0)).Single().Quantity.Should().Be(10); + (await Store(uow).ListAsync(77,0)).Should().HaveCount(1); + } + [TestCase(false, false), TestCase(true, false), TestCase(true, true)] + public async Task Linked_parts_and_reversals_post_once_with_authoritative_cost_and_independent_witness(bool controlled, bool cancel) + { + 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 parts",UnitOfMeasure="each",CurrencyCode="EUR"} }); + var location=await pair.Inventory.SaveLocationAsync(actor,new() {Name="Synthetic store",Type=InventoryLocationType.Facility}); + var receive=new InventoryCommand {RequestId=Guid.NewGuid().ToString("D"),Lines=new() {new() {ItemId=item.Id,ToLocationId=location.Id,Quantity=10,UnitCost=4.25m,Type=InventoryTransactionType.Receive}}}; + var receipt=await pair.Inventory.PostTransactionAsync(actor,receive); + if(receipt.AwaitingWitness) await pair.Inventory.WitnessAsync(witness,receive.RequestId,"Independent receipt"); + 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 {Revision=order.Order.Revision,RequestId=Guid.NewGuid().ToString("D"),InventoryItemId=item.Id,InventoryLocationId=location.Id,Content=new() {Description="Synthetic installed part",Quantity=2.123456m,UnitCost=1m}}; + if (!controlled) + { + input.Content.Quantity = 11; + await FluentActions.Awaiting(()=>pair.Orders.AddPartAsync(principal,order.Order.Id,input)).Should().ThrowAsync(); + (await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Should().BeEmpty("stock failure rolls back the source row too"); + (await Store(uow).ListAsync(77,0)).Should().HaveCount(1); + input.Content.Quantity = 2.123456m; + } + await pair.Orders.AddPartAsync(principal,order.Order.Id,input); + await pair.Orders.AddPartAsync(principal,order.Order.Id,input); + var part=(await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Single(); + if(controlled) + { + part.AwaitingWitness.Should().BeTrue(); + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(actor,input.RequestId,"Same actor denied")).Should().ThrowAsync(); + pair.Access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(witness,input.RequestId,"Expired addon denied")).Should().ThrowAsync(); + pair.Access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(true); + await pair.Inventory.WitnessAsync(witness,input.RequestId,"Independent part consumption"); + } + order=await pair.Orders.GetAsync(principal,order.Order.Id); part=order.Parts.Single(); part.Content.UnitCost.Should().Be(4.25m); part.Content.Currency.Should().Be("EUR"); part.AwaitingWitness.Should().BeFalse(); + var ledger=await pair.Inventory.GetAsync(actor,part.InventoryTransactionId); ledger.WorkOrderPartId.Should().Be(part.Id); ledger.ReferenceId.Should().Be(order.Order.Id.ToString()); + var stock=(await Store(uow).ListAsync(77,0)).Single(); stock.Quantity.Should().Be(7.876544m); + await pair.Orders.VoidPartAsync(principal,order.Order.Id,part.Id,order.Order.Revision,"Unused"); + var operation= (await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Single(); + if(controlled) + { + operation.AwaitingWitness.Should().BeTrue(); + var pending=await pair.Inventory.GetAsync(actor,operation.InventoryOperationId); + operation.InventoryWitnessRequestId.Should().Be(pending.RequestId); + if (cancel) + { + order=await pair.Orders.GetAsync(principal,order.Order.Id); + await pair.Orders.CancelPartWitnessAsync(principal,order.Order.Id,part.Id,order.Order.Revision,"Keep installed"); + await FluentActions.Awaiting(()=>pair.Inventory.WitnessAsync(witness,pending.RequestId,"Cancelled reversal")).Should().ThrowAsync(); + order=await pair.Orders.GetAsync(principal,order.Order.Id); + order.Parts.Single().AwaitingWitness.Should().BeFalse(); + (await Store(uow).ListAsync(77,0)).Single().Quantity.Should().Be(7.876544m); + await pair.Orders.VoidPartAsync(principal,order.Order.Id,part.Id,order.Order.Revision,"New independent reversal"); + operation=(await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Single(); + var retried=await pair.Inventory.GetAsync(actor,operation.InventoryOperationId); + retried.RequestId.Should().NotBe(pending.RequestId); + pending=retried; + } + await pair.Inventory.WitnessAsync(witness,pending.RequestId,"Independent reversal"); + } + (await pair.Orders.GetAsync(principal,order.Order.Id)).Parts.Single().VoidedOn.Should().NotBeNull(); + (await Store(uow).ListAsync(77,0)).Single().Quantity.Should().Be(10m); + (await Store(uow).ListAsync(77,0)).Count(t=>t.WorkOrderPartId==part.Id).Should().Be(2); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index d01810ca..43a106e3 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -359,7 +359,8 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "ChecklistDefinitions", "ChecklistDefinitionVersions", "ChecklistOccurrences", "ChecklistCompletions", "ChecklistCompletionItems", "ChecklistCompletionFiles", "DepartmentChecklistSettings", "ChecklistSchedules", // Work-order content uses WorkOrderTables.Fields(); file downloads use the binary resolver. - "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles", + "WorkOrders", "WorkOrderActivities", "WorkOrderLabors", "WorkOrderParts", "WorkOrderFiles", + "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrences", "WorkOrderRecurrenceVersions", "WorkOrderMeterReadings", "WorkOrderRecurrenceChanges", // Inventory content uses InventoryTables.Fields() through the generic protected resolver. "InventoryCategories", "InventoryItems", "InventoryLocations", "InventoryLots", "InventoryStocks", "InventoryAssets", "InventoryTransactions", "InventoryOperations", "InventoryTransfers", "InventoryTransferItems", diff --git a/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs index d9a6d2ea..7a66adec 100644 --- a/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs +++ b/Tests/Resgrid.Tests/Services/RmsInventoryModernUsageTests.cs @@ -80,6 +80,12 @@ public void SetUp() _catalog.Setup(c => c.GetAsync(It.IsAny(), ItemId)).ReturnsAsync(() => Copy(item)); _catalog.Setup(c => c.GetAsync(It.IsAny(), LocationId)).ReturnsAsync(() => Copy(location)); _catalog.Setup(c => c.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InventoryActor a, string id) => Copy(_ledger.SingleOrDefault(t => t.DepartmentId == a.DepartmentId && t.Id == id))); + _catalog.Setup(c => c.GetManyAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((InventoryActor a, IReadOnlyCollection ids) => _ledger.Where(t => t.DepartmentId == a.DepartmentId && ids.Contains(t.Id)).ToDictionary(t => t.Id, Copy)); + _catalog.Setup(c => c.GetManyAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((InventoryActor a, IReadOnlyCollection ids) => _usageRows.Where(t => t.DepartmentId == a.DepartmentId && ids.Contains(t.Id)).ToDictionary(t => t.Id, Copy)); + _modernStore.Setup(s => s.RelatedManyAsync(Department, It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, string column, IReadOnlyCollection ids) => _ledger.Where(t => t.DepartmentId == d && ids.Contains((string)typeof(InventoryTransaction).GetProperty(column).GetValue(t))).Select(Copy).ToList()); _catalog.Setup(c => c.ListAsync(It.IsAny(), 0)).ReturnsAsync(new InventoryPage { Items = new() { location } }); _modernStore.Setup(s => s.HasLegacyMigrationAsync(Department)).ReturnsAsync(() => _migrated); _modernStore.Setup(s => s.LockDepartmentAsync(Department)).Returns(() => { _transaction.Should().NotBeNull(); return Task.CompletedTask; }); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs index d736df7e..0bf76827 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs @@ -55,7 +55,16 @@ public async Task CreateIsolatedDatabaseAndMigrate() await using var db = Connect(_connection); await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "CREATE TABLE departments(departmentid integer PRIMARY KEY); INSERT INTO departments VALUES(77),(88);" : "CREATE TABLE Departments(DepartmentId int PRIMARY KEY); INSERT INTO Departments VALUES(77),(88);"); await db.ExecuteAsync($"CREATE TABLE {Q("AuditLogs")} ({Q("AuditLogId")} int PRIMARY KEY, {Q("DepartmentId")} int, {Q("LogType")} int, {Q("Data")} {TextType}); CREATE TABLE {Q("DomainEventOutbox")} ({Q("DomainEventOutboxId")} bigint PRIMARY KEY, {Q("DepartmentId")} int, {Q("ProducerSubsystem")} varchar(100), {Q("PayloadJson")} {TextType}, {Q("LastError")} {TextType}, {Q("AggregateId")} varchar(36)); CREATE TABLE {Q("WorkflowRuns")} ({Q("WorkflowRunId")} varchar(36) PRIMARY KEY, {Q("WorkflowId")} varchar(36), {Q("DepartmentId")} int, {Q("TriggerEventType")} int, {Q("Status")} int, {Q("AttemptNumber")} int, {Q("InputPayload")} {TextType}, {Q("ErrorMessage")} varchar(4000)); CREATE TABLE {Q("WorkflowRunLogs")} ({Q("WorkflowRunLogId")} varchar(36) PRIMARY KEY, {Q("WorkflowRunId")} varchar(36), {Q("RenderedOutput")} {TextType}, {Q("ActionResult")} varchar(4000), {Q("ErrorMessage")} varchar(4000));"); - var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(new IMigration[] { _type == DatabaseTypes.Postgres ? new M0197_AddWorkOrdersPg() : new M0197_AddWorkOrders() }); + await db.ExecuteAsync($"CREATE TABLE {Q("InventoryTransactions")} ({Q("Id")} varchar(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL);"); + var identity = _type == DatabaseTypes.Postgres ? "integer GENERATED BY DEFAULT AS IDENTITY" : "int IDENTITY(1,1)"; + var date = _type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; + var boolean = _type == DatabaseTypes.Postgres ? "boolean" : "bit"; + await db.ExecuteAsync($"CREATE TABLE {Q("Units")} ({Q("UnitId")} int PRIMARY KEY, {Q("DepartmentId")} int NOT NULL); INSERT INTO {Q("Units")} VALUES(10,77),(20,88); CREATE TABLE {Q("UnitStates")} ({Q("UnitStateId")} {identity} PRIMARY KEY, {Q("UnitId")} int NOT NULL REFERENCES {Q("Units")}({Q("UnitId")}), {Q("State")} int NOT NULL, {Q("Timestamp")} {date} NOT NULL, {Q("IsProtected")} {boolean} NOT NULL); CREATE INDEX ix_unitstate_current ON {Q("UnitStates")} ({Q("UnitId")},{Q("Timestamp")} DESC,{Q("UnitStateId")} DESC);"); + 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() + }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { if (_type == DatabaseTypes.Postgres) r.AddPostgres(); else r.AddSqlServer(); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs index f6cadf68..8129e00e 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderGdprTests.cs @@ -16,6 +16,17 @@ namespace Resgrid.Tests.Services { public partial class GdprExportProtectedDataTests { + private static IWorkOrderMaintenanceRepository EmptyMaintenance() + { + 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())); + store.SetReturnsDefault(Task.FromResult(new List())); + return store.Object; + } private static IWorkOrderRepository EmptyWorkOrders() { var store = new Mock(); @@ -40,12 +51,26 @@ public async Task Work_order_personal_export_masks_protected_candidates_and_neve else policy.Setup(p=>p.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); var reminders = new Mock(); reminders.SetReturnsDefault(Task.FromResult(new List())); + var maintenance = Mock.Get(EmptyMaintenance()); + maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { + new() { Id=3, DepartmentId=DeptId, CreatedBy="other", ReleasedBy=UserId, Content="HOLD-CANARY" }, + new() { Id=4, DepartmentId=DeptId, CreatedBy="other", Content="UNRELATED-HOLD" } + }); + maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { + new() { Id=5, DepartmentId=DeptId, CreatedBy="other", AssignedToUserId=UserId, Content="TEMPLATE-CANARY" } + }); + maintenance.Setup(s=>s.QueryMaintenanceAsync(DeptId,null,null,0,false)).ReturnsAsync(new List { + new() { Id=6, DepartmentId=DeptId, CreatedBy=UserId, RecurrenceId=5, Content="READING-CANARY" } + }); _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, _actionLogsService.Object, _messageService.Object, _certificationService.Object, _trainingService.Object, _shiftsService.Object, _emailService.Object, new ChecklistWorkflowTests.MemoryStore(), - new Lazy(()=>new ReadinessHistoryProtectionService(Mock.Of(),policy.Object)),reminders.Object,store.Object,EmptyInventory()); + 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["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 2eed3a6b..05ce0d0b 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderHttpTests.cs @@ -57,6 +57,7 @@ public void OnResultExecuted(ResultExecutedContext c) { } [Test,NonParallelizable] public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_localize_protected_failures() { + Maintenance(); _auth.Setup(a=>a.ChoicesAsync(It.IsAny())).ReturnsAsync(new WorkOrderChoices()); var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); while(directory != null && !System.IO.File.Exists(Path.Combine(directory.FullName,"Resgrid.sln"))) directory=directory.Parent; @@ -68,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(_auth.Object); builder.Services.AddSingleton(_access.Object); + 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"}); @@ -115,6 +116,20 @@ public async Task Http_routes_bind_forms_enforce_CSRF_preserve_conflicts_and_loc var body=new StringContent(JsonConvert.SerializeObject(new { Id=id,Input=new WorkOrderTransition {Revision=1,Status=WorkOrderStatus.Accepted}}),Encoding.UTF8,"application/json"); response=await client.PostAsync("/api/v4/WorkOrders/SetWorkOrderStatus",body); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); response=await client.PostAsync("/api/v4/WorkOrders/SetWorkOrderStatus",body); response.StatusCode.Should().Be(HttpStatusCode.Conflict); + response=await client.GetAsync("/User/WorkOrders/NewRecurrence"); html=await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK,html); html.Should().Contain("name=\"Input.Template.Content.Title\""); + var pmFields=new Dictionary { ["Input.Template.RequestId"]=Guid.NewGuid().ToString("D"),["Input.Template.Content.Title"]="Synthetic HTTP PM",["Input.Template.Content.Currency"]="EUR", + ["Input.AnchorLocal"]="2026-09-12T12:00",["Input.TimeZoneId"]="UTC",["Input.Calendar"]="3",["serviceStart"]="00:00",["serviceEnd"]="23:59",["Input.IsActive"]="true" }; + (await client.PostAsync("/User/WorkOrders/SaveRecurrence",new FormUrlEncodedContent(pmFields))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + pmFields["__RequestVerificationToken"]=csrf; + response=await client.PostAsync("/User/WorkOrders/SaveRecurrence",new FormUrlEncodedContent(pmFields)); + response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); var pmId=JObject.Parse(await response.Content.ReadAsStringAsync())["id"].Value(); + foreach(var pmPage in new[]{"Recurrences","Recurrence?id="+pmId,"EditRecurrence?id="+pmId}) + { response=await client.GetAsync("/User/WorkOrders/"+pmPage); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); } + response=await client.GetAsync("/api/v4/WorkOrders/GetWorkOrderRecurrence?id="+pmId); response.StatusCode.Should().Be(HttpStatusCode.OK,await response.Content.ReadAsStringAsync()); + (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()); _access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); 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); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderLocalizationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderLocalizationTests.cs index c0f91b47..67296266 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderLocalizationTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderLocalizationTests.cs @@ -15,7 +15,24 @@ namespace Resgrid.Tests.Services [TestFixture] public class WorkOrderLocalizationTests { - public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + [Test] + public void Maintenance_views_and_service_errors_resolve_real_resource_keys() + { + var root = new DirectoryInfo(ResourceDirectory()).Parent.Parent.Parent.Parent.Parent.FullName; + var files = Directory.GetFiles(Path.Combine(root, "Core", "Resgrid.Services"), "WorkOrder*.cs") + .Concat(Directory.GetFiles(Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User", "Views", "WorkOrders"), "*.cshtml")); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + var source = File.ReadAllText(file); + foreach (Match match in Regex.Matches(source, """localizer\["([^"]+)"\]|WorkOrderException\(\d+, "([^"]+)"\)|MaintenanceText\("([^"]+)"\)""")) + keys.Add(match.Groups.Cast().Skip(1).First(g=>g.Success).Value); + } + keys.UnionWith(new[] { "ActivitySafetyHold", "ActivitySafetyReleased", "StateRestored", "StatePreserved", "Active", "Paused", "GeneratedFailureTitle" }); + var resources = Read(Path.Combine(ResourceDirectory(), "WorkOrders.resx")); + keys.Except(resources.Keys).Should().BeEmpty("user interfaces and errors must not show untranslated resource identifiers"); + } private static string ResourceDirectory() { var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceDatabaseTests.cs new file mode 100644 index 00000000..60b993ca --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceDatabaseTests.cs @@ -0,0 +1,89 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.WorkOrders; +using Resgrid.Repositories.DataRepository.Transactions; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderDatabaseTests + { + [Test, Order(1)] + public async Task Recurrence_rollback_refuses_to_destroy_a_manual_orders_original_due() + { + using var uow=new UnitOfWork(Connections()); var store=Orders(uow); + await uow.CreateOrGetConnectionAsync(); var order=await Insert(store); + order.OriginalDueOn=DateTime.UtcNow.Date; order.DueOn=order.OriginalDueOn.Value.AddDays(2); await store.WriteAsync(order); uow.CommitChanges(); + var runner=_runner.GetRequiredService(); + FluentActions.Invoking(()=>runner.MigrateDown(203)).Should().Throw(); + _runner.GetRequiredService().LoadVersionInfo(); runner.MigrateUp(); + var retained=await store.GetAsync(77,order.Id); + retained.OriginalDueOn.Should().Be(order.OriginalDueOn); retained.DueOn.Should().Be(order.DueOn); + } + [Test, Order(10)] + public async Task Maintenance_schema_stores_every_model_column_and_catalog_versions_are_separate() + { + await using var db = Connect(_connection); + foreach (var pair in WorkOrderTables.All) + { + var columns = (await db.QueryAsync("SELECT LOWER(column_name) FROM information_schema.columns WHERE LOWER(table_name)=@table", new { table = pair.Value.ToLowerInvariant() })).ToHashSet(); + columns.Should().Contain(pair.Key.GetProperties().Where(p => p.CanWrite && !Attribute.IsDefined(p, typeof(NotMappedAttribute))).Select(p => p.Name.ToLowerInvariant()), pair.Value); + } + var catalog = new ProtectedFieldCatalog(); + catalog.GetForTable("WorkOrderSafetyHolds").Single().AddedInCatalogVersion.Should().Be(23); + foreach (var table in new[] { "WorkOrderRecurrences", "WorkOrderRecurrenceVersions", "WorkOrderMeterReadings", "WorkOrderRecurrenceChanges" }) + catalog.GetForTable(table).Single().AddedInCatalogVersion.Should().Be(24); + catalog.GetForTable("WorkOrders").Should().OnlyContain(f => f.AddedInCatalogVersion == 18); + } + [Test, Order(10)] + public async Task Maintenance_state_lock_serializes_an_ordinary_unit_state_insert_until_commit() + { + using var owner = new UnitOfWork(Connections()); var repository = Orders(owner); + await owner.CreateOrGetConnectionAsync(); + await repository.AppendUnitStateAsync(77, 10, (int)UnitStateTypes.Available, DateTime.UtcNow.AddMinutes(-1)); + owner.CommitChanges(); + await owner.CreateOrGetConnectionAsync(); var before = await repository.LatestUnitStateAsync(77, 10); before.Should().NotBeNull(); + await using var dispatch = Connect(_connection); await dispatch.OpenAsync(); + // This is an ordinary dispatch INSERT: it does not call the maintenance lock. + var writer = dispatch.ExecuteAsync($"INSERT INTO {Q("UnitStates")} ({Q("UnitId")},{Q("State")},{Q("Timestamp")},{Q("IsProtected")}) VALUES(10,2,@now,@protect)", new { now = DateTime.UtcNow.AddMinutes(1), protect = false }, commandTimeout: 10); + await Task.Delay(200); writer.IsCompleted.Should().BeFalse("the immediate PostgreSQL FK / SQL Server range lock must protect restoration from a concurrent ordinary dispatch insert"); + await repository.AppendUnitStateAsync(77, 10, (int)UnitStateTypes.OutOfService, DateTime.UtcNow); + owner.CommitChanges(); (await writer).Should().Be(1); + await owner.CreateOrGetConnectionAsync(); + (await repository.LatestUnitStateAsync(77,10)).State.Should().Be((int)UnitStateTypes.Unavailable); + owner.DiscardChanges(); + } + [Test, Order(10)] + public async Task Failed_source_identity_is_unique_and_inventory_part_backlink_is_tenant_bound() + { + using var owner = new UnitOfWork(Connections()); var repository = Orders(owner); + await owner.CreateOrGetConnectionAsync(); var order = await Insert(repository); + var intent = new WorkOrderFailureIntent { DepartmentId = 77, CompletionId = Guid.NewGuid().ToString("D"), ItemId = Guid.NewGuid().ToString("D"), CreatedBy = "actor", CreatedOn = DateTime.UtcNow, UpdatedOn = DateTime.UtcNow, WorkOrderId = order.Id }; + await repository.AllocateAsync(intent); + var part = new WorkOrderPart { DepartmentId=77, WorkOrderId=order.Id, CreatedBy="actor", CreatedOn=DateTime.UtcNow, UpdatedOn=DateTime.UtcNow }; + await repository.AllocateAsync(part); owner.CommitChanges(); + await owner.CreateOrGetConnectionAsync(); intent.Id=0; + await FluentActions.Awaiting(() => repository.AllocateAsync(intent)).Should().ThrowAsync(); owner.DiscardChanges(); + await using var db = Connect(_connection); + await FluentActions.Awaiting(() => db.ExecuteAsync($"INSERT INTO {Q("InventoryTransactions")} ({Q("Id")},{Q("DepartmentId")},{Q("WorkOrderPartId")}) VALUES(@id,88,@part)",new {id=Guid.NewGuid().ToString("D"),part=part.Id})).Should().ThrowAsync(); + (await repository.QueryMaintenanceAsync(88)).Should().BeEmpty(); + } + [Test, Order(10)] + public async Task Late_escalation_candidates_are_filtered_before_the_500_row_page() + { + using var owner=new UnitOfWork(Connections()); var repository=Orders(owner); await owner.CreateOrGetConnectionAsync(); + for(var i=0;i<501;i++) { var order=await Insert(repository); order.DueOn=DateTime.UtcNow.AddMinutes(-10); order.EscalateAfterMinutes=60; await repository.WriteAsync(order); } + var ready=await Insert(repository); ready.DueOn=DateTime.UtcNow.AddMinutes(-1); await repository.WriteAsync(ready); owner.CommitChanges(); + (await repository.OverdueAsync(77,DateTime.UtcNow)).Should().Contain(o=>o.Id==ready.Id); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs index 54d7c039..45031e81 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs @@ -134,7 +134,7 @@ public async Task Failed_protection_rolls_back_allocated_number_and_emits_no_eve await ((Func)(async () => await _service.CreateAsync(_actor, Input()))).Should().ThrowAsync(); _store.All().Should().BeEmpty(); _events.Should().BeEmpty(); _uow.Verify(u => u.CommitChanges(), Times.Never); } - private sealed class Store : IWorkOrderRepository + private sealed partial class Store : IWorkOrderRepository, IWorkOrderMaintenanceRepository { private Dictionary> _rows = new Dictionary>(); private Dictionary> _before; diff --git a/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs new file mode 100644 index 00000000..0dce536f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Checklists; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.WorkOrders; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + public partial class WorkOrderP2M1Tests + { + private sealed class MaintenanceClock : TimeProvider { public DateTime Utc = new(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc); public override DateTimeOffset GetUtcNow() => new(Utc); } + private MaintenanceClock _maintenanceClock; + private Mock _maintenanceChecklists; + private void Maintenance() + { + _maintenanceClock = new(); _maintenanceChecklists = new(); + DbTransaction transaction = null; + _uow.SetupGet(u => u.Transaction).Returns(() => transaction); + _uow.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync(() => { _store.Begin(); transaction = Mock.Of(); return (DbConnection)null; }); + _uow.Setup(u => u.CommitChanges()).Callback(() => transaction = null); + _uow.Setup(u => u.DiscardChanges()).Callback(() => { _store.Rollback(); transaction = null; }); + var audit = new Mock(); + audit.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((AuditLog a, CancellationToken c, bool b) => { a.AuditLogId = 1; return a; }); + var units = new Mock(); units.Setup(u => u.GetUnitByIdAsync(10)).ReturnsAsync(new Unit { UnitId = 10, DepartmentId = 77 }); + _service = new WorkOrdersService(_store, _auth.Object, _access.Object, _uow.Object, audit.Object, _outbox.Object, new(() => _read.Object), new(() => _write.Object), _scanner.Object, + _maintenanceClock, _store, checklists: _maintenanceChecklists.Object, maintenanceUnits: units.Object); + } + private WorkOrderRecurrenceInput Schedule() => new() { AnchorLocal = _maintenanceClock.Utc.AddDays(3), Template = Input(), TimeZoneId = "UTC", Calendar = MaintenanceCalendar.Monthly, LeadDays = 3 }; + [Test] + public async Task PM_generation_is_idempotent_metadata_only_and_pins_the_original_template() + { + Maintenance(); var input = Schedule(); var id = await _service.SaveRecurrenceAsync(_actor, input); + (await _service.SaveRecurrenceAsync(_actor, input)).Should().Be(id); + _read.Invocations.Clear(); _write.Invocations.Clear(); + var sweep = await _service.GenerateMaintenanceAsync(77); + sweep.Generated.Should().Be(1); sweep.Errors.Should().Be(0); + _read.Invocations.Should().BeEmpty(); _write.Invocations.Should().BeEmpty("workers must not decrypt or seal customer content"); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + var order = _store.All().Single(); order.Content.Should().BeNull(); order.OriginalDueOn.Should().Be(input.AnchorLocal); + var version = order.RecurrenceVersionId; + input.Id = id; input.Revision = _store.All().Single().Revision; input.Reason = "new procedure"; input.Template.Content.Title = "Revised template"; + await _service.SaveRecurrenceAsync(_actor, input); + (await _service.GetAsync(_actor, order.Id)).Order.Title.Should().Be("Synthetic equipment repair"); + _store.All().Single().RecurrenceVersionId.Should().Be(version); + _store.All().Should().HaveCount(2); + JsonConvert.SerializeObject(_events).Should().NotContain("CANARY"); + } + [Test] + public async Task Recurrence_requires_a_caller_retry_identity_and_keeps_its_target() + { + Maintenance(); var input=Schedule(); input.Template.RequestId=null; + await FluentActions.Awaiting(()=>_service.SaveRecurrenceAsync(_actor,input)).Should().ThrowAsync(); + input.Template.RequestId=Guid.NewGuid().ToString("D"); + var id=await _service.SaveRecurrenceAsync(_actor,input); + input.Id=id; input.Revision=1; input.Reason="Move schedule"; input.Template.TargetUnitId=10; + (await FluentActions.Awaiting(()=>_service.SaveRecurrenceAsync(_actor,input)).Should().ThrowAsync()).Which.Code.Should().Be("TargetUnavailable"); + } + [TestCase(false), TestCase(true)] + public async Task Meter_or_condition_whichever_first_generates_once_and_readings_replay(bool condition) + { + Maintenance(); var input = Schedule(); input.AnchorLocal = _maintenanceClock.Utc.AddMonths(2); input.MeterUnit = MaintenanceMeterUnit.Hours; input.MeterBaseline = 100; input.MeterInterval = 50; + input.Condition = MaintenanceCondition.AtOrAbove; input.ConditionThreshold = 90; input.ConditionUnit = "C"; + var id = await _service.SaveRecurrenceAsync(_actor, input); + var reading = new WorkOrderReadingInput { Revision = 1, RequestId = Guid.NewGuid().ToString("D"), ObservedOn = _maintenanceClock.Utc, Source = "instrument CANARY", MeterValue = condition ? 110 : 150, ConditionValue = condition ? 95 : 70 }; + await _service.RecordReadingAsync(_actor, id, reading); await _service.RecordReadingAsync(_actor, id, reading); + _store.All().Should().ContainSingle(); + _maintenanceClock.Utc = _maintenanceClock.Utc.AddHours(2); + await _service.RecordReadingAsync(_actor, id, new() { Revision = 2, RequestId = Guid.NewGuid().ToString("D"), ObservedOn = _maintenanceClock.Utc, Source = "Later observation", MeterValue = 160, ConditionValue = 96 }); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(1); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + _store.All().Single().OriginalDueOn.Should().Be(reading.ObservedOn, "a later reading or worker outage must not erase the first threshold crossing"); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.WorkOrderThresholdReached).Should().Be(1); + JsonConvert.SerializeObject(_events).Should().NotContain("CANARY"); + } + [Test] + public async Task Meter_reset_requires_reason_keeps_immutable_provenance_and_advances_epoch() + { + Maintenance(); var input = Schedule(); input.AnchorLocal = _maintenanceClock.Utc.AddMonths(2); input.MeterUnit = MaintenanceMeterUnit.Kilometers; input.MeterBaseline = 1000; input.MeterInterval = 500; + var id = await _service.SaveRecurrenceAsync(_actor, input); + var reading = new WorkOrderReadingInput { Revision = 1, RequestId = Guid.NewGuid().ToString("D"), ObservedOn = _maintenanceClock.Utc, Source = "replacement instrument", MeterValue = 0, ResetMeter = true }; + await FluentActions.Awaiting(() => _service.RecordReadingAsync(_actor, id, reading)).Should().ThrowAsync(); + reading.Note = "Replacement documented"; await _service.RecordReadingAsync(_actor, id, reading); + var row = _store.All().Single(); row.MeterEpoch.Should().Be(1); row.MeterBaseline.Should().Be(0); row.LastMeterValue.Should().Be(0); + _store.All().Single().Content.Should().Contain("Replacement documented"); + input.Id = id; input.Revision = row.Revision; input.Reason = "rename"; input.MeterBaseline = row.MeterBaseline; + await _service.SaveRecurrenceAsync(_actor, input); + _store.All().Single().MeterBaseline.Should().Be(0); + } + [TestCase(false), TestCase(true)] + public async Task Calendar_completion_preserves_fixed_or_completion_anchor(bool completionBased) + { + Maintenance(); var input = Schedule(); input.AnchorLocal = _maintenanceClock.Utc.AddDays(-40); input.CompletionBased = completionBased; input.AssignedToUserId = _actor.UserId; + var id = await _service.SaveRecurrenceAsync(_actor, input); await _service.GenerateMaintenanceAsync(77); var order = _store.All().Single(); + await _service.AcceptAssignmentAsync(_actor, order.Id, order.Revision); await Transition(order.Id, WorkOrderStatus.InProgress); await Transition(order.Id, WorkOrderStatus.Completed); + await Transition(order.Id, WorkOrderStatus.Closed, evidence: "test passed"); + var row = _store.All().Single(); row.PendingWorkOrderId.Should().BeNull(); + row.NextDueOn.Should().Be(completionBased ? _maintenanceClock.Utc.AddMonths(1) : input.AnchorLocal.AddMonths(1)); + row.NextDueOn.Should().BeAfter(order.OriginalDueOn.Value); + } + [Test] + public async Task Deferral_preserves_original_due_and_overdue_escalates_once_after_delay() + { + Maintenance(); var input = Schedule(); input.AnchorLocal = _maintenanceClock.Utc.AddDays(-1); input.EscalateAfterMinutes = 60; + await _service.SaveRecurrenceAsync(_actor, input); await _service.GenerateMaintenanceAsync(77); var order = _store.All().Single(); + (await _service.EscalateMaintenanceAsync(77)).Escalated.Should().Be(1); + (await _service.EscalateMaintenanceAsync(77)).Escalated.Should().Be(0); + await _service.DeferAsync(_actor, order.Id, new() { Revision = _store.All().Single().Revision, DueOn = _maintenanceClock.Utc.AddDays(1), Reason = "Approved outage" }); + var deferred = _store.All().Single(); deferred.OriginalDueOn.Should().Be(input.AnchorLocal); deferred.DueOn.Should().Be(_maintenanceClock.Utc.AddDays(1)); + (await _service.EscalateMaintenanceAsync(77)).Escalated.Should().Be(0); + _maintenanceClock.Utc = deferred.DueOn.Value.AddMinutes(61); + (await _service.EscalateMaintenanceAsync(77)).Escalated.Should().Be(1); + } + [Test] + public async Task Reopening_a_closed_PM_order_pauses_its_cycle_and_prevents_a_duplicate() + { + Maintenance(); var input=Schedule(); input.AnchorLocal=_maintenanceClock.Utc.AddDays(-1); input.AssignedToUserId=_actor.UserId; + await _service.SaveRecurrenceAsync(_actor,input); await _service.GenerateMaintenanceAsync(77); + var order=_store.All().Single(); + await _service.AcceptAssignmentAsync(_actor,order.Id,order.Revision); await Transition(order.Id,WorkOrderStatus.InProgress); await Transition(order.Id,WorkOrderStatus.Completed); + await Transition(order.Id,WorkOrderStatus.Closed,evidence:"Function test passed"); + await Transition(order.Id,WorkOrderStatus.Accepted,reason:"Defect recurred"); + var recurrence=_store.All().Single(); recurrence.IsActive.Should().BeFalse(); recurrence.PendingWorkOrderId.Should().Be(order.Id); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + } + [Test] + public async Task Manual_order_deferrals_preserve_every_old_and_new_due_in_protected_activity() + { + Maintenance(); var input=Input(); input.DueOn=_maintenanceClock.Utc; + var order=await _service.CreateAsync(_actor,input); + await _service.DeferAsync(_actor,order.Order.Id,new() {Revision=1,DueOn=_maintenanceClock.Utc.AddDays(1),Reason="First approved delay"}); + order=await _service.GetAsync(_actor,order.Order.Id); + await _service.DeferAsync(_actor,order.Order.Id,new() {Revision=order.Order.Revision,DueOn=_maintenanceClock.Utc.AddDays(2),Reason="Second approved delay"}); + order=await _service.GetAsync(_actor,order.Order.Id); + order.OriginalDueOn.Should().Be(input.DueOn); + var history=order.Activities.Where(a=>a.Type==WorkOrderActivityType.Deferred).ToList(); + history.Should().HaveCount(2); + history[0].OriginalDueOn.Should().Be(input.DueOn); history[0].RevisedDueOn.Should().Be(_maintenanceClock.Utc.AddDays(1)); + history[1].OriginalDueOn.Should().Be(history[0].RevisedDueOn); history[1].RevisedDueOn.Should().Be(_maintenanceClock.Utc.AddDays(2)); + } + [Test] + public async Task Cancellation_pauses_a_due_cycle_and_commercial_pause_blocks_generation_without_losing_history() + { + Maintenance(); await _service.SaveRecurrenceAsync(_actor, Schedule()); await _service.GenerateMaintenanceAsync(77); + var order = _store.All().Single(); await Transition(order.Id, WorkOrderStatus.Cancelled, reason: "Review target"); + _store.All().Single().IsActive.Should().BeFalse(); + _store.All().Single().PendingWorkOrderId.Should().BeNull(); + _access.Setup(a => a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + (await _service.RecurrencesAsync(_actor)).Should().HaveCount(1); + await FluentActions.Awaiting(() => _service.SaveRecurrenceAsync(_actor, Schedule())).Should().ThrowAsync(); + } + [TestCase(false, false), TestCase(true, false), TestCase(true, true)] + public async Task Multiple_unit_holds_require_independent_release_and_preserve_intervening_dispatch(bool intervening, bool backdated) + { + Maintenance(); _store.UnitStates.Add(new UnitState { UnitStateId = 1, UnitId = 10, State = (int)UnitStateTypes.Available, Timestamp = _maintenanceClock.Utc.AddDays(-1) }); + for (var i = 0; i < 2; i++) { var input = Input(true); input.TargetUnitId = 10; var order = await _service.CreateAsync(_actor, input); await _service.AddHoldAsync(_actor, order.Order.Id, new() { Revision = 1, Unit = true, Reason = "Defect " + i }); } + var holds = _store.All().ToList(); holds[0].AppliedStateId.Should().Be(holds[1].AppliedStateId); + var heldOrder=await _service.GetAsync(_actor,holds[0].WorkOrderId.Value); heldOrder.Input.TargetUnitId=null; + (await FluentActions.Awaiting(()=>_service.UpdateAsync(_actor,heldOrder.Order.Id,heldOrder.Input)).Should().ThrowAsync()).Which.Code.Should().Be("TargetUnavailable"); + var release = new WorkOrderReleaseInput { Revision = 1, Qualification = "Qualified test technician", Evidence = "Independent verification", RestoreState = true }; + await FluentActions.Awaiting(() => _service.ReleaseHoldAsync(_actor, holds[0].Id, release)).Should().ThrowAsync(); + var reviewer = new ChecklistActor { DepartmentId = 77, UserId = "verifier" }; + _access.Setup(a => a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); + await _service.ReleaseHoldAsync(reviewer, holds[0].Id, release); + _store.UnitStates.Last().State.Should().Be((int)UnitStateTypes.OutOfService); + if (intervening) _store.UnitStates.Add(new UnitState { UnitStateId = 50, UnitId = 10, State = (int)UnitStateTypes.OutOfService, Timestamp = _maintenanceClock.Utc.AddSeconds(backdated ? -1 : 1) }); + await _service.ReleaseHoldAsync(reviewer, holds[1].Id, release); + _store.All().Last().StateRestored.Should().Be(!intervening); + _store.UnitStates.Last().State.Should().Be((int)(intervening ? UnitStateTypes.OutOfService : UnitStateTypes.Available)); + } + [Test] + public async Task Free_failure_intent_does_not_call_billing_and_replays_after_paid_recovery_even_awaiting_witness() + { + Maintenance(); var completion = new ChecklistCompletion { Id = Guid.NewGuid().ToString("D"), VersionId = Guid.NewGuid().ToString("D"), DepartmentId = 77, CreatedBy = "submitter", State = (int)ChecklistRunState.AwaitingWitness, TargetType = (int)ChecklistTargetType.Unit, TargetId = "10" }; + var version = new ChecklistDefinitionVersion { Id = completion.VersionId, DepartmentId = 77, CreatedBy = "manager" }; + var item = new ChecklistItem { Id = Guid.NewGuid().ToString("D"), CreateWorkOrderOnFail = true, SetUnitStateOnFail = true }; + _access.Invocations.Clear(); + await _uow.Object.CreateOrGetConnectionAsync(); await _service.RecordFailureIntentAsync(_actor, completion, version, item); await _service.RecordFailureIntentAsync(_actor, completion, version, item); _uow.Object.CommitChanges(); + _access.Invocations.Should().BeEmpty(); _store.All().Should().ContainSingle(); + _maintenanceChecklists.Setup(s => s.GetAsync(77, completion.Id, CancellationToken.None)).ReturnsAsync(completion); + _access.Setup(a => a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + _access.Setup(a => a.CanUseMaintenanceAsync(77)).ReturnsAsync(true); var sweep = await _service.GenerateMaintenanceAsync(77); + sweep.Generated.Should().Be(1); sweep.Held.Should().Be(1); sweep.Errors.Should().Be(0); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(0); + _store.All().Single().SourceChecklistCompletionId.Should().Be(completion.Id); + _store.All().Single().Content.Should().BeNull(); + } + [Test] + public async Task Pending_part_witness_blocks_terminal_transitions_and_preserves_open_order() + { + Maintenance(); var id = await Assigned(); await Transition(id, WorkOrderStatus.InProgress); + var part = new WorkOrderPart { WorkOrderId = id, DepartmentId = 77, CreatedBy = "manager", InventoryOperationId = Guid.NewGuid().ToString("D") }; + await _store.AllocateAsync(part); part.Content = "{}"; await _store.WriteAsync(part); + (await FluentActions.Awaiting(() => Transition(id, WorkOrderStatus.Completed)).Should().ThrowAsync()).Which.Code.Should().Be("InventoryWitnessPending"); + _store.All().Single().Status.Should().Be((int)WorkOrderStatus.InProgress); + } + [Test] + public void Calendar_clamps_month_end_and_leap_year_and_dst_has_one_occurrence() + { + var row = new WorkOrderRecurrence { Calendar = (int)MaintenanceCalendar.Monthly, Interval = 1, AnchorLocal = new(2024, 1, 31, 9, 0, 0), TimeZoneId = "UTC" }; + WorkOrdersService.NextMaintenanceDue(row, new DateTime(2024, 2, 1)).Should().Be(new DateTime(2024, 2, 29, 9, 0, 0, DateTimeKind.Utc)); + WorkOrdersService.NextMaintenanceDue(row, new DateTime(2024, 3, 1)).Should().Be(new DateTime(2024, 3, 31, 9, 0, 0, DateTimeKind.Utc)); + WorkOrdersService.MaintenanceUtc(new DateTime(2026, 3, 8, 2, 30, 0), "America/New_York").Should().Be(new DateTime(2026, 3, 8, 7, 0, 0, DateTimeKind.Utc)); + WorkOrdersService.MaintenanceUtc(new DateTime(2026, 11, 1, 1, 30, 0), "America/New_York").Should().Be(new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc)); + } + [Test] + public void Service_window_and_blackout_preserve_the_unadjusted_due() + { + var row = new WorkOrderRecurrence { TimeZoneId = "UTC", ServiceWeekdays = 62, ServiceStartMinute = 540, ServiceEndMinute = 1020, BlackoutFrom = new(2026,9,14), BlackoutUntil = new(2026,9,15) }; + WorkOrdersService.ServiceDue(row, new DateTime(2026,9,12,12,0,0,DateTimeKind.Utc)).Should().Be(new DateTime(2026,9,16,9,0,0,DateTimeKind.Utc)); + } + private sealed partial class Store + { + public List UnitStates { get; } = new(); + public Task> QueryMaintenanceAsync(int d, string field = null, object value = null, int skip = 0, bool pendingOnly = false) where T : WorkOrderRow => + Task.FromResult(All().Where(r => r.DepartmentId == d && (field == null || Equals(typeof(T).GetProperty(field).GetValue(r), value)) && (!pendingOnly || r is WorkOrderRecurrence { IsActive: true } || r is WorkOrderFailureIntent { ProcessedOn: null })).Skip(skip).Take(500).ToList()); + public Task> MaintenanceDepartmentsAsync(int after) => Task.FromResult(new List()); + public Task> OverdueAsync(int d, DateTime now) => Task.FromResult(All().Where(r => r.DepartmentId == d && r.Status < 5 && r.EscalatedOn == null && r.DueOn?.AddMinutes(r.EscalateAfterMinutes) < now).ToList()); + public Task FailureAsync(int d, string completion, string item) => Task.FromResult(All().SingleOrDefault(r => r.DepartmentId == d && r.CompletionId == completion && r.ItemId == item)); + public Task> ActiveHoldsAsync(int d, int? unit, string asset) => Task.FromResult(All().Where(r => r.DepartmentId == d && r.ReleasedOn == null && (unit.HasValue && r.UnitId == unit || asset != null && r.AssetId == asset)).ToList()); + public Task LockUnitAsync(int d, int unit) => Task.CompletedTask; + public Task LastAppendedUnitStateIdAsync(int d, int unit) => Task.FromResult(UnitStates.Where(r => r.UnitId == unit).Select(r => r.UnitStateId).DefaultIfEmpty().Max()); + public Task LatestUnitStateAsync(int d, int unit) => Task.FromResult(UnitStates.Where(r => r.UnitId == unit).OrderByDescending(r => r.Timestamp).ThenByDescending(r => r.UnitStateId).FirstOrDefault()); + public Task AppendUnitStateAsync(int d, int unit, int state, DateTime now) { var id = UnitStates.Select(r => r.UnitStateId).DefaultIfEmpty().Max() + 1; UnitStates.Add(new UnitState { UnitStateId = id, UnitId = unit, State = state, Timestamp = now }); return Task.FromResult(id); } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs index e0a0cec7..e659bf98 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderProtectionTests.cs @@ -21,12 +21,12 @@ namespace Resgrid.Tests.Services { public partial class DepartmentDataMigrationEngineTests { - [Test] - public async Task Work_order_catalog_upgrade_is_retry_safe_and_offboards_all_text_and_binary_fields() + [TestCase(17,18,5,6),TestCase(22,23,2,2),TestCase(23,24,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"); - var bindings=AdpTableBindings.ForVersionRange(new ProtectedFieldCatalog(),17,18); - bindings.Should().HaveCount(5); bindings.Sum(b=>b.Columns.Count).Should().Be(6); + var bindings=AdpTableBindings.ForVersionRange(new ProtectedFieldCatalog(),from,to); + bindings.Should().HaveCount(tables); bindings.Sum(b=>b.Columns.Count).Should().Be(columns); var bytes=Encoding.UTF8.GetBytes("SYNTHETIC-PHI-FILE"); foreach(var b in bindings) { @@ -35,7 +35,7 @@ public async Task Work_order_catalog_upgrade_is_retry_safe_and_offboards_all_tex foreach(var c in b.Columns) row[c.ColumnName]=c.StorageKind==ProtectedFieldStorageKind.Binary ? bytes : "SYNTHETIC-PHI-CANARY"; _bulk.Seed(b.TableName,b.PkColumn,row); } - var upgrade=Context(DepartmentDataProtectionMigrationKind.CatalogUpgrade);upgrade.FromCatalogVersion=17;upgrade.CatalogVersion=18; + var upgrade=Context(DepartmentDataProtectionMigrationKind.CatalogUpgrade);upgrade.FromCatalogVersion=from;upgrade.CatalogVersion=to; (await _engine.RunEncryptionNightAsync(upgrade,CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); var envelopes=new Dictionary(); foreach(var b in bindings) @@ -48,15 +48,15 @@ public async Task Work_order_catalog_upgrade_is_retry_safe_and_offboards_all_tex } (await _engine.RunEncryptionNightAsync(upgrade,CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); foreach(var b in bindings) _bulk.Table(b.TableName).Single()["Content"].Should().Be(envelopes[b.TableName]); - var offboarding=Context(DepartmentDataProtectionMigrationKind.Offboarding);offboarding.CatalogVersion=18; + var offboarding=Context(DepartmentDataProtectionMigrationKind.Offboarding);offboarding.CatalogVersion=to; (await _engine.RunDecryptionNightAsync(offboarding,CancellationToken.None)).Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); foreach(var b in bindings) _bulk.Table(b.TableName).Single()["Content"].Should().Be("SYNTHETIC-PHI-CANARY"); - ((byte[])_bulk.Table("WorkOrderFiles").Single()["Data"]).Should().Equal(bytes); + if(to==18) ((byte[])_bulk.Table("WorkOrderFiles").Single()["Data"]).Should().Equal(bytes); } } public partial class ChecklistEventDeliveryTests { - [TestCase(70),TestCase(71),TestCase(72)] + [TestCase(70),TestCase(71),TestCase(72),TestCase(73),TestCase(167),TestCase(168),TestCase(169),TestCase(170),TestCase(171),TestCase(172)] 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/Web/User/ProfileReportScheduleSecurityTests.cs b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs index 27aa95cb..ab1b3cec 100644 --- a/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs +++ b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs @@ -26,6 +26,7 @@ public sealed class ProfileReportScheduleSecurityTests private const int ScheduleId = 410; private const string UserId = "report-owner"; private Mock _tasks; + private Mock _authorization; private ProfileController _controller; private IHttpContextAccessor _previousAccessor; @@ -44,8 +45,10 @@ public void SetUp() http.Request.Method = "POST"; ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; _tasks = new Mock(MockBehavior.Strict); + _authorization = new Mock(MockBehavior.Strict); + _authorization.Setup(a => a.CanUserEditProfileAsync(UserId, DepartmentId, UserId)).ReturnsAsync(true); _controller = new ProfileController( - departmentsService: null, usersService: null, authorizationService: null, + departmentsService: null, usersService: null, authorizationService: _authorization.Object, userProfileService: null, scheduledTasksService: _tasks.Object, certificationService: null, customStateService: null, imageService: null, appOptionsAccessor: null, emailService: null, userManager: null, signInManager: null, @@ -145,6 +148,45 @@ public async Task Generic_routes_preserve_staffing_schedule_mutations(string act _tasks.Invocations.Select(i => i.Method.Name).Should().Equal(nameof(IScheduledTasksService.GetScheduledTaskByIdAsync), MutationMethod(action)); } + private static IEnumerable RejectedStaffingTargets() + { + foreach (var action in new[] { nameof(ProfileController.ActivateSchedule), nameof(ProfileController.DeactivateSchedule), nameof(ProfileController.DeleteSchedule) }) + foreach (var target in new[] { "missing", "department", "owner", "empty-owner", "task-type" }) + yield return new TestCaseData(action, target); + } + + [TestCaseSource(nameof(RejectedStaffingTargets))] + public async Task Staffing_mutations_reject_inaccessible_schedules(string action, string target) + { + var schedule = OwnedReport(); schedule.TaskType = (int)TaskTypes.UserStaffingLevel; + if (target == "missing") schedule = null; + if (target == "department") schedule.DepartmentId++; + if (target == "empty-owner") schedule.UserId = ""; + if (target == "task-type") schedule.TaskType = -1; + if (target == "owner") + { + schedule.UserId = "another-member"; + _authorization.Setup(a => a.CanUserEditProfileAsync(UserId, DepartmentId, schedule.UserId)).ReturnsAsync(false); + } + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(schedule); + (await InvokeAsync(action)).Should().BeOfType(); + VerifyNoMutation(); + } + + [TestCase(nameof(ProfileController.ActivateSchedule))] + [TestCase(nameof(ProfileController.DeactivateSchedule))] + [TestCase(nameof(ProfileController.DeleteSchedule))] + public async Task Staffing_mutations_preserve_delegated_member_management(string action) + { + var schedule = OwnedReport(); schedule.TaskType = (int)TaskTypes.UserStaffingLevel; schedule.UserId = "managed-member"; + _authorization.Setup(a => a.CanUserEditProfileAsync(UserId, DepartmentId, schedule.UserId)).ReturnsAsync(true); + _tasks.Setup(s => s.GetScheduledTaskByIdAsync(ScheduleId)).ReturnsAsync(schedule); + ExpectMutation(action, schedule, CancellationToken.None); + (await InvokeAsync(action)).Should().BeOfType(); + _authorization.Verify(a => a.CanUserEditProfileAsync(UserId, DepartmentId, schedule.UserId), Times.Once); + _tasks.VerifyAll(); + } + private static ScheduledTask OwnedReport() => new() { ScheduledTaskId = ScheduleId, diff --git a/Tests/Resgrid.Tests/Web/inventory-pr507.test.cjs b/Tests/Resgrid.Tests/Web/inventory-pr507.test.cjs new file mode 100644 index 00000000..646e994e --- /dev/null +++ b/Tests/Resgrid.Tests/Web/inventory-pr507.test.cjs @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { playwright, launchOptions } = require('./browser-launch.cjs'); +const scripts = path.resolve(__dirname, '../../../Web/Resgrid.Web/wwwroot/js/app/internal'); + +(async () => { + const browser = await playwright().chromium.launch(launchOptions()); + try { + const page = await browser.newPage(); + await page.setContent(`
+ + + + + +
`); + await page.addScriptTag({ path: path.join(scripts, 'inventory/inventory-purchasing.js') }); + await page.locator('[name="Lines[0].LocationId"]').selectOption('chosen'); + await page.locator('[name="Lines[0].LotId"]').selectOption('lot-chosen'); + await page.locator('.m4-copy-line').click(); + assert.equal(await page.locator('[name="Lines[1].LocationId"]').inputValue(), 'chosen'); + assert.equal(await page.locator('[name="Lines[1].LotId"]').inputValue(), 'lot-chosen'); + assert.equal(await page.locator('[name="Lines[1].PurchaseOrderItemId"]').inputValue(), 'line-1'); + assert.equal(await page.locator('[name="Lines[1].Quantity"]').inputValue(), '1'); + assert.equal(await page.locator('[name="Lines[1].Asset.SerialNumber"]').inputValue(), ''); + } finally { await browser.close(); } + + const requests = []; let hidden = true, reloads = 0; + const $ = selector => ({ + ready: callback => callback(), on: () => {}, val: () => 'csrf', + prop: (name, value) => { if (selector === '#reporting-error' && name === 'hidden') hidden = value; }, + DataTable: () => ({ ajax: { reload: () => reloads++ } }) + }); + $.ajax = options => { + const request = { options, done(callback) { this.success = callback; return this; }, fail(callback) { this.failure = callback; return this; } }; + requests.push(request); return request; + }; + const context = { $, document: {}, resgrid: { absoluteBaseUrl: '' }, confirm: () => true }; + vm.runInNewContext(fs.readFileSync(path.join(scripts, 'profile/resgrid.profile.reporting.js'), 'utf8'), context); + for (const action of ['activateSchedule', 'deactivateSchedule', 'deleteSchedule']) { + context.resgrid.profile.reporting[action](42); + const request = requests.at(-1); + assert.equal(request.options.type, 'POST'); + assert.equal(request.options.data.__RequestVerificationToken, 'csrf'); + hidden = true; const previousReloads = reloads; + request.failure({ status: 403, responseText: '' }); + assert.equal(hidden, false); assert.equal(reloads, previousReloads); + request.success(); assert.equal(hidden, true); assert.equal(reloads, previousReloads + 1); + request.failure({ status: 401 }); assert.equal(hidden, true); + } + console.log('PR507 receipt copying and all three schedule failure handlers passed.'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/Tests/Resgrid.Tests/Web/work-orders-maintenance.test.cjs b/Tests/Resgrid.Tests/Web/work-orders-maintenance.test.cjs new file mode 100644 index 00000000..d5734e99 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/work-orders-maintenance.test.cjs @@ -0,0 +1,55 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const launch = require('./browser-launch.cjs'); +(async () => { + const browser = await launch.playwright().chromium.launch(launch.launchOptions()); + try { + const page = await browser.newPage(); const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const pickers = ['item','location','lot','asset'].map(kind => ``).join(''); + await page.setContent(`
+
+ +
+ +
${pickers}
+ `); + await page.evaluate(() => { + window.$ = f => f(); window.requests = []; window.navigations = []; + window.resgridAdpReveal = { bindForm() {}, applyGrantHeader(h) { h.set('X-Test-Grant', 'synthetic-grant'); } }; + window.fetch = async (url, options) => { + const data = Object.fromEntries(options.body); requests.push({data, cache:options.cache, credentials:options.credentials, grant:options.headers?.get('X-Test-Grant')}); + if (url.endsWith('/save')) return {ok:true,json:async()=>({id:42})}; + if (data.kind === 'asset') return new Promise(resolve => { window.completeChoices = () => resolve({ok:true,json:async()=>({items:[{id:'late',name:'Private late result'}],hasMore:false})}); }); + return {ok:true,json:async()=>({items:[{id:'item-'+data.page,name:''}],hasMore:data.page==='0'})}; + }; + 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('#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(); + assert.equal(await page.locator('#maintenance-weekdays').inputValue(),'34'); + await page.click('button[data-kind=item]'); await page.click('button[data-kind=item]'); + assert.equal(await page.locator('select[data-kind=item] option').count(),3); + assert.equal(await page.locator('button[data-kind=item]').isDisabled(),true); + assert.equal(await page.locator('.work-order-inventory img').count(),0); + assert.equal(await page.evaluate(()=>window.xss),undefined); + const request = await page.evaluate(()=>requests[0]); + assert.equal(request.data.__ResgridProtectedGrant,'synthetic-grant'); assert.equal(request.data.__RequestVerificationToken,'synthetic-csrf'); + assert.equal(request.cache,'no-store'); assert.equal(request.credentials,'same-origin'); assert.equal(request.grant,'synthetic-grant'); + await page.selectOption('select[data-kind=item]','item-0'); await page.click('button[data-kind=lot]'); + await page.selectOption('select[data-kind=item]','item-1'); + assert.equal(await page.locator('select[data-kind=lot] option').count(),1); + assert.equal(await page.locator('button[data-kind=lot]').getAttribute('data-page'),'0'); + await page.locator('[name="Input.Template.Content.Steps[0].Text"]').fill('Inspection'); + await page.click('button[type=submit]'); await page.waitForFunction(()=>navigations.length===1); + assert.equal(await page.evaluate(()=>navigations[0].destination),'Recurrence'); + await page.click('button[data-kind=asset]'); + await page.waitForFunction(()=>typeof completeChoices==='function'); + await page.evaluate(()=>{window.resgridAdpPageConcealed();window.completeChoices();}); + assert.equal(await page.locator('.work-order-protected option').count(),0); + assert.deepEqual(errors,[]); + console.log('PASS: PM field prefixes, weekday mask, paged inventory choices, grant/CSRF propagation, safe labels, dependent resets and expiry concealment.'); + } finally { await browser.close(); } +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs index 29f0844a..8372f8be 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInventoryController.cs @@ -78,7 +78,8 @@ private async Task ExecuteUsage(string recordId, RmsRecordKind var usage = await action(); string evidenceId = null; try { evidenceId = (await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = DepartmentId, RecordId = recordId, RecordKind = kind, Kind = RmsEvidenceKind.InventoryUsage, CapturedByUserId = UserId, CaptureReason = "Officer recorded inventory usage or correction", OriginClient = RmsOriginClient.Api }, true, ct)).RmsEvidenceArtifactId; } - catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { } + catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) + { Resgrid.Framework.Logging.LogError($"Record inventory evidence capture failed for department {DepartmentId}: {ex.GetType().FullName}."); } return await Allowed(recordId) ? StatusCode(201, new { usage, evidenceId, evidenceCaptureRequired = evidenceId == null }) : Forbid(); } catch (RecordConcurrencyException) { return Conflict(new { error = "The draft changed. Reload its version and recorded usage before retrying." }); } @@ -100,7 +101,8 @@ public async Task Consume(ConsumeInput input,CancellationToken ca : await _usage.ConsumeAsync(DepartmentId,UserId,input.RecordId,input.Kind,input.ExpectedRowVersion,input.TypeId,input.GroupId,input.UnitId,input.Quantity,input.Note,cancellationToken,grant); string evidenceId=null; try { evidenceId=(await _evidence.CaptureAsync(new RecordEvidenceCaptureRequest {DepartmentId=DepartmentId,RecordId=input.RecordId,RecordKind=input.Kind,Kind=RmsEvidenceKind.InventoryUsage,CapturedByUserId=UserId,CaptureReason="Officer recorded inventory consumption",OriginClient=RmsOriginClient.Api},true,cancellationToken)).RmsEvidenceArtifactId; } - catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { } + catch(Exception ex) when(ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) + { Resgrid.Framework.Logging.LogError($"Record inventory evidence capture failed for department {DepartmentId}: {ex.GetType().FullName}."); } if (!await Allowed(input.RecordId)) return Forbid(); return StatusCode(201,new {usage,evidenceId,evidenceCaptureRequired=evidenceId==null}); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs new file mode 100644 index 00000000..6766a98d --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrderMaintenanceController.cs @@ -0,0 +1,33 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.WorkOrders; +namespace Resgrid.Web.Services.Controllers.v4 +{ + public sealed class WorkOrderMaintenanceCommand { public int Id { get; set; } public T Input { get; set; } } + public sealed class WorkOrderCancelPartWitness { public int Id { get; set; } public int PartId { get; set; } public int Revision { get; set; } public string Reason { get; set; } } + public sealed partial class WorkOrdersController + { + private void MaintenanceAvailable() { if (_maintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); } + [HttpPost("CancelWorkOrderPartWitness")] + public async Task CancelWorkOrderPartWitness([FromBody] WorkOrderCancelPartWitness command) { MaintenanceAvailable(); Required(command); await _maintenance.CancelPartWitnessAsync(Actor, command.Id, command.PartId, command.Revision, command.Reason); return Reply(await _orders.GetAsync(Actor, command.Id)); } + [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)); } + [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")] + public async Task ReleaseWorkOrderHold([FromBody] WorkOrderMaintenanceCommand command) { MaintenanceAvailable(); Required(command); await _maintenance.ReleaseHoldAsync(Actor, command.Id, Required(command.Input)); return Reply(new { Released = true }); } + [HttpGet("GetWorkOrderRecurrences")] + public async Task GetWorkOrderRecurrences(int page = 0) { MaintenanceAvailable(); var rows = await _maintenance.RecurrencesAsync(Actor, page); return Reply(rows.Take(50), System.Math.Min(rows.Count, 50), rows.Count > 50); } + [HttpGet("GetWorkOrderRecurrence")] + public async Task GetWorkOrderRecurrence(int id, int historyPage = 0) { MaintenanceAvailable(); return Reply(await _maintenance.RecurrenceAsync(Actor, id, historyPage)); } + [HttpPost("SaveWorkOrderRecurrence")] + public async Task SaveWorkOrderRecurrence([FromBody] WorkOrderRecurrenceInput input) { MaintenanceAvailable(); var id = await _maintenance.SaveRecurrenceAsync(Actor, Required(input)); return Reply(await _maintenance.RecurrenceAsync(Actor, id)); } + [HttpPost("RecordWorkOrderReading")] + public async Task RecordWorkOrderReading([FromBody] WorkOrderMaintenanceCommand command) { MaintenanceAvailable(); Required(command); await _maintenance.RecordReadingAsync(Actor, command.Id, Required(command.Input)); return Reply(await _maintenance.RecurrenceAsync(Actor, command.Id)); } + [HttpPost("DeferWorkOrder")] + public async Task DeferWorkOrder([FromBody] WorkOrderMaintenanceCommand command) { MaintenanceAvailable(); Required(command); await _maintenance.DeferAsync(Actor, command.Id, Required(command.Input)); return Reply(await _orders.GetAsync(Actor, command.Id)); } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs index 30518811..270de5b4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WorkOrdersController.cs @@ -15,11 +15,12 @@ namespace Resgrid.Web.Services.Controllers.v4 { [Route("api/v{VersionId:apiVersion}/[controller]"), ApiVersion("4.0"), ApiExplorerSettings(GroupName = "v4"), Authorize] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] - public sealed class WorkOrdersController : V4AuthenticatedApiControllerbase, IAsyncActionFilter + public sealed partial class WorkOrdersController : V4AuthenticatedApiControllerbase, IAsyncActionFilter { private readonly IWorkOrdersService _orders; + private readonly IWorkOrderMaintenanceService _maintenance; private readonly IStringLocalizer _strings; - public WorkOrdersController(IWorkOrdersService orders, IStringLocalizer strings) { _orders = orders; _strings = strings; } + public WorkOrdersController(IWorkOrdersService orders, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null) { _orders = orders; _strings = strings; _maintenance = maintenance; } 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"); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs index 5cf753cc..de01a169 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs @@ -32,10 +32,11 @@ private async Task RequireInventoryReportsAsync() } [HttpGet] - public async Task Operations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null) + public async Task Operations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null, InventoryReportKind? kind = null) { if (tab is not ("Counts" or "Alerts" or "Reports") || page < 0 || page > 10000) throw new InventoryException(400, "InvalidPage"); - var view = new InventoryWorkspaceView { Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId, + if (kind.HasValue && !Enum.IsDefined(kind.Value)) throw new InventoryException(400, "InvalidInput"); + var view = new InventoryWorkspaceView { Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId, ReportKind = kind ?? InventoryReportKind.OnHand, Migrated = await _migration.IsMigratedAsync(DepartmentId), CanViewReports = await MayViewInventoryReportsAsync() }; var groupId = (await _groups.GetGroupForUserAsync(UserId, DepartmentId))?.DepartmentGroupId; view.CanWrite = await MayWriteAsync(PermissionTypes.AdjustInventory, groupId); @@ -70,13 +71,13 @@ public async Task Operations(string tab = "Counts", int page = 0, catch (InventoryException error) when (error.Code == "ProtectedDataRequired" && HttpMethods.IsGet(Request.Method)) { return View("Operations", new InventoryWorkspaceView { Locked = true, Migrated = view.Migrated, CanWrite = view.CanWrite, - CanViewReports = view.CanViewReports, Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId }); + CanViewReports = view.CanViewReports, Tab = tab, Page = page, Id = id, ItemId = itemId, LocationId = locationId, ReportKind = view.ReportKind }); } } [HttpPost, ValidateAntiForgeryToken] - public Task ReopenOperations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null) - => Operations(tab, page, id, itemId, locationId); + public Task ReopenOperations(string tab = "Counts", int page = 0, string id = null, string itemId = null, string locationId = null, InventoryReportKind? kind = null) + => Operations(tab, page, id, itemId, locationId, kind); [HttpPost, ValidateAntiForgeryToken] public async Task GetCounts(int page = 0, string locationId = null) => Json(await _catalog.QueryAsync(Actor, new InventoryQuery { LocationId = locationId }, page)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index ee4dc55e..5bcee012 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -779,7 +779,7 @@ public async Task EditStaffingSchedule(EditStaffingLevelView mod public async Task DeactivateSchedule(int scheduleId, CancellationToken cancellationToken) { var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); - if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); + if (!await CanManageStaffingScheduleAsync(task)) return NotFound(); await _scheduledTasksService.DisabledScheduledTaskByIdAsync(scheduleId, cancellationToken); return new EmptyResult(); } @@ -789,7 +789,7 @@ public async Task DeactivateSchedule(int scheduleId, Cancellatio public async Task ActivateSchedule(int scheduleId, CancellationToken cancellationToken) { var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); - if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); + if (!await CanManageStaffingScheduleAsync(task)) return NotFound(); await _scheduledTasksService.EnableScheduledTaskByIdAsync(scheduleId, cancellationToken); return new EmptyResult(); } @@ -799,10 +799,16 @@ public async Task ActivateSchedule(int scheduleId, CancellationT public async Task DeleteSchedule(int scheduleId, CancellationToken cancellationToken) { var task = await _scheduledTasksService.GetScheduledTaskByIdAsync(scheduleId); - if (task?.TaskType == (int)TaskTypes.ReportDelivery) return NotFound(); + if (!await CanManageStaffingScheduleAsync(task)) return NotFound(); await _scheduledTasksService.DeleteScheduledTask(scheduleId, cancellationToken); return new EmptyResult(); } + private Task CanManageStaffingScheduleAsync(ScheduledTask task) + { + if (task == null || task.DepartmentId != DepartmentId || task.TaskType != (int)TaskTypes.UserStaffingLevel || string.IsNullOrWhiteSpace(task.UserId)) + return Task.FromResult(false); + return _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, task.UserId); + } #endregion Staffing Schedules #region Certifications diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs index 6654b261..b6ecd48e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsInventoryController.cs @@ -147,6 +147,7 @@ private async Task CaptureSavedUsageAsync(string recordId, RmsRecordKind } catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException || ex is UnauthorizedAccessException || ex is InventoryException) { + Resgrid.Framework.Logging.LogError($"Record inventory evidence capture failed for department {DepartmentId}: {ex.GetType().FullName}."); return Text("RecordUsageEvidencePending", "The inventory action was saved. Evidence could not be captured; use Refresh evidence after resolving access or draft changes. Do not submit the inventory action again."); } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs new file mode 100644 index 00000000..904d4f76 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrderMaintenanceController.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.Linq; +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 + { + private void MaintenanceAvailable() { if (_maintenance == null) throw new WorkOrderException(503, "MaintenanceUnavailable"); } + [HttpPost, ValidateAntiForgeryToken] + public async Task CancelPartWitness(int id, int partId, int revision, string reason) { MaintenanceAvailable(); await _maintenance.CancelPartWitnessAsync(Actor, id, partId, revision, reason); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task PartChoices(string kind, string itemId, int page = 0) { MaintenanceAvailable(); return Json(await _maintenance.InventoryChoicesAsync(Actor, kind, itemId, page)); } + [HttpGet] + public async Task Recurrences(int page = 0) + { + MaintenanceAvailable(); + return View("Recurrences", new WorkOrderRecurrencePageView { Rows = await _maintenance.RecurrencesAsync(Actor, page), Page = page, CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); + } + [HttpGet] + public async Task NewRecurrence() + { + MaintenanceAvailable(); + if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); + return View("EditRecurrence", new WorkOrderRecurrenceEditView { Input = new WorkOrderRecurrenceInput { AnchorLocal = DateTime.UtcNow.Date.AddDays(1).AddHours(9), Template = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D"), Type = WorkOrderType.Preventive } }, Choices = await _orders.ChoicesAsync(Actor) }); + } + [HttpGet] + public async Task EditRecurrence(int id) + { + MaintenanceAvailable(); + if (!await _access.CanUseMaintenanceAsync(DepartmentId)) throw new WorkOrderException(402, "ReadinessProRequired"); + return View("EditRecurrence", new WorkOrderRecurrenceEditView { Input = (await _maintenance.RecurrenceAsync(Actor, id)).Settings, Choices = await _orders.ChoicesAsync(Actor) }); + } + [HttpGet] + public async Task Recurrence(int id, int historyPage = 0) + { + MaintenanceAvailable(); return View("Recurrence", new WorkOrderRecurrenceDetailView { Detail = await _maintenance.RecurrenceAsync(Actor, id, historyPage), CanWrite = await _access.CanUseMaintenanceAsync(DepartmentId) }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveRecurrence(WorkOrderRecurrenceInput input, string serviceStart, string serviceEnd) + { + MaintenanceAvailable(); + if (!TimeOnly.TryParse(serviceStart, CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) || !TimeOnly.TryParse(serviceEnd, CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) throw new WorkOrderException(400, "RecurrenceInvalid"); + input.ServiceStartMinute = start.Hour * 60 + start.Minute; input.ServiceEndMinute = end.Hour * 60 + end.Minute; + return Json(new { id = await _maintenance.SaveRecurrenceAsync(Actor, input) }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task Reading(int id, WorkOrderReadingInput input) { MaintenanceAvailable(); await _maintenance.RecordReadingAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task Hold(int id, WorkOrderHoldInput input) { MaintenanceAvailable(); await _maintenance.AddHoldAsync(Actor, id, input); return Json(new { id }); } + [HttpPost, ValidateAntiForgeryToken] + public async Task ReleaseHold(int id, int holdId, WorkOrderReleaseInput input) + { + MaintenanceAvailable(); + if (!(await _maintenance.HoldsAsync(Actor, id)).Any(h => h.Hold.Id == holdId)) throw new WorkOrderException(404, "Unavailable"); + await _maintenance.ReleaseHoldAsync(Actor, holdId, input); return Json(new { id }); + } + [HttpPost, ValidateAntiForgeryToken] + public async Task Defer(int id, WorkOrderDeferralInput input) { MaintenanceAvailable(); await _maintenance.DeferAsync(Actor, id, input); return Json(new { id }); } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs index 8acb0d59..acf5ce05 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkOrdersController.cs @@ -18,17 +18,18 @@ namespace Resgrid.Web.Areas.User.Controllers { [WorkOrderFormCulture] [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(1024 * 1024)] - public sealed class WorkOrdersController : SecureBaseController + public sealed partial class WorkOrdersController : SecureBaseController { private readonly IWorkOrdersService _orders; + private readonly IWorkOrderMaintenanceService _maintenance; 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) - { _orders = orders; _authorization = authorization; _access = access; _grant = grant; _protection = protection; _strings = strings; } + IDepartmentDataProtectionService protection, IStringLocalizer strings, IWorkOrderMaintenanceService maintenance = null) + { _orders = orders; _authorization = authorization; _access = access; _grant = grant; _protection = protection; _strings = strings; _maintenance = maintenance; } private ChecklistActor Actor => new ChecklistActor { DepartmentId = DepartmentId, UserId = UserId, GrantToken = _grant.GrantToken }; public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { @@ -57,7 +58,7 @@ public async Task New() return View("Edit", new WorkOrderEditView { Input = new WorkOrderInput { RequestId = Guid.NewGuid().ToString("D") }, Choices = await _orders.ChoicesAsync(Actor), CanManage = await _authorization.CanManageAsync(Actor, null) }); } [HttpGet] - public async Task Detail(int id) => View("Detail", new WorkOrderDetailView { Detail = await _orders.GetAsync(Actor, id), Choices = await _orders.ChoicesAsync(Actor) }); + public async Task Detail(int id) { var detail = await _orders.GetAsync(Actor, id); return View("Detail", new WorkOrderDetailView { Detail = detail, Choices = await _orders.ChoicesAsync(Actor), Holds = _maintenance == null ? new() : await _maintenance.HoldsAsync(Actor, id), CanRelease = await _authorization.CanManageAsync(Actor, detail.Order.GroupId) }); } [HttpGet] public async Task Edit(int id) { @@ -96,6 +97,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(), "Index" => Index(filter ?? new WorkOrderFilter()), _ => Task.FromResult(BadRequest()) }; + 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()) }; } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs index ae95cbca..f7c6fa25 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Inventory/InventoryWorkspaceView.cs @@ -38,6 +38,7 @@ public sealed class InventoryWorkspaceView public InventoryValuation Valuation { get; set; } public InventoryCountDetail CountDetail { get; set; } public bool CanViewReports { get; set; } + public InventoryReportKind ReportKind { get; set; } = InventoryReportKind.OnHand; public static T Details(InventoryRow row) where T : new() => string.IsNullOrEmpty(row?.Content) ? new T() : JsonConvert.DeserializeObject(row.Content) ?? new T(); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs index 4d9d4c12..e7c7ed74 100644 --- a/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/WorkOrders/WorkOrderViews.cs @@ -4,6 +4,9 @@ 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 WorkOrderDetail Detail { get; set; } public WorkOrderChoices Choices { 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 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/Checklists/CompletionDetail.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml index fdbffda6..293ed398 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/CompletionDetail.cshtml @@ -14,6 +14,7 @@
@localizer["Version"]
@Model.VersionNumber
@if (run.WitnessedOn.HasValue) {
@localizer["WitnessLabel"]
@run.WitnessUserId · @run.WitnessedOn.Value.ToString("u")
@localizer["Attestation"]
@Model.WitnessAttestation
} + @if (Model.Form.Sections.SelectMany(s => s.Items).Any(i => i.CreateWorkOrderOnFail)) { @localizer["RelatedWorkOrders"] } @foreach (var checklistSection in Model.Form.Sections) {

@checklistSection.Name

diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml index 4524f29f..48b48a68 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Operations.cshtml @@ -18,6 +18,7 @@ @if (protectedData) { @await Html.PartialAsync("_AdpRevealBanner", new AdpRevealView { BannerTitle = localizer["ProtectedDataRequired"].Value }) }
+ @Html.AntiForgeryToken() @if (Model.Locked) { }
@@ -88,7 +89,7 @@

@localizer["M5ReportHelp"]

@Html.AntiForgeryToken() -
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml index da8cb63a..9538f32d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Workspace.cshtml @@ -72,7 +72,9 @@ var amount = row switch { InventoryStock s => s.Quantity.ToString(CultureInfo.CurrentCulture), InventoryIssuance i => (i.Quantity - i.ReturnedQuantity).ToString(CultureInfo.CurrentCulture), InventoryTransaction t => t.Quantity.ToString(CultureInfo.CurrentCulture), _ => "" }; @title@row.Id@location@amount@(row is InventoryAsset asset ? localizer["Status" + asset.Status] : row is InventoryIssuance issued ? localizer["IssuanceStatus" + issued.Status] : "") @if (row is InventoryAsset) { @Html.AntiForgeryToken()
} - @if (row is InventoryTransaction txn) {
@localizer["Details"]

@(InventoryWorkspaceView.Details(txn).Note)

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

} + @if (row is InventoryTransaction txn) {
@localizer["Details"]

@(InventoryWorkspaceView.Details(txn).Note)

@localizer["Reference"]: @txn.ReferenceType / @txn.ReferenceId + @if (txn.ReferenceType == (int)InventoryReferenceType.WorkOrder && int.TryParse(txn.ReferenceId, out var orderId) && orderId > 0) { @localizer["Details"] } +

} @if (Model.CanWrite && Model.Tab is "Items" or "Categories" or "Locations" or "Kits") {
@Html.AntiForgeryToken()
} @if (Model.CanIssue && row is InventoryIssuance outstanding && outstanding.Status is 0 or 2) {
@localizer["Return"]
@Html.AntiForgeryToken()@Field("ToLocationId", "ToLocation", null, options: locationOptions)@Field("Quantity", "Amount", outstanding.Quantity - outstanding.ReturnedQuantity, "number")@Field("Condition", "Status", 0, options: Codes("Status", 0, 2, 3))@Field("Note", "Note")
diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml index 66437c59..7bda2056 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/Reporting.cshtml @@ -36,6 +36,7 @@
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index 08f0f46d..9d36c117 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -201,7 +201,7 @@ @foreach (var kind in Enum.GetValues()) { - @Resgrid.Services.InventoryReportDocuments.Title(kind)@Resgrid.Services.InventoryReportDocuments.Text("M5ReportHelp") + @Resgrid.Services.InventoryReportDocuments.Title(kind)@Resgrid.Services.InventoryReportDocuments.Text("M5ReportHelp") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml index 77c8b471..437503e3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -39,8 +39,10 @@
} +@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

    @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.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)

    @@ -57,10 +59,11 @@ } -

    @localizer["Parts"]

    @localizer["FreeTextPartsNotice"]

    -@foreach (var p in d.Parts) { } +

    @localizer["Parts"]

    @localizer["Description"]@localizer["Quantity"]@localizer["UnitCost"]@localizer["Actions"]
    @p.Content.Description@p.Content.Quantity@p.Content.UnitCost @c.Currency@if (p.VoidedOn.HasValue) { @localizer["Voided"]: @p.Content.VoidReason } else if (d.CanContribute) {
    @Html.AntiForgeryToken()
    }
    +@foreach (var p in d.Parts) { }
    @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()
    } +@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()
      }
    • }
    @if (d.CanContribute || d.CanWrite && o.Status == WorkOrderStatus.Requested && d.ReportedBy == Resgrid.Web.Helpers.ClaimsAuthorizationHelper.GetUserId()) {
    @Html.AntiForgeryToken()

    @localizer["FileHelp"]

    } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml index 6c267c71..f95b5784 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Edit.cshtml @@ -7,33 +7,7 @@ @await Html.PartialAsync("_Protection")
    @Html.AntiForgeryToken() -
    -
    -
    -
    -
    -
    -
    -@if (Model.Choices.Assets.Count > 0) {
    } -else { }
    -
    -
    -
    -@if (Model.CanManage) {
    }
    -
    -
    -
    - - -

    @localizer["SafetyNotice"]

    -
    -
    -
    -
    -
    -

    @localizer["TaskSteps"]

    -@for (var i = 0; i < Model.Input.Content.Steps.Count; i++) {
    } -
    +@await Html.PartialAsync("_OrderFields", Model.Input, new ViewDataDictionary(ViewData) { TemplateInfo = { HtmlFieldPrefix = "Input" }, ["Choices"] = Model.Choices, ["CanManage"] = Model.CanManage }) @localizer["Cancel"]
    @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml new file mode 100644 index 00000000..236d740a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml @@ -0,0 +1,39 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderRecurrenceEditView +@using Resgrid.Model.WorkOrders +@using System.Globalization +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["PreventiveMaintenance"]; } +
    +

    @localizer["PreventiveMaintenance"]

    @await Html.PartialAsync("_Protection") +
    +@Html.AntiForgeryToken() + +@await Html.PartialAsync("_OrderFields", Model.Input.Template, new ViewDataDictionary(ViewData) { TemplateInfo = { HtmlFieldPrefix = "Input.Template" }, ["Choices"] = Model.Choices, ["CanManage"] = true, ["Recurrence"] = true }) +

    @localizer["Schedule"]

    @localizer["RecurrenceHelp"]

    +
    +
    +
    +
    +
    + +

    @localizer["ServiceWindow"]

    +@for (var day = 0; day < 7; day++) { } +
    +
    +
    +

    @localizer["UsageAndCondition"]

    +
    +
    +
    +
    +
    +
    +

    @localizer["AssignmentAndEscalation"]

    + + + + + +@localizer["Cancel"] +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml index 8478fd61..7b9fc384 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Index.cshtml @@ -2,14 +2,14 @@ @using Resgrid.Model.WorkOrders @inject IStringLocalizer localizer @{ ViewBag.Title = "Resgrid | " + localizer["WorkOrders"]; } -

    @localizer["WorkOrders"]

    Readiness Pro

    +

    @localizer["WorkOrders"]

    @localizer["PreventiveMaintenance"]

    Readiness Pro

    @await Html.PartialAsync("_Protection") @if (Model.Orders.CanWrite) { @localizer["NewWorkOrder"] } else {

    @localizer["ReadOnlyHistory"] Readiness Pro

    }
    @Html.AntiForgeryToken() - + @@ -25,7 +25,7 @@ else {

    @localizer["ReadOnlyHistory"] }

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

    @localizer["NoWorkOrders"]

    } -@if (Model.Filter.Page > 0) {
    @localizer["Previous"] } +@if (Model.Filter.Page > 0) { @localizer["Previous"] } @if (Model.Orders.HasMore) { @localizer["Next"] }
    @section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml new file mode 100644 index 00000000..bd2286ac --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrence.cshtml @@ -0,0 +1,30 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderRecurrenceDetailView +@using Newtonsoft.Json.Linq +@inject IStringLocalizer localizer +@{ var row = Model.Detail.Schedule; var settings = Model.Detail.Settings; ViewBag.Title = "Resgrid | " + localizer["PreventiveMaintenance"]; } +
    +@await Html.PartialAsync("_Protection") +

    @settings.Template.Content.Title

    @settings.Template.Content.Description

    +

    @localizer["NextDue"]: @row.NextDueOn?.ToString("u") · @localizer[row.IsActive ? "Active" : "Paused"]

    +

    @localizer["MeterBaseline"]: @row.MeterBaseline · @localizer["MeterValue"]: @row.LastMeterValue · @localizer["MeterEpoch"]: @row.MeterEpoch

    +@localizer["PreventiveMaintenance"] +@if (Model.CanWrite) { @localizer["Edit"] } +@if (row.PendingWorkOrderId.HasValue) { @localizer["WorkOrders"] } +@if (Model.CanWrite && (row.MeterUnit != 0 || row.Condition != 0)) { +

    @localizer["RecordReading"]

    + +@Html.AntiForgeryToken() + +@if (row.MeterUnit != 0) { } +@if (row.Condition != 0) { } + + +} +

    @localizer["Readings"]

    +@foreach (var reading in Model.Detail.Readings) { var c = JObject.Parse(reading.Content ?? "{}"); } +
    @localizer["ObservedOn"]@localizer["MeterValue"]@localizer["ConditionValue"]@localizer["ReadingSource"]@localizer["Note"]
    @reading.ObservedOn.ToString("u")@c["MeterValue"]@c["ConditionValue"]@c["Source"]@c["Note"]
    +

    @localizer["Activity"]

      @foreach (var change in Model.Detail.Changes) { var c = JObject.Parse(change.Content ?? "{}");
    1. @change.CreatedOn.ToString("u") · @localizer["MaintenanceChange" + change.ChangeType]

      @c["Note"]

      @change.OriginalDueOn?.ToString("u") → @change.RevisedDueOn?.ToString("u")

    2. }
    +@if (Model.Detail.HistoryPage > 0) { @localizer["Previous"] } +@if (Model.Detail.HasMoreHistory) { @localizer["Next"] } +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml new file mode 100644 index 00000000..3fc55a5f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Recurrences.cshtml @@ -0,0 +1,17 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderRecurrencePageView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ ViewBag.Title = "Resgrid | " + localizer["PreventiveMaintenance"]; } +
    +

    @localizer["PreventiveMaintenance"]

    +@await Html.PartialAsync("_Protection") +
    +@localizer["WorkOrders"] +@if (Model.CanWrite) { @localizer["NewRecurrence"] } + +@foreach (var entry in Model.Rows.Take(50)) { } +
    @localizer["Title"]@localizer["NextDue"]@localizer["Active"]@localizer["Actions"]
    @entry.Settings.Template.Content.Title@entry.Schedule.NextDueOn?.ToString("u")@localizer[entry.Schedule.IsActive ? "Active" : "Paused"]@if (entry.Schedule.PendingWorkOrderId.HasValue) { @localizer["WorkOrders"] }
    +@if (Model.Page > 0) { @localizer["Previous"] } +@if (Model.Rows.Count > 50) { @localizer["Next"] } +
    +@section Scripts { @await Html.PartialAsync("_Scripts") } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml index 70bbe1d1..a251a335 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_FilterFields.cshtml @@ -1,4 +1,5 @@ @model Resgrid.Model.WorkOrders.WorkOrderFilter + diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml new file mode 100644 index 00000000..4aca9767 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_Maintenance.cshtml @@ -0,0 +1,35 @@ +@model Resgrid.Web.Areas.User.Models.WorkOrders.WorkOrderDetailView +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ var d = Model.Detail; var id = d.Order.Id; } +@if (d.SourceChecklistCompletionId != null) {

    @localizer["SourceChecklist"]

    } +@if (d.RecurrenceId != null) {

    @localizer["PreventiveMaintenance"]

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

    @localizer["OriginalDue"]: @d.OriginalDueOn.Value.ToString("u")

    } +

    @localizer["SafetyHolds"]

    @localizer["HoldReleaseHelp"]

    + +@foreach (var entry in Model.Holds) { + var h = entry.Hold; + + + +} +
    @localizer["Target"]@localizer["Reason"]@localizer["Status"]@localizer["Actions"]
    @localizer[h.UnitId.HasValue ? "Unit" : "Asset"]: @(h.UnitId?.ToString() ?? h.AssetId)@entry.Content.Reason@localizer[h.ReleasedOn.HasValue ? "ActivitySafetyReleased" : "ActivitySafetyHold"] @if (h.ReleasedOn.HasValue) { · @localizer[h.StateRestored ? "StateRestored" : "StatePreserved"]

    @entry.Content.ReleaseEvidence

    @entry.Content.Qualification

    }
    @if (!h.ReleasedOn.HasValue && Model.CanRelease) { +
    @Html.AntiForgeryToken() + + + + +
    + }
    +@if (d.CanManage && d.Order.Status is not (WorkOrderStatus.Closed or WorkOrderStatus.Cancelled or WorkOrderStatus.Rejected or WorkOrderStatus.Duplicate)) { +@if (d.Order.UnitId.HasValue || d.Order.AssetId != null) { +
    @Html.AntiForgeryToken() +@if (d.Order.UnitId.HasValue) { } +@if (d.Order.AssetId != null) { } +
    +} +@if (d.Order.Status != WorkOrderStatus.Completed) { +
    @Html.AntiForgeryToken() +
    +} +} diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml new file mode 100644 index 00000000..57aa33a6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_OrderFields.cshtml @@ -0,0 +1,32 @@ +@model Resgrid.Model.WorkOrders.WorkOrderInput +@using Resgrid.Model.WorkOrders +@inject IStringLocalizer localizer +@{ var choices = (WorkOrderChoices)ViewData["Choices"]; var canManage = (bool)ViewData["CanManage"]; } +
    +
    +
    +@if (ViewData["Recurrence"] as bool? != true) {
    } +
    +@if (ViewData["Recurrence"] as bool? != true) {
    }
    +
    +
    +@if (choices.Assets.Count > 0) {
    } +else { }
    +
    +
    +
    +@if (canManage) {
    }
    +
    +
    +
    + + +

    @localizer["SafetyNotice"]

    +
    +
    +
    +
    +
    +

    @localizer["TaskSteps"]

    +@for (var i = 0; i < Model.Content.Steps.Count; i++) {
    } +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_PartInventory.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_PartInventory.cshtml new file mode 100644 index 00000000..d4b6f6b8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/_PartInventory.cshtml @@ -0,0 +1,9 @@ +@inject IStringLocalizer localizer +
    +@localizer["InventoryParts"]

    @localizer["InventoryPartsHelp"]

    + +@foreach (var choice in new[] { ("item", "InventoryItemId", "InventoryItem"), ("location", "InventoryLocationId", "InventoryLocation"), ("lot", "InventoryLotId", "InventoryLot"), ("asset", "InventoryAssetId", "Asset") }) { + + +} +
    diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js index 96f2e91d..2c235ce4 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/checklists/checklists.js @@ -61,7 +61,7 @@ field(root, 'Category', model, 'Category', 'number', categories.map((name, i) => [i, name])); const targets = [[0, 'Department'], [1, 'Unit'], [2, 'Group / station'], [3, 'Personnel']]; if (root.dataset.assetsAvailable === 'true' || model.TargetType === 5) targets.push([5, 'InventoryAsset']); - field(root, 'Target type', model, 'TargetType', 'number', targets); + field(root, 'Target type', model, 'TargetType', 'number', targets).addEventListener('change', () => { model.Sections.forEach(section => section.Items.forEach(item => { if (model.TargetType !== 1 && model.TargetType !== 5) item.SetUnitStateOnFail = false; if (model.TargetType !== 5) item.HoldAssetOnFail = false; })); render(); }); const threshold = field(root, 'Passing score (%)', model, 'PassThreshold', 'number'); threshold.min = 0; threshold.max = 100; field(root, 'Require reported location', model, 'RequireLocation', 'checkbox'); field(root, 'Require a different authenticated member to witness the submission', model, 'RequiresIndependentWitness', 'checkbox'); @@ -79,6 +79,14 @@ field(box, 'Item instructions', item, 'Instructions', 'textarea').maxLength = 5000; field(box, 'Answer type', item, 'Type', 'number', types.map((name, i) => [i, name])).addEventListener('change', render); [['Required', 'Required'], ['Critical failure overrides the score', 'Critical'], ['Allow N/A with a reason', 'AllowNotApplicable'], ['Require a note on failure', 'RequireNoteOnFail'], ['Require a photo on failure', 'RequirePhotoOnFail']].forEach(pair => field(box, pair[0], item, pair[1], 'checkbox')); + field(box, 'CreateWorkOrderOnFail', item, 'CreateWorkOrderOnFail', 'checkbox').addEventListener('change', () => { if (!item.CreateWorkOrderOnFail) { item.SetUnitStateOnFail = false; item.HoldAssetOnFail = false; } render(); }); + if (item.CreateWorkOrderOnFail) { + if (item.WorkOrderPriority == null) item.WorkOrderPriority = 1; + field(box, 'WorkOrderPriority', item, 'WorkOrderPriority', 'number', [[0, 'WorkOrderPriorityLow'], [1, 'WorkOrderPriorityNormal'], [2, 'WorkOrderPriorityHigh'], [3, 'WorkOrderPriorityEmergency']]); + if (model.TargetType === 1 || model.TargetType === 5) field(box, 'SetUnitStateOnFail', item, 'SetUnitStateOnFail', 'checkbox'); + if (model.TargetType === 5) field(box, 'HoldAssetOnFail', item, 'HoldAssetOnFail', 'checkbox'); + el('p', tr('ReadinessFailureHelp'), box); + } const weight = field(box, 'Score weight (0 excludes this item from the score)', item, 'Weight', 'number'); weight.min = 0; weight.max = 1000; if (item.Type === 1 || item.Type === 2) field(box, 'Passing answer', item, 'PassingValue', 'text', [['true', 'Yes / checked'], ['false', 'No / unchecked']]); if (item.Type === 3 || item.Type === 4) { diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js index a3332be4..874f0c30 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/inventory-purchasing.js @@ -16,7 +16,13 @@ const copy = event.target.closest('.m4-copy-line'); if (remove && lines.children.length > 1) remove.closest('.m4-line').remove(); if ((add || copy) && lines.children.length < 100) { - const row = (copy ? copy.closest('.m4-line') : lines.firstElementChild).cloneNode(true); + const source = copy ? copy.closest('.m4-line') : lines.firstElementChild; + const row = source.cloneNode(true); + if (copy && form.dataset.receipt === 'true') { + source.querySelectorAll('select[name]').forEach(field => { + row.querySelector('select[name="' + field.name + '"]').value = field.value; + }); + } row.querySelectorAll('[name]').forEach(field => { const label = field.id && row.querySelector('label[for="' + field.id + '"]'); if (field.id) { field.id = 'purchase-added-' + (++identity); if (label) label.htmlFor = field.id; } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.reporting.js b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.reporting.js index 8209e422..68a95202 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.reporting.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.reporting.js @@ -49,7 +49,7 @@ var resgrid; __RequestVerificationToken: $('#reporting-antiforgery input[name="__RequestVerificationToken"]').val() }, type: 'POST' - }).done(function () { refreshGrid(); }); + }).done(function () { refreshGrid(); }).fail(reportFailure); } reporting.activateSchedule = activateSchedule; function deactivateSchedule(scheduleId) { @@ -61,7 +61,7 @@ var resgrid; __RequestVerificationToken: $('#reporting-antiforgery input[name="__RequestVerificationToken"]').val() }, type: 'POST' - }).done(function () { refreshGrid(); }); + }).done(function () { refreshGrid(); }).fail(reportFailure); } reporting.deactivateSchedule = deactivateSchedule; function deleteSchedule(scheduleId) { @@ -74,16 +74,20 @@ var resgrid; __RequestVerificationToken: $('#reporting-antiforgery input[name="__RequestVerificationToken"]').val() }, type: 'POST' - }).done(function () { refreshGrid(); }); + }).done(function () { refreshGrid(); }).fail(reportFailure); } } reporting.deleteSchedule = deleteSchedule; function refreshGrid() { + $('#reporting-error').prop('hidden', true); if (reportingTable) { reportingTable.ajax.reload(); } } reporting.refreshGrid = refreshGrid; + function reportFailure(xhr) { + if (xhr.status !== 401) $('#reporting-error').prop('hidden', false); + } })(reporting = profile.reporting || (profile.reporting = {})); })(profile = resgrid.profile || (resgrid.profile = {})); })(resgrid || (resgrid = {})); 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 089c8218..5b34a85d 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 @@ -15,7 +15,9 @@ } }); config.grant = null; configNode.textContent = ''; + var concealed = false; window.resgridAdpPageConcealed = function () { + concealed = true; document.querySelectorAll('.work-order-protected').forEach(function (node) { node.replaceChildren(); }); window.location.replace(config.index); }; @@ -36,9 +38,10 @@ buttons.forEach(function (b) { b.disabled = true; }); var response = await fetch(form.action, { method: 'POST', body: new FormData(form), credentials: 'same-origin', cache: 'no-store' }); var result = await response.json(); + if (concealed || !form.isConnected) return; if (!response.ok || !Number.isInteger(result.id)) { showError(result.message); if (result.code === 'ProtectedDataRequired') form.dispatchEvent(new Event('adp:grant-required')); return; } var reopen = document.createElement('form'); reopen.method = 'POST'; reopen.action = config.reopen; - hidden(reopen, 'destination', 'Detail'); hidden(reopen, 'id', String(result.id)); + hidden(reopen, 'destination', form.dataset.destination || 'Detail'); hidden(reopen, 'id', String(result.id)); ['__RequestVerificationToken', '__ResgridProtectedGrant', '__ResgridProtectedGrantExpiresOn'].forEach(function (name) { var field = form.querySelector('input[name="' + name + '"]'); if (field) hidden(reopen, name, field.value); }); @@ -56,11 +59,50 @@ document.querySelectorAll('.work-order-user, .work-order-role').forEach(function (select) { select.addEventListener('change', function () { if (select.value) select.form.querySelector(select.classList.contains('work-order-user') ? '.work-order-role' : '.work-order-user').value = ''; }); }); + document.querySelectorAll('.maintenance-weekday').forEach(function (box) { box.addEventListener('change', function () { var mask = 0; document.querySelectorAll('.maintenance-weekday:checked').forEach(function (day) { mask |= 1 << Number(day.value); }); document.getElementById('maintenance-weekdays').value = String(mask); }); }); + document.querySelectorAll('.inventory-part-load').forEach(function (button) { + 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; + if ((kind === 'lot' || kind === 'asset') && !item) return; + var data = new FormData(); + ['__RequestVerificationToken', '__ResgridProtectedGrant', '__ResgridProtectedGrantExpiresOn'].forEach(function (name) { + var field = form.querySelector('input[name="' + name + '"]'); if (field) data.set(name, field.value); + }); + data.set('kind', kind); data.set('page', button.dataset.page || '0'); if (item) data.set('itemId', item); + var headers = new Headers(); if (window.resgridAdpReveal) window.resgridAdpReveal.applyGrantHeader(headers); + button.disabled = true; + try { + var response = await fetch(root.dataset.url, { method: 'POST', body: data, headers: headers, credentials: 'same-origin', cache: 'no-store' }); + var result = await response.json(); + if (concealed || !root.isConnected || ((kind === 'lot' || kind === 'asset') && root.querySelector('select[data-kind="item"]').value !== item)) return; + if (!response.ok) { showError(result.message); if (result.code === 'ProtectedDataRequired') form.dispatchEvent(new Event('adp:grant-required')); return; } + (result.items || result.Items || []).forEach(function (choice) { + var id = choice.id || choice.Id; + if (Array.from(select.options).some(function (option) { return option.value === id; })) return; + var option = document.createElement('option'); option.value = id; option.textContent = choice.name || choice.Name; select.appendChild(option); + }); + var more = result.hasMore === true || result.HasMore === true; + button.dataset.page = String(Number(button.dataset.page || '0') + 1); button.dataset.complete = more ? 'false' : 'true'; + } catch (_) { if (!concealed) showError(config.error); } + finally { button.disabled = button.dataset.complete === 'true'; } + }); + }); + document.querySelectorAll('.inventory-part-choice[data-kind="item"]').forEach(function (select) { + select.addEventListener('change', function () { + var root = select.closest('.work-order-inventory'); + ['asset', 'lot'].forEach(function (kind) { + var child = root.querySelector('select[data-kind="' + kind + '"]'); while (child.options.length > 1) child.remove(1); child.value = ''; + var load = root.querySelector('button[data-kind="' + kind + '"]'); load.dataset.page = '0'; load.dataset.complete = 'false'; load.disabled = false; + }); + }); + }); var add = document.getElementById('work-order-add-step'), steps = document.getElementById('work-order-steps'); if (add && steps) add.addEventListener('click', function () { var index = steps.children.length; if (index >= 100) return; var row = document.createElement('div'); row.className = 'form-group'; - var input = document.createElement('input'); input.name = 'Input.Content.Steps[' + index + '].Text'; input.className = 'form-control'; input.maxLength = 1000; input.required = true; + var input = document.createElement('input'); input.name = (add.closest('form')?.dataset.stepPrefix || 'Input.Content.Steps') + '[' + index + '].Text'; input.className = 'form-control'; input.maxLength = 1000; input.required = true; row.appendChild(input); steps.appendChild(row); input.focus(); }); }()); diff --git a/Workers/Resgrid.Workers.Console/Commands/MaintenanceEscalationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/MaintenanceEscalationCommand.cs new file mode 100644 index 00000000..0c829fbf --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/MaintenanceEscalationCommand.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class MaintenanceEscalationCommand : IQuidjiboCommand + { + public MaintenanceEscalationCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Commands/MaintenanceGenerationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/MaintenanceGenerationCommand.cs new file mode 100644 index 00000000..94d3878d --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/MaintenanceGenerationCommand.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class MaintenanceGenerationCommand : IQuidjiboCommand + { + public MaintenanceGenerationCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 70280b90..15f6ea8d 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -494,6 +494,8 @@ await Client.ScheduleAsync("Domain Event Outbox Dispatch", // Worker 63 is the readiness registry allocation. Department gating and pauses are evaluated by the service. await Client.ScheduleAsync("Checklist Scheduling", new Commands.ChecklistSchedulingCommand(63), Cron.MinuteIntervals(5), stoppingToken); await Client.ScheduleAsync("Checklist Reminders", new Commands.ChecklistReminderCommand(64), Cron.MinuteIntervals(5), stoppingToken); + await Client.ScheduleAsync("Maintenance Generation", new Commands.MaintenanceGenerationCommand(65), Cron.MinuteIntervals(5), stoppingToken); + await Client.ScheduleAsync("Maintenance Escalation", new Commands.MaintenanceEscalationCommand(66), Cron.MinuteIntervals(5), stoppingToken); await Client.ScheduleAsync("Inventory Alerts", new Commands.InventoryAlertsCommand(19), Cron.MinuteIntervals(15), stoppingToken); // Worker ID 44 (Identifier Allocation Registry section 3.3, the Unified Search allocation absorbed by RMS-1): records search index maintenance. This diff --git a/Workers/Resgrid.Workers.Console/Tasks/MaintenanceEscalationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/MaintenanceEscalationTask.cs new file mode 100644 index 00000000..219c7feb --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/MaintenanceEscalationTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class MaintenanceEscalationTask : IQuidjiboHandler + { + public string Name => "Maintenance Escalation"; + public int Priority => 1; + public async Task ProcessAsync(MaintenanceEscalationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new MaintenanceEscalationLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Tasks/MaintenanceGenerationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/MaintenanceGenerationTask.cs new file mode 100644 index 00000000..9ea392c3 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/MaintenanceGenerationTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class MaintenanceGenerationTask : IQuidjiboHandler + { + public string Name => "Maintenance Generation"; + public int Priority => 1; + public async Task ProcessAsync(MaintenanceGenerationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new MaintenanceGenerationLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs new file mode 100644 index 00000000..e6dd1450 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceEscalationLogic.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +namespace Resgrid.Workers.Framework.Logic +{ + public sealed class MaintenanceEscalationLogic + { + public async Task> Process(CancellationToken ct) + { + var errors = 0; var generated = 0; var escalated = 0; + try + { + var after = 0; + while (true) + { + using var listing = Bootstrapper.GetKernel().BeginLifetimeScope(); + var departments = await listing.Resolve().MaintenanceDepartmentsAsync(after); + foreach (var id in departments) + { + 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++; } + after = id; + } + if (departments.Count < 200) break; + } + 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."); } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs new file mode 100644 index 00000000..cf2a88ce --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/MaintenanceGenerationLogic.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +namespace Resgrid.Workers.Framework.Logic +{ + public sealed class MaintenanceGenerationLogic + { + public async Task> Process(CancellationToken ct) + { + var errors = 0; var generated = 0; var escalated = 0; + try + { + var after = 0; + while (true) + { + using var listing = Bootstrapper.GetKernel().BeginLifetimeScope(); + var departments = await listing.Resolve().MaintenanceDepartmentsAsync(after); + foreach (var id in departments) + { + 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++; } + after = id; + } + if (departments.Count < 200) break; + } + 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."); } + } + } +}