From 55b073a2dbfeae362919170de14cf7430fe883d4 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 19 Sep 2026 14:57:14 -0700 Subject: [PATCH] RG-T51 Deployments and Costing, Search Fix --- .../User/Deployments/Deployments.ar.resx | 261 ++++++ .../Areas/User/Deployments/Deployments.cs | 4 + .../User/Deployments/Deployments.de.resx | 261 ++++++ .../User/Deployments/Deployments.el.resx | 261 ++++++ .../User/Deployments/Deployments.en.resx | 261 ++++++ .../User/Deployments/Deployments.es.resx | 261 ++++++ .../User/Deployments/Deployments.fr.resx | 261 ++++++ .../User/Deployments/Deployments.it.resx | 261 ++++++ .../User/Deployments/Deployments.pl.resx | 261 ++++++ .../Areas/User/Deployments/Deployments.resx | 261 ++++++ .../User/Deployments/Deployments.sv.resx | 261 ++++++ .../User/Deployments/Deployments.uk.resx | 261 ++++++ Core/Resgrid.Model/AuditLogTypes.cs | 20 +- .../Certifications/CertificationModels.cs | 7 + Core/Resgrid.Model/FeatureFlagKeys.cs | 9 + .../Invoicing/DeploymentContracts.cs | 109 +++ .../Invoicing/DeploymentModels.cs | 474 +++++++++++ .../Invoicing/DeploymentPermissionCatalog.cs | 19 + Core/Resgrid.Model/Invoicing/Invoice.cs | 4 + .../Invoicing/InvoiceLineItem.cs | 3 + .../Invoicing/OnlinePaymentModels.cs | 5 +- Core/Resgrid.Model/PermissionTypes.cs | 18 +- .../Records/RecordsSearchContracts.cs | 5 + .../ICertificationRepositories.cs | 8 + .../Repositories/IDeploymentRepositories.cs | 78 ++ Core/Resgrid.Model/Search/SearchContracts.cs | 2 +- .../Search/UnifiedSearchContracts.cs | 10 +- .../Services/ICertificationService.cs | 8 + .../Services/IDeploymentService.cs | 56 ++ .../Services/IPersonnelRolesService.cs | 8 + .../Resgrid.Model/Services/ISearchServices.cs | 5 + .../Services/ITimeTrackingService.cs | 48 ++ .../WorkflowTemplateVariableCatalog.cs | 13 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 16 + Core/Resgrid.Search/GlobalIndexFields.cs | 1 + .../GlobalSearchDocumentBuilder.cs | 1 + .../LuceneGlobalSearchService.cs | 6 + .../LuceneRecordsSearchService.cs | Bin 7611 -> 8064 bytes Core/Resgrid.Services/AdpTableBindings.cs | 14 + .../BusinessOperationsAccessService.cs | 6 +- .../CertificationService.Sweep.cs | 9 +- Core/Resgrid.Services/CertificationService.cs | 22 +- .../Invoicing/DeploymentService.Documents.cs | 106 +++ .../Invoicing/DeploymentService.Protection.cs | 92 +++ .../Invoicing/DeploymentService.cs | 782 ++++++++++++++++++ .../Invoicing/TimeTrackingService.cs | 611 ++++++++++++++ .../Resgrid.Services/PersonnelRolesService.cs | 49 ++ .../Resgrid.Services/ProtectedFieldCatalog.cs | 10 + .../Search/SystemActionCatalog.cs | 6 + .../UnifiedSearchService.Authorization.cs | 196 +++++ .../Search/UnifiedSearchService.cs | 129 +-- Core/Resgrid.Services/ServicesModule.cs | 2 + .../WorkflowSampleDataGenerator.cs | 14 + .../WorkflowTemplateContextBuilder.cs | 17 + .../Resgrid.Providers.Claims/ClaimsLogic.cs | 10 +- .../ResgridClaimTypes.cs | 2 + .../ResgridResources.cs | 3 + .../Migrations/M0212_AddOnlinePayments.cs | 22 +- .../Migrations/M0213_AddCertificationTypes.cs | 8 +- .../Migrations/M0215_AddRateSchedules.cs | 116 +++ .../Migrations/M0216_AddServiceContracts.cs | 92 +++ .../Migrations/M0217_AddBids.cs | 92 +++ .../M0218_AddDeploymentsAndTimeTracking.cs | 252 ++++++ ...tendInvoicingAndAddCostRecoveryProfiles.cs | 318 +++++++ .../Migrations/M0212_AddOnlinePaymentsPg.cs | 22 +- .../M0213_AddCertificationTypesPg.cs | 8 +- .../Migrations/M0215_AddRateSchedulesPg.cs | 116 +++ .../Migrations/M0216_AddServiceContractsPg.cs | 92 +++ .../Migrations/M0217_AddBidsPg.cs | 92 +++ .../M0218_AddDeploymentsAndTimeTrackingPg.cs | 252 ++++++ ...ndInvoicingAndAddCostRecoveryProfilesPg.cs | 318 +++++++ .../CertificationRepositories.cs | 39 + .../ChecklistDepartmentCleanup.cs | 5 +- .../DeploymentRepositories.cs | 179 ++++ .../InvoicingRepositories.cs | 2 +- .../Modules/ApiDataModule.cs | 10 + .../Modules/DataModule.cs | 10 + .../Modules/NonWebDataModule.cs | 10 + .../Modules/TestingDataModule.cs | 10 + .../OnlinePaymentRepositories.cs | 13 +- .../RmsRepositories.cs | 10 + .../SearchRepositories.cs | 12 +- .../Allocations/trigger-baseline.json | 15 +- Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs | 13 + .../Rms/RmsIdentifierPinTests.cs | 13 +- .../Resgrid.Tests/Search/GlobalSearchTests.cs | 26 + .../Search/SearchControllerSecurityTests.cs | 152 ++++ .../Search/UnifiedSearchSecurityTests.cs | 294 +++++++ .../Search/UnifiedSearchServiceTests.cs | 76 +- .../Services/CertificationServiceTests.cs | 46 +- .../Services/DeploymentLocalizationTests.cs | 126 +++ .../Services/DeploymentServiceTests.cs | 403 +++++++++ .../Services/PersonnelRolesServiceTests.cs | 69 ++ .../Services/ProtectedReadServiceTests.cs | 2 + .../Services/TimeTrackingServiceTests.cs | 292 +++++++ .../Controllers/PaymentWebhooksController.cs | 8 +- .../v4/CertificationsController.cs | 25 +- .../Controllers/v4/DeploymentsController.cs | 371 +++++++++ .../Controllers/v4/HealthController.cs | 8 +- .../Controllers/v4/SearchController.cs | 24 +- .../Controllers/v4/TimeReportsController.cs | 313 +++++++ .../Helpers/ClaimsAuthorizationHelper.cs | 3 + .../v4/Deployments/DeploymentsApiModels.cs | 403 +++++++++ .../Models/v4/Health/HealthResult.cs | 4 +- .../Models/v4/Search/SearchApiModels.cs | 5 +- .../Resgrid.Web.Services.xml | 70 +- Web/Resgrid.Web.Services/Startup.cs | 3 + .../User/Controllers/DeploymentsController.cs | 675 +++++++++++++++ .../User/Controllers/PersonnelController.cs | 28 +- .../User/Controllers/ProfileController.cs | 22 +- .../User/Controllers/ReportsController.cs | 24 +- .../User/Controllers/SearchController.cs | 1 + .../User/Controllers/SecurityController.cs | 2 +- .../Models/Deployments/DeploymentViews.cs | 144 ++++ .../Certifications/RoleRequirements.cshtml | 8 +- .../User/Views/Certifications/Settings.cshtml | 6 +- .../User/Views/Certifications/Types.cshtml | 2 +- .../Areas/User/Views/Deployments/Edit.cshtml | 81 ++ .../Deployments/FromExternalOrder.cshtml | 97 +++ .../Areas/User/Views/Deployments/Index.cshtml | 64 ++ .../User/Views/Deployments/TimeReport.cshtml | 207 +++++ .../Areas/User/Views/Deployments/View.cshtml | 340 ++++++++ .../Views/Deployments/_ExpenseForm.cshtml | 30 + .../User/Views/Deployments/_Message.cshtml | 10 + .../Deployments/_ReportStatusBadge.cshtml | 14 + .../User/Views/Deployments/_Shell.cshtml | 31 + .../Views/Deployments/_StatusBadge.cshtml | 15 + .../Areas/User/Views/Invoicing/View.cshtml | 9 +- .../Areas/User/Views/Personnel/Index.cshtml | 4 + .../Areas/User/Views/Security/Index.cshtml | 6 + .../User/Views/Shared/_Navigation.cshtml | 8 + .../Areas/User/Views/Workflows/New.cshtml | 4 +- Web/Resgrid.Web/Controllers/PayController.cs | 39 +- .../Helpers/ClaimsAuthorizationHelper.cs | 3 + Web/Resgrid.Web/Startup.cs | 3 + Web/Resgrid.Web/Views/Pay/Cancel.cshtml | 2 +- Web/Resgrid.Web/Views/Pay/Index.cshtml | 2 +- Web/Resgrid.Web/Views/Pay/Return.cshtml | 2 +- .../Logic/CertificationExpiryLogic.cs | 31 +- 139 files changed, 12456 insertions(+), 210 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx create mode 100644 Core/Resgrid.Model/Invoicing/DeploymentContracts.cs create mode 100644 Core/Resgrid.Model/Invoicing/DeploymentModels.cs create mode 100644 Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs create mode 100644 Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs create mode 100644 Core/Resgrid.Model/Services/IDeploymentService.cs create mode 100644 Core/Resgrid.Model/Services/ITimeTrackingService.cs create mode 100644 Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs create mode 100644 Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs create mode 100644 Core/Resgrid.Services/Invoicing/DeploymentService.cs create mode 100644 Core/Resgrid.Services/Invoicing/TimeTrackingService.cs create mode 100644 Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs create mode 100644 Tests/Resgrid.Tests/Search/SearchControllerSecurityTests.cs create mode 100644 Tests/Resgrid.Tests/Search/UnifiedSearchSecurityTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/FromExternalOrder.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/_Message.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/_ReportStatusBadge.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/_Shell.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Deployments/_StatusBadge.cshtml diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx new file mode 100644 index 000000000..482ba58de --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + عمليات الانتشار + مالية الانتشار + عمليات انتشاري + أغلفة مالية لعمليات الانتشار: قوائم التشكيل وتقارير الوقت اليومية والمصروفات والملفات للانتشارات القابلة للفوترة واسترداد التكاليف والتشغيلية. + يجمع الانتشار الوحدات والأفراد والمعدات المرسلة إلى حادث مع تقارير الوقت اليومية والمصروفات. اربطه بنداء أو بطلب مساعدة متبادلة من Records. + لا توجد عمليات انتشار بعد. + عرض الكل + عرض المفتوحة فقط + انتشار جديد + تعديل الانتشار + المعرّفات التي تحددها الجهة العميلة وبيانات الاختصاص التي يحتاجها محرك الفوترة. + إنشاء من طلب خارجي + فتح + الاسم + الحالة + الوضع المالي + «تشغيلي فقط» يحفظ الوقت والمصروفات للتوثيق؛ «استرداد التكاليف» يغذي مطالبة تعويض؛ «قابل للفوترة» يغذي فاتورة للعميل. + رقم الحادث + رقم طلب الموارد + رقم الطلب + رقم طلب الخدمة + رمز التكلفة + نقطة التعاقد + جهة الاتصال + النافذة الزمنية + أيام + الحد الأقصى للأيام + ملاحظات + مرتبط بطلب خارجي في Records + الانتشار + معرّفات الجهة + الاختصاص + البلد الأصلي + الولاية / المقاطعة الأصلية + البلد المضيف + الولاية / المقاطعة المضيفة + المنطقة الزمنية + المنطقة الزمنية المحلية للحادث لتقارير الوقت؛ الفراغ يستخدم منطقة القسم. + العملة + خارج المقاطعة / الولاية + السفر جوًا + معرّف النداء + اختياري: النداء الذي تُفوتر عليه هذه العملية. انتشار واحد لكل نداء. + حفظ + إلغاء + تم الحفظ. + تعذّر حفظ التغيير. + غلّف طلب مساعدة متبادلة مفتوحًا من Records بانتشار لتتبع الوقت والمصروفات والتشكيل وفوترتها. يبقى الطلب نفسه في Records. + الطلبات الخارجية المفتوحة + نظام Records غير مفعّل لهذا القسم، لذا لا توجد طلبات خارجية للاختيار منها. + لا توجد طلبات خارجية مفتوحة بدون انتشار. + طلب خارجي + الافتراضي هو اسم الحادث + تعبئة التشكيل مسبقًا من التخصيصات المقبولة + إنشاء نداء أيضًا + نوع النداء + أولوية النداء + إنشاء الانتشار + {0} طلب(ات) مفتوحة لديها انتشار بالفعل. + {0} تخصيص(ات) + تعديل + تصدير الوقت CSV + النداء + الملخص + الوحدات + الأفراد + تقارير الوقت + ساعات الأفراد + المصروفات + تغيير الحالة + هل تريد تغيير حالة الانتشار؟ + التشكيل + الملفات + الوحدة + رمز النداء + الطاقم + هل تريد إزالة هذا الصف؟ + اختر وحدة + إضافة وحدة + المعدات + لا توجد وحدات في التشكيل. + لا توجد معدات في التشكيل. + العنصر + تم التسليم + إرجاع + اسم المعدة + بدون وحدة + إضافة معدة + لا يوجد أفراد في التشكيل. + المقعد + الشهادة + العضو + اختر عضوًا + المقاعد المميزة بـ * تتطلب دور الأفراد الخاص بالمقعد؛ يتم التحقق من متطلبات شهادات الدور ويتم تمييز الشهادات التي توشك على الانتهاء. + التعيين حتى لو لم يتحقق أحد المتطلبات + إضافة عضو + لا يستوفي العضو متطلبات المقعد. حدد «التعيين حتى لو لم يتحقق أحد المتطلبات» للتجاوز. + تقرير وقت جديد + تقرير واحد لكل يوم؛ تُعبأ الإدخالات مسبقًا من التشكيل. + لا توجد تقارير وقت بعد. + تاريخ التقرير + تم الإرسال + تمت الموافقة + الفاتورة + لا توجد مصروفات بعد. + التاريخ + النوع + الوصف + المدينة + المبلغ + الإيصال + تقرير الوقت + موافق عليه مسبقًا + غير قابل للفوترة + الإجمالي + إضافة مصروف + لا توجد ملفات بعد. + اسم الملف + الحجم + أُضيف + رفع + معاينة البيان + حفظ PDF البيان + قابل للفوترة + رمز الوجبة + تقرير الوقت اليومي رقم {0} + العودة إلى الانتشار + بدون راحة 8 ساعات (يستمر الوقت الإضافي إلى الوردية التالية) + توقف بسبب ظروف غير آمنة + إدخالات الوقت + الأوقات بالتوقيت المحلي للحادث + الموضوع + نوع الإدخال + البداية + النهاية + استراحة مدفوعة (دقيقة) + استراحة غير مدفوعة (دقيقة) + كم + إضافة إدخال + الإجراءات + حفظ وإرسال + هل تريد إرسال هذا التقرير للموافقة؟ تُقفل الإدخالات بعد الموافقة. + فتح الفاتورة + التوقيعات + المقاول + العميل + التوقيع بصفة المقاول + اسم موقّع العميل + توقيع + المراجعة + موافقة + هل تريد الموافقة على تقرير الوقت هذا؟ + السبب + إبطال + هل تريد إبطال تقرير الوقت هذا؟ يُحفظ للتوثيق لكنه يخرج من الفوترة. + حفظ PDF في الانتشار + مخطط + استعداد + نشط + إعادة الانتشار + مكتمل + ملغى + مسودة + مرسل + موافق عليه + مفوتر + مبطل + تشغيلي فقط + استرداد التكاليف + قابل للفوترة + بدل يومي / وجبة + إقامة + إقامة خاصة + عبّارة + وقود + إعادة تزويد المستلزمات + أخرى + إيصال + طلب خدمة موقّع + PDF تقرير الوقت + بيان + شهادة + أخرى + طلب خارجي + F-42 موقّع + F-42 ورقي + موافقة ICS 213 + موافقة تناوب الطاقم + فقد / تلف + إثبات مصروف MARS + فاتورة MARS + انتشار + استعداد + سفر + معيّن بالفعل في انتشار مفتوح آخر خلال هذه النافذة + لا يحمل العضو دور الأفراد الخاص بالمقعد + شهادة يتطلبها المقعد مفقودة أو منتهية + تنتهي شهادة مطلوبة خلال نافذة الانتشار + موجود بالفعل في هذا التشكيل + فشل الصرف من المخزون؛ تم الاحتفاظ بصف المعدة بدون صرف + يتداخل نطاقان زمنيان لنفس الموضوع + ينتهي إدخال قبل أن يبدأ + يشير إدخال إلى موضوع ليس في التشكيل + لا يحتوي التقرير على إدخالات وقت + نطاق يزيد عن 5 ساعات بدون استراحة مسجلة + وقت السفر الذي يزيد عن 12 ساعة يحتاج موافقة + يقع إدخال خارج تاريخ التقرير + الاسم مطلوب. + اختر وضعًا ماليًا. + يجب ألا تكون النهاية قبل البداية. + يجب أن يكون الخصم بين 0 و100 بالمئة. + لم يتم العثور على جهة الاتصال. + لم يتم العثور على النداء. + هذا النداء لديه انتشار بالفعل. + لم يتم العثور على الانتشار. + تغيير الحالة هذا غير مسموح من الحالة الحالية. + لا يمكن حذف انتشار نشط؛ أكمله أو ألغه أولًا. + اختر طلبًا خارجيًا. + هذا الطلب الخارجي لديه انتشار بالفعل. + لم يتم العثور على الطلب الخارجي. + لم يتم العثور على الوحدة. + هذه الوحدة موجودة بالفعل في التشكيل. + الانتشار مكتمل أو ملغى وتشكيله مقفل. + اختر عضوًا. + لم يتم العثور على العضو. + العضو ليس في هذا القسم. + هذا العضو موجود بالفعل في التشكيل. + لم يتم العثور على المقعد في تلك الوحدة. + أدخل اسم معدة أو اختر عنصرًا من المخزون. + اختر ملفًا للرفع. + حجم الملف أكبر من 30 ميغابايت. + تعذّر إنشاء ملف PDF. + تعذّرت كتابة البيانات المحمية؛ تحقق من حالة حماية بيانات القسم. + نوع الملف هذا غير مسموح. + يوجد تقرير وقت لهذا التاريخ بالفعل. + لم يتم العثور على تقرير الوقت. + تقرير الوقت موافق عليه أو مفوتر أو مبطل ولا يمكن تعديله بعد الآن. + تغيير الحالة هذا غير مسموح لتقرير الوقت هذا. + أدخل وقتي البداية والنهاية بصيغة HH:mm. + صحّح الأخطاء أدناه قبل الإرسال. + يجب ألا يكون المبلغ سالبًا. + اختر نوع المصروف. + لم يتم العثور على المصروف. + تعذّرت قراءة الإيصال. + إدارة عمليات الانتشار + إنشاء وتعديل عمليات الانتشار والتشكيلات والمعدات والمصروفات والملفات وتغييرات الحالة، ورؤية كل عمليات الانتشار. يرى الأعضاء المعيّنون دائمًا انتشاراتهم ويسجلون الوقت بدون هذا الإذن. + الموافقة على تقارير الوقت + الموافقة على تقارير الوقت اليومية المرسلة وإبطالها. التقارير الموافق عليها تغذي الفوترة. + تم إنشاء انتشار + تغيرت حالة الانتشار + تغير تشكيل الانتشار + أُضيف مصروف انتشار + أُرسل تقرير وقت + تمت الموافقة على تقرير وقت + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.cs b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.cs new file mode 100644 index 000000000..6d9e63b29 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.Deployments +{ + public class Deployments { } +} diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx new file mode 100644 index 000000000..c78cd8e1a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Einsätze + Einsatzabrechnung + Meine Einsätze + Finanzhüllen für Einsätze: Besetzungslisten, tägliche Zeitberichte, Spesen und Dateien für abrechenbare, kostenerstattete und rein operative Einsätze. + Ein Einsatz fasst die zu einem Ereignis entsandten Einheiten, Personen und Ausrüstung mit ihren täglichen Zeitberichten und Spesen zusammen. Verknüpfen Sie ihn mit einem Alarm oder einem Nachbarschaftshilfe-Auftrag aus Records. + Noch keine Einsätze. + Alle anzeigen + Nur offene anzeigen + Neuer Einsatz + Einsatz bearbeiten + Kennungen, die die Kundenbehörde vergibt, und die Zuständigkeitsangaben, die die Abrechnung benötigt. + Aus externem Auftrag erstellen + Öffnen + Name + Status + Finanzierungsart + „Nur operativ“ hält Zeiten und Spesen nur fest; „Kostenerstattung“ speist einen Erstattungsantrag; „Abrechenbar“ speist eine Kundenrechnung. + Ereignis-Nr. + Ressourcenauftrags-Nr. + Anforderungs-Nr. + Serviceanfrage-Nr. + Kostenstelle + Einstellungsort + Kontakt + Zeitraum + Tage + Max. Tage + Notizen + Mit einem externen Auftrag aus Records verknüpft + Einsatz + Behördenkennungen + Zuständigkeit + Heimatland + Heimat-Bundesland / -Provinz + Gastland + Gast-Bundesland / -Provinz + Zeitzone + Zeitzone am Ereignisort für Zeitberichte; leer verwendet die der Organisation. + Währung + Außerhalb der Provinz / des Bundeslandes + Anreise per Flugzeug + Alarm-ID + Optional: der Alarm, gegen den dieser Einsatz abgerechnet wird. Ein Einsatz je Alarm. + Speichern + Abbrechen + Gespeichert. + Die Änderung konnte nicht gespeichert werden. + Hüllen Sie einen offenen Nachbarschaftshilfe-Auftrag aus Records in einen Einsatz, damit Zeiten, Spesen und Besetzung erfasst und abgerechnet werden können. Der Auftrag selbst bleibt in Records. + Offene externe Aufträge + Das Records-System ist für diese Organisation nicht aktiviert, daher gibt es keine externen Aufträge zur Auswahl. + Keine offenen externen Aufträge ohne Einsatz. + Externer Auftrag + Standard ist der Ereignisname + Besetzung aus angenommenen Zuweisungen vorbelegen + Zusätzlich einen Alarm anlegen + Alarmtyp + Alarmpriorität + Einsatz erstellen + {0} offene(r) Auftrag/Aufträge haben bereits einen Einsatz. + {0} Zuweisung(en) + Bearbeiten + Zeiten als CSV exportieren + Alarm + Zusammenfassung + Einheiten + Personal + Zeitberichte + Personalstunden + Spesen + Status ändern + Einsatzstatus ändern? + Besetzung + Dateien + Einheit + Rufzeichen + Besatzung + Diese Zeile entfernen? + Einheit auswählen + Einheit hinzufügen + Ausrüstung + Keine Einheiten eingeteilt. + Keine Ausrüstung eingeteilt. + Gegenstand + Ausgegeben + Rückgabe + Ausrüstungsname + Keine Einheit + Ausrüstung hinzufügen + Kein Personal eingeteilt. + Platz + Zertifizierung + Mitglied + Mitglied auswählen + Mit * markierte Plätze erfordern die Personalrolle des Platzes; die Zertifizierungsanforderungen der Rolle werden geprüft und ablaufende Zertifizierungen markiert. + Auch bei nicht erfüllter Anforderung einteilen + Mitglied hinzufügen + Das Mitglied erfüllt die Anforderungen des Platzes nicht. Aktivieren Sie „Auch bei nicht erfüllter Anforderung einteilen“, um dies zu übergehen. + Neuer Zeitbericht + Ein Bericht je Tag; die Einträge werden aus der Besetzung vorbelegt. + Noch keine Zeitberichte. + Berichtsdatum + Eingereicht + Genehmigt + Rechnung + Noch keine Spesen. + Datum + Art + Beschreibung + Ort + Betrag + Beleg + Zeitbericht + Vorab genehmigt + Nicht abrechenbar + Summe + Spesen hinzufügen + Noch keine Dateien. + Dateiname + Größe + Hinzugefügt + Hochladen + Manifest-Vorschau + Manifest-PDF ablegen + Abrechenbar + Mahlzeitencode + Täglicher Zeitbericht Nr. {0} + Zurück zum Einsatz + Keine 8 Stunden Ruhe (Überstunden laufen in die nächste Schicht) + Abbruch wegen unsicherer Bedingungen + Zeiteinträge + Zeiten in Ereignis-Ortszeit + Betreff + Eintragsart + Beginn + Ende + Bezahlte Pause (Min.) + Unbezahlte Pause (Min.) + Km + Eintrag hinzufügen + Aktionen + Speichern und einreichen + Diesen Bericht zur Genehmigung einreichen? Nach der Genehmigung sind die Einträge gesperrt. + Rechnung öffnen + Unterschriften + Auftragnehmer + Kunde + Als Auftragnehmer unterschreiben + Name des Kundenunterzeichners + Unterschreiben + Prüfung + Genehmigen + Diesen Zeitbericht genehmigen? + Grund + Stornieren + Diesen Zeitbericht stornieren? Er bleibt zur Dokumentation erhalten, fällt aber aus der Abrechnung. + PDF am Einsatz ablegen + Geplant + Bereitschaft + Aktiv + Demobilisierung + Abgeschlossen + Abgebrochen + Entwurf + Eingereicht + Genehmigt + Abgerechnet + Storniert + Nur operativ + Kostenerstattung + Abrechenbar + Tagegeld / Mahlzeit + Unterkunft + Private Unterkunft + Fähre + Kraftstoff + Nachschub + Sonstiges + Beleg + Unterzeichnete Serviceanfrage + Zeitbericht-PDF + Manifest + Zertifizierung + Sonstiges + Externer Auftrag + Unterzeichnetes F-42 + Papier-F-42 + ICS-213-Genehmigung + Genehmigung Besatzungswechsel + Verlust / Schaden + MARS-Spesennachweis + MARS-Rechnung + Einsatz + Bereitschaft + Reise + In diesem Zeitraum bereits in einem anderen offenen Einsatz eingeteilt + Das Mitglied hat die Personalrolle des Platzes nicht + Eine für den Platz erforderliche Zertifizierung fehlt oder ist abgelaufen + Eine erforderliche Zertifizierung läuft während des Einsatzzeitraums ab + Bereits in dieser Besetzung + Die Lagerausgabe ist fehlgeschlagen; die Ausrüstungszeile wurde ohne Ausgabe behalten + Zwei Zeitspannen desselben Betreffs überschneiden sich + Ein Eintrag endet vor seinem Beginn + Ein Eintrag verweist auf einen Betreff, der nicht in der Besetzung ist + Der Bericht hat keine Zeiteinträge + Eine Spanne über 5 Stunden hat keine Pause + Reisezeit über 12 Stunden benötigt eine Genehmigung + Ein Eintrag liegt außerhalb des Berichtsdatums + Ein Name ist erforderlich. + Wählen Sie eine Finanzierungsart. + Das Ende darf nicht vor dem Beginn liegen. + Der Rabatt muss zwischen 0 und 100 Prozent liegen. + Der Kontakt wurde nicht gefunden. + Der Alarm wurde nicht gefunden. + Dieser Alarm hat bereits einen Einsatz. + Der Einsatz wurde nicht gefunden. + Diese Statusänderung ist vom aktuellen Status aus nicht erlaubt. + Ein aktiver Einsatz kann nicht gelöscht werden; schließen Sie ihn zuerst ab oder brechen Sie ihn ab. + Wählen Sie einen externen Auftrag. + Dieser externe Auftrag hat bereits einen Einsatz. + Der externe Auftrag wurde nicht gefunden. + Die Einheit wurde nicht gefunden. + Diese Einheit ist bereits in der Besetzung. + Der Einsatz ist abgeschlossen oder abgebrochen und seine Besetzung ist gesperrt. + Wählen Sie ein Mitglied. + Das Mitglied wurde nicht gefunden. + Das Mitglied gehört nicht zu dieser Organisation. + Dieses Mitglied ist bereits in der Besetzung. + Der Platz wurde auf dieser Einheit nicht gefunden. + Geben Sie einen Ausrüstungsnamen ein oder wählen Sie einen Lagerartikel. + Wählen Sie eine Datei zum Hochladen. + Die Datei ist größer als 30 MB. + Das PDF konnte nicht erzeugt werden. + Geschützte Daten konnten nicht geschrieben werden; prüfen Sie den Datenschutzstatus der Organisation. + Dieser Dateityp ist nicht erlaubt. + Für dieses Datum existiert bereits ein Zeitbericht. + Der Zeitbericht wurde nicht gefunden. + Der Zeitbericht ist genehmigt, abgerechnet oder storniert und kann nicht mehr bearbeitet werden. + Diese Statusänderung ist für diesen Zeitbericht nicht erlaubt. + Geben Sie Beginn und Ende als HH:mm ein. + Beheben Sie die unten stehenden Fehler vor dem Einreichen. + Der Betrag darf nicht negativ sein. + Wählen Sie eine Spesenart. + Die Spesenposition wurde nicht gefunden. + Der Beleg konnte nicht gelesen werden. + Einsätze verwalten + Einsätze, Besetzungen, Ausrüstung, Spesen, Dateien und Statusänderungen anlegen und bearbeiten sowie alle Einsätze sehen. Eingeteilte Mitglieder sehen ihre eigenen Einsätze und erfassen Zeiten auch ohne dieses Recht. + Zeitberichte genehmigen + Eingereichte tägliche Zeitberichte genehmigen und stornieren. Genehmigte Berichte fließen in die Abrechnung. + Einsatz erstellt + Einsatzstatus geändert + Einsatzbesetzung geändert + Einsatzspesen hinzugefügt + Zeitbericht eingereicht + Zeitbericht genehmigt + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx new file mode 100644 index 000000000..671c7d9a5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Αποστολές + Οικονομικά αποστολών + Οι αποστολές μου + Οικονομικά περιβλήματα αποστολών: συνθέσεις, ημερήσιες αναφορές χρόνου, έξοδα και αρχεία για τιμολογήσιμες, ανάκτησης κόστους και επιχειρησιακές αποστολές. + Μια αποστολή ομαδοποιεί τις μονάδες, το προσωπικό και τον εξοπλισμό που στάλθηκαν σε ένα συμβάν μαζί με τις ημερήσιες αναφορές χρόνου και τα έξοδα. Συνδέστε την με μια κλήση ή με μια εντολή αμοιβαίας συνδρομής από το Records. + Δεν υπάρχουν αποστολές ακόμη. + Εμφάνιση όλων + Εμφάνιση μόνο ανοιχτών + Νέα αποστολή + Επεξεργασία αποστολής + Αναγνωριστικά που ορίζει ο φορέας-πελάτης και στοιχεία δικαιοδοσίας που χρειάζεται η μηχανή τιμολόγησης. + Δημιουργία από εξωτερική εντολή + Άνοιγμα + Όνομα + Κατάσταση + Οικονομική λειτουργία + «Μόνο επιχειρησιακή» κρατά χρόνο και έξοδα ως αρχείο· «ανάκτηση κόστους» τροφοδοτεί αίτημα αποζημίωσης· «τιμολογήσιμη» τροφοδοτεί τιμολόγιο πελάτη. + Αρ. συμβάντος + Αρ. εντολής πόρων + Αρ. αιτήματος + Αρ. αιτήματος υπηρεσίας + Κωδικός κόστους + Σημείο πρόσληψης + Επαφή + Χρονικό παράθυρο + ημέρες + Μέγ. ημέρες + Σημειώσεις + Συνδεδεμένη με εξωτερική εντολή του Records + Αποστολή + Αναγνωριστικά φορέα + Δικαιοδοσία + Χώρα προέλευσης + Πολιτεία / επαρχία προέλευσης + Χώρα υποδοχής + Πολιτεία / επαρχία υποδοχής + Ζώνη ώρας + Τοπική ζώνη ώρας του συμβάντος για τις αναφορές χρόνου· κενό χρησιμοποιεί αυτήν του τμήματος. + Νόμισμα + Εκτός επαρχίας / πολιτείας + Μετακίνηση αεροπορικώς + Αναγνωριστικό κλήσης + Προαιρετικό: η κλήση στην οποία χρεώνεται η αποστολή. Μία αποστολή ανά κλήση. + Αποθήκευση + Ακύρωση + Αποθηκεύτηκε. + Η αλλαγή δεν ήταν δυνατό να αποθηκευτεί. + Περιβάλετε μια ανοιχτή εντολή αμοιβαίας συνδρομής του Records με μια αποστολή ώστε να παρακολουθούνται και να τιμολογούνται χρόνος, έξοδα και σύνθεση. Η ίδια η εντολή παραμένει στο Records. + Ανοιχτές εξωτερικές εντολές + Το σύστημα Records δεν είναι ενεργοποιημένο για αυτό το τμήμα, οπότε δεν υπάρχουν εξωτερικές εντολές προς επιλογή. + Δεν υπάρχουν ανοιχτές εξωτερικές εντολές χωρίς αποστολή. + Εξωτερική εντολή + Προεπιλογή το όνομα του συμβάντος + Προσυμπλήρωση της σύνθεσης από αποδεκτές αναθέσεις + Δημιουργία και κλήσης + Τύπος κλήσης + Προτεραιότητα κλήσης + Δημιουργία αποστολής + {0} ανοιχτή/ές εντολή/ές έχουν ήδη αποστολή. + {0} ανάθεση/εις + Επεξεργασία + Εξαγωγή χρόνου CSV + Κλήση + Σύνοψη + Μονάδες + Προσωπικό + Αναφορές χρόνου + Ώρες προσωπικού + Έξοδα + Αλλαγή κατάστασης + Αλλαγή της κατάστασης της αποστολής; + Σύνθεση + Αρχεία + Μονάδα + Διακριτικό κλήσης + Πλήρωμα + Αφαίρεση αυτής της γραμμής; + Επιλέξτε μονάδα + Προσθήκη μονάδας + Εξοπλισμός + Καμία μονάδα στη σύνθεση. + Κανένας εξοπλισμός στη σύνθεση. + Είδος + Χορηγήθηκε + Επιστροφή + Όνομα εξοπλισμού + Χωρίς μονάδα + Προσθήκη εξοπλισμού + Κανένα προσωπικό στη σύνθεση. + Θέση + Πιστοποίηση + Μέλος + Επιλέξτε μέλος + Οι θέσεις με * απαιτούν τον ρόλο προσωπικού της θέσης· ελέγχονται οι απαιτήσεις πιστοποίησης του ρόλου και επισημαίνονται οι πιστοποιήσεις που λήγουν. + Τοποθέτηση ακόμη κι αν αποτύχει μια απαίτηση + Προσθήκη μέλους + Το μέλος δεν πληροί τις απαιτήσεις της θέσης. Επιλέξτε «Τοποθέτηση ακόμη κι αν αποτύχει μια απαίτηση» για παράκαμψη. + Νέα αναφορά χρόνου + Μία αναφορά ανά ημέρα· οι καταχωρίσεις προσυμπληρώνονται από τη σύνθεση. + Δεν υπάρχουν αναφορές χρόνου ακόμη. + Ημερομηνία αναφοράς + Υποβλήθηκε + Εγκρίθηκε + Τιμολόγιο + Δεν υπάρχουν έξοδα ακόμη. + Ημερομηνία + Τύπος + Περιγραφή + Πόλη + Ποσό + Απόδειξη + Αναφορά χρόνου + Προεγκεκριμένο + Μη τιμολογήσιμο + Σύνολο + Προσθήκη εξόδου + Δεν υπάρχουν αρχεία ακόμη. + Όνομα αρχείου + Μέγεθος + Προστέθηκε + Μεταφόρτωση + Προεπισκόπηση δηλωτικού + Αρχειοθέτηση PDF δηλωτικού + Τιμολογήσιμο + Κωδικός γεύματος + Ημερήσια αναφορά χρόνου αρ. {0} + Επιστροφή στην αποστολή + Χωρίς 8ωρη ανάπαυση (οι υπερωρίες μεταφέρονται στην επόμενη βάρδια) + Διακοπή λόγω μη ασφαλών συνθηκών + Καταχωρίσεις χρόνου + οι ώρες είναι σε τοπική ώρα συμβάντος + Υποκείμενο + Τύπος καταχώρισης + Έναρξη + Λήξη + Αμειβόμενο διάλειμμα (λεπτά) + Μη αμειβόμενο διάλειμμα (λεπτά) + Χλμ + Προσθήκη καταχώρισης + Ενέργειες + Αποθήκευση και υποβολή + Υποβολή της αναφοράς για έγκριση; Οι καταχωρίσεις κλειδώνουν μετά την έγκριση. + Άνοιγμα τιμολογίου + Υπογραφές + Ανάδοχος + Πελάτης + Υπογραφή ως ανάδοχος + Όνομα υπογράφοντος πελάτη + Υπογραφή + Έλεγχος + Έγκριση + Έγκριση αυτής της αναφοράς χρόνου; + Αιτία + Ακύρωση αναφοράς + Ακύρωση αυτής της αναφοράς χρόνου; Διατηρείται ως αρχείο αλλά εξαιρείται από την τιμολόγηση. + Αρχειοθέτηση PDF στην αποστολή + Προγραμματισμένη + Σε αναμονή + Ενεργή + Αποκινητοποίηση + Ολοκληρωμένη + Ακυρωμένη + Πρόχειρο + Υποβληθείσα + Εγκεκριμένη + Τιμολογημένη + Ακυρωμένη + Μόνο επιχειρησιακή + Ανάκτηση κόστους + Τιμολογήσιμη + Ημερήσια αποζημίωση / γεύμα + Διαμονή + Ιδιωτική διαμονή + Πορθμείο + Καύσιμα + Ανεφοδιασμός + Άλλο + Απόδειξη + Υπογεγραμμένο αίτημα υπηρεσίας + PDF αναφοράς χρόνου + Δηλωτικό + Πιστοποίηση + Άλλο + Εξωτερική εντολή + Υπογεγραμμένο F-42 + Έντυπο F-42 + Έγκριση ICS 213 + Έγκριση εναλλαγής πληρώματος + Απώλεια / ζημιά + Αποδεικτικό εξόδου MARS + Τιμολόγιο MARS + Αποστολή + Αναμονή + Μετακίνηση + Ήδη τοποθετημένο σε άλλη ανοιχτή αποστολή σε αυτό το παράθυρο + Το μέλος δεν κατέχει τον ρόλο προσωπικού της θέσης + Λείπει ή έχει λήξει πιστοποίηση που απαιτεί η θέση + Μια απαιτούμενη πιστοποίηση λήγει εντός του παραθύρου της αποστολής + Ήδη σε αυτή τη σύνθεση + Η χορήγηση από το απόθεμα απέτυχε· η γραμμή εξοπλισμού διατηρήθηκε χωρίς χορήγηση + Δύο χρονικά διαστήματα επικαλύπτονται για το ίδιο υποκείμενο + Μια καταχώριση λήγει πριν αρχίσει + Μια καταχώριση αναφέρεται σε υποκείμενο εκτός σύνθεσης + Η αναφορά δεν έχει καταχωρίσεις χρόνου + Διάστημα άνω των 5 ωρών χωρίς καταγεγραμμένο διάλειμμα + Χρόνος μετακίνησης άνω των 12 ωρών απαιτεί έγκριση + Μια καταχώριση βρίσκεται εκτός της ημερομηνίας αναφοράς + Απαιτείται όνομα. + Επιλέξτε οικονομική λειτουργία. + Η λήξη δεν μπορεί να προηγείται της έναρξης. + Η έκπτωση πρέπει να είναι μεταξύ 0 και 100 τοις εκατό. + Η επαφή δεν βρέθηκε. + Η κλήση δεν βρέθηκε. + Αυτή η κλήση έχει ήδη αποστολή. + Η αποστολή δεν βρέθηκε. + Αυτή η αλλαγή κατάστασης δεν επιτρέπεται από την τρέχουσα κατάσταση. + Μια ενεργή αποστολή δεν μπορεί να διαγραφεί· ολοκληρώστε ή ακυρώστε την πρώτα. + Επιλέξτε εξωτερική εντολή. + Αυτή η εξωτερική εντολή έχει ήδη αποστολή. + Η εξωτερική εντολή δεν βρέθηκε. + Η μονάδα δεν βρέθηκε. + Αυτή η μονάδα είναι ήδη στη σύνθεση. + Η αποστολή είναι ολοκληρωμένη ή ακυρωμένη και η σύνθεσή της είναι κλειδωμένη. + Επιλέξτε μέλος. + Το μέλος δεν βρέθηκε. + Το μέλος δεν ανήκει σε αυτό το τμήμα. + Αυτό το μέλος είναι ήδη στη σύνθεση. + Η θέση δεν βρέθηκε σε αυτή τη μονάδα. + Εισαγάγετε όνομα εξοπλισμού ή επιλέξτε είδος αποθέματος. + Επιλέξτε αρχείο για μεταφόρτωση. + Το αρχείο υπερβαίνει τα 30 MB. + Δεν ήταν δυνατή η δημιουργία του PDF. + Δεν ήταν δυνατή η εγγραφή προστατευμένων δεδομένων· ελέγξτε την κατάσταση προστασίας δεδομένων του τμήματος. + Αυτός ο τύπος αρχείου δεν επιτρέπεται. + Υπάρχει ήδη αναφορά χρόνου για αυτή την ημερομηνία. + Η αναφορά χρόνου δεν βρέθηκε. + Η αναφορά χρόνου είναι εγκεκριμένη, τιμολογημένη ή ακυρωμένη και δεν μπορεί πλέον να επεξεργαστεί. + Αυτή η αλλαγή κατάστασης δεν επιτρέπεται για αυτή την αναφορά χρόνου. + Εισαγάγετε ώρες έναρξης και λήξης ως HH:mm. + Διορθώστε τα παρακάτω σφάλματα πριν την υποβολή. + Το ποσό δεν μπορεί να είναι αρνητικό. + Επιλέξτε τύπο εξόδου. + Το έξοδο δεν βρέθηκε. + Δεν ήταν δυνατή η ανάγνωση της απόδειξης. + Διαχείριση αποστολών + Δημιουργία και επεξεργασία αποστολών, συνθέσεων, εξοπλισμού, εξόδων, αρχείων και αλλαγών κατάστασης, και προβολή όλων των αποστολών. Τα μέλη της σύνθεσης βλέπουν πάντα τις δικές τους αποστολές και καταχωρίζουν χρόνο χωρίς αυτό το δικαίωμα. + Έγκριση αναφορών χρόνου + Έγκριση και ακύρωση υποβληθεισών ημερήσιων αναφορών χρόνου. Οι εγκεκριμένες αναφορές τροφοδοτούν την τιμολόγηση. + Δημιουργήθηκε αποστολή + Άλλαξε η κατάσταση αποστολής + Άλλαξε η σύνθεση αποστολής + Προστέθηκε έξοδο αποστολής + Υποβλήθηκε αναφορά χρόνου + Εγκρίθηκε αναφορά χρόνου + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx new file mode 100644 index 000000000..a7535a972 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Deployments + Deployment Finance + My Deployments + Deployment finance wrappers: rosters, daily time reports, expenses and files for billable, cost-recovery and operational deployments. + A deployment groups the units, personnel and equipment sent to an incident with their daily time reports and expenses. Link it to a call or to a Records mutual-aid order. + No deployments yet. + Show all + Show open only + New Deployment + Edit Deployment + Identifiers the customer agency assigns and the jurisdiction facts the billing engine needs. + Create from external order + Open + Name + Status + Finance mode + Operational only keeps time and expenses for the record; cost recovery feeds a reimbursement claim; billable feeds a customer invoice. + Incident # + Resource order # + Request # + Service request # + Cost code + Point of hire + Contact + Window + days + Max days + Notes + Linked to a Records external order + Deployment + Agency identifiers + Jurisdiction + Home country + Home state / province + Host country + Host state / province + Time zone + Incident-local time zone for time reports; blank uses the department's. + Currency + Out of province / state + Travel via air + Call ID + Optional: the call this deployment is billed against. One deployment per call. + Save + Cancel + Saved. + The change could not be saved. + Wrap an open Records mutual-aid order in a deployment so time, expenses and the roster can be tracked and billed. The order itself stays in Records. + Open external orders + The Records system is not enabled for this department, so there are no external orders to pick from. + No open external orders without a deployment. + External order + Defaults to the incident name + Prefill the roster from accepted fills + Also create a call + Call type + Call priority + Create deployment + {0} open order(s) already have a deployment. + {0} fill(s) + Edit + Export time CSV + Call + Summary + Units + Personnel + Time reports + Personnel hours + Expenses + Change status + Change the deployment status? + Roster + Files + Unit + Call sign + Crew + Remove this row? + Select a unit + Add unit + Equipment + No units rostered. + No equipment rostered. + Item + Issued + Return + Equipment name + No unit + Add equipment + No personnel rostered. + Seat + Certification + Member + Select a member + Seats marked * require the seat's personnel role; the role's certification requirements are checked and expiring certifications are flagged. + Seat even when a requirement fails + Add member + The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override. + New time report + One report per day; entries are prefilled from the roster. + No time reports yet. + Report date + Submitted + Approved + Invoice + No expenses yet. + Date + Type + Description + City + Amount + Receipt + Time report + Pre-approved + Not billable + Total + Add expense + No files yet. + File name + Size + Added + Upload + Preview manifest + File manifest PDF + Billable + Meal code + Daily Time Report #{0} + Back to deployment + No clear 8 (OT carries into the next shift) + Unsafe conditions stand-down + Time entries + times are incident-local + Subject + Entry type + Start + End + Paid break (min) + Unpaid break (min) + Km + Add entry + Actions + Save and submit + Submit this report for approval? Entries lock once it is approved. + Open invoice + Signatures + Contractor + Customer + Sign as contractor + Customer signer name + Sign + Review + Approve + Approve this time report? + Reason + Void + Void this time report? It is kept for the record but drops out of billing. + File PDF on deployment + Planned + Standby + Active + Demobilizing + Completed + Cancelled + Draft + Submitted + Approved + Billed + Void + Operational only + Cost recovery + Billable + Per diem / meal + Accommodation + Private accommodation + Ferry + Fuel + Supply restock + Other + Receipt + Signed service request + Time report PDF + Manifest + Certification + Other + External order + Signed F-42 + Paper F-42 + ICS 213 approval + Crew rotation approval + Loss / damage + MARS expense evidence + MARS invoice + Deployment + Standby + Travel + Already seated on another open deployment in this window + The member does not hold the seat's personnel role + A certification the seat requires is missing or expired + A required certification expires during the deployment window + Already on this roster + The inventory issue failed; the equipment row was kept without an issuance + Two time spans overlap for the same subject + An entry ends before it starts + An entry refers to a subject that is not on the roster + The report has no time entries + A span longer than 5 hours has no break recorded + Travel time above 12 hours needs approval + An entry falls outside the report date + A name is required. + Choose a finance mode. + The end must not be before the start. + The discount must be between 0 and 100 percent. + The contact was not found. + The call was not found. + That call already has a deployment. + The deployment was not found. + That status change is not allowed from the current status. + An active deployment cannot be deleted; complete or cancel it first. + Choose an external order. + That external order already has a deployment. + The external order was not found. + The unit was not found. + That unit is already on the roster. + The deployment is completed or cancelled and its roster is locked. + Choose a member. + The member was not found. + The member is not in this department. + That member is already on the roster. + The seat was not found on that unit. + Enter an equipment name or pick an inventory item. + Choose a file to upload. + The file is larger than 30 MB. + The PDF could not be generated. + Protected data could not be written; check the department's data protection status. + That file type is not allowed. + A time report already exists for that date. + The time report was not found. + The time report is approved, billed or void and can no longer be edited. + That status change is not allowed for this time report. + Enter start and end times as HH:mm. + Fix the errors below before submitting. + The amount must not be negative. + Choose an expense type. + The expense was not found. + The receipt could not be read. + Manage deployments + Create and edit deployments, rosters, equipment, expenses, files and status changes, and see every deployment. Rostered members always see their own deployments and file time without this. + Approve time reports + Approve and void submitted daily time reports. Approved reports feed billing. + Deployment created + Deployment status changed + Deployment roster changed + Deployment expense added + Time report submitted + Time report approved + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx new file mode 100644 index 000000000..150864df7 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Despliegues + Finanzas de despliegue + Mis despliegues + Envoltorios financieros de despliegues: dotaciones, partes diarios de horas, gastos y archivos para despliegues facturables, de recuperación de costes y operativos. + Un despliegue agrupa las unidades, el personal y el equipo enviados a un incidente con sus partes diarios de horas y gastos. Vincúlelo a una llamada o a una orden de ayuda mutua de Records. + Aún no hay despliegues. + Mostrar todos + Mostrar solo abiertos + Nuevo despliegue + Editar despliegue + Identificadores que asigna la agencia cliente y los datos de jurisdicción que necesita el motor de facturación. + Crear desde orden externa + Abrir + Nombre + Estado + Modo financiero + «Solo operativo» conserva horas y gastos como registro; «recuperación de costes» alimenta una reclamación de reembolso; «facturable» alimenta una factura al cliente. + N.º de incidente + N.º de orden de recursos + N.º de solicitud + N.º de solicitud de servicio + Código de coste + Punto de contratación + Contacto + Ventana + días + Máx. días + Notas + Vinculado a una orden externa de Records + Despliegue + Identificadores de la agencia + Jurisdicción + País de origen + Estado / provincia de origen + País anfitrión + Estado / provincia anfitrión + Zona horaria + Zona horaria local del incidente para los partes de horas; en blanco usa la del departamento. + Moneda + Fuera de la provincia / estado + Viaje en avión + ID de llamada + Opcional: la llamada contra la que se factura este despliegue. Un despliegue por llamada. + Guardar + Cancelar + Guardado. + No se pudo guardar el cambio. + Envuelva una orden abierta de ayuda mutua de Records en un despliegue para registrar y facturar horas, gastos y dotación. La orden permanece en Records. + Órdenes externas abiertas + El sistema Records no está habilitado para este departamento, así que no hay órdenes externas para elegir. + No hay órdenes externas abiertas sin despliegue. + Orden externa + Por defecto, el nombre del incidente + Rellenar la dotación con las asignaciones aceptadas + Crear también una llamada + Tipo de llamada + Prioridad de la llamada + Crear despliegue + {0} orden(es) abierta(s) ya tienen despliegue. + {0} asignación(es) + Editar + Exportar horas a CSV + Llamada + Resumen + Unidades + Personal + Partes de horas + Horas de personal + Gastos + Cambiar estado + ¿Cambiar el estado del despliegue? + Dotación + Archivos + Unidad + Indicativo + Tripulación + ¿Quitar esta fila? + Seleccione una unidad + Añadir unidad + Equipo + No hay unidades en la dotación. + No hay equipo en la dotación. + Artículo + Entregado + Devolver + Nombre del equipo + Sin unidad + Añadir equipo + No hay personal en la dotación. + Puesto + Certificación + Miembro + Seleccione un miembro + Los puestos marcados con * requieren el rol de personal del puesto; se comprueban los requisitos de certificación del rol y se señalan las certificaciones a punto de caducar. + Asignar aunque falle un requisito + Añadir miembro + El miembro no cumple los requisitos del puesto. Marque «Asignar aunque falle un requisito» para forzarlo. + Nuevo parte de horas + Un parte por día; las entradas se rellenan desde la dotación. + Aún no hay partes de horas. + Fecha del parte + Enviado + Aprobado + Factura + Aún no hay gastos. + Fecha + Tipo + Descripción + Ciudad + Importe + Recibo + Parte de horas + Preaprobado + No facturable + Total + Añadir gasto + Aún no hay archivos. + Nombre de archivo + Tamaño + Añadido + Subir + Vista previa del manifiesto + Archivar PDF del manifiesto + Facturable + Código de comida + Parte diario de horas n.º {0} + Volver al despliegue + Sin descanso de 8 h (las horas extra pasan al siguiente turno) + Parada por condiciones inseguras + Entradas de tiempo + horas en hora local del incidente + Sujeto + Tipo de entrada + Inicio + Fin + Descanso pagado (min) + Descanso no pagado (min) + Km + Añadir entrada + Acciones + Guardar y enviar + ¿Enviar este parte para aprobación? Las entradas se bloquean al aprobarse. + Abrir factura + Firmas + Contratista + Cliente + Firmar como contratista + Nombre del firmante del cliente + Firmar + Revisión + Aprobar + ¿Aprobar este parte de horas? + Motivo + Anular + ¿Anular este parte de horas? Se conserva como registro pero sale de la facturación. + Archivar PDF en el despliegue + Planificado + En espera + Activo + Desmovilizando + Completado + Cancelado + Borrador + Enviado + Aprobado + Facturado + Anulado + Solo operativo + Recuperación de costes + Facturable + Dieta / comida + Alojamiento + Alojamiento privado + Ferry + Combustible + Reposición de suministros + Otro + Recibo + Solicitud de servicio firmada + PDF del parte de horas + Manifiesto + Certificación + Otro + Orden externa + F-42 firmado + F-42 en papel + Aprobación ICS 213 + Aprobación de rotación de tripulación + Pérdida / daño + Evidencia de gastos MARS + Factura MARS + Despliegue + En espera + Viaje + Ya asignado a otro despliegue abierto en esta ventana + El miembro no tiene el rol de personal del puesto + Falta o ha caducado una certificación que exige el puesto + Una certificación requerida caduca durante la ventana del despliegue + Ya está en esta dotación + La entrega de inventario falló; la fila de equipo se conservó sin entrega + Dos tramos de tiempo se solapan para el mismo sujeto + Una entrada termina antes de empezar + Una entrada se refiere a un sujeto que no está en la dotación + El parte no tiene entradas de tiempo + Un tramo de más de 5 horas no tiene descanso registrado + El tiempo de viaje superior a 12 horas necesita aprobación + Una entrada queda fuera de la fecha del parte + Se requiere un nombre. + Elija un modo financiero. + El fin no puede ser anterior al inicio. + El descuento debe estar entre 0 y 100 por ciento. + No se encontró el contacto. + No se encontró la llamada. + Esa llamada ya tiene un despliegue. + No se encontró el despliegue. + Ese cambio de estado no está permitido desde el estado actual. + Un despliegue activo no se puede eliminar; complételo o cancélelo primero. + Elija una orden externa. + Esa orden externa ya tiene un despliegue. + No se encontró la orden externa. + No se encontró la unidad. + Esa unidad ya está en la dotación. + El despliegue está completado o cancelado y su dotación está bloqueada. + Elija un miembro. + No se encontró el miembro. + El miembro no pertenece a este departamento. + Ese miembro ya está en la dotación. + No se encontró el puesto en esa unidad. + Introduzca un nombre de equipo o elija un artículo de inventario. + Elija un archivo para subir. + El archivo supera los 30 MB. + No se pudo generar el PDF. + No se pudieron escribir los datos protegidos; compruebe el estado de protección de datos del departamento. + Ese tipo de archivo no está permitido. + Ya existe un parte de horas para esa fecha. + No se encontró el parte de horas. + El parte de horas está aprobado, facturado o anulado y ya no puede editarse. + Ese cambio de estado no está permitido para este parte de horas. + Introduzca las horas de inicio y fin como HH:mm. + Corrija los errores siguientes antes de enviar. + El importe no puede ser negativo. + Elija un tipo de gasto. + No se encontró el gasto. + No se pudo leer el recibo. + Gestionar despliegues + Crear y editar despliegues, dotaciones, equipo, gastos, archivos y cambios de estado, y ver todos los despliegues. Los miembros asignados siempre ven sus propios despliegues y registran horas sin este permiso. + Aprobar partes de horas + Aprobar y anular partes diarios de horas enviados. Los partes aprobados alimentan la facturación. + Despliegue creado + Estado del despliegue cambiado + Dotación del despliegue cambiada + Gasto de despliegue añadido + Parte de horas enviado + Parte de horas aprobado + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx new file mode 100644 index 000000000..2190d64ac --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Déploiements + Finances de déploiement + Mes déploiements + Enveloppes financières des déploiements : effectifs, rapports de temps quotidiens, dépenses et fichiers pour les déploiements facturables, en recouvrement de coûts ou opérationnels. + Un déploiement regroupe les unités, le personnel et l'équipement envoyés sur un incident avec leurs rapports de temps quotidiens et leurs dépenses. Reliez-le à un appel ou à un ordre d'entraide de Records. + Aucun déploiement pour l'instant. + Tout afficher + Afficher seulement les ouverts + Nouveau déploiement + Modifier le déploiement + Identifiants attribués par l'agence cliente et données de juridiction nécessaires au moteur de facturation. + Créer depuis un ordre externe + Ouvrir + Nom + Statut + Mode financier + « Opérationnel seulement » conserve les temps et dépenses pour mémoire ; « recouvrement de coûts » alimente une demande de remboursement ; « facturable » alimente une facture client. + N° d'incident + N° d'ordre de ressources + N° de demande + N° de demande de service + Code de coût + Point d'embauche + Contact + Période + jours + Jours max. + Notes + Relié à un ordre externe de Records + Déploiement + Identifiants de l'agence + Juridiction + Pays d'origine + État / province d'origine + Pays hôte + État / province hôte + Fuseau horaire + Fuseau horaire local de l'incident pour les rapports de temps ; vide utilise celui du service. + Devise + Hors province / État + Déplacement par avion + ID d'appel + Facultatif : l'appel auquel ce déploiement est facturé. Un déploiement par appel. + Enregistrer + Annuler + Enregistré. + La modification n'a pas pu être enregistrée. + Enveloppez un ordre d'entraide ouvert de Records dans un déploiement pour suivre et facturer les temps, les dépenses et l'effectif. L'ordre lui-même reste dans Records. + Ordres externes ouverts + Le système Records n'est pas activé pour ce service ; il n'y a donc aucun ordre externe à choisir. + Aucun ordre externe ouvert sans déploiement. + Ordre externe + Par défaut, le nom de l'incident + Pré-remplir l'effectif avec les affectations acceptées + Créer aussi un appel + Type d'appel + Priorité de l'appel + Créer le déploiement + {0} ordre(s) ouvert(s) ont déjà un déploiement. + {0} affectation(s) + Modifier + Exporter les temps en CSV + Appel + Résumé + Unités + Personnel + Rapports de temps + Heures du personnel + Dépenses + Changer le statut + Changer le statut du déploiement ? + Effectif + Fichiers + Unité + Indicatif + Équipage + Retirer cette ligne ? + Choisir une unité + Ajouter une unité + Équipement + Aucune unité affectée. + Aucun équipement affecté. + Article + Remis + Retour + Nom de l'équipement + Sans unité + Ajouter un équipement + Aucun personnel affecté. + Poste + Certification + Membre + Choisir un membre + Les postes marqués * exigent le rôle de personnel du poste ; les exigences de certification du rôle sont vérifiées et les certifications expirant sont signalées. + Affecter même si une exigence échoue + Ajouter un membre + Le membre ne remplit pas les exigences du poste. Cochez « Affecter même si une exigence échoue » pour passer outre. + Nouveau rapport de temps + Un rapport par jour ; les entrées sont pré-remplies depuis l'effectif. + Aucun rapport de temps pour l'instant. + Date du rapport + Soumis + Approuvé + Facture + Aucune dépense pour l'instant. + Date + Type + Description + Ville + Montant + Reçu + Rapport de temps + Pré-approuvé + Non facturable + Total + Ajouter une dépense + Aucun fichier pour l'instant. + Nom du fichier + Taille + Ajouté + Téléverser + Aperçu du manifeste + Classer le PDF du manifeste + Facturable + Code repas + Rapport de temps quotidien n° {0} + Retour au déploiement + Pas de repos de 8 h (les heures supplémentaires se reportent sur le quart suivant) + Arrêt pour conditions dangereuses + Entrées de temps + heures en heure locale de l'incident + Sujet + Type d'entrée + Début + Fin + Pause payée (min) + Pause non payée (min) + Km + Ajouter une entrée + Actions + Enregistrer et soumettre + Soumettre ce rapport pour approbation ? Les entrées se verrouillent une fois approuvées. + Ouvrir la facture + Signatures + Prestataire + Client + Signer en tant que prestataire + Nom du signataire client + Signer + Révision + Approuver + Approuver ce rapport de temps ? + Motif + Annuler le rapport + Annuler ce rapport de temps ? Il est conservé pour mémoire mais sort de la facturation. + Classer le PDF sur le déploiement + Planifié + En attente + Actif + Démobilisation + Terminé + Annulé + Brouillon + Soumis + Approuvé + Facturé + Annulé + Opérationnel seulement + Recouvrement de coûts + Facturable + Indemnité / repas + Hébergement + Hébergement privé + Traversier + Carburant + Réapprovisionnement + Autre + Reçu + Demande de service signée + PDF du rapport de temps + Manifeste + Certification + Autre + Ordre externe + F-42 signé + F-42 papier + Approbation ICS 213 + Approbation de rotation d'équipage + Perte / dommage + Justificatif de dépense MARS + Facture MARS + Déploiement + En attente + Déplacement + Déjà affecté à un autre déploiement ouvert sur cette période + Le membre ne détient pas le rôle de personnel du poste + Une certification exigée par le poste manque ou est expirée + Une certification requise expire pendant la période du déploiement + Déjà dans cet effectif + La remise d'inventaire a échoué ; la ligne d'équipement a été conservée sans remise + Deux plages de temps se chevauchent pour le même sujet + Une entrée se termine avant de commencer + Une entrée renvoie à un sujet absent de l'effectif + Le rapport n'a aucune entrée de temps + Une plage de plus de 5 heures n'a aucune pause enregistrée + Un temps de déplacement supérieur à 12 heures nécessite une approbation + Une entrée sort de la date du rapport + Un nom est requis. + Choisissez un mode financier. + La fin ne peut pas précéder le début. + La remise doit être comprise entre 0 et 100 pour cent. + Le contact est introuvable. + L'appel est introuvable. + Cet appel a déjà un déploiement. + Le déploiement est introuvable. + Ce changement de statut n'est pas autorisé depuis le statut actuel. + Un déploiement actif ne peut pas être supprimé ; terminez-le ou annulez-le d'abord. + Choisissez un ordre externe. + Cet ordre externe a déjà un déploiement. + L'ordre externe est introuvable. + L'unité est introuvable. + Cette unité est déjà dans l'effectif. + Le déploiement est terminé ou annulé et son effectif est verrouillé. + Choisissez un membre. + Le membre est introuvable. + Le membre n'appartient pas à ce service. + Ce membre est déjà dans l'effectif. + Le poste est introuvable sur cette unité. + Saisissez un nom d'équipement ou choisissez un article d'inventaire. + Choisissez un fichier à téléverser. + Le fichier dépasse 30 Mo. + Le PDF n'a pas pu être généré. + Les données protégées n'ont pas pu être écrites ; vérifiez l'état de protection des données du service. + Ce type de fichier n'est pas autorisé. + Un rapport de temps existe déjà pour cette date. + Le rapport de temps est introuvable. + Le rapport de temps est approuvé, facturé ou annulé et ne peut plus être modifié. + Ce changement de statut n'est pas autorisé pour ce rapport de temps. + Saisissez les heures de début et de fin au format HH:mm. + Corrigez les erreurs ci-dessous avant de soumettre. + Le montant ne peut pas être négatif. + Choisissez un type de dépense. + La dépense est introuvable. + Le reçu n'a pas pu être lu. + Gérer les déploiements + Créer et modifier les déploiements, effectifs, équipements, dépenses, fichiers et changements de statut, et voir tous les déploiements. Les membres affectés voient toujours leurs propres déploiements et saisissent leurs temps sans cette permission. + Approuver les rapports de temps + Approuver et annuler les rapports de temps quotidiens soumis. Les rapports approuvés alimentent la facturation. + Déploiement créé + Statut du déploiement modifié + Effectif du déploiement modifié + Dépense de déploiement ajoutée + Rapport de temps soumis + Rapport de temps approuvé + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx new file mode 100644 index 000000000..9dd59e079 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Impieghi + Contabilità impieghi + I miei impieghi + Contenitori finanziari degli impieghi: organici, rapporti giornalieri delle ore, spese e file per impieghi fatturabili, a recupero costi e operativi. + Un impiego raggruppa le unità, il personale e l'equipaggiamento inviati a un incidente con i loro rapporti giornalieri delle ore e le spese. Collegalo a una chiamata o a un ordine di mutuo soccorso di Records. + Ancora nessun impiego. + Mostra tutti + Mostra solo aperti + Nuovo impiego + Modifica impiego + Identificativi assegnati dall'agenzia cliente e dati di giurisdizione necessari al motore di fatturazione. + Crea da ordine esterno + Apri + Nome + Stato + Modalità finanziaria + «Solo operativo» conserva ore e spese come registro; «recupero costi» alimenta una richiesta di rimborso; «fatturabile» alimenta una fattura al cliente. + N. incidente + N. ordine risorse + N. richiesta + N. richiesta di servizio + Codice di costo + Punto di assunzione + Contatto + Finestra + giorni + Giorni max + Note + Collegato a un ordine esterno di Records + Impiego + Identificativi dell'agenzia + Giurisdizione + Paese di origine + Stato / provincia di origine + Paese ospitante + Stato / provincia ospitante + Fuso orario + Fuso orario locale dell'incidente per i rapporti delle ore; vuoto usa quello del dipartimento. + Valuta + Fuori provincia / stato + Viaggio in aereo + ID chiamata + Facoltativo: la chiamata a cui viene fatturato questo impiego. Un impiego per chiamata. + Salva + Annulla + Salvato. + Impossibile salvare la modifica. + Racchiudi un ordine di mutuo soccorso aperto di Records in un impiego per tracciare e fatturare ore, spese e organico. L'ordine resta in Records. + Ordini esterni aperti + Il sistema Records non è abilitato per questo dipartimento, quindi non ci sono ordini esterni da scegliere. + Nessun ordine esterno aperto senza impiego. + Ordine esterno + Predefinito: il nome dell'incidente + Precompila l'organico dalle assegnazioni accettate + Crea anche una chiamata + Tipo di chiamata + Priorità della chiamata + Crea impiego + {0} ordine/i aperto/i hanno già un impiego. + {0} assegnazione/i + Modifica + Esporta ore in CSV + Chiamata + Riepilogo + Unità + Personale + Rapporti ore + Ore del personale + Spese + Cambia stato + Cambiare lo stato dell'impiego? + Organico + File + Unità + Nominativo radio + Equipaggio + Rimuovere questa riga? + Seleziona un'unità + Aggiungi unità + Equipaggiamento + Nessuna unità in organico. + Nessun equipaggiamento in organico. + Articolo + Consegnato + Restituisci + Nome equipaggiamento + Nessuna unità + Aggiungi equipaggiamento + Nessun personale in organico. + Posto + Certificazione + Membro + Seleziona un membro + I posti contrassegnati con * richiedono il ruolo del personale del posto; i requisiti di certificazione del ruolo vengono verificati e le certificazioni in scadenza segnalate. + Assegna anche se un requisito non è soddisfatto + Aggiungi membro + Il membro non soddisfa i requisiti del posto. Spunta «Assegna anche se un requisito non è soddisfatto» per forzare. + Nuovo rapporto ore + Un rapporto al giorno; le voci sono precompilate dall'organico. + Ancora nessun rapporto ore. + Data del rapporto + Inviato + Approvato + Fattura + Ancora nessuna spesa. + Data + Tipo + Descrizione + Città + Importo + Ricevuta + Rapporto ore + Preapprovato + Non fatturabile + Totale + Aggiungi spesa + Ancora nessun file. + Nome file + Dimensione + Aggiunto + Carica + Anteprima manifesto + Archivia PDF manifesto + Fatturabile + Codice pasto + Rapporto giornaliero ore n. {0} + Torna all'impiego + Nessun riposo di 8 ore (lo straordinario passa al turno successivo) + Sospensione per condizioni non sicure + Voci di tempo + orari in ora locale dell'incidente + Soggetto + Tipo di voce + Inizio + Fine + Pausa retribuita (min) + Pausa non retribuita (min) + Km + Aggiungi voce + Azioni + Salva e invia + Inviare questo rapporto per l'approvazione? Le voci si bloccano una volta approvato. + Apri fattura + Firme + Appaltatore + Cliente + Firma come appaltatore + Nome del firmatario cliente + Firma + Revisione + Approva + Approvare questo rapporto ore? + Motivo + Annulla rapporto + Annullare questo rapporto ore? Resta a registro ma esce dalla fatturazione. + Archivia PDF sull'impiego + Pianificato + In attesa + Attivo + Smobilitazione + Completato + Annullato + Bozza + Inviato + Approvato + Fatturato + Annullato + Solo operativo + Recupero costi + Fatturabile + Diaria / pasto + Alloggio + Alloggio privato + Traghetto + Carburante + Rifornimento scorte + Altro + Ricevuta + Richiesta di servizio firmata + PDF rapporto ore + Manifesto + Certificazione + Altro + Ordine esterno + F-42 firmato + F-42 cartaceo + Approvazione ICS 213 + Approvazione rotazione equipaggio + Perdita / danno + Prova di spesa MARS + Fattura MARS + Impiego + In attesa + Viaggio + Già assegnato a un altro impiego aperto in questa finestra + Il membro non possiede il ruolo del personale del posto + Manca o è scaduta una certificazione richiesta dal posto + Una certificazione richiesta scade durante la finestra dell'impiego + Già in questo organico + La consegna da inventario non è riuscita; la riga di equipaggiamento è stata mantenuta senza consegna + Due intervalli di tempo si sovrappongono per lo stesso soggetto + Una voce termina prima di iniziare + Una voce fa riferimento a un soggetto non in organico + Il rapporto non ha voci di tempo + Un intervallo di oltre 5 ore non ha pause registrate + Il tempo di viaggio oltre 12 ore richiede approvazione + Una voce è fuori dalla data del rapporto + Il nome è obbligatorio. + Scegli una modalità finanziaria. + La fine non può precedere l'inizio. + Lo sconto deve essere compreso tra 0 e 100 per cento. + Contatto non trovato. + Chiamata non trovata. + Quella chiamata ha già un impiego. + Impiego non trovato. + Questo cambio di stato non è consentito dallo stato attuale. + Un impiego attivo non può essere eliminato; completalo o annullalo prima. + Scegli un ordine esterno. + Quell'ordine esterno ha già un impiego. + Ordine esterno non trovato. + Unità non trovata. + Quell'unità è già in organico. + L'impiego è completato o annullato e il suo organico è bloccato. + Scegli un membro. + Membro non trovato. + Il membro non appartiene a questo dipartimento. + Quel membro è già in organico. + Posto non trovato su quell'unità. + Inserisci un nome di equipaggiamento o scegli un articolo di inventario. + Scegli un file da caricare. + Il file supera i 30 MB. + Impossibile generare il PDF. + Impossibile scrivere i dati protetti; verifica lo stato di protezione dei dati del dipartimento. + Questo tipo di file non è consentito. + Esiste già un rapporto ore per quella data. + Rapporto ore non trovato. + Il rapporto ore è approvato, fatturato o annullato e non può più essere modificato. + Questo cambio di stato non è consentito per questo rapporto ore. + Inserisci inizio e fine nel formato HH:mm. + Correggi gli errori sottostanti prima di inviare. + L'importo non può essere negativo. + Scegli un tipo di spesa. + Spesa non trovata. + Impossibile leggere la ricevuta. + Gestisci impieghi + Creare e modificare impieghi, organici, equipaggiamento, spese, file e cambi di stato, e vedere tutti gli impieghi. I membri in organico vedono sempre i propri impieghi e registrano le ore anche senza questo permesso. + Approva rapporti ore + Approvare e annullare i rapporti giornalieri delle ore inviati. I rapporti approvati alimentano la fatturazione. + Impiego creato + Stato dell'impiego cambiato + Organico dell'impiego cambiato + Spesa di impiego aggiunta + Rapporto ore inviato + Rapporto ore approvato + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx new file mode 100644 index 000000000..f6b68245d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Dyslokacje + Finanse dyslokacji + Moje dyslokacje + Finansowe otoczki dyslokacji: obsady, dzienne raporty czasu, wydatki i pliki dla dyslokacji rozliczanych, w trybie zwrotu kosztów i operacyjnych. + Dyslokacja grupuje jednostki, personel i sprzęt wysłane do zdarzenia wraz z dziennymi raportami czasu i wydatkami. Powiąż ją ze zgłoszeniem lub ze zleceniem pomocy wzajemnej z Records. + Brak dyslokacji. + Pokaż wszystkie + Pokaż tylko otwarte + Nowa dyslokacja + Edytuj dyslokację + Identyfikatory nadawane przez agencję klienta oraz dane jurysdykcji potrzebne silnikowi rozliczeń. + Utwórz ze zlecenia zewnętrznego + Otwórz + Nazwa + Status + Tryb finansowy + „Tylko operacyjna” zachowuje czas i wydatki jako zapis; „zwrot kosztów” zasila wniosek o refundację; „rozliczana” zasila fakturę dla klienta. + Nr zdarzenia + Nr zlecenia zasobów + Nr żądania + Nr zlecenia usługi + Kod kosztu + Miejsce zatrudnienia + Kontakt + Okno czasowe + dni + Maks. dni + Notatki + Powiązana ze zleceniem zewnętrznym w Records + Dyslokacja + Identyfikatory agencji + Jurysdykcja + Kraj macierzysty + Stan / województwo macierzyste + Kraj przyjmujący + Stan / województwo przyjmujące + Strefa czasowa + Lokalna strefa czasowa zdarzenia dla raportów czasu; puste oznacza strefę jednostki organizacyjnej. + Waluta + Poza województwem / stanem + Podróż samolotem + ID zgłoszenia + Opcjonalnie: zgłoszenie, na które rozliczana jest ta dyslokacja. Jedna dyslokacja na zgłoszenie. + Zapisz + Anuluj + Zapisano. + Nie udało się zapisać zmiany. + Opakuj otwarte zlecenie pomocy wzajemnej z Records w dyslokację, aby śledzić i rozliczać czas, wydatki i obsadę. Samo zlecenie pozostaje w Records. + Otwarte zlecenia zewnętrzne + System Records nie jest włączony dla tej jednostki organizacyjnej, więc nie ma zleceń zewnętrznych do wyboru. + Brak otwartych zleceń zewnętrznych bez dyslokacji. + Zlecenie zewnętrzne + Domyślnie nazwa zdarzenia + Wypełnij obsadę z zaakceptowanych przydziałów + Utwórz także zgłoszenie + Typ zgłoszenia + Priorytet zgłoszenia + Utwórz dyslokację + {0} otwarte zlecenie/zlecenia mają już dyslokację. + {0} przydział(ów) + Edytuj + Eksportuj czas do CSV + Zgłoszenie + Podsumowanie + Jednostki + Personel + Raporty czasu + Godziny personelu + Wydatki + Zmień status + Zmienić status dyslokacji? + Obsada + Pliki + Jednostka + Znak wywoławczy + Załoga + Usunąć ten wiersz? + Wybierz jednostkę + Dodaj jednostkę + Sprzęt + Brak jednostek w obsadzie. + Brak sprzętu w obsadzie. + Pozycja + Wydano + Zwrot + Nazwa sprzętu + Bez jednostki + Dodaj sprzęt + Brak personelu w obsadzie. + Stanowisko + Certyfikat + Członek + Wybierz członka + Stanowiska oznaczone * wymagają roli personelu danego stanowiska; sprawdzane są wymagania certyfikacyjne roli, a wygasające certyfikaty są oznaczane. + Przydziel nawet gdy wymaganie nie jest spełnione + Dodaj członka + Członek nie spełnia wymagań stanowiska. Zaznacz „Przydziel nawet gdy wymaganie nie jest spełnione”, aby wymusić. + Nowy raport czasu + Jeden raport dziennie; wpisy są wypełniane z obsady. + Brak raportów czasu. + Data raportu + Przesłano + Zatwierdzono + Faktura + Brak wydatków. + Data + Typ + Opis + Miasto + Kwota + Paragon + Raport czasu + Wstępnie zatwierdzony + Nierozliczany + Razem + Dodaj wydatek + Brak plików. + Nazwa pliku + Rozmiar + Dodano + Prześlij + Podgląd manifestu + Zapisz PDF manifestu + Rozliczany + Kod posiłku + Dzienny raport czasu nr {0} + Powrót do dyslokacji + Brak 8 godzin odpoczynku (nadgodziny przechodzą na następną zmianę) + Wstrzymanie z powodu niebezpiecznych warunków + Wpisy czasu + czasy w lokalnej strefie zdarzenia + Podmiot + Typ wpisu + Początek + Koniec + Płatna przerwa (min) + Bezpłatna przerwa (min) + Km + Dodaj wpis + Akcje + Zapisz i prześlij + Przesłać ten raport do zatwierdzenia? Po zatwierdzeniu wpisy zostaną zablokowane. + Otwórz fakturę + Podpisy + Wykonawca + Klient + Podpisz jako wykonawca + Imię i nazwisko osoby podpisującej po stronie klienta + Podpisz + Weryfikacja + Zatwierdź + Zatwierdzić ten raport czasu? + Powód + Unieważnij + Unieważnić ten raport czasu? Pozostanie w rejestrze, ale zostanie wyłączony z rozliczeń. + Zapisz PDF w dyslokacji + Zaplanowana + W gotowości + Aktywna + Demobilizacja + Zakończona + Anulowana + Szkic + Przesłany + Zatwierdzony + Rozliczony + Unieważniony + Tylko operacyjna + Zwrot kosztów + Rozliczana + Dieta / posiłek + Zakwaterowanie + Zakwaterowanie prywatne + Prom + Paliwo + Uzupełnienie zapasów + Inne + Paragon + Podpisane zlecenie usługi + PDF raportu czasu + Manifest + Certyfikat + Inne + Zlecenie zewnętrzne + Podpisany F-42 + Papierowy F-42 + Zatwierdzenie ICS 213 + Zatwierdzenie rotacji załogi + Strata / uszkodzenie + Dowód wydatku MARS + Faktura MARS + Dyslokacja + Gotowość + Podróż + Już przydzielony do innej otwartej dyslokacji w tym oknie + Członek nie posiada roli personelu wymaganej na stanowisku + Brakuje lub wygasł certyfikat wymagany na stanowisku + Wymagany certyfikat wygasa w oknie dyslokacji + Już w tej obsadzie + Wydanie z magazynu nie powiodło się; wiersz sprzętu zachowano bez wydania + Dwa przedziały czasu nakładają się dla tego samego podmiotu + Wpis kończy się przed rozpoczęciem + Wpis odnosi się do podmiotu spoza obsady + Raport nie ma wpisów czasu + Przedział dłuższy niż 5 godzin nie ma zarejestrowanej przerwy + Czas podróży powyżej 12 godzin wymaga zatwierdzenia + Wpis wykracza poza datę raportu + Nazwa jest wymagana. + Wybierz tryb finansowy. + Koniec nie może być wcześniejszy niż początek. + Rabat musi mieścić się między 0 a 100 procent. + Nie znaleziono kontaktu. + Nie znaleziono zgłoszenia. + To zgłoszenie ma już dyslokację. + Nie znaleziono dyslokacji. + Ta zmiana statusu nie jest dozwolona z bieżącego statusu. + Aktywnej dyslokacji nie można usunąć; najpierw ją zakończ lub anuluj. + Wybierz zlecenie zewnętrzne. + To zlecenie zewnętrzne ma już dyslokację. + Nie znaleziono zlecenia zewnętrznego. + Nie znaleziono jednostki. + Ta jednostka jest już w obsadzie. + Dyslokacja jest zakończona lub anulowana, a jej obsada jest zablokowana. + Wybierz członka. + Nie znaleziono członka. + Członek nie należy do tej jednostki organizacyjnej. + Ten członek jest już w obsadzie. + Nie znaleziono stanowiska w tej jednostce. + Wpisz nazwę sprzętu lub wybierz pozycję z magazynu. + Wybierz plik do przesłania. + Plik jest większy niż 30 MB. + Nie udało się wygenerować PDF. + Nie udało się zapisać danych chronionych; sprawdź stan ochrony danych jednostki organizacyjnej. + Ten typ pliku nie jest dozwolony. + Raport czasu dla tej daty już istnieje. + Nie znaleziono raportu czasu. + Raport czasu jest zatwierdzony, rozliczony lub unieważniony i nie można go już edytować. + Ta zmiana statusu nie jest dozwolona dla tego raportu czasu. + Wpisz godziny początku i końca w formacie HH:mm. + Popraw poniższe błędy przed przesłaniem. + Kwota nie może być ujemna. + Wybierz typ wydatku. + Nie znaleziono wydatku. + Nie udało się odczytać paragonu. + Zarządzaj dyslokacjami + Tworzenie i edycja dyslokacji, obsad, sprzętu, wydatków, plików i zmian statusu oraz wgląd we wszystkie dyslokacje. Członkowie w obsadzie zawsze widzą swoje dyslokacje i rejestrują czas bez tego uprawnienia. + Zatwierdzaj raporty czasu + Zatwierdzanie i unieważnianie przesłanych dziennych raportów czasu. Zatwierdzone raporty zasilają rozliczenia. + Utworzono dyslokację + Zmieniono status dyslokacji + Zmieniono obsadę dyslokacji + Dodano wydatek dyslokacji + Przesłano raport czasu + Zatwierdzono raport czasu + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx new file mode 100644 index 000000000..a7535a972 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Deployments + Deployment Finance + My Deployments + Deployment finance wrappers: rosters, daily time reports, expenses and files for billable, cost-recovery and operational deployments. + A deployment groups the units, personnel and equipment sent to an incident with their daily time reports and expenses. Link it to a call or to a Records mutual-aid order. + No deployments yet. + Show all + Show open only + New Deployment + Edit Deployment + Identifiers the customer agency assigns and the jurisdiction facts the billing engine needs. + Create from external order + Open + Name + Status + Finance mode + Operational only keeps time and expenses for the record; cost recovery feeds a reimbursement claim; billable feeds a customer invoice. + Incident # + Resource order # + Request # + Service request # + Cost code + Point of hire + Contact + Window + days + Max days + Notes + Linked to a Records external order + Deployment + Agency identifiers + Jurisdiction + Home country + Home state / province + Host country + Host state / province + Time zone + Incident-local time zone for time reports; blank uses the department's. + Currency + Out of province / state + Travel via air + Call ID + Optional: the call this deployment is billed against. One deployment per call. + Save + Cancel + Saved. + The change could not be saved. + Wrap an open Records mutual-aid order in a deployment so time, expenses and the roster can be tracked and billed. The order itself stays in Records. + Open external orders + The Records system is not enabled for this department, so there are no external orders to pick from. + No open external orders without a deployment. + External order + Defaults to the incident name + Prefill the roster from accepted fills + Also create a call + Call type + Call priority + Create deployment + {0} open order(s) already have a deployment. + {0} fill(s) + Edit + Export time CSV + Call + Summary + Units + Personnel + Time reports + Personnel hours + Expenses + Change status + Change the deployment status? + Roster + Files + Unit + Call sign + Crew + Remove this row? + Select a unit + Add unit + Equipment + No units rostered. + No equipment rostered. + Item + Issued + Return + Equipment name + No unit + Add equipment + No personnel rostered. + Seat + Certification + Member + Select a member + Seats marked * require the seat's personnel role; the role's certification requirements are checked and expiring certifications are flagged. + Seat even when a requirement fails + Add member + The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override. + New time report + One report per day; entries are prefilled from the roster. + No time reports yet. + Report date + Submitted + Approved + Invoice + No expenses yet. + Date + Type + Description + City + Amount + Receipt + Time report + Pre-approved + Not billable + Total + Add expense + No files yet. + File name + Size + Added + Upload + Preview manifest + File manifest PDF + Billable + Meal code + Daily Time Report #{0} + Back to deployment + No clear 8 (OT carries into the next shift) + Unsafe conditions stand-down + Time entries + times are incident-local + Subject + Entry type + Start + End + Paid break (min) + Unpaid break (min) + Km + Add entry + Actions + Save and submit + Submit this report for approval? Entries lock once it is approved. + Open invoice + Signatures + Contractor + Customer + Sign as contractor + Customer signer name + Sign + Review + Approve + Approve this time report? + Reason + Void + Void this time report? It is kept for the record but drops out of billing. + File PDF on deployment + Planned + Standby + Active + Demobilizing + Completed + Cancelled + Draft + Submitted + Approved + Billed + Void + Operational only + Cost recovery + Billable + Per diem / meal + Accommodation + Private accommodation + Ferry + Fuel + Supply restock + Other + Receipt + Signed service request + Time report PDF + Manifest + Certification + Other + External order + Signed F-42 + Paper F-42 + ICS 213 approval + Crew rotation approval + Loss / damage + MARS expense evidence + MARS invoice + Deployment + Standby + Travel + Already seated on another open deployment in this window + The member does not hold the seat's personnel role + A certification the seat requires is missing or expired + A required certification expires during the deployment window + Already on this roster + The inventory issue failed; the equipment row was kept without an issuance + Two time spans overlap for the same subject + An entry ends before it starts + An entry refers to a subject that is not on the roster + The report has no time entries + A span longer than 5 hours has no break recorded + Travel time above 12 hours needs approval + An entry falls outside the report date + A name is required. + Choose a finance mode. + The end must not be before the start. + The discount must be between 0 and 100 percent. + The contact was not found. + The call was not found. + That call already has a deployment. + The deployment was not found. + That status change is not allowed from the current status. + An active deployment cannot be deleted; complete or cancel it first. + Choose an external order. + That external order already has a deployment. + The external order was not found. + The unit was not found. + That unit is already on the roster. + The deployment is completed or cancelled and its roster is locked. + Choose a member. + The member was not found. + The member is not in this department. + That member is already on the roster. + The seat was not found on that unit. + Enter an equipment name or pick an inventory item. + Choose a file to upload. + The file is larger than 30 MB. + The PDF could not be generated. + Protected data could not be written; check the department's data protection status. + That file type is not allowed. + A time report already exists for that date. + The time report was not found. + The time report is approved, billed or void and can no longer be edited. + That status change is not allowed for this time report. + Enter start and end times as HH:mm. + Fix the errors below before submitting. + The amount must not be negative. + Choose an expense type. + The expense was not found. + The receipt could not be read. + Manage deployments + Create and edit deployments, rosters, equipment, expenses, files and status changes, and see every deployment. Rostered members always see their own deployments and file time without this. + Approve time reports + Approve and void submitted daily time reports. Approved reports feed billing. + Deployment created + Deployment status changed + Deployment roster changed + Deployment expense added + Time report submitted + Time report approved + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx new file mode 100644 index 000000000..0e44dd2be --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Insatser + Insatsekonomi + Mina insatser + Ekonomiska omslag för insatser: bemanning, dagliga tidrapporter, utlägg och filer för fakturerbara, kostnadsåtervinnande och operativa insatser. + En insats samlar de enheter, den personal och den utrustning som skickats till en händelse tillsammans med dagliga tidrapporter och utlägg. Koppla den till ett larm eller till en beställning för ömsesidigt bistånd i Records. + Inga insatser ännu. + Visa alla + Visa endast öppna + Ny insats + Redigera insats + Identifierare som kundmyndigheten tilldelar och de jurisdiktionsuppgifter som faktureringsmotorn behöver. + Skapa från extern beställning + Öppna + Namn + Status + Ekonomiläge + ”Endast operativ” sparar tid och utlägg som dokumentation; ”kostnadsåtervinning” matar ett ersättningsanspråk; ”fakturerbar” matar en kundfaktura. + Händelsenr + Resursbeställningsnr + Begäran nr + Servicebegäran nr + Kostnadskod + Anställningsort + Kontakt + Tidsfönster + dagar + Max dagar + Anteckningar + Kopplad till en extern beställning i Records + Insats + Myndighetsidentifierare + Jurisdiktion + Hemland + Hemstat / -provins + Värdland + Värdstat / -provins + Tidszon + Händelsens lokala tidszon för tidrapporter; tomt använder avdelningens. + Valuta + Utanför provinsen / delstaten + Resa med flyg + Larm-ID + Valfritt: larmet som insatsen faktureras mot. En insats per larm. + Spara + Avbryt + Sparat. + Ändringen kunde inte sparas. + Omslut en öppen beställning för ömsesidigt bistånd i Records med en insats så att tid, utlägg och bemanning kan följas upp och faktureras. Själva beställningen stannar i Records. + Öppna externa beställningar + Records-systemet är inte aktiverat för den här avdelningen, så det finns inga externa beställningar att välja. + Inga öppna externa beställningar utan insats. + Extern beställning + Standard är händelsens namn + Förifyll bemanningen från accepterade tilldelningar + Skapa även ett larm + Larmtyp + Larmprioritet + Skapa insats + {0} öppen/öppna beställning(ar) har redan en insats. + {0} tilldelning(ar) + Redigera + Exportera tid som CSV + Larm + Sammanfattning + Enheter + Personal + Tidrapporter + Personaltimmar + Utlägg + Ändra status + Ändra insatsens status? + Bemanning + Filer + Enhet + Anropssignal + Besättning + Ta bort den här raden? + Välj en enhet + Lägg till enhet + Utrustning + Inga enheter bemannade. + Ingen utrustning bemannad. + Artikel + Utlämnad + Återlämna + Utrustningsnamn + Ingen enhet + Lägg till utrustning + Ingen personal bemannad. + Plats + Certifiering + Medlem + Välj en medlem + Platser märkta * kräver platsens personalroll; rollens certifieringskrav kontrolleras och certifieringar som snart går ut flaggas. + Bemanna även om ett krav inte uppfylls + Lägg till medlem + Medlemmen uppfyller inte platsens krav. Markera ”Bemanna även om ett krav inte uppfylls” för att åsidosätta. + Ny tidrapport + En rapport per dag; posterna förifylls från bemanningen. + Inga tidrapporter ännu. + Rapportdatum + Inskickad + Godkänd + Faktura + Inga utlägg ännu. + Datum + Typ + Beskrivning + Ort + Belopp + Kvitto + Tidrapport + Förhandsgodkänt + Ej fakturerbart + Summa + Lägg till utlägg + Inga filer ännu. + Filnamn + Storlek + Tillagd + Ladda upp + Förhandsgranska manifest + Arkivera manifest-PDF + Fakturerbart + Måltidskod + Daglig tidrapport nr {0} + Tillbaka till insatsen + Ingen 8 timmars vila (övertid förs över till nästa pass) + Avbrott på grund av osäkra förhållanden + Tidposter + tider i händelsens lokala tid + Objekt + Posttyp + Start + Slut + Betald rast (min) + Obetald rast (min) + Km + Lägg till post + Åtgärder + Spara och skicka in + Skicka in rapporten för godkännande? Posterna låses när den godkänts. + Öppna faktura + Underskrifter + Entreprenör + Kund + Signera som entreprenör + Kundens undertecknare + Signera + Granskning + Godkänn + Godkänna den här tidrapporten? + Orsak + Makulera + Makulera den här tidrapporten? Den behålls som dokumentation men faller bort från faktureringen. + Arkivera PDF på insatsen + Planerad + Beredskap + Aktiv + Demobilisering + Avslutad + Avbruten + Utkast + Inskickad + Godkänd + Fakturerad + Makulerad + Endast operativ + Kostnadsåtervinning + Fakturerbar + Traktamente / måltid + Boende + Privat boende + Färja + Bränsle + Påfyllning av förnödenheter + Övrigt + Kvitto + Signerad servicebegäran + Tidrapport-PDF + Manifest + Certifiering + Övrigt + Extern beställning + Signerad F-42 + Pappers-F-42 + ICS 213-godkännande + Godkännande av besättningsrotation + Förlust / skada + MARS-utläggsunderlag + MARS-faktura + Insats + Beredskap + Resa + Redan bemannad på en annan öppen insats i det här fönstret + Medlemmen har inte platsens personalroll + En certifiering som platsen kräver saknas eller har gått ut + En obligatorisk certifiering går ut under insatsfönstret + Redan i den här bemanningen + Lagerutlämningen misslyckades; utrustningsraden behölls utan utlämning + Två tidsintervall överlappar för samma objekt + En post slutar innan den börjar + En post hänvisar till ett objekt som inte finns i bemanningen + Rapporten saknar tidposter + Ett intervall längre än 5 timmar saknar registrerad rast + Restid över 12 timmar kräver godkännande + En post ligger utanför rapportdatumet + Ett namn krävs. + Välj ett ekonomiläge. + Slutet får inte ligga före starten. + Rabatten måste vara mellan 0 och 100 procent. + Kontakten hittades inte. + Larmet hittades inte. + Det larmet har redan en insats. + Insatsen hittades inte. + Den statusändringen tillåts inte från nuvarande status. + En aktiv insats kan inte tas bort; avsluta eller avbryt den först. + Välj en extern beställning. + Den externa beställningen har redan en insats. + Den externa beställningen hittades inte. + Enheten hittades inte. + Den enheten finns redan i bemanningen. + Insatsen är avslutad eller avbruten och bemanningen är låst. + Välj en medlem. + Medlemmen hittades inte. + Medlemmen tillhör inte den här avdelningen. + Den medlemmen finns redan i bemanningen. + Platsen hittades inte på den enheten. + Ange ett utrustningsnamn eller välj en lagerartikel. + Välj en fil att ladda upp. + Filen är större än 30 MB. + PDF-filen kunde inte skapas. + Skyddade data kunde inte skrivas; kontrollera avdelningens dataskyddsstatus. + Den filtypen är inte tillåten. + Det finns redan en tidrapport för det datumet. + Tidrapporten hittades inte. + Tidrapporten är godkänd, fakturerad eller makulerad och kan inte längre redigeras. + Den statusändringen tillåts inte för den här tidrapporten. + Ange start- och sluttid som HH:mm. + Åtgärda felen nedan innan du skickar in. + Beloppet får inte vara negativt. + Välj en utläggstyp. + Utlägget hittades inte. + Kvittot kunde inte läsas. + Hantera insatser + Skapa och redigera insatser, bemanning, utrustning, utlägg, filer och statusändringar samt se alla insatser. Bemannade medlemmar ser alltid sina egna insatser och registrerar tid utan denna behörighet. + Godkänn tidrapporter + Godkänn och makulera inskickade dagliga tidrapporter. Godkända rapporter matar faktureringen. + Insats skapad + Insatsstatus ändrad + Insatsbemanning ändrad + Insatsutlägg tillagt + Tidrapport inskickad + Tidrapport godkänd + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx new file mode 100644 index 000000000..ff34aac48 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx @@ -0,0 +1,261 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Розгортання + Фінанси розгортань + Мої розгортання + Фінансові обгортки розгортань: склади, щоденні звіти про час, витрати та файли для розгортань, що виставляються до оплати, з відшкодуванням витрат і суто операційних. + Розгортання об'єднує підрозділи, персонал та обладнання, направлені на подію, з їхніми щоденними звітами про час і витратами. Пов'яжіть його з викликом або з замовленням взаємодопомоги з Records. + Розгортань поки немає. + Показати всі + Показати лише відкриті + Нове розгортання + Редагувати розгортання + Ідентифікатори, які призначає агенція-замовник, і дані юрисдикції, потрібні механізму виставлення рахунків. + Створити із зовнішнього замовлення + Відкрити + Назва + Статус + Фінансовий режим + «Лише операційне» зберігає час і витрати для обліку; «відшкодування витрат» живить заявку на компенсацію; «до оплати» живить рахунок клієнту. + № події + № замовлення ресурсів + № запиту + № запиту на послугу + Код витрат + Місце найму + Контакт + Часове вікно + днів + Макс. днів + Нотатки + Пов'язано із зовнішнім замовленням у Records + Розгортання + Ідентифікатори агенції + Юрисдикція + Домашня країна + Домашній штат / область + Країна приймання + Штат / область приймання + Часовий пояс + Місцевий часовий пояс події для звітів про час; порожнє значення використовує пояс підрозділу. + Валюта + За межами області / штату + Подорож літаком + ID виклику + Необов'язково: виклик, на який виставляється це розгортання. Одне розгортання на виклик. + Зберегти + Скасувати + Збережено. + Не вдалося зберегти зміну. + Обгорніть відкрите замовлення взаємодопомоги з Records у розгортання, щоб відстежувати та виставляти час, витрати і склад. Саме замовлення залишається в Records. + Відкриті зовнішні замовлення + Систему Records не ввімкнено для цього підрозділу, тому немає зовнішніх замовлень для вибору. + Немає відкритих зовнішніх замовлень без розгортання. + Зовнішнє замовлення + Типово — назва події + Заповнити склад із прийнятих призначень + Також створити виклик + Тип виклику + Пріоритет виклику + Створити розгортання + {0} відкрите(і) замовлення вже мають розгортання. + {0} призначення(нь) + Редагувати + Експортувати час у CSV + Виклик + Підсумок + Підрозділи + Персонал + Звіти про час + Години персоналу + Витрати + Змінити статус + Змінити статус розгортання? + Склад + Файли + Підрозділ + Позивний + Екіпаж + Вилучити цей рядок? + Виберіть підрозділ + Додати підрозділ + Обладнання + Підрозділів у складі немає. + Обладнання у складі немає. + Позиція + Видано + Повернути + Назва обладнання + Без підрозділу + Додати обладнання + Персоналу у складі немає. + Місце + Сертифікація + Учасник + Виберіть учасника + Місця, позначені *, вимагають ролі персоналу цього місця; перевіряються вимоги ролі до сертифікацій, а сертифікації, що закінчуються, позначаються. + Призначити навіть якщо вимога не виконана + Додати учасника + Учасник не відповідає вимогам місця. Позначте «Призначити навіть якщо вимога не виконана», щоб обійти. + Новий звіт про час + Один звіт на день; записи заповнюються зі складу. + Звітів про час поки немає. + Дата звіту + Подано + Затверджено + Рахунок + Витрат поки немає. + Дата + Тип + Опис + Місто + Сума + Чек + Звіт про час + Попередньо затверджено + Не до оплати + Разом + Додати витрату + Файлів поки немає. + Ім'я файлу + Розмір + Додано + Завантажити + Попередній перегляд маніфесту + Зберегти PDF маніфесту + До оплати + Код харчування + Щоденний звіт про час № {0} + Назад до розгортання + Без 8 годин відпочинку (понаднормові переходять у наступну зміну) + Зупинка через небезпечні умови + Записи часу + час у місцевому часі події + Суб'єкт + Тип запису + Початок + Кінець + Оплачувана перерва (хв) + Неоплачувана перерва (хв) + Км + Додати запис + Дії + Зберегти та подати + Подати цей звіт на затвердження? Після затвердження записи блокуються. + Відкрити рахунок + Підписи + Підрядник + Клієнт + Підписати як підрядник + Ім'я підписанта клієнта + Підписати + Перевірка + Затвердити + Затвердити цей звіт про час? + Причина + Анулювати + Анулювати цей звіт про час? Він зберігається для обліку, але виключається з виставлення рахунків. + Зберегти PDF у розгортанні + Заплановано + Очікування + Активне + Демобілізація + Завершено + Скасовано + Чернетка + Подано + Затверджено + Виставлено + Анульовано + Лише операційне + Відшкодування витрат + До оплати + Добові / харчування + Проживання + Приватне проживання + Пором + Пальне + Поповнення запасів + Інше + Чек + Підписаний запит на послугу + PDF звіту про час + Маніфест + Сертифікація + Інше + Зовнішнє замовлення + Підписаний F-42 + Паперовий F-42 + Погодження ICS 213 + Погодження ротації екіпажу + Втрата / пошкодження + Підтвердження витрат MARS + Рахунок MARS + Розгортання + Очікування + Подорож + Уже призначено на інше відкрите розгортання в цьому вікні + Учасник не має ролі персоналу цього місця + Сертифікація, потрібна для місця, відсутня або прострочена + Потрібна сертифікація закінчується протягом вікна розгортання + Уже в цьому складі + Видача зі складу не вдалася; рядок обладнання збережено без видачі + Два проміжки часу перекриваються для одного суб'єкта + Запис закінчується раніше, ніж починається + Запис посилається на суб'єкта, якого немає у складі + Звіт не має записів часу + Проміжок понад 5 годин без зареєстрованої перерви + Час у дорозі понад 12 годин потребує затвердження + Запис виходить за межі дати звіту + Потрібна назва. + Виберіть фінансовий режим. + Кінець не може бути раніше початку. + Знижка має бути від 0 до 100 відсотків. + Контакт не знайдено. + Виклик не знайдено. + Цей виклик уже має розгортання. + Розгортання не знайдено. + Така зміна статусу не дозволена з поточного статусу. + Активне розгортання не можна видалити; спочатку завершіть або скасуйте його. + Виберіть зовнішнє замовлення. + Це зовнішнє замовлення вже має розгортання. + Зовнішнє замовлення не знайдено. + Підрозділ не знайдено. + Цей підрозділ уже у складі. + Розгортання завершено або скасовано, і його склад заблоковано. + Виберіть учасника. + Учасника не знайдено. + Учасник не належить до цього підрозділу. + Цей учасник уже у складі. + Місце не знайдено в цьому підрозділі. + Введіть назву обладнання або виберіть позицію зі складу. + Виберіть файл для завантаження. + Файл більший за 30 МБ. + Не вдалося створити PDF. + Не вдалося записати захищені дані; перевірте стан захисту даних підрозділу. + Цей тип файлу не дозволено. + Звіт про час на цю дату вже існує. + Звіт про час не знайдено. + Звіт про час затверджено, виставлено або анульовано, і його більше не можна редагувати. + Така зміна статусу не дозволена для цього звіту про час. + Введіть час початку та кінця у форматі HH:mm. + Виправте помилки нижче перед поданням. + Сума не може бути від'ємною. + Виберіть тип витрати. + Витрату не знайдено. + Не вдалося прочитати чек. + Керувати розгортаннями + Створювати та редагувати розгортання, склади, обладнання, витрати, файли й зміни статусу та бачити всі розгортання. Учасники складу завжди бачать свої розгортання й реєструють час без цього дозволу. + Затверджувати звіти про час + Затверджувати та анулювати подані щоденні звіти про час. Затверджені звіти живлять виставлення рахунків. + Розгортання створено + Статус розгортання змінено + Склад розгортання змінено + Додано витрату розгортання + Звіт про час подано + Звіт про час затверджено + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index de7eb7d8d..732c98159 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -261,6 +261,24 @@ public enum AuditLogTypes UnitCertificationAdded, UnitCertificationUpdated, UnitCertificationRemoved, - UnitCertificationStatusChanged + UnitCertificationStatusChanged, + + // Workforce & Business Operations plan Phase C (deployment core). Append-only; the contractor-billing and + // Cal OES MARS milestones append their own entries after these. + DeploymentCreated, + DeploymentUpdated, + DeploymentStatusChanged, + DeploymentRosterChanged, + DeploymentEquipmentChanged, + DeploymentAttachmentAdded, + DeploymentAttachmentRemoved, + TimeReportCreated, + TimeReportUpdated, + TimeReportSubmitted, + TimeReportApproved, + TimeReportVoided, + DeploymentExpenseAdded, + DeploymentExpenseUpdated, + DeploymentExpenseRemoved } } diff --git a/Core/Resgrid.Model/Certifications/CertificationModels.cs b/Core/Resgrid.Model/Certifications/CertificationModels.cs index d6babdc20..b53b15bc8 100644 --- a/Core/Resgrid.Model/Certifications/CertificationModels.cs +++ b/Core/Resgrid.Model/Certifications/CertificationModels.cs @@ -147,6 +147,13 @@ public class DepartmentCertificationSettings : IEntity public string UpdatedByUserId { get; set; } + /// + /// Department-local date of the last claimed expiry sweep (worker 34). The worker claims the date atomically before it + /// runs, so a repeated or skipped local hour (clock drift, daylight-saving transitions, an overlapping tick) can neither + /// send a day's expiring notifications twice nor miss the day. Owned by the worker; the settings page never writes it. + /// + public DateTime? LastSweepLocalDate { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index 9df2cf4ae..c489506a0 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -110,5 +110,14 @@ public static class FeatureFlagKeys /// Phase B2 online payment collection through a department's own Stripe account. Child of Invoicing.CustomerInvoicing and Payments.StripeConnect. Seeded off by M0212. public const string OnlinePayments = "Invoicing.OnlinePayments"; + + /// Phase C deployment core (deployments, roster, daily time reports, expenses, attachments, external-order link). Free: no Business.Operations prerequisite. Seeded off by M0219. + public const string Deployments = "Operations.Deployments"; + + /// Phase C contractor billing (rate schedules, contracts, bids, charge runs, invoice generation). Child of Business.Operations. Seeded off by M0219. + public const string ContractorBilling = "Invoicing.ContractorBilling"; + + /// Phase C Cal OES MARS cost recovery. Child of Business.Operations. Seeded off by M0219. + public const string CalOesMars = "CostRecovery.CalOesMars"; } } diff --git a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs new file mode 100644 index 000000000..cfbc78cfd --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Invoicing +{ + // Workforce & Business Operations plan, Phase C (C4): inputs and results of the deployment core services. + + /// Input for CreateFromExternalOrderAsync (plan C4, decision 39). + public sealed class ExternalOrderDeploymentInput + { + public string RmsExternalOrderId { get; set; } + public DeploymentFinanceModes FinanceMode { get; set; } = DeploymentFinanceModes.OperationalOnly; + /// Create a Call for the deployment (the department's choice; the Records deployment already has its own identity). + public bool CreateCall { get; set; } + public string CallType { get; set; } + public int CallPriority { get; set; } + /// Seat the accepted/mobilized/checked-in/assigned fills' AssignedUserId/AssignedUnitId on the roster. + public bool PrefillRoster { get; set; } = true; + public string Name { get; set; } + public string ContactId { get; set; } + public string Notes { get; set; } + } + + /// A roster warning or conflict (plan C4/C6): the row is still written unless . + public sealed class DeploymentRosterWarning + { + public const string ScheduleConflict = "schedule_conflict"; + public const string RoleNotHeld = "role_not_held"; + public const string CertificationMissing = "certification_missing"; + public const string CertificationExpiring = "certification_expiring"; + public const string AlreadyRostered = "already_rostered"; + + public string Code { get; set; } + public string SubjectId { get; set; } + public string Detail { get; set; } + public bool Blocking { get; set; } + } + + public sealed class DeploymentRosterResult + { + public DeploymentPersonnel Personnel { get; set; } + public DeploymentUnit Unit { get; set; } + public DeploymentEquipment Equipment { get; set; } + public List Warnings { get; set; } = new List(); + public bool HasBlockingWarnings => Warnings.Exists(w => w.Blocking); + } + + public sealed class DeploymentPersonnelInput + { + public string UserId { get; set; } + public string DeploymentUnitId { get; set; } + public int? UnitRoleId { get; set; } + public string CertificationCode { get; set; } + public string CallSign { get; set; } + public string RmsExternalOrderFillId { get; set; } + /// Write the row even when a seat requirement fails (the wizard's partial-fill path, decision 17). + public bool Force { get; set; } + } + + public sealed class DeploymentEquipmentInput + { + public string DeploymentUnitId { get; set; } + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string FreeTextName { get; set; } + public string Notes { get; set; } + /// Post an inventory Issue transaction (ReferenceType=Deployment) when the inventory module is present. + public bool IssueFromInventory { get; set; } + public string FromLocationId { get; set; } + } + + /// Result of a time-report validation pass (plan C4 ITimeTrackingService). + public sealed class TimeReportValidation + { + public const string Overlap = "entries_overlap"; + public const string EndBeforeStart = "end_before_start"; + public const string SubjectNotOnRoster = "subject_not_on_roster"; + public const string NoEntries = "no_entries"; + public const string BreakRule = "break_rule"; + public const string LongTravel = "long_travel"; + public const string OutsideReportDate = "outside_report_date"; + + public List Errors { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + public bool IsValid => Errors.Count == 0; + } + + public sealed class TimeReportIssue + { + public string Code { get; set; } + public string SubjectId { get; set; } + public string EntryId { get; set; } + public string Detail { get; set; } + } + + /// A saved time report plus the validation that ran on it. + public sealed class TimeReportSaveResult + { + public DeploymentTimeReport Report { get; set; } + public TimeReportValidation Validation { get; set; } = new TimeReportValidation(); + } + + /// The external order and fills behind a deployment, as the RMS pack owns them (decision 39). + public sealed class DeploymentExternalContext + { + public RmsExternalOrder Order { get; set; } + public List Fills { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs new file mode 100644 index 000000000..2c7a7ee95 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model.Invoicing +{ + // Workforce & Business Operations plan, Phase C (C2): the free deployment finance wrapper (M0218). External orders + // and fills are the RMS-owned RmsExternalOrder / RmsExternalOrderFill (decision 39); Deployment links by id only. + + /// How a deployment is financed (plan C1): operational only, Cal OES MARS cost recovery, or billable to a customer. + public enum DeploymentFinanceModes + { + OperationalOnly = 0, + CostRecovery = 1, + Billable = 2 + } + + /// Coarse finance status of a deployment (plan C4); per-resource milestones live on the RMS fill. + public enum DeploymentStatuses + { + Planned = 0, + Standby = 1, + Active = 2, + Demobilizing = 3, + Completed = 4, + Cancelled = 5 + } + + public enum DeploymentTimeReportStatuses + { + Draft = 0, + Submitted = 1, + Approved = 2, + Billed = 3, + Void = 4 + } + + public enum DeploymentTimeSubjectTypes + { + Personnel = 0, + Unit = 1, + Equipment = 2 + } + + public enum DeploymentTimeEntryTypes + { + Deployment = 0, + Standby = 1, + Travel = 2 + } + + public enum DeploymentExpenseTypes + { + PerDiemMeal = 0, + Accommodation = 1, + PrivateAccommodation = 2, + Ferry = 3, + Fuel = 4, + SupplyRestock = 5, + Other = 6 + } + + public enum DeploymentAttachmentTypes + { + Receipt = 0, + SignedServiceRequest = 1, + TimeReportPdf = 2, + Manifest = 3, + Certification = 4, + Other = 5, + ExternalOrder = 6, + SignedF42 = 7, + PaperF42 = 8, + Ics213Approval = 9, + CrewRotationApproval = 10, + LossDamage = 11, + MarsExpenseEvidence = 12, + MarsInvoice = 13 + } + + /// + /// The finance wrapper around a Call (plan C1/C4). Coarse status, identifiers the customer agency assigns + /// (incident, service request, resource order, request, cost code), the jurisdiction facts the billing engine + /// needs (out of province, air travel, currency) and the soft links to the bid, contract, rate schedule and the + /// RMS external order. Rows are never hard-deleted (7-year retention). + /// + public class Deployment : IEntity + { + [Required] + public string DeploymentId { get; set; } + [Required] + public int DepartmentId { get; set; } + public int? CallId { get; set; } + /// Soft reference to the RMS Mutual Aid pack's RmsExternalOrders row (decision 39). + public string RmsExternalOrderId { get; set; } + /// . + public int FinanceMode { get; set; } + public string BidId { get; set; } + public string ServiceContractId { get; set; } + public string RateScheduleId { get; set; } + public string ContactId { get; set; } + [Required] + public string Name { get; set; } + /// . + public int Status { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + /// Single-request display/search projection only; multi-request identity lives on the RMS fills. + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string HomeCountry { get; set; } + public string HostCountry { get; set; } + public string HomeSubdivision { get; set; } + public string HostSubdivision { get; set; } + public string LocalTimeZoneId { get; set; } + public string Locale { get; set; } + public string MeasurementSystem { get; set; } + public string Currency { get; set; } + public decimal? DiscountPercent { get; set; } + public DateTime? StatusChangedOn { get; set; } + public int? CalendarItemId { get; set; } + public string Notes { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] + public List Units { get; set; } = new List(); + [NotMapped] + public List Personnel { get; set; } = new List(); + [NotMapped] + public List Equipment { get; set; } = new List(); + + [NotMapped] + public bool IsOpen => Status is (int)DeploymentStatuses.Planned or (int)DeploymentStatuses.Standby or (int)DeploymentStatuses.Active or (int)DeploymentStatuses.Demobilizing; + + [NotMapped] public string TableName => "Deployments"; + [NotMapped] public string IdName => "DeploymentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentId; set => DeploymentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Units", "Personnel", "Equipment", "IsOpen" }; + } + + public class DeploymentUnit : IEntity + { + [Required] + public string DeploymentUnitId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + public int UnitId { get; set; } + /// Pins the crew rate family (GroupKey) for the unit. + public string RateScheduleEntryId { get; set; } + public string CallSign { get; set; } + public string Notes { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? RemovedOn { get; set; } + + [NotMapped] public string UnitName { get; set; } + [NotMapped] public bool IsActive => !RemovedOn.HasValue; + [NotMapped] public string TableName => "DeploymentUnits"; + [NotMapped] public string IdName => "DeploymentUnitId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentUnitId; set => DeploymentUnitId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "UnitName", "IsActive" }; + } + + public class DeploymentPersonnel : IEntity + { + [Required] + public string DeploymentPersonnelId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + public string DeploymentUnitId { get; set; } + [Required] + public string UserId { get; set; } + public int? UnitRoleId { get; set; } + public string RateScheduleEntryId { get; set; } + /// Certification level snapshot at rostering (a Phase D type code when typed). + public string CertificationCode { get; set; } + public string PremiumIdsJson { get; set; } + public string CallSign { get; set; } + public string RmsExternalOrderFillId { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? RemovedOn { get; set; } + + [NotMapped] public string DisplayName { get; set; } + [NotMapped] public bool IsActive => !RemovedOn.HasValue; + [NotMapped] public string TableName => "DeploymentPersonnel"; + [NotMapped] public string IdName => "DeploymentPersonnelId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentPersonnelId; set => DeploymentPersonnelId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "DisplayName", "IsActive" }; + } + + public class DeploymentEquipment : IEntity + { + [Required] + public string DeploymentEquipmentId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + public string DeploymentUnitId { get; set; } + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string FreeTextName { get; set; } + public string RateScheduleEntryId { get; set; } + public DateTime? IssuedOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public string Notes { get; set; } + public DateTime AddedOn { get; set; } + + [NotMapped] public bool IsActive => !ReturnedOn.HasValue; + [NotMapped] public string TableName => "DeploymentEquipment"; + [NotMapped] public string IdName => "DeploymentEquipmentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentEquipmentId; set => DeploymentEquipmentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsActive" }; + } + + /// A pre-numbered daily time report (DTR; the shift-ticket / CTR analog). CustomerSignerName is an ADP catalog 28 field. + public class DeploymentTimeReport : IEntity + { + [Required] + public string DeploymentTimeReportId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + public int ReportNumber { get; set; } + public DateTime ReportDate { get; set; } + /// . + public int Status { get; set; } + public string IncidentNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + /// OT rate carries into the next shift until an 8-hour rest is cleared (BCWS). + public bool NoClear8 { get; set; } + public bool UnsafeConditionsStandDown { get; set; } + public string ContractorSignedByUserId { get; set; } + public DateTime? ContractorSignedOn { get; set; } + public string CustomerSignerName { get; set; } + public DateTime? CustomerSignedOn { get; set; } + public string SubmittedByUserId { get; set; } + public DateTime? SubmittedOn { get; set; } + public string ApprovedByUserId { get; set; } + public DateTime? ApprovedOn { get; set; } + public string InvoiceId { get; set; } + public string RmsExternalOrderFillId { get; set; } + public string Notes { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] + public List Entries { get; set; } = new List(); + [NotMapped] + public bool IsEditable => Status is (int)DeploymentTimeReportStatuses.Draft or (int)DeploymentTimeReportStatuses.Submitted; + [NotMapped] public string TableName => "DeploymentTimeReports"; + [NotMapped] public string IdName => "DeploymentTimeReportId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentTimeReportId; set => DeploymentTimeReportId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "IsEditable" }; + } + + /// One time span for one subject on a DTR (multi-span per day supported). Times are UTC. + public class DeploymentTimeEntry : IEntity + { + [Required] + public string DeploymentTimeEntryId { get; set; } + [Required] + public string DeploymentTimeReportId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + /// . + public int SubjectType { get; set; } + public string DeploymentPersonnelId { get; set; } + public string DeploymentUnitId { get; set; } + public string DeploymentEquipmentId { get; set; } + /// . + public int EntryType { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int PaidBreakMinutes { get; set; } + public int UnpaidBreakMinutes { get; set; } + public int? CrewSizeSnapshot { get; set; } + public string CertificationCode { get; set; } + public decimal? MileageKm { get; set; } + public decimal? FuelDeductionLitres { get; set; } + public bool AgencySuppliedMeals { get; set; } + public bool AgencySuppliedAccommodation { get; set; } + public string Notes { get; set; } + public int SortOrder { get; set; } + + [NotMapped] + public decimal Hours => Math.Max(0m, (decimal)(EndTime - StartTime).TotalMinutes - UnpaidBreakMinutes) / 60m; + [NotMapped] public string SubjectId => DeploymentPersonnelId ?? DeploymentUnitId ?? DeploymentEquipmentId; + [NotMapped] public string TableName => "DeploymentTimeEntries"; + [NotMapped] public string IdName => "DeploymentTimeEntryId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentTimeEntryId; set => DeploymentTimeEntryId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Hours", "SubjectId" }; + } + + /// A dated expense against a deployment. Description is an ADP catalog 28 field. + public class DeploymentExpense : IEntity + { + [Required] + public string DeploymentExpenseId { get; set; } + [Required] + public string DeploymentId { get; set; } + public string DeploymentTimeReportId { get; set; } + public int DepartmentId { get; set; } + public DateTime ExpenseDate { get; set; } + /// . + public int ExpenseType { get; set; } + public string MealCode { get; set; } + public string City { get; set; } + public string Description { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public bool PreApproved { get; set; } + public bool Billable { get; set; } = true; + public int? ReceiptAttachmentId { get; set; } + public string RmsExternalOrderFillId { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "DeploymentExpenses"; + [NotMapped] public string IdName => "DeploymentExpenseId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentExpenseId; set => DeploymentExpenseId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A file on a deployment (receipt, signed request, DTR PDF, manifest…). Name/FileName/Data are ADP catalog 28 fields. + public class DeploymentAttachment : IEntity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int DeploymentAttachmentId { get; set; } + [Required] + public string DeploymentId { get; set; } + public int DepartmentId { get; set; } + /// . + public int AttachmentType { get; set; } + public string Name { get; set; } + public string FileName { get; set; } + public string FileType { get; set; } + public int? FileSize { get; set; } + public byte[] Data { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "DeploymentAttachments"; + [NotMapped] public string IdName => "DeploymentAttachmentId"; + [NotMapped] public int IdType => 0; + [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentAttachmentId; set => DeploymentAttachmentId = (int)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Per-department DTR number counter (M0218); allocated atomically by the repository. + public class TimeReportNumberSequence : IEntity + { + [Required] + public int DepartmentId { get; set; } + public int NextReportNumber { get; set; } = 1; + + [NotMapped] public string TableName => "TimeReportNumberSequences"; + [NotMapped] public string IdName => "DepartmentId"; + [NotMapped] public int IdType => 0; + [NotMapped] [JsonIgnore] public object IdValue { get => DepartmentId; set => DepartmentId = (int)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// ADP catalog 28 accessor maps for the deployment core (plan C8). The MARS and compliance-document fields join the same version with their milestones. + public static class DeploymentProtectedFields + { + public const int CatalogVersion = 28; + public const string Family = "Contacts"; + public const string AttachmentDataFieldId = "deploymentattachments.data"; + + public static readonly IReadOnlyDictionary Get, Action Set)> TimeReport = + new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) + { + ["deploymenttimereports.customersignername"] = (r => r.CustomerSignerName, (r, v) => r.CustomerSignerName = v) + }; + + public static readonly IReadOnlyDictionary Get, Action Set)> Expense = + new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) + { + ["deploymentexpenses.description"] = (e => e.Description, (e, v) => e.Description = v) + }; + + public static readonly IReadOnlyDictionary Get, Action Set)> Attachment = + new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) + { + ["deploymentattachments.name"] = (a => a.Name, (a, v) => a.Name = v), + ["deploymentattachments.filename"] = (a => a.FileName, (a, v) => a.FileName = v) + }; + + /// (table, column, binary) for the catalog registration. + public static IEnumerable<(string Table, string Column, bool Binary)> All() + { + yield return ("DeploymentTimeReports", "CustomerSignerName", false); + yield return ("DeploymentExpenses", "Description", false); + yield return ("DeploymentAttachments", "Name", false); + yield return ("DeploymentAttachments", "FileName", false); + yield return ("DeploymentAttachments", "Data", true); + } + } + + /// Workflow payload contract for the Phase C deployment triggers (plan C8; decision 22). Variables are the safe, non-protected facts. + public static class DeploymentWorkflowPayload + { + public const string Producer = "Deployments"; + + public static readonly (string Variable, string Property)[] Variables = + { + ("id", "DeploymentId"), ("name", "Name"), ("status", "Status"), ("old_status", "OldStatus"), ("finance_mode", "FinanceMode"), + ("call_id", "CallId"), ("incident_number", "IncidentNumber"), ("resource_order_number", "ResourceOrderNumber"), ("request_number", "RequestNumber"), ("cost_code", "CostCode"), + ("external_order_id", "RmsExternalOrderId"), ("contact_id", "ContactId"), ("start_on", "StartOn"), ("end_on", "EndOn"), + ("subject_type", "SubjectType"), ("subject_id", "SubjectId"), ("subject_name", "SubjectName"), ("roster_action", "RosterAction"), + ("report_number", "ReportNumber"), ("report_date", "ReportDate"), ("report_id", "TimeReportId"), + ("expense_type", "ExpenseType"), ("expense_amount", "ExpenseAmount"), ("expense_currency", "ExpenseCurrency") + }; + + public static readonly int[] Triggers = + { + (int)WorkflowTriggerEventType.DeploymentCreated, (int)WorkflowTriggerEventType.DeploymentStatusChanged, (int)WorkflowTriggerEventType.DeploymentRosterChanged, + (int)WorkflowTriggerEventType.DeploymentExpenseAdded, (int)WorkflowTriggerEventType.TimeReportSubmitted, (int)WorkflowTriggerEventType.TimeReportApproved + }; + + public static bool IsDeployment(int trigger) => Triggers.Contains(trigger); + + /// Registry 74-80 (bids, contracts): declared so the values stay locked, published by the contractor-billing milestone; hidden from the trigger picker until then. + public static readonly int[] Reserved = + { + (int)WorkflowTriggerEventType.BidCreated, (int)WorkflowTriggerEventType.BidSent, (int)WorkflowTriggerEventType.BidAccepted, (int)WorkflowTriggerEventType.BidDeclined, + (int)WorkflowTriggerEventType.BidExpired, (int)WorkflowTriggerEventType.ContractStatusChanged, (int)WorkflowTriggerEventType.ContractExpiring + }; + + public static bool IsReserved(int trigger) => Reserved.Contains(trigger); + } +} diff --git a/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs b/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs new file mode 100644 index 000000000..410117b61 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Department-configurable deployment-core permissions (Workforce & Business Operations plan, C8; registry 118-119). + /// Both fall back to department administrators. Rostered members always see their own deployments and file time + /// entries without either; ManageBids (116), ManageContracts (117) and ManageMutualAidReimbursement (79) join this + /// list with the contractor-billing and Cal OES MARS milestones. + /// + public static class DeploymentPermissionCatalog + { + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.ManageDeployments, PermissionActions.DepartmentAdminsOnly, false, "ManageDeploymentsNote", false), + new RecordPermissionDescriptor(PermissionTypes.ApproveTimeReports, PermissionActions.DepartmentAdminsOnly, false, "ApproveTimeReportsNote", false) + }; + } +} diff --git a/Core/Resgrid.Model/Invoicing/Invoice.cs b/Core/Resgrid.Model/Invoicing/Invoice.cs index 88e71910f..8ab192342 100644 --- a/Core/Resgrid.Model/Invoicing/Invoice.cs +++ b/Core/Resgrid.Model/Invoicing/Invoice.cs @@ -54,6 +54,10 @@ public class Invoice : IEntity /// Reserved (Phase B2): always null in v1, Resgrid takes no fee. public decimal? PlatformFeeAmount { get; set; } + + // Phase C provenance (M0219): the contract and deployment a contractor invoice was generated from. + public string ServiceContractId { get; set; } + public string DeploymentId { get; set; } public bool IsDeleted { get; set; } public DateTime AddedOn { get; set; } public string AddedByUserId { get; set; } diff --git a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs index 8b5a02e48..8b40c4b55 100644 --- a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs +++ b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs @@ -25,6 +25,9 @@ public class InvoiceLineItem : IEntity public decimal UnitRate { get; set; } public decimal Amount { get; set; } public bool Taxable { get; set; } = true; + + /// Phase C provenance (M0219): the approved daily time report this line was generated from. + public string DeploymentTimeReportId { get; set; } public int SortOrder { get; set; } public bool IsProtected { get; set; } public int ProtectedCatalogVersion { get; set; } diff --git a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs index fb6b064b3..667c01869 100644 --- a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs @@ -203,7 +203,8 @@ public object IdValue /// /// A received provider webhook event (plan B2.2): the idempotency ledger and the forensic copy of the body. /// The body contains no card data by construction (hosted Checkout); rows are purged after - /// PaymentConnectConfig.EventRetentionDays. + /// PaymentConnectConfig.EventRetentionDays. Its own table: the legacy SaaS-billing + /// already owns PaymentProviderEvents with a different shape. /// public class PaymentConnectEvent : IEntity { @@ -227,7 +228,7 @@ public class PaymentConnectEvent : IEntity public string PayloadJson { get; set; } [NotMapped] - public string TableName => "PaymentProviderEvents"; + public string TableName => "PaymentConnectEvents"; [NotMapped] public string IdName => "PaymentConnectEventId"; diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index f40022b14..0008adcce 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -172,7 +172,23 @@ public enum PermissionTypes /// Workforce & Business Operations plan Phase D (registry 43): view others' certification records and the expiry dashboard; own records are always visible. Defaults to department administrators. ViewCertifications = 43, /// Workforce & Business Operations plan Phase D (registry 44): certification types, role requirements and department certification settings. Defaults to department administrators. - ManageCertificationSetup = 44 + ManageCertificationSetup = 44, + + // -- Workforce & Business Operations Phase C (registry 116-119 + 79) --------------------------------- + // 118-119 are wired (catalog, claims, policies, Security page) by the deployment core milestone; 116, 117 + // and 79 are declared so the values stay locked and gain their chain with the contractor-billing and + // Cal OES MARS milestones. + + /// Phase C contractor billing (registry 116): create/edit/send/accept bids. Defaults to department administrators. Chain wired by the contractor milestone. + ManageBids = 116, + /// Phase C contractor billing (registry 117): service contracts, document requirements and compliance documents. Defaults to department administrators. Chain wired by the contractor milestone. + ManageContracts = 117, + /// Phase C deployment core (registry 118): create/edit deployments, roster, equipment, expenses, attachments and status transitions; view every deployment. Defaults to department administrators. Rostered members always see their own deployments and file time entries without it. + ManageDeployments = 118, + /// Phase C deployment core (registry 119): approve and void submitted daily time reports (TimeReports_Approve). Defaults to department administrators. + ApproveTimeReports = 119, + /// Phase C Cal OES MARS (registry 79): agency/rate/agreement management, protected portal handoff, external status observation and MARS invoice reconciliation. Defaults to department administrators. Chain wired by the MARS milestone. + ManageMutualAidReimbursement = 79 } } diff --git a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs index 74304fc1f..fe430966b 100644 --- a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs @@ -41,6 +41,9 @@ public class RecordsSearchDocumentSource public class RecordsSearchRequest { + public string Generation { get; set; } + /// Allows callers to withdraw narrative matching immediately, before the index rebuild. + public bool IncludeNarrative { get; set; } = true; public string Text { get; set; } /// Null means unrestricted; otherwise the viewer's visible group ids plus the always-visible cases. @@ -57,6 +60,8 @@ public class RecordsSearchRequest public class RecordsSearchHit { + public int DepartmentId { get; set; } + public string Generation { get; set; } public string SourceType { get; set; } public string SourceId { get; set; } public string RecordNumber { get; set; } diff --git a/Core/Resgrid.Model/Repositories/ICertificationRepositories.cs b/Core/Resgrid.Model/Repositories/ICertificationRepositories.cs index 346609924..e2b9a00f4 100644 --- a/Core/Resgrid.Model/Repositories/ICertificationRepositories.cs +++ b/Core/Resgrid.Model/Repositories/ICertificationRepositories.cs @@ -21,6 +21,14 @@ public interface IDepartmentCertificationSettingsRepository { Task GetAsync(int departmentId); Task SaveAsync(DepartmentCertificationSettings settings, CancellationToken cancellationToken = default); + /// + /// Atomically records as the department's last claimed sweep date; false when that date + /// (or a later one) is already recorded, so exactly one caller runs a department's sweep for a local day. + /// Inserts the default settings row for a department that has never saved one. + /// + Task TryClaimSweepAsync(int departmentId, DateTime localDate, CancellationToken cancellationToken = default); + /// Gives a claim back after a failed sweep so the next tick of the same local day retries it. + Task ReleaseSweepClaimAsync(int departmentId, DateTime localDate, CancellationToken cancellationToken = default); } /// Continuing-education credit entries (M0214). diff --git a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs new file mode 100644 index 000000000..fb719bf6a --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Repositories +{ + // Workforce & Business Operations plan, Phase C (C3): the deployment core repositories (M0218). Every query begins + // at DepartmentId. External orders and fills are read through IRecordDeploymentsService, never here. + + public interface IDeploymentRepository : IRepository + { + Task GetByIdForDepartmentAsync(string deploymentId, int departmentId); + Task GetByCallIdAsync(int callId, int departmentId); + Task GetByExternalOrderIdAsync(string rmsExternalOrderId, int departmentId); + /// Non-deleted deployments, newest first; keeps Planned/Standby/Active/Demobilizing. + Task> GetForDepartmentAsync(int departmentId, bool openOnly, int skip, int take); + Task CountForDepartmentAsync(int departmentId, bool openOnly); + Task> GetByIdsAsync(int departmentId, IEnumerable deploymentIds); + } + + public interface IDeploymentUnitRepository : IRepository + { + Task> GetByDeploymentAsync(string deploymentId); + /// Units seated on another open deployment overlapping the window (wizard conflict detection). + Task> GetActiveAssignmentsForUnitsAsync(int departmentId, IEnumerable unitIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId); + } + + public interface IDeploymentPersonnelRepository : IRepository + { + Task> GetByDeploymentAsync(string deploymentId); + /// Members seated on another open deployment overlapping the window (wizard conflict detection, plan C3). + Task> GetActiveAssignmentsForUsersAsync(int departmentId, IEnumerable userIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId); + /// Every roster row (active or removed) for one member; the field user's "my deployments" scope. + Task> GetForUserAsync(int departmentId, string userId); + } + + public interface IDeploymentEquipmentRepository : IRepository + { + Task> GetByDeploymentAsync(string deploymentId); + } + + public interface IDeploymentTimeReportRepository : IRepository + { + Task GetByIdForDepartmentAsync(string deploymentTimeReportId, int departmentId); + Task> GetByDeploymentAsync(string deploymentId); + Task GetByDeploymentAndDateAsync(string deploymentId, DateTime reportDate); + /// Approved reports not yet on an invoice (the billing engine's input). + Task> GetUnbilledApprovedAsync(int departmentId, string deploymentId = null); + } + + public interface IDeploymentTimeEntryRepository : IRepository + { + Task> GetByReportAsync(string deploymentTimeReportId); + Task> GetByDeploymentAsync(string deploymentId); + Task DeleteByReportAsync(string deploymentTimeReportId, CancellationToken cancellationToken = default); + } + + public interface IDeploymentExpenseRepository : IRepository + { + Task GetByIdForDepartmentAsync(string deploymentExpenseId, int departmentId); + Task> GetByDeploymentAsync(string deploymentId); + } + + public interface IDeploymentAttachmentRepository : IRepository + { + /// Attachment rows without their bytes. + Task> GetByDeploymentAsync(string deploymentId); + Task GetByIdWithDataAsync(int deploymentAttachmentId); + } + + public interface ITimeReportNumberSequenceRepository : IRepository + { + /// Atomically returns the next report number for the department and advances the sequence (dialect-specific SQL). + Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Search/SearchContracts.cs b/Core/Resgrid.Model/Search/SearchContracts.cs index 0a1123693..879f48695 100644 --- a/Core/Resgrid.Model/Search/SearchContracts.cs +++ b/Core/Resgrid.Model/Search/SearchContracts.cs @@ -43,7 +43,7 @@ public enum SearchIndexBuildState public static class GlobalSearchGeneration { /// Bump when GlobalSearchDocumentBuilder or the projection allowlist changes. - public const int SchemaVersion = 1; + public const int SchemaVersion = 2; public static string Compute(int protectedCatalogVersion, long policyEpoch) { diff --git a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs index 83ea8ae7e..ae8e41b22 100644 --- a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs +++ b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs @@ -18,7 +18,7 @@ public class SearchPrincipal /// Claim check against the caller's principal: (resource, action) → held. public Func HasClaim { get; set; } = (r, a) => false; - /// Department module toggles (Messaging, Mapping, ...) as the web layer sees them; null = all enabled. + /// Department module toggles (Messaging, Mapping, ...); missing or failed checks deny module access. public Func IsModuleEnabled { get; set; } public bool HasResourceClaim(string resource, string action) @@ -29,10 +29,10 @@ public bool HasResourceClaim(string resource, string action) public bool ModuleEnabled(string module) { - if (string.IsNullOrWhiteSpace(module) || IsModuleEnabled == null) + if (string.IsNullOrWhiteSpace(module)) return true; - try { return IsModuleEnabled(module); } - catch { return true; } + try { return IsModuleEnabled != null && IsModuleEnabled(module); } + catch { return false; } } } @@ -75,7 +75,7 @@ public class UnifiedSearchResult public List Actions { get; set; } = new List(); - /// Authorized total, or null when a hit was dropped by per-entity authorization and the total cannot be proven. + /// Authorized total, or null when any candidate was dropped or was outside the authorization window. public int? Total { get; set; } public bool Truncated { get; set; } diff --git a/Core/Resgrid.Model/Services/ICertificationService.cs b/Core/Resgrid.Model/Services/ICertificationService.cs index 08c56191f..3b3fc972a 100644 --- a/Core/Resgrid.Model/Services/ICertificationService.cs +++ b/Core/Resgrid.Model/Services/ICertificationService.cs @@ -104,6 +104,14 @@ public interface ICertificationService /// Departments the sweep has anything to do for: typed personnel records, unit records or role requirements. Task> GetDepartmentsForSweepAsync(); + /// + /// Claims as the department's sweep day; false when that day was already claimed. The + /// worker claims before it runs, so the expiring notifications for a local day are sent once even when the hourly + /// tick repeats, drifts past the configured hour or overlaps. + /// + Task TryClaimSweepDayAsync(int departmentId, DateTime localToday, CancellationToken cancellationToken = default); + /// Returns a claim after a failed sweep so the next tick of the same local day retries it. + Task ReleaseSweepDayAsync(int departmentId, DateTime localToday, CancellationToken cancellationToken = default); /// Runs the expire, expiring, unit, enforcement and digest passes for one department for its local date. Task RunExpirySweepAsync(int departmentId, DateTime localToday, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Services/IDeploymentService.cs b/Core/Resgrid.Model/Services/IDeploymentService.cs new file mode 100644 index 000000000..5fe5e4197 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDeploymentService.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Workforce & Business Operations plan Phase C (C4): the free deployment core. Deployments, their roster + /// (units, personnel seats, equipment), attachments, the manifest and the link to an RMS external order. + /// Entitlement and permission checks are the callers' job; the service trusts its caller's authorization + /// except for , which the surfaces use for the field member's own-deployment scope. + /// Every mutation audits and the lifecycle mutations publish their Workflow trigger through the domain outbox. + /// + public interface IDeploymentService + { + Task GetDeploymentByIdAsync(string deploymentId, int departmentId); + Task GetDeploymentByCallIdAsync(int callId, int departmentId); + Task GetDeploymentByExternalOrderIdAsync(string rmsExternalOrderId, int departmentId); + Task> GetDeploymentsForDepartmentAsync(int departmentId, bool openOnly, int skip = 0, int take = 100); + Task CountDeploymentsForDepartmentAsync(int departmentId, bool openOnly); + /// Deployments the member is or was rostered on (the field user's scope). + Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly); + Task IsRosteredAsync(string deploymentId, int departmentId, string userId); + + Task SaveDeploymentAsync(Deployment deployment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Planned→Standby→Active→Demobilizing→Completed, Cancelled from any open state; Completed/Cancelled are terminal. + Task SetDeploymentStatusAsync(string deploymentId, int departmentId, DeploymentStatuses status, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteDeploymentAsync(string deploymentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Creates the finance wrapper for an RMS mutual-aid deployment Record (decision 39). + Task CreateFromExternalOrderAsync(int departmentId, ExternalOrderDeploymentInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// The order and fills behind a deployment, read through the Records service (never the RMS tables). + Task GetExternalContextAsync(string deploymentId, int departmentId, string userId); + + Task AddUnitAsync(string deploymentId, int departmentId, int unitId, string callSign, string notes, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RemoveUnitAsync(string deploymentUnitId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task AddPersonnelAsync(string deploymentId, int departmentId, DeploymentPersonnelInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RemovePersonnelAsync(string deploymentPersonnelId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task AddEquipmentAsync(string deploymentId, int departmentId, DeploymentEquipmentInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ReturnEquipmentAsync(string deploymentEquipmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Seat/schedule warnings for candidate members without writing anything (the wizard's conflict badges). + Task> GetRosterWarningsAsync(string deploymentId, int departmentId, IEnumerable userIds, IEnumerable unitIds); + Task GetCrewSizeForUnitAsync(string deploymentUnitId, int departmentId); + + Task> GetAttachmentsAsync(string deploymentId, int departmentId); + Task GetAttachmentAsync(int deploymentAttachmentId, int departmentId, bool includeData); + Task SaveAttachmentAsync(DeploymentAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteAttachmentAsync(int deploymentAttachmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + Task RenderManifestHtmlAsync(string deploymentId, int departmentId); + /// Renders the manifest (roster, call signs, certifications) to PDF and files it as a Manifest attachment. + Task GenerateManifestAsync(string deploymentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IPersonnelRolesService.cs b/Core/Resgrid.Model/Services/IPersonnelRolesService.cs index 9201944e8..617fb6981 100644 --- a/Core/Resgrid.Model/Services/IPersonnelRolesService.cs +++ b/Core/Resgrid.Model/Services/IPersonnelRolesService.cs @@ -42,6 +42,14 @@ public interface IPersonnelRolesService /// Task<PersonnelRole>. Task SaveRoleAsync(PersonnelRole role, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null); + /// + /// Saves the role and replaces its membership with in one transaction: the certification + /// gate (plan D4) runs against the members the role gains before anything is deleted, and a failure anywhere leaves + /// the previous membership in place. Throws InvalidOperationException("certifications_role_requirements_unmet") when + /// a gained member is blocked under Enforce. + /// + Task ReplaceRoleMembersAsync(PersonnelRole role, IEnumerable userIds, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null); + /// /// Gets the role by department and name asynchronous. /// diff --git a/Core/Resgrid.Model/Services/ISearchServices.cs b/Core/Resgrid.Model/Services/ISearchServices.cs index 8c16cea7d..659fd5348 100644 --- a/Core/Resgrid.Model/Services/ISearchServices.cs +++ b/Core/Resgrid.Model/Services/ISearchServices.cs @@ -82,6 +82,8 @@ public interface IGlobalSearchIndexer public class GlobalSearchQuery { + /// Current department policy generation, supplied by the authorized orchestrator. + public string Generation { get; set; } public string Text { get; set; } public List EntityTypes { get; set; } public string ViewerUserId { get; set; } @@ -93,6 +95,9 @@ public class GlobalSearchQuery public class GlobalSearchHit { + public int DepartmentId { get; set; } + public string Generation { get; set; } + public long RowVersion { get; set; } public string ProjectionId { get; set; } public string EntityType { get; set; } public string EntityId { get; set; } diff --git a/Core/Resgrid.Model/Services/ITimeTrackingService.cs b/Core/Resgrid.Model/Services/ITimeTrackingService.cs new file mode 100644 index 000000000..b69f57aae --- /dev/null +++ b/Core/Resgrid.Model/Services/ITimeTrackingService.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Workforce & Business Operations plan Phase C (C4): daily time reports (DTRs), their time entries, signatures, + /// expenses and receipts. Report numbers come from the atomic per-department sequence; status transitions happen + /// only here (Draft→Submitted→Approved→Billed, Void from any unbilled state); Submitted and Approved publish + /// their Workflow trigger through the domain outbox. Callers authorize (approval needs TimeReports_Approve). + /// + public interface ITimeTrackingService + { + Task> GetTimeReportsAsync(string deploymentId, int departmentId); + Task GetTimeReportByIdAsync(string deploymentTimeReportId, int departmentId); + Task> GetUnbilledApprovedReportsAsync(int departmentId, string deploymentId = null); + + /// Allocates the next report number, copies the deployment's agency identifiers and prefills one Deployment entry per active roster subject (prior report's times when one exists). + Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Header fields only (flags, notes, identifiers); entries go through . + Task UpdateTimeReportAsync(DeploymentTimeReport report, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Replaces the report's entries as a batch after validation; errors leave the stored entries untouched. + Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + TimeReportValidation Validate(DeploymentTimeReport report, IReadOnlyList entries, IReadOnlyCollection rosterSubjectIds); + + Task SubmitTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ApproveTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task VoidTimeReportAsync(string deploymentTimeReportId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Contractor signature (the acting user) and/or the customer signer's typed name. + Task SignTimeReportAsync(string deploymentTimeReportId, int departmentId, bool contractorSigned, string customerSignerName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + Task RenderTimeReportHtmlAsync(string deploymentTimeReportId, int departmentId); + Task GetTimeReportPdfAsync(string deploymentTimeReportId, int departmentId); + /// Renders the DTR to PDF and files it as a TimeReportPdf attachment on the deployment. + Task GenerateTimeReportPdfAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// CSV of every entry on the deployment (agency reconciliation). + Task ExportTimeEntriesCsvAsync(string deploymentId, int departmentId); + + Task> GetExpensesAsync(string deploymentId, int departmentId); + Task GetExpenseByIdAsync(string deploymentExpenseId, int departmentId); + /// Creates or updates an expense; a receipt (bytes + file name + content type) becomes a Receipt attachment linked by ReceiptAttachmentId. + Task SaveExpenseAsync(DeploymentExpense expense, byte[] receipt, string receiptFileName, string receiptContentType, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteExpenseAsync(string deploymentExpenseId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 7a8d437d8..61824c7fa 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -401,6 +401,19 @@ pair.Variable is "number" or "status" or "old_status" ? "int" : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) ? "datetime" : "string", false)); list.Add(new TemplateVariableDescriptor("invoice.url", "Authenticated invoice link", "string", false)); break; + case WorkflowTriggerEventType.DeploymentCreated: + case WorkflowTriggerEventType.DeploymentStatusChanged: + case WorkflowTriggerEventType.DeploymentRosterChanged: + case WorkflowTriggerEventType.DeploymentExpenseAdded: + case WorkflowTriggerEventType.TimeReportSubmitted: + case WorkflowTriggerEventType.TimeReportApproved: + foreach (var pair in Invoicing.DeploymentWorkflowPayload.Variables) + list.Add(new TemplateVariableDescriptor("deployment." + pair.Variable, "Deployment " + pair.Variable.Replace('_', ' ') + (pair.Variable == "subject_name" ? "; REDACTED on a protected row" : ""), + pair.Variable is "status" or "old_status" or "finance_mode" or "call_id" or "subject_type" or "report_number" or "expense_type" ? "int" + : pair.Variable == "expense_amount" ? "decimal" + : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) || pair.Variable == "report_date" ? "datetime" : "string", false)); + list.Add(new TemplateVariableDescriptor("deployment.url", "Authenticated deployment link", "string", false)); + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index a8d012a1a..d80bebc35 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -75,6 +75,22 @@ public enum WorkflowTriggerEventType InvoicePaymentRefunded = 94, InvoicePaymentDisputed = 95, + // Workforce & Business Operations plan Phase C: bids, contracts, deployments, time reports (registry 74-86; plan C8). + // 81-86 are published by the deployment core (C-M1); 74-80 are registered here and published by the contractor milestone. + BidCreated = 74, + BidSent = 75, + BidAccepted = 76, + BidDeclined = 77, + BidExpired = 78, + ContractStatusChanged = 79, + ContractExpiring = 80, + DeploymentCreated = 81, + DeploymentStatusChanged = 82, + DeploymentRosterChanged = 83, + DeploymentExpenseAdded = 84, + TimeReportSubmitted = 85, + TimeReportApproved = 86, + // Workforce & Business Operations plan Phase D: certifications & role qualification (registry 87-93; plan D7). // CertificationExpiring = 23 (above) is activated by the same worker. CertificationAdded = 87, diff --git a/Core/Resgrid.Search/GlobalIndexFields.cs b/Core/Resgrid.Search/GlobalIndexFields.cs index 1e7345c9f..4e165514e 100644 --- a/Core/Resgrid.Search/GlobalIndexFields.cs +++ b/Core/Resgrid.Search/GlobalIndexFields.cs @@ -42,6 +42,7 @@ public static class GlobalIndexFields public const string Url = "Url"; public const string MetadataJson = "MetadataJson"; public const string Generation = "Generation"; + public const string RowVersion = "RowVersion"; public const int PrefixMinGram = 1; public const int PrefixMaxGram = 20; diff --git a/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs b/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs index b497e3ee8..6e7da3007 100644 --- a/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs +++ b/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs @@ -27,6 +27,7 @@ public static Document Build(SearchProjection p, string generation) new StringField(GlobalIndexFields.IsAdminOnly, p.IsAdminOnly ? "1" : "0", Field.Store.NO), new StringField(GlobalIndexFields.IsActive, p.IsActive ? "1" : "0", Field.Store.NO), new StringField(GlobalIndexFields.Generation, generation ?? string.Empty, Field.Store.YES), + new StoredField(GlobalIndexFields.RowVersion, p.RowVersion), new Int64Field(GlobalIndexFields.OccurredOn, p.OccurredOn.Ticks, Field.Store.YES), new NumericDocValuesField(GlobalIndexFields.OccurredOnSort, p.OccurredOn.Ticks) }; diff --git a/Core/Resgrid.Search/LuceneGlobalSearchService.cs b/Core/Resgrid.Search/LuceneGlobalSearchService.cs index 5a5df184b..96db6494c 100644 --- a/Core/Resgrid.Search/LuceneGlobalSearchService.cs +++ b/Core/Resgrid.Search/LuceneGlobalSearchService.cs @@ -102,6 +102,9 @@ public Task SearchAsync(int departmentId, GlobalSearchQuery var doc = searcher.Doc(scoreDoc.Doc); result.Hits.Add(new GlobalSearchHit { + DepartmentId = int.TryParse(doc.Get(GlobalIndexFields.DepartmentId), out var department) ? department : 0, + Generation = doc.Get(GlobalIndexFields.Generation), + RowVersion = long.TryParse(doc.Get(GlobalIndexFields.RowVersion), out var version) ? version : 0, ProjectionId = doc.Get(GlobalIndexFields.ProjectionId), EntityType = doc.Get(GlobalIndexFields.EntityType), EntityId = doc.Get(GlobalIndexFields.EntityId), @@ -178,6 +181,9 @@ public static Query BuildQuery(int departmentId, GlobalSearchQuery request) { new TermQuery(new Term(GlobalIndexFields.DepartmentId, departmentId.ToString())), Occur.MUST } }; + if (!string.IsNullOrWhiteSpace(request.Generation)) + query.Add(new TermQuery(new Term(GlobalIndexFields.Generation, request.Generation)), Occur.MUST); + if (request.EntityTypes != null && request.EntityTypes.Count > 0) { var types = new BooleanQuery(); diff --git a/Core/Resgrid.Search/LuceneRecordsSearchService.cs b/Core/Resgrid.Search/LuceneRecordsSearchService.cs index 9e626f05663d1d25ba8a51a9d3c9618990783591..76de9cc9b1383dbe8b77236c86cad8feed5a208b 100644 GIT binary patch delta 295 zcmdmO-C(~#i&w@awIH#mBsVp$#4|;~Rv|O5L@%VMG9a<2I8|eE0k8Dr4?;YanmP*k zr6mewiA4%2aP^uB_DDPhD+L1`E>19TPt8j$N-W9D&zqdfE60Q2YU*rO<`rU_JXuPK z3n4n0QPf%v!PQYHN-Zo+EiTbRh)td-EVfcLW+zsWJJl Nlow;#W*_N%CIC?kV~PL( delta 32 ocmZp$-)+4?i+A&NUT(I{jUt!XCtsAYXAMcMC~?~?D4Wj&0LlXkXaE2J diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index b03c55a15..5eb489b8a 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -128,6 +128,20 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Text("PersonnelCertificationCredits", "Description"), Text("PersonnelCertificationCredits", "FileName"), Binary("PersonnelCertificationCredits", "Data") }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase C (ADP catalog 28): deployment time reports, expenses and attachments. + AdpTableBinding.Direct("DeploymentTimeReports", "DeploymentTimeReportId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("DeploymentTimeReports", "CustomerSignerName") + }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("DeploymentExpenses", "DeploymentExpenseId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("DeploymentExpenses", "Description") + }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("DeploymentAttachments", "DeploymentAttachmentId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("DeploymentAttachments", "Name"), Text("DeploymentAttachments", "FileName"), Binary("DeploymentAttachments", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("Contacts", "ContactId", pkIsNumeric: false, "DepartmentId", new[] { Text("Contacts", "FirstName"), Text("Contacts", "MiddleName"), Text("Contacts", "LastName"), diff --git a/Core/Resgrid.Services/BusinessOperationsAccessService.cs b/Core/Resgrid.Services/BusinessOperationsAccessService.cs index 828d3b0b7..99f5649fc 100644 --- a/Core/Resgrid.Services/BusinessOperationsAccessService.cs +++ b/Core/Resgrid.Services/BusinessOperationsAccessService.cs @@ -22,9 +22,9 @@ public BusinessOperationsAccessService(IFeatureToggleService flags, IDepartmentS public Task CanUseInvoicingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CustomerInvoicing); - // Phase C / Phase E flag keys are declared when those phases are authored; until then the capability is off. - public Task CanUseContractorBillingAsync(int departmentId) => CanUseAsync(departmentId, "Invoicing.ContractorBilling"); - public Task CanUseCostRecoveryAsync(int departmentId) => CanUseAsync(departmentId, "CostRecovery.CalOesMars"); + public Task CanUseContractorBillingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.ContractorBilling); + public Task CanUseCostRecoveryAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CalOesMars); + // The Phase E flag key is declared when that phase is authored; until then the capability is off. public Task CanUseWorkforceAsync(int departmentId) => CanUseAsync(departmentId, "Workforce.InternalCosting"); private async Task CanUseAsync(int departmentId, string capabilityFlag) diff --git a/Core/Resgrid.Services/CertificationService.Sweep.cs b/Core/Resgrid.Services/CertificationService.Sweep.cs index 8938319e4..7214d9df6 100644 --- a/Core/Resgrid.Services/CertificationService.Sweep.cs +++ b/Core/Resgrid.Services/CertificationService.Sweep.cs @@ -15,10 +15,17 @@ namespace Resgrid.Services /// The nightly sweep (Workforce & Business Operations plan, Phase D5; worker 34). Runs once per department per /// department-local day: expire pass, expiring pass on the configured lead days, the same two passes over unit /// records, the enforcement pass (Enforce removes after grace, WarnOnly only notifies, Off evaluates nothing) and - /// the admin digest. Exact-day matching keeps every pass idempotent for a given local date. + /// the admin digest. The worker claims the local day (TryClaimSweepDayAsync) before it runs, which is what keeps the + /// expiring passes idempotent: the expire pass is idempotent on its own, the notifications are not. /// public partial class CertificationService { + public Task TryClaimSweepDayAsync(int departmentId, DateTime localToday, CancellationToken cancellationToken = default) + => _settings.TryClaimSweepAsync(departmentId, localToday.Date, cancellationToken); + + public Task ReleaseSweepDayAsync(int departmentId, DateTime localToday, CancellationToken cancellationToken = default) + => _settings.ReleaseSweepClaimAsync(departmentId, localToday.Date, cancellationToken); + public async Task> GetDepartmentsForSweepAsync() { var ids = new HashSet(); diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs index f7548455d..be7092aea 100644 --- a/Core/Resgrid.Services/CertificationService.cs +++ b/Core/Resgrid.Services/CertificationService.cs @@ -167,15 +167,22 @@ public async Task> GetDepartmentCertificationTypesAsync(int departm } if (existing != null) { - // Routing metadata the edit form does not carry survives the save. + // Routing metadata the edit form does not carry survives the save (Expired included: only a future expiry + // below returns a lapsed record to service). certification.IsDeleted = existing.IsDeleted; - if (certification.Status == 0 && existing.Status != 0 && existing.Status != (int)PersonnelCertificationStatuses.Expired) + if (certification.Status == 0 && existing.Status != 0) certification.Status = existing.Status; - if (existing.Status == (int)PersonnelCertificationStatuses.Expired && certification.ExpiresOn.HasValue && certification.ExpiresOn.Value.Date >= DateTime.UtcNow.Date) - certification.Status = (int)PersonnelCertificationStatuses.Active; certification.StatusChangedOn ??= existing.StatusChangedOn; certification.StatusChangedByUserId ??= existing.StatusChangedByUserId; certification.StatusReason ??= existing.StatusReason; + // A future expiry on an expired row returns it to service the same way RenewCertificationAsync does: a type + // that requires sign-off goes back through PendingVerification, never straight to Active. + if (existing.Status == (int)PersonnelCertificationStatuses.Expired && certification.ExpiresOn.HasValue && certification.ExpiresOn.Value.Date >= DateTime.UtcNow.Date) + { + certification.Status = type?.RequiresVerification == true ? (int)PersonnelCertificationStatuses.PendingVerification : (int)PersonnelCertificationStatuses.Active; + certification.StatusChangedOn = DateTime.UtcNow; + certification.StatusReason = null; + } certification.VerifiedByUserId ??= existing.VerifiedByUserId; certification.VerifiedOn ??= existing.VerifiedOn; certification.IsProtected = existing.IsProtected; @@ -785,9 +792,14 @@ public async Task GetExpiryDashboardAsync(int depa var horizon = leadDays.Count > 0 ? leadDays.Max() : 60; var people = (await GetCertificationsForDepartmentAsync(departmentId)).Where(r => r.IsTyped && byType.ContainsKey(r.DepartmentCertificationTypeId.Value)).ToList(); + // One cached department name list instead of a profile read per member; a member missing from it (a fresh + // account, a stale list) still resolves through the profile. + var departmentNames = (await _departments.Value.GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List()) + .Where(n => !string.IsNullOrWhiteSpace(n.UserId)).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Name?.Trim(), StringComparer.OrdinalIgnoreCase); var names = new Dictionary(); foreach (var userId in people.Select(p => p.UserId).Distinct()) - names[userId] = await DisplayNameAsync(userId); + names[userId] = departmentNames.TryGetValue(userId, out var known) && !string.IsNullOrWhiteSpace(known) ? known : await DisplayNameAsync(userId); foreach (var group in people.GroupBy(r => new { r.UserId, TypeId = r.DepartmentCertificationTypeId.Value })) { var type = byType[group.Key.TypeId]; diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs new file mode 100644 index 000000000..e48e22c88 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.Documents.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Services.Invoicing +{ + /// The deployment manifest (plan C4): roster with call signs, seats and certification snapshots, rendered as self-contained HTML and filed as a Manifest attachment. + public partial class DeploymentService + { + public async Task RenderManifestHtmlAsync(string deploymentId, int departmentId) + { + var deployment = await GetDeploymentByIdAsync(deploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var units = await _unitsService.GetUnitsForDepartmentUnlimitedAsync(departmentId) ?? new List(); + var seats = new Dictionary(); + foreach (var unitId in deployment.Units.Where(u => u.IsActive).Select(u => u.UnitId).Distinct()) + { + try { foreach (var role in await _unitsService.GetRolesForUnitAsync(unitId) ?? new List()) seats[role.UnitRoleId] = role.Name; } + catch (Exception ex) { Logging.LogException(ex, "Manifest seat names could not be read."); } + } + return RenderManifestHtml(deployment, department, units.ToDictionary(u => u.UnitId, u => u), seats); + } + + public async Task GenerateManifestAsync(string deploymentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var html = await RenderManifestHtmlAsync(deploymentId, departmentId); + var pdf = _pdfProvider.ConvertHtmlToPdf(html); + if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("deployments_pdf_failed"); + var attachment = new DeploymentAttachment + { + DeploymentId = deploymentId, DepartmentId = departmentId, AttachmentType = (int)DeploymentAttachmentTypes.Manifest, + Name = $"Manifest {DateTime.UtcNow:yyyy-MM-dd HHmm}", FileName = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}.pdf", FileType = "application/pdf", Data = pdf + }; + return await SaveAttachmentAsync(attachment, userId, ipAddress, userAgent, cancellationToken); + } + + public static string RenderManifestHtml(Deployment deployment, Department department, IReadOnlyDictionary units, IReadOnlyDictionary seatNames) + { + string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); + string D(DateTime? value) => value.HasValue ? (department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") + " UTC" : value.Value.TimeConverter(department).ToString("yyyy-MM-dd HH:mm")) : "—"; + var sb = new StringBuilder(); + sb.Append("Manifest"); + sb.Append("

").Append(E(department?.Name)).Append(" — Deployment Manifest

"); + sb.Append("
").Append(E(deployment.Name)).Append(" · ").Append(E(((DeploymentStatuses)deployment.Status).ToString())).Append("
"); + sb.Append(""); + sb.Append("
Incident # ").Append(E(deployment.IncidentNumber)).Append("Resource order # ").Append(E(deployment.ResourceOrderNumber)).Append("Request # ").Append(E(deployment.RequestNumber)).Append("
Cost code ").Append(E(deployment.CostCode)).Append("Point of hire ").Append(E(deployment.PointOfHire)).Append("Window ").Append(D(deployment.StartOn)).Append(" → ").Append(D(deployment.EndOn)).Append("
"); + + var activeUnits = deployment.Units.Where(u => u.IsActive).ToList(); + var activePeople = deployment.Personnel.Where(p => p.IsActive).ToList(); + sb.Append("

Units

"); + if (activeUnits.Count == 0) sb.Append("

No units rostered.

"); + else + { + sb.Append(""); + foreach (var unit in activeUnits) + { + units.TryGetValue(unit.UnitId, out var detail); + sb.Append(""); + } + sb.Append("
UnitTypeCall signCrewNotes
").Append(E(detail?.Name ?? unit.UnitName ?? unit.UnitId.ToString())).Append("").Append(E(detail?.Type)).Append("").Append(E(unit.CallSign)).Append("") + .Append(activePeople.Count(p => p.DeploymentUnitId == unit.DeploymentUnitId)).Append("").Append(E(unit.Notes)).Append("
"); + } + + sb.Append("

Personnel

"); + if (activePeople.Count == 0) sb.Append("

No personnel rostered.

"); + else + { + sb.Append(""); + foreach (var person in activePeople.OrderBy(p => p.DeploymentUnitId ?? "~").ThenBy(p => p.DisplayName)) + { + var unit = activeUnits.FirstOrDefault(u => u.DeploymentUnitId == person.DeploymentUnitId); + var unitName = unit == null ? string.Empty : (units.TryGetValue(unit.UnitId, out var detail) ? detail.Name : unit.UnitName); + sb.Append(""); + } + sb.Append("
NameUnitSeatCertificationCall signFill
").Append(E(person.DisplayName ?? person.UserId)).Append("").Append(E(unitName)).Append("") + .Append(E(person.UnitRoleId.HasValue && seatNames.TryGetValue(person.UnitRoleId.Value, out var seat) ? seat : string.Empty)).Append("").Append(E(person.CertificationCode)).Append("") + .Append(E(person.CallSign)).Append("").Append(E(person.RmsExternalOrderFillId)).Append("
"); + } + + var activeEquipment = deployment.Equipment.Where(e => e.IsActive).ToList(); + if (activeEquipment.Count > 0) + { + sb.Append("

Equipment

"); + foreach (var item in activeEquipment) + { + var unit = activeUnits.FirstOrDefault(u => u.DeploymentUnitId == item.DeploymentUnitId); + var unitName = unit == null ? string.Empty : (units.TryGetValue(unit.UnitId, out var detail) ? detail.Name : unit.UnitName); + sb.Append(""); + } + sb.Append("
ItemUnitIssuedNotes
").Append(E(item.FreeTextName ?? item.InventoryAssetId ?? item.InventoryItemId)).Append("").Append(E(unitName)).Append("").Append(D(item.IssuedOn)).Append("").Append(E(item.Notes)).Append("
"); + } + + sb.Append("

Generated ").Append(D(DateTime.UtcNow)).Append("

"); + return sb.ToString(); + } + } +} diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs new file mode 100644 index 000000000..2813a4eaa --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Advanced Data Protection seam for ADP catalog 28 (plan C8): DTR customer signer names, expense descriptions + /// and attachment names/bytes. Mirrors the certification catalog-27 seam: writes run as a workload caller + /// (encrypted at rest, sentinel-safe), user reads see REDACTED for protected values until the reveal path lands, + /// and attachment bytes ride the generic binary seam. Without the protection services rows are written and read as-is. + /// + public partial class DeploymentService + { + internal static void MarkProtected(DeploymentTimeReport row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion ?? 0, DeploymentProtectedFields.CatalogVersion); } + internal static void MarkProtected(DeploymentExpense row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion ?? 0, DeploymentProtectedFields.CatalogVersion); } + internal static void MarkProtected(DeploymentAttachment row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion ?? 0, DeploymentProtectedFields.CatalogVersion); } + + internal async Task SaveProtectedAsync(IRepository repository, T entity, T existing, Func rowKey, + IReadOnlyDictionary Get, Action Set)> accessors, Action markProtected, int departmentId, CancellationToken cancellationToken) where T : class, IEntity + { + if (_protectedWrite?.Value == null) + return await repository.SaveOrUpdateAsync(entity, cancellationToken); + + var key = rowKey(entity); + if (string.IsNullOrWhiteSpace(key) || key == "0") + return await InsertProtectedAsync(repository, entity, rowKey, accessors, markProtected, departmentId, cancellationToken); + + var result = await _protectedWrite.Value.PrepareRecordsEntityWriteAsync(departmentId, entity, existing, key, accessors, () => markProtected(entity), null, null, true, cancellationToken); + if (result != null && !result.Success) + throw new InvalidOperationException("deployments_protected_write_refused"); + return await repository.SaveOrUpdateAsync(entity, cancellationToken); + } + + /// A new row has no key until inserted and the key binds the envelope: hold the cataloged columns, allocate, encrypt, write. + private async Task InsertProtectedAsync(IRepository repository, T entity, Func rowKey, + IReadOnlyDictionary Get, Action Set)> accessors, Action markProtected, int departmentId, CancellationToken cancellationToken) where T : class, IEntity + { + var held = accessors.ToDictionary(a => a.Key, a => a.Value.Get(entity), StringComparer.OrdinalIgnoreCase); + if (held.Values.All(string.IsNullOrEmpty)) + return await repository.SaveOrUpdateAsync(entity, cancellationToken); + + foreach (var accessor in accessors) accessor.Value.Set(entity, null); + var allocated = await repository.SaveOrUpdateAsync(entity, cancellationToken); + foreach (var accessor in accessors) accessor.Value.Set(allocated, held[accessor.Key]); + + var result = await _protectedWrite.Value.PrepareRecordsEntityWriteAsync(departmentId, allocated, null, rowKey(allocated), accessors, () => markProtected(allocated), null, null, true, cancellationToken); + if (result != null && !result.Success) + throw new InvalidOperationException("deployments_protected_write_refused"); + return await repository.SaveOrUpdateAsync(allocated, cancellationToken); + } + + /// Attachment rows: text columns through the entity seam, bytes through the binary seam, both keyed by the allocated identity. + private async Task SaveProtectedAttachmentAsync(DeploymentAttachment attachment, CancellationToken cancellationToken) + { + if (_protectedWrite?.Value == null) + return await _attachments.SaveOrUpdateAsync(attachment, cancellationToken); + + var bytes = attachment.Data; + attachment.Data = null; + var saved = await InsertProtectedAsync(_attachments, attachment, a => a.DeploymentAttachmentId.ToString(), DeploymentProtectedFields.Attachment, MarkProtected, attachment.DepartmentId, cancellationToken); + saved.Data = bytes; + var result = await _protectedWrite.Value.PrepareRecordsBinaryWriteAsync(saved.DepartmentId, DeploymentProtectedFields.AttachmentDataFieldId, saved.DeploymentAttachmentId.ToString(), bytes, + data => saved.Data = data, () => MarkProtected(saved), null, null, true, cancellationToken); + if (result != null && !result.Success) + throw new InvalidOperationException("deployments_protected_write_refused"); + return await _attachments.SaveOrUpdateAsync(saved, cancellationToken); + } + + /// User-facing read: protected values become REDACTED (no grant is carried on this path yet). Never throws. + internal async Task ResolveReadAsync(IReadOnlyList rows, Func rowKey, IReadOnlyDictionary Get, Action Set)> accessors, int departmentId) where T : class + { + if (_protectedRead?.Value == null || rows == null || rows.Count == 0) return; + try { await _protectedRead.Value.ResolveRecordsEntitiesForReadAsync(departmentId, rows.Select(r => (r, rowKey(r))).ToList(), accessors, null, null); } + catch (Exception ex) { Logging.LogException(ex, "Protected deployment rows could not be resolved for read."); } + } + + /// User-facing read of one cataloged blob: enveloped bytes are nulled rather than served. Never throws. + internal async Task ResolveBinaryReadAsync(int departmentId, string fieldId, string rowKey, byte[] data, Action apply) + { + if (_protectedRead?.Value == null || data == null) return; + try { await _protectedRead.Value.ResolveRecordsBinaryForReadAsync(departmentId, fieldId, rowKey, data, apply, null, null); } + catch (Exception ex) { Logging.LogException(ex, "A protected deployment file could not be resolved for read."); } + } + } +} diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs new file mode 100644 index 000000000..43646ab2e --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -0,0 +1,782 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Inventories; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Phase C deployment core (Workforce & Business Operations plan, C4). Status transitions happen only here, + /// every lifecycle change publishes its Workflow trigger through the domain outbox (decision 22), the RMS + /// external order is read through and never written (decision 39), and + /// inventory issuance is optional (absent module = free-text equipment rows). Callers authorize. + /// + public partial class DeploymentService : IDeploymentService + { + private readonly IDeploymentRepository _deployments; + private readonly IDeploymentUnitRepository _units; + private readonly IDeploymentPersonnelRepository _personnel; + private readonly IDeploymentEquipmentRepository _equipment; + private readonly IDeploymentAttachmentRepository _attachments; + private readonly IDepartmentsService _departmentsService; + private readonly IUnitsService _unitsService; + private readonly IUserProfileService _userProfileService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly ICertificationService _certificationService; + private readonly IContactsService _contactsService; + private readonly ICallsService _callsService; + private readonly IRecordDeploymentsService _recordDeployments; + private readonly IDomainEventOutboxService _outbox; + private readonly IEventAggregator _eventAggregator; + private readonly IPdfProvider _pdfProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly Lazy _inventoryIssuance; + private readonly Lazy _protectedWrite; + private readonly Lazy _protectedRead; + + public DeploymentService(IDeploymentRepository deployments, IDeploymentUnitRepository units, IDeploymentPersonnelRepository personnel, + IDeploymentEquipmentRepository equipment, IDeploymentAttachmentRepository attachments, + IDepartmentsService departmentsService, IUnitsService unitsService, IUserProfileService userProfileService, + IPersonnelRolesService personnelRolesService, ICertificationService certificationService, IContactsService contactsService, + ICallsService callsService, IRecordDeploymentsService recordDeployments, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, + IPdfProvider pdfProvider, IUnitOfWork unitOfWork, + Lazy inventoryIssuance = null, Lazy protectedWrite = null, Lazy protectedRead = null) + { + _deployments = deployments; + _units = units; + _personnel = personnel; + _equipment = equipment; + _attachments = attachments; + _departmentsService = departmentsService; + _unitsService = unitsService; + _userProfileService = userProfileService; + _personnelRolesService = personnelRolesService; + _certificationService = certificationService; + _contactsService = contactsService; + _callsService = callsService; + _recordDeployments = recordDeployments; + _outbox = outbox; + _eventAggregator = eventAggregator; + _pdfProvider = pdfProvider; + _unitOfWork = unitOfWork; + _inventoryIssuance = inventoryIssuance; + _protectedWrite = protectedWrite; + _protectedRead = protectedRead; + } + + #region Reads + + public async Task GetDeploymentByIdAsync(string deploymentId, int departmentId) + { + if (string.IsNullOrWhiteSpace(deploymentId)) return null; + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || deployment.IsDeleted) return null; + await LoadRosterAsync(deployment); + return deployment; + } + + public async Task GetDeploymentByCallIdAsync(int callId, int departmentId) + { + var deployment = await _deployments.GetByCallIdAsync(callId, departmentId); + if (deployment == null) return null; + await LoadRosterAsync(deployment); + return deployment; + } + + public async Task GetDeploymentByExternalOrderIdAsync(string rmsExternalOrderId, int departmentId) + { + if (string.IsNullOrWhiteSpace(rmsExternalOrderId)) return null; + var deployment = await _deployments.GetByExternalOrderIdAsync(rmsExternalOrderId, departmentId); + if (deployment == null) return null; + await LoadRosterAsync(deployment); + return deployment; + } + + public async Task> GetDeploymentsForDepartmentAsync(int departmentId, bool openOnly, int skip = 0, int take = 100) => + (await _deployments.GetForDepartmentAsync(departmentId, openOnly, skip, take))?.ToList() ?? new List(); + + public Task CountDeploymentsForDepartmentAsync(int departmentId, bool openOnly) => _deployments.CountForDepartmentAsync(departmentId, openOnly); + + public async Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly) + { + var rows = (await _personnel.GetForUserAsync(departmentId, userId))?.ToList() ?? new List(); + if (rows.Count == 0) return new List(); + var ids = rows.Select(r => r.DeploymentId).Distinct().ToList(); + var deployments = (await _deployments.GetByIdsAsync(departmentId, ids))?.ToList() ?? new List(); + return openOnly ? deployments.Where(d => d.IsOpen).ToList() : deployments; + } + + public async Task IsRosteredAsync(string deploymentId, int departmentId, string userId) + { + if (string.IsNullOrWhiteSpace(deploymentId) || string.IsNullOrWhiteSpace(userId)) return false; + var rows = await _personnel.GetByDeploymentAsync(deploymentId); + return rows != null && rows.Any(r => r.DepartmentId == departmentId && r.UserId == userId); + } + + private async Task LoadRosterAsync(Deployment deployment) + { + deployment.Units = (await _units.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); + deployment.Personnel = (await _personnel.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); + deployment.Equipment = (await _equipment.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); + await DecorateNamesAsync(deployment); + } + + /// Unit and member display names for the roster; a lookup failure leaves the name blank rather than failing the read. + private async Task DecorateNamesAsync(Deployment deployment) + { + try + { + if (deployment.Units.Count > 0) + { + var units = (await _unitsService.GetUnitsForDepartmentUnlimitedAsync(deployment.DepartmentId))?.ToDictionary(u => u.UnitId, u => u.Name) ?? new Dictionary(); + foreach (var unit in deployment.Units) unit.UnitName = units.TryGetValue(unit.UnitId, out var name) ? name : null; + } + if (deployment.Personnel.Count > 0) + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(deployment.Personnel.Select(p => p.UserId).Distinct().ToList()); + var names = profiles?.Where(p => p != null).GroupBy(p => p.UserId).ToDictionary(g => g.Key, g => g.First().FullName.AsFirstNameLastName) ?? new Dictionary(); + foreach (var person in deployment.Personnel) person.DisplayName = names.TryGetValue(person.UserId, out var name) ? name : null; + } + } + catch (Exception ex) { Logging.LogException(ex, "Deployment roster names could not be resolved."); } + } + + #endregion + + #region Deployment lifecycle + + public async Task SaveDeploymentAsync(Deployment deployment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (deployment == null) throw new ArgumentNullException(nameof(deployment)); + if (deployment.DepartmentId <= 0) throw new ArgumentException("DepartmentId is required.", nameof(deployment)); + if (string.IsNullOrWhiteSpace(deployment.Name)) throw new InvalidOperationException("deployments_name_required"); + if (!Enum.IsDefined(typeof(DeploymentFinanceModes), deployment.FinanceMode)) throw new InvalidOperationException("deployments_finance_mode_invalid"); + if (deployment.StartOn.HasValue && deployment.EndOn.HasValue && deployment.EndOn < deployment.StartOn) throw new InvalidOperationException("deployments_window_invalid"); + if (deployment.DiscountPercent.HasValue && (deployment.DiscountPercent < 0 || deployment.DiscountPercent > 100)) throw new InvalidOperationException("deployments_discount_invalid"); + + if (!string.IsNullOrWhiteSpace(deployment.ContactId)) + { + var contact = await _contactsService.GetContactByIdAsync(deployment.ContactId); + if (contact == null || contact.DepartmentId != deployment.DepartmentId) throw new InvalidOperationException("deployments_contact_not_found"); + } + if (deployment.CallId.HasValue) + { + var call = await _callsService.GetCallByIdAsync(deployment.CallId.Value); + if (call == null || call.DepartmentId != deployment.DepartmentId) throw new InvalidOperationException("deployments_call_not_found"); + var linked = await _deployments.GetByCallIdAsync(deployment.CallId.Value, deployment.DepartmentId); + if (linked != null && linked.DeploymentId != deployment.DeploymentId) throw new InvalidOperationException("deployments_call_already_linked"); + } + + var isNew = string.IsNullOrWhiteSpace(deployment.DeploymentId); + Deployment existing = null; + if (!isNew) + { + existing = await _deployments.GetByIdForDepartmentAsync(deployment.DeploymentId, deployment.DepartmentId); + if (existing == null || existing.IsDeleted) throw new InvalidOperationException("deployments_not_found"); + // Status, provenance and audit columns never change through a save; they have their own paths. + deployment.Status = existing.Status; + deployment.StatusChangedOn = existing.StatusChangedOn; + deployment.RmsExternalOrderId = existing.RmsExternalOrderId; + deployment.BidId = existing.BidId; + deployment.AddedOn = existing.AddedOn; + deployment.AddedByUserId = existing.AddedByUserId; + deployment.IsProtected = existing.IsProtected; + deployment.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; + deployment.EditedOn = DateTime.UtcNow; + deployment.EditedByUserId = userId; + } + else + { + deployment.DeploymentId = null; + deployment.Status = (int)DeploymentStatuses.Planned; + deployment.StatusChangedOn = null; + deployment.AddedOn = DateTime.UtcNow; + deployment.AddedByUserId = userId; + deployment.EditedOn = null; + deployment.EditedByUserId = null; + } + deployment.IsDeleted = false; + deployment.Currency = string.IsNullOrWhiteSpace(deployment.Currency) ? null : deployment.Currency.Trim().ToUpperInvariant(); + + var audit = NewAuditEvent(deployment.DepartmentId, userId, isNew ? AuditLogTypes.DeploymentCreated : AuditLogTypes.DeploymentUpdated, ipAddress, userAgent); + audit.Before = existing == null ? null : Snapshot(existing); + var saved = await _deployments.SaveOrUpdateAsync(deployment, cancellationToken); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + + if (isNew) await PublishAsync(saved, WorkflowTriggerEventType.DeploymentCreated, cancellationToken: cancellationToken); + await LoadRosterAsync(saved); + return saved; + } + + public async Task SetDeploymentStatusAsync(string deploymentId, int departmentId, DeploymentStatuses status, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("deployments_not_found"); + var oldStatus = (DeploymentStatuses)deployment.Status; + if (oldStatus == status) { await LoadRosterAsync(deployment); return deployment; } + if (!IsValidTransition(oldStatus, status)) throw new InvalidOperationException("deployments_status_transition_invalid"); + + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.DeploymentStatusChanged, ipAddress, userAgent); + audit.Before = Snapshot(deployment); + deployment.Status = (int)status; + deployment.StatusChangedOn = DateTime.UtcNow; + deployment.EditedOn = DateTime.UtcNow; + deployment.EditedByUserId = userId; + if (status == DeploymentStatuses.Active && !deployment.StartOn.HasValue) deployment.StartOn = DateTime.UtcNow; + if ((status == DeploymentStatuses.Completed || status == DeploymentStatuses.Cancelled) && !deployment.EndOn.HasValue) deployment.EndOn = DateTime.UtcNow; + var saved = await _deployments.SaveOrUpdateAsync(deployment, cancellationToken); + audit.After = Snapshot(saved); + _eventAggregator.SendMessage(audit); + + await PublishAsync(saved, WorkflowTriggerEventType.DeploymentStatusChanged, oldStatus: (int)oldStatus, cancellationToken: cancellationToken); + await LoadRosterAsync(saved); + return saved; + } + + /// Planned→Standby→Active→Demobilizing→Completed; Cancelled from any open state; a step may be skipped forward, never backward. + public static bool IsValidTransition(DeploymentStatuses from, DeploymentStatuses to) + { + if (from is DeploymentStatuses.Completed or DeploymentStatuses.Cancelled) return false; + if (to == DeploymentStatuses.Cancelled) return true; + if (to == DeploymentStatuses.Planned) return false; + return (int)to > (int)from; + } + + public async Task DeleteDeploymentAsync(string deploymentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || deployment.IsDeleted) return false; + if (deployment.Status is (int)DeploymentStatuses.Active or (int)DeploymentStatuses.Demobilizing) throw new InvalidOperationException("deployments_delete_open"); + + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.DeploymentUpdated, ipAddress, userAgent); + audit.Before = Snapshot(deployment); + deployment.IsDeleted = true; + deployment.EditedOn = DateTime.UtcNow; + deployment.EditedByUserId = userId; + await _deployments.SaveOrUpdateAsync(deployment, cancellationToken); + audit.After = Snapshot(deployment); + _eventAggregator.SendMessage(audit); + return true; + } + + #endregion + + #region External orders (decision 39) + + public async Task CreateFromExternalOrderAsync(int departmentId, ExternalOrderDeploymentInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (string.IsNullOrWhiteSpace(input.RmsExternalOrderId)) throw new InvalidOperationException("deployments_external_order_required"); + var linked = await _deployments.GetByExternalOrderIdAsync(input.RmsExternalOrderId, departmentId); + if (linked != null) throw new InvalidOperationException("deployments_external_order_already_linked"); + + var aggregate = await _recordDeployments.GetAsync(departmentId, userId, input.RmsExternalOrderId); + var order = aggregate?.Order; + if (order == null || order.DepartmentId != departmentId || order.DeletedOn.HasValue) throw new InvalidOperationException("deployments_external_order_not_found"); + var fills = aggregate.Fills?.Where(f => !f.DeletedOn.HasValue).ToList() ?? new List(); + var firstFill = fills.OrderBy(f => f.RequestNumber, StringComparer.OrdinalIgnoreCase).FirstOrDefault(); + + return await TransactionAsync(async () => + { + var deployment = new Deployment + { + DepartmentId = departmentId, + RmsExternalOrderId = order.RmsExternalOrderId, + FinanceMode = (int)input.FinanceMode, + ContactId = input.ContactId, + Name = string.IsNullOrWhiteSpace(input.Name) ? (string.IsNullOrWhiteSpace(order.IncidentName) ? $"Order {order.OrderNumber}" : order.IncidentName) : input.Name.Trim(), + IncidentNumber = order.IncidentNumber, + ResourceOrderNumber = order.OrderNumber, + RequestNumber = fills.Count == 1 ? firstFill?.RequestNumber : null, + CostCode = order.CostCode ?? firstFill?.CostCode, + PointOfHire = fills.Count == 1 ? firstFill?.PointOfHire : null, + HostCountry = order.IncidentCountry, + HostSubdivision = order.IncidentSubdivision, + LocalTimeZoneId = order.TimeZoneId, + Currency = order.CurrencyCode, + MeasurementSystem = order.MeasurementSystem, + StartOn = order.MobilizedOn ?? fills.Where(f => f.NeededOn.HasValue).Select(f => f.NeededOn).OrderBy(d => d).FirstOrDefault(), + EndOn = order.ReleasedOn, + Notes = input.Notes + }; + + if (input.CreateCall) + { + var call = new Call + { + DepartmentId = departmentId, + ReportingUserId = userId, + Name = deployment.Name, + NatureOfCall = string.IsNullOrWhiteSpace(order.IncidentName) ? $"Mutual aid order {order.OrderNumber}" : $"Mutual aid: {order.IncidentName} (order {order.OrderNumber})", + IncidentNumber = order.IncidentNumber, + Priority = input.CallPriority, + Type = input.CallType, + LoggedOn = DateTime.UtcNow, + State = (int)CallStates.Active, + CallSource = (int)CallSources.User, + Notes = string.IsNullOrWhiteSpace(order.RequestingAgency) ? null : $"Requesting agency: {order.RequestingAgency}" + }; + if (!string.IsNullOrWhiteSpace(input.ContactId)) + call.Contacts = new List { new CallContact { DepartmentId = departmentId, ContactId = input.ContactId, CallContactType = 0 } }; + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + deployment.CallId = savedCall.CallId; + } + + var saved = await SaveDeploymentAsync(deployment, userId, ipAddress, userAgent, cancellationToken); + + if (input.PrefillRoster) + { + var seatable = fills.Where(f => f.Status is (int)RmsDeploymentFillStatus.Accepted or (int)RmsDeploymentFillStatus.Mobilized or (int)RmsDeploymentFillStatus.CheckedIn or (int)RmsDeploymentFillStatus.Assigned).ToList(); + var unitRows = new Dictionary(); + foreach (var unitId in seatable.Where(f => f.AssignedUnitId.HasValue).Select(f => f.AssignedUnitId.Value).Distinct()) + { + try { var result = await AddUnitAsync(saved.DeploymentId, departmentId, unitId, null, null, userId, ipAddress, userAgent, cancellationToken); unitRows[unitId] = result.Unit; } + catch (InvalidOperationException ex) { Logging.LogError($"External order {order.RmsExternalOrderId}: unit {unitId} not seated ({ex.Message})."); } + } + foreach (var fill in seatable.Where(f => !string.IsNullOrWhiteSpace(f.AssignedUserId))) + { + try + { + await AddPersonnelAsync(saved.DeploymentId, departmentId, new DeploymentPersonnelInput + { + UserId = fill.AssignedUserId, + DeploymentUnitId = fill.AssignedUnitId.HasValue && unitRows.TryGetValue(fill.AssignedUnitId.Value, out var row) ? row.DeploymentUnitId : null, + CertificationCode = fill.Position, + RmsExternalOrderFillId = fill.RmsExternalOrderFillId, + Force = true + }, userId, ipAddress, userAgent, cancellationToken); + } + catch (InvalidOperationException ex) { Logging.LogError($"External order {order.RmsExternalOrderId}: fill {fill.RmsExternalOrderFillId} not seated ({ex.Message})."); } + } + } + + return await GetDeploymentByIdAsync(saved.DeploymentId, departmentId); + }, cancellationToken); + } + + public async Task GetExternalContextAsync(string deploymentId, int departmentId, string userId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || string.IsNullOrWhiteSpace(deployment.RmsExternalOrderId)) return null; + var aggregate = await _recordDeployments.GetAsync(departmentId, userId, deployment.RmsExternalOrderId); + if (aggregate?.Order == null) return null; + return new DeploymentExternalContext { Order = aggregate.Order, Fills = aggregate.Fills?.Where(f => !f.DeletedOn.HasValue).ToList() ?? new List() }; + } + + #endregion + + #region Roster + + public async Task AddUnitAsync(string deploymentId, int departmentId, int unitId, string callSign, string notes, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await RequireOpenAsync(deploymentId, departmentId); + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != departmentId) throw new InvalidOperationException("deployments_unit_not_found"); + var current = (await _units.GetByDeploymentAsync(deploymentId))?.Where(u => u.IsActive).ToList() ?? new List(); + if (current.Any(u => u.UnitId == unitId)) throw new InvalidOperationException("deployments_unit_already_rostered"); + + var result = new DeploymentRosterResult(); + result.Warnings.AddRange(await UnitConflictsAsync(deployment, new[] { unitId })); + var row = new DeploymentUnit { DeploymentId = deploymentId, DepartmentId = departmentId, UnitId = unitId, CallSign = Trim(callSign), Notes = Trim(notes), AddedOn = DateTime.UtcNow }; + result.Unit = await _units.SaveOrUpdateAsync(row, cancellationToken); + result.Unit.UnitName = unit.Name; + + Audit(departmentId, userId, AuditLogTypes.DeploymentRosterChanged, ipAddress, userAgent, null, result.Unit); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Unit, result.Unit.DeploymentUnitId, unit.Name, "Added"), cancellationToken: cancellationToken); + return result; + } + + public async Task RemoveUnitAsync(string deploymentUnitId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _units.GetByIdAsync(deploymentUnitId); + if (row == null || row.DepartmentId != departmentId || !row.IsActive) return false; + var deployment = await RequireOpenAsync(row.DeploymentId, departmentId); + var before = Snapshot(row); + row.RemovedOn = DateTime.UtcNow; + await _units.SaveOrUpdateAsync(row, cancellationToken); + // Seats on the unit stay rostered but lose the unit link. + foreach (var person in (await _personnel.GetByDeploymentAsync(row.DeploymentId))?.Where(p => p.IsActive && p.DeploymentUnitId == deploymentUnitId) ?? Enumerable.Empty()) + { + person.DeploymentUnitId = null; + await _personnel.SaveOrUpdateAsync(person, cancellationToken); + } + Audit(departmentId, userId, AuditLogTypes.DeploymentRosterChanged, ipAddress, userAgent, before, row); + var unit = await _unitsService.GetUnitByIdAsync(row.UnitId); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Unit, row.DeploymentUnitId, unit?.Name, "Removed"), cancellationToken: cancellationToken); + return true; + } + + public async Task AddPersonnelAsync(string deploymentId, int departmentId, DeploymentPersonnelInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (string.IsNullOrWhiteSpace(input.UserId)) throw new InvalidOperationException("deployments_user_required"); + var deployment = await RequireOpenAsync(deploymentId, departmentId); + var profile = await _userProfileService.GetProfileByUserIdAsync(input.UserId); + if (profile == null) throw new InvalidOperationException("deployments_user_not_found"); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var members = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId); + if (members == null || members.All(m => m.UserId != input.UserId)) throw new InvalidOperationException("deployments_user_not_in_department"); + + var current = (await _personnel.GetByDeploymentAsync(deploymentId))?.Where(p => p.IsActive).ToList() ?? new List(); + if (current.Any(p => p.UserId == input.UserId)) throw new InvalidOperationException("deployments_user_already_rostered"); + + DeploymentUnit seatUnit = null; + if (!string.IsNullOrWhiteSpace(input.DeploymentUnitId)) + { + seatUnit = (await _units.GetByDeploymentAsync(deploymentId))?.FirstOrDefault(u => u.DeploymentUnitId == input.DeploymentUnitId && u.IsActive); + if (seatUnit == null) throw new InvalidOperationException("deployments_unit_not_found"); + } + + var result = new DeploymentRosterResult(); + result.Warnings.AddRange(await PersonnelConflictsAsync(deployment, new[] { input.UserId })); + if (input.UnitRoleId.HasValue) + result.Warnings.AddRange(await SeatWarningsAsync(departmentId, input.UserId, input.UnitRoleId.Value, seatUnit?.UnitId, deployment)); + if (result.HasBlockingWarnings && !input.Force) return result; + + var row = new DeploymentPersonnel + { + DeploymentId = deploymentId, DepartmentId = departmentId, UserId = input.UserId, DeploymentUnitId = seatUnit?.DeploymentUnitId, UnitRoleId = input.UnitRoleId, + CertificationCode = Trim(input.CertificationCode), CallSign = Trim(input.CallSign), RmsExternalOrderFillId = Trim(input.RmsExternalOrderFillId), AddedOn = DateTime.UtcNow + }; + result.Personnel = await _personnel.SaveOrUpdateAsync(row, cancellationToken); + result.Personnel.DisplayName = profile.FullName.AsFirstNameLastName; + foreach (var warning in result.Warnings) warning.Blocking = false; + + Audit(departmentId, userId, AuditLogTypes.DeploymentRosterChanged, ipAddress, userAgent, null, result.Personnel); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Personnel, result.Personnel.DeploymentPersonnelId, result.Personnel.DisplayName, "Added"), subjectUserId: input.UserId, cancellationToken: cancellationToken); + return result; + } + + public async Task RemovePersonnelAsync(string deploymentPersonnelId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _personnel.GetByIdAsync(deploymentPersonnelId); + if (row == null || row.DepartmentId != departmentId || !row.IsActive) return false; + var deployment = await RequireOpenAsync(row.DeploymentId, departmentId); + var before = Snapshot(row); + row.RemovedOn = DateTime.UtcNow; + await _personnel.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.DeploymentRosterChanged, ipAddress, userAgent, before, row); + var profile = await _userProfileService.GetProfileByUserIdAsync(row.UserId); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Personnel, row.DeploymentPersonnelId, profile?.FullName.AsFirstNameLastName, "Removed"), subjectUserId: row.UserId, cancellationToken: cancellationToken); + return true; + } + + public async Task AddEquipmentAsync(string deploymentId, int departmentId, DeploymentEquipmentInput input, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (string.IsNullOrWhiteSpace(input.FreeTextName) && string.IsNullOrWhiteSpace(input.InventoryAssetId) && string.IsNullOrWhiteSpace(input.InventoryItemId)) + throw new InvalidOperationException("deployments_equipment_required"); + var deployment = await RequireOpenAsync(deploymentId, departmentId); + DeploymentUnit unit = null; + if (!string.IsNullOrWhiteSpace(input.DeploymentUnitId)) + { + unit = (await _units.GetByDeploymentAsync(deploymentId))?.FirstOrDefault(u => u.DeploymentUnitId == input.DeploymentUnitId && u.IsActive); + if (unit == null) throw new InvalidOperationException("deployments_unit_not_found"); + } + + var result = new DeploymentRosterResult(); + var row = new DeploymentEquipment + { + DeploymentId = deploymentId, DepartmentId = departmentId, DeploymentUnitId = unit?.DeploymentUnitId, InventoryAssetId = Trim(input.InventoryAssetId), InventoryItemId = Trim(input.InventoryItemId), + FreeTextName = Trim(input.FreeTextName), Notes = Trim(input.Notes), AddedOn = DateTime.UtcNow + }; + + // Inventory plan M1: an Issue transaction referencing the deployment when the module is present (plan C4; absent = free-text row). + if (input.IssueFromInventory && _inventoryIssuance?.Value != null && (row.InventoryAssetId != null || row.InventoryItemId != null)) + { + try + { + await _inventoryIssuance.Value.IssueAsync(new InventoryActor { DepartmentId = departmentId, UserId = userId }, new InventoryIssueInput + { + RequestId = Guid.NewGuid().ToString("N"), ItemId = row.InventoryItemId, AssetId = row.InventoryAssetId, FromLocationId = Trim(input.FromLocationId), Quantity = 1, + UnitId = unit?.UnitId, ExpectedReturnOn = deployment.EndOn, ReferenceType = InventoryReferenceType.Deployment, ReferenceId = deploymentId, Note = $"Deployment {deployment.Name}" + }); + row.IssuedOn = DateTime.UtcNow; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Deployment {deploymentId}: inventory issue failed; equipment row kept without an issuance."); + result.Warnings.Add(new DeploymentRosterWarning { Code = "inventory_issue_failed", Detail = ex.Message }); + } + } + + result.Equipment = await _equipment.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.DeploymentEquipmentChanged, ipAddress, userAgent, null, result.Equipment); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Equipment, result.Equipment.DeploymentEquipmentId, row.FreeTextName ?? row.InventoryAssetId ?? row.InventoryItemId, "Added"), cancellationToken: cancellationToken); + return result; + } + + public async Task ReturnEquipmentAsync(string deploymentEquipmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _equipment.GetByIdAsync(deploymentEquipmentId); + if (row == null || row.DepartmentId != departmentId || !row.IsActive) return false; + var deployment = await _deployments.GetByIdForDepartmentAsync(row.DeploymentId, departmentId); + if (deployment == null) return false; + var before = Snapshot(row); + row.ReturnedOn = DateTime.UtcNow; + await _equipment.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.DeploymentEquipmentChanged, ipAddress, userAgent, before, row); + await PublishAsync(deployment, WorkflowTriggerEventType.DeploymentRosterChanged, subject: (DeploymentTimeSubjectTypes.Equipment, row.DeploymentEquipmentId, row.FreeTextName ?? row.InventoryAssetId ?? row.InventoryItemId, "Removed"), cancellationToken: cancellationToken); + return true; + } + + public async Task> GetRosterWarningsAsync(string deploymentId, int departmentId, IEnumerable userIds, IEnumerable unitIds) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + var warnings = new List(); + warnings.AddRange(await PersonnelConflictsAsync(deployment, userIds?.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct().ToList() ?? new List())); + warnings.AddRange(await UnitConflictsAsync(deployment, unitIds?.Distinct().ToList() ?? new List())); + return warnings; + } + + public async Task GetCrewSizeForUnitAsync(string deploymentUnitId, int departmentId) + { + var unit = await _units.GetByIdAsync(deploymentUnitId); + if (unit == null || unit.DepartmentId != departmentId) return 0; + return (await _personnel.GetByDeploymentAsync(unit.DeploymentId))?.Count(p => p.IsActive && p.DeploymentUnitId == deploymentUnitId) ?? 0; + } + + private async Task> PersonnelConflictsAsync(Deployment deployment, IReadOnlyCollection userIds) + { + var warnings = new List(); + if (userIds.Count == 0) return warnings; + var (start, end) = Window(deployment); + var overlaps = await _personnel.GetActiveAssignmentsForUsersAsync(deployment.DepartmentId, userIds, start, end, deployment.DeploymentId); + foreach (var overlap in overlaps ?? Enumerable.Empty()) + warnings.Add(new DeploymentRosterWarning { Code = DeploymentRosterWarning.ScheduleConflict, SubjectId = overlap.UserId, Detail = overlap.DeploymentId }); + return warnings; + } + + private async Task> UnitConflictsAsync(Deployment deployment, IReadOnlyCollection unitIds) + { + var warnings = new List(); + if (unitIds.Count == 0) return warnings; + var (start, end) = Window(deployment); + var overlaps = await _units.GetActiveAssignmentsForUnitsAsync(deployment.DepartmentId, unitIds, start, end, deployment.DeploymentId); + foreach (var overlap in overlaps ?? Enumerable.Empty()) + warnings.Add(new DeploymentRosterWarning { Code = DeploymentRosterWarning.ScheduleConflict, SubjectId = overlap.UnitId.ToString(), Detail = overlap.DeploymentId }); + return warnings; + } + + /// Seat qualification chain (plan C6 step 3): UnitRole → PersonnelRoleRequired → the role's Phase D certification requirements. + private async Task> SeatWarningsAsync(int departmentId, string userId, int unitRoleId, int? unitId, Deployment deployment) + { + var warnings = new List(); + UnitRole seat = null; + try { seat = await _unitsService.GetRoleByIdAsync(unitRoleId); } catch (Exception ex) { Logging.LogException(ex, "Deployment seat role could not be read."); } + if (seat == null || (unitId.HasValue && seat.UnitId != unitId.Value)) throw new InvalidOperationException("deployments_seat_not_found"); + if (!seat.PersonnelRoleId.HasValue) return warnings; + + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); + var holds = roles != null && roles.Any(r => r.PersonnelRoleId == seat.PersonnelRoleId.Value); + if (!holds) + warnings.Add(new DeploymentRosterWarning { Code = DeploymentRosterWarning.RoleNotHeld, SubjectId = userId, Detail = seat.Name, Blocking = seat.PersonnelRoleRequired }); + + try + { + var evaluation = await _certificationService.EvaluateUserForRoleAsync(departmentId, seat.PersonnelRoleId.Value, userId); + if (evaluation != null) + { + foreach (var violation in evaluation.Violations) + warnings.Add(new DeploymentRosterWarning { Code = DeploymentRosterWarning.CertificationMissing, SubjectId = userId, Detail = violation.TypeName ?? violation.TypeCode, Blocking = seat.PersonnelRoleRequired && violation.IsMandatory }); + var windowEnd = deployment.EndOn ?? (deployment.StartOn ?? DateTime.UtcNow).AddDays(deployment.MaxDays ?? 14); + foreach (var outcome in evaluation.Outcomes.Where(o => o.Satisfied && o.ExpiresOn.HasValue && o.ExpiresOn.Value <= windowEnd)) + warnings.Add(new DeploymentRosterWarning { Code = DeploymentRosterWarning.CertificationExpiring, SubjectId = userId, Detail = $"{outcome.TypeName ?? outcome.TypeCode}|{outcome.ExpiresOn:yyyy-MM-dd}" }); + } + } + catch (Exception ex) { Logging.LogException(ex, "Deployment seat certification evaluation failed; seat allowed with no certification warnings."); } + return warnings; + } + + private static (DateTime Start, DateTime End) Window(Deployment deployment) + { + var start = deployment.StartOn ?? DateTime.UtcNow; + var end = deployment.EndOn ?? start.AddDays(deployment.MaxDays ?? 14); + return (start, end < start ? start : end); + } + + private async Task RequireOpenAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("deployments_not_found"); + if (!deployment.IsOpen) throw new InvalidOperationException("deployments_closed"); + return deployment; + } + + #endregion + + #region Attachments + + public async Task> GetAttachmentsAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null) return new List(); + var rows = (await _attachments.GetByDeploymentAsync(deploymentId))?.Where(a => a.DepartmentId == departmentId).ToList() ?? new List(); + await ResolveReadAsync(rows, a => a.DeploymentAttachmentId.ToString(), DeploymentProtectedFields.Attachment, departmentId); + return rows; + } + + public async Task GetAttachmentAsync(int deploymentAttachmentId, int departmentId, bool includeData) + { + var row = await _attachments.GetByIdWithDataAsync(deploymentAttachmentId); + if (row == null || row.DepartmentId != departmentId || row.IsDeleted) return null; + await ResolveReadAsync(new[] { row }, a => a.DeploymentAttachmentId.ToString(), DeploymentProtectedFields.Attachment, departmentId); + if (includeData) await ResolveBinaryReadAsync(departmentId, DeploymentProtectedFields.AttachmentDataFieldId, row.DeploymentAttachmentId.ToString(), row.Data, bytes => row.Data = bytes); + else row.Data = null; + return row; + } + + public async Task SaveAttachmentAsync(DeploymentAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (attachment == null) throw new ArgumentNullException(nameof(attachment)); + if (attachment.Data == null || attachment.Data.Length == 0) throw new InvalidOperationException("deployments_attachment_empty"); + if (attachment.Data.Length > MaxAttachmentBytes) throw new InvalidOperationException("deployments_attachment_too_large"); + var deployment = await _deployments.GetByIdForDepartmentAsync(attachment.DeploymentId, attachment.DepartmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("deployments_not_found"); + if (!Enum.IsDefined(typeof(DeploymentAttachmentTypes), attachment.AttachmentType)) attachment.AttachmentType = (int)DeploymentAttachmentTypes.Other; + + attachment.DeploymentAttachmentId = 0; + attachment.FileSize = attachment.Data.Length; + attachment.Name = string.IsNullOrWhiteSpace(attachment.Name) ? attachment.FileName : attachment.Name.Trim(); + attachment.IsDeleted = false; + attachment.AddedOn = DateTime.UtcNow; + attachment.AddedByUserId = userId; + var saved = await SaveProtectedAttachmentAsync(attachment, cancellationToken); + Audit(attachment.DepartmentId, userId, AuditLogTypes.DeploymentAttachmentAdded, ipAddress, userAgent, null, WithoutBytes(saved)); + return WithoutBytes(saved); + } + + public async Task DeleteAttachmentAsync(int deploymentAttachmentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _attachments.GetByIdWithDataAsync(deploymentAttachmentId); + if (row == null || row.DepartmentId != departmentId || row.IsDeleted) return false; + row.IsDeleted = true; + await _attachments.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.DeploymentAttachmentRemoved, ipAddress, userAgent, null, WithoutBytes(row)); + return true; + } + + public const int MaxAttachmentBytes = 30 * 1024 * 1024; + + internal static DeploymentAttachment WithoutBytes(DeploymentAttachment row) + { + var copy = row.CloneJson(); + copy.Data = null; + return copy; + } + + #endregion + + #region Events and audit + + /// Publishes a lifecycle trigger through the domain outbox. The payload is identifiers, status, dates and the roster subject; names are REDACTED on a protected row. + private async Task PublishAsync(Deployment deployment, WorkflowTriggerEventType trigger, int? oldStatus = null, + (DeploymentTimeSubjectTypes Type, string Id, string Name, string Action)? subject = null, string subjectUserId = null, + DeploymentTimeReport report = null, DeploymentExpense expense = null, CancellationToken cancellationToken = default) + { + try + { + await _outbox.EnqueueAsync(deployment.DepartmentId, DeploymentWorkflowPayload.Producer, new DomainEventEnvelope + { + EventName = trigger.ToString(), + AggregateType = "Deployment", + AggregateId = deployment.DeploymentId, + AggregateVersion = 0, + Trigger = trigger, + OccurredOn = DateTime.UtcNow, + CorrelationId = report?.DeploymentTimeReportId ?? deployment.DeploymentId, + Payload = new + { + deployment.DeploymentId, deployment.Name, deployment.Status, OldStatus = oldStatus, deployment.FinanceMode, deployment.CallId, + deployment.IncidentNumber, deployment.ResourceOrderNumber, deployment.RequestNumber, deployment.CostCode, deployment.RmsExternalOrderId, deployment.ContactId, + deployment.StartOn, deployment.EndOn, + SubjectType = subject.HasValue ? (int?)subject.Value.Type : null, + SubjectId = subject?.Id, + SubjectName = subject.HasValue ? (deployment.IsProtected ? ProtectedDataEnvelope.RedactionValue : subject.Value.Name) : null, + SubjectUserId = subjectUserId, + RosterAction = subject?.Action, + ReportNumber = report?.ReportNumber, ReportDate = report?.ReportDate, TimeReportId = report?.DeploymentTimeReportId, + ExpenseType = expense?.ExpenseType, ExpenseAmount = expense?.Amount, ExpenseCurrency = expense?.Currency ?? deployment.Currency + } + }, cancellationToken); + } + catch (Exception ex) + { + // A failed publish never undoes a committed change; it is logged and visible in the outbox health. + Logging.LogException(ex, $"Deployment {deployment.DeploymentId} {trigger} could not be published."); + } + } + + internal Task PublishTimeReportAsync(Deployment deployment, WorkflowTriggerEventType trigger, DeploymentTimeReport report, CancellationToken cancellationToken) => + PublishAsync(deployment, trigger, report: report, cancellationToken: cancellationToken); + + internal Task PublishExpenseAsync(Deployment deployment, DeploymentExpense expense, CancellationToken cancellationToken) => + PublishAsync(deployment, WorkflowTriggerEventType.DeploymentExpenseAdded, expense: expense, cancellationToken: cancellationToken); + + private void Audit(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent, string before, T after) + { + var audit = NewAuditEvent(departmentId, userId, type, ipAddress, userAgent); + audit.Before = before; + audit.After = after == null ? null : Snapshot(after); + _eventAggregator.SendMessage(audit); + } + + internal static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + switch (clone) + { + case Deployment deployment: deployment.Units = null; deployment.Personnel = null; deployment.Equipment = null; break; + case DeploymentTimeReport report: report.Entries = null; break; + case DeploymentAttachment attachment: attachment.Data = null; break; + } + return clone.CloneJsonToString(); + } + + internal static AuditEvent NewAuditEvent(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent) => new AuditEvent + { + DepartmentId = departmentId, + UserId = userId, + Type = type, + Successful = true, + IpAddress = ipAddress, + UserAgent = userAgent, + ServerName = Environment.MachineName + }; + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Runs in the scope's transaction, joining one the caller already opened (tests pass no unit of work and run unwrapped). + private async Task TransactionAsync(Func> action, CancellationToken cancellationToken) + { + if (_unitOfWork == null || _unitOfWork.Transaction != null) + return await action(); + try + { + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); + var result = await action(); + _unitOfWork.CommitChanges(); + return result; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs new file mode 100644 index 000000000..04c80eaae --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs @@ -0,0 +1,611 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Helpers; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Phase C daily time reports (Workforce & Business Operations plan, C4). Numbers come from the atomic + /// per-department sequence, entries are validated and replaced as a batch, status transitions happen only here, + /// and Submitted/Approved publish through the deployment outbox producer. The ADP catalog-28 seam of + /// protects the customer signer name, expense descriptions and receipts. + /// + public class TimeTrackingService : ITimeTrackingService + { + private readonly IDeploymentRepository _deployments; + private readonly IDeploymentPersonnelRepository _personnel; + private readonly IDeploymentUnitRepository _units; + private readonly IDeploymentEquipmentRepository _equipment; + private readonly IDeploymentTimeReportRepository _reports; + private readonly IDeploymentTimeEntryRepository _entries; + private readonly IDeploymentExpenseRepository _expenses; + private readonly IDeploymentAttachmentRepository _attachments; + private readonly ITimeReportNumberSequenceRepository _sequence; + private readonly IDeploymentService _deploymentService; + private readonly IDepartmentsService _departmentsService; + private readonly IUserProfileService _userProfileService; + private readonly IUnitsService _unitsService; + private readonly IEventAggregator _eventAggregator; + private readonly IPdfProvider _pdfProvider; + private readonly IUnitOfWork _unitOfWork; + + /// A span longer than this without any break is flagged (plan C4 "breaks per 5-h rule"). + public const int BreakRuleHours = 5; + /// Travel beyond this many hours in a day needs approval (plan C4). + public const int LongTravelHours = 12; + private const int DefaultStartHour = 8; + private const int DefaultEndHour = 16; + + public TimeTrackingService(IDeploymentRepository deployments, IDeploymentPersonnelRepository personnel, IDeploymentUnitRepository units, IDeploymentEquipmentRepository equipment, + IDeploymentTimeReportRepository reports, IDeploymentTimeEntryRepository entries, IDeploymentExpenseRepository expenses, IDeploymentAttachmentRepository attachments, + ITimeReportNumberSequenceRepository sequence, IDeploymentService deploymentService, IDepartmentsService departmentsService, IUserProfileService userProfileService, + IUnitsService unitsService, IEventAggregator eventAggregator, IPdfProvider pdfProvider, IUnitOfWork unitOfWork) + { + _deployments = deployments; + _personnel = personnel; + _units = units; + _equipment = equipment; + _reports = reports; + _entries = entries; + _expenses = expenses; + _attachments = attachments; + _sequence = sequence; + _deploymentService = deploymentService; + _departmentsService = departmentsService; + _userProfileService = userProfileService; + _unitsService = unitsService; + _eventAggregator = eventAggregator; + _pdfProvider = pdfProvider; + _unitOfWork = unitOfWork; + } + + private DeploymentService Core => _deploymentService as DeploymentService; + + #region Reads + + public async Task> GetTimeReportsAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null) return new List(); + var reports = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.DepartmentId == departmentId).ToList() ?? new List(); + await ResolveReportsAsync(reports, departmentId); + return reports; + } + + public async Task GetTimeReportByIdAsync(string deploymentTimeReportId, int departmentId) + { + if (string.IsNullOrWhiteSpace(deploymentTimeReportId)) return null; + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) return null; + report.Entries = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToList() ?? new List(); + await ResolveReportsAsync(new[] { report }, departmentId); + return report; + } + + public async Task> GetUnbilledApprovedReportsAsync(int departmentId, string deploymentId = null) + { + var reports = (await _reports.GetUnbilledApprovedAsync(departmentId, deploymentId))?.ToList() ?? new List(); + await ResolveReportsAsync(reports, departmentId); + return reports; + } + + private Task ResolveReportsAsync(IReadOnlyList reports, int departmentId) => + Core == null ? Task.CompletedTask : Core.ResolveReadAsync(reports, r => r.DeploymentTimeReportId, DeploymentProtectedFields.TimeReport, departmentId); + + #endregion + + #region Reports + + public async Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deploymentService.GetDeploymentByIdAsync(deploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + if (!deployment.IsOpen) throw new InvalidOperationException("deployments_closed"); + var day = reportDate.Date; + var existing = await _reports.GetByDeploymentAndDateAsync(deploymentId, day); + if (existing != null) throw new InvalidOperationException("timereports_date_exists"); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var timeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? department?.TimeZone : deployment.LocalTimeZoneId; + + return await TransactionAsync(async () => + { + var report = new DeploymentTimeReport + { + DeploymentId = deploymentId, DepartmentId = departmentId, ReportNumber = await _sequence.GetNextNumberAsync(departmentId, cancellationToken), ReportDate = day, + Status = (int)DeploymentTimeReportStatuses.Draft, IncidentNumber = deployment.IncidentNumber, ResourceOrderNumber = deployment.ResourceOrderNumber, + RequestNumber = deployment.RequestNumber, CostCode = deployment.CostCode, PointOfHire = deployment.PointOfHire, + AddedOn = DateTime.UtcNow, AddedByUserId = userId + }; + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); + + // Prefill: one Deployment entry per active roster subject, copying the previous report's span for the same subject when there is one. + var previous = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.ReportDate < day && r.Status != (int)DeploymentTimeReportStatuses.Void).OrderByDescending(r => r.ReportDate).FirstOrDefault(); + var previousEntries = previous == null ? new List() : (await _entries.GetByReportAsync(previous.DeploymentTimeReportId))?.ToList() ?? new List(); + var defaultStart = ToUtc(day.AddHours(DefaultStartHour), timeZone); + var defaultEnd = ToUtc(day.AddHours(DefaultEndHour), timeZone); + var sort = 0; + var subjects = deployment.Personnel.Where(p => p.IsActive).Select(p => (Type: DeploymentTimeSubjectTypes.Personnel, Id: p.DeploymentPersonnelId, Cert: p.CertificationCode, Unit: p.DeploymentUnitId)) + .Concat(deployment.Units.Where(u => u.IsActive).Select(u => (Type: DeploymentTimeSubjectTypes.Unit, Id: u.DeploymentUnitId, Cert: (string)null, Unit: u.DeploymentUnitId))) + .Concat(deployment.Equipment.Where(e => e.IsActive).Select(e => (Type: DeploymentTimeSubjectTypes.Equipment, Id: e.DeploymentEquipmentId, Cert: (string)null, Unit: e.DeploymentUnitId))); + foreach (var subject in subjects) + { + var prior = previousEntries.Where(e => e.SubjectId == subject.Id && e.EntryType == (int)DeploymentTimeEntryTypes.Deployment).OrderBy(e => e.StartTime).FirstOrDefault(); + var start = prior == null ? defaultStart : day.Add(ToLocal(prior.StartTime, timeZone).TimeOfDay); + var end = prior == null ? defaultEnd : day.Add(ToLocal(prior.EndTime, timeZone).TimeOfDay); + if (prior != null) { start = ToUtc(start, timeZone); end = ToUtc(end, timeZone); if (end <= start) end = end.AddDays(1); } + var crew = subject.Type == DeploymentTimeSubjectTypes.Unit ? deployment.Personnel.Count(p => p.IsActive && p.DeploymentUnitId == subject.Id) : (int?)null; + await _entries.SaveOrUpdateAsync(new DeploymentTimeEntry + { + DeploymentTimeReportId = saved.DeploymentTimeReportId, DeploymentId = deploymentId, DepartmentId = departmentId, SubjectType = (int)subject.Type, + DeploymentPersonnelId = subject.Type == DeploymentTimeSubjectTypes.Personnel ? subject.Id : null, + DeploymentUnitId = subject.Type == DeploymentTimeSubjectTypes.Unit ? subject.Id : null, + DeploymentEquipmentId = subject.Type == DeploymentTimeSubjectTypes.Equipment ? subject.Id : null, + EntryType = (int)DeploymentTimeEntryTypes.Deployment, StartTime = start, EndTime = end, + PaidBreakMinutes = prior?.PaidBreakMinutes ?? 0, UnpaidBreakMinutes = prior?.UnpaidBreakMinutes ?? 0, CrewSizeSnapshot = crew, CertificationCode = subject.Cert, + AgencySuppliedMeals = prior?.AgencySuppliedMeals ?? false, AgencySuppliedAccommodation = prior?.AgencySuppliedAccommodation ?? false, SortOrder = sort++ + }, cancellationToken); + } + + Audit(departmentId, userId, AuditLogTypes.TimeReportCreated, ipAddress, userAgent, null, saved); + return await GetTimeReportByIdAsync(saved.DeploymentTimeReportId, departmentId); + }, cancellationToken); + } + + public async Task UpdateTimeReportAsync(DeploymentTimeReport report, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (report == null) throw new ArgumentNullException(nameof(report)); + var existing = await _reports.GetByIdForDepartmentAsync(report.DeploymentTimeReportId, report.DepartmentId); + if (existing == null || existing.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (!existing.IsEditable) throw new InvalidOperationException("timereports_locked"); + + var before = DeploymentService.Snapshot(existing); + existing.IncidentNumber = Trim(report.IncidentNumber); + existing.ResourceOrderNumber = Trim(report.ResourceOrderNumber); + existing.RequestNumber = Trim(report.RequestNumber); + existing.CostCode = Trim(report.CostCode); + existing.PointOfHire = Trim(report.PointOfHire); + existing.NoClear8 = report.NoClear8; + existing.UnsafeConditionsStandDown = report.UnsafeConditionsStandDown; + existing.Notes = Trim(report.Notes); + existing.RmsExternalOrderFillId = Trim(report.RmsExternalOrderFillId); + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + var saved = await _reports.SaveOrUpdateAsync(existing, cancellationToken); + Audit(report.DepartmentId, userId, AuditLogTypes.TimeReportUpdated, ipAddress, userAgent, before, saved); + return await GetTimeReportByIdAsync(saved.DeploymentTimeReportId, report.DepartmentId); + } + + public async Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (!report.IsEditable) throw new InvalidOperationException("timereports_locked"); + var deployment = await _deploymentService.GetDeploymentByIdAsync(report.DeploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + + var incoming = (entries ?? new List()).Where(e => e != null).ToList(); + var roster = RosterSubjects(deployment); + foreach (var entry in incoming) + { + // The subject id decides the type; the caller's SubjectType is not trusted. + if (!string.IsNullOrWhiteSpace(entry.DeploymentPersonnelId)) { entry.SubjectType = (int)DeploymentTimeSubjectTypes.Personnel; entry.DeploymentUnitId = null; entry.DeploymentEquipmentId = null; } + else if (!string.IsNullOrWhiteSpace(entry.DeploymentUnitId)) { entry.SubjectType = (int)DeploymentTimeSubjectTypes.Unit; entry.DeploymentEquipmentId = null; } + else if (!string.IsNullOrWhiteSpace(entry.DeploymentEquipmentId)) entry.SubjectType = (int)DeploymentTimeSubjectTypes.Equipment; + if (!Enum.IsDefined(typeof(DeploymentTimeEntryTypes), entry.EntryType)) entry.EntryType = (int)DeploymentTimeEntryTypes.Deployment; + entry.PaidBreakMinutes = Math.Max(0, entry.PaidBreakMinutes); + entry.UnpaidBreakMinutes = Math.Max(0, entry.UnpaidBreakMinutes); + entry.Notes = Trim(entry.Notes); + entry.CertificationCode = Trim(entry.CertificationCode); + } + var validation = Validate(report, incoming, roster.Keys); + var result = new TimeReportSaveResult { Validation = validation }; + if (!validation.IsValid) + { + result.Report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + return result; + } + + var before = DeploymentService.Snapshot(report); + await TransactionAsync(async () => + { + await _entries.DeleteByReportAsync(deploymentTimeReportId, cancellationToken); + var sort = 0; + foreach (var entry in incoming.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime)) + { + entry.DeploymentTimeEntryId = null; + entry.DeploymentTimeReportId = deploymentTimeReportId; + entry.DeploymentId = report.DeploymentId; + entry.DepartmentId = departmentId; + entry.SortOrder = sort++; + if (entry.SubjectType == (int)DeploymentTimeSubjectTypes.Unit && !entry.CrewSizeSnapshot.HasValue) + entry.CrewSizeSnapshot = deployment.Personnel.Count(p => p.IsActive && p.DeploymentUnitId == entry.DeploymentUnitId); + await _entries.SaveOrUpdateAsync(entry, cancellationToken); + } + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + await _reports.SaveOrUpdateAsync(report, cancellationToken); + return true; + }, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportUpdated, ipAddress, userAgent, before, report); + result.Report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + return result; + } + + public TimeReportValidation Validate(DeploymentTimeReport report, IReadOnlyList entries, IReadOnlyCollection rosterSubjectIds) + { + var validation = new TimeReportValidation(); + if (entries == null || entries.Count == 0) + { + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.NoEntries }); + return validation; + } + var dayStart = report.ReportDate.Date.AddDays(-1); + var dayEnd = report.ReportDate.Date.AddDays(2); + foreach (var entry in entries) + { + var subject = entry.SubjectId; + if (string.IsNullOrWhiteSpace(subject) || (rosterSubjectIds != null && !rosterSubjectIds.Contains(subject))) + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.SubjectNotOnRoster, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId }); + if (entry.EndTime <= entry.StartTime) + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.EndBeforeStart, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId }); + if (entry.StartTime < dayStart || entry.EndTime > dayEnd) + validation.Warnings.Add(new TimeReportIssue { Code = TimeReportValidation.OutsideReportDate, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId }); + if ((entry.EndTime - entry.StartTime).TotalHours > BreakRuleHours && entry.PaidBreakMinutes + entry.UnpaidBreakMinutes == 0 && entry.EntryType != (int)DeploymentTimeEntryTypes.Travel) + validation.Warnings.Add(new TimeReportIssue { Code = TimeReportValidation.BreakRule, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId, Detail = BreakRuleHours.ToString(CultureInfo.InvariantCulture) }); + } + foreach (var group in entries.Where(e => !string.IsNullOrWhiteSpace(e.SubjectId)).GroupBy(e => e.SubjectId)) + { + var ordered = group.OrderBy(e => e.StartTime).ToList(); + for (var i = 1; i < ordered.Count; i++) + if (ordered[i].StartTime < ordered[i - 1].EndTime) + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.Overlap, SubjectId = group.Key, EntryId = ordered[i].DeploymentTimeEntryId }); + var travel = group.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Travel).Sum(e => e.Hours); + if (travel > LongTravelHours) + validation.Warnings.Add(new TimeReportIssue { Code = TimeReportValidation.LongTravel, SubjectId = group.Key, Detail = travel.ToString("0.##", CultureInfo.InvariantCulture) }); + } + return validation; + } + + public async Task SubmitTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (report.Status != (int)DeploymentTimeReportStatuses.Draft) throw new InvalidOperationException("timereports_status_transition_invalid"); + var deployment = await _deploymentService.GetDeploymentByIdAsync(report.DeploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + var entries = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToList() ?? new List(); + var validation = Validate(report, entries, RosterSubjects(deployment).Keys); + var result = new TimeReportSaveResult { Validation = validation }; + if (!validation.IsValid) { result.Report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); return result; } + + var before = DeploymentService.Snapshot(report); + report.Status = (int)DeploymentTimeReportStatuses.Submitted; + report.SubmittedByUserId = userId; + report.SubmittedOn = DateTime.UtcNow; + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportSubmitted, ipAddress, userAgent, before, saved); + if (Core != null) await Core.PublishTimeReportAsync(deployment, WorkflowTriggerEventType.TimeReportSubmitted, saved, cancellationToken); + result.Report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + return result; + } + + public async Task ApproveTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (report.Status != (int)DeploymentTimeReportStatuses.Submitted) throw new InvalidOperationException("timereports_status_transition_invalid"); + var deployment = await _deployments.GetByIdForDepartmentAsync(report.DeploymentId, departmentId); + + var before = DeploymentService.Snapshot(report); + report.Status = (int)DeploymentTimeReportStatuses.Approved; + report.ApprovedByUserId = userId; + report.ApprovedOn = DateTime.UtcNow; + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportApproved, ipAddress, userAgent, before, saved); + if (Core != null && deployment != null) await Core.PublishTimeReportAsync(deployment, WorkflowTriggerEventType.TimeReportApproved, saved, cancellationToken); + return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + } + + public async Task VoidTimeReportAsync(string deploymentTimeReportId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (report.Status is (int)DeploymentTimeReportStatuses.Billed or (int)DeploymentTimeReportStatuses.Void || !string.IsNullOrWhiteSpace(report.InvoiceId)) + throw new InvalidOperationException("timereports_status_transition_invalid"); + + var before = DeploymentService.Snapshot(report); + report.Status = (int)DeploymentTimeReportStatuses.Void; + report.Notes = string.IsNullOrWhiteSpace(reason) ? report.Notes : (string.IsNullOrWhiteSpace(report.Notes) ? $"Void: {reason.Trim()}" : $"{report.Notes}\nVoid: {reason.Trim()}"); + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportVoided, ipAddress, userAgent, before, saved); + return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + } + + public async Task SignTimeReportAsync(string deploymentTimeReportId, int departmentId, bool contractorSigned, string customerSignerName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + if (report.Status is (int)DeploymentTimeReportStatuses.Void or (int)DeploymentTimeReportStatuses.Billed) throw new InvalidOperationException("timereports_locked"); + var existing = report.CloneJson(); + var before = DeploymentService.Snapshot(report); + if (contractorSigned) { report.ContractorSignedByUserId = userId; report.ContractorSignedOn = DateTime.UtcNow; } + var signer = Trim(customerSignerName); + if (signer != null) { report.CustomerSignerName = signer; report.CustomerSignedOn = DateTime.UtcNow; } + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + var saved = Core == null + ? await _reports.SaveOrUpdateAsync(report, cancellationToken) + : await Core.SaveProtectedAsync(_reports, report, existing, r => r.DeploymentTimeReportId, DeploymentProtectedFields.TimeReport, DeploymentService.MarkProtected, departmentId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportUpdated, ipAddress, userAgent, before, saved); + return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + } + + private static Dictionary RosterSubjects(Deployment deployment) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in deployment.Personnel) map[p.DeploymentPersonnelId] = p.DisplayName ?? p.UserId; + foreach (var u in deployment.Units) map[u.DeploymentUnitId] = u.UnitName ?? u.UnitId.ToString(); + foreach (var e in deployment.Equipment) map[e.DeploymentEquipmentId] = e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId; + return map; + } + + #endregion + + #region Documents and export + + public async Task RenderTimeReportHtmlAsync(string deploymentTimeReportId, int departmentId) + { + var report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); + if (report == null) throw new InvalidOperationException("timereports_not_found"); + var deployment = await _deploymentService.GetDeploymentByIdAsync(report.DeploymentId, departmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var signer = string.IsNullOrWhiteSpace(report.ContractorSignedByUserId) ? null : await _userProfileService.GetProfileByUserIdAsync(report.ContractorSignedByUserId); + return RenderTimeReportHtml(report, deployment, department, RosterSubjects(deployment), signer?.FullName.AsFirstNameLastName); + } + + public async Task GetTimeReportPdfAsync(string deploymentTimeReportId, int departmentId) + { + var html = await RenderTimeReportHtmlAsync(deploymentTimeReportId, departmentId); + return _pdfProvider.ConvertHtmlToPdf(html); + } + + public async Task GenerateTimeReportPdfAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); + if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); + var pdf = await GetTimeReportPdfAsync(deploymentTimeReportId, departmentId); + if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("deployments_pdf_failed"); + return await _deploymentService.SaveAttachmentAsync(new DeploymentAttachment + { + DeploymentId = report.DeploymentId, DepartmentId = departmentId, AttachmentType = (int)DeploymentAttachmentTypes.TimeReportPdf, + Name = $"DTR {report.ReportNumber} {report.ReportDate:yyyy-MM-dd}", FileName = $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf", FileType = "application/pdf", Data = pdf + }, userId, ipAddress, userAgent, cancellationToken); + } + + public static string RenderTimeReportHtml(DeploymentTimeReport report, Deployment deployment, Department department, IReadOnlyDictionary subjectNames, string contractorSignerName) + { + string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); + string T(DateTime value) => department == null ? value.ToString("HH:mm") : value.TimeConverter(department).ToString("HH:mm"); + string D(DateTime? value) => value.HasValue ? (department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") : value.Value.TimeConverter(department).ToString("yyyy-MM-dd HH:mm")) : "—"; + var sb = new StringBuilder(); + sb.Append("Daily Time Report"); + sb.Append("

").Append(E(department?.Name)).Append(" — Daily Time Report #").Append(report.ReportNumber).Append("

"); + sb.Append("
").Append(E(deployment?.Name)).Append(" · ").Append(report.ReportDate.ToString("yyyy-MM-dd")).Append(" · ").Append(E(((DeploymentTimeReportStatuses)report.Status).ToString())).Append("
"); + sb.Append(""); + sb.Append("
Incident # ").Append(E(report.IncidentNumber)).Append("Resource order # ").Append(E(report.ResourceOrderNumber)).Append("Request # ").Append(E(report.RequestNumber)).Append("
Cost code ").Append(E(report.CostCode)).Append("Point of hire ").Append(E(report.PointOfHire)).Append(""); + if (report.NoClear8) sb.Append("No clear 8 "); + if (report.UnsafeConditionsStandDown) sb.Append("Unsafe conditions stand-down"); + sb.Append("
"); + + sb.Append("

Time entries

"); + decimal total = 0; + foreach (var entry in report.Entries.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime)) + { + total += entry.Hours; + sb.Append(""); + } + sb.Append("
SubjectTypeStartEndPaid breakUnpaid breakHoursCrewCertKmNotes
").Append(E(subjectNames != null && entry.SubjectId != null && subjectNames.TryGetValue(entry.SubjectId, out var name) ? name : entry.SubjectId)).Append("").Append(E(((DeploymentTimeEntryTypes)entry.EntryType).ToString())).Append("") + .Append(T(entry.StartTime)).Append("").Append(T(entry.EndTime)).Append("").Append(entry.PaidBreakMinutes).Append("").Append(entry.UnpaidBreakMinutes).Append("") + .Append(entry.Hours.ToString("0.00", CultureInfo.InvariantCulture)).Append("").Append(entry.CrewSizeSnapshot?.ToString() ?? string.Empty).Append("").Append(E(entry.CertificationCode)).Append("") + .Append(entry.MileageKm?.ToString("0.#", CultureInfo.InvariantCulture) ?? string.Empty).Append("").Append(E(entry.Notes)).Append("
Total hours").Append(total.ToString("0.00", CultureInfo.InvariantCulture)).Append("
"); + if (!string.IsNullOrWhiteSpace(report.Notes)) sb.Append("

Notes

").Append(E(report.Notes).Replace("\n", "
")).Append("

"); + + sb.Append("

Signatures

Contractor: ").Append(E(contractorSignerName)).Append(report.ContractorSignedOn.HasValue ? " · " + D(report.ContractorSignedOn) : string.Empty) + .Append("Customer: ").Append(E(ProtectedDataEnvelope.SafeDisplay(report.CustomerSignerName))).Append(report.CustomerSignedOn.HasValue ? " · " + D(report.CustomerSignedOn) : string.Empty).Append("
"); + sb.Append("

Generated ").Append(D(DateTime.UtcNow)).Append("

"); + return sb.ToString(); + } + + public async Task ExportTimeEntriesCsvAsync(string deploymentId, int departmentId) + { + var deployment = await _deploymentService.GetDeploymentByIdAsync(deploymentId, departmentId); + if (deployment == null) throw new InvalidOperationException("deployments_not_found"); + var reports = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.DepartmentId == departmentId).ToDictionary(r => r.DeploymentTimeReportId) ?? new Dictionary(); + var entries = (await _entries.GetByDeploymentAsync(deploymentId))?.Where(e => e.DepartmentId == departmentId && reports.ContainsKey(e.DeploymentTimeReportId)).ToList() ?? new List(); + var names = RosterSubjects(deployment); + static string C(object value) + { + var text = value switch { null => string.Empty, DateTime d => d.ToString("o", CultureInfo.InvariantCulture), decimal m => m.ToString(CultureInfo.InvariantCulture), _ => value.ToString() }; + return text.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0 ? "\"" + text.Replace("\"", "\"\"") + "\"" : text; + } + var sb = new StringBuilder(); + sb.AppendLine("ReportNumber,ReportDate,ReportStatus,IncidentNumber,ResourceOrderNumber,RequestNumber,CostCode,SubjectType,SubjectId,Subject,EntryType,StartUtc,EndUtc,PaidBreakMinutes,UnpaidBreakMinutes,Hours,CrewSize,CertificationCode,MileageKm,FuelDeductionLitres,AgencyMeals,AgencyAccommodation,Notes,InvoiceId"); + foreach (var entry in entries.OrderBy(e => reports[e.DeploymentTimeReportId].ReportDate).ThenBy(e => reports[e.DeploymentTimeReportId].ReportNumber).ThenBy(e => e.SortOrder)) + { + var report = reports[entry.DeploymentTimeReportId]; + sb.AppendLine(string.Join(",", new[] + { + C(report.ReportNumber), C(report.ReportDate.ToString("yyyy-MM-dd")), C(((DeploymentTimeReportStatuses)report.Status).ToString()), C(report.IncidentNumber), C(report.ResourceOrderNumber), C(report.RequestNumber), C(report.CostCode), + C(((DeploymentTimeSubjectTypes)entry.SubjectType).ToString()), C(entry.SubjectId), C(entry.SubjectId != null && names.TryGetValue(entry.SubjectId, out var name) ? name : null), C(((DeploymentTimeEntryTypes)entry.EntryType).ToString()), + C(entry.StartTime), C(entry.EndTime), C(entry.PaidBreakMinutes), C(entry.UnpaidBreakMinutes), C(entry.Hours), C(entry.CrewSizeSnapshot), C(entry.CertificationCode), C(entry.MileageKm), C(entry.FuelDeductionLitres), + C(entry.AgencySuppliedMeals), C(entry.AgencySuppliedAccommodation), C(entry.Notes), C(report.InvoiceId) + })); + } + return sb.ToString(); + } + + #endregion + + #region Expenses + + public async Task> GetExpensesAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null) return new List(); + var rows = (await _expenses.GetByDeploymentAsync(deploymentId))?.Where(e => e.DepartmentId == departmentId).ToList() ?? new List(); + if (Core != null) await Core.ResolveReadAsync(rows, e => e.DeploymentExpenseId, DeploymentProtectedFields.Expense, departmentId); + return rows; + } + + public async Task GetExpenseByIdAsync(string deploymentExpenseId, int departmentId) + { + var row = await _expenses.GetByIdForDepartmentAsync(deploymentExpenseId, departmentId); + if (row == null || row.IsDeleted) return null; + if (Core != null) await Core.ResolveReadAsync(new[] { row }, e => e.DeploymentExpenseId, DeploymentProtectedFields.Expense, departmentId); + return row; + } + + public async Task SaveExpenseAsync(DeploymentExpense expense, byte[] receipt, string receiptFileName, string receiptContentType, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (expense == null) throw new ArgumentNullException(nameof(expense)); + if (expense.Amount < 0) throw new InvalidOperationException("expenses_amount_invalid"); + if (!Enum.IsDefined(typeof(DeploymentExpenseTypes), expense.ExpenseType)) throw new InvalidOperationException("expenses_type_invalid"); + var deployment = await _deployments.GetByIdForDepartmentAsync(expense.DeploymentId, expense.DepartmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("deployments_not_found"); + if (!string.IsNullOrWhiteSpace(expense.DeploymentTimeReportId)) + { + var report = await _reports.GetByIdForDepartmentAsync(expense.DeploymentTimeReportId, expense.DepartmentId); + if (report == null || report.DeploymentId != expense.DeploymentId) throw new InvalidOperationException("timereports_not_found"); + } + + var isNew = string.IsNullOrWhiteSpace(expense.DeploymentExpenseId); + DeploymentExpense existing = null; + if (!isNew) + { + existing = await _expenses.GetByIdForDepartmentAsync(expense.DeploymentExpenseId, expense.DepartmentId); + if (existing == null || existing.IsDeleted) throw new InvalidOperationException("expenses_not_found"); + expense.ReceiptAttachmentId ??= existing.ReceiptAttachmentId; + expense.AddedOn = existing.AddedOn; + expense.AddedByUserId = existing.AddedByUserId; + expense.IsProtected = existing.IsProtected; + expense.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; + expense.EditedOn = DateTime.UtcNow; + expense.EditedByUserId = userId; + } + else + { + expense.DeploymentExpenseId = null; + expense.AddedOn = DateTime.UtcNow; + expense.AddedByUserId = userId; + } + expense.IsDeleted = false; + expense.Currency = string.IsNullOrWhiteSpace(expense.Currency) ? deployment.Currency : expense.Currency.Trim().ToUpperInvariant(); + expense.Description = Trim(expense.Description); + expense.MealCode = Trim(expense.MealCode); + expense.City = Trim(expense.City); + expense.ExpenseDate = expense.ExpenseDate == default ? DateTime.UtcNow.Date : expense.ExpenseDate.Date; + + if (receipt != null && receipt.Length > 0) + { + var attachment = await _deploymentService.SaveAttachmentAsync(new DeploymentAttachment + { + DeploymentId = expense.DeploymentId, DepartmentId = expense.DepartmentId, AttachmentType = (int)DeploymentAttachmentTypes.Receipt, + Name = $"Receipt {expense.ExpenseDate:yyyy-MM-dd}", FileName = Trim(receiptFileName) ?? "receipt", FileType = Trim(receiptContentType) ?? "application/octet-stream", Data = receipt + }, userId, ipAddress, userAgent, cancellationToken); + expense.ReceiptAttachmentId = attachment.DeploymentAttachmentId; + } + + var before = existing == null ? null : DeploymentService.Snapshot(existing); + var saved = Core == null + ? await _expenses.SaveOrUpdateAsync(expense, cancellationToken) + : await Core.SaveProtectedAsync(_expenses, expense, existing, e => e.DeploymentExpenseId, DeploymentProtectedFields.Expense, DeploymentService.MarkProtected, expense.DepartmentId, cancellationToken); + Audit(expense.DepartmentId, userId, isNew ? AuditLogTypes.DeploymentExpenseAdded : AuditLogTypes.DeploymentExpenseUpdated, ipAddress, userAgent, before, saved); + if (isNew && Core != null) await Core.PublishExpenseAsync(deployment, saved, cancellationToken); + return await GetExpenseByIdAsync(saved.DeploymentExpenseId, expense.DepartmentId); + } + + public async Task DeleteExpenseAsync(string deploymentExpenseId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _expenses.GetByIdForDepartmentAsync(deploymentExpenseId, departmentId); + if (row == null || row.IsDeleted) return false; + if (!string.IsNullOrWhiteSpace(row.DeploymentTimeReportId)) + { + var report = await _reports.GetByIdForDepartmentAsync(row.DeploymentTimeReportId, departmentId); + if (report != null && report.Status is (int)DeploymentTimeReportStatuses.Approved or (int)DeploymentTimeReportStatuses.Billed) throw new InvalidOperationException("timereports_locked"); + } + var before = DeploymentService.Snapshot(row); + row.IsDeleted = true; + row.EditedOn = DateTime.UtcNow; + row.EditedByUserId = userId; + await _expenses.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.DeploymentExpenseRemoved, ipAddress, userAgent, before, row); + return true; + } + + #endregion + + #region Helpers + + private void Audit(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent, string before, T after) + { + var audit = DeploymentService.NewAuditEvent(departmentId, userId, type, ipAddress, userAgent); + audit.Before = before; + audit.After = after == null ? null : DeploymentService.Snapshot(after); + _eventAggregator.SendMessage(audit); + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static DateTime ToUtc(DateTime local, string timeZone) + { + try { return string.IsNullOrWhiteSpace(timeZone) ? DateTime.SpecifyKind(local, DateTimeKind.Utc) : DateTimeHelpers.ConvertToUtc(local, timeZone, true); } + catch { return DateTime.SpecifyKind(local, DateTimeKind.Utc); } + } + + private static DateTime ToLocal(DateTime utc, string timeZone) + { + try { return string.IsNullOrWhiteSpace(timeZone) ? utc : DateTimeHelpers.GetLocalDateTime(utc, timeZone); } + catch { return utc; } + } + + private async Task TransactionAsync(Func> action, CancellationToken cancellationToken) + { + if (_unitOfWork == null || _unitOfWork.Transaction != null) + return await action(); + try + { + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); + var result = await action(); + _unitOfWork.CommitChanges(); + return result; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/PersonnelRolesService.cs b/Core/Resgrid.Services/PersonnelRolesService.cs index 1caa631bb..b320f7c76 100644 --- a/Core/Resgrid.Services/PersonnelRolesService.cs +++ b/Core/Resgrid.Services/PersonnelRolesService.cs @@ -140,6 +140,55 @@ public async Task> GetAllRolesForDepartmentAsync(int departm return saved; } + public async Task ReplaceRoleMembersAsync(PersonnelRole role, IEnumerable userIds, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null) + { + if (role == null || role.PersonnelRoleId <= 0) + throw new ArgumentException("An existing role is required.", nameof(role)); + + var incoming = (userIds ?? Enumerable.Empty()).Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var current = (await _personnelRoleUsersRepository.GetAllMembersOfRoleAsync(role.PersonnelRoleId))?.Where(m => m != null).ToList() ?? new List(); + var currentIds = new HashSet(current.Select(m => m.UserId), StringComparer.OrdinalIgnoreCase); + var incomingIds = new HashSet(incoming, StringComparer.OrdinalIgnoreCase); + var added = incoming.Where(u => !currentIds.Contains(u)).ToList(); + var removed = current.Where(m => !incomingIds.Contains(m.UserId)).ToList(); + + // Gate before the delete: only the members the role gains are evaluated, so a standing member who is inside a + // grace period does not block a rename, and nothing is removed for a save that will be refused. + foreach (var userId in added) + { + var check = await CheckRoleMembershipAsync(role.DepartmentId, userId, new[] { role.PersonnelRoleId }); + if (check.IsBlocked) + throw new InvalidOperationException("certifications_role_requirements_unmet"); + } + + // Delete-then-cascade under one transaction: the repository cascades the Users collection on the role save, + // and the explicit delete of the previous rows is what lets the cascade re-insert the new membership. + PersonnelRole saved; + _unitOfWork.CreateOrGetConnection(); + try + { + foreach (var member in current) + await _personnelRoleUsersRepository.DeleteAsync(member, cancellationToken); + + role.Users = incoming.Select(userId => new PersonnelRoleUser { PersonnelRoleId = role.PersonnelRoleId, DepartmentId = role.DepartmentId, UserId = userId }).ToList(); + saved = await _personnelRolesRepository.SaveOrUpdateAsync(role, cancellationToken); + + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + + foreach (var member in removed) + AuditMembership(role.DepartmentId, actingUserId, AuditLogTypes.RoleMemberRemoved, member.UserId, role.PersonnelRoleId, saved.Name); + foreach (var userId in added) + AuditMembership(role.DepartmentId, actingUserId, AuditLogTypes.RoleMemberAdded, userId, saved.PersonnelRoleId, saved.Name); + SendRoleVisibilityRefresh(role.DepartmentId); + return saved; + } + public async Task GetRoleByDepartmentAndNameAsync(int departmentId, string name) { return await _personnelRolesRepository.GetRoleByDepartmentAndNameAsync(departmentId, name.Trim()); diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 1c53fec83..3ca057cbb 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -731,6 +731,16 @@ void Prevention(string table, string column, ProtectedFieldClassification classi personnel ? PermissionTypes.ViewProtectedPersonnelData : PermissionTypes.EditProtectedCallData, Resgrid.Model.Certifications.CertificationProtectedFields.CatalogVersion)); } + + // Workforce & Business Operations plan, Phase C (catalog 28, registered with M0218): the deployment core's + // customer signer name on daily time reports, expense descriptions and attachment names/bytes (receipts, + // signed requests, DTR PDFs, manifests), under the Contacts family. Identifiers, statuses, times, hours, + // amounts and the roster stay metadata. The Cal OES MARS identity/rate/agreement fields and compliance + // document blobs join this version with their milestones. + foreach (var (table, column, binary) in Resgrid.Model.Invoicing.DeploymentProtectedFields.All()) + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ContactsFamily, table, column, + binary ? ProtectedFieldStorageKind.Binary : ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Pii, + PermissionTypes.ViewProtectedContactData, PermissionTypes.ViewProtectedContactData, Resgrid.Model.Invoicing.DeploymentProtectedFields.CatalogVersion)); return list; } } diff --git a/Core/Resgrid.Services/Search/SystemActionCatalog.cs b/Core/Resgrid.Services/Search/SystemActionCatalog.cs index bad4d2e28..42f8cf1c9 100644 --- a/Core/Resgrid.Services/Search/SystemActionCatalog.cs +++ b/Core/Resgrid.Services/Search/SystemActionCatalog.cs @@ -34,6 +34,7 @@ public static class SystemActionCatalog private const string WorkOrder = "WorkOrder"; private const string Invoicing = "Invoicing"; private const string Certifications = "Certifications"; + private const string Deployments = "Deployments"; private const string Group = "Group"; private const string Protocols = "Protocols"; private const string Forms = "Forms"; @@ -153,6 +154,11 @@ private static SystemActionDefinition Act(string key, string title, string descr Nav("certification-settings", "Certification Settings", "Enforcement mode, grace period and expiry notifications", "/User/Certifications/Settings", new[] { "certification", "enforcement", "grace", "expiry", "notifications" }, Certifications, "Setup"), Nav("my-certifications", "My Certifications", "Your own certification records", "/User/Profile/Certifications", new[] { "my certifications", "my certs", "my licenses", "credentials" }), + // Workforce & Business Operations plan, Phase C (deployment core; free behind Operations.Deployments). DTRs, expenses and attachments are never projected (decision 41). + Nav("deployments", "Deployment Finance", "Deployments, rosters, daily time reports and expenses", "/User/Deployments", new[] { "deployment", "deployments", "strike team", "mutual aid", "time report", "dtr", "shift ticket", "roster", "expenses" }, flag: FeatureFlagKeys.Deployments), + Act("new-deployment", "New Deployment", "Create a deployment finance wrapper", "/User/Deployments/New", SystemActionCategories.Create, new[] { "deployment", "deploy", "strike team" }, Deployments, Update, flag: FeatureFlagKeys.Deployments), + Act("deployment-from-external-order", "Deployment From External Order", "Create a deployment from an open RMS mutual-aid order", "/User/Deployments/FromExternalOrder", SystemActionCategories.Create, new[] { "external order", "mutual aid", "resource order", "deployment" }, Deployments, Update, flag: FeatureFlagKeys.Deployments), + // ---- Messaging / chat Nav("inbox", "Inbox", "Your messages inbox", "/User/Messages/Inbox", new[] { "messages", "mail", "read" }, Messages, View, SystemActionModules.Messaging), Nav("outbox", "Sent Messages", "Messages you sent", "/User/Messages/Outbox", new[] { "sent", "outbox" }, Messages, View, SystemActionModules.Messaging), diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs new file mode 100644 index 000000000..d16705575 --- /dev/null +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Search; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Search +{ + public partial class UnifiedSearchService + { + private readonly IDepartmentsService _departments; + private readonly IPermissionsService _permissions; + private readonly IDepartmentGroupsService _groups; + private readonly IPersonnelRolesService _roles; + private readonly ICallsService _calls; + private readonly IUnitsService _units; + private readonly IMessageService _messages; + private readonly IDocumentsService _documents; + private readonly INotesService _notes; + private readonly IContactsService _contacts; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly IDepartmentSettingsService _departmentSettings; + private readonly ISearchProjectionsRepository _projections; + + // One read operation owns this snapshot. Never cache authorization across users or requests. + private sealed class SearchAccess + { + public SearchPrincipal Principal; + public DepartmentGroupMember Group; + public List Roles; + public Permission PersonnelPermission; + public Permission UnitPermission; + public int CatalogVersion; + public long PolicyEpoch; + public bool ProtectedTextAllowed; + public string GlobalGeneration => GlobalSearchGeneration.Compute(CatalogVersion, PolicyEpoch); + public string RecordsGeneration => RecordsSearchGeneration.Compute(CatalogVersion, PolicyEpoch); + } + + private async Task LoadAccessAsync(SearchPrincipal supplied) + { + try + { + var department = await _departments.GetDepartmentByIdAsync(supplied.DepartmentId, true); + var member = await _departments.GetDepartmentMemberAsync(supplied.UserId, supplied.DepartmentId, true); + if (department == null || department.DepartmentId != supplied.DepartmentId) + return null; + var owner = department.ManagingUserId == supplied.UserId; + if (member == null ? !owner : !ActiveMember(member, supplied.DepartmentId, supplied.UserId)) + return null; + + var modules = await _departmentSettings.GetDepartmentModuleSettingsAsync(supplied.DepartmentId, true); + if (modules == null) return null; + var policy = await _dataProtection.GetPolicyByDepartmentIdAsync(supplied.DepartmentId, true); + if (policy != null && policy.DepartmentId != supplied.DepartmentId) return null; + var group = await _groups.GetGroupMemberForUserAsync(supplied.UserId, supplied.DepartmentId); + if (group != null && (group.DepartmentId != supplied.DepartmentId || group.UserId != supplied.UserId)) return null; + + return new SearchAccess + { + Principal = new SearchPrincipal + { + UserId = supplied.UserId, + DepartmentId = supplied.DepartmentId, + IsDepartmentAdmin = supplied.IsDepartmentAdmin && (owner || member?.IsAdmin == true), + IsGroupAdmin = group?.IsAdmin == true, + HasClaim = supplied.HasClaim, + IsModuleEnabled = module => ModuleEnabled(modules, module) + }, + Group = group, + Roles = (await _roles.GetRolesForUserAsync(supplied.UserId, supplied.DepartmentId) ?? new List()) + .Where(r => r.DepartmentId == supplied.DepartmentId).ToList(), + PersonnelPermission = await _permissions.GetPermissionByDepartmentTypeAsync(supplied.DepartmentId, PermissionTypes.ViewGroupUsers), + UnitPermission = await _permissions.GetPermissionByDepartmentTypeAsync(supplied.DepartmentId, PermissionTypes.ViewGroupUnits), + CatalogVersion = policy?.CatalogVersion ?? 0, + PolicyEpoch = policy?.PolicyEpoch ?? 0, + // Enrollment and offboarding are also restrictive: no stale plaintext matches during a transition. + ProtectedTextAllowed = policy == null || policy.State == (int)DepartmentDataProtectionState.Disabled + }; + } + catch (Exception ex) + { + Logging.LogException(ex, "Search access could not be verified."); + return null; + } + } + + private static bool ActiveMember(DepartmentMember member, int departmentId, string userId) => + member != null && member.DepartmentId == departmentId && member.UserId == userId && + !member.IsDeleted && member.IsDisabled != true; + + private static bool ProjectionIsCurrent(GlobalSearchHit hit, SearchProjection projection, SearchAccess access) => + hit.DepartmentId == access.Principal.DepartmentId && projection.DepartmentId == access.Principal.DepartmentId && + hit.Generation == access.GlobalGeneration && hit.RowVersion > 0 && hit.RowVersion == projection.RowVersion && + hit.EntityType == projection.EntityType && hit.EntityId == projection.EntityId && !projection.DeletedOn.HasValue && + projection.PolicyEpoch == access.PolicyEpoch && projection.ProtectedCatalogVersion == access.CatalogVersion && + (!projection.IsAdminOnly || access.Principal.IsDepartmentAdmin) && + (!projection.IncludesProtectedText || access.ProtectedTextAllowed); + + private async Task AuthorizeAsync(GlobalSearchHit hit, SearchAccess access) + { + var principal = access.Principal; + var departmentId = principal.DepartmentId; + var userId = principal.UserId; + try + { + switch (hit.EntityType) + { + case SearchEntityTypes.Call: + if (!int.TryParse(hit.EntityId, out var callId)) return false; + var call = await _calls.GetCallByIdAsync(callId, true); + return call != null && call.DepartmentId == departmentId && !call.IsDeleted && + await _authorization.CanUserViewCallAsync(userId, callId); + case SearchEntityTypes.Unit: + if (!int.TryParse(hit.EntityId, out var unitId)) return false; + var unit = await _units.GetUnitByIdAsync(unitId); + return unit != null && unit.DepartmentId == departmentId && + CanViewGroup(access.UnitPermission, unit.StationGroupId, access); + case SearchEntityTypes.Personnel: + var member = await _departments.GetDepartmentMemberAsync(hit.EntityId, departmentId, true); + if (!ActiveMember(member, departmentId, hit.EntityId) || member.IsHidden == true) return false; + if (hit.EntityId == userId) return true; + var group = await _groups.GetGroupMemberForUserAsync(hit.EntityId, departmentId); + if (group != null && (group.DepartmentId != departmentId || group.UserId != hit.EntityId)) return false; + return CanViewGroup(access.PersonnelPermission, group?.DepartmentGroupId, access); + case SearchEntityTypes.Message: + if (!int.TryParse(hit.EntityId, out var messageId)) return false; + var message = await _messages.GetMessageByIdAsync(messageId); + return message != null && message.DepartmentId == departmentId && !message.IsDeleted && + (!message.ExpireOn.HasValue || message.ExpireOn > DateTime.UtcNow) && + (message.SendingUserId == userId || message.ReceivingUserId == userId || + message.MessageRecipients?.Any(r => r.UserId == userId && !r.IsDeleted && + (!r.DepartmentId.HasValue || r.DepartmentId == departmentId)) == true); + case SearchEntityTypes.Contact: + var contact = await _contacts.GetContactByIdAsync(hit.EntityId); + return contact != null && contact.DepartmentId == departmentId && !contact.IsDeleted && access.ProtectedTextAllowed; + case SearchEntityTypes.Document: + if (!int.TryParse(hit.EntityId, out var documentId)) return false; + var document = await _documents.GetDocumentByIdAsync(documentId); + return document != null && document.DepartmentId == departmentId && + (!document.AdminsOnly || principal.IsDepartmentAdmin) && + (!document.RemoveOn.HasValue || document.RemoveOn > DateTime.UtcNow); + case SearchEntityTypes.Note: + if (!int.TryParse(hit.EntityId, out var noteId)) return false; + var note = await _notes.GetNoteByIdAsync(noteId); + return note != null && note.DepartmentId == departmentId && + (!note.IsAdminOnly || principal.IsDepartmentAdmin) && + (!note.ExpiresOn.HasValue || note.ExpiresOn > DateTime.UtcNow); + default: + return false; + } + } + catch (Exception ex) + { + Logging.LogException(ex, "Search hit access could not be verified."); + return false; + } + } + + private bool CanViewGroup(Permission permission, int? targetGroupId, SearchAccess access) + { + if (permission == null) return true; + if (permission.DepartmentId != access.Principal.DepartmentId) return false; + // The generic permission overload lets Everyone bypass LockToGroup. Visibility must apply the lock + // first, matching SecurityLogic's personnel/unit matrix without relying on its fail-open cache. + if (!access.Principal.IsDepartmentAdmin && permission.LockToGroup && + (!targetGroupId.HasValue || targetGroupId != access.Group?.DepartmentGroupId)) return false; + return _permissions.IsUserAllowed(permission, access.Principal.IsDepartmentAdmin, + access.Principal.IsGroupAdmin, access.Roles); + } + + private static bool ModuleEnabled(DepartmentModuleSettings settings, string module) + { + switch (module) + { + case SystemActionModules.Messaging: return !settings.MessagingDisabled; + case SystemActionModules.Mapping: return !settings.MappingDisabled; + case SystemActionModules.Shifts: return !settings.ShiftsDisabled; + case SystemActionModules.Logs: return !settings.LogsDisabled; + case SystemActionModules.Reports: return !settings.ReportsDisabled; + case SystemActionModules.Documents: return !settings.DocumentsDisabled; + case SystemActionModules.Calendar: return !settings.CalendarDisabled; + case SystemActionModules.Notes: return !settings.NotesDisabled; + case SystemActionModules.Training: return !settings.TrainingDisabled; + case SystemActionModules.Inventory: return !settings.InventoryDisabled; + case SystemActionModules.Maintenance: return !settings.MaintenanceDisabled; + case SystemActionModules.BusinessOperations: return !settings.BusinessOperationsDisabled; + default: return string.IsNullOrWhiteSpace(module); + } + } + } +} diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs index dd8fdac0b..316d59991 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs @@ -14,13 +14,13 @@ namespace Resgrid.Services.Search { /// - /// The unified endpoint (plan R4 Phase 2). Order of operations: department flag → system actions (always, cheap) → - /// global index query with the department clause injected → per-hit authorization using the entity's own rule - /// (call view, unit/person matrix, message sender/recipient, claim for contacts/documents/notes) → Records - /// federated from the RMS index with its own re-check → totals suppressed whenever a hit was dropped. A department + /// The unified endpoint (plan R4 Phase 2). Checks current membership, module settings and policy, then queries + /// the department's current index generation. Each candidate must match its live projection and pass current + /// entity ownership and visibility checks. Records are federated with the same department and policy boundary. + /// Totals are returned only when every candidate was checked and authorized. A department /// that has never searched gets a state row on its first query so worker 70 starts indexing it (lazy activation). /// - public class UnifiedSearchService : IUnifiedSearchService + public partial class UnifiedSearchService : IUnifiedSearchService { private const int CandidateWindow = 200; @@ -36,7 +36,12 @@ public class UnifiedSearchService : IUnifiedSearchService public UnifiedSearchService(IGlobalSearchService global, ISystemActionsService actions, IFeatureToggleService featureToggles, IAuthorizationService authorization, ISearchIndexStatesRepository states, IRecordsSearchService recordsSearch, - IRecordsAuthorizationService recordsAuthorization, IRecordsService records, IRecordsCutoverService recordsCutover) + IRecordsAuthorizationService recordsAuthorization, IRecordsService records, IRecordsCutoverService recordsCutover, + IDepartmentsService departments, IPermissionsService permissions, IDepartmentGroupsService groups, + IPersonnelRolesService roles, ICallsService calls, IUnitsService units, IMessageService messages, + IDocumentsService documents, INotesService notes, IContactsService contacts, + IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings, + ISearchProjectionsRepository projections) { _global = global; _actions = actions; @@ -47,6 +52,19 @@ public UnifiedSearchService(IGlobalSearchService global, ISystemActionsService a _recordsAuthorization = recordsAuthorization; _records = records; _recordsCutover = recordsCutover; + _departments = departments; + _permissions = permissions; + _groups = groups; + _roles = roles; + _calls = calls; + _units = units; + _messages = messages; + _documents = documents; + _notes = notes; + _contacts = contacts; + _dataProtection = dataProtection; + _departmentSettings = departmentSettings; + _projections = projections; } public async Task SearchAsync(UnifiedSearchRequest request, SearchPrincipal principal, CancellationToken cancellationToken = default) @@ -68,6 +86,14 @@ public async Task SearchAsync(UnifiedSearchRequest request, return Finish(result, watch); } + var access = await LoadAccessAsync(principal); + if (access == null) + { + result.Available = false; + return Finish(result, watch); + } + principal = access.Principal; + var text = (request.Text ?? string.Empty).Trim(); if (text.Length > 500) text = text.Substring(0, 500); @@ -93,16 +119,18 @@ public async Task SearchAsync(UnifiedSearchRequest request, } var types = AllowedTypes(request.EntityTypes, principal); + var skip = Math.Max(0, request.Skip); + var take = Math.Max(1, Math.Min(100, request.Take)); + var needed = Math.Min(skip, CandidateWindow) + take; var dropped = 0; var authorized = new List(); - var indexTotal = 0; - var truncated = false; - var windowCoveredAll = false; + var windowCoveredAll = true; if (types.Count > 0) { if (!_global.IsAvailable) { + windowCoveredAll = false; result.Degraded = true; result.DegradedReason = "The search index is not available yet."; await EnsureStateAsync(principal.DepartmentId, cancellationToken); @@ -114,6 +142,7 @@ public async Task SearchAsync(UnifiedSearchRequest request, { indexResult = await _global.SearchAsync(principal.DepartmentId, new GlobalSearchQuery { + Generation = access.GlobalGeneration, Text = text, EntityTypes = types, ViewerUserId = principal.UserId, @@ -131,23 +160,27 @@ public async Task SearchAsync(UnifiedSearchRequest request, if (!indexResult.Available) { + windowCoveredAll = false; result.Degraded = true; result.DegradedReason = "The search index is not available yet."; await EnsureStateAsync(principal.DepartmentId, cancellationToken); } else { - indexTotal = indexResult.Total; - truncated = indexResult.Truncated; - windowCoveredAll = indexResult.Hits.Count >= indexResult.Total; - var need = Math.Max(0, request.Skip) + Math.Max(1, request.Take); + windowCoveredAll = !indexResult.Truncated && indexResult.Hits.Count == indexResult.Total; + var projections = (await _projections.GetByIdsAsync(principal.DepartmentId, + indexResult.Hits.Where(h => h != null && !string.IsNullOrWhiteSpace(h.ProjectionId)).Select(h => h.ProjectionId)) + ?? Enumerable.Empty()).ToDictionary(p => p.SearchProjectionId, StringComparer.Ordinal); foreach (var hit in indexResult.Hits) { cancellationToken.ThrowIfCancellationRequested(); - if (authorized.Count >= need && windowCoveredAll == false) + // An incomplete window can never yield an authorized total; stop once the page is filled. + if (!windowCoveredAll && authorized.Count >= needed) break; - if (await AuthorizeAsync(hit, principal)) - authorized.Add(Map(hit)); + if (hit != null && types.Contains(hit.EntityType) && + projections.TryGetValue(hit.ProjectionId ?? string.Empty, out var projection) && + ProjectionIsCurrent(hit, projection, access) && await AuthorizeAsync(hit, access)) + authorized.Add(Map(projection, hit.Score)); else dropped++; } @@ -157,15 +190,13 @@ public async Task SearchAsync(UnifiedSearchRequest request, // The records federation must reach as deep as the requested page can: the page is cut from index hits followed // by record hits, so a fixed top-N of records would leave every page past N empty for a records-heavy query. - var skip = Math.Max(0, request.Skip); - var take = Math.Max(1, Math.Min(100, request.Take)); var recordHits = new List(); int? recordsTotal = 0; if (request.IncludeRecords && !request.Prefix && WantsType(request.EntityTypes, SearchEntityTypes.Record)) { try { - (recordHits, recordsTotal) = await FederateRecordsAsync(text, principal, Math.Min(Math.Min(skip, CandidateWindow) + take, CandidateWindow), cancellationToken); + (recordHits, recordsTotal) = await FederateRecordsAsync(text, access, Math.Min(Math.Min(skip, CandidateWindow) + take, CandidateWindow), cancellationToken); } catch (Exception ex) { @@ -177,11 +208,12 @@ public async Task SearchAsync(UnifiedSearchRequest request, // One sequence, index hits then the records federation, paged as a whole: special-casing the first page // dropped the Records family from every later page. result.Hits = authorized.Concat(recordHits).Skip(skip).Take(take).ToList(); - result.Truncated = truncated; + // A raw index truncation flag also discloses unauthorized matches. Only expose authorized metadata. + result.Truncated = false; // Totals only when they can be proven from authorized results (plan 2026-08-15 correction). - if (dropped == 0 && recordsTotal.HasValue) - result.Total = (windowCoveredAll ? authorized.Count : indexTotal) + recordsTotal.Value; + if (dropped == 0 && windowCoveredAll && recordsTotal.HasValue) + result.Total = authorized.Count + recordsTotal.Value; else result.Total = null; @@ -224,39 +256,7 @@ void Add(string type, string resource, string module = null) return allowed; } - private async Task AuthorizeAsync(GlobalSearchHit hit, SearchPrincipal principal) - { - try - { - switch (hit.EntityType) - { - case SearchEntityTypes.Call: - return int.TryParse(hit.EntityId, out var callId) && await _authorization.CanUserViewCallAsync(principal.UserId, callId); - case SearchEntityTypes.Unit: - return int.TryParse(hit.EntityId, out var unitId) && await _authorization.CanUserViewUnitViaMatrixAsync(unitId, principal.UserId, principal.DepartmentId); - case SearchEntityTypes.Personnel: - return !string.IsNullOrWhiteSpace(hit.EntityId) && await _authorization.CanUserViewPersonViaMatrixAsync(hit.EntityId, principal.UserId, principal.DepartmentId); - case SearchEntityTypes.Message: - return int.TryParse(hit.EntityId, out var messageId) && await _authorization.CanUserViewMessageAsync(principal.UserId, messageId); - case SearchEntityTypes.Contact: - return principal.IsDepartmentAdmin || principal.HasResourceClaim("Contacts", "View"); - case SearchEntityTypes.Document: - return principal.IsDepartmentAdmin || principal.HasResourceClaim("Documents", "View"); - case SearchEntityTypes.Note: - return principal.IsDepartmentAdmin || principal.HasResourceClaim("Notes", "View"); - default: - return false; - } - } - catch (Exception ex) - { - // Fail closed: an authorization error drops the hit and suppresses the total. - Logging.LogException(ex, $"Search hit authorization failed for {hit.EntityType} {hit.EntityId}."); - return false; - } - } - - private static UnifiedSearchHit Map(GlobalSearchHit hit) + private static UnifiedSearchHit Map(SearchProjection hit, float score) { IDictionary metadata = new Dictionary(); if (!string.IsNullOrWhiteSpace(hit.MetadataJson)) @@ -272,16 +272,17 @@ private static UnifiedSearchHit Map(GlobalSearchHit hit) Title = hit.Title, Summary = hit.Summary, Url = hit.Url, - Score = hit.Score, - OccurredOn = hit.OccurredOnTicks > 0 ? new DateTime(hit.OccurredOnTicks, DateTimeKind.Utc) : (DateTime?)null, + Score = score, + OccurredOn = hit.OccurredOn, Category = hit.Category, Status = hit.Status, Metadata = metadata }; } - private async Task<(List hits, int? total)> FederateRecordsAsync(string text, SearchPrincipal principal, int window, CancellationToken cancellationToken) + private async Task<(List hits, int? total)> FederateRecordsAsync(string text, SearchAccess access, int window, CancellationToken cancellationToken) { + var principal = access.Principal; var hits = new List(); if (!principal.IsDepartmentAdmin && !principal.HasResourceClaim("Record", "View")) return (hits, 0); @@ -300,6 +301,9 @@ private static UnifiedSearchHit Map(GlobalSearchHit hit) var search = await _recordsSearch.SearchAsync(principal.DepartmentId, new RecordsSearchRequest { + Generation = access.RecordsGeneration, + IncludeNarrative = access.ProtectedTextAllowed && + (await _departmentSettings.GetRecordsSearchConfigAsync(principal.DepartmentId, true))?.IndexNarrative == true, Text = text, VisibleGroupIds = visibleGroups, ViewerUserId = principal.UserId, @@ -319,7 +323,12 @@ private static UnifiedSearchHit Map(GlobalSearchHit hit) foreach (var hit in search.Hits.Where(h => h.SourceType == recordSource)) { cancellationToken.ThrowIfCancellationRequested(); - if (!loaded.TryGetValue(hit.SourceId ?? string.Empty, out var projection) || !await _recordsAuthorization.CanUserViewRecordAsync(principal.UserId, hit.SourceId, principal.DepartmentId)) + if (hit.DepartmentId != principal.DepartmentId || hit.Generation != access.RecordsGeneration || + !loaded.TryGetValue(hit.SourceId ?? string.Empty, out var projection) || + projection.DepartmentId != principal.DepartmentId || projection.DeletedOn.HasValue || + projection.SourceType != (int)RmsSearchSourceType.Record || projection.SourceId != hit.SourceId || + projection.PolicyEpoch != access.PolicyEpoch || projection.ProtectedCatalogVersion != access.CatalogVersion || + !await _recordsAuthorization.CanUserViewRecordAsync(principal.UserId, hit.SourceId, principal.DepartmentId)) { dropped++; continue; @@ -345,7 +354,7 @@ private static UnifiedSearchHit Map(GlobalSearchHit hit) }); } - return (hits, dropped == 0 ? search.Total : (int?)null); + return (hits, dropped == 0 && !search.Truncated && search.Hits.Count == search.Total ? hits.Count : (int?)null); } private async Task EnsureStateAsync(int departmentId, CancellationToken cancellationToken) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 57095bc6d..1319477bd 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -42,6 +42,8 @@ protected override void Load(ContainerBuilder builder) }).InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 4f3712d19..4c260a4fa 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -93,6 +93,20 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve foreach (var variable in Resgrid.Model.Invoicing.InvoiceWorkflowPayload.Variables) if (!sampleInvoice.ContainsKey(variable.Variable)) sampleInvoice[variable.Variable] = null; if (eventType is WorkflowTriggerEventType.InvoicePaymentRecorded or WorkflowTriggerEventType.InvoicePaid or WorkflowTriggerEventType.InvoicePaymentRefunded or WorkflowTriggerEventType.InvoicePaymentDisputed) { sampleInvoice["payment_amount"] = 1215.00m; sampleInvoice["payment_method"] = "Online"; sampleInvoice["payment_id"] = "0b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"; sampleInvoice["amount_paid"] = 1215.00m; sampleInvoice["balance"] = 0m; sampleInvoice["status"] = 3; } break; + case WorkflowTriggerEventType.DeploymentCreated: + case WorkflowTriggerEventType.DeploymentStatusChanged: + case WorkflowTriggerEventType.DeploymentRosterChanged: + case WorkflowTriggerEventType.DeploymentExpenseAdded: + case WorkflowTriggerEventType.TimeReportSubmitted: + case WorkflowTriggerEventType.TimeReportApproved: + obj["deployment"] = new ScriptObject { ["id"] = "5d1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a", ["name"] = "Ridge Fire strike team", ["status"] = 2, ["finance_mode"] = 2, ["call_id"] = 1042, ["incident_number"] = "CA-BTU-012345", ["resource_order_number"] = "O-1234", ["request_number"] = "E-12", ["cost_code"] = "CC-8891", ["start_on"] = "2026-09-18T14:00:00Z", ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Deployments/View/5d1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a" }; + var sampleDeployment = (ScriptObject)obj["deployment"]; + foreach (var variable in Resgrid.Model.Invoicing.DeploymentWorkflowPayload.Variables) if (!sampleDeployment.ContainsKey(variable.Variable)) sampleDeployment[variable.Variable] = null; + if (eventType == WorkflowTriggerEventType.DeploymentStatusChanged) sampleDeployment["old_status"] = 1; + if (eventType == WorkflowTriggerEventType.DeploymentRosterChanged) { sampleDeployment["subject_type"] = 0; sampleDeployment["subject_id"] = "8e7d6c5b-4a39-4281-9f0e-1d2c3b4a5968"; sampleDeployment["subject_name"] = "J. Alvarez"; sampleDeployment["roster_action"] = "Added"; } + if (eventType == WorkflowTriggerEventType.DeploymentExpenseAdded) { sampleDeployment["expense_type"] = 1; sampleDeployment["expense_amount"] = 189.50m; sampleDeployment["expense_currency"] = "USD"; } + if (eventType is WorkflowTriggerEventType.TimeReportSubmitted or WorkflowTriggerEventType.TimeReportApproved) { sampleDeployment["report_number"] = 57; sampleDeployment["report_date"] = "2026-09-18T00:00:00Z"; sampleDeployment["report_id"] = "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"; } + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 285523116..ebcd42e52 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -513,6 +513,23 @@ public async Task BuildContextAsync( scriptObject["invoice"] = invoice; break; } + case WorkflowTriggerEventType.DeploymentCreated: + case WorkflowTriggerEventType.DeploymentStatusChanged: + case WorkflowTriggerEventType.DeploymentRosterChanged: + case WorkflowTriggerEventType.DeploymentExpenseAdded: + case WorkflowTriggerEventType.TimeReportSubmitted: + case WorkflowTriggerEventType.TimeReportApproved: + { + var deploymentEvent = TryDeserialize(eventPayloadJson); + var deploymentPayload = deploymentEvent?.Payload ?? new JObject(); var deployment = new ScriptObject(); + foreach (var pair in Resgrid.Model.Invoicing.DeploymentWorkflowPayload.Variables) deployment[pair.Variable] = ToScriptValue(deploymentPayload[pair.Property]); + var deploymentId = deploymentPayload["DeploymentId"]?.Type == JTokenType.String ? deploymentPayload["DeploymentId"].Value() : null; + deployment["url"] = string.IsNullOrWhiteSpace(deploymentId) + ? string.Empty + : $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Deployments/View/{deploymentId}"; + scriptObject["deployment"] = deployment; + break; + } case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index dca248760..f713993bd 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1670,7 +1670,7 @@ public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List

1) " + + "THROW 51212, 'M0212: InvoicePayments holds duplicate (Provider, GatewayTransactionId) pairs; resolve them before UX_InvoicePayments_Gateway can be created.', 1;"); Execute.Sql("IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_InvoicePayments_Gateway' AND object_id = OBJECT_ID('[InvoicePayments]')) DROP INDEX [IX_InvoicePayments_Gateway] ON [InvoicePayments];"); Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_InvoicePayments_Gateway' AND object_id = OBJECT_ID('[InvoicePayments]')) " + "CREATE UNIQUE INDEX [UX_InvoicePayments_Gateway] ON [InvoicePayments] ([Provider], [GatewayTransactionId]) WHERE [GatewayTransactionId] IS NOT NULL AND [Provider] IS NOT NULL;"); @@ -154,7 +160,7 @@ public override void Down() Execute.Sql("IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_InvoicePayments_Gateway' AND object_id = OBJECT_ID('[InvoicePayments]')) DROP INDEX [UX_InvoicePayments_Gateway] ON [InvoicePayments];"); Execute.Sql("IF OBJECT_ID('[InvoicePayments]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_InvoicePayments_Gateway' AND object_id = OBJECT_ID('[InvoicePayments]')) " + "CREATE INDEX [IX_InvoicePayments_Gateway] ON [InvoicePayments] ([Provider], [GatewayTransactionId]);"); - Execute.Sql("IF OBJECT_ID('[PaymentProviderEvents]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM [PaymentProviderEvents]) DROP TABLE [PaymentProviderEvents];"); + Execute.Sql("IF OBJECT_ID('[PaymentConnectEvents]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM [PaymentConnectEvents]) DROP TABLE [PaymentConnectEvents];"); Execute.Sql("IF OBJECT_ID('[InvoicePaymentRequests]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM [InvoicePaymentRequests]) DROP TABLE [InvoicePaymentRequests];"); Execute.Sql("IF OBJECT_ID('[DepartmentPaymentConnections]', 'U') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM [DepartmentPaymentConnections]) DROP TABLE [DepartmentPaymentConnections];"); } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0213_AddCertificationTypes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0213_AddCertificationTypes.cs index e5524949f..7019665d5 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0213_AddCertificationTypes.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0213_AddCertificationTypes.cs @@ -100,8 +100,14 @@ public override void Up() .WithColumn("TreatPendingVerificationAsValid").AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn("SendAdminDigest").AsBoolean().NotNullable().WithDefaultValue(true) .WithColumn("UpdatedOn").AsDateTime2().NotNullable() - .WithColumn("UpdatedByUserId").AsString(128).Nullable(); + .WithColumn("UpdatedByUserId").AsString(128).Nullable() + .WithColumn("LastSweepLocalDate").AsDateTime2().Nullable(); } + // The worker's per-department sweep claim (one sweep per department-local day, survives a repeated or skipped hour). + // Schema checks run when Up() is evaluated while Create.Table above is deferred, so a fresh database takes the + // column from the create and only a database that already carries the table gets the alter. + if (Schema.Table("DepartmentCertificationSettings").Exists() && !Schema.Table("DepartmentCertificationSettings").Column("LastSweepLocalDate").Exists()) + Alter.Table("DepartmentCertificationSettings").AddColumn("LastSweepLocalDate").AsDateTime2().Nullable(); // ---- Unit-scoped records ---------------------------------------------------------------------------- if (!Schema.Table("UnitCertifications").Exists()) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs new file mode 100644 index 000000000..e9f551447 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs @@ -0,0 +1,116 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + ///

+ /// Workforce & Business Operations plan, Phase C (C1): contractor rate schedules with typed entries (per-certification, crew size families, vehicle, equipment, service), explicit rate bands (standby, deployment, overtime thresholds, daily tiers, out-of-province, mileage, per-diem) and stacking premiums. Rates are explicit numbers (decision 16); thresholds are data, not code. Every table carries the ADP row marker from creation. Registry M0215. Guarded for safe retry. + /// + [Migration(215)] + public class M0215_AddRateSchedules : Migration + { + public override void Up() + { + if (!Schema.Table("RateSchedules").Exists()) + { + Create.Table("RateSchedules") + .WithColumn("RateScheduleId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Currency").AsString(3).NotNullable().WithDefaultValue("USD") + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("PolicyJson").AsString(int.MaxValue).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_RateSchedules_Department").OnTable("RateSchedules").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("RateScheduleEntries").Exists()) + { + Create.Table("RateScheduleEntries") + .WithColumn("RateScheduleEntryId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("RateScheduleId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("EntryType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Code").AsString(50).Nullable() + .WithColumn("GroupKey").AsString(50).Nullable() + .WithColumn("CrewSize").AsInt32().Nullable() + .WithColumn("CertificationCode").AsString(50).Nullable() + .WithColumn("UnitTypeId").AsInt32().Nullable() + .WithColumn("InventoryItemId").AsString(36).Nullable() + .WithColumn("InventoryCategoryId").AsString(36).Nullable() + .WithColumn("BillingBasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RequiredCertificationsJson").AsString(int.MaxValue).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_RateScheduleEntries_Schedule").OnTable("RateScheduleEntries").OnColumn("RateScheduleId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("SortOrder").Ascending(); + Create.Index("IX_RateScheduleEntries_Group").OnTable("RateScheduleEntries").OnColumn("RateScheduleId").Ascending().OnColumn("GroupKey").Ascending(); + Create.ForeignKey("FK_RateScheduleEntries_Schedule").FromTable("RateScheduleEntries").ForeignColumn("RateScheduleId").ToTable("RateSchedules").PrimaryColumn("RateScheduleId"); + } + if (!Schema.Table("RateScheduleEntryBands").Exists()) + { + Create.Table("RateScheduleEntryBands") + .WithColumn("RateScheduleEntryBandId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("RateScheduleEntryId").AsString(36).NotNullable() + .WithColumn("BandType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Rate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("ThresholdStartHours").AsDecimal(9,2).Nullable() + .WithColumn("ThresholdEndHours").AsDecimal(9,2).Nullable() + .WithColumn("DailyTierMinHours").AsDecimal(9,2).Nullable() + .WithColumn("DailyTierMaxHours").AsDecimal(9,2).Nullable() + .WithColumn("FreeUnitsPerDay").AsDecimal(9,2).Nullable() + .WithColumn("RequiresAirTravel").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("MealCode").AsString(10).Nullable() + .WithColumn("Label").AsString(200).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + Create.Index("IX_RateScheduleEntryBands_Entry").OnTable("RateScheduleEntryBands").OnColumn("RateScheduleEntryId").Ascending().OnColumn("SortOrder").Ascending(); + Create.ForeignKey("FK_RateScheduleEntryBands_Entry").FromTable("RateScheduleEntryBands").ForeignColumn("RateScheduleEntryId").ToTable("RateScheduleEntries").PrimaryColumn("RateScheduleEntryId"); + } + if (!Schema.Table("RatePremiums").Exists()) + { + Create.Table("RatePremiums") + .WithColumn("RatePremiumId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("RateScheduleId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Code").AsString(50).Nullable() + .WithColumn("StandbyAdder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("DeploymentAdder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("Overtime1Adder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("Overtime2Adder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_RatePremiums_Schedule").OnTable("RatePremiums").OnColumn("RateScheduleId").Ascending().OnColumn("IsDeleted").Ascending(); + Create.ForeignKey("FK_RatePremiums_Schedule").FromTable("RatePremiums").ForeignColumn("RateScheduleId").ToTable("RateSchedules").PrimaryColumn("RateScheduleId"); + } + } + + public override void Down() + { + if (Schema.Table("RatePremiums").Exists()) Delete.Table("RatePremiums"); + if (Schema.Table("RateScheduleEntryBands").Exists()) Delete.Table("RateScheduleEntryBands"); + if (Schema.Table("RateScheduleEntries").Exists()) Delete.Table("RateScheduleEntries"); + if (Schema.Table("RateSchedules").Exists()) Delete.Table("RateSchedules"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs new file mode 100644 index 000000000..55d851ec8 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs @@ -0,0 +1,92 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase C (C1): customer service contracts with per-stage document requirements, and the department-level compliance document register (decision 24: COI, WCB/WorkSafe clearance, SAM/UEI, licences, CAGE, tax registration, bonds) with expiry lead days and a file blob. Registry M0216. Guarded for safe retry. + /// + [Migration(216)] + public class M0216_AddServiceContracts : Migration + { + public override void Up() + { + if (!Schema.Table("ServiceContracts").Exists()) + { + Create.Table("ServiceContracts") + .WithColumn("ServiceContractId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("CustomerBillingProfileId").AsString(36).Nullable() + .WithColumn("ContractNumber").AsString(100).Nullable() + .WithColumn("Name").AsString(250).NotNullable() + .WithColumn("ContractType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StartOn").AsDateTime2().NotNullable() + .WithColumn("EndOn").AsDateTime2().Nullable() + .WithColumn("RateScheduleId").AsString(36).Nullable() + .WithColumn("DiscountPercent").AsDecimal(9,4).Nullable() + .WithColumn("TermsNetDays").AsInt32().Nullable() + .WithColumn("InvoiceSubmissionEmail").AsString(500).Nullable() + .WithColumn("MaxDeploymentDays").AsInt32().Nullable() + .WithColumn("ResponseTimeMinutes").AsInt32().Nullable() + .WithColumn("PointOfHire").AsString(250).Nullable() + .WithColumn("DocumentTemplateKey").AsString(50).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_ServiceContracts_Department").OnTable("ServiceContracts").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending(); + Create.Index("IX_ServiceContracts_Contact").OnTable("ServiceContracts").OnColumn("ContactId").Ascending(); + } + if (!Schema.Table("ServiceContractDocumentRequirements").Exists()) + { + Create.Table("ServiceContractDocumentRequirements") + .WithColumn("ServiceContractDocumentRequirementId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ServiceContractId").AsString(36).NotNullable() + .WithColumn("Name").AsString(250).NotNullable() + .WithColumn("Stage").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ComplianceDocumentType").AsInt32().Nullable() + .WithColumn("IsMandatory").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + Create.Index("IX_ServiceContractDocumentRequirements_Contract").OnTable("ServiceContractDocumentRequirements").OnColumn("ServiceContractId").Ascending().OnColumn("SortOrder").Ascending(); + Create.ForeignKey("FK_ServiceContractDocumentRequirements_Contract").FromTable("ServiceContractDocumentRequirements").ForeignColumn("ServiceContractId").ToTable("ServiceContracts").PrimaryColumn("ServiceContractId"); + } + if (!Schema.Table("DepartmentComplianceDocuments").Exists()) + { + Create.Table("DepartmentComplianceDocuments") + .WithColumn("DepartmentComplianceDocumentId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DocumentType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(250).NotNullable() + .WithColumn("DocumentNumber").AsString(int.MaxValue).Nullable() + .WithColumn("Issuer").AsString(250).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("AlertLeadDays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("FileName").AsString(int.MaxValue).Nullable() + .WithColumn("FileType").AsString(200).Nullable() + .WithColumn("FileSize").AsInt32().Nullable() + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_DepartmentComplianceDocuments_Department").OnTable("DepartmentComplianceDocuments").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ExpiresOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("DepartmentComplianceDocuments").Exists()) Delete.Table("DepartmentComplianceDocuments"); + if (Schema.Table("ServiceContractDocumentRequirements").Exists()) Delete.Table("ServiceContractDocumentRequirements"); + if (Schema.Table("ServiceContracts").Exists()) Delete.Table("ServiceContracts"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs new file mode 100644 index 000000000..775e2eb8e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs @@ -0,0 +1,92 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase C (C1): bids (quotes) with rate-snapshotting line items, lifecycle status, conversion back-links to the Call and Deployment, and the per-department atomic bid number sequence (the invoice-sequence precedent). Registry M0217. Guarded for safe retry. + /// + [Migration(217)] + public class M0217_AddBids : Migration + { + public override void Up() + { + if (!Schema.Table("Bids").Exists()) + { + Create.Table("Bids") + .WithColumn("BidId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("BidNumber").AsInt32().NotNullable() + .WithColumn("ContactId").AsString(128).NotNullable() + .WithColumn("CustomerBillingProfileId").AsString(36).Nullable() + .WithColumn("ServiceContractId").AsString(36).Nullable() + .WithColumn("RateScheduleId").AsString(36).Nullable() + .WithColumn("Title").AsString(250).NotNullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ValidUntil").AsDateTime2().Nullable() + .WithColumn("RequestedStartOn").AsDateTime2().Nullable() + .WithColumn("RequestedEndOn").AsDateTime2().Nullable() + .WithColumn("IncidentNumber").AsString(100).Nullable() + .WithColumn("DeliveryLocation").AsString(int.MaxValue).Nullable() + .WithColumn("DiscountPercent").AsDecimal(9,4).Nullable() + .WithColumn("EstimatedSubTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("EstimatedDiscountAmount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("EstimatedTaxAmount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("EstimatedTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("TermsText").AsString(int.MaxValue).Nullable() + .WithColumn("SentOn").AsDateTime2().Nullable() + .WithColumn("SentToEmail").AsString(500).Nullable() + .WithColumn("AcceptedOn").AsDateTime2().Nullable() + .WithColumn("DeclinedOn").AsDateTime2().Nullable() + .WithColumn("DeclineReason").AsString(int.MaxValue).Nullable() + .WithColumn("ConvertedCallId").AsInt32().Nullable() + .WithColumn("ConvertedDeploymentId").AsString(36).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_Bids_Department").OnTable("Bids").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending(); + Create.Index("IX_Bids_Contact").OnTable("Bids").OnColumn("ContactId").Ascending(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_Bids_Number' AND object_id = OBJECT_ID('Bids')) CREATE UNIQUE INDEX [UX_Bids_Number] ON [Bids] ([DepartmentId], [BidNumber]);"); + } + if (!Schema.Table("BidLineItems").Exists()) + { + Create.Table("BidLineItems") + .WithColumn("BidLineItemId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("BidId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("RateScheduleEntryId").AsString(36).Nullable() + .WithColumn("LineType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Description").AsString(int.MaxValue).NotNullable() + .WithColumn("CrewSize").AsInt32().Nullable() + .WithColumn("Quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(1) + .WithColumn("EstimatedHoursPerDay").AsDecimal(9,2).Nullable() + .WithColumn("EstimatedDays").AsDecimal(9,2).Nullable() + .WithColumn("UnitRate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("PremiumIdsJson").AsString(int.MaxValue).Nullable() + .WithColumn("EstimatedAmount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("Taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + Create.Index("IX_BidLineItems_Bid").OnTable("BidLineItems").OnColumn("BidId").Ascending().OnColumn("SortOrder").Ascending(); + Create.ForeignKey("FK_BidLineItems_Bid").FromTable("BidLineItems").ForeignColumn("BidId").ToTable("Bids").PrimaryColumn("BidId"); + } + if (!Schema.Table("BidNumberSequences").Exists()) + { + Create.Table("BidNumberSequences") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("NextBidNumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + if (Schema.Table("BidNumberSequences").Exists()) Delete.Table("BidNumberSequences"); + if (Schema.Table("BidLineItems").Exists()) Delete.Table("BidLineItems"); + if (Schema.Table("Bids").Exists()) Delete.Table("Bids"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs new file mode 100644 index 000000000..efa1323a6 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs @@ -0,0 +1,252 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase C (C1, decision 39): the free deployment finance wrapper around a Call — deployments (with the RMS external-order soft link and finance mode), roster units/personnel/equipment, pre-numbered daily time reports with multi-span time entries, expenses, attachments and the report number sequence. External orders and fills are the RMS-owned RmsExternalOrders/RmsExternalOrderFills; no order tables here. Registry M0218. Guarded for safe retry. + /// + [Migration(218)] + public class M0218_AddDeploymentsAndTimeTracking : Migration + { + public override void Up() + { + if (!Schema.Table("Deployments").Exists()) + { + Create.Table("Deployments") + .WithColumn("DeploymentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("RmsExternalOrderId").AsString(36).Nullable() + .WithColumn("FinanceMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("BidId").AsString(36).Nullable() + .WithColumn("ServiceContractId").AsString(36).Nullable() + .WithColumn("RateScheduleId").AsString(36).Nullable() + .WithColumn("ContactId").AsString(128).Nullable() + .WithColumn("Name").AsString(250).NotNullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IncidentNumber").AsString(100).Nullable() + .WithColumn("ServiceRequestNumber").AsString(100).Nullable() + .WithColumn("ResourceOrderNumber").AsString(100).Nullable() + .WithColumn("RequestNumber").AsString(100).Nullable() + .WithColumn("CostCode").AsString(100).Nullable() + .WithColumn("PointOfHire").AsString(250).Nullable() + .WithColumn("StartOn").AsDateTime2().Nullable() + .WithColumn("EndOn").AsDateTime2().Nullable() + .WithColumn("MaxDays").AsInt32().Nullable() + .WithColumn("OutOfProvince").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("TravelViaAir").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("HomeCountry").AsString(2).Nullable() + .WithColumn("HostCountry").AsString(2).Nullable() + .WithColumn("HomeSubdivision").AsString(10).Nullable() + .WithColumn("HostSubdivision").AsString(10).Nullable() + .WithColumn("LocalTimeZoneId").AsString(100).Nullable() + .WithColumn("Locale").AsString(20).Nullable() + .WithColumn("MeasurementSystem").AsString(10).Nullable() + .WithColumn("Currency").AsString(3).Nullable() + .WithColumn("DiscountPercent").AsDecimal(9,4).Nullable() + .WithColumn("StatusChangedOn").AsDateTime2().Nullable() + .WithColumn("CalendarItemId").AsInt32().Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_Deployments_Department").OnTable("Deployments").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending(); + Create.Index("IX_Deployments_ExternalOrder").OnTable("Deployments").OnColumn("RmsExternalOrderId").Ascending(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_Deployments_Call' AND object_id = OBJECT_ID('Deployments')) CREATE UNIQUE INDEX [UX_Deployments_Call] ON [Deployments] ([CallId]) WHERE [CallId] IS NOT NULL AND [IsDeleted] = 0;"); + } + if (!Schema.Table("DeploymentUnits").Exists()) + { + Create.Table("DeploymentUnits") + .WithColumn("DeploymentUnitId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UnitId").AsInt32().NotNullable() + .WithColumn("RateScheduleEntryId").AsString(36).Nullable() + .WithColumn("CallSign").AsString(100).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("RemovedOn").AsDateTime2().Nullable(); + Create.Index("IX_DeploymentUnits_Deployment").OnTable("DeploymentUnits").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_DeploymentUnits_Unit").OnTable("DeploymentUnits").OnColumn("UnitId").Ascending(); + Create.ForeignKey("FK_DeploymentUnits_Deployment").FromTable("DeploymentUnits").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("DeploymentPersonnel").Exists()) + { + Create.Table("DeploymentPersonnel") + .WithColumn("DeploymentPersonnelId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DeploymentUnitId").AsString(36).Nullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("UnitRoleId").AsInt32().Nullable() + .WithColumn("RateScheduleEntryId").AsString(36).Nullable() + .WithColumn("CertificationCode").AsString(50).Nullable() + .WithColumn("PremiumIdsJson").AsString(int.MaxValue).Nullable() + .WithColumn("CallSign").AsString(100).Nullable() + .WithColumn("RmsExternalOrderFillId").AsString(36).Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("RemovedOn").AsDateTime2().Nullable(); + Create.Index("IX_DeploymentPersonnel_Deployment").OnTable("DeploymentPersonnel").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_DeploymentPersonnel_User").OnTable("DeploymentPersonnel").OnColumn("UserId").Ascending().OnColumn("RemovedOn").Ascending(); + Create.ForeignKey("FK_DeploymentPersonnel_Deployment").FromTable("DeploymentPersonnel").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("DeploymentEquipment").Exists()) + { + Create.Table("DeploymentEquipment") + .WithColumn("DeploymentEquipmentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DeploymentUnitId").AsString(36).Nullable() + .WithColumn("InventoryAssetId").AsString(36).Nullable() + .WithColumn("InventoryItemId").AsString(36).Nullable() + .WithColumn("FreeTextName").AsString(250).Nullable() + .WithColumn("RateScheduleEntryId").AsString(36).Nullable() + .WithColumn("IssuedOn").AsDateTime2().Nullable() + .WithColumn("ReturnedOn").AsDateTime2().Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable(); + Create.Index("IX_DeploymentEquipment_Deployment").OnTable("DeploymentEquipment").OnColumn("DeploymentId").Ascending(); + Create.ForeignKey("FK_DeploymentEquipment_Deployment").FromTable("DeploymentEquipment").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("DeploymentTimeReports").Exists()) + { + Create.Table("DeploymentTimeReports") + .WithColumn("DeploymentTimeReportId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ReportNumber").AsInt32().NotNullable() + .WithColumn("ReportDate").AsDate().NotNullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IncidentNumber").AsString(100).Nullable() + .WithColumn("ResourceOrderNumber").AsString(100).Nullable() + .WithColumn("RequestNumber").AsString(100).Nullable() + .WithColumn("CostCode").AsString(100).Nullable() + .WithColumn("PointOfHire").AsString(250).Nullable() + .WithColumn("NoClear8").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("UnsafeConditionsStandDown").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ContractorSignedByUserId").AsString(128).Nullable() + .WithColumn("ContractorSignedOn").AsDateTime2().Nullable() + .WithColumn("CustomerSignerName").AsString(int.MaxValue).Nullable() + .WithColumn("CustomerSignedOn").AsDateTime2().Nullable() + .WithColumn("SubmittedByUserId").AsString(128).Nullable() + .WithColumn("SubmittedOn").AsDateTime2().Nullable() + .WithColumn("ApprovedByUserId").AsString(128).Nullable() + .WithColumn("ApprovedOn").AsDateTime2().Nullable() + .WithColumn("InvoiceId").AsString(36).Nullable() + .WithColumn("RmsExternalOrderFillId").AsString(36).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_DeploymentTimeReports_Deployment").OnTable("DeploymentTimeReports").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ReportDate").Ascending(); + Create.Index("IX_DeploymentTimeReports_Department").OnTable("DeploymentTimeReports").OnColumn("DepartmentId").Ascending().OnColumn("Status").Ascending(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Number' AND object_id = OBJECT_ID('DeploymentTimeReports')) CREATE UNIQUE INDEX [UX_DeploymentTimeReports_Number] ON [DeploymentTimeReports] ([DepartmentId], [ReportNumber]);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Date' AND object_id = OBJECT_ID('DeploymentTimeReports')) CREATE UNIQUE INDEX [UX_DeploymentTimeReports_Date] ON [DeploymentTimeReports] ([DeploymentId], [ReportDate]) WHERE [IsDeleted] = 0 AND [Status] <> 4;"); + Create.ForeignKey("FK_DeploymentTimeReports_Deployment").FromTable("DeploymentTimeReports").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("DeploymentTimeEntries").Exists()) + { + Create.Table("DeploymentTimeEntries") + .WithColumn("DeploymentTimeEntryId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentTimeReportId").AsString(36).NotNullable() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubjectType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("DeploymentPersonnelId").AsString(36).Nullable() + .WithColumn("DeploymentUnitId").AsString(36).Nullable() + .WithColumn("DeploymentEquipmentId").AsString(36).Nullable() + .WithColumn("EntryType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StartTime").AsDateTime2().NotNullable() + .WithColumn("EndTime").AsDateTime2().NotNullable() + .WithColumn("PaidBreakMinutes").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UnpaidBreakMinutes").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CrewSizeSnapshot").AsInt32().Nullable() + .WithColumn("CertificationCode").AsString(50).Nullable() + .WithColumn("MileageKm").AsDecimal(9,2).Nullable() + .WithColumn("FuelDeductionLitres").AsDecimal(9,2).Nullable() + .WithColumn("AgencySuppliedMeals").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AgencySuppliedAccommodation").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + Create.Index("IX_DeploymentTimeEntries_Report").OnTable("DeploymentTimeEntries").OnColumn("DeploymentTimeReportId").Ascending().OnColumn("SortOrder").Ascending(); + Create.Index("IX_DeploymentTimeEntries_Deployment").OnTable("DeploymentTimeEntries").OnColumn("DeploymentId").Ascending(); + Create.ForeignKey("FK_DeploymentTimeEntries_Report").FromTable("DeploymentTimeEntries").ForeignColumn("DeploymentTimeReportId").ToTable("DeploymentTimeReports").PrimaryColumn("DeploymentTimeReportId"); + } + if (!Schema.Table("DeploymentExpenses").Exists()) + { + Create.Table("DeploymentExpenses") + .WithColumn("DeploymentExpenseId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DeploymentTimeReportId").AsString(36).Nullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ExpenseDate").AsDateTime2().NotNullable() + .WithColumn("ExpenseType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MealCode").AsString(10).Nullable() + .WithColumn("City").AsString(200).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Amount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("Currency").AsString(3).Nullable() + .WithColumn("PreApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Billable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("ReceiptAttachmentId").AsInt32().Nullable() + .WithColumn("RmsExternalOrderFillId").AsString(36).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_DeploymentExpenses_Deployment").OnTable("DeploymentExpenses").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ExpenseDate").Ascending(); + Create.ForeignKey("FK_DeploymentExpenses_Deployment").FromTable("DeploymentExpenses").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("DeploymentAttachments").Exists()) + { + Create.Table("DeploymentAttachments") + .WithColumn("DeploymentAttachmentId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DeploymentId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("AttachmentType").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("Name").AsString(250).Nullable() + .WithColumn("FileName").AsString(int.MaxValue).Nullable() + .WithColumn("FileType").AsString(200).Nullable() + .WithColumn("FileSize").AsInt32().Nullable() + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_DeploymentAttachments_Deployment").OnTable("DeploymentAttachments").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending(); + Create.ForeignKey("FK_DeploymentAttachments_Deployment").FromTable("DeploymentAttachments").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId"); + } + if (!Schema.Table("TimeReportNumberSequences").Exists()) + { + Create.Table("TimeReportNumberSequences") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("NextReportNumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + if (Schema.Table("TimeReportNumberSequences").Exists()) Delete.Table("TimeReportNumberSequences"); + if (Schema.Table("DeploymentAttachments").Exists()) Delete.Table("DeploymentAttachments"); + if (Schema.Table("DeploymentExpenses").Exists()) Delete.Table("DeploymentExpenses"); + if (Schema.Table("DeploymentTimeEntries").Exists()) Delete.Table("DeploymentTimeEntries"); + if (Schema.Table("DeploymentTimeReports").Exists()) Delete.Table("DeploymentTimeReports"); + if (Schema.Table("DeploymentEquipment").Exists()) Delete.Table("DeploymentEquipment"); + if (Schema.Table("DeploymentPersonnel").Exists()) Delete.Table("DeploymentPersonnel"); + if (Schema.Table("DeploymentUnits").Exists()) Delete.Table("DeploymentUnits"); + if (Schema.Table("Deployments").Exists()) Delete.Table("Deployments"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs new file mode 100644 index 000000000..2c6aa7458 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs @@ -0,0 +1,318 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase C (C1, C11): invoice provenance columns (service contract, deployment, time report per line), the Cal OES MARS shadow tables (agency profile, F-5 resource crosswalk, annual rate profiles/lines, administrative-rate inputs, agreement snapshots, work items, expected reimbursement lines — local mirrors of an external system that stores no portal credentials), and the three Phase C feature flags: Operations.Deployments (free), Invoicing.ContractorBilling and CostRecovery.CalOesMars (prerequisite Business.Operations). Registry M0219. Guarded for safe retry. + /// + [Migration(219)] + public class M0219_ExtendInvoicingAndAddCostRecoveryProfiles : Migration + { + public override void Up() + { + if (!Schema.Table("Invoices").Column("ServiceContractId").Exists()) + Alter.Table("Invoices").AddColumn("ServiceContractId").AsString(36).Nullable(); + if (!Schema.Table("Invoices").Column("DeploymentId").Exists()) + Alter.Table("Invoices").AddColumn("DeploymentId").AsString(36).Nullable(); + if (!Schema.Table("InvoiceLineItems").Column("DeploymentTimeReportId").Exists()) + Alter.Table("InvoiceLineItems").AddColumn("DeploymentTimeReportId").AsString(36).Nullable(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Invoices_Deployment' AND object_id = OBJECT_ID('Invoices')) CREATE INDEX [IX_Invoices_Deployment] ON [Invoices] ([DeploymentId]) WHERE [DeploymentId] IS NOT NULL;"); + + if (!Schema.Table("CalOesMarsAgencyProfiles").Exists()) + { + Create.Table("CalOesMarsAgencyProfiles") + .WithColumn("CalOesMarsAgencyProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("AuthorityProfileCode").AsString(50).NotNullable() + .WithColumn("MacsDesignator").AsString(20).Nullable() + .WithColumn("AgencyName").AsString(250).Nullable() + .WithColumn("AgencyCategory").AsString(50).Nullable() + .WithColumn("ContactName").AsString(int.MaxValue).Nullable() + .WithColumn("ContactPhone").AsString(int.MaxValue).Nullable() + .WithColumn("ContactEmail").AsString(int.MaxValue).Nullable() + .WithColumn("Address").AsString(int.MaxValue).Nullable() + .WithColumn("FeinReference").AsString(int.MaxValue).Nullable() + .WithColumn("UeiReference").AsString(int.MaxValue).Nullable() + .WithColumn("SamReference").AsString(int.MaxValue).Nullable() + .WithColumn("FiscalSupplierReference").AsString(int.MaxValue).Nullable() + .WithColumn("PortalAccountRole").AsString(50).Nullable() + .WithColumn("PortalAccountReference").AsString(200).Nullable() + .WithColumn("VerifiedOn").AsDateTime2().Nullable() + .WithColumn("VerifiedByUserId").AsString(128).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_CalOesMarsAgencyProfiles_Department' AND object_id = OBJECT_ID('CalOesMarsAgencyProfiles')) CREATE UNIQUE INDEX [UX_CalOesMarsAgencyProfiles_Department] ON [CalOesMarsAgencyProfiles] ([DepartmentId]) WHERE [IsDeleted] = 0;"); + } + if (!Schema.Table("CalOesMarsResourceProfiles").Exists()) + { + Create.Table("CalOesMarsResourceProfiles") + .WithColumn("CalOesMarsResourceProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubjectType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UnitId").AsInt32().Nullable() + .WithColumn("InventoryAssetId").AsString(36).Nullable() + .WithColumn("ExternalResourceName").AsString(250).Nullable() + .WithColumn("MarsResourceId").AsString(100).Nullable() + .WithColumn("ResourceType").AsString(100).Nullable() + .WithColumn("ResourceKind").AsString(100).Nullable() + .WithColumn("CodeScheme").AsString(50).Nullable() + .WithColumn("UnitDesignator").AsString(100).Nullable() + .WithColumn("LicensePlate").AsString(int.MaxValue).Nullable() + .WithColumn("Vin").AsString(int.MaxValue).Nullable() + .WithColumn("SerialNumber").AsString(int.MaxValue).Nullable() + .WithColumn("Ownership").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("ObservedExternalStatus").AsString(50).Nullable() + .WithColumn("ObservedOn").AsDateTime2().Nullable() + .WithColumn("ReviewState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsResourceProfiles_Department").OnTable("CalOesMarsResourceProfiles").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + Create.Index("IX_CalOesMarsResourceProfiles_Unit").OnTable("CalOesMarsResourceProfiles").OnColumn("UnitId").Ascending(); + } + if (!Schema.Table("CalOesMarsRateProfiles").Exists()) + { + Create.Table("CalOesMarsRateProfiles") + .WithColumn("CalOesMarsRateProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubmissionYear").AsInt32().NotNullable() + .WithColumn("SubmissionType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("BaseRateAccepted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AdministrativeRateMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AdministrativeRateValue").AsDecimal(9,4).Nullable() + .WithColumn("AuthorityProfileCode").AsString(50).NotNullable() + .WithColumn("SourceUrl").AsString(int.MaxValue).Nullable() + .WithColumn("SourceDate").AsDateTime2().Nullable() + .WithColumn("SignedOn").AsDateTime2().Nullable() + .WithColumn("SignedByName").AsString(int.MaxValue).Nullable() + .WithColumn("ObservedExternalStatus").AsString(50).Nullable() + .WithColumn("ObservedOn").AsDateTime2().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsRateProfiles_Department").OnTable("CalOesMarsRateProfiles").OnColumn("DepartmentId").Ascending().OnColumn("SubmissionYear").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("CalOesMarsRateLines").Exists()) + { + Create.Table("CalOesMarsRateLines") + .WithColumn("CalOesMarsRateLineId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("CalOesMarsRateProfileId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("LineKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ClassificationCode").AsString(100).Nullable() + .WithColumn("ResourceCode").AsString(100).Nullable() + .WithColumn("FemaCode").AsString(50).Nullable() + .WithColumn("Description").AsString(int.MaxValue).Nullable() + .WithColumn("Basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StraightRate").AsDecimal(18,4).Nullable() + .WithColumn("OvertimeRate").AsDecimal(18,4).Nullable() + .WithColumn("IncludesWorkersComp").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IncludesUnemploymentInsurance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("PortalToPortalEligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("OvertimeEligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Authority").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SourceInputVersions").AsString(int.MaxValue).Nullable() + .WithColumn("ObservedExternalStatus").AsString(50).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsRateLines_Profile").OnTable("CalOesMarsRateLines").OnColumn("CalOesMarsRateProfileId").Ascending().OnColumn("SortOrder").Ascending(); + Create.ForeignKey("FK_CalOesMarsRateLines_Profile").FromTable("CalOesMarsRateLines").ForeignColumn("CalOesMarsRateProfileId").ToTable("CalOesMarsRateProfiles").PrimaryColumn("CalOesMarsRateProfileId"); + } + if (!Schema.Table("CalOesMarsAdministrativeRateInputs").Exists()) + { + Create.Table("CalOesMarsAdministrativeRateInputs") + .WithColumn("CalOesMarsAdministrativeRateInputId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("CalOesMarsRateProfileId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("FiscalYear").AsInt32().NotNullable() + .WithColumn("FunctionCode").AsString(50).Nullable() + .WithColumn("CategoryCode").AsString(50).Nullable() + .WithColumn("CategoryProfileVersion").AsString(50).Nullable() + .WithColumn("Classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ActualAmount").AsString(int.MaxValue).Nullable() + .WithColumn("SourceSystem").AsString(100).Nullable() + .WithColumn("SourceLine").AsString(200).Nullable() + .WithColumn("InputVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IncidentDirectExclusion").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DoubleCountMarker").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ReviewStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ReviewReason").AsString(int.MaxValue).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsAdministrativeRateInputs_Profile").OnTable("CalOesMarsAdministrativeRateInputs").OnColumn("CalOesMarsRateProfileId").Ascending(); + Create.ForeignKey("FK_CalOesMarsAdministrativeRateInputs_Profile").FromTable("CalOesMarsAdministrativeRateInputs").ForeignColumn("CalOesMarsRateProfileId").ToTable("CalOesMarsRateProfiles").PrimaryColumn("CalOesMarsRateProfileId"); + } + if (!Schema.Table("CalOesMarsAgreementSnapshots").Exists()) + { + Create.Table("CalOesMarsAgreementSnapshots") + .WithColumn("CalOesMarsAgreementSnapshotId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ClassificationCode").AsString(100).Nullable() + .WithColumn("ClassificationTitle").AsString(250).Nullable() + .WithColumn("DocumentKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CompensationMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("OvertimeMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StartOn").AsDateTime2().Nullable() + .WithColumn("EndOn").AsDateTime2().Nullable() + .WithColumn("ExternalApprovalStatus").AsString(50).Nullable() + .WithColumn("ObservedOn").AsDateTime2().Nullable() + .WithColumn("AttachmentId").AsInt32().Nullable() + .WithColumn("AttachmentChecksum").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsAgreementSnapshots_Department").OnTable("CalOesMarsAgreementSnapshots").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("StartOn").Ascending(); + } + if (!Schema.Table("CalOesMarsWorkItems").Exists()) + { + Create.Table("CalOesMarsWorkItems") + .WithColumn("CalOesMarsWorkItemId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DeploymentId").AsString(36).Nullable() + .WithColumn("RmsExternalOrderId").AsString(36).Nullable() + .WithColumn("RmsExternalOrderFillId").AsString(36).Nullable() + .WithColumn("RecordType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LocalState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MarsRecordId").AsString(100).Nullable() + .WithColumn("MarsInvoiceId").AsString(100).Nullable() + .WithColumn("ObservedExternalStatus").AsString(50).Nullable() + .WithColumn("ObservedOn").AsDateTime2().Nullable() + .WithColumn("ObservedSource").AsString(50).Nullable() + .WithColumn("CorrectionComment").AsString(int.MaxValue).Nullable() + .WithColumn("AuthorityProfileCode").AsString(50).Nullable() + .WithColumn("RateProfileVersion").AsString(50).Nullable() + .WithColumn("AgreementSnapshotId").AsString(36).Nullable() + .WithColumn("SnapshotJson").AsString(int.MaxValue).Nullable() + .WithColumn("ValidationSummaryJson").AsString(int.MaxValue).Nullable() + .WithColumn("SubmittedByUserId").AsString(128).Nullable() + .WithColumn("SubmittedOn").AsDateTime2().Nullable() + .WithColumn("ApprovedByUserId").AsString(128).Nullable() + .WithColumn("ApprovedOn").AsDateTime2().Nullable() + .WithColumn("RejectedByUserId").AsString(128).Nullable() + .WithColumn("RejectedOn").AsDateTime2().Nullable() + .WithColumn("PaidOn").AsDateTime2().Nullable() + .WithColumn("ExpectedTotal").AsDecimal(18,2).Nullable() + .WithColumn("ApprovedTotal").AsDecimal(18,2).Nullable() + .WithColumn("PaidTotal").AsDecimal(18,2).Nullable() + .WithColumn("PaymentReference").AsString(int.MaxValue).Nullable() + .WithColumn("SupersedesWorkItemId").AsString(36).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SourceArtifact").AsString(int.MaxValue).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_CalOesMarsWorkItems_Department").OnTable("CalOesMarsWorkItems").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("LocalState").Ascending(); + Create.Index("IX_CalOesMarsWorkItems_Deployment").OnTable("CalOesMarsWorkItems").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_CalOesMarsWorkItems_External").OnTable("CalOesMarsWorkItems").OnColumn("MarsRecordId").Ascending(); + } + if (!Schema.Table("CalOesMarsReimbursementLines").Exists()) + { + Create.Table("CalOesMarsReimbursementLines") + .WithColumn("CalOesMarsReimbursementLineId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("CalOesMarsWorkItemId").AsString(36).NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DeploymentId").AsString(36).Nullable() + .WithColumn("LineDate").AsDateTime2().Nullable() + .WithColumn("LineKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SubjectType").AsInt32().Nullable() + .WithColumn("SubjectId").AsString(128).Nullable() + .WithColumn("SourceWorkId").AsString(36).Nullable() + .WithColumn("SourceExpenseId").AsString(36).Nullable() + .WithColumn("Quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("Unit").AsString(20).Nullable() + .WithColumn("Rate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("RateLineId").AsString(36).Nullable() + .WithColumn("RateLineVersion").AsInt32().Nullable() + .WithColumn("ExpectedAmount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ApprovedAmount").AsDecimal(18,2).Nullable() + .WithColumn("PaidAmount").AsDecimal(18,2).Nullable() + .WithColumn("EligibilityState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EligibilityReason").AsString(int.MaxValue).Nullable() + .WithColumn("SourceVersions").AsString(int.MaxValue).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable(); + Create.Index("IX_CalOesMarsReimbursementLines_WorkItem").OnTable("CalOesMarsReimbursementLines").OnColumn("CalOesMarsWorkItemId").Ascending().OnColumn("SortOrder").Ascending(); + Create.ForeignKey("FK_CalOesMarsReimbursementLines_WorkItem").FromTable("CalOesMarsReimbursementLines").ForeignColumn("CalOesMarsWorkItemId").ToTable("CalOesMarsWorkItems").PrimaryColumn("CalOesMarsWorkItemId"); + } + + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Operations.Deployments') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Operations.Deployments', 'Deployment finance', 'Free deployment finance wrapper around a Call: roster, daily time reports, expenses, attachments, manifests and the RMS external-order link (Workforce & Business Operations plan, Phase C). Independent of the Business Operations add-on. Seeded off.', 'Business', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Invoicing.ContractorBilling') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Invoicing.ContractorBilling', 'Contractor billing', 'Rate schedules, service contracts, bids, bid conversion, charge calculation and invoice generation from deployments (Workforce & Business Operations plan, Phase C). Requires Business.Operations. Seeded off.', 'Business', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlagPrerequisites] p JOIN [FeatureFlags] f ON f.[FeatureFlagId] = p.[FeatureFlagId] JOIN [FeatureFlags] r ON r.[FeatureFlagId] = p.[RequiredFeatureFlagId] WHERE f.[FlagKey] = 'Invoicing.ContractorBilling' AND r.[FlagKey] = 'Business.Operations') INSERT INTO [FeatureFlagPrerequisites] ([FeatureFlagId], [RequiredFeatureFlagId], [RequiredValue]) SELECT f.[FeatureFlagId], r.[FeatureFlagId], NULL FROM [FeatureFlags] f CROSS JOIN [FeatureFlags] r WHERE f.[FlagKey] = 'Invoicing.ContractorBilling' AND r.[FlagKey] = 'Business.Operations';"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'CostRecovery.CalOesMars') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('CostRecovery.CalOesMars', 'Cal OES MARS cost recovery', 'California Fire Assistance Agreement cost-recovery preparation and reconciliation for the Cal OES Mutual Aid Reimbursement System (Workforce & Business Operations plan, Phase C11). Manual portal handoff only; requires Business.Operations. Seeded off.', 'Business', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlagPrerequisites] p JOIN [FeatureFlags] f ON f.[FeatureFlagId] = p.[FeatureFlagId] JOIN [FeatureFlags] r ON r.[FeatureFlagId] = p.[RequiredFeatureFlagId] WHERE f.[FlagKey] = 'CostRecovery.CalOesMars' AND r.[FlagKey] = 'Business.Operations') INSERT INTO [FeatureFlagPrerequisites] ([FeatureFlagId], [RequiredFeatureFlagId], [RequiredValue]) SELECT f.[FeatureFlagId], r.[FeatureFlagId], NULL FROM [FeatureFlags] f CROSS JOIN [FeatureFlags] r WHERE f.[FlagKey] = 'CostRecovery.CalOesMars' AND r.[FlagKey] = 'Business.Operations';"); + } + + public override void Down() + { + if (Schema.Table("CalOesMarsReimbursementLines").Exists()) Delete.Table("CalOesMarsReimbursementLines"); + if (Schema.Table("CalOesMarsWorkItems").Exists()) Delete.Table("CalOesMarsWorkItems"); + if (Schema.Table("CalOesMarsAgreementSnapshots").Exists()) Delete.Table("CalOesMarsAgreementSnapshots"); + if (Schema.Table("CalOesMarsAdministrativeRateInputs").Exists()) Delete.Table("CalOesMarsAdministrativeRateInputs"); + if (Schema.Table("CalOesMarsRateLines").Exists()) Delete.Table("CalOesMarsRateLines"); + if (Schema.Table("CalOesMarsRateProfiles").Exists()) Delete.Table("CalOesMarsRateProfiles"); + if (Schema.Table("CalOesMarsResourceProfiles").Exists()) Delete.Table("CalOesMarsResourceProfiles"); + if (Schema.Table("CalOesMarsAgencyProfiles").Exists()) Delete.Table("CalOesMarsAgencyProfiles"); + if (Schema.Table("InvoiceLineItems").Column("DeploymentTimeReportId").Exists()) Delete.Column("DeploymentTimeReportId").FromTable("InvoiceLineItems"); + if (Schema.Table("Invoices").Column("DeploymentId").Exists()) Delete.Column("DeploymentId").FromTable("Invoices"); + if (Schema.Table("Invoices").Column("ServiceContractId").Exists()) Delete.Column("ServiceContractId").FromTable("Invoices"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0212_AddOnlinePaymentsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0212_AddOnlinePaymentsPg.cs index 4ead14c5d..d7b7249f1 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0212_AddOnlinePaymentsPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0212_AddOnlinePaymentsPg.cs @@ -78,9 +78,9 @@ public override void Up() Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_invoicepaymentrequests_open ON invoicepaymentrequests (invoiceid) WHERE status IN (0, 1, 2);"); } - if (!Schema.Table("paymentproviderevents").Exists()) + if (!Schema.Table("paymentconnectevents").Exists()) { - Create.Table("paymentproviderevents") + Create.Table("paymentconnectevents") .WithColumn("paymentconnecteventid").AsString(36).NotNullable().PrimaryKey() .WithColumn("provider").AsInt32().NotNullable() .WithColumn("externaleventid").AsString(200).NotNullable() @@ -94,15 +94,21 @@ public override void Up() .WithColumn("error").AsString(1000).Nullable() .WithColumn("payloadjson").AsCustom("text").Nullable(); - Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_paymentproviderevents_event ON paymentproviderevents (provider, externaleventid);"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentproviderevents_account ON paymentproviderevents (provider, externalaccountid, receivedon);"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentproviderevents_received ON paymentproviderevents (receivedon);"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentproviderevents_outcome ON paymentproviderevents (outcome, receivedon);"); - Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentproviderevents_department ON paymentproviderevents (departmentid);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_paymentconnectevents_event ON paymentconnectevents (provider, externaleventid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentconnectevents_account ON paymentconnectevents (provider, externalaccountid, receivedon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentconnectevents_received ON paymentconnectevents (receivedon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentconnectevents_outcome ON paymentconnectevents (outcome, receivedon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paymentconnectevents_department ON paymentconnectevents (departmentid);"); } // Idempotency of online payments is enforced by the database, not only by the pre-insert lookup: M0210's // non-unique gateway index is replaced by a partial unique one (manual payments carry no transaction id). + // Preflight: a duplicate (provider, gatewaytransactionid) pair would make the unique index fail after the old + // index was already dropped, so the migration stops here, names the problem and leaves M0210's index in place. + Execute.Sql( + "DO $guard$ BEGIN IF to_regclass('invoicepayments') IS NOT NULL AND EXISTS (SELECT 1 FROM invoicepayments WHERE gatewaytransactionid IS NOT NULL AND provider IS NOT NULL " + + "GROUP BY provider, gatewaytransactionid HAVING COUNT(*) > 1) " + + "THEN RAISE EXCEPTION 'M0212: invoicepayments holds duplicate (provider, gatewaytransactionid) pairs; resolve them before ux_invoicepayments_gateway can be created.'; END IF; END $guard$;"); Execute.Sql("DROP INDEX IF EXISTS ix_invoicepayments_gateway;"); Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_invoicepayments_gateway ON invoicepayments (provider, gatewaytransactionid) WHERE gatewaytransactionid IS NOT NULL AND provider IS NOT NULL;"); @@ -130,7 +136,7 @@ public override void Down() { Execute.Sql("DROP INDEX IF EXISTS ux_invoicepayments_gateway;"); Execute.Sql("DO $guard$ BEGIN IF to_regclass('invoicepayments') IS NOT NULL THEN CREATE INDEX IF NOT EXISTS ix_invoicepayments_gateway ON invoicepayments (provider, gatewaytransactionid); END IF; END $guard$;"); - Execute.Sql("DO $guard$ BEGIN IF to_regclass('paymentproviderevents') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM paymentproviderevents) THEN DROP TABLE paymentproviderevents; END IF; END $guard$;"); + Execute.Sql("DO $guard$ BEGIN IF to_regclass('paymentconnectevents') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM paymentconnectevents) THEN DROP TABLE paymentconnectevents; END IF; END $guard$;"); Execute.Sql("DO $guard$ BEGIN IF to_regclass('invoicepaymentrequests') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM invoicepaymentrequests) THEN DROP TABLE invoicepaymentrequests; END IF; END $guard$;"); Execute.Sql("DO $guard$ BEGIN IF to_regclass('departmentpaymentconnections') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM departmentpaymentconnections) THEN DROP TABLE departmentpaymentconnections; END IF; END $guard$;"); } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0213_AddCertificationTypesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0213_AddCertificationTypesPg.cs index 01b62521c..2ee4c0373 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0213_AddCertificationTypesPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0213_AddCertificationTypesPg.cs @@ -85,8 +85,14 @@ public override void Up() .WithColumn("treatpendingverificationasvalid").AsBoolean().NotNullable().WithDefaultValue(false) .WithColumn("sendadmindigest").AsBoolean().NotNullable().WithDefaultValue(true) .WithColumn("updatedon").AsDateTime2().NotNullable() - .WithColumn("updatedbyuserid").AsString(128).Nullable(); + .WithColumn("updatedbyuserid").AsString(128).Nullable() + .WithColumn("lastsweeplocaldate").AsDateTime2().Nullable(); } + // The worker's per-department sweep claim (one sweep per department-local day, survives a repeated or skipped hour). + // Schema checks run when Up() is evaluated while Create.Table above is deferred, so a fresh database takes the + // column from the create and only a database that already carries the table gets the alter. + if (Schema.Table("departmentcertificationsettings").Exists() && !Schema.Table("departmentcertificationsettings").Column("lastsweeplocaldate").Exists()) + Alter.Table("departmentcertificationsettings").AddColumn("lastsweeplocaldate").AsDateTime2().Nullable(); if (!Schema.Table("unitcertifications").Exists()) { diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs new file mode 100644 index 000000000..6e3a78044 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs @@ -0,0 +1,116 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0215 (Workforce & Business Operations plan, Phase C). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(215)] + public class M0215_AddRateSchedulesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rateschedules").Exists()) + { + Create.Table("rateschedules") + .WithColumn("ratescheduleid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("currency").AsString(3).NotNullable().WithDefaultValue("USD") + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("policyjson").AsCustom("text").Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rateschedules_department ON rateschedules (departmentid, isdeleted);"); + } + if (!Schema.Table("ratescheduleentries").Exists()) + { + Create.Table("ratescheduleentries") + .WithColumn("ratescheduleentryid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ratescheduleid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("entrytype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("code").AsString(50).Nullable() + .WithColumn("groupkey").AsString(50).Nullable() + .WithColumn("crewsize").AsInt32().Nullable() + .WithColumn("certificationcode").AsString(50).Nullable() + .WithColumn("unittypeid").AsInt32().Nullable() + .WithColumn("inventoryitemid").AsString(36).Nullable() + .WithColumn("inventorycategoryid").AsString(36).Nullable() + .WithColumn("billingbasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("requiredcertificationsjson").AsCustom("text").Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratescheduleentries_schedule ON ratescheduleentries (ratescheduleid, isdeleted, sortorder);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratescheduleentries_group ON ratescheduleentries (ratescheduleid, groupkey);"); + Create.ForeignKey("fk_ratescheduleentries_schedule").FromTable("ratescheduleentries").ForeignColumn("ratescheduleid").ToTable("rateschedules").PrimaryColumn("ratescheduleid"); + } + if (!Schema.Table("ratescheduleentrybands").Exists()) + { + Create.Table("ratescheduleentrybands") + .WithColumn("ratescheduleentrybandid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ratescheduleentryid").AsString(36).NotNullable() + .WithColumn("bandtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("thresholdstarthours").AsDecimal(9,2).Nullable() + .WithColumn("thresholdendhours").AsDecimal(9,2).Nullable() + .WithColumn("dailytierminhours").AsDecimal(9,2).Nullable() + .WithColumn("dailytiermaxhours").AsDecimal(9,2).Nullable() + .WithColumn("freeunitsperday").AsDecimal(9,2).Nullable() + .WithColumn("requiresairtravel").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("mealcode").AsString(10).Nullable() + .WithColumn("label").AsString(200).Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratescheduleentrybands_entry ON ratescheduleentrybands (ratescheduleentryid, sortorder);"); + Create.ForeignKey("fk_ratescheduleentrybands_entry").FromTable("ratescheduleentrybands").ForeignColumn("ratescheduleentryid").ToTable("ratescheduleentries").PrimaryColumn("ratescheduleentryid"); + } + if (!Schema.Table("ratepremiums").Exists()) + { + Create.Table("ratepremiums") + .WithColumn("ratepremiumid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("ratescheduleid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("code").AsString(50).Nullable() + .WithColumn("standbyadder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("deploymentadder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("overtime1adder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("overtime2adder").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratepremiums_schedule ON ratepremiums (ratescheduleid, isdeleted);"); + Create.ForeignKey("fk_ratepremiums_schedule").FromTable("ratepremiums").ForeignColumn("ratescheduleid").ToTable("rateschedules").PrimaryColumn("ratescheduleid"); + } + } + + public override void Down() + { + Execute.Sql("DROP TABLE IF EXISTS ratepremiums;"); + Execute.Sql("DROP TABLE IF EXISTS ratescheduleentrybands;"); + Execute.Sql("DROP TABLE IF EXISTS ratescheduleentries;"); + Execute.Sql("DROP TABLE IF EXISTS rateschedules;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs new file mode 100644 index 000000000..37018db3e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs @@ -0,0 +1,92 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0216 (Workforce & Business Operations plan, Phase C). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(216)] + public class M0216_AddServiceContractsPg : Migration + { + public override void Up() + { + if (!Schema.Table("servicecontracts").Exists()) + { + Create.Table("servicecontracts") + .WithColumn("servicecontractid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("customerbillingprofileid").AsString(36).Nullable() + .WithColumn("contractnumber").AsString(100).Nullable() + .WithColumn("name").AsString(250).NotNullable() + .WithColumn("contracttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("starton").AsDateTime2().NotNullable() + .WithColumn("endon").AsDateTime2().Nullable() + .WithColumn("ratescheduleid").AsString(36).Nullable() + .WithColumn("discountpercent").AsDecimal(9,4).Nullable() + .WithColumn("termsnetdays").AsInt32().Nullable() + .WithColumn("invoicesubmissionemail").AsString(500).Nullable() + .WithColumn("maxdeploymentdays").AsInt32().Nullable() + .WithColumn("responsetimeminutes").AsInt32().Nullable() + .WithColumn("pointofhire").AsString(250).Nullable() + .WithColumn("documenttemplatekey").AsString(50).Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_servicecontracts_department ON servicecontracts (departmentid, isdeleted, status);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_servicecontracts_contact ON servicecontracts (contactid);"); + } + if (!Schema.Table("servicecontractdocumentrequirements").Exists()) + { + Create.Table("servicecontractdocumentrequirements") + .WithColumn("servicecontractdocumentrequirementid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("servicecontractid").AsString(36).NotNullable() + .WithColumn("name").AsString(250).NotNullable() + .WithColumn("stage").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("compliancedocumenttype").AsInt32().Nullable() + .WithColumn("ismandatory").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_servicecontractdocumentrequirements_contract ON servicecontractdocumentrequirements (servicecontractid, sortorder);"); + Create.ForeignKey("fk_servicecontractdocumentrequirements_contract").FromTable("servicecontractdocumentrequirements").ForeignColumn("servicecontractid").ToTable("servicecontracts").PrimaryColumn("servicecontractid"); + } + if (!Schema.Table("departmentcompliancedocuments").Exists()) + { + Create.Table("departmentcompliancedocuments") + .WithColumn("departmentcompliancedocumentid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("documenttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(250).NotNullable() + .WithColumn("documentnumber").AsCustom("text").Nullable() + .WithColumn("issuer").AsString(250).Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("alertleaddays").AsInt32().NotNullable().WithDefaultValue(30) + .WithColumn("filename").AsCustom("text").Nullable() + .WithColumn("filetype").AsString(200).Nullable() + .WithColumn("filesize").AsInt32().Nullable() + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_departmentcompliancedocuments_department ON departmentcompliancedocuments (departmentid, isdeleted, expireson);"); + } + } + + public override void Down() + { + Execute.Sql("DROP TABLE IF EXISTS departmentcompliancedocuments;"); + Execute.Sql("DROP TABLE IF EXISTS servicecontractdocumentrequirements;"); + Execute.Sql("DROP TABLE IF EXISTS servicecontracts;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs new file mode 100644 index 000000000..6ba67f1a2 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs @@ -0,0 +1,92 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0217 (Workforce & Business Operations plan, Phase C). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(217)] + public class M0217_AddBidsPg : Migration + { + public override void Up() + { + if (!Schema.Table("bids").Exists()) + { + Create.Table("bids") + .WithColumn("bidid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("bidnumber").AsInt32().NotNullable() + .WithColumn("contactid").AsString(128).NotNullable() + .WithColumn("customerbillingprofileid").AsString(36).Nullable() + .WithColumn("servicecontractid").AsString(36).Nullable() + .WithColumn("ratescheduleid").AsString(36).Nullable() + .WithColumn("title").AsString(250).NotNullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("validuntil").AsDateTime2().Nullable() + .WithColumn("requestedstarton").AsDateTime2().Nullable() + .WithColumn("requestedendon").AsDateTime2().Nullable() + .WithColumn("incidentnumber").AsString(100).Nullable() + .WithColumn("deliverylocation").AsCustom("text").Nullable() + .WithColumn("discountpercent").AsDecimal(9,4).Nullable() + .WithColumn("estimatedsubtotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("estimateddiscountamount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("estimatedtaxamount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("estimatedtotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("termstext").AsCustom("text").Nullable() + .WithColumn("senton").AsDateTime2().Nullable() + .WithColumn("senttoemail").AsString(500).Nullable() + .WithColumn("acceptedon").AsDateTime2().Nullable() + .WithColumn("declinedon").AsDateTime2().Nullable() + .WithColumn("declinereason").AsCustom("text").Nullable() + .WithColumn("convertedcallid").AsInt32().Nullable() + .WithColumn("converteddeploymentid").AsString(36).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_bids_department ON bids (departmentid, isdeleted, status);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_bids_contact ON bids (contactid);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_bids_number ON bids (departmentid, bidnumber);"); + } + if (!Schema.Table("bidlineitems").Exists()) + { + Create.Table("bidlineitems") + .WithColumn("bidlineitemid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("bidid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("ratescheduleentryid").AsString(36).Nullable() + .WithColumn("linetype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("description").AsCustom("text").NotNullable() + .WithColumn("crewsize").AsInt32().Nullable() + .WithColumn("quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(1) + .WithColumn("estimatedhoursperday").AsDecimal(9,2).Nullable() + .WithColumn("estimateddays").AsDecimal(9,2).Nullable() + .WithColumn("unitrate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("premiumidsjson").AsCustom("text").Nullable() + .WithColumn("estimatedamount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("taxable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_bidlineitems_bid ON bidlineitems (bidid, sortorder);"); + Create.ForeignKey("fk_bidlineitems_bid").FromTable("bidlineitems").ForeignColumn("bidid").ToTable("bids").PrimaryColumn("bidid"); + } + if (!Schema.Table("bidnumbersequences").Exists()) + { + Create.Table("bidnumbersequences") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("nextbidnumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + Execute.Sql("DROP TABLE IF EXISTS bidnumbersequences;"); + Execute.Sql("DROP TABLE IF EXISTS bidlineitems;"); + Execute.Sql("DROP TABLE IF EXISTS bids;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs new file mode 100644 index 000000000..d3deaee82 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs @@ -0,0 +1,252 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0218 (Workforce & Business Operations plan, Phase C). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(218)] + public class M0218_AddDeploymentsAndTimeTrackingPg : Migration + { + public override void Up() + { + if (!Schema.Table("deployments").Exists()) + { + Create.Table("deployments") + .WithColumn("deploymentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("rmsexternalorderid").AsString(36).Nullable() + .WithColumn("financemode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("bidid").AsString(36).Nullable() + .WithColumn("servicecontractid").AsString(36).Nullable() + .WithColumn("ratescheduleid").AsString(36).Nullable() + .WithColumn("contactid").AsString(128).Nullable() + .WithColumn("name").AsString(250).NotNullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("incidentnumber").AsString(100).Nullable() + .WithColumn("servicerequestnumber").AsString(100).Nullable() + .WithColumn("resourceordernumber").AsString(100).Nullable() + .WithColumn("requestnumber").AsString(100).Nullable() + .WithColumn("costcode").AsString(100).Nullable() + .WithColumn("pointofhire").AsString(250).Nullable() + .WithColumn("starton").AsDateTime2().Nullable() + .WithColumn("endon").AsDateTime2().Nullable() + .WithColumn("maxdays").AsInt32().Nullable() + .WithColumn("outofprovince").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("travelviaair").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("homecountry").AsString(2).Nullable() + .WithColumn("hostcountry").AsString(2).Nullable() + .WithColumn("homesubdivision").AsString(10).Nullable() + .WithColumn("hostsubdivision").AsString(10).Nullable() + .WithColumn("localtimezoneid").AsString(100).Nullable() + .WithColumn("locale").AsString(20).Nullable() + .WithColumn("measurementsystem").AsString(10).Nullable() + .WithColumn("currency").AsString(3).Nullable() + .WithColumn("discountpercent").AsDecimal(9,4).Nullable() + .WithColumn("statuschangedon").AsDateTime2().Nullable() + .WithColumn("calendaritemid").AsInt32().Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deployments_department ON deployments (departmentid, isdeleted, status);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deployments_externalorder ON deployments (rmsexternalorderid);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deployments_call ON deployments (callid) WHERE callid IS NOT NULL AND isdeleted = FALSE;"); + } + if (!Schema.Table("deploymentunits").Exists()) + { + Create.Table("deploymentunits") + .WithColumn("deploymentunitid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("unitid").AsInt32().NotNullable() + .WithColumn("ratescheduleentryid").AsString(36).Nullable() + .WithColumn("callsign").AsString(100).Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("removedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentunits_deployment ON deploymentunits (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentunits_unit ON deploymentunits (unitid);"); + Create.ForeignKey("fk_deploymentunits_deployment").FromTable("deploymentunits").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("deploymentpersonnel").Exists()) + { + Create.Table("deploymentpersonnel") + .WithColumn("deploymentpersonnelid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("deploymentunitid").AsString(36).Nullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("unitroleid").AsInt32().Nullable() + .WithColumn("ratescheduleentryid").AsString(36).Nullable() + .WithColumn("certificationcode").AsString(50).Nullable() + .WithColumn("premiumidsjson").AsCustom("text").Nullable() + .WithColumn("callsign").AsString(100).Nullable() + .WithColumn("rmsexternalorderfillid").AsString(36).Nullable() + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("removedon").AsDateTime2().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentpersonnel_deployment ON deploymentpersonnel (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentpersonnel_user ON deploymentpersonnel (userid, removedon);"); + Create.ForeignKey("fk_deploymentpersonnel_deployment").FromTable("deploymentpersonnel").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("deploymentequipment").Exists()) + { + Create.Table("deploymentequipment") + .WithColumn("deploymentequipmentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("deploymentunitid").AsString(36).Nullable() + .WithColumn("inventoryassetid").AsString(36).Nullable() + .WithColumn("inventoryitemid").AsString(36).Nullable() + .WithColumn("freetextname").AsString(250).Nullable() + .WithColumn("ratescheduleentryid").AsString(36).Nullable() + .WithColumn("issuedon").AsDateTime2().Nullable() + .WithColumn("returnedon").AsDateTime2().Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("addedon").AsDateTime2().NotNullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentequipment_deployment ON deploymentequipment (deploymentid);"); + Create.ForeignKey("fk_deploymentequipment_deployment").FromTable("deploymentequipment").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("deploymenttimereports").Exists()) + { + Create.Table("deploymenttimereports") + .WithColumn("deploymenttimereportid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("reportnumber").AsInt32().NotNullable() + .WithColumn("reportdate").AsDate().NotNullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("incidentnumber").AsString(100).Nullable() + .WithColumn("resourceordernumber").AsString(100).Nullable() + .WithColumn("requestnumber").AsString(100).Nullable() + .WithColumn("costcode").AsString(100).Nullable() + .WithColumn("pointofhire").AsString(250).Nullable() + .WithColumn("noclear8").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("unsafeconditionsstanddown").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("contractorsignedbyuserid").AsString(128).Nullable() + .WithColumn("contractorsignedon").AsDateTime2().Nullable() + .WithColumn("customersignername").AsCustom("text").Nullable() + .WithColumn("customersignedon").AsDateTime2().Nullable() + .WithColumn("submittedbyuserid").AsString(128).Nullable() + .WithColumn("submittedon").AsDateTime2().Nullable() + .WithColumn("approvedbyuserid").AsString(128).Nullable() + .WithColumn("approvedon").AsDateTime2().Nullable() + .WithColumn("invoiceid").AsString(36).Nullable() + .WithColumn("rmsexternalorderfillid").AsString(36).Nullable() + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimereports_deployment ON deploymenttimereports (deploymentid, isdeleted, reportdate);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimereports_department ON deploymenttimereports (departmentid, status);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deploymenttimereports_number ON deploymenttimereports (departmentid, reportnumber);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deploymenttimereports_date ON deploymenttimereports (deploymentid, reportdate) WHERE isdeleted = FALSE AND status <> 4;"); + Create.ForeignKey("fk_deploymenttimereports_deployment").FromTable("deploymenttimereports").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("deploymenttimeentries").Exists()) + { + Create.Table("deploymenttimeentries") + .WithColumn("deploymenttimeentryid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymenttimereportid").AsString(36).NotNullable() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("subjecttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("deploymentpersonnelid").AsString(36).Nullable() + .WithColumn("deploymentunitid").AsString(36).Nullable() + .WithColumn("deploymentequipmentid").AsString(36).Nullable() + .WithColumn("entrytype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("starttime").AsDateTime2().NotNullable() + .WithColumn("endtime").AsDateTime2().NotNullable() + .WithColumn("paidbreakminutes").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("unpaidbreakminutes").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("crewsizesnapshot").AsInt32().Nullable() + .WithColumn("certificationcode").AsString(50).Nullable() + .WithColumn("mileagekm").AsDecimal(9,2).Nullable() + .WithColumn("fueldeductionlitres").AsDecimal(9,2).Nullable() + .WithColumn("agencysuppliedmeals").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("agencysuppliedaccommodation").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("notes").AsCustom("text").Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimeentries_report ON deploymenttimeentries (deploymenttimereportid, sortorder);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimeentries_deployment ON deploymenttimeentries (deploymentid);"); + Create.ForeignKey("fk_deploymenttimeentries_report").FromTable("deploymenttimeentries").ForeignColumn("deploymenttimereportid").ToTable("deploymenttimereports").PrimaryColumn("deploymenttimereportid"); + } + if (!Schema.Table("deploymentexpenses").Exists()) + { + Create.Table("deploymentexpenses") + .WithColumn("deploymentexpenseid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("deploymenttimereportid").AsString(36).Nullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("expensedate").AsDateTime2().NotNullable() + .WithColumn("expensetype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("mealcode").AsString(10).Nullable() + .WithColumn("city").AsString(200).Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("amount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("currency").AsString(3).Nullable() + .WithColumn("preapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("billable").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("receiptattachmentid").AsInt32().Nullable() + .WithColumn("rmsexternalorderfillid").AsString(36).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentexpenses_deployment ON deploymentexpenses (deploymentid, isdeleted, expensedate);"); + Create.ForeignKey("fk_deploymentexpenses_deployment").FromTable("deploymentexpenses").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("deploymentattachments").Exists()) + { + Create.Table("deploymentattachments") + .WithColumn("deploymentattachmentid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("deploymentid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("attachmenttype").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("name").AsString(250).Nullable() + .WithColumn("filename").AsCustom("text").Nullable() + .WithColumn("filetype").AsString(200).Nullable() + .WithColumn("filesize").AsInt32().Nullable() + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentattachments_deployment ON deploymentattachments (deploymentid, isdeleted);"); + Create.ForeignKey("fk_deploymentattachments_deployment").FromTable("deploymentattachments").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid"); + } + if (!Schema.Table("timereportnumbersequences").Exists()) + { + Create.Table("timereportnumbersequences") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("nextreportnumber").AsInt32().NotNullable().WithDefaultValue(1); + } + } + + public override void Down() + { + Execute.Sql("DROP TABLE IF EXISTS timereportnumbersequences;"); + Execute.Sql("DROP TABLE IF EXISTS deploymentattachments;"); + Execute.Sql("DROP TABLE IF EXISTS deploymentexpenses;"); + Execute.Sql("DROP TABLE IF EXISTS deploymenttimeentries;"); + Execute.Sql("DROP TABLE IF EXISTS deploymenttimereports;"); + Execute.Sql("DROP TABLE IF EXISTS deploymentequipment;"); + Execute.Sql("DROP TABLE IF EXISTS deploymentpersonnel;"); + Execute.Sql("DROP TABLE IF EXISTS deploymentunits;"); + Execute.Sql("DROP TABLE IF EXISTS deployments;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs new file mode 100644 index 000000000..65a2a7288 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs @@ -0,0 +1,318 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0219 (Workforce & Business Operations plan, Phase C). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(219)] + public class M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg : Migration + { + public override void Up() + { + if (!Schema.Table("invoices").Column("servicecontractid").Exists()) + Alter.Table("invoices").AddColumn("servicecontractid").AsString(36).Nullable(); + if (!Schema.Table("invoices").Column("deploymentid").Exists()) + Alter.Table("invoices").AddColumn("deploymentid").AsString(36).Nullable(); + if (!Schema.Table("invoicelineitems").Column("deploymenttimereportid").Exists()) + Alter.Table("invoicelineitems").AddColumn("deploymenttimereportid").AsString(36).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_deployment ON invoices (deploymentid) WHERE deploymentid IS NOT NULL;"); + + if (!Schema.Table("caloesmarsagencyprofiles").Exists()) + { + Create.Table("caloesmarsagencyprofiles") + .WithColumn("caloesmarsagencyprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("authorityprofilecode").AsString(50).NotNullable() + .WithColumn("macsdesignator").AsString(20).Nullable() + .WithColumn("agencyname").AsString(250).Nullable() + .WithColumn("agencycategory").AsString(50).Nullable() + .WithColumn("contactname").AsCustom("text").Nullable() + .WithColumn("contactphone").AsCustom("text").Nullable() + .WithColumn("contactemail").AsCustom("text").Nullable() + .WithColumn("address").AsCustom("text").Nullable() + .WithColumn("feinreference").AsCustom("text").Nullable() + .WithColumn("ueireference").AsCustom("text").Nullable() + .WithColumn("samreference").AsCustom("text").Nullable() + .WithColumn("fiscalsupplierreference").AsCustom("text").Nullable() + .WithColumn("portalaccountrole").AsString(50).Nullable() + .WithColumn("portalaccountreference").AsString(200).Nullable() + .WithColumn("verifiedon").AsDateTime2().Nullable() + .WithColumn("verifiedbyuserid").AsString(128).Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_caloesmarsagencyprofiles_department ON caloesmarsagencyprofiles (departmentid) WHERE isdeleted = FALSE;"); + } + if (!Schema.Table("caloesmarsresourceprofiles").Exists()) + { + Create.Table("caloesmarsresourceprofiles") + .WithColumn("caloesmarsresourceprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("subjecttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("unitid").AsInt32().Nullable() + .WithColumn("inventoryassetid").AsString(36).Nullable() + .WithColumn("externalresourcename").AsString(250).Nullable() + .WithColumn("marsresourceid").AsString(100).Nullable() + .WithColumn("resourcetype").AsString(100).Nullable() + .WithColumn("resourcekind").AsString(100).Nullable() + .WithColumn("codescheme").AsString(50).Nullable() + .WithColumn("unitdesignator").AsString(100).Nullable() + .WithColumn("licenseplate").AsCustom("text").Nullable() + .WithColumn("vin").AsCustom("text").Nullable() + .WithColumn("serialnumber").AsCustom("text").Nullable() + .WithColumn("ownership").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("observedexternalstatus").AsString(50).Nullable() + .WithColumn("observedon").AsDateTime2().Nullable() + .WithColumn("reviewstate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsresourceprofiles_department ON caloesmarsresourceprofiles (departmentid, isdeleted);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsresourceprofiles_unit ON caloesmarsresourceprofiles (unitid);"); + } + if (!Schema.Table("caloesmarsrateprofiles").Exists()) + { + Create.Table("caloesmarsrateprofiles") + .WithColumn("caloesmarsrateprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("submissionyear").AsInt32().NotNullable() + .WithColumn("submissiontype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("baserateaccepted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("administrativeratemethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("administrativeratevalue").AsDecimal(9,4).Nullable() + .WithColumn("authorityprofilecode").AsString(50).NotNullable() + .WithColumn("sourceurl").AsCustom("text").Nullable() + .WithColumn("sourcedate").AsDateTime2().Nullable() + .WithColumn("signedon").AsDateTime2().Nullable() + .WithColumn("signedbyname").AsCustom("text").Nullable() + .WithColumn("observedexternalstatus").AsString(50).Nullable() + .WithColumn("observedon").AsDateTime2().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsrateprofiles_department ON caloesmarsrateprofiles (departmentid, submissionyear, isdeleted);"); + } + if (!Schema.Table("caloesmarsratelines").Exists()) + { + Create.Table("caloesmarsratelines") + .WithColumn("caloesmarsratelineid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("caloesmarsrateprofileid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("linekind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("classificationcode").AsString(100).Nullable() + .WithColumn("resourcecode").AsString(100).Nullable() + .WithColumn("femacode").AsString(50).Nullable() + .WithColumn("description").AsCustom("text").Nullable() + .WithColumn("basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("straightrate").AsDecimal(18,4).Nullable() + .WithColumn("overtimerate").AsDecimal(18,4).Nullable() + .WithColumn("includesworkerscomp").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("includesunemploymentinsurance").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("portaltoportaleligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("overtimeeligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("authority").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sourceinputversions").AsCustom("text").Nullable() + .WithColumn("observedexternalstatus").AsString(50).Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsratelines_profile ON caloesmarsratelines (caloesmarsrateprofileid, sortorder);"); + Create.ForeignKey("fk_caloesmarsratelines_profile").FromTable("caloesmarsratelines").ForeignColumn("caloesmarsrateprofileid").ToTable("caloesmarsrateprofiles").PrimaryColumn("caloesmarsrateprofileid"); + } + if (!Schema.Table("caloesmarsadministrativerateinputs").Exists()) + { + Create.Table("caloesmarsadministrativerateinputs") + .WithColumn("caloesmarsadministrativerateinputid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("caloesmarsrateprofileid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("fiscalyear").AsInt32().NotNullable() + .WithColumn("functioncode").AsString(50).Nullable() + .WithColumn("categorycode").AsString(50).Nullable() + .WithColumn("categoryprofileversion").AsString(50).Nullable() + .WithColumn("classification").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("actualamount").AsCustom("text").Nullable() + .WithColumn("sourcesystem").AsString(100).Nullable() + .WithColumn("sourceline").AsString(200).Nullable() + .WithColumn("inputversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("incidentdirectexclusion").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("doublecountmarker").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("reviewstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("reviewreason").AsCustom("text").Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsadministrativerateinputs_profile ON caloesmarsadministrativerateinputs (caloesmarsrateprofileid);"); + Create.ForeignKey("fk_caloesmarsadministrativerateinputs_profile").FromTable("caloesmarsadministrativerateinputs").ForeignColumn("caloesmarsrateprofileid").ToTable("caloesmarsrateprofiles").PrimaryColumn("caloesmarsrateprofileid"); + } + if (!Schema.Table("caloesmarsagreementsnapshots").Exists()) + { + Create.Table("caloesmarsagreementsnapshots") + .WithColumn("caloesmarsagreementsnapshotid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("classificationcode").AsString(100).Nullable() + .WithColumn("classificationtitle").AsString(250).Nullable() + .WithColumn("documentkind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("compensationmethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("overtimemethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("starton").AsDateTime2().Nullable() + .WithColumn("endon").AsDateTime2().Nullable() + .WithColumn("externalapprovalstatus").AsString(50).Nullable() + .WithColumn("observedon").AsDateTime2().Nullable() + .WithColumn("attachmentid").AsInt32().Nullable() + .WithColumn("attachmentchecksum").AsString(128).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsagreementsnapshots_department ON caloesmarsagreementsnapshots (departmentid, isdeleted, starton);"); + } + if (!Schema.Table("caloesmarsworkitems").Exists()) + { + Create.Table("caloesmarsworkitems") + .WithColumn("caloesmarsworkitemid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("deploymentid").AsString(36).Nullable() + .WithColumn("rmsexternalorderid").AsString(36).Nullable() + .WithColumn("rmsexternalorderfillid").AsString(36).Nullable() + .WithColumn("recordtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("localstate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("marsrecordid").AsString(100).Nullable() + .WithColumn("marsinvoiceid").AsString(100).Nullable() + .WithColumn("observedexternalstatus").AsString(50).Nullable() + .WithColumn("observedon").AsDateTime2().Nullable() + .WithColumn("observedsource").AsString(50).Nullable() + .WithColumn("correctioncomment").AsCustom("text").Nullable() + .WithColumn("authorityprofilecode").AsString(50).Nullable() + .WithColumn("rateprofileversion").AsString(50).Nullable() + .WithColumn("agreementsnapshotid").AsString(36).Nullable() + .WithColumn("snapshotjson").AsCustom("text").Nullable() + .WithColumn("validationsummaryjson").AsCustom("text").Nullable() + .WithColumn("submittedbyuserid").AsString(128).Nullable() + .WithColumn("submittedon").AsDateTime2().Nullable() + .WithColumn("approvedbyuserid").AsString(128).Nullable() + .WithColumn("approvedon").AsDateTime2().Nullable() + .WithColumn("rejectedbyuserid").AsString(128).Nullable() + .WithColumn("rejectedon").AsDateTime2().Nullable() + .WithColumn("paidon").AsDateTime2().Nullable() + .WithColumn("expectedtotal").AsDecimal(18,2).Nullable() + .WithColumn("approvedtotal").AsDecimal(18,2).Nullable() + .WithColumn("paidtotal").AsDecimal(18,2).Nullable() + .WithColumn("paymentreference").AsCustom("text").Nullable() + .WithColumn("supersedesworkitemid").AsString(36).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("sourceartifact").AsCustom("text").Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_department ON caloesmarsworkitems (departmentid, isdeleted, localstate);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_deployment ON caloesmarsworkitems (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_external ON caloesmarsworkitems (marsrecordid);"); + } + if (!Schema.Table("caloesmarsreimbursementlines").Exists()) + { + Create.Table("caloesmarsreimbursementlines") + .WithColumn("caloesmarsreimbursementlineid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("caloesmarsworkitemid").AsString(36).NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("deploymentid").AsString(36).Nullable() + .WithColumn("linedate").AsDateTime2().Nullable() + .WithColumn("linekind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("subjecttype").AsInt32().Nullable() + .WithColumn("subjectid").AsString(128).Nullable() + .WithColumn("sourceworkid").AsString(36).Nullable() + .WithColumn("sourceexpenseid").AsString(36).Nullable() + .WithColumn("quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("unit").AsString(20).Nullable() + .WithColumn("rate").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("ratelineid").AsString(36).Nullable() + .WithColumn("ratelineversion").AsInt32().Nullable() + .WithColumn("expectedamount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("approvedamount").AsDecimal(18,2).Nullable() + .WithColumn("paidamount").AsDecimal(18,2).Nullable() + .WithColumn("eligibilitystate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("eligibilityreason").AsCustom("text").Nullable() + .WithColumn("sourceversions").AsCustom("text").Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsreimbursementlines_workitem ON caloesmarsreimbursementlines (caloesmarsworkitemid, sortorder);"); + Create.ForeignKey("fk_caloesmarsreimbursementlines_workitem").FromTable("caloesmarsreimbursementlines").ForeignColumn("caloesmarsworkitemid").ToTable("caloesmarsworkitems").PrimaryColumn("caloesmarsworkitemid"); + } + + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Operations.Deployments', 'Deployment finance', 'Free deployment finance wrapper around a Call: roster, daily time reports, expenses, attachments, manifests and the RMS external-order link (Workforce & Business Operations plan, Phase C). Independent of the Business Operations add-on. Seeded off.', 'Business', FALSE WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Operations.Deployments');"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Invoicing.ContractorBilling', 'Contractor billing', 'Rate schedules, service contracts, bids, bid conversion, charge calculation and invoice generation from deployments (Workforce & Business Operations plan, Phase C). Requires Business.Operations. Seeded off.', 'Business', FALSE WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Invoicing.ContractorBilling');"); + Execute.Sql("INSERT INTO featureflagprerequisites (featureflagid, requiredfeatureflagid, requiredvalue) SELECT f.featureflagid, r.featureflagid, NULL FROM featureflags f CROSS JOIN featureflags r WHERE f.flagkey = 'Invoicing.ContractorBilling' AND r.flagkey = 'Business.Operations' AND NOT EXISTS (SELECT 1 FROM featureflagprerequisites p WHERE p.featureflagid = f.featureflagid AND p.requiredfeatureflagid = r.featureflagid);"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'CostRecovery.CalOesMars', 'Cal OES MARS cost recovery', 'California Fire Assistance Agreement cost-recovery preparation and reconciliation for the Cal OES Mutual Aid Reimbursement System (Workforce & Business Operations plan, Phase C11). Manual portal handoff only; requires Business.Operations. Seeded off.', 'Business', FALSE WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'CostRecovery.CalOesMars');"); + Execute.Sql("INSERT INTO featureflagprerequisites (featureflagid, requiredfeatureflagid, requiredvalue) SELECT f.featureflagid, r.featureflagid, NULL FROM featureflags f CROSS JOIN featureflags r WHERE f.flagkey = 'CostRecovery.CalOesMars' AND r.flagkey = 'Business.Operations' AND NOT EXISTS (SELECT 1 FROM featureflagprerequisites p WHERE p.featureflagid = f.featureflagid AND p.requiredfeatureflagid = r.featureflagid);"); + } + + public override void Down() + { + Execute.Sql("DROP TABLE IF EXISTS caloesmarsreimbursementlines;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsworkitems;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsagreementsnapshots;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsadministrativerateinputs;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsratelines;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsrateprofiles;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsresourceprofiles;"); + Execute.Sql("DROP TABLE IF EXISTS caloesmarsagencyprofiles;"); + Execute.Sql("ALTER TABLE invoicelineitems DROP COLUMN IF EXISTS deploymenttimereportid;"); + Execute.Sql("ALTER TABLE invoices DROP COLUMN IF EXISTS deploymentid;"); + Execute.Sql("ALTER TABLE invoices DROP COLUMN IF EXISTS servicecontractid;"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CertificationRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/CertificationRepositories.cs index c7b187f42..f3c0a86e6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CertificationRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CertificationRepositories.cs @@ -90,6 +90,45 @@ await ExecuteAsync( parameters, cancellationToken); return await GetAsync(settings.DepartmentId); } + + public async Task TryClaimSweepAsync(int departmentId, DateTime localDate, CancellationToken cancellationToken = default) + { + var date = DatabaseTimestamp(localDate.Date); + // One conditional UPDATE is the claim: it only moves the marker forward, so two workers on the same local day + // cannot both win, and a repeated hour after a daylight-saving change finds the day already taken. + var claimed = await ExecuteAsync( + $"UPDATE {Tbl("DepartmentCertificationSettings")} SET {Col("LastSweepLocalDate")} = {P}Date WHERE {Col("DepartmentId")} = {P}DepartmentId AND ({Col("LastSweepLocalDate")} IS NULL OR {Col("LastSweepLocalDate")} < {P}Date)", + new { DepartmentId = departmentId, Date = date }, cancellationToken); + if (claimed > 0) + return true; + if (await GetAsync(departmentId) != null) + return false; + + // No settings row yet (the department never opened the settings page): the claim inserts the defaults. + var defaults = new DepartmentCertificationSettings { DepartmentId = departmentId }; + try + { + await ExecuteAsync( + $"INSERT INTO {Tbl("DepartmentCertificationSettings")} ({Cols("DepartmentId", "EnforcementMode", "RoleRemovalGraceDays", "NotifyLeadDaysCsv", "NotifyCertificationHolder", "TreatPendingVerificationAsValid", "SendAdminDigest", "UpdatedOn", "UpdatedByUserId", "LastSweepLocalDate")}) " + + $"VALUES ({P}DepartmentId, {P}EnforcementMode, {P}RoleRemovalGraceDays, {P}NotifyLeadDaysCsv, {P}NotifyCertificationHolder, {P}TreatPendingVerificationAsValid, {P}SendAdminDigest, {P}UpdatedOn, {P}UpdatedByUserId, {P}Date)", + new + { + defaults.DepartmentId, defaults.EnforcementMode, defaults.RoleRemovalGraceDays, defaults.NotifyLeadDaysCsv, defaults.NotifyCertificationHolder, defaults.TreatPendingVerificationAsValid, + defaults.SendAdminDigest, UpdatedOn = DatabaseTimestamp(DateTime.UtcNow), UpdatedByUserId = (string)null, Date = date + }, cancellationToken); + return true; + } + catch (Exception ex) when (IsUniqueViolation(ex)) + { + // Another worker inserted the row (and its claim) first. + return false; + } + } + + public Task ReleaseSweepClaimAsync(int departmentId, DateTime localDate, CancellationToken cancellationToken = default) => + ExecuteAsync( + $"UPDATE {Tbl("DepartmentCertificationSettings")} SET {Col("LastSweepLocalDate")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("LastSweepLocalDate")} = {P}Date", + new { DepartmentId = departmentId, Date = DatabaseTimestamp(localDate.Date) }, cancellationToken); } /// Continuing-education credits (M0214). diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs index 6a3041092..a6f4ca421 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChecklistDepartmentCleanup.cs @@ -48,7 +48,10 @@ 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[] { "PersonnelCertificationCredits", "UnitCertifications", "PersonnelRoleCertificationRequirements", "DepartmentCertificationSettings", "PaymentProviderEvents", "InvoicePaymentRequests", "DepartmentPaymentConnections", "InvoicePayments", "InvoiceLineItems", "Invoices", "InvoiceNumberSequences", "RateCardItems", "RateCards", "CustomerBillingProfiles", "DepartmentBillingIdentities", "BusinessOperationsBillingAccounts", "WorkOrderPartMovements", "WorkOrderVendorCharges", "WorkOrderOperationReceipts", "WorkOrderPolicies", "WorkOrderReportSnapshots", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) + // Phase C children without a DepartmentId column are scoped through their parent. + if (await Exists("RateScheduleEntryBands")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("RateScheduleEntryBands")} WHERE {Q("RateScheduleEntryId")} IN (SELECT {Q("RateScheduleEntryId")} FROM {Q("RateScheduleEntries")} WHERE {Q("DepartmentId")}=@DepartmentId)", parameters, transaction, cancellationToken: ct)); + if (await Exists("ServiceContractDocumentRequirements")) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q("ServiceContractDocumentRequirements")} WHERE {Q("ServiceContractId")} IN (SELECT {Q("ServiceContractId")} FROM {Q("ServiceContracts")} WHERE {Q("DepartmentId")}=@DepartmentId)", parameters, transaction, cancellationToken: ct)); + foreach (var table in new[] { "CalOesMarsReimbursementLines", "CalOesMarsWorkItems", "CalOesMarsAgreementSnapshots", "CalOesMarsAdministrativeRateInputs", "CalOesMarsRateLines", "CalOesMarsRateProfiles", "CalOesMarsResourceProfiles", "CalOesMarsAgencyProfiles", "DeploymentAttachments", "DeploymentExpenses", "DeploymentTimeEntries", "DeploymentTimeReports", "DeploymentEquipment", "DeploymentPersonnel", "DeploymentUnits", "Deployments", "TimeReportNumberSequences", "BidLineItems", "Bids", "BidNumberSequences", "DepartmentComplianceDocuments", "ServiceContracts", "RatePremiums", "RateScheduleEntries", "RateSchedules", "PersonnelCertificationCredits", "UnitCertifications", "PersonnelRoleCertificationRequirements", "DepartmentCertificationSettings", "PaymentConnectEvents", "InvoicePaymentRequests", "DepartmentPaymentConnections", "InvoicePayments", "InvoiceLineItems", "Invoices", "InvoiceNumberSequences", "RateCardItems", "RateCards", "CustomerBillingProfiles", "DepartmentBillingIdentities", "BusinessOperationsBillingAccounts", "WorkOrderPartMovements", "WorkOrderVendorCharges", "WorkOrderOperationReceipts", "WorkOrderPolicies", "WorkOrderReportSnapshots", "WorkOrderFailureIntents", "WorkOrderSafetyHolds", "WorkOrderRecurrenceChanges", "WorkOrderMeterReadings", "ReadinessProBillingAccounts", "WorkOrderNotifications", "WorkOrderFiles", "WorkOrderParts", "WorkOrderLabors", "WorkOrderActivities", "WorkOrders", "WorkOrderRecurrenceVersions", "WorkOrderRecurrences", "ChecklistReminders", "ChecklistCompletionFiles", "ChecklistCompletionItems", "ChecklistCompletions", "ChecklistOccurrences", "ChecklistSchedules", "ChecklistDefinitionVersions", "ChecklistDefinitions", "DepartmentChecklistSettings" }) if (await Exists(table)) await connection.ExecuteAsync(new CommandDefinition($"DELETE FROM {Q(table)} WHERE {Q("DepartmentId")}=@DepartmentId", parameters, transaction, cancellationToken: ct)); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs new file mode 100644 index 000000000..795343bb7 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + // Workforce & Business Operations plan, Phase C (C3): deployment core repositories (registry M0218). + + public class DeploymentRepository : RmsRepositoryBase, IDeploymentRepository + { + public DeploymentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + private string OpenStatuses => $"{Col("Status")} IN (0, 1, 2, 3)"; + + public Task GetByIdForDepartmentAsync(string deploymentId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("Deployments")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = deploymentId, DepartmentId = departmentId }); + + public Task GetByCallIdAsync(int callId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("Deployments")} WHERE {Col("CallId")} = {P}CallId AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}", new { CallId = callId, DepartmentId = departmentId }); + + public Task GetByExternalOrderIdAsync(string rmsExternalOrderId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("Deployments")} WHERE {Col("RmsExternalOrderId")} = {P}OrderId AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", new { OrderId = rmsExternalOrderId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId, bool openOnly, int skip, int take) => + QueryAsync( + $"SELECT * FROM {Tbl("Deployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (openOnly ? $" AND {OpenStatuses}" : string.Empty) + + $" ORDER BY {Col("AddedOn")} DESC {Paging()}", + new { DepartmentId = departmentId, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 500) }); + + public Task> GetByIdsAsync(int departmentId, IEnumerable deploymentIds) => + QueryAsync($"SELECT * FROM {Tbl("Deployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("DeploymentId", "Ids")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC", new { DepartmentId = departmentId, Ids = InListValue(deploymentIds) }); + + public Task CountForDepartmentAsync(int departmentId, bool openOnly) => + ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Deployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (openOnly ? $" AND {OpenStatuses}" : string.Empty), new { DepartmentId = departmentId }); + } + + public class DeploymentUnitRepository : RmsRepositoryBase, IDeploymentUnitRepository + { + public DeploymentUnitRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentUnits")} WHERE {Col("DeploymentId")} = {P}Id ORDER BY {Col("AddedOn")}", new { Id = deploymentId }); + + public Task> GetActiveAssignmentsForUnitsAsync(int departmentId, IEnumerable unitIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId) => + QueryAsync( + $"SELECT u.* FROM {Tbl("DeploymentUnits")} u JOIN {Tbl("Deployments")} d ON d.{Col("DeploymentId")} = u.{Col("DeploymentId")} " + + $"WHERE u.{Col("DepartmentId")} = {P}DepartmentId AND {InList("UnitId", "UnitIds", "u")} AND u.{Col("RemovedOn")} IS NULL AND d.{Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} AND d.{Col("Status")} IN (0, 1, 2, 3) " + + $"AND (d.{Col("StartOn")} IS NULL OR d.{Col("StartOn")} <= {P}WindowEnd) AND (d.{Col("EndOn")} IS NULL OR d.{Col("EndOn")} >= {P}WindowStart) AND d.{Col("DeploymentId")} <> {P}Excluding", + new { DepartmentId = departmentId, UnitIds = InListValue(unitIds), WindowStart = DatabaseTimestamp(windowStart), WindowEnd = DatabaseTimestamp(windowEnd), Excluding = excludingDeploymentId ?? string.Empty }); + } + + public class DeploymentPersonnelRepository : RmsRepositoryBase, IDeploymentPersonnelRepository + { + public DeploymentPersonnelRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentPersonnel")} WHERE {Col("DeploymentId")} = {P}Id ORDER BY {Col("AddedOn")}", new { Id = deploymentId }); + + public Task> GetActiveAssignmentsForUsersAsync(int departmentId, IEnumerable userIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId) => + QueryAsync( + $"SELECT p.* FROM {Tbl("DeploymentPersonnel")} p JOIN {Tbl("Deployments")} d ON d.{Col("DeploymentId")} = p.{Col("DeploymentId")} " + + $"WHERE p.{Col("DepartmentId")} = {P}DepartmentId AND {InList("UserId", "UserIds", "p")} AND p.{Col("RemovedOn")} IS NULL AND d.{Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} AND d.{Col("Status")} IN (0, 1, 2, 3) " + + $"AND (d.{Col("StartOn")} IS NULL OR d.{Col("StartOn")} <= {P}WindowEnd) AND (d.{Col("EndOn")} IS NULL OR d.{Col("EndOn")} >= {P}WindowStart) AND d.{Col("DeploymentId")} <> {P}Excluding", + new { DepartmentId = departmentId, UserIds = InListValue(userIds), WindowStart = DatabaseTimestamp(windowStart), WindowEnd = DatabaseTimestamp(windowEnd), Excluding = excludingDeploymentId ?? string.Empty }); + + public Task> GetForUserAsync(int departmentId, string userId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentPersonnel")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("UserId")} = {P}UserId ORDER BY {Col("AddedOn")} DESC", new { DepartmentId = departmentId, UserId = userId }); + } + + public class DeploymentEquipmentRepository : RmsRepositoryBase, IDeploymentEquipmentRepository + { + public DeploymentEquipmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentEquipment")} WHERE {Col("DeploymentId")} = {P}Id ORDER BY {Col("AddedOn")}", new { Id = deploymentId }); + } + + public class DeploymentTimeReportRepository : RmsRepositoryBase, IDeploymentTimeReportRepository + { + public DeploymentTimeReportRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + + public Task GetByIdForDepartmentAsync(string deploymentTimeReportId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DeploymentTimeReports")} WHERE {Col("DeploymentTimeReportId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = deploymentTimeReportId, DepartmentId = departmentId }); + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentTimeReports")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("IsDeleted")} = {False} ORDER BY {Col("ReportDate")}, {Col("ReportNumber")}", new { Id = deploymentId }); + + public Task GetByDeploymentAndDateAsync(string deploymentId, DateTime reportDate) => + QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("DeploymentTimeReports")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("ReportDate")} = {P}ReportDate AND {Col("IsDeleted")} = {False} AND {Col("Status")} <> {P}Void ORDER BY {Col("ReportNumber")}", + new { Id = deploymentId, ReportDate = DatabaseTimestamp(reportDate.Date), Void = (int)DeploymentTimeReportStatuses.Void }); + + public Task> GetUnbilledApprovedAsync(int departmentId, string deploymentId = null) => + QueryAsync( + $"SELECT * FROM {Tbl("DeploymentTimeReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {Col("Status")} = {P}Approved AND {Col("InvoiceId")} IS NULL" + + (deploymentId != null ? $" AND {Col("DeploymentId")} = {P}DeploymentId" : string.Empty) + $" ORDER BY {Col("DeploymentId")}, {Col("ReportDate")}", + new { DepartmentId = departmentId, Approved = (int)DeploymentTimeReportStatuses.Approved, DeploymentId = deploymentId ?? string.Empty }); + } + + public class DeploymentTimeEntryRepository : RmsRepositoryBase, IDeploymentTimeEntryRepository + { + public DeploymentTimeEntryRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByReportAsync(string deploymentTimeReportId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentTimeEntries")} WHERE {Col("DeploymentTimeReportId")} = {P}Id ORDER BY {Col("SortOrder")}, {Col("StartTime")}", new { Id = deploymentTimeReportId }); + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentTimeEntries")} WHERE {Col("DeploymentId")} = {P}Id ORDER BY {Col("StartTime")}", new { Id = deploymentId }); + + public Task DeleteByReportAsync(string deploymentTimeReportId, CancellationToken cancellationToken = default) => + ExecuteAsync($"DELETE FROM {Tbl("DeploymentTimeEntries")} WHERE {Col("DeploymentTimeReportId")} = {P}Id", new { Id = deploymentTimeReportId }, cancellationToken); + } + + public class DeploymentExpenseRepository : RmsRepositoryBase, IDeploymentExpenseRepository + { + public DeploymentExpenseRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + + public Task GetByIdForDepartmentAsync(string deploymentExpenseId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DeploymentExpenses")} WHERE {Col("DeploymentExpenseId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = deploymentExpenseId, DepartmentId = departmentId }); + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentExpenses")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("IsDeleted")} = {False} ORDER BY {Col("ExpenseDate")}, {Col("AddedOn")}", new { Id = deploymentId }); + } + + public class DeploymentAttachmentRepository : RmsRepositoryBase, IDeploymentAttachmentRepository + { + private static readonly string[] Meta = { "DeploymentAttachmentId", "DeploymentId", "DepartmentId", "AttachmentType", "Name", "FileName", "FileType", "FileSize", "IsDeleted", "AddedOn", "AddedByUserId", "IsProtected", "ProtectedCatalogVersion" }; + + public DeploymentAttachmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByDeploymentAsync(string deploymentId) => + QueryAsync($"SELECT {Cols(Meta)} FROM {Tbl("DeploymentAttachments")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("AddedOn")} DESC", new { Id = deploymentId }); + + public Task GetByIdWithDataAsync(int deploymentAttachmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DeploymentAttachments")} WHERE {Col("DeploymentAttachmentId")} = {P}Id", new { Id = deploymentAttachmentId }); + } + + public class TimeReportNumberSequenceRepository : RmsRepositoryBase, ITimeReportNumberSequenceRepository + { + public TimeReportNumberSequenceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default) + { + // The invoice-sequence precedent: one atomic statement that inserts the row on first use (handing out 1) or advances it. + string sql; + if (IsPostgres) + sql = $"INSERT INTO {Tbl("TimeReportNumberSequences")} ({Col("DepartmentId")}, {Col("NextReportNumber")}) VALUES ({P}DepartmentId, 2) " + + $"ON CONFLICT ({Col("DepartmentId")}) DO UPDATE SET {Col("NextReportNumber")} = {Tbl("TimeReportNumberSequences")}.{Col("NextReportNumber")} + 1 " + + $"RETURNING {Col("NextReportNumber")} - 1"; + else + sql = $"MERGE {Tbl("TimeReportNumberSequences")} WITH (HOLDLOCK) AS t USING (SELECT {P}DepartmentId AS DepartmentId) AS s ON t.[DepartmentId] = s.DepartmentId " + + "WHEN MATCHED THEN UPDATE SET [NextReportNumber] = t.[NextReportNumber] + 1 " + + "WHEN NOT MATCHED THEN INSERT ([DepartmentId], [NextReportNumber]) VALUES (s.DepartmentId, 2) " + + "OUTPUT inserted.[NextReportNumber] - 1;"; + return ScalarAsync(sql, new { DepartmentId = departmentId }, cancellationToken); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs index 287adc302..df136da64 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs @@ -370,7 +370,7 @@ await ExecuteAsync( $"INSERT INTO {Tbl("DepartmentBillingIdentities")} ({string.Join(", ", all.Select(Col))}) VALUES ({string.Join(", ", all.Select(c => P + c))})", identity, cancellationToken); } - catch (Exception ex) when (SearchProjectionsRepository.IsUniqueViolation(ex)) + catch (Exception ex) when (IsUniqueViolation(ex)) { // Two first saves for the department raced past the update; the loser applies its values over the winner's row. await ExecuteAsync( diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index cfca3fdec..cc335a881 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -32,6 +32,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase C (M0218): the free deployment core (deployments, roster, time reports, expenses, attachments). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().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 4fac645a1..1171b574b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -246,6 +246,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase C (M0218): the free deployment core (deployments, roster, time reports, expenses, attachments). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Indoor Maps Repositories diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 12d0e3e6e..ebd1720f0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -32,6 +32,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase C (M0218): the free deployment core (deployments, roster, time reports, expenses, attachments). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().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 c7447a5a7..f4f146f45 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -32,6 +32,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan Phase C (M0218): the free deployment core (deployments, roster, time reports, expenses, attachments). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/OnlinePaymentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/OnlinePaymentRepositories.cs index 2a6759118..22ffd29ff 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/OnlinePaymentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/OnlinePaymentRepositories.cs @@ -66,7 +66,8 @@ public Task> GetStaleVerifiedAsync(Date /// Hosted payment requests (plan B2.2). public class InvoicePaymentRequestRepository : RmsRepositoryBase, IInvoicePaymentRequestRepository { - private const string OpenStatuses = "(0, 1, 2)"; + /// The non-terminal request states; mirrors the UX_InvoicePaymentRequests_Open filter in M0212. + private static readonly string OpenStatuses = $"({(int)PaymentRequestStatuses.Created}, {(int)PaymentRequestStatuses.Opened}, {(int)PaymentRequestStatuses.Processing})"; public InvoicePaymentRequestRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -152,33 +153,33 @@ public PaymentProviderEventRepository(IConnectionProvider connectionProvider, Sq public Task GetByExternalEventIdAsync(int provider, string externalEventId) { return QueryFirstOrDefaultAsync( - $"SELECT * FROM {Tbl("PaymentProviderEvents")} WHERE {Col("Provider")} = {P}Provider AND {Col("ExternalEventId")} = {P}EventId", + $"SELECT * FROM {Tbl("PaymentConnectEvents")} WHERE {Col("Provider")} = {P}Provider AND {Col("ExternalEventId")} = {P}EventId", new { Provider = provider, EventId = externalEventId }); } public Task GetNewestReceivedOnAsync() { - return ScalarAsync($"SELECT MAX({Col("ReceivedOn")}) FROM {Tbl("PaymentProviderEvents")}", new { }); + return ScalarAsync($"SELECT MAX({Col("ReceivedOn")}) FROM {Tbl("PaymentConnectEvents")}", new { }); } public Task GetNewestAppliedOnAsync() { return ScalarAsync( - $"SELECT MAX({Col("ProcessedOn")}) FROM {Tbl("PaymentProviderEvents")} WHERE {Col("Outcome")} = {P}Applied", + $"SELECT MAX({Col("ProcessedOn")}) FROM {Tbl("PaymentConnectEvents")} WHERE {Col("Outcome")} = {P}Applied", new { Applied = (int)PaymentEventOutcomes.Applied }); } public Task CountByOutcomeSinceAsync(int outcome, DateTime sinceUtc) { return ScalarAsync( - $"SELECT COUNT(1) FROM {Tbl("PaymentProviderEvents")} WHERE {Col("Outcome")} = {P}Outcome AND {Col("ReceivedOn")} >= {P}Since", + $"SELECT COUNT(1) FROM {Tbl("PaymentConnectEvents")} WHERE {Col("Outcome")} = {P}Outcome AND {Col("ReceivedOn")} >= {P}Since", new { Outcome = outcome, Since = DatabaseTimestamp(sinceUtc) }); } public Task PurgeReceivedBeforeAsync(DateTime receivedBeforeUtc, CancellationToken cancellationToken = default) { return ExecuteAsync( - $"DELETE FROM {Tbl("PaymentProviderEvents")} WHERE {Col("ReceivedOn")} < {P}Before", + $"DELETE FROM {Tbl("PaymentConnectEvents")} WHERE {Col("ReceivedOn")} < {P}Before", new { Before = DatabaseTimestamp(receivedBeforeUtc) }, cancellationToken); } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 889f68e47..d828eccb9 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -106,6 +106,16 @@ protected RmsRepositoryBase(IConnectionProvider connectionProvider, SqlConfigura protected static bool IsPostgres => DataConfig.DatabaseType == DatabaseTypes.Postgres; + /// PostgreSQL 23505 or SQL Server 2601/2627: the one insert failure an insert-then-update race is allowed to absorb. + protected internal static bool IsUniqueViolation(Exception ex) + { + if (ex is Npgsql.PostgresException postgres) + return postgres.SqlState == "23505"; + if (ex is Microsoft.Data.SqlClient.SqlException sql) + return sql.Number == 2601 || sql.Number == 2627; + return false; + } + // Records store UTC clock values in PostgreSQL timestamp columns without a time zone. // DateTime2 preserves precision on both providers, but Npgsql requires an Unspecified kind. protected static DateTime DatabaseTimestamp(DateTime value) diff --git a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs index 071325cb4..e33879fa7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/SearchRepositories.cs @@ -19,16 +19,6 @@ public class SearchProjectionsRepository : RmsRepositoryBase, public SearchProjectionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } - /// PostgreSQL 23505 or SQL Server 2601/2627: the only failures the insert-then-update race is allowed to absorb. - internal static bool IsUniqueViolation(Exception ex) - { - if (ex is Npgsql.PostgresException postgres) - return postgres.SqlState == "23505"; - if (ex is Microsoft.Data.SqlClient.SqlException sql) - return sql.Number == 2601 || sql.Number == 2627; - return false; - } - public Task GetAsync(int departmentId, string entityType, string entityId) { return QueryFirstOrDefaultAsync( @@ -201,7 +191,7 @@ await ExecuteAsync( new { IndexName = indexName, Owner = owner, Until = until, Now = now }, cancellationToken); return true; } - catch (Exception ex) when (SearchProjectionsRepository.IsUniqueViolation(ex)) + catch (Exception ex) when (IsUniqueViolation(ex)) { // Lost the insert race; the other writer holds it. Any other database failure propagates so the // publish is reported as failed rather than as "lease held elsewhere". diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 1c00ca906..52230fbc7 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -122,5 +122,18 @@ "CertificationRoleRemoved": 90, "CertificationStatusChanged": 91, "UnitCertificationExpiring": 92, - "UnitCertificationExpired": 93 + "UnitCertificationExpired": 93, + "BidCreated": 74, + "BidSent": 75, + "BidAccepted": 76, + "BidDeclined": 77, + "BidExpired": 78, + "ContractStatusChanged": 79, + "ContractExpiring": 80, + "DeploymentCreated": 81, + "DeploymentStatusChanged": 82, + "DeploymentRosterChanged": 83, + "DeploymentExpenseAdded": 84, + "TimeReportSubmitted": 85, + "TimeReportApproved": 86 } diff --git a/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs index 1320ba68d..fadda6cd7 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs @@ -67,6 +67,19 @@ public async Task Department_filter_is_always_injected() hostile.Hits.Should().OnlyContain(h => h.SourceId != "rec-9", "request text can never widen the department clause"); } + [Test] + public async Task Policy_generation_filters_stale_narrative_matches_and_counts() + { + var existing = await _search.SearchAsync(1, new RecordsSearchRequest { Text = "hydrant" }); + existing.Hits.Should().ContainSingle(h => h.DepartmentId == 1); + var stale = await _search.SearchAsync(1, new RecordsSearchRequest { Text = "hydrant", Generation = "changed-policy" }); + stale.Hits.Should().BeEmpty(); + stale.Total.Should().Be(0); + var withdrawn = await _search.SearchAsync(1, new RecordsSearchRequest { Text = "hydrant", IncludeNarrative = false }); + withdrawn.Hits.Should().BeEmpty("an unchanged generation cannot bypass the current narrative policy"); + withdrawn.Total.Should().Be(0); + } + [Test] public async Task Group_scope_resolves_inside_the_query_with_the_always_visible_cases() { diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 5efc9e418..c38d21c44 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -49,6 +49,12 @@ public void Permission_types_50_to_67_are_the_registry_names() ((int)PermissionTypes.ManageCertificationSetup).Should().Be(44); foreach (var value in Enumerable.Range(45, 2)) Enum.IsDefined(typeof(PermissionTypes), value).Should().BeFalse($"PermissionTypes {value} is reserved for another plan"); + // Contacts/Billing Phase C authored 116-119 and 79 on 2026-09-19 (registry). + ((int)PermissionTypes.ManageBids).Should().Be(116); + ((int)PermissionTypes.ManageContracts).Should().Be(117); + ((int)PermissionTypes.ManageDeployments).Should().Be(118); + ((int)PermissionTypes.ApproveTimeReports).Should().Be(119); + ((int)PermissionTypes.ManageMutualAidReimbursement).Should().Be(79); } [Test] @@ -126,7 +132,12 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() // Certifications Phase D authored 87-93 on 2026-09-19 (registry). ((int)WorkflowTriggerEventType.CertificationAdded).Should().Be(87); ((int)WorkflowTriggerEventType.UnitCertificationExpired).Should().Be(93); - foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(52, 6)).Except(Enumerable.Range(58, 16)).Except(Enumerable.Range(87, 7)).Except(new[] { 94, 95 })) + // Contacts/Billing Phase C authored 74-86 on 2026-09-19 (registry). + ((int)WorkflowTriggerEventType.BidCreated).Should().Be(74); + ((int)WorkflowTriggerEventType.ContractExpiring).Should().Be(80); + ((int)WorkflowTriggerEventType.DeploymentCreated).Should().Be(81); + ((int)WorkflowTriggerEventType.TimeReportApproved).Should().Be(86); + foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(52, 6)).Except(Enumerable.Range(58, 16)).Except(Enumerable.Range(74, 13)).Except(Enumerable.Range(87, 7)).Except(new[] { 94, 95 })) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for another plan"); } diff --git a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs index 9453f01cd..3e1c9e7ed 100644 --- a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs +++ b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs @@ -60,6 +60,32 @@ public async Task Department_filter_is_always_injected() two.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "11" }); } + [Test] + public async Task Policy_generation_is_filtered_before_matching_or_counting() + { + var current = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "fire", ViewerUserId = "u1", Generation = "1.25.3" }); + current.Hits.Should().ContainSingle(h => h.DepartmentId == 1 && h.Generation == "1.25.3" && h.RowVersion == 1); + var changed = await _search.SearchAsync(1, new GlobalSearchQuery { Text = "fire", ViewerUserId = "u1", Generation = "2.25.4" }); + changed.Hits.Should().BeEmpty(); + changed.Total.Should().Be(0); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Every_entity_family_is_isolated_even_for_the_same_user_in_two_departments(bool prefix) + { + foreach (var department in new[] { 1, 2 }) + await _indexer.IndexAsync(SearchEntityTypes.Indexed.Select(type => + Projection(department, type, "isolation-" + type, "Canary isolation", owner: "shared-member")), "2.0.0"); + await _indexer.CommitAsync(); + foreach (var department in new[] { 1, 2 }) + { + var result = await _search.SearchAsync(department, new GlobalSearchQuery { Text = "canary", Prefix = prefix, ViewerUserId = "shared-member", IncludeAdminOnly = true }); + result.Hits.Should().HaveCount(SearchEntityTypes.Indexed.Count).And.OnlyContain(h => h.DepartmentId == department); + result.Total.Should().Be(SearchEntityTypes.Indexed.Count); + } + } + [Test] public async Task An_unbounded_skip_yields_an_empty_page_instead_of_an_overflowed_window() { diff --git a/Tests/Resgrid.Tests/Search/SearchControllerSecurityTests.cs b/Tests/Resgrid.Tests/Search/SearchControllerSecurityTests.cs new file mode 100644 index 000000000..e251e9538 --- /dev/null +++ b/Tests/Resgrid.Tests/Search/SearchControllerSecurityTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Search; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Search +{ + [TestFixture, NonParallelizable] + public class SearchControllerSecurityTests + { + private IHttpContextAccessor _previous; + private SearchController _controller; + private Mock _search; + private Mock _global; + private Mock _records; + private Mock _authorization; + private Mock _maintenance; + private Mock _states; + + [SetUp] + public void SetUp() + { + _previous = ClaimsAuthorizationHelper._httpContextAccessor; + var http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, "viewer"), new Claim(ClaimTypes.PrimaryGroupSid, "7"), + new Claim("Department", "View"), new Claim("Department", "Update"), new Claim("Unit", "View") + }, "Test")) + }; + http.Request.QueryString = new QueryString("?departmentId=8&userId=foreign&isDepartmentAdmin=true"); + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + _search = new Mock(); + _search.Setup(s => s.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new UnifiedSearchResult()); + _global = new Mock(); + _records = new Mock(); + _authorization = new Mock(); + _maintenance = new Mock(); + _states = new Mock(); + var flags = new Mock(); + flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.SearchUnified, 7, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + var settings = new Mock(); + settings.Setup(s => s.GetDepartmentModuleSettingsAsync(7, true)).ReturnsAsync(new DepartmentModuleSettings()); + _controller = new SearchController(_search.Object, _global.Object, _records.Object, _maintenance.Object, _states.Object, settings.Object, flags.Object, _authorization.Object) + { + ControllerContext = new ControllerContext { HttpContext = http } + }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previous; + + [TestCase(false)] + [TestCase(true)] + public async Task Search_identity_comes_only_from_authenticated_claims(bool prefix) + { + if (prefix) await _controller.Typeahead("engine"); + else await _controller.Search("engine"); + _search.Verify(s => s.SearchAsync(It.Is(r => r.Prefix == prefix), + It.Is(p => p.DepartmentId == 7 && p.UserId == "viewer" && p.HasResourceClaim("Unit", "View")), It.IsAny()), Times.Once); + } + + [Test] + public async Task Revoked_admin_cannot_read_health_or_request_rebuild() + { + (await _controller.Health()).Result.Should().BeOfType(); + (await _controller.Rebuild(default)).Result.Should().BeOfType(); + _global.Verify(g => g.GetHealthAsync(), Times.Never); + _maintenance.Verify(m => m.RequestRebuildAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Health_discloses_only_department_counts_and_no_shared_revision() + { + _authorization.Setup(a => a.IsDepartmentAdminAsync("viewer", 7)).ReturnsAsync(true); + _global.Setup(g => g.GetHealthAsync()).ReturnsAsync(new SearchIndexHealth { DocumentCount = 90001, LastSyncedRevision = "other-department-write", LastSyncedOnUtc = DateTime.UtcNow }); + _records.Setup(r => r.GetHealthAsync()).ReturnsAsync(new RecordsSearchHealth { DocumentCount = 80001 }); + _states.Setup(s => s.GetAsync(SearchIndexNames.Global, 7)).ReturnsAsync(new SearchIndexState { DepartmentId = 7, DocumentCount = 5 }); + var response = (SearchHealthResult)((OkObjectResult)(await _controller.Health()).Result).Value; + response.Data.DepartmentDocumentCount.Should().Be(5); + response.Data.GlobalDocumentCount.Should().BeNull(); + response.Data.RecordsDocumentCount.Should().BeNull(); + response.Data.LastSyncedRevision.Should().BeNull(); + response.Data.LastSyncedOnUtc.Should().BeNull(); + _states.Setup(s => s.GetAsync(SearchIndexNames.Global, 7)).ReturnsAsync(new SearchIndexState { DepartmentId = 8, DocumentCount = 991 }); + response = (SearchHealthResult)((OkObjectResult)(await _controller.Health()).Result).Value; + response.Data.DepartmentDocumentCount.Should().Be(0); + } + + [Test] + public async Task Anonymous_health_does_not_disclose_shared_index_counts() + { + var enabled = Resgrid.Config.SearchConfig.Enabled; + try + { + Resgrid.Config.SearchConfig.Enabled = true; + _global.Setup(g => g.GetHealthAsync()).ReturnsAsync(new SearchIndexHealth { DocumentCount = 90001, Online = true }); + var health = new Mock(); + health.Setup(h => h.GetDatabaseTimestamp()).ReturnsAsync("now"); + var controller = new HealthController(health.Object, _global.Object, Mock.Of()); + var result = await controller.GetCurrent(); + result.Status.Should().Be("success"); + result.Data.SearchOnline.Should().BeTrue(); + result.Data.SearchIndexDocCount.Should().BeNull(); + } + finally { Resgrid.Config.SearchConfig.Enabled = enabled; } + } + + [Test] + public void Endpoints_require_authentication_and_forbid_response_caching() + { + foreach (var type in new[] { typeof(SearchController), typeof(Resgrid.Web.Areas.User.Controllers.SearchController) }) + { + foreach (var action in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + type.GetCustomAttributes(true).Concat(action.GetCustomAttributes()) + .Should().NotBeEmpty("each search action must require authorization"); + type.GetCustomAttributes(true).Should().BeEmpty(); + var cache = type.GetCustomAttribute(); + cache.NoStore.Should().BeTrue(); + cache.Location.Should().Be(ResponseCacheLocation.None); + } + foreach (var name in new[] { nameof(SearchController.Search), nameof(SearchController.Typeahead) }) + typeof(SearchController).GetMethod(name).GetCustomAttributes() + .Should().Contain(a => a.Policy == ResgridResources.Department_View); + } + + [Test] + public void Missing_or_failed_module_checks_deny_module_access() + { + new SearchPrincipal().ModuleEnabled(SystemActionModules.Documents).Should().BeFalse(); + new SearchPrincipal { IsModuleEnabled = _ => throw new InvalidOperationException() }.ModuleEnabled(SystemActionModules.Documents).Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchSecurityTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchSecurityTests.cs new file mode 100644 index 000000000..2206775ee --- /dev/null +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchSecurityTests.cs @@ -0,0 +1,294 @@ +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.Search; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Search +{ + public partial class UnifiedSearchServiceTests + { + private Task Search(string resource = "Call", bool prefix = false) => + _service.SearchAsync(new UnifiedSearchRequest { Text = "secret", Prefix = prefix }, Principal(resource + ":View")); + + [TestCase("Call", "Call")] + [TestCase("Unit", "Unit")] + [TestCase("Personnel", "Personnel")] + [TestCase("Message", "Messages")] + [TestCase("Contact", "Contacts")] + [TestCase("Document", "Documents")] + [TestCase("Note", "Notes")] + public async Task Foreign_index_hits_are_rejected_for_every_family(string type, string resource) + { + var hit = Hit(type, "9"); + hit.DepartmentId = 8; + Answer(hit); + var result = await Search(resource); + result.Hits.Should().BeEmpty(); + result.Total.Should().BeNull(); + } + + [TestCase("Call", "Call")] + [TestCase("Unit", "Unit")] + [TestCase("Personnel", "Personnel")] + [TestCase("Message", "Messages")] + [TestCase("Contact", "Contacts")] + [TestCase("Document", "Documents")] + [TestCase("Note", "Notes")] + public async Task Live_ownership_is_rechecked_even_when_the_index_claims_our_department(string type, string resource) + { + Answer(Hit(type, "9")); + SeedEntity(type, 8); + var result = await Search(resource); + result.Hits.Should().BeEmpty(); + result.Total.Should().BeNull(); + SeedEntity(type, 7); + (await Search(resource)).Hits.Should().ContainSingle("the same valid entity is visible in the caller's department"); + } + + private void SeedEntity(string type, int departmentId) + { + switch (type) + { + case "Call": _calls.Setup(c => c.GetCallByIdAsync(9, true)).ReturnsAsync(new Call { CallId = 9, DepartmentId = departmentId }); break; + case "Unit": _units.Setup(u => u.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { UnitId = 9, DepartmentId = departmentId }); break; + case "Personnel": _departments.Setup(d => d.GetDepartmentMemberAsync("9", 7, true)).ReturnsAsync(new DepartmentMember { UserId = "9", DepartmentId = departmentId }); break; + case "Message": _messages.Setup(m => m.GetMessageByIdAsync(9)).ReturnsAsync(new Message { MessageId = 9, DepartmentId = departmentId, SendingUserId = "u1" }); break; + case "Contact": _contacts.Setup(c => c.GetContactByIdAsync("9")).ReturnsAsync(new Contact { ContactId = "9", DepartmentId = departmentId }); break; + case "Document": _documents.Setup(d => d.GetDocumentByIdAsync(9)).ReturnsAsync(new Document { DocumentId = 9, DepartmentId = departmentId }); break; + case "Note": _notes.Setup(n => n.GetNoteByIdAsync(9)).ReturnsAsync(new Note { NoteId = 9, DepartmentId = departmentId }); break; + } + } + + [TestCase("missing")] + [TestCase("deleted")] + [TestCase("disabled")] + [TestCase("foreign")] + public async Task Revoked_or_foreign_members_cannot_search_even_with_admin_claims(string state) + { + _viewer.IsAdmin = true; + if (state == "missing") _viewer = null; + if (state == "deleted") _viewer.IsDeleted = true; + if (state == "disabled") _viewer.IsDisabled = true; + if (state == "foreign") _viewer.DepartmentId = 8; + var principal = Principal("Call:View"); + principal.IsDepartmentAdmin = true; + var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "secret" }, principal); + result.Available.Should().BeFalse(); + result.Hits.Should().BeEmpty(); + result.Actions.Should().BeEmpty(); + _global.Verify(g => g.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Search_and_typeahead_use_live_group_permissions_without_a_matrix(bool prefix) + { + Answer(Hit("Unit", "9"), Hit("Unit", "10"), Hit("Personnel", "9"), Hit("Personnel", "10")); + foreach (var id in new[] { 9, 10 }) + { + _units.Setup(u => u.GetUnitByIdAsync(id)).ReturnsAsync(new Unit { UnitId = id, DepartmentId = 7, StationGroupId = id }); + _departments.Setup(d => d.GetDepartmentMemberAsync(id.ToString(), 7, true)).ReturnsAsync(new DepartmentMember { UserId = id.ToString(), DepartmentId = 7 }); + _groups.Setup(g => g.GetGroupMemberForUserAsync(id.ToString(), 7)).ReturnsAsync(new DepartmentGroupMember { UserId = id.ToString(), DepartmentId = 7, DepartmentGroupId = id }); + } + _groups.Setup(g => g.GetGroupMemberForUserAsync("u1", 7)).ReturnsAsync(new DepartmentGroupMember { UserId = "u1", DepartmentId = 7, DepartmentGroupId = 9 }); + var permission = new Permission { DepartmentId = 7, Action = (int)PermissionActions.Everyone, LockToGroup = true }; + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(7, It.IsAny())).ReturnsAsync(permission); + var request = new UnifiedSearchRequest { Text = "secret", Prefix = prefix }; + var principal = Principal("Unit:View", "Personnel:View"); + var result = await _service.SearchAsync(request, principal); + result.Hits.Should().HaveCount(2).And.OnlyContain(h => h.EntityId == "9"); + result.Total.Should().BeNull(); + _auth.Verify(a => a.CanUserViewUnitViaMatrixAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _auth.Verify(a => a.CanUserViewPersonViaMatrixAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + permission.Action = (int)PermissionActions.DepartmentAdminsOnly; + (await _service.SearchAsync(request, principal)).Hits.Should().BeEmpty("permission revocation takes effect on the next request"); + } + + [TestCase(PermissionActions.DepartmentAdminsOnly, false, false, false)] + [TestCase(PermissionActions.DepartmentAndGroupAdmins, true, false, true)] + [TestCase(PermissionActions.DepartmentAndGroupAdmins, false, true, false)] + [TestCase(PermissionActions.DepartmentAdminsAndSelectRoles, false, true, true)] + [TestCase(PermissionActions.DepartmentAdminsAndSelectRoles, true, false, false)] + [TestCase(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, true, false, true)] + [TestCase(PermissionActions.DepartmentAndGroupAdminsAndSelectRoles, false, true, true)] + [TestCase((PermissionActions)99, true, true, false)] + public async Task Group_admin_and_selected_role_rules_are_enforced(PermissionActions action, bool groupAdmin, bool hasRole, bool allowed) + { + Answer(Hit("Unit", "9")); + _units.Setup(u => u.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { DepartmentId = 7, UnitId = 9, StationGroupId = 4 }); + _groups.Setup(g => g.GetGroupMemberForUserAsync("u1", 7)).ReturnsAsync(new DepartmentGroupMember { DepartmentId = 7, UserId = "u1", DepartmentGroupId = 4, IsAdmin = groupAdmin }); + _roles.Setup(r => r.GetRolesForUserAsync("u1", 7)).ReturnsAsync(new List { new PersonnelRole { DepartmentId = 7, PersonnelRoleId = hasRole ? 12 : 13 } }); + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(7, (int)PermissionTypes.ViewGroupUnits)).ReturnsAsync(new Permission { DepartmentId = 7, Action = (int)action, LockToGroup = true, Data = "12" }); + (await Search("Unit")).Hits.Count.Should().Be(allowed ? 1 : 0); + } + + [TestCase("hidden")] + [TestCase("deleted")] + [TestCase("disabled")] + public async Task Personnel_visibility_is_rechecked_against_current_membership(string state) + { + Answer(Hit("Personnel", "9")); + _departments.Setup(d => d.GetDepartmentMemberAsync("9", 7, true)).ReturnsAsync(new DepartmentMember + { + UserId = "9", DepartmentId = 7, IsHidden = state == "hidden", IsDisabled = state == "disabled", IsDeleted = state == "deleted" + }); + (await Search("Personnel")).Hits.Should().BeEmpty(); + } + + [TestCase("Document", "Documents")] + [TestCase("Note", "Notes")] + public async Task Newly_admin_only_entities_are_hidden_and_revoked_admin_claims_do_not_bypass_checks(string type, string resource) + { + Answer(Hit(type, "9")); + _documents.Setup(d => d.GetDocumentByIdAsync(9)).ReturnsAsync(new Document { DepartmentId = 7, DocumentId = 9, AdminsOnly = true }); + _notes.Setup(n => n.GetNoteByIdAsync(9)).ReturnsAsync(new Note { DepartmentId = 7, NoteId = 9, IsAdminOnly = true }); + var principal = Principal(resource + ":View"); + principal.IsDepartmentAdmin = true; + (await _service.SearchAsync(new UnifiedSearchRequest { Text = "secret" }, principal)).Hits.Should().BeEmpty(); + _viewer.IsAdmin = true; + (await _service.SearchAsync(new UnifiedSearchRequest { Text = "secret" }, principal)).Hits.Should().ContainSingle(); + } + + [Test] + public async Task Removed_message_recipients_are_not_authorized_by_stale_index_participants() + { + Answer(Hit("Message", "9")); + var recipient = new MessageRecipient { UserId = "u1", DepartmentId = 7, IsDeleted = true }; + _messages.Setup(m => m.GetMessageByIdAsync(9)).ReturnsAsync(new Message { DepartmentId = 7, MessageId = 9, MessageRecipients = new List { recipient } }); + (await Search("Messages")).Hits.Should().BeEmpty(); + recipient.IsDeleted = false; + (await Search("Messages")).Hits.Should().ContainSingle(); + } + + [TestCase("department")] + [TestCase("deleted")] + [TestCase("version")] + [TestCase("identity")] + [TestCase("generation")] + [TestCase("missing")] + public async Task Stale_or_mismatched_projections_are_not_returned(string mismatch) + { + var hit = Hit("Call", "1"); + Answer(hit); + if (mismatch == "department") _rows[0].DepartmentId = 8; + if (mismatch == "deleted") _rows[0].DeletedOn = DateTime.UtcNow; + if (mismatch == "version") _rows[0].RowVersion++; + if (mismatch == "identity") _rows[0].EntityId = "2"; + if (mismatch == "generation") hit.Generation = "1.0.0"; + if (mismatch == "missing") _rows.Clear(); + var result = await Search(); + result.Hits.Should().BeEmpty(); + result.Total.Should().BeNull(); + } + + [Test] + public async Task Current_projection_supplies_display_values_instead_of_index_payload() + { + Answer(Hit("Call", "1")); + (await Search()).Hits.Single().Title.Should().Be("Current title"); + } + + [TestCase(DepartmentDataProtectionState.EnrollmentQueued)] + [TestCase(DepartmentDataProtectionState.Enabled)] + [TestCase(DepartmentDataProtectionState.Decrypting)] + public async Task Protected_text_is_suppressed_during_protection_transitions(DepartmentDataProtectionState state) + { + Answer(Hit("Call", "1")); + _rows[0].IncludesProtectedText = true; + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(7, true)).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = 7, State = (int)state }); + (await Search()).Hits.Should().BeEmpty("even when an old index shares the policy epoch"); + _rows[0].IncludesProtectedText = false; + (await Search()).Hits.Should().ContainSingle("safe metadata remains searchable"); + } + + [Test] + public async Task Moving_a_member_or_revoking_a_role_takes_effect_on_the_next_search() + { + Answer(Hit("Unit", "9")); + _units.Setup(u => u.GetUnitByIdAsync(9)).ReturnsAsync(new Unit { DepartmentId = 7, UnitId = 9, StationGroupId = 4 }); + var group = new DepartmentGroupMember { DepartmentId = 7, UserId = "u1", DepartmentGroupId = 4 }; + _groups.Setup(g => g.GetGroupMemberForUserAsync("u1", 7)).ReturnsAsync(group); + var roles = new List { new PersonnelRole { DepartmentId = 7, PersonnelRoleId = 12 } }; + _roles.Setup(r => r.GetRolesForUserAsync("u1", 7)).ReturnsAsync(roles); + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(7, (int)PermissionTypes.ViewGroupUnits)) + .ReturnsAsync(new Permission { DepartmentId = 7, Action = (int)PermissionActions.DepartmentAdminsAndSelectRoles, LockToGroup = true, Data = "12" }); + (await Search("Unit")).Hits.Should().ContainSingle(); + group.DepartmentGroupId = 5; + (await Search("Unit")).Hits.Should().BeEmpty(); + group.DepartmentGroupId = 4; + roles.Clear(); + (await Search("Unit")).Hits.Should().BeEmpty(); + } + + [Test] + public async Task Permission_or_entity_lookup_errors_never_allow_access() + { + Answer(Hit("Unit", "9")); + _units.Setup(u => u.GetUnitByIdAsync(9)).ThrowsAsync(new InvalidOperationException()); + (await Search("Unit")).Hits.Should().BeEmpty(); + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(7, (int)PermissionTypes.ViewGroupUnits)).ThrowsAsync(new InvalidOperationException()); + (await Search("Unit")).Available.Should().BeFalse(); + } + + [Test] + public async Task Both_indexes_receive_the_current_department_policy_generation() + { + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(7, true)).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = 7, CatalogVersion = 25, PolicyEpoch = 41 }); + RecordsAnswer(); + await _service.SearchAsync(new UnifiedSearchRequest { Text = "secret" }, Principal("Call:View", "Record:View")); + _lastQuery.Generation.Should().Be(GlobalSearchGeneration.Compute(25, 41)); + _recordsSearch.Verify(r => r.SearchAsync(7, It.Is(q => q.Generation == RecordsSearchGeneration.Compute(25, 41)), It.IsAny()), Times.Once); + } + + [Test] + public async Task Unknown_policy_or_module_state_fails_closed() + { + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(7, true)).ThrowsAsync(new InvalidOperationException()); + (await Search()).Available.Should().BeFalse(); + _protection.Setup(p => p.GetPolicyByDepartmentIdAsync(7, true)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(7, true)).ThrowsAsync(new InvalidOperationException()); + (await Search()).Available.Should().BeFalse(); + } + + [Test] + public async Task Current_disabled_module_overrides_old_principal_settings() + { + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(7, true)).ReturnsAsync(new DepartmentModuleSettings { DocumentsDisabled = true }); + var result = await Search("Documents"); + result.Hits.Should().BeEmpty(); + _global.Verify(g => g.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Unchecked_candidates_do_not_leak_through_totals_or_truncation() + { + _global.Setup(g => g.SearchAsync(7, It.IsAny(), It.IsAny())) + .ReturnsAsync(new GlobalSearchResult { Hits = new List { Hit("Call", "1") }, Total = 900, Truncated = true }); + var result = await Search(); + result.Hits.Should().ContainSingle(); + result.Total.Should().BeNull(); + result.Truncated.Should().BeFalse(); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Foreign_record_hits_and_projections_are_rejected(bool foreignProjection) + { + var hit = new RecordsSearchHit { SourceType = ((int)RmsSearchSourceType.Record).ToString(), SourceId = "r1" }; + RecordsAnswer(hit); + if (foreignProjection) + _records.Setup(r => r.GetProjectionsByIdsAsync(7, It.IsAny>())).ReturnsAsync(new List { new RmsRecordSearchProjection { DepartmentId = 8, RmsRecordSearchProjectionId = "r1", SourceId = "r1", SourceType = (int)RmsSearchSourceType.Record } }); + else hit.DepartmentId = 8; + var result = await Search("Record"); + result.Hits.Should().BeEmpty(); + result.Total.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs index 1777baa28..3785a8273 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchServiceTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -15,7 +16,7 @@ namespace Resgrid.Tests.Search { /// The unified orchestrator: flag gate, claim-based family filter, per-hit authorization with total suppression, lazy state-row activation. [TestFixture] - public class UnifiedSearchServiceTests + public partial class UnifiedSearchServiceTests { private Mock _global; private Mock _actions; @@ -29,11 +30,27 @@ public class UnifiedSearchServiceTests private UnifiedSearchService _service; private bool _flagOn; private GlobalSearchQuery _lastQuery; + private Mock _departments; + private Mock _groups; + private Mock _roles; + private Mock _calls; + private Mock _units; + private Mock _messages; + private Mock _documents; + private Mock _notes; + private Mock _contacts; + private Mock _protection; + private Mock _settings; + private Mock _projections; + private Mock _permissions; + private DepartmentMember _viewer; + private List _rows; [SetUp] public void SetUp() { _flagOn = true; + _lastQuery = null; _global = new Mock(); _global.SetupGet(g => g.IsAvailable).Returns(true); _global.Setup(g => g.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -43,8 +60,8 @@ public void SetUp() Total = 2, Hits = new List { - new GlobalSearchHit { EntityType = SearchEntityTypes.Call, EntityId = "1", Title = "One", Score = 2f }, - new GlobalSearchHit { EntityType = SearchEntityTypes.Call, EntityId = "2", Title = "Two", Score = 1f } + Hit(SearchEntityTypes.Call, "1"), + Hit(SearchEntityTypes.Call, "2") } }); _actions = new Mock(); @@ -60,14 +77,63 @@ public void SetUp() _recordsAuth = new Mock(); _records = new Mock(); _cutover = new Mock(); + _departments = new Mock(); + _departments.Setup(d => d.GetDepartmentByIdAsync(7, true)).ReturnsAsync(new Department { DepartmentId = 7, ManagingUserId = "owner" }); + _viewer = new DepartmentMember { DepartmentId = 7, UserId = "u1" }; + _departments.Setup(d => d.GetDepartmentMemberAsync("u1", 7, true)).ReturnsAsync(() => _viewer); + _groups = new Mock(); + _roles = new Mock(); + _roles.Setup(r => r.GetRolesForUserAsync("u1", 7)).ReturnsAsync(new List()); + _calls = new Mock(); + _calls.Setup(c => c.GetCallByIdAsync(It.IsAny(), true)).ReturnsAsync((int id, bool _) => new Call { CallId = id, DepartmentId = 7 }); + _units = new Mock(); + _messages = new Mock(); + _documents = new Mock(); + _notes = new Mock(); + _contacts = new Mock(); + _protection = new Mock(); + _settings = new Mock(); + _settings.Setup(s => s.GetDepartmentModuleSettingsAsync(7, true)).ReturnsAsync(new DepartmentModuleSettings()); + _rows = new List { Projection(SearchEntityTypes.Call, "1"), Projection(SearchEntityTypes.Call, "2") }; + _projections = new Mock(); + _projections.Setup(p => p.GetByIdsAsync(7, It.IsAny>())).ReturnsAsync(() => _rows); + _permissions = new Mock(); + var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of()); _service = new UnifiedSearchService(_global.Object, _actions.Object, _flags.Object, _auth.Object, _states.Object, _recordsSearch.Object, - _recordsAuth.Object, _records.Object, _cutover.Object); + _recordsAuth.Object, _records.Object, _cutover.Object, _departments.Object, permissions, _groups.Object, + _roles.Object, _calls.Object, _units.Object, _messages.Object, _documents.Object, _notes.Object, + _contacts.Object, _protection.Object, _settings.Object, _projections.Object); + } + + private static GlobalSearchHit Hit(string type, string id) => new GlobalSearchHit + { + DepartmentId = 7, Generation = GlobalSearchGeneration.Compute(0, 0), RowVersion = 1, + ProjectionId = type + id, EntityType = type, EntityId = id, Title = "Indexed title" + }; + + private static SearchProjection Projection(string type, string id) => new SearchProjection + { + DepartmentId = 7, RowVersion = 1, SearchProjectionId = type + id, EntityType = type, + EntityId = id, Title = "Current title", OccurredOn = DateTime.UtcNow + }; + + private void Answer(params GlobalSearchHit[] hits) + { + _rows = hits.Select(h => Projection(h.EntityType, h.EntityId)).ToList(); + _global.Setup(g => g.SearchAsync(7, It.IsAny(), It.IsAny())) + .Callback((int _, GlobalSearchQuery q, CancellationToken __) => _lastQuery = q) + .ReturnsAsync(new GlobalSearchResult { Total = hits.Length, Hits = hits.ToList() }); } /// An activated Records module whose index answers with the given hits; every record is viewable and loadable. private void RecordsAnswer(params RecordsSearchHit[] hits) { + foreach (var hit in hits) + { + hit.DepartmentId = 7; + hit.Generation = RecordsSearchGeneration.Compute(0, 0); + } var recordSource = ((int)RmsSearchSourceType.Record).ToString(); _recordsSearch.SetupGet(r => r.IsAvailable).Returns(true); _recordsSearch.Setup(r => r.SearchAsync(7, It.IsAny(), It.IsAny())) @@ -190,7 +256,7 @@ public async Task A_records_only_page_past_the_first_twenty_still_returns_record asked.Take.Should().BeGreaterThanOrEqualTo(24, "the federation must cover skip + take"); page.Hits.Select(h => h.EntityId).Should().Equal("r23", "r24"); - page.Total.Should().Be(30); + page.Total.Should().BeNull("records outside the candidate window have not been authorized"); } } } diff --git a/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs index 1d27ac0e3..04feb2152 100644 --- a/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs @@ -111,6 +111,18 @@ public void SetUp() var settings = new Mock(); settings.Setup(r => r.GetAsync(Dept)).ReturnsAsync(() => _settings); settings.Setup(r => r.SaveAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DepartmentCertificationSettings s, CancellationToken _) => _settings = s); + // The claim semantics of the repository's conditional UPDATE: the marker only moves forward, one winner per local day. + settings.Setup(r => r.TryClaimSweepAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int _, DateTime day, CancellationToken __) => + { + _settings ??= new DepartmentCertificationSettings { DepartmentId = Dept }; + if (_settings.LastSweepLocalDate.HasValue && _settings.LastSweepLocalDate.Value >= day.Date) return false; + _settings.LastSweepLocalDate = day.Date; return true; + }); + settings.Setup(r => r.ReleaseSweepClaimAsync(Dept, It.IsAny(), It.IsAny())).Returns((int _, DateTime day, CancellationToken __) => + { + if (_settings?.LastSweepLocalDate == day.Date) _settings.LastSweepLocalDate = null; + return Task.CompletedTask; + }); var credits = new Mock(); credits.Setup(r => r.GetByCertificationIdAsync(It.IsAny())).ReturnsAsync((int id) => _credits.Where(c => c.PersonnelCertificationId == id).ToList()); @@ -247,6 +259,24 @@ public async Task Verify_status_and_renew_follow_the_lifecycle_and_publish_once_ Audits.Last().Type.Should().Be(AuditLogTypes.CertificationRemoved); } + [Test] + public async Task Editing_an_expired_record_with_a_future_expiry_reactivates_it_the_way_renew_does() + { + // A type that requires sign-off goes back through PendingVerification; a plain type returns to Active. + var expiredSkills = AddRecord(3, Today.AddDays(-2), status: PersonnelCertificationStatuses.Expired); + var edited = await _service.SaveCertificationAsync(new PersonnelCertification { PersonnelCertificationId = expiredSkills.PersonnelCertificationId, DepartmentId = Dept, UserId = "u1", Name = "Skills", DepartmentCertificationTypeId = 3, ExpiresOn = Today.AddYears(1) }); + edited.Status.Should().Be((int)PersonnelCertificationStatuses.PendingVerification, "the plain edit form must not bypass the verifier"); + edited.StatusReason.Should().BeNull(); + + var expiredPlain = AddRecord(1, Today.AddDays(-2), status: PersonnelCertificationStatuses.Expired); + var reactivated = await _service.SaveCertificationAsync(new PersonnelCertification { PersonnelCertificationId = expiredPlain.PersonnelCertificationId, DepartmentId = Dept, UserId = "u1", Name = "Card", DepartmentCertificationTypeId = 1, ExpiresOn = Today.AddYears(1) }); + reactivated.Status.Should().Be((int)PersonnelCertificationStatuses.Active); + + var stillExpired = AddRecord(1, Today.AddDays(-2), status: PersonnelCertificationStatuses.Expired); + var unchanged = await _service.SaveCertificationAsync(new PersonnelCertification { PersonnelCertificationId = stillExpired.PersonnelCertificationId, DepartmentId = Dept, UserId = "u1", Name = "Card", DepartmentCertificationTypeId = 1, ExpiresOn = Today.AddDays(-2) }); + unchanged.Status.Should().Be((int)PersonnelCertificationStatuses.Expired, "an expiry still in the past keeps the record expired"); + } + [Test] public async Task Credits_attach_to_person_records_only_and_roll_up() { @@ -366,10 +396,24 @@ public async Task Sweep_expires_notifies_on_lead_days_handles_units_and_is_idemp _published.Clear(); _notified.Clear(); var again = await _service.RunExpirySweepAsync(Dept, Today); - again.Expired.Should().Be(0); again.UnitsExpired.Should().Be(0); again.ExpiringNotified.Should().Be(1, "the same local day repeats the lead-day match; a new day will not"); + again.Expired.Should().Be(0); again.UnitsExpired.Should().Be(0); again.ExpiringNotified.Should().Be(1, "the same local day repeats the lead-day match; the worker's day claim is what prevents a second run"); (await _service.RunExpirySweepAsync(Dept, Today.AddDays(1))).ExpiringNotified.Should().Be(1, "only the record due in 8 days reaches its 7-day lead tomorrow"); } + [Test] + public async Task The_sweep_day_claim_is_granted_once_per_local_day_and_a_release_lets_the_same_day_retry() + { + _settings = null; + (await _service.TryClaimSweepDayAsync(Dept, Today.AddHours(6))).Should().BeTrue("a department without a settings row gets one on its first claim"); + _settings.LastSweepLocalDate.Should().Be(Today); + (await _service.TryClaimSweepDayAsync(Dept, Today.AddHours(7))).Should().BeFalse("a repeated or drifted tick on the same local day is skipped"); + (await _service.TryClaimSweepDayAsync(Dept, Today.AddDays(-1))).Should().BeFalse("the marker never moves backwards"); + + await _service.ReleaseSweepDayAsync(Dept, Today); + (await _service.TryClaimSweepDayAsync(Dept, Today)).Should().BeTrue("a failed sweep hands the day back so the next tick retries it"); + (await _service.TryClaimSweepDayAsync(Dept, Today.AddDays(1))).Should().BeTrue("the next local day is a new claim"); + } + [Test] public async Task Enforcement_removes_only_under_enforce_and_only_after_grace_and_audits_the_removal() { diff --git a/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs b/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs new file mode 100644 index 000000000..c9b61c046 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Resources; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Localization; +using Resgrid.Model; +using File = System.IO.File; + +namespace Resgrid.Tests.Services +{ + /// + /// Twin of for the deployment core (Workforce & Business Operations + /// plan, Phase C). Every resource key a view, the controller, the Security page rows, the workflow trigger labels or + /// the service's error codes reference must exist, and every supported culture must carry a complete, non-English + /// translation. + /// + [TestFixture] + public class DeploymentLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + + [Test] + public void Deployment_views_controllers_and_service_errors_resolve_real_resource_keys() + { + var root = RepositoryRoot(); + var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User"); + var files = Directory.GetFiles(Path.Combine(web, "Views", "Deployments"), "*.cshtml") + .Concat(new[] + { + Path.Combine(web, "Controllers", "DeploymentsController.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "DeploymentService.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "DeploymentService.Protection.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "DeploymentService.Documents.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "TimeTrackingService.cs"), + Path.Combine(web, "Views", "Security", "Index.cshtml"), + Path.Combine(web, "Views", "Shared", "_Navigation.cshtml"), + Path.Combine(web, "Views", "Workflows", "New.cshtml") + }); + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + var source = File.ReadAllText(file); + var deploymentsModule = file.Contains(Path.Combine("Views", "Deployments")) || file.EndsWith("DeploymentsController.cs") || file.Contains("DeploymentService") || file.EndsWith("TimeTrackingService.cs"); + var pattern = deploymentsModule + ? """(?)""" + : """_?deploymentLocalizer\["([^"]+)"\]|deploymentStrings\["([^"]+)"\]"""; + foreach (Match match in Regex.Matches(source, pattern)) + keys.Add(match.Groups.Cast().Skip(1).First(g => g.Success).Value); + } + + // Keys the views build by concatenation. + foreach (var status in Enum.GetNames()) keys.Add("Status" + status); + foreach (var status in Enum.GetNames()) keys.Add("ReportStatus" + status); + foreach (var mode in Enum.GetNames()) keys.Add("Finance" + mode); + foreach (var type in Enum.GetNames()) keys.Add("Expense" + type); + foreach (var type in Enum.GetNames()) keys.Add("Attachment" + type); + foreach (var type in Enum.GetNames()) keys.Add("Entry" + type); + foreach (var code in new[] { Resgrid.Model.Invoicing.DeploymentRosterWarning.ScheduleConflict, Resgrid.Model.Invoicing.DeploymentRosterWarning.RoleNotHeld, Resgrid.Model.Invoicing.DeploymentRosterWarning.CertificationMissing, Resgrid.Model.Invoicing.DeploymentRosterWarning.CertificationExpiring, Resgrid.Model.Invoicing.DeploymentRosterWarning.AlreadyRostered, "inventory_issue_failed" }) keys.Add("Warning" + code); + foreach (var code in new[] { Resgrid.Model.Invoicing.TimeReportValidation.Overlap, Resgrid.Model.Invoicing.TimeReportValidation.EndBeforeStart, Resgrid.Model.Invoicing.TimeReportValidation.SubjectNotOnRoster, Resgrid.Model.Invoicing.TimeReportValidation.NoEntries, Resgrid.Model.Invoicing.TimeReportValidation.BreakRule, Resgrid.Model.Invoicing.TimeReportValidation.LongTravel, Resgrid.Model.Invoicing.TimeReportValidation.OutsideReportDate }) keys.Add("Issue" + code); + foreach (var permission in new[] { PermissionTypes.ManageDeployments, PermissionTypes.ApproveTimeReports }) + { + keys.Add(permission.ToString()); + keys.Add(permission + "Note"); + } + foreach (var trigger in Resgrid.Model.Invoicing.DeploymentWorkflowPayload.Triggers) keys.Add(((WorkflowTriggerEventType)trigger).ToString()); + + var resources = Read(Path.Combine(ResourceDirectory(), "Deployments.resx")); + keys.Except(resources.Keys).Should().BeEmpty("user interfaces and errors must not show untranslated resource identifiers"); + } + + // These values are the same in both languages; they are reviewed, not untranslated. + private static readonly Dictionary SharedSpellings = new Dictionary + { + ["de"] = new[] { "Name", "Status", "Km", "AttachmentManifest" }, + ["es"] = new[] { "Total", "Km", "ExpenseFerry" }, + ["fr"] = new[] { "Contact", "Notes", "Personnel", "Certification", "ExpenseDate", "Type", "Description", "Total", "Km", "Actions", "Signatures", "AttachmentCertification" }, + ["it"] = new[] { "Km" }, + ["pl"] = new[] { "Status", "Km", "AttachmentManifest" }, + ["sv"] = new[] { "Status", "Start", "Km", "AttachmentManifest" }, + }; + + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "Deployments.resx")); + var file = Path.Combine(ResourceDirectory(), "Deployments." + culture + ".resx"); + File.Exists(file).Should().BeTrue("every supported language needs its own dictionary"); + var translated = Read(file); + translated.Keys.Should().BeEquivalentTo(baseline.Keys); + var manager = new ResourceManager("Resgrid.Localization.Areas.User.Deployments.Deployments", typeof(SupportedLocales).Assembly); + var compiled = manager.GetResourceSet(CultureInfo.GetCultureInfo(culture), true, false); + compiled.Should().NotBeNull("the language resource must be included in the built assembly"); + var allowed = SharedSpellings.TryGetValue(culture, out var entries) ? entries : Array.Empty(); + foreach (var entry in translated) + { + entry.Value.Should().NotBeNullOrWhiteSpace(culture + ": " + entry.Key); + compiled.GetString(entry.Key).Should().Be(entry.Value, culture + ": " + entry.Key); + Regex.Matches(entry.Value, @"\{\d+\}").Select(m => m.Value).Should().BeEquivalentTo(Regex.Matches(baseline[entry.Key], @"\{\d+\}").Select(m => m.Value), "format arguments must survive translation: " + entry.Key); + if (culture != "en" && !allowed.Contains(entry.Key)) entry.Value.Should().NotBe(baseline[entry.Key], culture + " must translate " + entry.Key); + } + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root unavailable."); + } + + private static string ResourceDirectory() => Path.Combine(RepositoryRoot(), "Core", "Resgrid.Localization", "Areas", "User", "Deployments"); + + private static Dictionary Read(string file) + { + var entries = XDocument.Load(file).Root.Elements("data").ToList(); + entries.Select(e => (string)e.Attribute("name")).Should().OnlyHaveUniqueItems(); + return entries.ToDictionary(e => (string)e.Attribute("name"), e => (string)e.Element("value"), StringComparer.Ordinal); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs new file mode 100644 index 000000000..616a05f79 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Certifications; +using Resgrid.Model.Events; +using Resgrid.Model.Identity; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// Workforce & Business Operations plan Phase C (deployment core): lifecycle, roster gates, external-order wrapping and events. + [TestFixture] + public class DeploymentServiceTests + { + private const int DeptId = 7; + private const string Manager = "manager"; + + private Mock _deployments; + private Mock _units; + private Mock _personnel; + private Mock _equipment; + private Mock _attachments; + private Mock _departments; + private Mock _unitsService; + private Mock _profiles; + private Mock _roles; + private Mock _certifications; + private Mock _contacts; + private Mock _calls; + private Mock _records; + private Mock _outbox; + private Mock _events; + private Mock _pdf; + private List _published; + private List _audits; + private List _storedDeployments; + private List _storedUnits; + private List _storedPersonnel; + private List _storedEquipment; + private List _storedAttachments; + private DeploymentService _service; + + [SetUp] + public void SetUp() + { + _deployments = new Mock(); + _units = new Mock(); + _personnel = new Mock(); + _equipment = new Mock(); + _attachments = new Mock(); + _departments = new Mock(); + _unitsService = new Mock(); + _profiles = new Mock(); + _roles = new Mock(); + _certifications = new Mock(); + _contacts = new Mock(); + _calls = new Mock(); + _records = new Mock(); + _outbox = new Mock(); + _events = new Mock(); + _pdf = new Mock(); + _published = new List(); + _audits = new List(); + _storedDeployments = new List(); + _storedUnits = new List(); + _storedPersonnel = new List(); + _storedEquipment = new List(); + _storedAttachments = new List(); + + _outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, e, ___) => _published.Add(e)).ReturnsAsync(new DomainEventOutboxEntry()); + _events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(html => System.Text.Encoding.UTF8.GetBytes(html)); + _departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test County Fire", TimeZone = "Pacific Standard Time" }); + _departments.Setup(d => d.GetAllUsersForDepartmentAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync(new List { new IdentityUser { UserId = "alice" }, new IdentityUser { UserId = "bob" } }); + _profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool _) => new UserProfile { UserId = id, FirstName = id, LastName = "Smith" }); + _profiles.Setup(p => p.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync((List ids) => ids.Select(id => new UserProfile { UserId = id, FirstName = id, LastName = "Smith" }).ToList()); + _unitsService.Setup(u => u.GetUnitsForDepartmentUnlimitedAsync(DeptId)).ReturnsAsync(new List { new Unit { UnitId = 1, DepartmentId = DeptId, Name = "Engine 1", Type = "Engine" }, new Unit { UnitId = 2, DepartmentId = DeptId, Name = "Engine 2" } }); + _unitsService.Setup(u => u.GetUnitByIdAsync(1)).ReturnsAsync(new Unit { UnitId = 1, DepartmentId = DeptId, Name = "Engine 1" }); + _unitsService.Setup(u => u.GetUnitByIdAsync(2)).ReturnsAsync(new Unit { UnitId = 2, DepartmentId = DeptId, Name = "Engine 2" }); + _unitsService.Setup(u => u.GetRoleByIdAsync(10)).ReturnsAsync(new UnitRole { UnitRoleId = 10, UnitId = 1, Name = "Captain", PersonnelRoleId = 5, PersonnelRoleRequired = true }); + _unitsService.Setup(u => u.GetRolesForUnitAsync(It.IsAny())).ReturnsAsync(new List()); + _roles.Setup(r => r.GetRolesForUserAsync(It.IsAny(), DeptId)).ReturnsAsync(new List()); + _certifications.Setup(c => c.EvaluateUserForRoleAsync(DeptId, 5, It.IsAny(), It.IsAny())).ReturnsAsync(new RoleCertificationEvaluation { Qualified = true }); + + // Repositories keep an in-memory list, echo the saved entity and assign ids like RepositoryBase. + _deployments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Deployment d, CancellationToken _, bool __) => { d.DeploymentId ??= Guid.NewGuid().ToString(); _storedDeployments.RemoveAll(x => x.DeploymentId == d.DeploymentId); _storedDeployments.Add(d); return d; }); + _deployments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _storedDeployments.FirstOrDefault(d => d.DeploymentId == id)); + _deployments.Setup(r => r.GetByCallIdAsync(It.IsAny(), DeptId)).ReturnsAsync((int callId, int _) => _storedDeployments.FirstOrDefault(d => d.CallId == callId && !d.IsDeleted)); + _deployments.Setup(r => r.GetByExternalOrderIdAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _storedDeployments.FirstOrDefault(d => d.RmsExternalOrderId == id && !d.IsDeleted)); + _units.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentUnit u, CancellationToken _, bool __) => { u.DeploymentUnitId ??= Guid.NewGuid().ToString(); _storedUnits.RemoveAll(x => x.DeploymentUnitId == u.DeploymentUnitId); _storedUnits.Add(u); return u; }); + _units.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedUnits.Where(u => u.DeploymentId == id).ToList()); + _units.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => _storedUnits.FirstOrDefault(u => u.DeploymentUnitId == (string)id)); + _units.Setup(r => r.GetActiveAssignmentsForUnitsAsync(DeptId, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _personnel.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentPersonnel p, CancellationToken _, bool __) => { p.DeploymentPersonnelId ??= Guid.NewGuid().ToString(); _storedPersonnel.RemoveAll(x => x.DeploymentPersonnelId == p.DeploymentPersonnelId); _storedPersonnel.Add(p); return p; }); + _personnel.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedPersonnel.Where(p => p.DeploymentId == id).ToList()); + _personnel.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => _storedPersonnel.FirstOrDefault(p => p.DeploymentPersonnelId == (string)id)); + _personnel.Setup(r => r.GetActiveAssignmentsForUsersAsync(DeptId, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _equipment.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentEquipment e, CancellationToken _, bool __) => { e.DeploymentEquipmentId ??= Guid.NewGuid().ToString(); _storedEquipment.RemoveAll(x => x.DeploymentEquipmentId == e.DeploymentEquipmentId); _storedEquipment.Add(e); return e; }); + _equipment.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedEquipment.Where(e => e.DeploymentId == id).ToList()); + _equipment.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object id) => _storedEquipment.FirstOrDefault(e => e.DeploymentEquipmentId == (string)id)); + _attachments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentAttachment a, CancellationToken _, bool __) => { if (a.DeploymentAttachmentId == 0) a.DeploymentAttachmentId = _storedAttachments.Count + 1; _storedAttachments.RemoveAll(x => x.DeploymentAttachmentId == a.DeploymentAttachmentId); _storedAttachments.Add(a); return a; }); + _attachments.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedAttachments.Where(a => a.DeploymentId == id && !a.IsDeleted).ToList()); + _attachments.Setup(r => r.GetByIdWithDataAsync(It.IsAny())).ReturnsAsync((int id) => _storedAttachments.FirstOrDefault(a => a.DeploymentAttachmentId == id)); + + _service = new DeploymentService(_deployments.Object, _units.Object, _personnel.Object, _equipment.Object, _attachments.Object, _departments.Object, _unitsService.Object, _profiles.Object, + _roles.Object, _certifications.Object, _contacts.Object, _calls.Object, _records.Object, _outbox.Object, _events.Object, _pdf.Object, null); + } + + private Task NewDeploymentAsync(string name = "Ridge Fire", DeploymentFinanceModes mode = DeploymentFinanceModes.Billable) => + _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = name, FinanceMode = (int)mode, StartOn = new DateTime(2026, 9, 20, 14, 0, 0, DateTimeKind.Utc), EndOn = new DateTime(2026, 9, 27, 14, 0, 0, DateTimeKind.Utc) }, Manager, "127.0.0.1", "test"); + + [Test] + public async Task Creating_a_deployment_starts_planned_publishes_created_and_audits() + { + var saved = await NewDeploymentAsync(); + + saved.DeploymentId.Should().NotBeNullOrEmpty(); + saved.Status.Should().Be((int)DeploymentStatuses.Planned); + saved.AddedByUserId.Should().Be(Manager); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.DeploymentCreated && e.AggregateId == saved.DeploymentId); + _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.DeploymentCreated); + } + + [Test] + public async Task Saving_rejects_a_blank_name_and_an_inverted_window() + { + (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = " " }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_name_required"); + (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "x", StartOn = DateTime.UtcNow, EndOn = DateTime.UtcNow.AddDays(-1) }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_window_invalid"); + } + + [Test] + public async Task A_call_can_back_only_one_deployment() + { + _calls.Setup(c => c.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(new Call { CallId = 42, DepartmentId = DeptId }); + await _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "First", CallId = 42 }, Manager, null, null); + + (await FluentActions.Awaiting(() => _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "Second", CallId = 42 }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_call_already_linked"); + } + + [Test] + public void Status_transitions_move_forward_only_and_terminal_states_are_final() + { + DeploymentService.IsValidTransition(DeploymentStatuses.Planned, DeploymentStatuses.Standby).Should().BeTrue(); + DeploymentService.IsValidTransition(DeploymentStatuses.Planned, DeploymentStatuses.Active).Should().BeTrue("a step may be skipped forward"); + DeploymentService.IsValidTransition(DeploymentStatuses.Active, DeploymentStatuses.Standby).Should().BeFalse("never backward"); + DeploymentService.IsValidTransition(DeploymentStatuses.Demobilizing, DeploymentStatuses.Cancelled).Should().BeTrue(); + DeploymentService.IsValidTransition(DeploymentStatuses.Completed, DeploymentStatuses.Active).Should().BeFalse(); + DeploymentService.IsValidTransition(DeploymentStatuses.Cancelled, DeploymentStatuses.Planned).Should().BeFalse(); + } + + [Test] + public async Task Status_change_publishes_old_and_new_status_and_stamps_the_window() + { + var saved = await _service.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "Standby team" }, Manager, null, null); + _published.Clear(); + + var active = await _service.SetDeploymentStatusAsync(saved.DeploymentId, DeptId, DeploymentStatuses.Active, Manager, null, null); + active.Status.Should().Be((int)DeploymentStatuses.Active); + active.StartOn.Should().NotBeNull("activation starts the window when none was set"); + active.StatusChangedOn.Should().NotBeNull(); + var payload = JObject.FromObject(_published.Single(e => e.Trigger == WorkflowTriggerEventType.DeploymentStatusChanged).Payload); + payload["OldStatus"].Value().Should().Be((int)DeploymentStatuses.Planned); + payload["Status"].Value().Should().Be((int)DeploymentStatuses.Active); + + (await FluentActions.Awaiting(() => _service.SetDeploymentStatusAsync(saved.DeploymentId, DeptId, DeploymentStatuses.Planned, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_status_transition_invalid"); + var completed = await _service.SetDeploymentStatusAsync(saved.DeploymentId, DeptId, DeploymentStatuses.Completed, Manager, null, null); + completed.EndOn.Should().NotBeNull(); + } + + [Test] + public async Task Roster_seats_units_and_people_publishes_roster_changes_and_refuses_duplicates() + { + var d = await NewDeploymentAsync(); + _published.Clear(); + + var unit = await _service.AddUnitAsync(d.DeploymentId, DeptId, 1, "E1", null, Manager, null, null); + unit.Unit.UnitName.Should().Be("Engine 1"); + var person = await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice", DeploymentUnitId = unit.Unit.DeploymentUnitId, CallSign = "E1-A" }, Manager, null, null); + person.Personnel.Should().NotBeNull(); + person.Personnel.DisplayName.Should().Be("alice Smith"); + + (await FluentActions.Awaiting(() => _service.AddUnitAsync(d.DeploymentId, DeptId, 1, null, null, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_unit_already_rostered"); + (await FluentActions.Awaiting(() => _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice" }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_user_already_rostered"); + (await FluentActions.Awaiting(() => _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "stranger" }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_user_not_in_department"); + + _published.Where(e => e.Trigger == WorkflowTriggerEventType.DeploymentRosterChanged).Should().HaveCount(2); + var payload = JObject.FromObject(_published.Last().Payload); + payload["SubjectType"].Value().Should().Be((int)DeploymentTimeSubjectTypes.Personnel); + payload["RosterAction"].Value().Should().Be("Added"); + payload["SubjectName"].Value().Should().Be("alice Smith"); + (await _service.GetCrewSizeForUnitAsync(unit.Unit.DeploymentUnitId, DeptId)).Should().Be(1); + (await _service.IsRosteredAsync(d.DeploymentId, DeptId, "alice")).Should().BeTrue(); + (await _service.IsRosteredAsync(d.DeploymentId, DeptId, "bob")).Should().BeFalse(); + } + + [Test] + public async Task Removing_a_unit_keeps_its_seats_but_unlinks_them() + { + var d = await NewDeploymentAsync(); + var unit = await _service.AddUnitAsync(d.DeploymentId, DeptId, 1, null, null, Manager, null, null); + await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice", DeploymentUnitId = unit.Unit.DeploymentUnitId }, Manager, null, null); + + (await _service.RemoveUnitAsync(unit.Unit.DeploymentUnitId, DeptId, Manager, null, null)).Should().BeTrue(); + + var full = await _service.GetDeploymentByIdAsync(d.DeploymentId, DeptId); + full.Units.Single().IsActive.Should().BeFalse(); + full.Personnel.Single().IsActive.Should().BeTrue(); + full.Personnel.Single().DeploymentUnitId.Should().BeNull(); + } + + [Test] + public async Task A_required_seat_role_blocks_unless_forced_and_certification_violations_flow_as_warnings() + { + var d = await NewDeploymentAsync(); + var unit = await _service.AddUnitAsync(d.DeploymentId, DeptId, 1, null, null, Manager, null, null); + _certifications.Setup(c => c.EvaluateUserForRoleAsync(DeptId, 5, "bob", It.IsAny())).ReturnsAsync(new RoleCertificationEvaluation + { + Qualified = false, + Violations = { new CertificationRequirementOutcome { TypeCode = "EMT", TypeName = "EMT-Basic", IsMandatory = true } }, + Outcomes = { new CertificationRequirementOutcome { TypeCode = "CPR", TypeName = "CPR", Satisfied = true, ExpiresOn = new DateTime(2026, 9, 22) } } + }); + + var blocked = await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "bob", DeploymentUnitId = unit.Unit.DeploymentUnitId, UnitRoleId = 10 }, Manager, null, null); + blocked.Personnel.Should().BeNull("the seat requires a role the member does not hold"); + blocked.HasBlockingWarnings.Should().BeTrue(); + blocked.Warnings.Select(w => w.Code).Should().Contain(new[] { DeploymentRosterWarning.RoleNotHeld, DeploymentRosterWarning.CertificationMissing, DeploymentRosterWarning.CertificationExpiring }); + _storedPersonnel.Should().BeEmpty(); + + var forced = await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "bob", DeploymentUnitId = unit.Unit.DeploymentUnitId, UnitRoleId = 10, Force = true }, Manager, null, null); + forced.Personnel.Should().NotBeNull(); + forced.Warnings.Should().OnlyContain(w => !w.Blocking, "once written the warnings are advisory"); + + _roles.Setup(r => r.GetRolesForUserAsync("alice", DeptId)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 5 } }); + var seated = await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice", DeploymentUnitId = unit.Unit.DeploymentUnitId, UnitRoleId = 10 }, Manager, null, null); + seated.Personnel.Should().NotBeNull(); + seated.Warnings.Should().BeEmpty(); + } + + [Test] + public async Task Schedule_conflicts_surface_as_warnings_without_blocking() + { + var d = await NewDeploymentAsync(); + _personnel.Setup(r => r.GetActiveAssignmentsForUsersAsync(DeptId, It.IsAny>(), It.IsAny(), It.IsAny(), d.DeploymentId)) + .ReturnsAsync(new List { new DeploymentPersonnel { UserId = "alice", DeploymentId = "other" } }); + + var warnings = await _service.GetRosterWarningsAsync(d.DeploymentId, DeptId, new[] { "alice", "bob" }, null); + warnings.Should().ContainSingle(w => w.Code == DeploymentRosterWarning.ScheduleConflict && w.SubjectId == "alice" && w.Detail == "other"); + + var result = await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice" }, Manager, null, null); + result.Personnel.Should().NotBeNull(); + result.Warnings.Should().ContainSingle(w => w.Code == DeploymentRosterWarning.ScheduleConflict); + } + + [Test] + public async Task A_closed_deployment_locks_its_roster() + { + var d = await NewDeploymentAsync(); + await _service.SetDeploymentStatusAsync(d.DeploymentId, DeptId, DeploymentStatuses.Cancelled, Manager, null, null); + + (await FluentActions.Awaiting(() => _service.AddUnitAsync(d.DeploymentId, DeptId, 1, null, null, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_closed"); + await FluentActions.Awaiting(() => _service.DeleteDeploymentAsync(d.DeploymentId, DeptId, Manager, null, null)).Should().NotThrowAsync(); + } + + [Test] + public async Task Equipment_without_the_inventory_module_is_a_free_text_row() + { + var d = await NewDeploymentAsync(); + var added = await _service.AddEquipmentAsync(d.DeploymentId, DeptId, new DeploymentEquipmentInput { FreeTextName = "Portable pump", IssueFromInventory = true }, Manager, null, null); + added.Equipment.FreeTextName.Should().Be("Portable pump"); + added.Equipment.IssuedOn.Should().BeNull("no inventory module is registered"); + added.Warnings.Should().BeEmpty(); + (await FluentActions.Awaiting(() => _service.AddEquipmentAsync(d.DeploymentId, DeptId, new DeploymentEquipmentInput(), Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_equipment_required"); + + (await _service.ReturnEquipmentAsync(added.Equipment.DeploymentEquipmentId, DeptId, Manager, null, null)).Should().BeTrue(); + _storedEquipment.Single().ReturnedOn.Should().NotBeNull(); + } + + [Test] + public async Task External_order_wrapping_prefills_identifiers_and_seats_accepted_fills_once() + { + _records.Setup(r => r.GetAsync(DeptId, Manager, "order-1", false)).ReturnsAsync(new RecordDeploymentAggregate + { + Order = new RmsExternalOrder { RmsExternalOrderId = "order-1", DepartmentId = DeptId, OrderNumber = "O-1234", IncidentName = "Ridge Fire", IncidentNumber = "CA-BTU-012345", CostCode = "CC-1", IncidentCountry = "US", IncidentSubdivision = "CA", TimeZoneId = "Pacific Standard Time", CurrencyCode = "USD", MeasurementSystem = "imperial" }, + Fills = new List + { + new RmsExternalOrderFill { RmsExternalOrderFillId = "fill-1", RequestNumber = "E-12", Status = (int)RmsDeploymentFillStatus.Accepted, AssignedUserId = "alice", AssignedUnitId = 1, Position = "ENGB" }, + new RmsExternalOrderFill { RmsExternalOrderFillId = "fill-2", RequestNumber = "E-13", Status = (int)RmsDeploymentFillStatus.Declined, AssignedUserId = "bob" }, + new RmsExternalOrderFill { RmsExternalOrderFillId = "fill-3", RequestNumber = "E-14", Status = (int)RmsDeploymentFillStatus.Mobilized, AssignedUserId = "bob", AssignedUnitId = 1 } + } + }); + + var d = await _service.CreateFromExternalOrderAsync(DeptId, new ExternalOrderDeploymentInput { RmsExternalOrderId = "order-1", FinanceMode = DeploymentFinanceModes.CostRecovery }, Manager, null, null); + + d.Name.Should().Be("Ridge Fire"); + d.RmsExternalOrderId.Should().Be("order-1"); + d.FinanceMode.Should().Be((int)DeploymentFinanceModes.CostRecovery); + d.IncidentNumber.Should().Be("CA-BTU-012345"); + d.ResourceOrderNumber.Should().Be("O-1234"); + d.RequestNumber.Should().BeNull("multi-request identity stays on the fills"); + d.CostCode.Should().Be("CC-1"); + d.HostCountry.Should().Be("US"); + d.LocalTimeZoneId.Should().Be("Pacific Standard Time"); + d.Currency.Should().Be("USD"); + d.CallId.Should().BeNull("no call was requested"); + d.Units.Should().ContainSingle(u => u.UnitId == 1); + d.Personnel.Select(p => p.UserId).Should().BeEquivalentTo(new[] { "alice", "bob" }, "declined fills are not seated"); + d.Personnel.Single(p => p.UserId == "alice").RmsExternalOrderFillId.Should().Be("fill-1"); + d.Personnel.Single(p => p.UserId == "alice").CertificationCode.Should().Be("ENGB"); + d.Personnel.Should().OnlyContain(p => p.DeploymentUnitId == d.Units.Single().DeploymentUnitId); + + (await FluentActions.Awaiting(() => _service.CreateFromExternalOrderAsync(DeptId, new ExternalOrderDeploymentInput { RmsExternalOrderId = "order-1" }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_external_order_already_linked"); + _calls.Verify(c => c.SaveCallAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task External_order_wrapping_can_create_the_call_with_the_contact_link() + { + _records.Setup(r => r.GetAsync(DeptId, Manager, "order-2", false)).ReturnsAsync(new RecordDeploymentAggregate + { + Order = new RmsExternalOrder { RmsExternalOrderId = "order-2", DepartmentId = DeptId, OrderNumber = "O-2", IncidentName = "Creek Fire", RequestingAgency = "CAL FIRE" }, + Fills = new List() + }); + _contacts.Setup(c => c.GetContactByIdAsync("contact-1")).ReturnsAsync(new Contact { ContactId = "contact-1", DepartmentId = DeptId, CompanyName = "CAL FIRE" }); + Call savedCall = null; + _calls.Setup(c => c.SaveCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Call c, CancellationToken _) => { c.CallId = 77; savedCall = c; return c; }); + _calls.Setup(c => c.GetCallByIdAsync(77, It.IsAny())).ReturnsAsync(() => savedCall); + + var d = await _service.CreateFromExternalOrderAsync(DeptId, new ExternalOrderDeploymentInput { RmsExternalOrderId = "order-2", CreateCall = true, CallType = "Mutual Aid", CallPriority = 2, ContactId = "contact-1" }, Manager, null, null); + + d.CallId.Should().Be(77); + savedCall.DepartmentId.Should().Be(DeptId); + savedCall.ReportingUserId.Should().Be(Manager); + savedCall.Name.Should().Be("Creek Fire"); + savedCall.NatureOfCall.Should().Contain("O-2"); + savedCall.Type.Should().Be("Mutual Aid"); + savedCall.Priority.Should().Be(2); + savedCall.Contacts.Should().ContainSingle(c => c.ContactId == "contact-1" && c.CallContactType == 0); + savedCall.Notes.Should().Contain("CAL FIRE"); + } + + [Test] + public async Task Manifest_renders_the_roster_and_files_as_a_manifest_attachment() + { + var d = await NewDeploymentAsync(); + var unit = await _service.AddUnitAsync(d.DeploymentId, DeptId, 1, "E1", null, Manager, null, null); + await _service.AddPersonnelAsync(d.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice", DeploymentUnitId = unit.Unit.DeploymentUnitId, CertificationCode = "ENGB" }, Manager, null, null); + + var html = await _service.RenderManifestHtmlAsync(d.DeploymentId, DeptId); + html.Should().Contain("Engine 1").And.Contain("alice Smith").And.Contain("ENGB").And.Contain("Test County Fire"); + + var attachment = await _service.GenerateManifestAsync(d.DeploymentId, DeptId, Manager, null, null); + attachment.AttachmentType.Should().Be((int)DeploymentAttachmentTypes.Manifest); + attachment.FileType.Should().Be("application/pdf"); + attachment.Data.Should().BeNull("the returned row carries no bytes"); + _storedAttachments.Single().Data.Should().NotBeEmpty(); + _storedAttachments.Single().FileSize.Should().Be(_storedAttachments.Single().Data.Length); + _audits.Should().Contain(a => a.Type == AuditLogTypes.DeploymentAttachmentAdded); + + (await _service.GetAttachmentsAsync(d.DeploymentId, DeptId)).Should().ContainSingle(); + (await _service.GetAttachmentAsync(attachment.DeploymentAttachmentId, DeptId, true)).Data.Should().NotBeEmpty(); + (await _service.GetAttachmentAsync(attachment.DeploymentAttachmentId, DeptId, false)).Data.Should().BeNull(); + } + + [Test] + public async Task Attachments_reject_empty_and_oversized_uploads() + { + var d = await NewDeploymentAsync(); + (await FluentActions.Awaiting(() => _service.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = d.DeploymentId, DepartmentId = DeptId, FileName = "x.pdf" }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_attachment_empty"); + (await FluentActions.Awaiting(() => _service.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = d.DeploymentId, DepartmentId = DeptId, FileName = "x.pdf", Data = new byte[DeploymentService.MaxAttachmentBytes + 1] }, Manager, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("deployments_attachment_too_large"); + } + + [Test] + public async Task Member_scope_lists_only_rostered_deployments() + { + var mine = await NewDeploymentAsync("Mine"); + var other = await NewDeploymentAsync("Other"); + await _service.AddPersonnelAsync(mine.DeploymentId, DeptId, new DeploymentPersonnelInput { UserId = "alice" }, Manager, null, null); + _personnel.Setup(r => r.GetForUserAsync(DeptId, "alice")).ReturnsAsync(() => _storedPersonnel.Where(p => p.UserId == "alice").ToList()); + _deployments.Setup(r => r.GetByIdsAsync(DeptId, It.IsAny>())).ReturnsAsync((int _, IEnumerable ids) => _storedDeployments.Where(x => ids.Contains(x.DeploymentId)).ToList()); + + var list = await _service.GetDeploymentsForUserAsync(DeptId, "alice", true); + list.Select(x => x.DeploymentId).Should().BeEquivalentTo(new[] { mine.DeploymentId }); + other.DeploymentId.Should().NotBe(mine.DeploymentId); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs index 1c2dea72b..4307bf837 100644 --- a/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs @@ -147,5 +147,74 @@ public async Task a_role_that_no_longer_exists_should_not_be_deleted() _repositoryCallOrder.Should().BeEmpty(); } } + + [TestFixture] + public class when_replacing_a_roles_members : with_the_personnel_roles_service + { + private readonly List _current = new List + { + new PersonnelRoleUser { PersonnelRoleUserId = 1, PersonnelRoleId = 6787, DepartmentId = 1, UserId = "keep" }, + new PersonnelRoleUser { PersonnelRoleUserId = 2, PersonnelRoleId = 6787, DepartmentId = 1, UserId = "drop" } + }; + + private PersonnelRole Role() => new PersonnelRole { PersonnelRoleId = 6787, DepartmentId = 1, Name = "Paramedic", Users = new List(_current) }; + + private void Arrange() + { + _personnelRoleUsersRepositoryMock.Setup(x => x.GetAllMembersOfRoleAsync(6787)).ReturnsAsync(new List(_current)); + _personnelRoleUsersRepositoryMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => _repositoryCallOrder.Add("delete:" + u.UserId)) + .ReturnsAsync(true); + _personnelRolesRepositoryMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((r, _, __) => _repositoryCallOrder.Add("save")) + .ReturnsAsync((PersonnelRole r, CancellationToken _, bool __) => r); + } + + [Test] + public async Task the_delete_and_the_save_share_one_transaction_and_the_cascade_carries_only_the_new_membership() + { + Arrange(); + + var saved = await _personnelRolesService.ReplaceRoleMembersAsync(Role(), new[] { "keep", "new", "new" }, actingUserId: "admin"); + + _repositoryCallOrder.Should().Equal("connection", "delete:keep", "delete:drop", "save", "commit"); + saved.Users.Should().HaveCount(2); + saved.Users.Should().OnlyContain(u => u.PersonnelRoleUserId == 0 && u.PersonnelRoleId == 6787 && u.DepartmentId == 1); + saved.Users.Should().Contain(u => u.UserId == "keep").And.Contain(u => u.UserId == "new"); + _eventAggregatorMock.Verify(x => x.SendMessage(It.Is(a => a.Type == AuditLogTypes.RoleMemberRemoved && a.After.Contains("\"drop\""))), Times.Once); + _eventAggregatorMock.Verify(x => x.SendMessage(It.Is(a => a.Type == AuditLogTypes.RoleMemberAdded && a.After.Contains("\"new\""))), Times.Once); + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.AtLeastOnce); + } + + [Test] + public void a_failed_save_rolls_the_membership_delete_back() + { + Arrange(); + _personnelRolesRepositoryMock + .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("constraint violation")); + + Assert.ThrowsAsync(async () => await _personnelRolesService.ReplaceRoleMembersAsync(Role(), new[] { "keep" })); + + // The previous members were deleted inside the transaction only; the rollback keeps them. + _unitOfWorkMock.Verify(x => x.DiscardChanges(), Times.Once); + _unitOfWorkMock.Verify(x => x.CommitChanges(), Times.Never); + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task an_unchanged_membership_is_rewritten_in_the_transaction_without_membership_audits() + { + Arrange(); + + await _personnelRolesService.ReplaceRoleMembersAsync(Role(), new[] { "keep", "drop" }); + + _repositoryCallOrder.Should().Equal("connection", "delete:keep", "delete:drop", "save", "commit"); + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + } } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index ea9133587..a41f34925 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -355,6 +355,8 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "CustomerBillingProfiles", "Invoices", "InvoicePayments", // Certifications, catalog v27 (Workforce & Business Operations plan, Phase D): read through the generic Records resolvers with CertificationProtectedFields accessors (CertificationService.Protection.cs). "UnitCertifications", "PersonnelCertificationCredits", + // Deployment core, catalog v28 (Workforce & Business Operations plan, Phase C): read through the generic Records resolvers with DeploymentProtectedFields accessors (DeploymentService.Protection.cs). + "DeploymentTimeReports", "DeploymentExpenses", "DeploymentAttachments", // RMS-5 prevention and investigations plus the RMS-4 quality review, catalog v13: read through the generic Records resolvers. "RmsOccupancies", "RmsOccupancyHazards", "RmsInspections", "RmsViolations", "RmsPermits", "RmsPlanReviews", "RmsInvestigationCases", "RmsInvestigationNotes", "RmsInvestigationEvidence", "RmsInvestigationCustody", "RmsInvestigationReferrals", diff --git a/Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs b/Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs new file mode 100644 index 000000000..25ba59a06 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs @@ -0,0 +1,292 @@ +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.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// Workforce & Business Operations plan Phase C (deployment core): DTR numbering, prefill, validation, lifecycle, expenses and export. + [TestFixture] + public class TimeTrackingServiceTests + { + private const int DeptId = 7; + private const string Crew = "alice"; + private const string Approver = "chief"; + private static readonly DateTime Day = new DateTime(2026, 9, 21); + + private Mock _deployments; + private Mock _personnel; + private Mock _units; + private Mock _equipment; + private Mock _reports; + private Mock _entries; + private Mock _expenses; + private Mock _attachments; + private Mock _sequence; + private Mock _deploymentService; + private Mock _departments; + private Mock _profiles; + private Mock _events; + private Mock _pdf; + private List _audits; + private List _storedReports; + private List _storedEntries; + private List _storedExpenses; + private List _storedAttachments; + private Deployment _deployment; + private int _nextNumber; + private TimeTrackingService _service; + + [SetUp] + public void SetUp() + { + _deployments = new Mock(); + _personnel = new Mock(); + _units = new Mock(); + _equipment = new Mock(); + _reports = new Mock(); + _entries = new Mock(); + _expenses = new Mock(); + _attachments = new Mock(); + _sequence = new Mock(); + _deploymentService = new Mock(); + _departments = new Mock(); + _profiles = new Mock(); + _events = new Mock(); + _pdf = new Mock(); + _audits = new List(); + _storedReports = new List(); + _storedEntries = new List(); + _storedExpenses = new List(); + _storedAttachments = new List(); + _nextNumber = 1; + + _deployment = new Deployment + { + DeploymentId = "dep-1", DepartmentId = DeptId, Name = "Ridge Fire", Status = (int)DeploymentStatuses.Active, IncidentNumber = "CA-BTU-1", ResourceOrderNumber = "O-1", RequestNumber = "E-12", CostCode = "CC-1", Currency = "USD", LocalTimeZoneId = "UTC", + Units = { new DeploymentUnit { DeploymentUnitId = "unit-1", DeploymentId = "dep-1", DepartmentId = DeptId, UnitId = 1, UnitName = "Engine 1" } }, + Personnel = + { + new DeploymentPersonnel { DeploymentPersonnelId = "per-1", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = Crew, DeploymentUnitId = "unit-1", DisplayName = "Alice Smith", CertificationCode = "ENGB" }, + new DeploymentPersonnel { DeploymentPersonnelId = "per-2", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "bob", DeploymentUnitId = "unit-1", DisplayName = "Bob Jones", RemovedOn = DateTime.UtcNow } + }, + Equipment = { new DeploymentEquipment { DeploymentEquipmentId = "eq-1", DeploymentId = "dep-1", DepartmentId = DeptId, FreeTextName = "Pump" } } + }; + _deploymentService.Setup(s => s.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment); + _deploymentService.Setup(s => s.SaveAttachmentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentAttachment a, string _, string __, string ___, CancellationToken ____) => { a.DeploymentAttachmentId = _storedAttachments.Count + 1; _storedAttachments.Add(a); return a; }); + _deployments.Setup(r => r.GetByIdForDepartmentAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment); + _departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test County Fire", TimeZone = "UTC" }); + _profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool _) => new UserProfile { UserId = id, FirstName = id, LastName = "Smith" }); + _events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(html => System.Text.Encoding.UTF8.GetBytes(html)); + _sequence.Setup(s => s.GetNextNumberAsync(DeptId, It.IsAny())).ReturnsAsync(() => _nextNumber++); + + _reports.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentTimeReport t, CancellationToken _, bool __) => { t.DeploymentTimeReportId ??= Guid.NewGuid().ToString(); _storedReports.RemoveAll(x => x.DeploymentTimeReportId == t.DeploymentTimeReportId); _storedReports.Add(t); return t; }); + _reports.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _storedReports.FirstOrDefault(t => t.DeploymentTimeReportId == id)); + _reports.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedReports.Where(t => t.DeploymentId == id && !t.IsDeleted).OrderBy(t => t.ReportDate).ToList()); + _reports.Setup(r => r.GetByDeploymentAndDateAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, DateTime date) => _storedReports.FirstOrDefault(t => t.DeploymentId == id && t.ReportDate == date.Date && t.Status != (int)DeploymentTimeReportStatuses.Void)); + _reports.Setup(r => r.GetUnbilledApprovedAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string id) => _storedReports.Where(t => t.Status == (int)DeploymentTimeReportStatuses.Approved && t.InvoiceId == null && (id == null || t.DeploymentId == id)).ToList()); + _entries.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentTimeEntry e, CancellationToken _, bool __) => { e.DeploymentTimeEntryId ??= Guid.NewGuid().ToString(); _storedEntries.Add(e); return e; }); + _entries.Setup(r => r.GetByReportAsync(It.IsAny())).ReturnsAsync((string id) => _storedEntries.Where(e => e.DeploymentTimeReportId == id).OrderBy(e => e.SortOrder).ToList()); + _entries.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedEntries.Where(e => e.DeploymentId == id).ToList()); + _entries.Setup(r => r.DeleteByReportAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, CancellationToken _) => _storedEntries.RemoveAll(e => e.DeploymentTimeReportId == id)); + _expenses.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentExpense e, CancellationToken _, bool __) => { e.DeploymentExpenseId ??= Guid.NewGuid().ToString(); _storedExpenses.RemoveAll(x => x.DeploymentExpenseId == e.DeploymentExpenseId); _storedExpenses.Add(e); return e; }); + _expenses.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _storedExpenses.FirstOrDefault(e => e.DeploymentExpenseId == id)); + _expenses.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedExpenses.Where(e => e.DeploymentId == id && !e.IsDeleted).ToList()); + + _service = new TimeTrackingService(_deployments.Object, _personnel.Object, _units.Object, _equipment.Object, _reports.Object, _entries.Object, _expenses.Object, _attachments.Object, _sequence.Object, + _deploymentService.Object, _departments.Object, _profiles.Object, new Mock().Object, _events.Object, _pdf.Object, null); + } + + private static DeploymentTimeEntry Entry(string subject, int hourStart, int hourEnd, DeploymentTimeEntryTypes type = DeploymentTimeEntryTypes.Deployment, int unpaidBreak = 30, DateTime? day = null) + { + var d = day ?? Day; + return new DeploymentTimeEntry + { + DeploymentPersonnelId = subject.StartsWith("per") ? subject : null, DeploymentUnitId = subject.StartsWith("unit") ? subject : null, DeploymentEquipmentId = subject.StartsWith("eq") ? subject : null, + EntryType = (int)type, StartTime = d.AddHours(hourStart), EndTime = d.AddHours(hourEnd), UnpaidBreakMinutes = unpaidBreak + }; + } + + [Test] + public async Task Creating_a_report_allocates_the_number_copies_identifiers_and_prefills_active_subjects() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + + report.ReportNumber.Should().Be(1); + report.Status.Should().Be((int)DeploymentTimeReportStatuses.Draft); + report.IncidentNumber.Should().Be("CA-BTU-1"); + report.ResourceOrderNumber.Should().Be("O-1"); + report.RequestNumber.Should().Be("E-12"); + report.CostCode.Should().Be("CC-1"); + report.Entries.Select(e => e.SubjectId).Should().BeEquivalentTo(new[] { "per-1", "unit-1", "eq-1" }, "removed members are not prefilled"); + report.Entries.Should().OnlyContain(e => e.EntryType == (int)DeploymentTimeEntryTypes.Deployment && e.EndTime > e.StartTime); + report.Entries.Single(e => e.SubjectId == "per-1").CertificationCode.Should().Be("ENGB"); + report.Entries.Single(e => e.SubjectId == "unit-1").CrewSizeSnapshot.Should().Be(1); + _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.TimeReportCreated); + + (await FluentActions.Awaiting(() => _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("timereports_date_exists"); + var next = await _service.CreateTimeReportAsync("dep-1", DeptId, Day.AddDays(1), Crew, null, null); + next.ReportNumber.Should().Be(2, "numbers come from the atomic sequence"); + } + + [Test] + public async Task Prefill_copies_the_previous_report_span_for_the_same_subject() + { + var first = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + await _service.SaveTimeEntriesAsync(first.DeploymentTimeReportId, DeptId, new List { Entry("per-1", 6, 18, unpaidBreak: 45), Entry("unit-1", 6, 18) }, Crew, null, null); + + var second = await _service.CreateTimeReportAsync("dep-1", DeptId, Day.AddDays(1), Crew, null, null); + var alice = second.Entries.Single(e => e.SubjectId == "per-1"); + alice.StartTime.Should().Be(Day.AddDays(1).AddHours(6)); + alice.EndTime.Should().Be(Day.AddDays(1).AddHours(18)); + alice.UnpaidBreakMinutes.Should().Be(45); + second.Entries.Single(e => e.SubjectId == "eq-1").StartTime.Hour.Should().Be(8, "no prior span falls back to the default day"); + } + + [Test] + public async Task Validation_flags_overlaps_inverted_spans_foreign_subjects_and_break_and_travel_rules() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + var result = await _service.SaveTimeEntriesAsync(report.DeploymentTimeReportId, DeptId, new List + { + Entry("per-1", 6, 14), Entry("per-1", 13, 20, DeploymentTimeEntryTypes.Standby), Entry("per-1", 22, 21), Entry("per-9", 6, 8), + Entry("unit-1", 6, 14, unpaidBreak: 0), Entry("eq-1", 0, 13, DeploymentTimeEntryTypes.Travel, 0) + }, Crew, null, null); + + result.Validation.IsValid.Should().BeFalse(); + result.Validation.Errors.Select(e => e.Code).Should().Contain(new[] { TimeReportValidation.Overlap, TimeReportValidation.EndBeforeStart, TimeReportValidation.SubjectNotOnRoster }); + result.Validation.Warnings.Select(w => w.Code).Should().Contain(new[] { TimeReportValidation.BreakRule, TimeReportValidation.LongTravel }); + result.Report.Entries.Should().HaveCount(3, "a failed batch leaves the stored prefill untouched"); + _audits.Should().NotContain(a => a.Type == AuditLogTypes.TimeReportUpdated); + } + + [Test] + public async Task A_valid_batch_replaces_entries_and_the_subject_id_decides_the_type() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + var standby = Entry("per-1", 14, 20, DeploymentTimeEntryTypes.Standby, 0); + standby.SubjectType = (int)DeploymentTimeSubjectTypes.Unit; + standby.DeploymentUnitId = "unit-1"; + + var result = await _service.SaveTimeEntriesAsync(report.DeploymentTimeReportId, DeptId, new List { Entry("per-1", 6, 14), standby, Entry("unit-1", 6, 20, unpaidBreak: 60) }, Crew, null, null); + + result.Validation.IsValid.Should().BeTrue(); + result.Report.Entries.Should().HaveCount(3); + var alice = result.Report.Entries.Where(e => e.DeploymentPersonnelId == "per-1").ToList(); + alice.Should().HaveCount(2); + alice.Should().OnlyContain(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Personnel && e.DeploymentUnitId == null); + alice.Sum(e => e.Hours).Should().Be(13.5m); + result.Report.Entries.Single(e => e.DeploymentUnitId == "unit-1").CrewSizeSnapshot.Should().Be(1); + result.Report.Entries.Select(e => e.SortOrder).Should().BeEquivalentTo(new[] { 0, 1, 2 }); + } + + [Test] + public async Task Lifecycle_submit_approve_void_publishes_and_locks() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + + (await FluentActions.Awaiting(() => _service.ApproveTimeReportAsync(report.DeploymentTimeReportId, DeptId, Approver, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("timereports_status_transition_invalid"); + + var submitted = await _service.SubmitTimeReportAsync(report.DeploymentTimeReportId, DeptId, Crew, null, null); + submitted.Validation.IsValid.Should().BeTrue(); + submitted.Report.Status.Should().Be((int)DeploymentTimeReportStatuses.Submitted); + submitted.Report.SubmittedByUserId.Should().Be(Crew); + _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.TimeReportSubmitted); + + var approved = await _service.ApproveTimeReportAsync(report.DeploymentTimeReportId, DeptId, Approver, null, null); + approved.Status.Should().Be((int)DeploymentTimeReportStatuses.Approved); + approved.ApprovedByUserId.Should().Be(Approver); + approved.IsEditable.Should().BeFalse(); + (await _service.GetUnbilledApprovedReportsAsync(DeptId)).Should().ContainSingle(r => r.DeploymentTimeReportId == report.DeploymentTimeReportId); + + (await FluentActions.Awaiting(() => _service.SaveTimeEntriesAsync(report.DeploymentTimeReportId, DeptId, new List(), Crew, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("timereports_locked"); + + var voided = await _service.VoidTimeReportAsync(report.DeploymentTimeReportId, DeptId, "duplicate", Approver, null, null); + voided.Status.Should().Be((int)DeploymentTimeReportStatuses.Void); + voided.Notes.Should().Contain("duplicate"); + (await FluentActions.Awaiting(() => _service.VoidTimeReportAsync(report.DeploymentTimeReportId, DeptId, null, Approver, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("timereports_status_transition_invalid"); + (await _service.GetUnbilledApprovedReportsAsync(DeptId)).Should().BeEmpty(); + } + + [Test] + public async Task Submit_refuses_a_report_that_fails_validation() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + _storedEntries.Clear(); + + var result = await _service.SubmitTimeReportAsync(report.DeploymentTimeReportId, DeptId, Crew, null, null); + result.Validation.Errors.Should().ContainSingle(e => e.Code == TimeReportValidation.NoEntries); + result.Report.Status.Should().Be((int)DeploymentTimeReportStatuses.Draft); + } + + [Test] + public async Task Signatures_stamp_the_contractor_and_the_customer_signer() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + var signed = await _service.SignTimeReportAsync(report.DeploymentTimeReportId, DeptId, true, " J. Agency ", Crew, null, null); + + signed.ContractorSignedByUserId.Should().Be(Crew); + signed.ContractorSignedOn.Should().NotBeNull(); + signed.CustomerSignerName.Should().Be("J. Agency"); + signed.CustomerSignedOn.Should().NotBeNull(); + + var html = await _service.RenderTimeReportHtmlAsync(report.DeploymentTimeReportId, DeptId); + html.Should().Contain("J. Agency").And.Contain("alice Smith").And.Contain("Alice Smith").And.Contain("CA-BTU-1"); + var pdf = await _service.GenerateTimeReportPdfAsync(report.DeploymentTimeReportId, DeptId, Crew, null, null); + pdf.AttachmentType.Should().Be((int)DeploymentAttachmentTypes.TimeReportPdf); + pdf.FileName.Should().Be("dtr-1-20260921.pdf"); + } + + [Test] + public async Task Expenses_default_currency_file_receipts_and_lock_behind_approved_reports() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + var expense = await _service.SaveExpenseAsync(new DeploymentExpense { DeploymentId = "dep-1", DepartmentId = DeptId, DeploymentTimeReportId = report.DeploymentTimeReportId, ExpenseType = (int)DeploymentExpenseTypes.Fuel, Amount = 88.40m, Description = " diesel " }, + new byte[] { 1, 2, 3 }, "receipt.jpg", "image/jpeg", Crew, null, null); + + expense.Currency.Should().Be("USD"); + expense.Description.Should().Be("diesel"); + expense.ReceiptAttachmentId.Should().Be(1); + _storedAttachments.Single().AttachmentType.Should().Be((int)DeploymentAttachmentTypes.Receipt); + _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.DeploymentExpenseAdded); + + (await FluentActions.Awaiting(() => _service.SaveExpenseAsync(new DeploymentExpense { DeploymentId = "dep-1", DepartmentId = DeptId, Amount = -1 }, null, null, null, Crew, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("expenses_amount_invalid"); + (await FluentActions.Awaiting(() => _service.SaveExpenseAsync(new DeploymentExpense { DeploymentId = "dep-1", DepartmentId = DeptId, Amount = 1, ExpenseType = 99 }, null, null, null, Crew, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("expenses_type_invalid"); + + await _service.SubmitTimeReportAsync(report.DeploymentTimeReportId, DeptId, Crew, null, null); + await _service.ApproveTimeReportAsync(report.DeploymentTimeReportId, DeptId, Approver, null, null); + (await FluentActions.Awaiting(() => _service.DeleteExpenseAsync(expense.DeploymentExpenseId, DeptId, Crew, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("timereports_locked"); + } + + [Test] + public async Task Csv_export_lists_every_entry_with_its_report_context() + { + var report = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + await _service.SaveTimeEntriesAsync(report.DeploymentTimeReportId, DeptId, new List { Entry("per-1", 6, 14) }, Crew, null, null); + _storedEntries.Single().Notes = "hose, \"long\" lay"; + var csv = await _service.ExportTimeEntriesCsvAsync("dep-1", DeptId); + + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + lines.Should().HaveCount(2); + lines[0].Should().StartWith("ReportNumber,ReportDate,ReportStatus"); + lines[1].Should().StartWith("1,2026-09-21,Draft,CA-BTU-1,O-1,E-12,CC-1,Personnel,per-1,Alice Smith,Deployment,"); + lines[1].Should().Contain("\"hose, \"\"long\"\" lay\""); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/PaymentWebhooksController.cs b/Web/Resgrid.Web.Services/Controllers/PaymentWebhooksController.cs index ed1fcd29b..4104b550d 100644 --- a/Web/Resgrid.Web.Services/Controllers/PaymentWebhooksController.cs +++ b/Web/Resgrid.Web.Services/Controllers/PaymentWebhooksController.cs @@ -41,7 +41,13 @@ public async Task Stripe(CancellationToken cancellationToken) using (var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) body = await reader.ReadToEndAsync(cancellationToken); - var receipt = await _payments.ReceiveWebhookAsync((int)PaymentProviders.Stripe, Request.Headers["Stripe-Signature"].ToString(), body, + // An unsigned or empty POST cannot be a Stripe delivery; it is refused here rather than parsed, recorded as a + // rejected event and stored (which every signed-but-invalid delivery still is). + var signature = Request.Headers["Stripe-Signature"].ToString(); + if (string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(body)) + return BadRequest(); + + var receipt = await _payments.ReceiveWebhookAsync((int)PaymentProviders.Stripe, signature, body, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); if (receipt.Accepted) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CertificationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CertificationsController.cs index c6d4cb42d..4fc6e364d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CertificationsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CertificationsController.cs @@ -30,6 +30,9 @@ namespace Resgrid.Web.Services.Controllers.v4 [Authorize] public class CertificationsController : V4AuthenticatedApiControllerbase { + /// Same ceiling as the MVC upload path (CertificationsController.MaxFileBytes). + private const int MaxFileBytes = 10 * 1024 * 1024; + private readonly ICertificationService _certifications; private readonly IProtectedReadService _protectedRead; private readonly IDepartmentsService _departments; @@ -54,6 +57,22 @@ public CertificationsController(ICertificationService certifications, IProtected return BadRequest(failed); } + /// + /// Decodes a base64 upload under the MVC size ceiling: the encoded length is checked before the decode so an + /// oversized payload is refused without the allocation. Throws certifications_file_too_large / FormatException, + /// both of which the actions already map. + /// + private static byte[] DecodeFile(string base64) + { + var encoded = base64.Trim(); + if (encoded.Length > ((MaxFileBytes + 2) / 3) * 4) + throw new InvalidOperationException("certifications_file_too_large"); + var bytes = Convert.FromBase64String(encoded); + if (bytes.Length > MaxFileBytes) + throw new InvalidOperationException("certifications_file_too_large"); + return bytes; + } + #region Types [HttpGet("GetCertificationTypes")] @@ -194,7 +213,7 @@ record = await _certifications.GetCertificationByIdAsync(input.Id); record.ExpiresOn = input.ExpiresOn; record.RecievedOn = input.ReceivedOn; if (!string.IsNullOrWhiteSpace(input.FileData)) { - record.Data = Convert.FromBase64String(input.FileData); + record.Data = DecodeFile(input.FileData); record.Filename = input.FileName; record.Filetype = input.FileType; } @@ -303,7 +322,7 @@ public async Task> AddCertificationCred try { var credit = new PersonnelCertificationCredit { PersonnelCertificationId = input.CertificationId, DepartmentId = DepartmentId, CreditDate = input.CreditDate ?? DateTime.UtcNow.Date, Hours = input.Hours, Category = input.Category, Description = input.Description, FileName = input.FileName, FileType = input.FileType }; - if (!string.IsNullOrWhiteSpace(input.FileData)) credit.Data = Convert.FromBase64String(input.FileData); + if (!string.IsNullOrWhiteSpace(input.FileData)) credit.Data = DecodeFile(input.FileData); await _certifications.AddCertificationCreditAsync(credit, UserId, cancellationToken); return await GetCertificationCredits(input.CertificationId); } @@ -370,7 +389,7 @@ public async Task> SaveUnitCertification([ try { var row = new UnitCertification { UnitCertificationId = input.Id, UnitId = input.UnitId, DepartmentId = DepartmentId, DepartmentCertificationTypeId = input.TypeId, Number = input.Number, IssuedBy = input.IssuedBy, IssuedOn = input.IssuedOn, ExpiresOn = input.ExpiresOn, Notes = input.Notes, FileName = input.FileName, FileType = input.FileType }; - if (!string.IsNullOrWhiteSpace(input.FileData)) row.Data = Convert.FromBase64String(input.FileData); + if (!string.IsNullOrWhiteSpace(input.FileData)) row.Data = DecodeFile(input.FileData); var saved = await _certifications.SaveUnitCertificationAsync(row, UserId, cancellationToken); var reloaded = await _certifications.GetUnitCertificationByIdAsync(saved.UnitCertificationId) ?? saved; var types = (await _certifications.GetAllCertificationTypesByDepartmentAsync(DepartmentId)).ToDictionary(t => t.DepartmentCertificationTypeId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs new file mode 100644 index 000000000..ebc48365f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; +using Resgrid.Web.Services.Models.v4.Deployments; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Deployment finance wrappers, roster and attachments (Workforce & Business Operations plan, Phase C5). The + /// deployment core is free behind the Operations.Deployments flag. Managers (Deployments_Update) see and edit every + /// deployment; a rostered member sees the deployments they are seated on. RMS owns the RecordDeployments routes; + /// this route is Deployments and its DTOs carry RmsExternalOrderId so a client can open the Records deployment. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class DeploymentsController : V4AuthenticatedApiControllerbase + { + private readonly IDeploymentService _deployments; + private readonly IFeatureToggleService _flags; + private readonly IBusinessOperationsAccessService _access; + + public DeploymentsController(IDeploymentService deployments, IFeatureToggleService flags, IBusinessOperationsAccessService access) + { + _deployments = deployments; + _flags = flags; + _access = access; + } + + internal static bool CanManage() => ClaimsAuthorizationHelper.CanManageDeployments() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + internal static bool CanView() => ClaimsAuthorizationHelper.CanViewDeployments() || CanManage(); + + private Task EnabledAsync() => _flags.IsEnabledAsync(FeatureFlagKeys.Deployments, DepartmentId); + + private ActionResult Failed(string reason, int status = StatusCodes.Status400BadRequest) where T : StandardApiResponseV4Base, new() + { + var failed = new T { PageSize = 0, Status = ResponseHelper.Failure }; + ResponseHelper.PopulateV4ResponseData(failed); + Response.Headers["X-Resgrid-Reason"] = reason; + return StatusCode(status, failed); + } + + private string Ip => IpAddressHelper.GetRequestIP(Request, true); + private string Agent => $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + + /// Whether the deployment core is available to this department and what the caller may do. Always answers. + [HttpGet("GetAccess")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAccess() + { + var result = new DeploymentAccessResult + { + Data = new DeploymentAccessData + { + Enabled = await EnabledAsync(), + CanManage = CanManage(), + CanApproveTimeReports = ClaimsAuthorizationHelper.CanApproveTimeReports() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), + ContractorBilling = await _access.CanUseContractorBillingAsync(DepartmentId) + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetDeployments")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDeployments(bool? active = null, int skip = 0, int take = 100) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + var openOnly = active ?? true; + var deployments = CanView() + ? await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, openOnly, skip, take) + : await _deployments.GetDeploymentsForUserAsync(DepartmentId, UserId, openOnly); + var result = new DeploymentsResult { Data = deployments.Select(d => Map(d, false)).ToList(), PageSize = deployments.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetDeployment")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDeployment(string id) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + var deployment = await _deployments.GetDeploymentByIdAsync(id, DepartmentId); + if (deployment == null) return NotFound(); + if (!CanView() && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + return Ok(deployment); + } + + /// The billing context behind a call (mobile: call → deployment). + [HttpGet("GetDeploymentByCallId")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDeploymentByCallId(int callId) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + var deployment = await _deployments.GetDeploymentByCallIdAsync(callId, DepartmentId); + if (deployment == null) return NotFound(); + if (!CanView() && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + return Ok(deployment); + } + + [HttpPost("SaveDeployment")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveDeployment([FromBody] SaveDeploymentInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var deployment = string.IsNullOrWhiteSpace(input.Id) ? new Deployment { DepartmentId = DepartmentId } : await _deployments.GetDeploymentByIdAsync(input.Id, DepartmentId); + if (deployment == null) return NotFound(); + deployment.Name = input.Name; + deployment.FinanceMode = input.FinanceMode; + deployment.CallId = input.CallId; + deployment.ContactId = input.ContactId; + deployment.IncidentNumber = input.IncidentNumber; + deployment.ServiceRequestNumber = input.ServiceRequestNumber; + deployment.ResourceOrderNumber = input.ResourceOrderNumber; + deployment.RequestNumber = input.RequestNumber; + deployment.CostCode = input.CostCode; + deployment.PointOfHire = input.PointOfHire; + deployment.StartOn = input.StartOn; + deployment.EndOn = input.EndOn; + deployment.MaxDays = input.MaxDays; + deployment.OutOfProvince = input.OutOfProvince; + deployment.TravelViaAir = input.TravelViaAir; + deployment.HomeCountry = input.HomeCountry; + deployment.HostCountry = input.HostCountry; + deployment.HomeSubdivision = input.HomeSubdivision; + deployment.HostSubdivision = input.HostSubdivision; + deployment.LocalTimeZoneId = input.LocalTimeZoneId; + deployment.Currency = input.Currency; + deployment.Notes = input.Notes; + var saved = await _deployments.SaveDeploymentAsync(deployment, UserId, Ip, Agent, cancellationToken); + return Ok(saved); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("CreateFromExternalOrder")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateFromExternalOrder([FromBody] CreateFromExternalOrderInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var saved = await _deployments.CreateFromExternalOrderAsync(DepartmentId, new ExternalOrderDeploymentInput + { + RmsExternalOrderId = input.RmsExternalOrderId, FinanceMode = (DeploymentFinanceModes)input.FinanceMode, CreateCall = input.CreateCall, CallType = input.CallType, CallPriority = input.CallPriority, + PrefillRoster = input.PrefillRoster, Name = input.Name, ContactId = input.ContactId, Notes = input.Notes + }, UserId, Ip, Agent, cancellationToken); + return Ok(saved); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + catch (UnauthorizedAccessException) { return Unauthorized(); } + } + + [HttpPost("SetDeploymentStatus")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SetDeploymentStatus([FromBody] SetDeploymentStatusInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null || !Enum.IsDefined(typeof(DeploymentStatuses), input.Status)) return BadRequest(); + try { return Ok(await _deployments.SetDeploymentStatusAsync(input.Id, DepartmentId, (DeploymentStatuses)input.Status, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + #region Roster + + [HttpPost("AddUnit")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AddUnit([FromBody] AddDeploymentUnitInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _deployments.AddUnitAsync(input.DeploymentId, DepartmentId, input.UnitId, input.CallSign, input.Notes, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("RemoveUnit")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RemoveUnit(string deploymentUnitId, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + try { return await _deployments.RemoveUnitAsync(deploymentUnitId, DepartmentId, UserId, Ip, Agent, cancellationToken) ? Done() : NotFound(); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("AddPersonnel")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AddPersonnel([FromBody] AddDeploymentPersonnelInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + return Ok(await _deployments.AddPersonnelAsync(input.DeploymentId, DepartmentId, new DeploymentPersonnelInput + { + UserId = input.UserId, DeploymentUnitId = input.DeploymentUnitId, UnitRoleId = input.UnitRoleId, CertificationCode = input.CertificationCode, CallSign = input.CallSign, RmsExternalOrderFillId = input.RmsExternalOrderFillId, Force = input.Force + }, UserId, Ip, Agent, cancellationToken)); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("RemovePersonnel")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RemovePersonnel(string deploymentPersonnelId, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + try { return await _deployments.RemovePersonnelAsync(deploymentPersonnelId, DepartmentId, UserId, Ip, Agent, cancellationToken) ? Done() : NotFound(); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("AddEquipment")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AddEquipment([FromBody] AddDeploymentEquipmentInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + return Ok(await _deployments.AddEquipmentAsync(input.DeploymentId, DepartmentId, new DeploymentEquipmentInput + { + DeploymentUnitId = input.DeploymentUnitId, InventoryAssetId = input.InventoryAssetId, InventoryItemId = input.InventoryItemId, FreeTextName = input.FreeTextName, Notes = input.Notes, IssueFromInventory = input.IssueFromInventory, FromLocationId = input.FromLocationId + }, UserId, Ip, Agent, cancellationToken)); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("ReturnEquipment")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReturnEquipment(string deploymentEquipmentId, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + return await _deployments.ReturnEquipmentAsync(deploymentEquipmentId, DepartmentId, UserId, Ip, Agent, cancellationToken) ? Done() : NotFound(); + } + + [HttpGet("GetRosterWarnings")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetRosterWarnings(string deploymentId, [FromQuery] List userIds, [FromQuery] List unitIds) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + try + { + var warnings = await _deployments.GetRosterWarningsAsync(deploymentId, DepartmentId, userIds, unitIds); + var result = new RosterWarningsResult { Data = warnings.Select(MapWarning).ToList(), PageSize = warnings.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + #endregion + + #region Attachments + + [HttpGet("GetAttachments")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAttachments(string deploymentId) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (!CanView() && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + var rows = await _deployments.GetAttachmentsAsync(deploymentId, DepartmentId); + var result = new DeploymentAttachmentsResult { Data = rows.Select(MapAttachment).ToList(), PageSize = rows.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("GenerateManifest")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GenerateManifest(string deploymentId, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + try + { + var attachment = await _deployments.GenerateManifestAsync(deploymentId, DepartmentId, UserId, Ip, Agent, cancellationToken); + var result = new DeploymentAttachmentsResult { Data = new List { MapAttachment(attachment) }, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteAttachment")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteAttachment(int deploymentAttachmentId, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + return await _deployments.DeleteAttachmentAsync(deploymentAttachmentId, DepartmentId, UserId, Ip, Agent, cancellationToken) ? Done() : NotFound(); + } + + #endregion + + #region Mapping + + private ActionResult Ok(Deployment deployment) + { + var result = new DeploymentResult { Data = Map(deployment, true), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Ok(DeploymentRosterResult change) + { + var result = new RosterChangeResult + { + Data = new RosterChangeData + { + Unit = change.Unit == null ? null : MapUnit(change.Unit), Personnel = change.Personnel == null ? null : MapPersonnel(change.Personnel), Equipment = change.Equipment == null ? null : MapEquipment(change.Equipment), + Warnings = change.Warnings.Select(MapWarning).ToList(), Blocked = change.HasBlockingWarnings && change.Unit == null && change.Personnel == null && change.Equipment == null + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Done() + { + var result = new StandardApiResponseV4Base { PageSize = 0, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + internal static DeploymentData Map(Deployment d, bool roster) => new DeploymentData + { + Id = d.DeploymentId, Name = d.Name, Status = d.Status, FinanceMode = d.FinanceMode, CallId = d.CallId, RmsExternalOrderId = d.RmsExternalOrderId, ContactId = d.ContactId, ServiceContractId = d.ServiceContractId, + IncidentNumber = d.IncidentNumber, ServiceRequestNumber = d.ServiceRequestNumber, ResourceOrderNumber = d.ResourceOrderNumber, RequestNumber = d.RequestNumber, CostCode = d.CostCode, PointOfHire = d.PointOfHire, + StartOn = d.StartOn, EndOn = d.EndOn, MaxDays = d.MaxDays, OutOfProvince = d.OutOfProvince, TravelViaAir = d.TravelViaAir, HomeCountry = d.HomeCountry, HostCountry = d.HostCountry, HomeSubdivision = d.HomeSubdivision, HostSubdivision = d.HostSubdivision, + LocalTimeZoneId = d.LocalTimeZoneId, Currency = d.Currency, Notes = d.Notes, StatusChangedOn = d.StatusChangedOn, AddedOn = d.AddedOn, UpdatedOn = d.EditedOn ?? d.AddedOn, + Units = roster ? d.Units.Select(MapUnit).ToList() : new List(), + Personnel = roster ? d.Personnel.Select(MapPersonnel).ToList() : new List(), + Equipment = roster ? d.Equipment.Select(MapEquipment).ToList() : new List() + }; + + internal static DeploymentUnitData MapUnit(DeploymentUnit u) => new DeploymentUnitData { Id = u.DeploymentUnitId, UnitId = u.UnitId, UnitName = u.UnitName, CallSign = u.CallSign, Notes = u.Notes, IsActive = u.IsActive, AddedOn = u.AddedOn, RemovedOn = u.RemovedOn }; + internal static DeploymentPersonnelData MapPersonnel(DeploymentPersonnel p) => new DeploymentPersonnelData { Id = p.DeploymentPersonnelId, UserId = p.UserId, Name = p.DisplayName, DeploymentUnitId = p.DeploymentUnitId, UnitRoleId = p.UnitRoleId, CertificationCode = p.CertificationCode, CallSign = p.CallSign, RmsExternalOrderFillId = p.RmsExternalOrderFillId, IsActive = p.IsActive, AddedOn = p.AddedOn, RemovedOn = p.RemovedOn }; + internal static DeploymentEquipmentData MapEquipment(DeploymentEquipment e) => new DeploymentEquipmentData { Id = e.DeploymentEquipmentId, DeploymentUnitId = e.DeploymentUnitId, InventoryAssetId = e.InventoryAssetId, InventoryItemId = e.InventoryItemId, Name = e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId, Notes = e.Notes, IssuedOn = e.IssuedOn, ReturnedOn = e.ReturnedOn, IsActive = e.IsActive }; + internal static RosterWarningData MapWarning(DeploymentRosterWarning w) => new RosterWarningData { Code = w.Code, SubjectId = w.SubjectId, Detail = w.Detail, Blocking = w.Blocking }; + internal static DeploymentAttachmentData MapAttachment(DeploymentAttachment a) => new DeploymentAttachmentData { Id = a.DeploymentAttachmentId, DeploymentId = a.DeploymentId, AttachmentType = a.AttachmentType, Name = a.Name, FileName = a.FileName, FileType = a.FileType, FileSize = a.FileSize, IsProtected = a.IsProtected, AddedOn = a.AddedOn, AddedByUserId = a.AddedByUserId }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/HealthController.cs b/Web/Resgrid.Web.Services/Controllers/v4/HealthController.cs index 489a9e940..c0983fc95 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/HealthController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/HealthController.cs @@ -20,14 +20,12 @@ public class HealthController : V4AuthenticatedApiControllerbase #region Members and Constructors private readonly IHealthService _healthService; private readonly IGlobalSearchService _globalSearch; - private readonly IRecordsSearchService _recordsSearch; private readonly IInvoicePaymentsService _invoicePayments; - public HealthController(IHealthService healthService, IGlobalSearchService globalSearch, IRecordsSearchService recordsSearch, IInvoicePaymentsService invoicePayments) + public HealthController(IHealthService healthService, IGlobalSearchService globalSearch, IInvoicePaymentsService invoicePayments) { _healthService = healthService; _globalSearch = globalSearch; - _recordsSearch = recordsSearch; _invoicePayments = invoicePayments; } #endregion Members and Constructors @@ -57,9 +55,9 @@ public async Task GetCurrent() if (Config.SearchConfig.Enabled) { var global = await _globalSearch.GetHealthAsync(); - var records = await _recordsSearch.GetHealthAsync(); result.Data.SearchOnline = global.Online; - result.Data.SearchIndexDocCount = global.DocumentCount + records.DocumentCount; + // This endpoint is anonymous; index counts describe every department in the cluster. + result.Data.SearchIndexDocCount = null; } } catch (System.Exception ex) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs index 1138e4fb2..64dadcb63 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs @@ -27,6 +27,7 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public class SearchController : V4AuthenticatedApiControllerbase { private const int MaxTake = 100; @@ -38,10 +39,11 @@ public class SearchController : V4AuthenticatedApiControllerbase private readonly ISearchIndexStatesRepository _states; private readonly IDepartmentSettingsService _departmentSettings; private readonly IFeatureToggleService _featureToggles; + private readonly IRecordsAuthorizationService _authorization; public SearchController(IUnifiedSearchService unifiedSearch, IGlobalSearchService globalSearch, IRecordsSearchService recordsSearch, ISearchIndexMaintenanceService maintenance, ISearchIndexStatesRepository states, IDepartmentSettingsService departmentSettings, - IFeatureToggleService featureToggles) + IFeatureToggleService featureToggles, IRecordsAuthorizationService authorization) { _unifiedSearch = unifiedSearch; _globalSearch = globalSearch; @@ -50,6 +52,7 @@ public SearchController(IUnifiedSearchService unifiedSearch, IGlobalSearchServic _states = states; _departmentSettings = departmentSettings; _featureToggles = featureToggles; + _authorization = authorization; } /// @@ -122,7 +125,7 @@ public async Task> Rebuild(CancellationToken c { if (!await _featureToggles.IsEnabledAsync(FeatureFlagKeys.SearchUnified, DepartmentId)) return NotFound(); - if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || !await _authorization.IsDepartmentAdminAsync(UserId, DepartmentId)) return Forbid(); var state = await _maintenance.RequestRebuildAsync(DepartmentId, cancellationToken); @@ -150,12 +153,14 @@ public async Task> Rebuild(CancellationToken c [Authorize(Policy = ResgridResources.Department_Update)] public async Task> Health() { - if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || !await _authorization.IsDepartmentAdminAsync(UserId, DepartmentId)) return Forbid(); var global = await _globalSearch.GetHealthAsync(); var records = await _recordsSearch.GetHealthAsync(); var state = await _states.GetAsync(SearchIndexNames.Global, DepartmentId); + if (state != null && state.DepartmentId != DepartmentId) + state = null; var result = new SearchHealthResult { @@ -163,12 +168,13 @@ public async Task> Health() { Enabled = global.Enabled, GlobalOnline = global.Online, - GlobalDocumentCount = global.DocumentCount, + // Shared-index totals and revisions describe other departments as well; never expose them here. + GlobalDocumentCount = null, RecordsOnline = records.Online, - RecordsDocumentCount = records.DocumentCount, + RecordsDocumentCount = null, StoreEnabled = global.StoreEnabled, - LastSyncedRevision = global.LastSyncedRevision, - LastSyncedOnUtc = global.LastSyncedOnUtc, + LastSyncedRevision = null, + LastSyncedOnUtc = null, DepartmentIndexState = state == null ? "None" : ((SearchIndexBuildState)state.State).ToString(), DepartmentDocumentCount = state?.DocumentCount ?? 0, DepartmentLastRebuiltOn = state?.LastRebuiltOn @@ -192,7 +198,7 @@ private async Task BuildPrincipalAsync() { var user = HttpContext?.User; DepartmentModuleSettings modules = null; - try { modules = await _departmentSettings.GetDepartmentModuleSettingsAsync(DepartmentId); } + try { modules = await _departmentSettings.GetDepartmentModuleSettingsAsync(DepartmentId, true); } catch (Exception ex) { Logging.LogException(ex); } return new SearchPrincipal @@ -203,7 +209,7 @@ private async Task BuildPrincipalAsync() HasClaim = (resource, action) => user != null && user.HasClaim(resource, action), IsModuleEnabled = module => { - if (modules == null) return true; + if (modules == null) return false; switch (module) { case SystemActionModules.Messaging: return !modules.MessagingDisabled; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs new file mode 100644 index 000000000..624c7d40f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; +using Resgrid.Web.Services.Models.v4.Deployments; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file + /// DTRs from the field: a rostered member may create, edit, sign and submit reports and expenses on their own + /// deployments without any new claim; approval and void need TimeReports_Approve. Receipts upload as base64 and + /// are stored as Receipt attachments; DTOs carry UpdatedOn for delta-sync. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class TimeReportsController : V4AuthenticatedApiControllerbase + { + private readonly ITimeTrackingService _timeTracking; + private readonly IDeploymentService _deployments; + private readonly IFeatureToggleService _flags; + + public TimeReportsController(ITimeTrackingService timeTracking, IDeploymentService deployments, IFeatureToggleService flags) + { + _timeTracking = timeTracking; + _deployments = deployments; + _flags = flags; + } + + private Task EnabledAsync() => _flags.IsEnabledAsync(FeatureFlagKeys.Deployments, DepartmentId); + private static bool CanApprove() => ClaimsAuthorizationHelper.CanApproveTimeReports() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private async Task CanTouchAsync(string deploymentId) => DeploymentsController.CanManage() || await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId); + private async Task CanSeeAsync(string deploymentId) => DeploymentsController.CanView() || await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId); + + private string Ip => IpAddressHelper.GetRequestIP(Request, true); + private string Agent => $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + + private ActionResult Failed(string reason, int status = StatusCodes.Status400BadRequest) where T : StandardApiResponseV4Base, new() + { + var failed = new T { PageSize = 0, Status = ResponseHelper.Failure }; + ResponseHelper.PopulateV4ResponseData(failed); + Response.Headers["X-Resgrid-Reason"] = reason; + return StatusCode(status, failed); + } + + private static bool IsDomainError(InvalidOperationException ex) => ex.Message.StartsWith("timereports_", StringComparison.Ordinal) || ex.Message.StartsWith("deployments_", StringComparison.Ordinal) || ex.Message.StartsWith("expenses_", StringComparison.Ordinal); + + #region Reports + + [HttpGet("GetTimeReports")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetTimeReports(string deploymentId) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (!await CanSeeAsync(deploymentId)) return Unauthorized(); + var reports = await _timeTracking.GetTimeReportsAsync(deploymentId, DepartmentId); + var result = new TimeReportsResult { Data = reports.Select(r => Map(r)).ToList(), PageSize = reports.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetTimeReport(string id) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + if (!await CanSeeAsync(report.DeploymentId)) return Unauthorized(); + return Ok(report); + } + + [HttpPost("NewTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> NewTimeReport([FromBody] NewTimeReportInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); + if (!await CanTouchAsync(input.DeploymentId)) return Unauthorized(); + try { return Ok(await _timeTracking.CreateTimeReportAsync(input.DeploymentId, DepartmentId, input.ReportDate, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("UpdateTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateTimeReport([FromBody] UpdateTimeReportInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); + if (report == null) return NotFound(); + if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + report.IncidentNumber = input.IncidentNumber; report.ResourceOrderNumber = input.ResourceOrderNumber; report.RequestNumber = input.RequestNumber; report.CostCode = input.CostCode; report.PointOfHire = input.PointOfHire; + report.NoClear8 = input.NoClear8; report.UnsafeConditionsStandDown = input.UnsafeConditionsStandDown; report.Notes = input.Notes; report.RmsExternalOrderFillId = input.RmsExternalOrderFillId; + try { return Ok(await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + /// Replaces the report's entries as a batch. Validation errors come back with the unchanged report and status Failure. + [HttpPost("SaveTimeEntries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveTimeEntries([FromBody] SaveTimeEntriesInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + var report = await _timeTracking.GetTimeReportByIdAsync(input.TimeReportId, DepartmentId); + if (report == null) return NotFound(); + if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + try + { + var entries = (input.Entries ?? new List()).Select(e => new DeploymentTimeEntry + { + DeploymentPersonnelId = e.DeploymentPersonnelId, DeploymentUnitId = e.DeploymentUnitId, DeploymentEquipmentId = e.DeploymentEquipmentId, EntryType = e.EntryType, StartTime = e.StartTime, EndTime = e.EndTime, + PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, FuelDeductionLitres = e.FuelDeductionLitres, + AgencySuppliedMeals = e.AgencySuppliedMeals, AgencySuppliedAccommodation = e.AgencySuppliedAccommodation, Notes = e.Notes, SortOrder = e.SortOrder + }).ToList(); + return Ok(await _timeTracking.SaveTimeEntriesAsync(input.TimeReportId, DepartmentId, entries, UserId, Ip, Agent, cancellationToken)); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("SubmitTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SubmitTimeReport([FromBody] TimeReportActionInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); + if (report == null) return NotFound(); + if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + try { return Ok(await _timeTracking.SubmitTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("ApproveTimeReport")] + [Authorize(Policy = ResgridResources.TimeReports_Approve)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ApproveTimeReport([FromBody] TimeReportActionInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _timeTracking.ApproveTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("VoidTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> VoidTimeReport([FromBody] TimeReportActionInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + if (!CanApprove()) return Unauthorized(); + try { return Ok(await _timeTracking.VoidTimeReportAsync(input.Id, DepartmentId, input.Reason, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("SignTimeReport")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SignTimeReport([FromBody] SignTimeReportInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); + if (report == null) return NotFound(); + if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + try { return Ok(await _timeTracking.SignTimeReportAsync(input.Id, DepartmentId, input.ContractorSigned, input.CustomerSignerName, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpGet("GetTimeReportPdf")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetTimeReportPdf(string id) + { + if (!await EnabledAsync()) return StatusCode(StatusCodes.Status403Forbidden); + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + if (!await CanSeeAsync(report.DeploymentId)) return Unauthorized(); + try + { + var pdf = await _timeTracking.GetTimeReportPdfAsync(id, DepartmentId); + return File(pdf, "application/pdf", $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return BadRequest(ex.Message); } + } + + #endregion + + #region Expenses + + [HttpGet("GetExpenses")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetExpenses(string deploymentId) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (!await CanSeeAsync(deploymentId)) return Unauthorized(); + var rows = await _timeTracking.GetExpensesAsync(deploymentId, DepartmentId); + var result = new ExpensesResult { Data = rows.Select(MapExpense).ToList(), PageSize = rows.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("SaveExpense")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveExpense([FromBody] SaveExpenseInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); + if (!await CanTouchAsync(input.DeploymentId)) return Unauthorized(); + byte[] receipt = null; + if (!string.IsNullOrWhiteSpace(input.ReceiptData)) + { + try { receipt = Convert.FromBase64String(input.ReceiptData); } + catch (FormatException) { return Failed("expenses_receipt_invalid"); } + if (receipt.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes) return Failed("deployments_attachment_too_large"); + } + try + { + var expense = new DeploymentExpense + { + DeploymentExpenseId = input.Id, DeploymentId = input.DeploymentId, DeploymentTimeReportId = input.TimeReportId, DepartmentId = DepartmentId, ExpenseDate = input.ExpenseDate ?? DateTime.UtcNow.Date, + ExpenseType = input.ExpenseType, MealCode = input.MealCode, City = input.City, Description = input.Description, Amount = input.Amount, Currency = input.Currency, PreApproved = input.PreApproved, Billable = input.Billable + }; + var saved = await _timeTracking.SaveExpenseAsync(expense, receipt, input.ReceiptFileName, input.ReceiptContentType, UserId, Ip, Agent, cancellationToken); + var result = new ExpenseResult { Data = MapExpense(saved), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteExpense")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteExpense(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); + var expense = await _timeTracking.GetExpenseByIdAsync(id, DepartmentId); + if (expense == null) return NotFound(); + if (!await CanTouchAsync(expense.DeploymentId)) return Unauthorized(); + try + { + await _timeTracking.DeleteExpenseAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); + var result = new StandardApiResponseV4Base { PageSize = 0, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + #endregion + + #region Mapping + + private ActionResult Ok(DeploymentTimeReport report) + { + var result = new TimeReportResult { Data = Map(report), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Ok(TimeReportSaveResult save) + { + var result = new TimeReportResult + { + Data = save.Report == null ? null : Map(save.Report), + Errors = save.Validation.Errors.Select(MapIssue).ToList(), + Warnings = save.Validation.Warnings.Select(MapIssue).ToList(), + PageSize = 1, + Status = save.Validation.IsValid ? ResponseHelper.Success : ResponseHelper.Failure + }; + ResponseHelper.PopulateV4ResponseData(result); + if (!save.Validation.IsValid) Response.Headers["X-Resgrid-Reason"] = "timereports_validation"; + return result; + } + + internal static TimeReportData Map(DeploymentTimeReport r) => new TimeReportData + { + Id = r.DeploymentTimeReportId, DeploymentId = r.DeploymentId, ReportNumber = r.ReportNumber, ReportDate = r.ReportDate, Status = r.Status, IncidentNumber = r.IncidentNumber, ResourceOrderNumber = r.ResourceOrderNumber, + RequestNumber = r.RequestNumber, CostCode = r.CostCode, PointOfHire = r.PointOfHire, NoClear8 = r.NoClear8, UnsafeConditionsStandDown = r.UnsafeConditionsStandDown, ContractorSignedByUserId = r.ContractorSignedByUserId, + ContractorSignedOn = r.ContractorSignedOn, CustomerSignerName = ProtectedDataEnvelope.SafeDisplay(r.CustomerSignerName), CustomerSignedOn = r.CustomerSignedOn, SubmittedByUserId = r.SubmittedByUserId, SubmittedOn = r.SubmittedOn, + ApprovedByUserId = r.ApprovedByUserId, ApprovedOn = r.ApprovedOn, InvoiceId = r.InvoiceId, RmsExternalOrderFillId = r.RmsExternalOrderFillId, Notes = r.Notes, IsProtected = r.IsProtected, AddedOn = r.AddedOn, UpdatedOn = r.EditedOn ?? r.AddedOn, + Entries = r.Entries.Select(e => new TimeEntryData + { + Id = e.DeploymentTimeEntryId, SubjectType = e.SubjectType, DeploymentPersonnelId = e.DeploymentPersonnelId, DeploymentUnitId = e.DeploymentUnitId, DeploymentEquipmentId = e.DeploymentEquipmentId, EntryType = e.EntryType, + StartTime = e.StartTime, EndTime = e.EndTime, PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, + FuelDeductionLitres = e.FuelDeductionLitres, AgencySuppliedMeals = e.AgencySuppliedMeals, AgencySuppliedAccommodation = e.AgencySuppliedAccommodation, Notes = e.Notes, SortOrder = e.SortOrder, Hours = e.Hours + }).ToList() + }; + + internal static TimeReportIssueData MapIssue(TimeReportIssue i) => new TimeReportIssueData { Code = i.Code, SubjectId = i.SubjectId, EntryId = i.EntryId, Detail = i.Detail }; + + internal static ExpenseData MapExpense(DeploymentExpense e) => new ExpenseData + { + Id = e.DeploymentExpenseId, DeploymentId = e.DeploymentId, TimeReportId = e.DeploymentTimeReportId, ExpenseDate = e.ExpenseDate, ExpenseType = e.ExpenseType, MealCode = e.MealCode, City = e.City, + Description = ProtectedDataEnvelope.SafeDisplay(e.Description), Amount = e.Amount, Currency = e.Currency, PreApproved = e.PreApproved, Billable = e.Billable, ReceiptAttachmentId = e.ReceiptAttachmentId, IsProtected = e.IsProtected, + AddedOn = e.AddedOn, UpdatedOn = e.EditedOn ?? e.AddedOn + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 069a79cd3..4e2152d81 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -18,6 +18,9 @@ public static class ClaimsAuthorizationHelper public static bool CanManageCertifications() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Update); public static bool CanViewCertifications() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.View); public static bool CanManageCertificationSetup() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Setup); + public static bool CanManageDeployments() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.Update); + public static bool CanViewDeployments() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.View); + public static bool CanApproveTimeReports() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.TimeReports, ResgridClaimTypes.Actions.Approve); public static bool CanManageChecklists() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update); public static bool CanViewChecklistResults() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View); public static ResgridIdentity GetIdentity() diff --git a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs new file mode 100644 index 000000000..98a7806ab --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Deployments +{ + // Workforce & Business Operations plan, Phase C5 (deployment core). Every data row carries UpdatedOn for mobile delta-sync. + // Protected values (ADP catalog 28) read REDACTED; attachment bytes never ride these endpoints except the receipt upload input. + + public class DeploymentAccessResult : StandardApiResponseV4Base + { + public DeploymentAccessData Data { get; set; } + } + + public class DeploymentAccessData + { + /// Operations.Deployments flag (free; no add-on). + public bool Enabled { get; set; } + public bool CanManage { get; set; } + public bool CanApproveTimeReports { get; set; } + /// Invoicing.ContractorBilling entitlement (paid; bids, contracts, charge runs) — informational for the deployment core. + public bool ContractorBilling { get; set; } + } + + public class DeploymentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class DeploymentResult : StandardApiResponseV4Base + { + public DeploymentData Data { get; set; } + } + + public class DeploymentData + { + public string Id { get; set; } + public string Name { get; set; } + /// DeploymentStatuses value. + public int Status { get; set; } + /// DeploymentFinanceModes value. + public int FinanceMode { get; set; } + public int? CallId { get; set; } + public string RmsExternalOrderId { get; set; } + public string ContactId { get; set; } + public string ServiceContractId { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string HomeCountry { get; set; } + public string HostCountry { get; set; } + public string HomeSubdivision { get; set; } + public string HostSubdivision { get; set; } + public string LocalTimeZoneId { get; set; } + public string Currency { get; set; } + public string Notes { get; set; } + public DateTime? StatusChangedOn { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public List Units { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + public List Equipment { get; set; } = new List(); + } + + public class DeploymentUnitData + { + public string Id { get; set; } + public int UnitId { get; set; } + public string UnitName { get; set; } + public string CallSign { get; set; } + public string Notes { get; set; } + public bool IsActive { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? RemovedOn { get; set; } + } + + public class DeploymentPersonnelData + { + public string Id { get; set; } + public string UserId { get; set; } + public string Name { get; set; } + public string DeploymentUnitId { get; set; } + public int? UnitRoleId { get; set; } + public string CertificationCode { get; set; } + public string CallSign { get; set; } + public string RmsExternalOrderFillId { get; set; } + public bool IsActive { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? RemovedOn { get; set; } + } + + public class DeploymentEquipmentData + { + public string Id { get; set; } + public string DeploymentUnitId { get; set; } + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string Name { get; set; } + public string Notes { get; set; } + public DateTime? IssuedOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public bool IsActive { get; set; } + } + + public class SaveDeploymentInput + { + public string Id { get; set; } + public string Name { get; set; } + public int FinanceMode { get; set; } + public int? CallId { get; set; } + public string ContactId { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string HomeCountry { get; set; } + public string HostCountry { get; set; } + public string HomeSubdivision { get; set; } + public string HostSubdivision { get; set; } + public string LocalTimeZoneId { get; set; } + public string Currency { get; set; } + public string Notes { get; set; } + } + + public class CreateFromExternalOrderInput + { + public string RmsExternalOrderId { get; set; } + public int FinanceMode { get; set; } + public bool CreateCall { get; set; } + public string CallType { get; set; } + public int CallPriority { get; set; } + public bool PrefillRoster { get; set; } = true; + public string Name { get; set; } + public string ContactId { get; set; } + public string Notes { get; set; } + } + + public class SetDeploymentStatusInput + { + public string Id { get; set; } + public int Status { get; set; } + } + + public class AddDeploymentUnitInput + { + public string DeploymentId { get; set; } + public int UnitId { get; set; } + public string CallSign { get; set; } + public string Notes { get; set; } + } + + public class AddDeploymentPersonnelInput + { + public string DeploymentId { get; set; } + public string UserId { get; set; } + public string DeploymentUnitId { get; set; } + public int? UnitRoleId { get; set; } + public string CertificationCode { get; set; } + public string CallSign { get; set; } + public string RmsExternalOrderFillId { get; set; } + public bool Force { get; set; } + } + + public class AddDeploymentEquipmentInput + { + public string DeploymentId { get; set; } + public string DeploymentUnitId { get; set; } + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string FreeTextName { get; set; } + public string Notes { get; set; } + public bool IssueFromInventory { get; set; } + public string FromLocationId { get; set; } + } + + public class RosterChangeResult : StandardApiResponseV4Base + { + public RosterChangeData Data { get; set; } + } + + public class RosterChangeData + { + public DeploymentUnitData Unit { get; set; } + public DeploymentPersonnelData Personnel { get; set; } + public DeploymentEquipmentData Equipment { get; set; } + public List Warnings { get; set; } = new List(); + public bool Blocked { get; set; } + } + + public class RosterWarningData + { + public string Code { get; set; } + public string SubjectId { get; set; } + public string Detail { get; set; } + public bool Blocking { get; set; } + } + + public class RosterWarningsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class DeploymentAttachmentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class DeploymentAttachmentData + { + public int Id { get; set; } + public string DeploymentId { get; set; } + /// DeploymentAttachmentTypes value. + public int AttachmentType { get; set; } + public string Name { get; set; } + public string FileName { get; set; } + public string FileType { get; set; } + public int? FileSize { get; set; } + public bool IsProtected { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + } + + public class TimeReportsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class TimeReportResult : StandardApiResponseV4Base + { + public TimeReportData Data { get; set; } + public List Errors { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + } + + public class TimeReportData + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public int ReportNumber { get; set; } + public DateTime ReportDate { get; set; } + /// DeploymentTimeReportStatuses value. + public int Status { get; set; } + public string IncidentNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public bool NoClear8 { get; set; } + public bool UnsafeConditionsStandDown { get; set; } + public string ContractorSignedByUserId { get; set; } + public DateTime? ContractorSignedOn { get; set; } + public string CustomerSignerName { get; set; } + public DateTime? CustomerSignedOn { get; set; } + public string SubmittedByUserId { get; set; } + public DateTime? SubmittedOn { get; set; } + public string ApprovedByUserId { get; set; } + public DateTime? ApprovedOn { get; set; } + public string InvoiceId { get; set; } + public string RmsExternalOrderFillId { get; set; } + public string Notes { get; set; } + public bool IsProtected { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public List Entries { get; set; } = new List(); + } + + public class TimeEntryData + { + public string Id { get; set; } + /// DeploymentTimeSubjectTypes value. + public int SubjectType { get; set; } + public string DeploymentPersonnelId { get; set; } + public string DeploymentUnitId { get; set; } + public string DeploymentEquipmentId { get; set; } + /// DeploymentTimeEntryTypes value. + public int EntryType { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int PaidBreakMinutes { get; set; } + public int UnpaidBreakMinutes { get; set; } + public int? CrewSizeSnapshot { get; set; } + public string CertificationCode { get; set; } + public decimal? MileageKm { get; set; } + public decimal? FuelDeductionLitres { get; set; } + public bool AgencySuppliedMeals { get; set; } + public bool AgencySuppliedAccommodation { get; set; } + public string Notes { get; set; } + public int SortOrder { get; set; } + public decimal Hours { get; set; } + } + + public class TimeReportIssueData + { + public string Code { get; set; } + public string SubjectId { get; set; } + public string EntryId { get; set; } + public string Detail { get; set; } + } + + public class NewTimeReportInput + { + public string DeploymentId { get; set; } + public DateTime ReportDate { get; set; } + } + + public class UpdateTimeReportInput + { + public string Id { get; set; } + public string IncidentNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public bool NoClear8 { get; set; } + public bool UnsafeConditionsStandDown { get; set; } + public string Notes { get; set; } + public string RmsExternalOrderFillId { get; set; } + } + + public class SaveTimeEntriesInput + { + public string TimeReportId { get; set; } + public List Entries { get; set; } = new List(); + } + + public class TimeReportActionInput + { + public string Id { get; set; } + public string Reason { get; set; } + } + + public class SignTimeReportInput + { + public string Id { get; set; } + public bool ContractorSigned { get; set; } + public string CustomerSignerName { get; set; } + } + + public class ExpensesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class ExpenseResult : StandardApiResponseV4Base + { + public ExpenseData Data { get; set; } + } + + public class ExpenseData + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public string TimeReportId { get; set; } + public DateTime ExpenseDate { get; set; } + /// DeploymentExpenseTypes value. + public int ExpenseType { get; set; } + public string MealCode { get; set; } + public string City { get; set; } + public string Description { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public bool PreApproved { get; set; } + public bool Billable { get; set; } + public int? ReceiptAttachmentId { get; set; } + public bool IsProtected { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class SaveExpenseInput + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public string TimeReportId { get; set; } + public DateTime? ExpenseDate { get; set; } + public int ExpenseType { get; set; } + public string MealCode { get; set; } + public string City { get; set; } + public string Description { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public bool PreApproved { get; set; } + public bool Billable { get; set; } = true; + /// Receipt bytes, base64. + public string ReceiptData { get; set; } + public string ReceiptFileName { get; set; } + public string ReceiptContentType { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Health/HealthResult.cs b/Web/Resgrid.Web.Services/Models/v4/Health/HealthResult.cs index b208b2809..6a5c099a3 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Health/HealthResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Health/HealthResult.cs @@ -57,8 +57,8 @@ public class HealthResultData /// A local reader is open for the global index (after a pull or a write). public bool SearchOnline { get; set; } - /// Documents in this process's copy of the global and records indexes. - public int SearchIndexDocCount { get; set; } + /// Suppressed: shared-index counts disclose other departments' data volumes. + public int? SearchIndexDocCount { get; set; } /// /// Department-connected Stripe payment collection is enabled in this process (PaymentConnectConfig.Enabled and diff --git a/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs index 3b135660d..81c64f2c6 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs @@ -77,9 +77,10 @@ public class SearchHealthData { public bool Enabled { get; set; } public bool GlobalOnline { get; set; } - public int GlobalDocumentCount { get; set; } + /// Suppressed: shared-index counts include other departments. + public int? GlobalDocumentCount { get; set; } public bool RecordsOnline { get; set; } - public int RecordsDocumentCount { get; set; } + public int? RecordsDocumentCount { get; set; } public bool StoreEnabled { get; set; } public string LastSyncedRevision { get; set; } public DateTime? LastSyncedOnUtc { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 717738fa6..406299f9d 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -527,6 +527,16 @@ REDACTED without a grant header, and file bytes never ride these endpoints. + + Same ceiling as the MVC upload path (CertificationsController.MaxFileBytes). + + + + Decodes a base64 upload under the MVC size ceiling: the encoded length is checked before the decode so an + oversized payload is refused without the allocation. Throws certifications_file_too_large / FormatException, + both of which the actions already map. + + The caller's records, or another member's with Certifications_View. @@ -1670,6 +1680,20 @@ The dispatch email code (local part of the email address) to look up. DepartmentResult with the matching department, or a not-found response. + + + Deployment finance wrappers, roster and attachments (Workforce & Business Operations plan, Phase C5). The + deployment core is free behind the Operations.Deployments flag. Managers (Deployments_Update) see and edit every + deployment; a rostered member sees the deployments they are seated on. RMS owns the RecordDeployments routes; + this route is Deployments and its DTOs carry RmsExternalOrderId so a client can open the Records deployment. + + + + Whether the deployment core is available to this department and what the caller may do. Always answers. + + + The billing context behind a call (mobile: call → deployment). + Mobile or Tablet Device specific operations @@ -3775,6 +3799,17 @@ Array of CallQuickTemplateResultData for each active call quick template + + + Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file + DTRs from the field: a rostered member may create, edit, sign and submit reports and expenses on their own + deployments without any new claim; approval and void need TimeReports_Approve. Receipts upload as base64 and + are stored as Receipt attachments; DTOs carry UpdatedOn for delta-sync. + + + + Replaces the report's entries as a batch. Validation errors come back with the unchanged report and status Failure. + Operations involving the location of a unit setting the real-time location or getting the latest location for a unit. @@ -11190,6 +11225,36 @@ Department code (short identifier) + + Operations.Deployments flag (free; no add-on). + + + Invoicing.ContractorBilling entitlement (paid; bids, contracts, charge runs) — informational for the deployment core. + + + DeploymentStatuses value. + + + DeploymentFinanceModes value. + + + DeploymentAttachmentTypes value. + + + DeploymentTimeReportStatuses value. + + + DeploymentTimeSubjectTypes value. + + + DeploymentTimeEntryTypes value. + + + DeploymentExpenseTypes value. + + + Receipt bytes, base64. + Object that contains the device specific information needed to register the device for push notifications @@ -11712,7 +11777,7 @@ A local reader is open for the global index (after a pull or a write). - Documents in this process's copy of the global and records indexes. + Suppressed: shared-index counts disclose other departments' data volumes. @@ -13879,6 +13944,9 @@ Relative web path; clients with native screens switch on EntityType/EntityId instead. + + Suppressed: shared-index counts include other departments. + Response Data diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 5000b40ef..2fcd74550 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -328,6 +328,9 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Certifications_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Certifications_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Delete)); options.AddPolicy(ResgridResources.Certifications_Setup, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Setup)); + options.AddPolicy(ResgridResources.Deployments_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Deployments_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.TimeReports_Approve, policy => policy.RequireClaim(ResgridClaimTypes.Resources.TimeReports, ResgridClaimTypes.Actions.Approve)); options.AddPolicy(ResgridResources.Checklist_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.ChecklistResults_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordDefinition_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs new file mode 100644 index 000000000..04b7b1f5f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs @@ -0,0 +1,675 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Deployments; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Deployment finance (Workforce & Business Operations plan, Phase C6, deployment core): the deployment list with + /// "Create from external order", the detail page (roster, equipment, DTRs, expenses, attachments, manifest), the + /// DTR editor and expense entry. Free behind Operations.Deployments; nothing here needs the Business Ops add-on. + /// Managers (Deployments_Update, admins by default) see and edit everything; a rostered member sees the deployments + /// they are seated on and files time and expenses there. Approval and void need TimeReports_Approve. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(32 * 1024 * 1024)] + public sealed class DeploymentsController : SecureBaseController + { + private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "csv", "heic" }; + + private readonly IDeploymentService _deployments; + private readonly ITimeTrackingService _timeTracking; + private readonly IFeatureToggleService _flags; + private readonly IDepartmentsService _departments; + private readonly IUnitsService _units; + private readonly IContactsService _contacts; + private readonly ICallsService _calls; + private readonly IUserProfileService _profiles; + private readonly IRecordDeploymentsService _recordDeployments; + private readonly IStringLocalizer _strings; + + public DeploymentsController(IDeploymentService deployments, ITimeTrackingService timeTracking, IFeatureToggleService flags, IDepartmentsService departments, IUnitsService units, + IContactsService contacts, ICallsService calls, IUserProfileService profiles, IRecordDeploymentsService recordDeployments, + IStringLocalizer strings) + { + _deployments = deployments; + _timeTracking = timeTracking; + _flags = flags; + _departments = departments; + _units = units; + _contacts = contacts; + _calls = calls; + _profiles = profiles; + _recordDeployments = recordDeployments; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageDeployments(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewDeployments(); + private static bool CanApprove => IsAdmin || ClaimsAuthorizationHelper.CanApproveTimeReports(); + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!await _flags.IsEnabledAsync(FeatureFlagKeys.Deployments, DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + ViewData["DepCanView"] = CanView; + ViewData["DepCanManage"] = CanManage; + await next(); + } + + private T Page(T view) where T : DeploymentPageView + { + view.CanView = CanView; + view.CanManage = CanManage; + view.CanApprove = CanApprove; + if (TempData["DeploymentsMessage"] is string message) view.Message = message; + if (TempData["DeploymentsSaved"] is bool saved) view.SaveSuccess = saved; + return view; + } + + private bool IsAjax() => string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase); + + private string ErrorText(string code) + { + var text = _strings[code]; + return text.ResourceNotFound ? _strings["SaveFailed"].Value : text.Value; + } + + private IActionResult Refused(int statusCode, string code, string redirectAction, object routeValues = null) + { + if (IsAjax()) return StatusCode(statusCode, new { message = ErrorText(code), code }); + TempData["DeploymentsMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["DeploymentsSaved"] = true; + return RedirectToAction(redirectAction, routeValues); + } + + private string Ip => IpAddressHelper.GetRequestIP(Request, true); + private string Agent => $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + + private static bool IsDomainError(InvalidOperationException ex) => ex.Message.StartsWith("deployments_", StringComparison.Ordinal) || ex.Message.StartsWith("timereports_", StringComparison.Ordinal) || ex.Message.StartsWith("expenses_", StringComparison.Ordinal); + + private async Task AccessibleAsync(string deploymentId) + { + var deployment = await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId); + if (deployment == null) return null; + if (CanView || deployment.Personnel.Any(p => p.UserId == UserId)) return deployment; + return null; + } + + private async Task> PersonnelNamesAsync() + { + var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + return (names ?? new List()).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Name, StringComparer.OrdinalIgnoreCase); + } + + private async Task<(byte[] Data, string FileName, string FileType, string Error)> ReadUploadAsync(IFormFile file, CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) return (null, null, null, null); + var extension = FileHelper.GetFileExtensionWithoutDot(file.FileName); + if (!AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) return (null, null, null, "deployments_file_type"); + if (file.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes) return (null, null, null, "deployments_attachment_too_large"); + using var stream = file.OpenReadStream(); + var bytes = await FileHelper.ReadAllBytesAsync(stream, cancellationToken); + return (bytes, FileHelper.GetSafeFileName(file.FileName), string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, null); + } + + #endregion + + #region List and edit + + [HttpGet] + public async Task Index(bool all = false, int page = 1) + { + var view = Page(new DeploymentIndexView { OpenOnly = !all, Page = Math.Max(1, page), MineOnly = !CanView }); + if (CanView) + { + view.Total = await _deployments.CountDeploymentsForDepartmentAsync(DepartmentId, view.OpenOnly); + view.Deployments = await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, view.OpenOnly, (view.Page - 1) * view.PageSize, view.PageSize); + } + else + { + view.Deployments = await _deployments.GetDeploymentsForUserAsync(DepartmentId, UserId, view.OpenOnly); + view.Total = view.Deployments.Count; + } + var contactIds = view.Deployments.Where(d => !string.IsNullOrWhiteSpace(d.ContactId)).Select(d => d.ContactId).Distinct().ToList(); + if (contactIds.Count > 0) + { + var contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(); + foreach (var contact in contacts.Where(c => contactIds.Contains(c.ContactId, StringComparer.OrdinalIgnoreCase))) view.ContactNames[contact.ContactId] = contact.Name; + } + return View(view); + } + + [HttpGet] + public async Task New() + { + if (!CanManage) return Unauthorized(); + var view = Page(new DeploymentEditView { Contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List() }); + var department = await _departments.GetDepartmentByIdAsync(DepartmentId); + view.Deployment.LocalTimeZoneId = department?.TimeZone; + return View("Edit", view); + } + + [HttpGet] + public async Task Edit(string id) + { + if (!CanManage) return Unauthorized(); + var deployment = await _deployments.GetDeploymentByIdAsync(id, DepartmentId); + if (deployment == null) return NotFound(); + var view = Page(new DeploymentEditView { Contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(), Deployment = ToInput(deployment) }); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Edit(DeploymentInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + try + { + var deployment = string.IsNullOrWhiteSpace(input.DeploymentId) ? new Deployment { DepartmentId = DepartmentId } : await _deployments.GetDeploymentByIdAsync(input.DeploymentId, DepartmentId); + if (deployment == null) return NotFound(); + Apply(input, deployment); + var saved = await _deployments.SaveDeploymentAsync(deployment, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id = saved.DeploymentId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) + { + var view = Page(new DeploymentEditView { Contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(), Deployment = input }); + view.Message = ErrorText(ex.Message); + return View(view); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SetStatus(string id, int status, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!Enum.IsDefined(typeof(DeploymentStatuses), status)) return BadRequest(); + try + { + await _deployments.SetDeploymentStatusAsync(id, DepartmentId, (DeploymentStatuses)status, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + await _deployments.DeleteDeploymentAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); + return Saved("Index"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id }); } + } + + private static DeploymentInput ToInput(Deployment d) => new DeploymentInput + { + DeploymentId = d.DeploymentId, Name = d.Name, FinanceMode = d.FinanceMode, CallId = d.CallId, ContactId = d.ContactId, IncidentNumber = d.IncidentNumber, ServiceRequestNumber = d.ServiceRequestNumber, + ResourceOrderNumber = d.ResourceOrderNumber, RequestNumber = d.RequestNumber, CostCode = d.CostCode, PointOfHire = d.PointOfHire, StartOn = d.StartOn, EndOn = d.EndOn, MaxDays = d.MaxDays, + OutOfProvince = d.OutOfProvince, TravelViaAir = d.TravelViaAir, HomeCountry = d.HomeCountry, HostCountry = d.HostCountry, HomeSubdivision = d.HomeSubdivision, HostSubdivision = d.HostSubdivision, + LocalTimeZoneId = d.LocalTimeZoneId, Currency = d.Currency, Notes = d.Notes + }; + + private static void Apply(DeploymentInput input, Deployment d) + { + d.Name = input.Name; d.FinanceMode = input.FinanceMode; d.CallId = input.CallId; d.ContactId = string.IsNullOrWhiteSpace(input.ContactId) ? null : input.ContactId; + d.IncidentNumber = input.IncidentNumber; d.ServiceRequestNumber = input.ServiceRequestNumber; d.ResourceOrderNumber = input.ResourceOrderNumber; d.RequestNumber = input.RequestNumber; d.CostCode = input.CostCode; d.PointOfHire = input.PointOfHire; + d.StartOn = input.StartOn.HasValue ? DateTime.SpecifyKind(input.StartOn.Value, DateTimeKind.Utc) : null; d.EndOn = input.EndOn.HasValue ? DateTime.SpecifyKind(input.EndOn.Value, DateTimeKind.Utc) : null; d.MaxDays = input.MaxDays; + d.OutOfProvince = input.OutOfProvince; d.TravelViaAir = input.TravelViaAir; d.HomeCountry = input.HomeCountry; d.HostCountry = input.HostCountry; d.HomeSubdivision = input.HomeSubdivision; d.HostSubdivision = input.HostSubdivision; + d.LocalTimeZoneId = input.LocalTimeZoneId; d.Currency = input.Currency; d.Notes = input.Notes; + } + + #endregion + + #region External orders (decision 39) + + [HttpGet] + public async Task FromExternalOrder() + { + if (!CanManage) return Unauthorized(); + var view = Page(new ExternalOrderPickerView { RecordsEnabled = await _flags.IsEnabledAsync(FeatureFlagKeys.RecordsSystem, DepartmentId) }); + if (view.RecordsEnabled) + { + try { view.Orders = await _recordDeployments.ListAsync(DepartmentId, UserId, false) ?? new List(); } + catch (Exception ex) { Logging.LogException(ex, "External orders could not be listed for the deployment picker."); } + foreach (var order in view.Orders) + if (await _deployments.GetDeploymentByExternalOrderIdAsync(order.RmsExternalOrderId, DepartmentId) != null) view.Linked.Add(order.RmsExternalOrderId); + } + view.Contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(); + view.CallTypes = await _calls.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + view.Priorities = await _calls.GetActiveCallPrioritiesForDepartmentAsync(DepartmentId) ?? new List(); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task FromExternalOrder(string rmsExternalOrderId, int financeMode, bool createCall, string callType, int callPriority, bool prefillRoster, string name, string contactId, string notes, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var saved = await _deployments.CreateFromExternalOrderAsync(DepartmentId, new ExternalOrderDeploymentInput + { + RmsExternalOrderId = rmsExternalOrderId, FinanceMode = Enum.IsDefined(typeof(DeploymentFinanceModes), financeMode) ? (DeploymentFinanceModes)financeMode : DeploymentFinanceModes.OperationalOnly, + CreateCall = createCall, CallType = callType, CallPriority = callPriority, PrefillRoster = prefillRoster, Name = name, ContactId = string.IsNullOrWhiteSpace(contactId) ? null : contactId, Notes = notes + }, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id = saved.DeploymentId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "FromExternalOrder"); } + catch (UnauthorizedAccessException) { return Unauthorized(); } + } + + #endregion + + #region Detail and roster + + [HttpGet] + public async Task View(string id, string tab = "roster") + { + var deployment = await AccessibleAsync(id); + if (deployment == null) return NotFound(); + var view = Page(new DeploymentDetailView { Deployment = deployment, Tab = tab ?? "roster", IsRostered = deployment.Personnel.Any(p => p.UserId == UserId) }); + view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); + view.TimeReports = await _timeTracking.GetTimeReportsAsync(id, DepartmentId); + view.Expenses = await _timeTracking.GetExpensesAsync(id, DepartmentId); + view.Attachments = await _deployments.GetAttachmentsAsync(id, DepartmentId); + view.TotalExpenses = view.Expenses.Sum(e => e.Amount); + view.UserNames = await PersonnelNamesAsync(); + if (deployment.CallId.HasValue) view.Call = await _calls.GetCallByIdAsync(deployment.CallId.Value); + if (!string.IsNullOrWhiteSpace(deployment.ContactId)) view.ContactName = (await _contacts.GetContactByIdAsync(deployment.ContactId))?.Name; + if (!string.IsNullOrWhiteSpace(deployment.RmsExternalOrderId)) + { + try { view.External = await _deployments.GetExternalContextAsync(id, DepartmentId, UserId); } + catch (Exception ex) { Logging.LogException(ex, "External order context could not be read for the deployment page."); } + } + if (CanManage) + { + view.Units = (await _units.GetUnitsForDepartmentUnlimitedAsync(DepartmentId) ?? new List()).OrderBy(u => u.Name).ToList(); + view.Personnel = (await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(p => p.LastName).ThenBy(p => p.FirstName).ToList(); + foreach (var unit in deployment.Units.Where(u => u.IsActive)) + { + try { view.UnitRoles[unit.UnitId] = await _units.GetRolesForUnitAsync(unit.UnitId) ?? new List(); } + catch (Exception ex) { Logging.LogException(ex, "Unit roles could not be read for the deployment roster."); } + } + } + foreach (var report in view.TimeReports.Where(r => r.Status != (int)DeploymentTimeReportStatuses.Void)) + { + var full = await _timeTracking.GetTimeReportByIdAsync(report.DeploymentTimeReportId, DepartmentId); + if (full != null) view.TotalHours += full.Entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Personnel).Sum(e => e.Hours); + } + if (TempData["DeploymentsWarnings"] is string warnings) + view.Warnings = warnings.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(w => w.Split('|')).Where(p => p.Length >= 2).Select(p => new DeploymentRosterWarning { Code = p[0], SubjectId = p[1], Detail = p.Length > 2 ? p[2] : null }).ToList(); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddUnit(string id, int unitId, string callSign, string notes, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var result = await _deployments.AddUnitAsync(id, DepartmentId, unitId, callSign, notes, UserId, Ip, Agent, cancellationToken); + RememberWarnings(result.Warnings); + return Saved("View", new { id, tab = "roster" }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "roster" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task RemoveUnit(string id, string deploymentUnitId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try { await _deployments.RemoveUnitAsync(deploymentUnitId, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", new { id, tab = "roster" }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "roster" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddPersonnel(string id, string userId, string deploymentUnitId, int? unitRoleId, string certificationCode, string callSign, bool force, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var result = await _deployments.AddPersonnelAsync(id, DepartmentId, new DeploymentPersonnelInput + { + UserId = userId, DeploymentUnitId = string.IsNullOrWhiteSpace(deploymentUnitId) ? null : deploymentUnitId, UnitRoleId = unitRoleId, CertificationCode = certificationCode, CallSign = callSign, Force = force + }, UserId, Ip, Agent, cancellationToken); + RememberWarnings(result.Warnings); + if (result.Personnel == null) + { + TempData["DeploymentsMessage"] = _strings["SeatBlocked"].Value; + return RedirectToAction("View", new { id, tab = "roster" }); + } + return Saved("View", new { id, tab = "roster" }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "roster" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task RemovePersonnel(string id, string deploymentPersonnelId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try { await _deployments.RemovePersonnelAsync(deploymentPersonnelId, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", new { id, tab = "roster" }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "roster" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task AddEquipment(string id, string deploymentUnitId, string freeTextName, string inventoryAssetId, string notes, bool issueFromInventory, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var result = await _deployments.AddEquipmentAsync(id, DepartmentId, new DeploymentEquipmentInput + { + DeploymentUnitId = string.IsNullOrWhiteSpace(deploymentUnitId) ? null : deploymentUnitId, FreeTextName = freeTextName, InventoryAssetId = inventoryAssetId, Notes = notes, IssueFromInventory = issueFromInventory + }, UserId, Ip, Agent, cancellationToken); + RememberWarnings(result.Warnings); + return Saved("View", new { id, tab = "roster" }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "roster" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task ReturnEquipment(string id, string deploymentEquipmentId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + await _deployments.ReturnEquipmentAsync(deploymentEquipmentId, DepartmentId, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id, tab = "roster" }); + } + + private void RememberWarnings(List warnings) + { + if (warnings == null || warnings.Count == 0) return; + TempData["DeploymentsWarnings"] = string.Join("\n", warnings.Select(w => $"{w.Code}|{w.SubjectId}|{w.Detail}")); + } + + #endregion + + #region Attachments and manifest + + [HttpPost, ValidateAntiForgeryToken] + public async Task UploadAttachment(string id, int attachmentType, string name, IFormFile file, CancellationToken cancellationToken) + { + var deployment = await AccessibleAsync(id); + if (deployment == null) return NotFound(); + if (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + var upload = await ReadUploadAsync(file, cancellationToken); + if (upload.Error != null) return Refused(400, upload.Error, "View", new { id, tab = "files" }); + if (upload.Data == null) return Refused(400, "deployments_attachment_empty", "View", new { id, tab = "files" }); + try + { + await _deployments.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = id, DepartmentId = DepartmentId, AttachmentType = attachmentType, Name = name, FileName = upload.FileName, FileType = upload.FileType, Data = upload.Data }, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id, tab = "files" }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "files" }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task DeleteAttachment(string id, int deploymentAttachmentId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + await _deployments.DeleteAttachmentAsync(deploymentAttachmentId, DepartmentId, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id, tab = "files" }); + } + + [HttpGet] + public async Task GetAttachment(int id) + { + var attachment = await _deployments.GetAttachmentAsync(id, DepartmentId, true); + if (attachment == null) return NotFound(); + if (await AccessibleAsync(attachment.DeploymentId) == null) return Unauthorized(); + if (attachment.Data == null || attachment.Data.Length == 0 || ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName)) return NotFound(); + var contentType = string.IsNullOrWhiteSpace(attachment.FileType) ? FileHelper.GetContentTypeByExtension(System.IO.Path.GetExtension(attachment.FileName ?? string.Empty)) ?? "application/octet-stream" : attachment.FileType; + return new FileContentResult(attachment.Data, contentType) { FileDownloadName = attachment.FileName ?? $"attachment-{attachment.DeploymentAttachmentId}" }; + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task GenerateManifest(string id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try { await _deployments.GenerateManifestAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", new { id, tab = "files" }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "files" }); } + } + + [HttpGet] + public async Task Manifest(string id) + { + if (await AccessibleAsync(id) == null) return NotFound(); + try { return Content(await _deployments.RenderManifestHtmlAsync(id, DepartmentId), "text/html", Encoding.UTF8); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return NotFound(); } + } + + [HttpGet] + public async Task ExportTimeEntries(string id) + { + if (await AccessibleAsync(id) == null) return NotFound(); + var csv = await _timeTracking.ExportTimeEntriesCsvAsync(id, DepartmentId); + return File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray(), "text/csv", $"deployment-time-{id}.csv"); + } + + #endregion + + #region Time reports + + [HttpPost, ValidateAntiForgeryToken] + public async Task NewTimeReport(string id, DateTime reportDate, CancellationToken cancellationToken) + { + var deployment = await AccessibleAsync(id); + if (deployment == null) return NotFound(); + if (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + try + { + var report = await _timeTracking.CreateTimeReportAsync(id, DepartmentId, reportDate, UserId, Ip, Agent, cancellationToken); + return RedirectToAction("TimeReport", new { id = report.DeploymentTimeReportId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "time" }); } + } + + [HttpGet] + public async Task TimeReport(string id) + { + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + var deployment = await AccessibleAsync(report.DeploymentId); + if (deployment == null) return Unauthorized(); + var view = await BuildTimeReportViewAsync(report, deployment); + if (TempData["TimeReportValidation"] is string issues) + foreach (var issue in issues.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(i => i.Split('|'))) + (issue[0] == "E" ? view.Validation.Errors : view.Validation.Warnings).Add(new TimeReportIssue { Code = issue.Length > 1 ? issue[1] : null, SubjectId = issue.Length > 2 ? issue[2] : null, Detail = issue.Length > 3 ? issue[3] : null }); + return View(view); + } + + private async Task BuildTimeReportViewAsync(DeploymentTimeReport report, Deployment deployment) + { + var view = Page(new TimeReportEditView { Report = report, Deployment = deployment, IsRostered = deployment.Personnel.Any(p => p.UserId == UserId) }); + view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); + view.TimeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? view.Department?.TimeZone : deployment.LocalTimeZoneId; + foreach (var p in deployment.Personnel) { view.SubjectNames[p.DeploymentPersonnelId] = p.DisplayName ?? p.UserId; if (p.IsActive) view.Subjects.Add((p.DeploymentPersonnelId, (int)DeploymentTimeSubjectTypes.Personnel, p.DisplayName ?? p.UserId)); } + foreach (var u in deployment.Units) { view.SubjectNames[u.DeploymentUnitId] = u.UnitName ?? u.UnitId.ToString(); if (u.IsActive) view.Subjects.Add((u.DeploymentUnitId, (int)DeploymentTimeSubjectTypes.Unit, u.UnitName ?? u.UnitId.ToString())); } + foreach (var e in deployment.Equipment) { var n = e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId; view.SubjectNames[e.DeploymentEquipmentId] = n; if (e.IsActive) view.Subjects.Add((e.DeploymentEquipmentId, (int)DeploymentTimeSubjectTypes.Equipment, n)); } + view.Expenses = (await _timeTracking.GetExpensesAsync(deployment.DeploymentId, DepartmentId)).Where(e => e.DeploymentTimeReportId == report.DeploymentTimeReportId).ToList(); + if (!string.IsNullOrWhiteSpace(report.ContractorSignedByUserId)) view.ContractorSignerName = (await _profiles.GetProfileByUserIdAsync(report.ContractorSignedByUserId))?.FullName.AsFirstNameLastName; + return view; + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveTimeReport(string id, string incidentNumber, string resourceOrderNumber, string requestNumber, string costCode, string pointOfHire, bool noClear8, bool unsafeConditionsStandDown, string notes, List entries, string action, CancellationToken cancellationToken) + { + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + var deployment = await AccessibleAsync(report.DeploymentId); + if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + var timeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? (await _departments.GetDepartmentByIdAsync(DepartmentId))?.TimeZone : deployment.LocalTimeZoneId; + try + { + report.IncidentNumber = incidentNumber; report.ResourceOrderNumber = resourceOrderNumber; report.RequestNumber = requestNumber; report.CostCode = costCode; report.PointOfHire = pointOfHire; + report.NoClear8 = noClear8; report.UnsafeConditionsStandDown = unsafeConditionsStandDown; report.Notes = notes; + await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken); + + var rows = (entries ?? new List()).Where(e => e != null && !string.IsNullOrWhiteSpace(e.SubjectId) && !string.IsNullOrWhiteSpace(e.Start) && !string.IsNullOrWhiteSpace(e.End)).ToList(); + var mapped = new List(); + var sort = 0; + foreach (var row in rows) + { + if (!TryParseLocal(report.ReportDate, row.Start, timeZone, out var start) || !TryParseLocal(report.ReportDate, row.End, timeZone, out var end)) return Refused(400, "timereports_time_invalid", "TimeReport", new { id }); + if (end <= start) end = end.AddDays(1); + var entry = new DeploymentTimeEntry + { + EntryType = row.EntryType, StartTime = start, EndTime = end, PaidBreakMinutes = row.PaidBreakMinutes, UnpaidBreakMinutes = row.UnpaidBreakMinutes, MileageKm = row.MileageKm, FuelDeductionLitres = row.FuelDeductionLitres, + AgencySuppliedMeals = row.AgencySuppliedMeals, AgencySuppliedAccommodation = row.AgencySuppliedAccommodation, CertificationCode = row.CertificationCode, Notes = row.Notes, SortOrder = sort++ + }; + if (deployment.Personnel.Any(p => p.DeploymentPersonnelId == row.SubjectId)) entry.DeploymentPersonnelId = row.SubjectId; + else if (deployment.Units.Any(u => u.DeploymentUnitId == row.SubjectId)) entry.DeploymentUnitId = row.SubjectId; + else entry.DeploymentEquipmentId = row.SubjectId; + mapped.Add(entry); + } + var result = await _timeTracking.SaveTimeEntriesAsync(id, DepartmentId, mapped, UserId, Ip, Agent, cancellationToken); + if (!result.Validation.IsValid) { RememberIssues(result.Validation); return RedirectToAction("TimeReport", new { id }); } + + if (string.Equals(action, "submit", StringComparison.OrdinalIgnoreCase)) + { + var submit = await _timeTracking.SubmitTimeReportAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); + RememberIssues(submit.Validation); + if (!submit.Validation.IsValid) return RedirectToAction("TimeReport", new { id }); + } + else RememberIssues(result.Validation); + return Saved("TimeReport", new { id }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } + } + + private void RememberIssues(TimeReportValidation validation) + { + if (validation.Errors.Count == 0 && validation.Warnings.Count == 0) return; + TempData["TimeReportValidation"] = string.Join("\n", validation.Errors.Select(i => $"E|{i.Code}|{i.SubjectId}|{i.Detail}").Concat(validation.Warnings.Select(i => $"W|{i.Code}|{i.SubjectId}|{i.Detail}"))); + } + + private static bool TryParseLocal(DateTime reportDate, string time, string timeZone, out DateTime utc) + { + utc = default; + // "HH:mm" on the report date, or a full local "yyyy-MM-ddTHH:mm". + DateTime local; + if (DateTime.TryParseExact(time, new[] { "yyyy-MM-ddTHH:mm", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-dd HH:mm" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var full)) local = full; + else if (TimeSpan.TryParseExact(time, new[] { "hh\\:mm", "h\\:mm" }, CultureInfo.InvariantCulture, out var span)) local = reportDate.Date.Add(span); + else return false; + try { utc = string.IsNullOrWhiteSpace(timeZone) ? DateTime.SpecifyKind(local, DateTimeKind.Utc) : DateTimeHelpers.ConvertToUtc(local, timeZone, true); } + catch { utc = DateTime.SpecifyKind(local, DateTimeKind.Utc); } + return true; + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task ApproveTimeReport(string id, CancellationToken cancellationToken) + { + if (!CanApprove) return Unauthorized(); + try { await _timeTracking.ApproveTimeReportAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task VoidTimeReport(string id, string reason, CancellationToken cancellationToken) + { + if (!CanApprove) return Unauthorized(); + try { await _timeTracking.VoidTimeReportAsync(id, DepartmentId, reason, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SignTimeReport(string id, bool contractorSigned, string customerSignerName, CancellationToken cancellationToken) + { + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + var deployment = await AccessibleAsync(report.DeploymentId); + if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + try { await _timeTracking.SignTimeReportAsync(id, DepartmentId, contractorSigned, customerSignerName, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } + } + + [HttpGet] + public async Task TimeReportPdf(string id) + { + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + if (await AccessibleAsync(report.DeploymentId) == null) return Unauthorized(); + var pdf = await _timeTracking.GetTimeReportPdfAsync(id, DepartmentId); + return File(pdf, "application/pdf", $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf"); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task FileTimeReportPdf(string id, CancellationToken cancellationToken) + { + var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); + if (report == null) return NotFound(); + var deployment = await AccessibleAsync(report.DeploymentId); + if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + try { await _timeTracking.GenerateTimeReportPdfAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } + } + + #endregion + + #region Expenses + + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveExpense(ExpenseInput input, IFormFile receipt, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); + var deployment = await AccessibleAsync(input.DeploymentId); + if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + var back = string.IsNullOrWhiteSpace(input.DeploymentTimeReportId) ? ("View", (object)new { id = input.DeploymentId, tab = "expenses" }) : ("TimeReport", new { id = input.DeploymentTimeReportId }); + var upload = await ReadUploadAsync(receipt, cancellationToken); + if (upload.Error != null) return Refused(400, upload.Error, back.Item1, back.Item2); + try + { + await _timeTracking.SaveExpenseAsync(new DeploymentExpense + { + DeploymentExpenseId = string.IsNullOrWhiteSpace(input.DeploymentExpenseId) ? null : input.DeploymentExpenseId, DeploymentId = input.DeploymentId, DeploymentTimeReportId = string.IsNullOrWhiteSpace(input.DeploymentTimeReportId) ? null : input.DeploymentTimeReportId, + DepartmentId = DepartmentId, ExpenseDate = input.ExpenseDate ?? DateTime.UtcNow.Date, ExpenseType = input.ExpenseType, MealCode = input.MealCode, City = input.City, Description = input.Description, Amount = input.Amount, + Currency = input.Currency, PreApproved = input.PreApproved, Billable = input.Billable + }, upload.Data, upload.FileName, upload.FileType, UserId, Ip, Agent, cancellationToken); + return Saved(back.Item1, back.Item2); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, back.Item1, back.Item2); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task DeleteExpense(string id, string deploymentExpenseId, CancellationToken cancellationToken) + { + var deployment = await AccessibleAsync(id); + if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + try { await _timeTracking.DeleteExpenseAsync(deploymentExpenseId, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", new { id, tab = "expenses" }); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "expenses" }); } + } + + #endregion + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index d32fb49be..9eb8ff823 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -2077,26 +2077,18 @@ public async Task EditRole(EditRoleModel model, IFormCollection c if (warned.Count > 0) TempData["RoleMembersWarning"] = string.Format(_certificationLocalizer["RoleMembersWarned"].Value, string.Join(", ", await PersonnelDisplayNamesAsync(warned))); - //using (var scope = new TransactionScope()) - //{ - await _personnelRolesService.DeleteRoleUsersAsync(role.Users.ToList(), cancellationToken); - - if (incomingUsers.Any()) + try { - foreach (var user in incomingUsers) - { - PersonnelRoleUser pru = new PersonnelRoleUser(); - string userId = user; - pru.UserId = userId; - - role.Users.Add(pru); - } + // One transaction for the name/description and the membership swap: a refused member (the service's own + // gate re-runs against live data) leaves the previous membership untouched. + await _personnelRolesService.ReplaceRoleMembersAsync(role, incomingUsers, cancellationToken, UserId); + } + catch (InvalidOperationException ex) when (ex.Message == "certifications_role_requirements_unmet") + { + ModelState.AddModelError("Role.Users", string.Format(_certificationLocalizer["RoleMembersBlocked"].Value, string.Join(", ", await PersonnelDisplayNamesAsync(incomingUsers.Where(u => !currentUsers.Contains(u)))))); + model.Users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); + return View(model); } - - // scope.Complete(); - //} - - await _personnelRolesService.SaveRoleAsync(role, cancellationToken, UserId); //_userProfileService.ClearUserProfileFromCache(model.UserId); _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 189a0ed12..dc3f629a0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -853,7 +853,7 @@ public async Task AddCertification(string userId) var model = new AddCertificationView(); model.UserId = userToGet; - var types = await _certificationService.GetAllCertificationTypesByDepartmentAsync(DepartmentId); + var types = await PersonCertificationTypesAsync(); model.CertificationTypes = new SelectList(types, "Type", "Type"); return View(model); @@ -972,10 +972,17 @@ public async Task EditCertification(EditCertificationView model, cert.IssuedBy = model.IssuedBy; cert.ExpiresOn = model.ExpiresOn; cert.RecievedOn = model.RecievedOn; - cert.Type = model.Type; - // Phase D: re-link the catalog type from the picked name; an untyped legacy record stays untyped when the name has no match. + // Phase D: re-link the catalog type from the picked name. An unchanged name keeps its link (the type may have + // been retired since); a name the picker no longer offers drops the stale link rather than saving a record + // whose name and catalog type disagree; an untyped legacy record stays untyped. + var pickedType = (model.Type ?? string.Empty).Trim(); var personTypes = await PersonCertificationTypesAsync(); - cert.DepartmentCertificationTypeId = personTypes.FirstOrDefault(t => t.Type == (model.Type ?? string.Empty).Trim())?.DepartmentCertificationTypeId ?? cert.DepartmentCertificationTypeId; + var pickedCatalogType = personTypes.FirstOrDefault(t => t.Type == pickedType); + if (pickedCatalogType != null) + cert.DepartmentCertificationTypeId = pickedCatalogType.DepartmentCertificationTypeId; + else if (!string.Equals((cert.Type ?? string.Empty).Trim(), pickedType, StringComparison.Ordinal)) + cert.DepartmentCertificationTypeId = null; + cert.Type = model.Type; if (fileToUpload != null && fileToUpload.Length > 0) { @@ -1034,8 +1041,11 @@ await _protectedReadService.ResolveCertificationsForReadAsync(DepartmentId, model.RecievedOn = cert.RecievedOn; model.Type = cert.Type; - var types = await _certificationService.GetAllCertificationTypesByDepartmentAsync(DepartmentId); - model.CertificationTypes = new SelectList(types, "Type", "Type"); + // The record's own type stays selectable even when the catalog has since retired it. + var typeNames = (await PersonCertificationTypesAsync()).Select(t => t.Type).ToList(); + if (!string.IsNullOrWhiteSpace(cert.Type) && !typeNames.Contains(cert.Type)) + typeNames.Insert(0, cert.Type); + model.CertificationTypes = new SelectList(typeNames, cert.Type); return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 50b70fce2..55f2d6c5d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -245,17 +245,33 @@ public async Task CertificationsReport() [Authorize(Policy = ResgridResources.Reports_View)] public async Task CertificationComplianceReport() { - return View(await CreateCertificationComplianceReportModel(DepartmentId)); + return View(await CreateCertificationComplianceReportModel(DepartmentId, applyPersonVisibility: true)); } - private async Task CreateCertificationComplianceReportModel(int departmentId) + /// + /// The interactive report applies the personnel visibility matrix like the sibling personnel reports; the scheduled + /// InternalRunReport path has no caller identity and renders the department-wide matrix. + /// + private async Task CreateCertificationComplianceReportModel(int departmentId, bool applyPersonVisibility) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var dashboard = await _certificationService.GetExpiryDashboardAsync(departmentId); + if (applyPersonVisibility) + { + var visible = new List(); + foreach (var cell in dashboard.PersonCells) + { + if (await _authorizationService.CanUserViewPersonViaMatrixAsync(cell.SubjectId, UserId, departmentId)) + visible.Add(cell); + } + dashboard.PersonCells = visible; + } + return new Resgrid.Web.Areas.User.Models.Certifications.CertificationComplianceReportView { Department = department, RunOn = DateTime.UtcNow.TimeConverter(department), - Dashboard = await _certificationService.GetExpiryDashboardAsync(departmentId), + Dashboard = dashboard, Settings = await _certificationService.GetCertificationSettingsAsync(departmentId) }; } @@ -607,7 +623,7 @@ public async Task InternalRunReport(int type, int departmentId) } else if (((ReportTypes)type) == ReportTypes.CertificationCompliance) { - return View("CertificationComplianceReport", await CreateCertificationComplianceReportModel(departmentId)); + return View("CertificationComplianceReport", await CreateCertificationComplianceReportModel(departmentId, applyPersonVisibility: false)); } return new EmptyResult(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs index fd98e30a6..bc7ede9de 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs @@ -21,6 +21,7 @@ namespace Resgrid.Web.Areas.User.Controllers /// the entity's own authorization rule before it is returned. /// [Area("User")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public class SearchController : SecureBaseController { private readonly IUnifiedSearchService _unifiedSearch; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index a5ba71b66..2898580ef 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -482,7 +482,7 @@ SelectList AdpOptions(bool includeEveryone) // Rows come from RecordPermissionCatalog so this screen, ClaimsLogic.AddRecordClaims and the // activation-time row migration share one set of no-row defaults. A missing row preselects that // default, which for the Logs-parity types equals today's CreateLog/DeleteLog fall-through. - model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InvoicingPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, CertificationPermissionCatalog.All)).ToList(); + model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InvoicingPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, CertificationPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, DeploymentPermissionCatalog.All)).ToList(); var recordsState = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); model.RecordsFlagEnabled = recordsState != null && recordsState.FlagEnabled; model.RecordsActivated = recordsState != null && recordsState.RecordsUsable; diff --git a/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs new file mode 100644 index 000000000..5c0f1a07b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Web.Areas.User.Models.Deployments +{ + // Workforce & Business Operations plan, Phase C6 (MVC, deployment core). + + public class DeploymentPageView + { + public bool CanView { get; set; } + public bool CanManage { get; set; } + public bool CanApprove { get; set; } + public string Message { get; set; } + public bool SaveSuccess { get; set; } + } + + public class DeploymentIndexView : DeploymentPageView + { + public List Deployments { get; set; } = new List(); + public bool OpenOnly { get; set; } = true; + public int Total { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public bool MineOnly { get; set; } + public Dictionary ContactNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary TimeReportCounts { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public class DeploymentEditView : DeploymentPageView + { + public DeploymentInput Deployment { get; set; } = new DeploymentInput(); + public List Contacts { get; set; } = new List(); + public bool IsNew => string.IsNullOrWhiteSpace(Deployment.DeploymentId); + } + + public class DeploymentInput + { + public string DeploymentId { get; set; } + public string Name { get; set; } + public int FinanceMode { get; set; } + public int? CallId { get; set; } + public string ContactId { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + public string ResourceOrderNumber { get; set; } + public string RequestNumber { get; set; } + public string CostCode { get; set; } + public string PointOfHire { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string HomeCountry { get; set; } + public string HostCountry { get; set; } + public string HomeSubdivision { get; set; } + public string HostSubdivision { get; set; } + public string LocalTimeZoneId { get; set; } + public string Currency { get; set; } + public string Notes { get; set; } + } + + public class ExternalOrderPickerView : DeploymentPageView + { + public List Orders { get; set; } = new List(); + /// Order ids that already have a finance wrapper. + public HashSet Linked { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public List Contacts { get; set; } = new List(); + public List CallTypes { get; set; } = new List(); + public List Priorities { get; set; } = new List(); + public bool RecordsEnabled { get; set; } + } + + public class DeploymentDetailView : DeploymentPageView + { + public Deployment Deployment { get; set; } + public Department Department { get; set; } + public Call Call { get; set; } + public string ContactName { get; set; } + public DeploymentExternalContext External { get; set; } + public List TimeReports { get; set; } = new List(); + public List Expenses { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + public Dictionary> UnitRoles { get; set; } = new Dictionary>(); + public Dictionary UserNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public List Warnings { get; set; } = new List(); + public bool IsRostered { get; set; } + public bool CanEditTime => CanManage || IsRostered; + public string Tab { get; set; } = "roster"; + public decimal TotalHours { get; set; } + public decimal TotalExpenses { get; set; } + } + + public class TimeReportEditView : DeploymentPageView + { + public DeploymentTimeReport Report { get; set; } + public Deployment Deployment { get; set; } + public Department Department { get; set; } + public string TimeZone { get; set; } + public Dictionary SubjectNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public List<(string Id, int Type, string Name)> Subjects { get; set; } = new List<(string Id, int Type, string Name)>(); + public List Expenses { get; set; } = new List(); + public TimeReportValidation Validation { get; set; } = new TimeReportValidation(); + public bool IsRostered { get; set; } + public bool CanEdit => (CanManage || IsRostered) && Report != null && Report.IsEditable; + public string ContractorSignerName { get; set; } + } + + public class TimeEntryInput + { + public string SubjectId { get; set; } + public int EntryType { get; set; } + public string Start { get; set; } + public string End { get; set; } + public int PaidBreakMinutes { get; set; } + public int UnpaidBreakMinutes { get; set; } + public decimal? MileageKm { get; set; } + public decimal? FuelDeductionLitres { get; set; } + public bool AgencySuppliedMeals { get; set; } + public bool AgencySuppliedAccommodation { get; set; } + public string CertificationCode { get; set; } + public string Notes { get; set; } + } + + public class ExpenseInput + { + public string DeploymentExpenseId { get; set; } + public string DeploymentId { get; set; } + public string DeploymentTimeReportId { get; set; } + public DateTime? ExpenseDate { get; set; } + public int ExpenseType { get; set; } + public string MealCode { get; set; } + public string City { get; set; } + public string Description { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public bool PreApproved { get; set; } + public bool Billable { get; set; } = true; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/RoleRequirements.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/RoleRequirements.cshtml index d904b8176..98e5b822d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/RoleRequirements.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/RoleRequirements.cshtml @@ -48,9 +48,9 @@ } - + - + @@ -74,9 +74,9 @@ } - + - + diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Settings.cshtml index 8e9ee49bb..4f8c666e7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Settings.cshtml @@ -38,7 +38,7 @@
-
+
@@ -50,8 +50,8 @@
-
-
+
+
@localizer["NotificationsHint"] @localizer["NotificationSettings"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml index 368ef9ddd..fe7621b7d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml @@ -78,7 +78,7 @@ @foreach (var t in templates) { var exists = Model.ExistingCodes.Contains(t.Code); - + @if (!exists) { } else { } @t.Code @t.Name diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml new file mode 100644 index 000000000..2401f21b4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml @@ -0,0 +1,81 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.DeploymentEditView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + (Model.IsNew ? localizer["NewDeployment"] : localizer["EditDeployment"]); + ViewData["Title"] = (Model.IsNew ? localizer["NewDeployment"] : localizer["EditDeployment"]).Value; + ViewData["Subtitle"] = localizer["EditIntro"].Value; + var d = Model.Deployment; +} + +@await Html.PartialAsync("_Shell") + +
+ @await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) +
+ @Html.AntiForgeryToken() + +
+
+
+
@localizer["Deployment"]
+
+
+
+ + @localizer["FinanceModeHelp"] +
+
+ +
+
@localizer["CallIdHelp"]
+
+
+
+
+
+
@localizer["AgencyIdentifiers"]
+
+
+
+
+
+
+
+
+
+
+
@localizer["Jurisdiction"]
+
+
+
+
+
+
@localizer["TimeZoneHelp"]
+
+
+
+
+
+
+
+
+
+ + @localizer["Cancel"] +
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/FromExternalOrder.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/FromExternalOrder.cshtml new file mode 100644 index 000000000..3b3df3dcf --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/FromExternalOrder.cshtml @@ -0,0 +1,97 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.ExternalOrderPickerView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CreateFromExternalOrder"]; + ViewData["Title"] = localizer["CreateFromExternalOrder"].Value; + ViewData["Subtitle"] = localizer["ExternalOrderIntro"].Value; + var available = Model.Orders.Where(o => !Model.Linked.Contains(o.RmsExternalOrderId)).ToList(); +} + +@await Html.PartialAsync("_Shell") + +
+ @await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) +
+
@localizer["OpenExternalOrders"]
+
+ @if (!Model.RecordsEnabled) + { +
@localizer["RecordsRequired"]
+ } + else if (available.Count == 0) + { +

@localizer["NoOpenExternalOrders"]

+ } + else + { +
+ @Html.AntiForgeryToken() +
+ +
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ + @localizer["Cancel"] +
+
+ } + @if (Model.Linked.Count > 0) + { +

@string.Format(localizer["OrdersAlreadyLinked"].Value, Model.Linked.Count)

+ } +
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/Index.cshtml new file mode 100644 index 000000000..2f935da9a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/Index.cshtml @@ -0,0 +1,64 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.DeploymentIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Deployments"]; + ViewData["Title"] = localizer["Deployments"].Value; + ViewData["Subtitle"] = localizer["IndexIntro"].Value; + var pages = Math.Max(1, (int)Math.Ceiling(Model.Total / (double)Model.PageSize)); +} + +@await Html.PartialAsync("_Shell") + +
+ @await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) +
+
+
@(Model.MineOnly ? localizer["MyDeployments"] : localizer["Deployments"])
+ +
+
+
@localizer["HelpIndex"]
+ @if (Model.Deployments.Count == 0) + { +

@localizer["NoDeployments"]

+ } + else + { + + + + @foreach (var d in Model.Deployments) + { + + + + + + + + + + + } + +
@localizer["Name"]@localizer["Status"]@localizer["FinanceMode"]@localizer["IncidentNumber"]@localizer["ResourceOrderNumber"]@localizer["Contact"]@localizer["Window"]
@d.Name @if (!string.IsNullOrWhiteSpace(d.RmsExternalOrderId)) { }@await Html.PartialAsync("_StatusBadge", d.Status)@localizer["Finance" + (Resgrid.Model.Invoicing.DeploymentFinanceModes)d.FinanceMode]@d.IncidentNumber@d.ResourceOrderNumber@(string.IsNullOrWhiteSpace(d.ContactId) ? "" : Model.ContactNames.TryGetValue(d.ContactId, out var contact) ? contact : "")@(d.StartOn.HasValue ? d.StartOn.Value.ToString("yyyy-MM-dd") : "—") → @(d.EndOn.HasValue ? d.EndOn.Value.ToString("yyyy-MM-dd") : "—") @localizer["Open"]
+ @if (!Model.MineOnly && pages > 1) + { +
    + @for (var p = 1; p <= pages; p++) + { +
  • @p
  • + } +
+ } + } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml new file mode 100644 index 000000000..04c894562 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -0,0 +1,207 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.TimeReportEditView +@inject IStringLocalizer localizer +@{ + var r = Model.Report; + var d = Model.Deployment; + ViewBag.Title = "Resgrid | " + string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); + ViewData["Title"] = string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); + ViewData["Subtitle"] = $"{d.Name} · {r.ReportDate:yyyy-MM-dd}"; + string LocalTime(DateTime utc) { try { return string.IsNullOrWhiteSpace(Model.TimeZone) ? utc.ToString("HH:mm") : Resgrid.Framework.DateTimeHelpers.GetLocalDateTime(utc, Model.TimeZone).ToString("HH:mm"); } catch { return utc.ToString("HH:mm"); } } + string LocalStamp(DateTime? utc) => utc.HasValue ? (Model.Department == null ? utc.Value.ToString("yyyy-MM-dd HH:mm") : Resgrid.Model.Helpers.TimeConverterHelper.TimeConverter(utc.Value, Model.Department).ToString("yyyy-MM-dd HH:mm")) : "—"; + string SubjectName(string id) => id != null && Model.SubjectNames.TryGetValue(id, out var n) ? n : id; + var entries = r.Entries.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime).ToList(); + var status = (Resgrid.Model.Invoicing.DeploymentTimeReportStatuses)r.Status; + var canSubmit = Model.CanEdit && status == Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Draft; + var canApprove = Model.CanApprove && status == Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Submitted; + var canVoid = Model.CanApprove && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed; +} + +@await Html.PartialAsync("_Shell") + +
+ @await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) + @foreach (var issue in Model.Validation.Errors) + { +
@localizer["Issue" + issue.Code] @(string.IsNullOrWhiteSpace(issue.SubjectId) ? "" : "— " + SubjectName(issue.SubjectId))
+ } + @foreach (var issue in Model.Validation.Warnings) + { +
@localizer["Issue" + issue.Code] @(string.IsNullOrWhiteSpace(issue.SubjectId) ? "" : "— " + SubjectName(issue.SubjectId)) @(string.IsNullOrWhiteSpace(issue.Detail) ? "" : $"({issue.Detail})")
+ } + +
+ @Html.AntiForgeryToken() +
+
+
+
+
@localizer["TimeReport"] #@r.ReportNumber @await Html.PartialAsync("_ReportStatusBadge", r.Status)
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +

@localizer["TimeEntries"] @localizer["TimesLocal"] (@Model.TimeZone)

+ + + + @for (var i = 0; i < entries.Count; i++) + { + var e = entries[i]; + + + + + + + + + + + + } + +
@localizer["Subject"]@localizer["EntryType"]@localizer["Start"]@localizer["End"]@localizer["PaidBreak"]@localizer["UnpaidBreak"]@localizer["Km"]@localizer["Notes"]
@if (Model.CanEdit) { } @e.Hours.ToString("0.00")h
+ @if (Model.CanEdit) + { + + } +
+ + +
+
+
+
+
+
+
@localizer["Actions"]
+
+ @if (Model.CanEdit) + { + + @if (canSubmit) + { + + } + } +
+
@localizer["Submitted"]
@LocalStamp(r.SubmittedOn)
+
@localizer["Approved"]
@LocalStamp(r.ApprovedOn)
+ @if (!string.IsNullOrWhiteSpace(r.InvoiceId)) {
@localizer["Invoice"]
@localizer["OpenInvoice"]
} +
+
+
+
+
+
+ +
+
+
+
@localizer["Signatures"]
+
+
+
@localizer["ContractorSignature"]
@(r.ContractorSignedOn.HasValue ? $"{Model.ContractorSignerName} · {LocalStamp(r.ContractorSignedOn)}" : "—")
+
@localizer["CustomerSignature"]
@(r.CustomerSignedOn.HasValue ? $"{Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.CustomerSignerName)} · {LocalStamp(r.CustomerSignedOn)}" : "—")
+
+ @if ((Model.CanManage || Model.IsRostered) && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+
+
@localizer["Expenses"]
+
+ @if (Model.Expenses.Count == 0) {

@localizer["NoExpenses"]

} + else + { + + + + @foreach (var e in Model.Expenses) + { + + } + +
@localizer["Type"]@localizer["Description"]@localizer["Amount"]
@localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(e.Description)@e.Amount.ToString("N2") @e.Currency@if (e.ReceiptAttachmentId.HasValue) { }
+ } + @if ((Model.CanManage || Model.IsRostered) && r.IsEditable) + { + @await Html.PartialAsync("_ExpenseForm", (d.DeploymentId, r.DeploymentTimeReportId, d.Currency)) + } +
+
+
+
+ @if (canApprove || canVoid || Model.CanManage || Model.IsRostered) + { +
+
@localizer["Review"]
+
+ @if (canApprove) + { +
@Html.AntiForgeryToken()
+ } + @if (canVoid) + { +
@Html.AntiForgeryToken()
+ } +
@Html.AntiForgeryToken()
+
+
+ } +
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml new file mode 100644 index 000000000..d01a73469 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -0,0 +1,340 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.DeploymentDetailView +@inject IStringLocalizer localizer +@{ + var d = Model.Deployment; + ViewBag.Title = "Resgrid | " + d.Name; + ViewData["Title"] = d.Name; + ViewData["Subtitle"] = localizer["Finance" + (Resgrid.Model.Invoicing.DeploymentFinanceModes)d.FinanceMode].Value; + var status = (Resgrid.Model.Invoicing.DeploymentStatuses)d.Status; + var activeUnits = d.Units.Where(u => u.IsActive).ToList(); + var activePeople = d.Personnel.Where(p => p.IsActive).ToList(); + var activeEquipment = d.Equipment.Where(e => e.IsActive).ToList(); + string Local(DateTime? value) => value.HasValue ? (Model.Department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") : Resgrid.Model.Helpers.TimeConverterHelper.TimeConverter(value.Value, Model.Department).ToString("yyyy-MM-dd HH:mm")) : "—"; + string UserName(string userId) => string.IsNullOrWhiteSpace(userId) ? "" : Model.UserNames.TryGetValue(userId, out var n) ? n : userId; + string UnitName(string deploymentUnitId) { var u = d.Units.FirstOrDefault(x => x.DeploymentUnitId == deploymentUnitId); return u == null ? "" : (u.UnitName ?? u.UnitId.ToString()); } + var next = new List(); + foreach (Resgrid.Model.Invoicing.DeploymentStatuses candidate in Enum.GetValues(typeof(Resgrid.Model.Invoicing.DeploymentStatuses))) + { + if (Resgrid.Services.Invoicing.DeploymentService.IsValidTransition(status, candidate)) next.Add(candidate); + } +} + +@await Html.PartialAsync("_Shell") + +
+ @await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) + @foreach (var warning in Model.Warnings) + { +
@localizer["Warning" + warning.Code] @(string.IsNullOrWhiteSpace(warning.Detail) ? "" : "— " + warning.Detail)
+ } +
+
+
+
+
@localizer["Deployment"] @await Html.PartialAsync("_StatusBadge", d.Status)
+
+ @if (Model.CanManage) + { + @localizer["Edit"] + } + @localizer["ExportCsv"] +
+
+
+
+
@localizer["IncidentNumber"]
@(d.IncidentNumber ?? "—")
+
@localizer["ResourceOrderNumber"]
@(d.ResourceOrderNumber ?? "—") @(string.IsNullOrWhiteSpace(d.RequestNumber) ? "" : $"/ {d.RequestNumber}")
+
@localizer["CostCode"]
@(d.CostCode ?? "—")
+
@localizer["PointOfHire"]
@(d.PointOfHire ?? "—")
+
@localizer["Window"]
@Local(d.StartOn) → @Local(d.EndOn) @(d.MaxDays.HasValue ? $"({d.MaxDays} {localizer["Days"]})" : "")
+
@localizer["Contact"]
@(Model.ContactName ?? "—")
+
@localizer["Call"]
@if (Model.Call != null) { @Model.Call.Number @Model.Call.Name } else { }
+ @if (!string.IsNullOrWhiteSpace(d.RmsExternalOrderId)) + { +
@localizer["ExternalOrder"]
+
+ @if (Model.External?.Order != null) + { + @Model.External.Order.OrderNumber — @Model.External.Order.IncidentName + · @string.Format(localizer["FillCount"].Value, Model.External.Fills.Count) + } + else + { + @d.RmsExternalOrderId + } +
+ } +
@localizer["Jurisdiction"]
@(d.HomeCountry ?? "—")@(string.IsNullOrWhiteSpace(d.HomeSubdivision) ? "" : "-" + d.HomeSubdivision) → @(d.HostCountry ?? "—")@(string.IsNullOrWhiteSpace(d.HostSubdivision) ? "" : "-" + d.HostSubdivision) @(d.OutOfProvince ? "· " + localizer["OutOfProvince"] : "") @(d.TravelViaAir ? "· " + localizer["TravelViaAir"] : "")
+
@localizer["Currency"]
@(d.Currency ?? "—") · @(d.LocalTimeZoneId ?? Model.Department?.TimeZone)
+ @if (!string.IsNullOrWhiteSpace(d.Notes)) {
@localizer["Notes"]
@d.Notes
} +
+
+
+
+
+
+
@localizer["Summary"]
+
+
+

@activeUnits.Count

@localizer["Units"]
+

@activePeople.Count

@localizer["Personnel"]
+

@Model.TimeReports.Count(r => r.Status != (int)Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void)

@localizer["TimeReports"]
+
+
+
+

@Model.TotalHours.ToString("0.0")

@localizer["PersonnelHours"]
+

@Model.TotalExpenses.ToString("N2") @d.Currency

@localizer["Expenses"]
+
+ @if (Model.CanManage && next.Count > 0) + { +
+
+ @Html.AntiForgeryToken() + + +
+ } +
+
+
+
+ +
+
+ + + @if (Model.Tab == "roster") + { +
+
+

@localizer["Units"]

+ @if (activeUnits.Count == 0) {

@localizer["NoUnits"]

} + else + { + + + + @foreach (var u in activeUnits) + { + + + + + } + +
@localizer["Unit"]@localizer["CallSign"]@localizer["Crew"]
@(u.UnitName ?? u.UnitId.ToString())@u.CallSign@activePeople.Count(p => p.DeploymentUnitId == u.DeploymentUnitId)@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
+ } + @if (Model.CanManage && d.IsOpen) + { +
+ @Html.AntiForgeryToken() + + + +
+ } + +

@localizer["Equipment"]

+ @if (activeEquipment.Count == 0) {

@localizer["NoEquipment"]

} + else + { + + + + @foreach (var e in activeEquipment) + { + + + + + } + +
@localizer["Item"]@localizer["Unit"]@localizer["Issued"]
@(e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId)@UnitName(e.DeploymentUnitId)@Local(e.IssuedOn)@if (Model.CanManage) {
@Html.AntiForgeryToken()
}
+ } + @if (Model.CanManage && d.IsOpen) + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+

@localizer["Personnel"]

+ @if (activePeople.Count == 0) {

@localizer["NoPersonnel"]

} + else + { + + + + @foreach (var p in activePeople.OrderBy(p => p.DeploymentUnitId ?? "~").ThenBy(p => p.DisplayName)) + { + var seat = p.UnitRoleId.HasValue ? Model.UnitRoles.Values.SelectMany(r => r).FirstOrDefault(r => r.UnitRoleId == p.UnitRoleId.Value)?.Name : null; + + + + + } + +
@localizer["Name"]@localizer["Unit"]@localizer["Seat"]@localizer["Certification"]
@(p.DisplayName ?? UserName(p.UserId))@UnitName(p.DeploymentUnitId)@seat@p.CertificationCode@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
+ } + @if (Model.CanManage && d.IsOpen) + { +
+ @Html.AntiForgeryToken() +
+ +
+
+ +
+
+ + @localizer["SeatHelp"] +
+
+
+
+ +
+
+ } +
+
+ } + else if (Model.Tab == "time") + { + @if (Model.CanEditTime && d.IsOpen) + { +
+ @Html.AntiForgeryToken() + + + @localizer["NewTimeReportHelp"] +
+ } + @if (Model.TimeReports.Count == 0) {

@localizer["NoTimeReports"]

} + else + { + + + + @foreach (var r in Model.TimeReports.OrderByDescending(r => r.ReportDate)) + { + + + + + + + } + +
#@localizer["ReportDate"]@localizer["Status"]@localizer["Submitted"]@localizer["Approved"]@localizer["Invoice"]
@r.ReportNumber@r.ReportDate.ToString("yyyy-MM-dd")@await Html.PartialAsync("_ReportStatusBadge", r.Status)@(r.SubmittedOn.HasValue ? $"{UserName(r.SubmittedByUserId)} · {Local(r.SubmittedOn)}" : "—")@(r.ApprovedOn.HasValue ? $"{UserName(r.ApprovedByUserId)} · {Local(r.ApprovedOn)}" : "—")@if (!string.IsNullOrWhiteSpace(r.InvoiceId)) { } @localizer["Open"]
+ } + } + else if (Model.Tab == "expenses") + { + @if (Model.Expenses.Count == 0) {

@localizer["NoExpenses"]

} + else + { + + + + @foreach (var e in Model.Expenses) + { + + + + + + + + } + + +
@localizer["ExpenseDate"]@localizer["Type"]@localizer["Description"]@localizer["City"]@localizer["Amount"]@localizer["Receipt"]@localizer["TimeReport"]
@e.ExpenseDate.ToString("yyyy-MM-dd")@localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(e.Description) @(e.PreApproved ? "· " + localizer["PreApproved"] : "") @(e.Billable ? "" : "· " + localizer["NotBillable"])@e.City@e.Amount.ToString("N2") @e.Currency@if (e.ReceiptAttachmentId.HasValue) { }@if (!string.IsNullOrWhiteSpace(e.DeploymentTimeReportId)) { var r = Model.TimeReports.FirstOrDefault(x => x.DeploymentTimeReportId == e.DeploymentTimeReportId); #@(r?.ReportNumber) }@if (Model.CanEditTime) {
@Html.AntiForgeryToken()
}
@localizer["Total"]@Model.TotalExpenses.ToString("N2") @d.Currency
+ } + @if (Model.CanEditTime) + { +

@localizer["AddExpense"]

+ @await Html.PartialAsync("_ExpenseForm", (d.DeploymentId, (string)null, d.Currency)) + } + } + else + { + @if (Model.Attachments.Count == 0) {

@localizer["NoFiles"]

} + else + { + + + + @foreach (var a in Model.Attachments) + { + + + + + } + +
@localizer["Name"]@localizer["Type"]@localizer["FileName"]@localizer["Size"]@localizer["Added"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(a.Name) @if (a.IsProtected) { }@localizer["Attachment" + (Resgrid.Model.Invoicing.DeploymentAttachmentTypes)a.AttachmentType]@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(a.FileName)@((a.FileSize ?? 0) / 1024) KB@Local(a.AddedOn) · @UserName(a.AddedByUserId) @if (Model.CanManage) {
@Html.AntiForgeryToken()
}
+ } +
+
+ @if (Model.CanEditTime) + { +
+ @Html.AntiForgeryToken() + + + + +
+ } +
+
+ @localizer["PreviewManifest"] + @if (Model.CanManage) + { +
@Html.AntiForgeryToken()
+ } +
+
+ } +
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml new file mode 100644 index 000000000..1aeb66852 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ExpenseForm.cshtml @@ -0,0 +1,30 @@ +@model (string DeploymentId, string TimeReportId, string Currency) +@inject IStringLocalizer localizer +
+ @Html.AntiForgeryToken() + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_Message.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_Message.cshtml new file mode 100644 index 000000000..92548015b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_Message.cshtml @@ -0,0 +1,10 @@ +@model Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView +@inject IStringLocalizer localizer +@if (Model.SaveSuccess) +{ +
@localizer["Saved"]
+} +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ +
@Model.Message
+} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_ReportStatusBadge.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ReportStatusBadge.cshtml new file mode 100644 index 000000000..a56f38be1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_ReportStatusBadge.cshtml @@ -0,0 +1,14 @@ +@model int +@inject IStringLocalizer localizer +@{ + var status = (Resgrid.Model.Invoicing.DeploymentTimeReportStatuses)Model; + var css = status switch + { + Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Draft => "label-default", + Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Submitted => "label-info", + Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Approved => "label-primary", + Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed => "label-success", + _ => "label-danger" + }; +} +@localizer["ReportStatus" + status] diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_Shell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_Shell.cshtml new file mode 100644 index 000000000..007c41473 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_Shell.cshtml @@ -0,0 +1,31 @@ +@inject IStringLocalizer localizer +@* + Page heading shared by every deployment screen (Workforce & Business Operations plan, Phase C6). + ViewData["Title"] heading + last crumb (defaults to the module name); ViewData["Subtitle"] optional line under it. +*@ +@{ + var title = (string)ViewData["Title"] ?? localizer["Deployments"].Value; + var subtitle = (string)ViewData["Subtitle"]; + var isRoot = string.Equals(title, localizer["Deployments"].Value, StringComparison.Ordinal); +} +
+
+

@title

+ + @if (!string.IsNullOrEmpty(subtitle)) + { + @subtitle + } +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/_StatusBadge.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/_StatusBadge.cshtml new file mode 100644 index 000000000..c335698dd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/_StatusBadge.cshtml @@ -0,0 +1,15 @@ +@model int +@inject IStringLocalizer localizer +@{ + var status = (Resgrid.Model.Invoicing.DeploymentStatuses)Model; + var css = status switch + { + Resgrid.Model.Invoicing.DeploymentStatuses.Planned => "label-default", + Resgrid.Model.Invoicing.DeploymentStatuses.Standby => "label-info", + Resgrid.Model.Invoicing.DeploymentStatuses.Active => "label-primary", + Resgrid.Model.Invoicing.DeploymentStatuses.Demobilizing => "label-warning", + Resgrid.Model.Invoicing.DeploymentStatuses.Completed => "label-success", + _ => "label-danger" + }; +} +@localizer["Status" + status] diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml index 28065fcde..e64f6c3fd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml @@ -322,8 +322,13 @@ var input = document.getElementById('payUrl'); if (!input) return; input.select(); - try { navigator.clipboard.writeText(input.value); toastr.success('@localizer["LinkCopied"]'); } - catch (e) { document.execCommand('copy'); } + var copied = function () { toastr.success('@localizer["LinkCopied"]'); }; + var fallback = function () { try { if (document.execCommand('copy')) copied(); } catch (e) { } }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(input.value).then(copied, fallback); + } else { + fallback(); + } }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml index 7459fd914..61787e945 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml @@ -55,6 +55,10 @@
+ @if (TempData["RoleMembersWarning"] is string roleWarning) + { +
@roleWarning
+ }
@{ diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index f59a8a96d..190b0a490 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -5,6 +5,7 @@ @inject IStringLocalizer workOrderLocalizer @inject IStringLocalizer invoicingLocalizer @inject IStringLocalizer certificationLocalizer +@inject IStringLocalizer deploymentLocalizer @inject IStringLocalizer checklistLocalizer @inject IStringLocalizer twoFactorLocalizer @{ @@ -26,6 +27,8 @@ case PermissionTypes.ManageCertifications: case PermissionTypes.ViewCertifications: case PermissionTypes.ManageCertificationSetup: return certificationLocalizer[row.Type.ToString()].Value; + case PermissionTypes.ManageDeployments: + case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type.ToString()].Value; case PermissionTypes.ManageInvoicing: return invoicingLocalizer["ManageInvoicing"].Value; case PermissionTypes.ViewInvoicing: return invoicingLocalizer["ViewInvoicing"].Value; case PermissionTypes.ManageWorkOrders: return workOrderLocalizer["ManageWorkOrders"].Value; @@ -43,6 +46,8 @@ case PermissionTypes.ManageCertifications: case PermissionTypes.ViewCertifications: case PermissionTypes.ManageCertificationSetup: return certificationLocalizer[row.Type + "Note"].Value; + case PermissionTypes.ManageDeployments: + case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type + "Note"].Value; case PermissionTypes.ManageInvoicing: return invoicingLocalizer["ManageInvoicingNote"].Value; case PermissionTypes.ViewInvoicing: return invoicingLocalizer["ViewInvoicingNote"].Value; case PermissionTypes.ManageWorkOrders: return workOrderLocalizer["ManageWorkOrdersNote"].Value; @@ -499,6 +504,7 @@ if (row.Type == PermissionTypes.ManageChecklists) { @checklistLocalizer["Checklists"] } if (row.Type == PermissionTypes.ManageInvoicing) { @invoicingLocalizer["Invoicing"] } if (row.Type == PermissionTypes.ManageCertifications) { @certificationLocalizer["Certifications"] } + if (row.Type == PermissionTypes.ManageDeployments) { @deploymentLocalizer["Deployments"] } @RecordsPermissionLabel(row) @RecordsPermissionNote(row) diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index 9aa174910..c45a0861b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -6,6 +6,7 @@ @inject IStringLocalizer recordsLocalizer @inject IStringLocalizer invoicingLocalizer @inject IStringLocalizer certificationLocalizer +@inject IStringLocalizer deploymentLocalizer @{ // Chat.System flag gates every chat surface (chat, assistant, moderation). When it is off // the nav items are hidden entirely; the API 404s the endpoints regardless. @@ -83,6 +84,13 @@ @invoicingLocalizer["Invoicing"] } + @if (await featureToggleService.IsEnabledAsync(FeatureFlagKeys.Deployments, ClaimsAuthorizationHelper.GetDepartmentId())) + { + @* Workforce & Business Operations plan, Phase C (deployment core, free): labelled "Deployment Finance" so it is not confused with Records → Deployments (risk 40). Members see only the deployments they are rostered on. *@ +
  • + @deploymentLocalizer["DeploymentFinance"] +
  • + } @if (SettingsHelper.IsMappingEnabled()) {
  • diff --git a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml index b0995f217..62347f85a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -3,6 +3,7 @@ @inject IStringLocalizer invoicingStrings @inject IStringLocalizer checklistStrings @inject IStringLocalizer certificationStrings +@inject IStringLocalizer deploymentStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @{ @@ -14,7 +15,8 @@ .Cast() .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) - .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Invoicing.InvoiceWorkflowPayload.IsInvoice((int)e) ? invoicingStrings[e.ToString()].Value : Resgrid.Model.Certifications.CertificationWorkflowTriggers.IsCertification((int)e) ? certificationStrings[e.ToString()].Value : Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory((int)e) ? inventoryStrings[e.ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder((int)e) ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) + .Where(e => !Resgrid.Model.Invoicing.DeploymentWorkflowPayload.IsReserved((int)e)) + .Select(e => new SelectListItem { Value = ((int)e).ToString(), Text = Resgrid.Model.Invoicing.InvoiceWorkflowPayload.IsInvoice((int)e) ? invoicingStrings[e.ToString()].Value : Resgrid.Model.Invoicing.DeploymentWorkflowPayload.IsDeployment((int)e) ? deploymentStrings[e.ToString()].Value : Resgrid.Model.Certifications.CertificationWorkflowTriggers.IsCertification((int)e) ? certificationStrings[e.ToString()].Value : Resgrid.Model.Inventories.InventoryWorkflowPayload.IsInventory((int)e) ? inventoryStrings[e.ToString()].Value : Resgrid.Model.WorkOrders.WorkOrderWorkflowPayload.IsWorkOrder((int)e) ? workOrderStrings[e.ToString()].Value : Resgrid.Model.Checklists.ChecklistWorkflowPayload.IsChecklist((int)e) ? checklistStrings[e.ToString()].Value : e.ToString() }) .ToList(); var noEventTypesAvailable = !eventTypes.Any(); } diff --git a/Web/Resgrid.Web/Controllers/PayController.cs b/Web/Resgrid.Web/Controllers/PayController.cs index 36169a355..72517c720 100644 --- a/Web/Resgrid.Web/Controllers/PayController.cs +++ b/Web/Resgrid.Web/Controllers/PayController.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -8,6 +10,7 @@ using Microsoft.Extensions.Localization; using Resgrid.Config; using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Helpers; @@ -18,7 +21,9 @@ namespace Resgrid.Web.Controllers /// name, the invoice number, the amount due and the due date; nothing else about the customer or the invoice. /// "Pay" opens the hosted provider page server-side and redirects; the return and cancel pages confirm nothing /// and never mark anything paid — only the verified webhook does. No-store, noindex (the layout), rate-limited - /// per IP by PaymentConnectConfig.PayPageRateLimitPerMinute. A token never grants access to the invoice itself. + /// per IP by PaymentConnectConfig.PayPageRateLimitPerMinute on a counter shared by every web process (the cache + /// provider, as the password-recovery and SSO limits are), with the in-process limiter only while that counter is + /// unavailable. A token never grants access to the invoice itself. ///
  • [AllowAnonymous] [Route("pay")] @@ -26,21 +31,24 @@ namespace Resgrid.Web.Controllers [Filters.AllowDuringDepartmentLock] public class PayController : Controller { + private const string RateLimitCachePrefix = "payments:pay-page:rate:"; private static readonly PayPageRateLimiter Limiter = new PayPageRateLimiter(); private readonly IInvoicePaymentsService _payments; + private readonly ICacheProvider _cacheProvider; private readonly IStringLocalizer _strings; - public PayController(IInvoicePaymentsService payments, IStringLocalizer strings) + public PayController(IInvoicePaymentsService payments, ICacheProvider cacheProvider, IStringLocalizer strings) { _payments = payments; + _cacheProvider = cacheProvider; _strings = strings; } [HttpGet("{token}")] public async Task Index(string token) { - if (Throttled()) return TooMany(); + if (await ThrottledAsync()) return TooMany(); var model = await _payments.GetPayPageModelAsync(token); ViewData["PayToken"] = token; return View("Index", model); @@ -50,7 +58,7 @@ public async Task Index(string token) [ValidateAntiForgeryToken] public async Task Start(string token, CancellationToken cancellationToken) { - if (Throttled()) return TooMany(); + if (await ThrottledAsync()) return TooMany(); var model = await _payments.GetPayPageModelAsync(token); ViewData["PayToken"] = token; if (!model.Available) @@ -76,7 +84,7 @@ public async Task Start(string token, CancellationToken cancellat [HttpGet("{token}/return")] public async Task Return(string token) { - if (Throttled()) return TooMany(); + if (await ThrottledAsync()) return TooMany(); var model = await _payments.GetPayPageModelAsync(token); ViewData["PayToken"] = token; return View("Return", model); @@ -85,18 +93,31 @@ public async Task Return(string token) [HttpGet("{token}/cancel")] public async Task Cancel(string token) { - if (Throttled()) return TooMany(); + if (await ThrottledAsync()) return TooMany(); var model = await _payments.GetPayPageModelAsync(token); ViewData["PayToken"] = token; return View("Cancel", model); } - private bool Throttled() + private async Task ThrottledAsync() { var ip = IpAddressHelper.GetRequestIP(Request, true) ?? "unknown"; - return !Limiter.Allow(ip, Math.Max(1, PaymentConnectConfig.PayPageRateLimitPerMinute), DateTime.UtcNow); + var perMinute = Math.Max(1, PaymentConnectConfig.PayPageRateLimitPerMinute); + var now = DateTime.UtcNow; + + // Fixed one-minute window keyed by a hash of the address (no raw addresses in cache keys); the key expires + // with the window. IncrementAsync returns 0 only when the cache is disabled or unreachable, and then the + // process-local limiter still bounds this instance rather than failing the pay page closed. + var window = now.Ticks / TimeSpan.TicksPerMinute; + var count = await _cacheProvider.IncrementAsync($"{RateLimitCachePrefix}{Hash(ip)}:{window}", TimeSpan.FromMinutes(2)); + if (count > 0) + return count > perMinute; + return !Limiter.Allow(ip, perMinute, now); } + private static string Hash(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty))).ToLowerInvariant(); + private IActionResult TooMany() { Response.Headers["Retry-After"] = "60"; @@ -104,7 +125,7 @@ private IActionResult TooMany() } } - /// In-process sliding-window limiter for the anonymous pay page: N requests per IP per minute. Small, self-pruning, no external state. + /// In-process sliding-window limiter for the anonymous pay page: N requests per IP per minute. Small, self-pruning; the fallback while the shared counter is unavailable. public sealed class PayPageRateLimiter { private readonly ConcurrentDictionary> _hits = new ConcurrentDictionary>(); diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 34ee481c5..6e0d46e8a 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -320,6 +320,9 @@ public static bool CanDeleteContacts() public static bool CanManageCertifications() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Update); public static bool CanViewCertifications() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.View); public static bool CanManageCertificationSetup() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Setup); + public static bool CanManageDeployments() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.Update); + public static bool CanViewDeployments() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.View); + public static bool CanApproveTimeReports() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.TimeReports, ResgridClaimTypes.Actions.Approve); public static bool CanManageChecklists() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update); public static bool CanViewChecklistResults() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View); diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index a393373cd..95779e6bf 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -265,6 +265,9 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy(ResgridResources.Certifications_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.Certifications_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Delete)); options.AddPolicy(ResgridResources.Certifications_Setup, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Certifications, ResgridClaimTypes.Actions.Setup)); + options.AddPolicy(ResgridResources.Deployments_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Deployments_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Deployments, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.TimeReports_Approve, policy => policy.RequireClaim(ResgridClaimTypes.Resources.TimeReports, ResgridClaimTypes.Actions.Approve)); options.AddPolicy(ResgridResources.Checklist_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update)); options.AddPolicy(ResgridResources.ChecklistResults_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ChecklistResults, ResgridClaimTypes.Actions.View)); options.AddPolicy(ResgridResources.RecordDefinition_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.RecordDefinition, ResgridClaimTypes.Actions.Update)); diff --git a/Web/Resgrid.Web/Views/Pay/Cancel.cshtml b/Web/Resgrid.Web/Views/Pay/Cancel.cshtml index ad9420c4c..e6d343b1c 100644 --- a/Web/Resgrid.Web/Views/Pay/Cancel.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Cancel.cshtml @@ -13,6 +13,6 @@

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — #" + Model.InvoiceNumber : "")

    }

    @localizer["PayCancelText"]

    -

    @localizer["BackToInvoice"]

    +

    @localizer["BackToInvoice"]

    diff --git a/Web/Resgrid.Web/Views/Pay/Index.cshtml b/Web/Resgrid.Web/Views/Pay/Index.cshtml index d89cdbd99..a9434debf 100644 --- a/Web/Resgrid.Web/Views/Pay/Index.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Index.cshtml @@ -28,7 +28,7 @@ @if (Model.Available) { -
    + @Html.AntiForgeryToken()
    diff --git a/Web/Resgrid.Web/Views/Pay/Return.cshtml b/Web/Resgrid.Web/Views/Pay/Return.cshtml index 5762d390c..c530471cf 100644 --- a/Web/Resgrid.Web/Views/Pay/Return.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Return.cshtml @@ -13,6 +13,6 @@

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — #" + Model.InvoiceNumber : "")

    }

    @localizer["PayReturnText"]

    -

    @localizer["BackToInvoice"]

    +

    @localizer["BackToInvoice"]

    diff --git a/Workers/Resgrid.Workers.Framework/Logic/CertificationExpiryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CertificationExpiryLogic.cs index 7c2a9b989..8bfa60c42 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CertificationExpiryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CertificationExpiryLogic.cs @@ -9,10 +9,12 @@ namespace Resgrid.Workers.Framework.Logic { /// /// Worker 34 (Workforce & Business Operations plan D5), hourly tick. Each department runs its nightly sweep on the - /// tick where its local hour equals CertificationConfig.SweepLocalHour, so "today", the lead days and the grace - /// deadlines are all department-local dates and every department sees exactly one sweep per local day. Only - /// departments with typed personnel records, unit records or role requirements are visited; one department's - /// failure never stops the others. + /// first tick at or after CertificationConfig.SweepLocalHour in its own time zone, once per local day: the day is + /// claimed in the department's settings row before the sweep runs, so a tick that lands late (drift past the hour), + /// an hour that repeats or vanishes on a daylight-saving change and an overlapping tick all see one sweep, and + /// "today", the lead days and the grace deadlines are all department-local dates. A failed sweep gives the claim + /// back so the next tick of the same day retries it. Only departments with typed personnel records, unit records or + /// role requirements are visited; one department's failure never stops the others. /// public sealed class CertificationExpiryLogic { @@ -38,13 +40,22 @@ public async Task> Process(CancellationToken ct) var department = await departments.GetDepartmentByIdAsync(departmentId, false); if (department == null) { skipped++; continue; } var local = nowUtc.TimeConverter(department); - if (local.Hour != targetHour) { skipped++; continue; } + if (local.Hour < targetHour) { skipped++; continue; } + if (!await certifications.TryClaimSweepDayAsync(departmentId, local.Date, ct)) { skipped++; continue; } - var result = await certifications.RunExpirySweepAsync(departmentId, local.Date, ct); - swept++; - expired += result.Expired + result.UnitsExpired; - expiring += result.ExpiringNotified + result.UnitsExpiringNotified; - removed += result.Removed; + try + { + var result = await certifications.RunExpirySweepAsync(departmentId, local.Date, ct); + swept++; + expired += result.Expired + result.UnitsExpired; + expiring += result.ExpiringNotified + result.UnitsExpiringNotified; + removed += result.Removed; + } + catch + { + await certifications.ReleaseSweepDayAsync(departmentId, local.Date, CancellationToken.None); + throw; + } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex)