From 8e04ee79e7a053a1e5b9b457559028c45dd2d08e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 19 Sep 2026 18:52:30 -0700 Subject: [PATCH] RG-T51 Contractor Billing, Certifications and Deployments --- Core/Resgrid.Config/DataProtectionConfig.cs | 8 +- .../Certifications/Certifications.ar.resx | 6 + .../Certifications/Certifications.de.resx | 6 + .../Certifications/Certifications.el.resx | 6 + .../Certifications/Certifications.en.resx | 6 + .../Certifications/Certifications.es.resx | 6 + .../Certifications/Certifications.fr.resx | 6 + .../Certifications/Certifications.it.resx | 6 + .../Certifications/Certifications.pl.resx | 6 + .../User/Certifications/Certifications.resx | 6 + .../Certifications/Certifications.sv.resx | 6 + .../Certifications/Certifications.uk.resx | 6 + .../ContractorBilling.ar.resx | 431 ++++++++++ .../ContractorBilling/ContractorBilling.cs | 4 + .../ContractorBilling.de.resx | 431 ++++++++++ .../ContractorBilling.el.resx | 431 ++++++++++ .../ContractorBilling.en.resx | 431 ++++++++++ .../ContractorBilling.es.resx | 431 ++++++++++ .../ContractorBilling.fr.resx | 431 ++++++++++ .../ContractorBilling.it.resx | 431 ++++++++++ .../ContractorBilling.pl.resx | 431 ++++++++++ .../ContractorBilling/ContractorBilling.resx | 431 ++++++++++ .../ContractorBilling.sv.resx | 431 ++++++++++ .../ContractorBilling.uk.resx | 431 ++++++++++ .../User/Deployments/Deployments.ar.resx | 5 + .../User/Deployments/Deployments.de.resx | 5 + .../User/Deployments/Deployments.el.resx | 5 + .../User/Deployments/Deployments.en.resx | 5 + .../User/Deployments/Deployments.es.resx | 5 + .../User/Deployments/Deployments.fr.resx | 5 + .../User/Deployments/Deployments.it.resx | 5 + .../User/Deployments/Deployments.pl.resx | 5 + .../Areas/User/Deployments/Deployments.resx | 5 + .../User/Deployments/Deployments.sv.resx | 5 + .../User/Deployments/Deployments.uk.resx | 5 + .../Areas/User/Invoicing/Invoicing.ar.resx | 9 + .../Areas/User/Invoicing/Invoicing.de.resx | 9 + .../Areas/User/Invoicing/Invoicing.el.resx | 9 + .../Areas/User/Invoicing/Invoicing.en.resx | 9 + .../Areas/User/Invoicing/Invoicing.es.resx | 9 + .../Areas/User/Invoicing/Invoicing.fr.resx | 9 + .../Areas/User/Invoicing/Invoicing.it.resx | 9 + .../Areas/User/Invoicing/Invoicing.pl.resx | 9 + .../Areas/User/Invoicing/Invoicing.resx | 9 + .../Areas/User/Invoicing/Invoicing.sv.resx | 9 + .../Areas/User/Invoicing/Invoicing.uk.resx | 9 + Core/Resgrid.Model/AuditLogTypes.cs | 27 +- .../Certifications/CertificationModels.cs | 16 + .../CertificationProtectedFields.cs | 16 +- .../CertificationWorkflowTriggers.cs | 8 +- .../Events/CertificationEvents.cs | 59 ++ .../Invoicing/ContractorBillingModels.cs | 550 +++++++++++++ .../Invoicing/ContractorChargeModels.cs | 227 ++++++ .../Invoicing/CustomerBillingProfile.cs | 2 +- .../Invoicing/DepartmentBillingIdentity.cs | 3 + .../Invoicing/DeploymentContracts.cs | 5 + .../Invoicing/DeploymentModels.cs | 59 +- .../Invoicing/DeploymentPermissionCatalog.cs | 13 +- Core/Resgrid.Model/Invoicing/Invoice.cs | 4 +- .../Resgrid.Model/Invoicing/InvoicePayment.cs | 8 +- .../Invoicing/OnlinePaymentModels.cs | 44 -- .../Resgrid.Model/Providers/IEmailProvider.cs | 3 +- .../Repositories/IContractorRepositories.cs | 82 ++ .../Repositories/IDeploymentRepositories.cs | 6 + Core/Resgrid.Model/RoleMembershipException.cs | 24 + Core/Resgrid.Model/Services/IBidsService.cs | 53 ++ .../Services/IContractorBillingEngine.cs | 31 + .../Services/IDeploymentService.cs | 4 +- Core/Resgrid.Model/Services/IEmailService.cs | 2 +- .../Services/IInvoicingService.cs | 4 + .../Services/IPersonnelRolesService.cs | 8 +- .../Services/IRateScheduleService.cs | 39 + .../Services/IServiceContractService.cs | 42 + .../Services/ITimeTrackingService.cs | 4 + .../WorkflowTemplateVariableCatalog.cs | 54 +- .../Resgrid.Model/WorkflowTriggerEventType.cs | 14 +- Core/Resgrid.Services/AdpTableBindings.cs | 38 +- .../CertificationService.Protection.cs | 6 +- .../CertificationService.Sweep.cs | 3 +- Core/Resgrid.Services/CertificationService.cs | 59 +- Core/Resgrid.Services/EmailService.cs | 4 +- .../Resgrid.Services/Invoicing/BidsService.cs | 747 ++++++++++++++++++ .../Invoicing/ContractorBillingEngine.cs | 332 ++++++++ .../Invoicing/ContractorChargeCalculator.cs | 542 +++++++++++++ .../Invoicing/DeploymentService.Protection.cs | 44 +- .../Invoicing/DeploymentService.cs | 71 +- .../Invoicing/InvoicePaymentsService.cs | 18 +- .../Invoicing/InvoicingService.Delivery.cs | 33 +- .../Invoicing/InvoicingService.Protection.cs | 94 +-- .../Invoicing/InvoicingService.cs | 104 ++- .../PaymentWebhookPayloadMinimizer.cs | 56 ++ .../Invoicing/RateScheduleService.cs | 476 +++++++++++ .../Invoicing/ServiceContractService.cs | 468 +++++++++++ .../Invoicing/TimeTrackingService.cs | 77 +- .../Resgrid.Services/PersonnelRolesService.cs | 27 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 30 +- Core/Resgrid.Services/ProtectedReadService.cs | 4 +- .../Search/SystemActionCatalog.cs | 9 + Core/Resgrid.Services/ServicesModule.cs | 5 + .../WorkflowSampleDataGenerator.cs | 45 +- .../WorkflowTemplateContextBuilder.cs | 88 ++- .../WorkflowEventProvider.cs | 6 + .../Resgrid.Providers.Claims/ClaimsLogic.cs | 14 + .../ResgridClaimTypes.cs | 2 + .../ResgridResources.cs | 6 + .../PostmarkTemplateProvider.cs | 9 +- ...tendInvoicingAndAddCostRecoveryProfiles.cs | 7 + ...ndInvoicingAndAddCostRecoveryProfilesPg.cs | 7 + .../ContractorRepositories.cs | 202 +++++ .../DeploymentRepositories.cs | 12 + .../InvoicingRepositories.cs | 2 +- .../Modules/ApiDataModule.cs | 11 + .../Modules/DataModule.cs | 11 + .../Modules/NonWebDataModule.cs | 11 + .../Modules/TestingDataModule.cs | 11 + .../Allocations/trigger-baseline.json | 10 +- .../Rms/RmsIdentifierPinTests.cs | 9 + .../Services/CertificationServiceTests.cs | 25 +- .../ContractorBillingLocalizationTests.cs | 126 +++ .../Services/ContractorBillingServiceTests.cs | 526 ++++++++++++ .../ContractorChargeCalculatorTests.cs | 291 +++++++ .../Services/DeploymentServiceTests.cs | 19 + .../Services/InvoicingServiceTests.cs | 30 +- .../Services/PersonnelRolesServiceTests.cs | 31 + .../Services/ProtectedReadServiceTests.cs | 11 +- .../Services/TimeTrackingServiceTests.cs | 55 +- .../WorkforceProtectionAndEventsTests.cs | 290 +++++++ .../Controllers/v4/BidsController.cs | 325 ++++++++ .../Controllers/v4/DeploymentsController.cs | 2 +- .../Controllers/v4/InvoicesController.cs | 6 +- .../Controllers/v4/RateSchedulesController.cs | 249 ++++++ .../v4/ServiceContractsController.cs | 270 +++++++ .../Controllers/v4/TimeReportsController.cs | 7 +- .../Helpers/ClaimsAuthorizationHelper.cs | 4 + .../ContractorBillingApiModels.cs | 592 ++++++++++++++ .../v4/Deployments/DeploymentsApiModels.cs | 6 +- .../Resgrid.Web.Services.xml | 72 ++ Web/Resgrid.Web.Services/Startup.cs | 6 + .../Areas/User/Controllers/BidsController.cs | 302 +++++++ .../Controllers/CertificationsController.cs | 33 + .../User/Controllers/ContractsController.cs | 294 +++++++ .../Controllers/DeploymentWizardController.cs | 201 +++++ .../User/Controllers/DeploymentsController.cs | 76 +- .../User/Controllers/InvoicingController.cs | 45 +- .../User/Controllers/PersonnelController.cs | 8 +- .../Controllers/RateSchedulesController.cs | 280 +++++++ .../User/Controllers/ReportsController.cs | 3 + .../ContractorBilling/ContractorViews.cs | 318 ++++++++ .../Models/Deployments/DeploymentViews.cs | 8 + .../User/Models/Invoicing/InvoicingViews.cs | 6 + .../Areas/User/Views/Bids/Edit.cshtml | 140 ++++ .../Areas/User/Views/Bids/Index.cshtml | 67 ++ .../Areas/User/Views/Bids/New.cshtml | 45 ++ .../Areas/User/Views/Bids/View.cshtml | 108 +++ .../User/Views/Bids/_BidStatusBadge.cshtml | 15 + .../User/Views/Certifications/Record.cshtml | 28 +- .../User/Views/Certifications/Unit.cshtml | 22 +- .../User/Views/Contracts/Compliance.cshtml | 83 ++ .../Areas/User/Views/Contracts/Edit.cshtml | 97 +++ .../Areas/User/Views/Contracts/Index.cshtml | 65 ++ .../Areas/User/Views/Contracts/View.cshtml | 123 +++ .../Contracts/_ContractStatusBadge.cshtml | 14 + .../User/Views/DeploymentWizard/Index.cshtml | 253 ++++++ .../Areas/User/Views/Deployments/Edit.cshtml | 20 +- .../User/Views/Deployments/TimeReport.cshtml | 8 +- .../Areas/User/Views/Deployments/View.cshtml | 99 ++- .../Views/Deployments/_ExpenseForm.cshtml | 2 + .../Views/Invoicing/BillingProfile.cshtml | 18 +- .../Areas/User/Views/Invoicing/Edit.cshtml | 2 +- .../Areas/User/Views/Invoicing/View.cshtml | 17 +- .../User/Views/RateSchedules/Edit.cshtml | 293 +++++++ .../User/Views/RateSchedules/Index.cshtml | 72 ++ .../Areas/User/Views/Security/Index.cshtml | 6 + .../Views/Shared/_ContractorMessage.cshtml | 10 + .../User/Views/Shared/_ContractorShell.cshtml | 34 + .../User/Views/Shared/_Navigation.cshtml | 18 + .../Areas/User/Views/Workflows/New.cshtml | 3 +- Web/Resgrid.Web/Helpers/AdpRevealHelper.cs | 23 + .../Helpers/ClaimsAuthorizationHelper.cs | 4 + Web/Resgrid.Web/Startup.cs | 6 + .../Commands/BidExpirationCommand.cs | 15 + .../Commands/ComplianceExpiryCommand.cs | 15 + .../DeploymentFinanceReminderCommand.cs | 15 + Workers/Resgrid.Workers.Console/Program.cs | 20 + .../Tasks/BidExpirationTask.cs | 22 + .../Tasks/ComplianceExpiryTask.cs | 22 + .../Tasks/DeploymentFinanceReminderTask.cs | 22 + .../Logic/BidExpirationLogic.cs | 34 + .../Logic/ComplianceExpiryLogic.cs | 35 + .../Logic/DeploymentFinanceReminderLogic.cs | 37 + 190 files changed, 16071 insertions(+), 472 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.cs create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx create mode 100644 Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs create mode 100644 Core/Resgrid.Model/Invoicing/ContractorChargeModels.cs create mode 100644 Core/Resgrid.Model/Repositories/IContractorRepositories.cs create mode 100644 Core/Resgrid.Model/RoleMembershipException.cs create mode 100644 Core/Resgrid.Model/Services/IBidsService.cs create mode 100644 Core/Resgrid.Model/Services/IContractorBillingEngine.cs create mode 100644 Core/Resgrid.Model/Services/IRateScheduleService.cs create mode 100644 Core/Resgrid.Model/Services/IServiceContractService.cs create mode 100644 Core/Resgrid.Services/Invoicing/BidsService.cs create mode 100644 Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs create mode 100644 Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs create mode 100644 Core/Resgrid.Services/Invoicing/PaymentWebhookPayloadMinimizer.cs create mode 100644 Core/Resgrid.Services/Invoicing/RateScheduleService.cs create mode 100644 Core/Resgrid.Services/Invoicing/ServiceContractService.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs create mode 100644 Tests/Resgrid.Tests/Services/ContractorBillingLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ContractorChargeCalculatorTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RateSchedulesController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Bids/_BidStatusBadge.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contracts/Compliance.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contracts/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contracts/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Contracts/_ContractStatusBadge.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RateSchedules/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorMessage.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml create mode 100644 Web/Resgrid.Web/Helpers/AdpRevealHelper.cs create mode 100644 Workers/Resgrid.Workers.Console/Commands/BidExpirationCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Commands/ComplianceExpiryCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Commands/DeploymentFinanceReminderCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/BidExpirationTask.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/ComplianceExpiryTask.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/DeploymentFinanceReminderTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/BidExpirationLogic.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/ComplianceExpiryLogic.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 1b9ac9ba1..24df75c24 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -55,10 +55,12 @@ public static class DataProtectionConfig /// /// Purposes the broker's workload decrypt lane (POST api/v1/broker/workload/decrypt?purpose=) accepts, comma /// separated (RMS plan section 5.9.4). Each purpose is an egress the department acknowledged in the - /// application before the caller reaches the broker: neris-submission (worker 41) and records-export - /// (worker 45 / Workflow renders). Empty disables the lane; callers fail closed with workload_purpose_denied. + /// application before the caller reaches the broker: neris-submission (worker 41), records-export + /// (worker 45 / Workflow renders) and invoicing (invoice delivery, pay links and deployment finance: the + /// Workforce & Business Operations plan's document renders and the DTR void-reason append). Empty + /// disables the lane; callers fail closed with workload_purpose_denied. /// - public static string BrokerWorkloadPurposes = "neris-submission,records-export"; + public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing"; /// True on the broker host to run the ADP migration coordinator sweep there (the only /// host with a real KMS adapter). Workers.Console keeps its sweep for liveness/offboarding diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.ar.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.ar.resx index e040977c9..d597387c0 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.ar.resx @@ -154,6 +154,7 @@ الإزالة في {0} هؤلاء الأعضاء لا يحملون الشهادات التي يتطلبها هذا الدور ولم تتم إضافتهم: {0} تمت الإضافة مع تحذير – شهادات مفقودة لهذا الدور: {0} + هؤلاء الأعضاء ليسوا في هذا القسم ولم تتم إضافتهم: {0} تم إنشاء العضو، لكن لم يتم تعيين دور أو أكثر: لا يحمل العضو الشهادات التي تتطلبها تلك الأدوار. شهادات الوحدة الشهادات والفحوصات – {0} @@ -219,4 +220,9 @@ لا يحمل العضو الشهادات التي يتطلبها هذا الدور. لا يمكن رفع هذا النوع من الملفات. حجم الملف أكبر من 10 ميغابايت. + تمت إضافة شهادة وحدة + تغيرت حالة شهادة الوحدة + تمت إزالة شهادة وحدة + تمت إزالة شهادة + تمت إضافة ساعات اعتماد شهادة diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.de.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.de.resx index 6f613eaf3..4dc0e9552 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.de.resx @@ -154,6 +154,7 @@ Entfernung am {0} Diese Mitglieder besitzen die für diese Rolle erforderlichen Zertifizierungen nicht und wurden nicht hinzugefügt: {0} Mit Warnung hinzugefügt – fehlende Zertifizierungen für diese Rolle: {0} + Diese Mitglieder gehören nicht zu dieser Abteilung und wurden nicht hinzugefügt: {0} Das Mitglied wurde angelegt, aber eine oder mehrere Rollen wurden nicht zugewiesen: Es fehlen die für diese Rollen erforderlichen Zertifizierungen. Einheitszertifizierungen Zertifizierungen und Prüfungen – {0} @@ -219,4 +220,9 @@ Das Mitglied besitzt die für diese Rolle erforderlichen Zertifizierungen nicht. Dieser Dateityp kann nicht hochgeladen werden. Die Datei ist größer als 10 MB. + Einheitszertifizierung hinzugefügt + Status der Einheitszertifizierung geändert + Einheitszertifizierung entfernt + Zertifizierung entfernt + Fortbildungspunkte hinzugefügt diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.el.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.el.resx index 75a64c7cf..f7a40d7c5 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.el.resx @@ -154,6 +154,7 @@ Αφαίρεση στις {0} Αυτά τα μέλη δεν κατέχουν τις πιστοποιήσεις που απαιτεί ο ρόλος και δεν προστέθηκαν: {0} Προστέθηκαν με προειδοποίηση – λείπουν πιστοποιήσεις για αυτόν τον ρόλο: {0} + Αυτά τα μέλη δεν ανήκουν σε αυτό το τμήμα και δεν προστέθηκαν: {0} Το μέλος δημιουργήθηκε, αλλά ένας ή περισσότεροι ρόλοι δεν ανατέθηκαν: δεν κατέχει τις πιστοποιήσεις που απαιτούν. Πιστοποιήσεις μονάδας Πιστοποιήσεις και επιθεωρήσεις – {0} @@ -219,4 +220,9 @@ Το μέλος δεν κατέχει τις πιστοποιήσεις που απαιτεί αυτός ο ρόλος. Αυτός ο τύπος αρχείου δεν μπορεί να μεταφορτωθεί. Το αρχείο είναι μεγαλύτερο από 10 MB. + Προστέθηκε πιστοποίηση μονάδας + Άλλαξε η κατάσταση πιστοποίησης μονάδας + Αφαιρέθηκε πιστοποίηση μονάδας + Αφαιρέθηκε πιστοποίηση + Προστέθηκαν μονάδες πιστοποίησης diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.en.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.en.resx index dfeb8b05b..b4b922250 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.en.resx @@ -154,6 +154,7 @@ Removal on {0} These members do not hold the certifications this role requires and were not added: {0} Added with a warning – missing certifications for this role: {0} + These members are not in this department and were not added: {0} The member was created, but one or more roles were not assigned: the member does not hold the certifications those roles require. Unit certifications Certifications and inspections – {0} @@ -219,4 +220,9 @@ The member does not hold the certifications this role requires. That file type cannot be uploaded. The file is larger than 10 MB. + Unit certification added + Unit certification status changed + Unit certification removed + Certification removed + Certification credit added diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.es.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.es.resx index cd207c657..6545fc354 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.es.resx @@ -154,6 +154,7 @@ Retirada el {0} Estos miembros no tienen las certificaciones que requiere este rol y no se añadieron: {0} Añadidos con aviso: faltan certificaciones para este rol: {0} + Estos miembros no pertenecen a este departamento y no se añadieron: {0} El miembro se creó, pero uno o más roles no se asignaron: no tiene las certificaciones que esos roles requieren. Certificaciones de unidad Certificaciones e inspecciones – {0} @@ -219,4 +220,9 @@ El miembro no tiene las certificaciones que requiere este rol. Ese tipo de archivo no se puede subir. El archivo supera los 10 MB. + Certificación de unidad añadida + Estado de certificación de unidad cambiado + Certificación de unidad eliminada + Certificación eliminada + Créditos de certificación añadidos diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.fr.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.fr.resx index 64094a1e6..4ac0c32e3 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.fr.resx @@ -154,6 +154,7 @@ Retrait le {0} Ces membres ne détiennent pas les certifications requises pour ce rôle et n'ont pas été ajoutés : {0} Ajoutés avec un avertissement – certifications manquantes pour ce rôle : {0} + Ces membres ne font pas partie de ce service et n'ont pas été ajoutés : {0} Le membre a été créé, mais un ou plusieurs rôles n'ont pas été attribués : il ne détient pas les certifications requises. Certifications d'unité Certifications et inspections – {0} @@ -219,4 +220,9 @@ Le membre ne détient pas les certifications requises pour ce rôle. Ce type de fichier ne peut pas être téléversé. Le fichier dépasse 10 Mo. + Certification d'unité ajoutée + Statut de certification d'unité modifié + Certification d'unité supprimée + Certification supprimée + Crédits de certification ajoutés diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.it.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.it.resx index 061b97f2b..bbf308d03 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.it.resx @@ -154,6 +154,7 @@ Rimozione il {0} Questi membri non possiedono le certificazioni richieste da questo ruolo e non sono stati aggiunti: {0} Aggiunti con un avviso – certificazioni mancanti per questo ruolo: {0} + Questi membri non appartengono a questo dipartimento e non sono stati aggiunti: {0} Il membro è stato creato, ma uno o più ruoli non sono stati assegnati: non possiede le certificazioni richieste da quei ruoli. Certificazioni unità Certificazioni e ispezioni – {0} @@ -219,4 +220,9 @@ Il membro non possiede le certificazioni richieste da questo ruolo. Questo tipo di file non può essere caricato. Il file supera i 10 MB. + Certificazione unità aggiunta + Stato certificazione unità cambiato + Certificazione unità rimossa + Certificazione rimossa + Crediti di certificazione aggiunti diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.pl.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.pl.resx index c51dabc30..8731330b9 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.pl.resx @@ -154,6 +154,7 @@ Usunięcie {0} Ci członkowie nie posiadają certyfikatów wymaganych przez tę rolę i nie zostali dodani: {0} Dodano z ostrzeżeniem – brak certyfikatów dla tej roli: {0} + Ci członkowie nie należą do tego działu i nie zostali dodani: {0} Członek został utworzony, ale co najmniej jedna rola nie została przypisana: nie posiada certyfikatów wymaganych przez te role. Certyfikaty jednostki Certyfikaty i przeglądy – {0} @@ -219,4 +220,9 @@ Członek nie posiada certyfikatów wymaganych przez tę rolę. Tego typu pliku nie można przesłać. Plik jest większy niż 10 MB. + Dodano certyfikat jednostki + Zmieniono status certyfikatu jednostki + Usunięto certyfikat jednostki + Usunięto certyfikat + Dodano punkty certyfikacji diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.resx index dfeb8b05b..b4b922250 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.resx @@ -154,6 +154,7 @@ Removal on {0} These members do not hold the certifications this role requires and were not added: {0} Added with a warning – missing certifications for this role: {0} + These members are not in this department and were not added: {0} The member was created, but one or more roles were not assigned: the member does not hold the certifications those roles require. Unit certifications Certifications and inspections – {0} @@ -219,4 +220,9 @@ The member does not hold the certifications this role requires. That file type cannot be uploaded. The file is larger than 10 MB. + Unit certification added + Unit certification status changed + Unit certification removed + Certification removed + Certification credit added diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.sv.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.sv.resx index ab879c61f..432f80968 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.sv.resx @@ -154,6 +154,7 @@ Borttagning {0} Dessa medlemmar saknar certifieringarna rollen kräver och lades inte till: {0} Tillagda med en varning – saknade certifieringar för denna roll: {0} + Dessa medlemmar tillhör inte den här avdelningen och lades inte till: {0} Medlemmen skapades, men en eller flera roller tilldelades inte: medlemmen saknar certifieringarna rollerna kräver. Enhetscertifieringar Certifieringar och besiktningar – {0} @@ -219,4 +220,9 @@ Medlemmen saknar certifieringarna rollen kräver. Den filtypen kan inte laddas upp. Filen är större än 10 MB. + Enhetscertifiering tillagd + Enhetscertifieringens status ändrad + Enhetscertifiering borttagen + Certifiering borttagen + Certifieringspoäng tillagda diff --git a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.uk.resx b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.uk.resx index 2eb4e101c..8e6f9f2b7 100644 --- a/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Certifications/Certifications.uk.resx @@ -154,6 +154,7 @@ Вилучення {0} Ці члени не мають сертифікатів, яких вимагає роль, і не були додані: {0} Додано з попередженням – бракує сертифікатів для цієї ролі: {0} + Ці учасники не належать до цього підрозділу й не були додані: {0} Члена створено, але одну чи кілька ролей не призначено: він не має сертифікатів, яких вимагають ці ролі. Сертифікати підрозділу Сертифікати та огляди – {0} @@ -219,4 +220,9 @@ Член не має сертифікатів, яких вимагає ця роль. Цей тип файлу не можна завантажити. Файл більший за 10 МБ. + Додано сертифікацію підрозділу + Змінено статус сертифікації підрозділу + Вилучено сертифікацію підрозділу + Вилучено сертифікацію + Додано кредити сертифікації diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx new file mode 100644 index 000000000..2c89cd21e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + مقبول + الإجراءات + تفعيل الآن + نشط + إضافة + إضافة شريحة + إضافة مستند امتثال + إضافة بند + إضافة بند + إضافة شخص + إضافة علاوة + إضافة متطلب + العنوان + يحتوي هذا العرض على بيانات عميل محمية. + أرقام مستندات الامتثال وملفاتها محمية. + يحتوي هذا العقد على بيانات عميل محمية. + جوي + مهلة التنبيه (أيام) + الكل + المبلغ + رجوع + مخصص + انتشار يومي + استعداد يومي + انتشار + المسافة لكل كم + خارج المقاطعة لكل شخص/يوم + إضافي 1 + إضافي 2 + بدل وجبة + إقامة خاصة/يوم + استعداد + الشريحة + الشرائح + عتبات الانتشار/الإضافي1/الإضافي2 والمستويات اليومية والوحدات المجانية ورموز الوجبات بيانات هنا — وليست كودًا. + السعر الأساسي + يومي + ثابت + بالساعة + لكل كيلومتر + لكل شخص في اليوم + العرض + تم قبول العرض + يوجَّه العرض إلى ملف الفوترة لجهة الاتصال هذه. + يوفر العقد جدول أسعاره وخصمه وشروطه. + تم إنشاء العرض + تم رفض العرض + انتهت صلاحية العرض + بند العرض + العرض رقم + تأخذ البنود سعرها من هذا الجدول عند الإضافة. + تم إرسال العرض + مقبول + مرفوض + مسودة + منتهي + مُقدَّم + مسحوب + العروض + تقديرات مسعّرة لجهات اتصال العملاء والعقود؛ العرض المقبول يجدول الانتشار. + الفوترة + أساس الفوترة + النداء + اسم النداء + طبيعة النداء + الأولوية + رمز النداء + نوع النداء + إلغاء + الحد الأدنى للإلغاء (ساعات) + تُحاسب المركبات يومًا كاملًا في يوم الإلغاء + الشهادة منتهية أو معلّقة + تنتهي الشهادة خلال فترة الانتشار + رمز الشهادة + معاينة الرسوم + طُلبت إقامة في يوم وفرتها فيه الجهة. + بند سعر يفتقر إلى الشريحة اللازمة لبعض الساعات. + لا يوجد بند لحجم الطاقم المشغول؛ استُخدم أقرب سعر أدنى. + لا توجد تقارير وقت معتمدة غير مفوترة. + طُلب بدل يومي في يوم وفرت فيه الجهة الوجبات. + طُلب بدل يومي خارج نافذة الأهلية. + مبلغ بدل يومي يختلف عن سعر الجدول. + عنصر بلا بند سعر وتم تخطيه. + لا يوجد جدول أسعار مطبق على هذا الانتشار. + تم تحديد ساعات السفر وفق السياسة. + تعذر تجهيز عملية احتساب الرسوم. + نسخ + ينشئ جدولًا جديدًا بنسخ من كل بند وشريحة وعلاوة — البداية المعتادة للتجديد. + الرمز + قائمة الامتثال + نوع المستند + مستندات الامتثال + {0} مستند(ات) امتثال على وشك الانتهاء أو منتهية. + PDF أو صورة حتى 30 ميغابايت؛ اتركه فارغًا للاحتفاظ بالملف المخزن. + التأمين وتعويض العمال وSAM/CAGE والتراخيص والكفالات مع تنبيهات الانتهاء؛ تستوفي متطلبات العقود وتُرفق بحزمة الفاتورة. + حذف هذا العنصر؟ + حذف مسودة العرض هذه؟ + حذف هذا العقد؟ + حذف جدول الأسعار هذا؟ + إنشاء مسودة فاتورة من هذه الرسوم؟ + سحب هذا العرض؟ + انتشار متداخل + فجوة التشغيل المتواصل (دقائق) + العقد + العروض وعمليات الانتشار والفواتير المرتبطة وحالة امتثال هذا العقد. + العقد على وشك الانتهاء + رقم العقد + العقد → افتراضي ملف العميل → أول جدول نشط. + نشط + تغيّرت حالة العقد + مسودة + منتهي + معلّق + منهى + نوع العقد + خدمات رئيسية + أخرى + مشروع + ترتيب دائم + فوترة المقاول + العقود + عقود الخدمة مع جدول الأسعار والشروط وعنوان التقديم ومتطلبات المستندات. + حُوِّل إلى انتشار + نسخة + إضافة إلى تقويم القسم + إنشاء النداء والانتشار + إنشاء مسودة + طاقم أشخاص + حجم الطاقم + العملة + العميل + بريد العميل + الضمان اليومي (ساعات) + التاريخ + الأيام + سبب الرفض + مرفوض + حذف + حذف الجدول + موقع التسليم + الانتشار + مرفق الانتشار + عمليات الانتشار + الوصف + الخصم + تسلسل الخصم + الخصم % + كفالة + رخصة تجارية + رمز CAGE + شهادة تأمين + أخرى + تسجيل SAM + التسجيل الضريبي + براءة تعويض العمال + رقم المستند + متطلبات المستندات + ما يتوقعه العميل في كل مرحلة؛ يُستوفى نوع مستند الامتثال بمستند قسم ساري، وإلا بمرفق انتشار. + قالب المستند + تعديل + تعديل العرض + الترويسة والبنود بأسعارها والعلاوات والتقدير. + تعديل مستند الامتثال + تعديل العقد + الترويسة والشروط التجارية ومتطلبات المستندات لكل مرحلة. + تعديل جدول الأسعار + الأسعار مبالغ صريحة لكل شريحة؛ عائلات الأطقم تتشارك مفتاح مجموعة وتختلف بحجم الطاقم. + ساري من + النهاية + بنود الأسعار + بنود الأفراد تطابق رمز شهادة؛ بنود الأطقم تتشارك مفتاح مجموعة ببند لكل حجم؛ بنود المركبات والمعدات مثبتة بعنصر القائمة. + بند سعر + نوع البند + طاقم + معدات + أفراد (شهادة) + خدمة + مركبة + المعدات + التقدير + تتبع الضريبة ملف فوترة العميل؛ والرسوم الفعلية تتبع تقارير الوقت اليومية. + الرسوم التقديرية للعميل يوميًا + الإجمالي التقديري + ينتهي في + تصدير JSON + الملف + اسم العنصر + مجاني/يوم + خصم الوقود لكل لتر + إنشاء فاتورة + ينشئ مسودة فاتورة من الرسوم أعلاه؛ تنتقل تقارير الوقت المفوترة إلى حالة مفوتر. + مفتاح المجموعة + مثال: crew-type6 + أنشئ عرضًا من جدول أسعار العميل، أرسله بصيغة PDF، ثم اقبله لبدء معالج الانتشار. + لكل مستند مهلة تنبيه خاصة؛ يُخطَر مديرو القسم عند دخوله النافذة أو انتهائه. + يحدد العقد جدول الأسعار والخصم وشروط الدفع لكل عرض وانتشار تابع؛ ومتطلبات المستندات تقود قائمة الامتثال وحزمة الفاتورة. + يشير العقد إلى جدول؛ وإلا يُطبق جدول الملف الافتراضي أو أول جدول نشط. انسخ جدولًا للموسم التالي. + ساعات/يوم + استيراد + استيراد JSON + الصق أو ارفع جدولًا مُصدَّرًا من Resgrid؛ تُحذف المعرفات وروابط القسم ليمكن مشاركة الجدول بين الأقسام. + غير نشط + رقم الحادث + معرف عنصر المخزون + بريد تقديم الفواتير + حيث تُرسل فواتير الانتشار وحزمتها افتراضيًا. + الفواتير + الجهة المصدرة + التسمية + نوع البند + طاقم + معدات + حر + أفراد + خدمة + مركبة + البنود + اختر بند سعر لنسخ سعره أو أدخل بندًا حرًا. الكمية × السعر × الساعات/اليوم × الأيام. + إدارة العروض + إنشاء العروض وتعديلها وإرسالها وقبولها وتحويلها. يستطيع مديرو القسم ذلك دائمًا. + إدارة العقود + إنشاء وتعديل العقود ومتطلبات المستندات ومستندات الامتثال. يستطيع مديرو القسم ذلك دائمًا. + إلزامي + وضع علامة مقبول + وضع علامة مرفوض + وضع علامة مُقدَّم + الحد الأقصى لأيام الانتشار + رمز الوجبة + أهلية الوجبات + نوافذ JSON لكل رمز وجبة، مثل [{"MealCode":"B","StartsBeforeMinutes":420}]؛ البدل خارج نافذته يُنبَّه عنه ولا يُمنع. + مفقود + التعبئة المسبقة بالمضاعف + الأساس + الاستعداد + مضاعفات الإضافي تُنشئ الشرائح الساعية؛ تبقى القيم المخزنة مبالغ صريحة. + الاسم + أقرب سعر أدنى + عرض جديد + اختر العميل، وإن وُجد، العقد؛ يتبع ذلك جدول الأسعار والخصم. + عقد جديد + جدول أسعار جديد + التالي + لا + لا توجد عروض بعد. + لا توجد تقارير وقت معتمدة غير مفوترة. + الترحيل بدون راحة 8 ساعات يبدأ اليوم في شريحة العمل الإضافي + لا توجد مستندات امتثال بعد. + بدون عقد + لا توجد عقود بعد. + لا توجد عمليات انتشار. + لا توجد بنود أسعار بعد. + لا توجد فواتير. + لا توجد جداول أسعار بعد. + لا توجد متطلبات مستندات. + بدون جدول أسعار + لا يوجد جدول أسعار مطبق: تحتاج البنود إلى سعر مُدخل. + لا شيء + ملاحظات + فتح + اختياري + …أو الصق JSON + خارج المقاطعة + أساس العمل الإضافي + ساعات متتالية + إجمالي ساعات اليوم + ملف PDF + يُفحص لكل انتشار + شخص + الأفراد + نقطة التعاقد + سياسة الفوترة + التقريب والحدود الدنيا وأساس العمل الإضافي وحدود السفر التي يطبقها المحرك على كل تقرير وقت في هذا الجدول. + من الباب إلى الباب (يُحاسب السفر كوقت انتشار) + تعبئة + علاوة + العلاوات + إضافات ثابتة بالساعة لكل شريحة (ليلي، خطر، قائد)؛ تُجمع ولا تُضرب أبدًا. + معاينة + الملف + مؤهل + الكمية + السعر + بند السعر + جدول الأسعار + جداول الأسعار + جداول أسعار المقاول: الشهادات والأطقم والمركبات والمعدات والعلاوات وسياسة الفوترة. + إزالة + إعادة فتح + المطلوب + النهاية المطلوبة + البداية المطلوبة + الشهادات المطلوبة + قائمة JSON من الرمز + الحد الأدنى للعدد الذي يجب أن يحمله طاقم بهذا الحجم. + إعادة إرسال العرض + زمن الاستجابة (دقائق) + لا يحمل الدور + التقريب (دقائق) + مستوفى + حفظ + تعذر حفظ التغيير. + تم الحفظ. + الجدول + جدولة نداء الانتشار + مقعد + المقاعد + اختر جهة اتصال… + إرسال العرض + إرسال إلى + أُرسل + رقم طلب الخدمة + تفعيل + العودة إلى المسودة + وضع علامة منتهي + تعليق + إنهاء + عرض النشط + عرض الكل + الترتيب + المرحلة + تقديم العرض + تقرير الوقت اليومي + بداية الانتشار + تقديم الفاتورة + البداية + الحالة + الحالة + المجموع الفرعي + الضريبة (تقديري) + خاضع للضريبة + الشروط (أيام صافية) + الشروط + إلى (ساعة) + من (ساعة) + حتى تاريخ + الحد الأقصى للمستوى (ساعة) + الحد الأدنى للمستوى (ساعة) + العنوان + الإجمالي قبل الضريبة + الحد الأقصى للسفر يوميًا (ساعات) + السفر جوًا + يُحاسب الأشخاص المنتشرون بدون مقعد وحدة وفق بند شهادتهم الخاص. + أفراد غير مُعيَّنين + الوحدة + نوع الوحدة + الوحدات + التوقف لأسباب السلامة (ساعات) + استخدام الجدول الافتراضي + صالح حتى + عرض العرض + موجود بالفعل في هذه القائمة. + تنتهي شهادة مطلوبة خلال الانتشار. + شخص مُعيَّن يفتقر إلى شهادة مطلوبة. + فشل صرف المخزون؛ أُضيف العنصر كنص حر. + شخص مُعيَّن لا يحمل دور المقعد. + شخص أو وحدة مُعيَّن بالفعل في انتشار متداخل. + الفترة + سحب + لكل وحدة: عناصر حرة أو معرفات أصول مخزون مع بند سعر يومي من الجدول. + العرض رقم {0} — {1}: تفاصيل النداء، الوحدات، مقاعد الطاقم، المعدات، الأسعار، المراجعة. + أكد بند الشهادة والعلاوات لكل شخص، وعائلة الطاقم لكل وحدة، والبند لكل معدة؛ الرقم اليومي تقديري. + املأ المقاعد من القائمة: الحالة والتوظيف والأدوار والشهادات لكل شخص؛ المنتهية أو المعلقة بالأحمر، والمنتهية خلال الفترة بالكهرماني، وعمليات الانتشار المتداخلة مُعلَّمة. تُحاسب الأطقم الجزئية بالحجم المشغول. + تفاصيل النداء + المعدات + الأسعار والعلاوات + مراجعة وإنشاء + مقاعد الطاقم + الوحدات + حدد الوحدات المراد نشرها واربط كلًا منها ببند طاقم في العرض؛ تحدد عائلة البند السعر. + نعم + هذا العرض له انتشار بالفعل. + يحتاج النداء إلى اسم. + لم يتم العثور على جهة اتصال العميل. + اختر جهة اتصال العميل. + العقد يخص جهة اتصال أخرى. + لم يتم العثور على العقد. + النهاية المطلوبة قبل البداية المطلوبة. + يجب أن يكون الخصم بين 0 و100 بالمئة. + لم يُرسل البريد؛ تحقق من العنوان وإعدادات بريد القسم. + يحتاج البند إلى وصف وكمية موجبة وسعر غير سالب. + يمكن تعديل العروض في حالة المسودة أو المُقدَّمة فقط. + أضف بندًا واحدًا على الأقل قبل التقديم. + لا يوجد بريد مستلم: أدخل واحدًا أو عيّن بريد الفوترة لجهة الاتصال. + يمكن جدولة العرض المقبول فقط. + لم يتم العثور على العرض. + تعذر إنشاء ملف PDF للعرض. + رفضت Advanced Data Protection الكتابة؛ تحقق من حالة حماية القسم. + لم يتم العثور على جدول الأسعار. + تغيير الحالة هذا غير مسموح من حالة العرض الحالية. + يحتاج العرض إلى عنوان. + تاريخ الانتهاء قبل تاريخ السريان. + الملف محمي؛ اكشفه أولًا بتصريح البيانات المحمية. + حجم الملف أكبر من 30 ميغابايت. + نوع الملف هذا غير مسموح. + يحتاج المستند إلى اسم. + لم يتم العثور على مستند الامتثال. + نوع المستند غير صالح. + لا يوجد للانتشار جهة اتصال عميل للفوترة. + يمكن فوترة الانتشار القابل للفوترة فقط. + لا شيء للفوترة: لم تُنتج تقارير الوقت المعتمدة غير المفوترة أي رسوم. + لا يمكن حذف عقد نشط؛ أنهِه أولًا. + لم يتم العثور على جهة اتصال العميل. + اختر جهة اتصال العميل. + تاريخ النهاية قبل تاريخ البداية. + يجب أن يكون الخصم بين 0 و100 بالمئة. + انقضى تاريخ انتهاء العقد؛ مدده قبل التفعيل. + يحتاج العقد إلى اسم. + لم يتم العثور على العقد. + رفضت Advanced Data Protection الكتابة؛ تحقق من حالة حماية القسم. + متطلب مستند بمرحلة أو نوع مستند غير معروف. + تغيير الحالة هذا غير مسموح من حالة العقد الحالية. + نوع العقد غير صالح. + يظهر نوع الشريحة نفسه مرتين في البند. + شريحة بنوع غير معروف أو سعر سالب. + يحتاج بند الطاقم إلى حجم. + تاريخ الانتهاء قبل تاريخ السريان. + نوع البند أو أساس الفوترة غير صالح. + يحتاج البند إلى اسم. + لم يتم العثور على بند السعر. + JSON ليس تصديرًا صالحًا لجدول أسعار. + الجدول مستخدم في عقد نشط أو مسودة ولا يمكن حذفه. + يحتاج الجدول إلى اسم. + لم يتم العثور على جدول الأسعار. + JSON السياسة غير صالح. + لا يمكن أن تكون العلاوات سالبة. + تحتاج العلاوة إلى اسم. + لم يتم العثور على العلاوة. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.cs b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.cs new file mode 100644 index 000000000..c2df2369c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.ContractorBilling +{ + public class ContractorBilling { } +} diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx new file mode 100644 index 000000000..081954c6e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Angenommen + Aktionen + Sofort aktivieren + Aktiv + Hinzufügen + Band hinzufügen + Compliance-Dokument hinzufügen + Eintrag hinzufügen + Position hinzufügen + Person hinzufügen + Zuschlag hinzufügen + Anforderung hinzufügen + Adresse + Dieses Angebot enthält geschützte Kundendaten. + Nummern und Dateien der Compliance-Dokumente sind geschützt. + Dieser Vertrag enthält geschützte Kundendaten. + Flug + Vorwarnzeit (Tage) + Alle + Betrag + Zurück + Benutzerdefiniert + Tageseinsatz + Tagesbereitschaft + Einsatz + Kilometergeld + Außerhalb der Provinz je Person/Tag + Überstunden 1 + Überstunden 2 + Verpflegungstagegeld + Privatunterkunft/Tag + Bereitschaft + Band + Bänder + Schwellen für Einsatz/ÜS1/ÜS2, Tagesstufen, Freieinheiten und Verpflegungscodes sind hier Daten — nie Code. + Basissatz + Täglich + Pauschal + Stündlich + Je Kilometer + Je Person und Tag + Angebot + Angebot angenommen + Das Angebot richtet sich an das Abrechnungsprofil dieses Kontakts. + Ein Vertrag liefert Tarifplan, Rabatt und Konditionen. + Angebot erstellt + Angebot abgelehnt + Angebot abgelaufen + Angebotsposition + Angebot Nr. + Positionen übernehmen ihren Satz beim Hinzufügen aus diesem Tarifplan. + Angebot gesendet + Angenommen + Abgelehnt + Entwurf + Abgelaufen + Eingereicht + Zurückgezogen + Angebote + Kalkulierte Angebote für Kundenkontakte und Verträge; ein angenommenes Angebot plant den Einsatz. + Abrechnung + Abrechnungsbasis + Einsatzmeldung + Einsatzname + Art des Einsatzes + Priorität + Rufzeichen + Einsatzart + Abbrechen + Stornomindestzeit (Stunden) + Fahrzeuge berechnen am Stornotag einen vollen Tag + Zertifizierung abgelaufen oder ausgesetzt + Zertifizierung läuft im Einsatzzeitraum ab + Zertifizierungscode + Kostenvorschau + Unterkunft wurde an einem Tag mit Unterkunft durch die Behörde beansprucht. + Einem Tarifeintrag fehlt das Band für einige Stunden. + Kein Eintrag für die besetzte Stärke; der nächstniedrigere Satz wurde verwendet. + Keine genehmigten, nicht abgerechneten Tagesberichte. + Ein Tagegeld wurde an einem Tag mit Verpflegung durch die Behörde beansprucht. + Ein Tagegeld wurde außerhalb seines Anspruchsfensters beansprucht. + Ein Tagegeldbetrag weicht vom Plansatz ab. + Für ein Subjekt gibt es keinen Tarifeintrag; es wurde übersprungen. + Für diesen Einsatz gilt kein Tarifplan. + Reisestunden wurden gemäß Regelwerk begrenzt. + Der Kostenlauf konnte nicht vorbereitet werden. + Klonen + Erstellt einen neuen Plan mit Kopien aller Einträge, Bänder und Zuschläge — der übliche Start einer Verlängerung. + Kürzel + Compliance-Prüfliste + Dokumenttyp + Compliance-Dokumente + {0} Compliance-Dokument(e) laufen ab oder sind abgelaufen. + PDF oder Bild bis 30 MB; leer lassen, um die gespeicherte Datei zu behalten. + Versicherung, Unfallversicherung, SAM/CAGE, Lizenzen und Bürgschaften mit Ablaufwarnungen; sie erfüllen Vertragsanforderungen und liegen dem Rechnungspaket bei. + Diesen Eintrag löschen? + Diesen Angebotsentwurf löschen? + Diesen Vertrag löschen? + Diesen Tarifplan löschen? + Aus diesen Kosten einen Rechnungsentwurf erzeugen? + Dieses Angebot zurückziehen? + Überlappender Einsatz + Pausenlücke für Durchlauf (Minuten) + Vertrag + Verknüpfte Angebote, Einsätze, Rechnungen und der Compliance-Status dieses Vertrags. + Vertrag läuft ab + Vertragsnummer + Vertrag → Standard des Kontaktprofils → erster aktiver Plan. + Aktiv + Vertragsstatus geändert + Entwurf + Abgelaufen + Ausgesetzt + Beendet + Vertragsart + Rahmenleistungsvertrag + Sonstiges + Projekt + Rahmenvereinbarung + Auftragnehmer-Abrechnung + Verträge + Dienstleistungsverträge mit Tarifplan, Konditionen, Einreichungsadresse und Dokumentanforderungen. + In einen Einsatz umgewandelt + Kopie + In den Abteilungskalender eintragen + Einsatzmeldung und Einsatz erstellen + Entwurf erstellen + Personen-Mannschaft + Mannschaftsstärke + Währung + Kunde + E-Mail des Kunden + Tagesgarantie (Stunden) + Datum + Tage + Ablehnungsgrund + Abgelehnt + Löschen + Plan löschen + Einsatzort + Einsatz + Einsatzanhang + Einsätze + Beschreibung + Rabatt + Rabattkaskade + Rabatt % + Bürgschaft + Gewerbeschein + CAGE-Code + Versicherungsnachweis + Sonstiges + SAM-Registrierung + Steuerregistrierung + Unfallversicherungsnachweis + Dokumentnummer + Dokumentanforderungen + Was der Kunde in jeder Phase erwartet; ein Compliance-Dokumenttyp wird durch ein gültiges Abteilungsdokument erfüllt, sonst durch einen Einsatzanhang. + Dokumentvorlage + Bearbeiten + Angebot bearbeiten + Kopf, Positionen mit Satzübernahme, Zuschläge und Schätzung. + Compliance-Dokument bearbeiten + Vertrag bearbeiten + Kopf, kaufmännische Bedingungen und Dokumentanforderungen je Phase. + Tarifplan bearbeiten + Sätze sind explizite Beträge je Band; Mannschaftsfamilien teilen einen Gruppenschlüssel und unterscheiden sich in der Stärke. + Gültig ab + Ende + Tarifeinträge + Personaleinträge passen zu einem Zertifizierungscode; Mannschaftseinträge teilen einen Gruppenschlüssel mit je einem Eintrag pro Stärke; Fahrzeug- und Ausrüstungseinträge werden dem Einsatzmittel zugeordnet. + Tarifeintrag + Eintragsart + Mannschaft + Ausrüstung + Personal (Zertifizierung) + Leistung + Fahrzeug + Ausrüstung + Schätzung + Die Steuer folgt dem Abrechnungsprofil des Kunden; die tatsächlichen Kosten folgen den Tagesberichten. + Geschätzte Kundenkosten pro Tag + Geschätzte Summe + Läuft ab am + JSON exportieren + Datei + Bezeichnung + Frei/Tag + Kraftstoffabzug je Liter + Rechnung erzeugen + Erstellt aus den obigen Kosten einen Rechnungsentwurf; die abgerechneten Tagesberichte wechseln zu Abgerechnet. + Gruppenschlüssel + z. B. typ6-crew + Erstellen Sie ein Angebot aus dem Tarifplan des Kunden, senden Sie es als PDF und nehmen Sie es an, um den Einsatzassistenten zu starten. + Jedes Dokument hat seine eigene Vorwarnzeit; Abteilungsadministratoren werden benachrichtigt, wenn es in das Fenster eintritt oder abläuft. + Ein Vertrag legt Tarifplan, Rabatt und Zahlungsziel für alle zugehörigen Angebote und Einsätze fest; Dokumentanforderungen steuern die Compliance-Prüfliste und das Rechnungspaket. + Ein Vertrag verweist auf einen Plan; sonst gilt der Standard des Kontaktprofils oder der erste aktive Plan. Klonen Sie einen Plan für die nächste Saison. + Std./Tag + Importieren + JSON importieren + Fügen Sie einen aus Resgrid exportierten Plan ein oder laden Sie ihn hoch; IDs und Abteilungsbezüge entfallen, sodass ein Plan zwischen Abteilungen geteilt werden kann. + Inaktiv + Einsatznummer + Inventarartikel-ID + E-Mail für Rechnungseinreichung + Wohin Einsatzrechnungen und ihr Paket standardmäßig gesendet werden. + Rechnungen + Aussteller + Bezeichnung + Positionsart + Mannschaft + Ausrüstung + Freie Position + Personal + Leistung + Fahrzeug + Positionen + Wählen Sie einen Tarifeintrag, um den Satz zu übernehmen, oder erfassen Sie eine freie Position. Menge × Satz × Std./Tag × Tage. + Angebote verwalten + Angebote erstellen, bearbeiten, senden, annehmen und umwandeln. Abteilungsadministratoren dürfen es immer. + Verträge verwalten + Dienstleistungsverträge, Dokumentanforderungen und Compliance-Dokumente anlegen und bearbeiten. Abteilungsadministratoren dürfen es immer. + Pflicht + Als angenommen markieren + Als abgelehnt markieren + Als eingereicht markieren + Max. Einsatztage + Verpflegungscode + Verpflegungsanspruch + JSON-Fenster je Verpflegungscode, z. B. [{"MealCode":"B","StartsBeforeMinutes":420}]; ein Tagegeld außerhalb wird gewarnt, nicht blockiert. + Fehlt + Vorbelegung per Multiplikator + Basis + Bereitschaft + ÜS-Multiplikatoren erzeugen die Stundenbänder; gespeichert werden explizite Beträge. + Name + nächstniedrigerer Satz + Neues Angebot + Wählen Sie den Kunden und ggf. den Vertrag; Tarifplan und Rabatt folgen daraus. + Neuer Vertrag + Neuer Tarifplan + Weiter + Nein + Noch keine Angebote. + Keine genehmigten, nicht abgerechneten Tagesberichte. + No-Clear-8-Übertrag startet den Tag im Überstundenband + Noch keine Compliance-Dokumente. + Kein Vertrag + Noch keine Verträge. + Keine Einsätze. + Noch keine Tarifeinträge. + Keine Rechnungen. + Noch keine Tarifpläne. + Keine Dokumentanforderungen. + Kein Tarifplan + Kein Tarifplan gilt: Positionen brauchen einen eingegebenen Satz. + Keine + Notizen + Öffnen + optional + …oder JSON einfügen + Außerhalb der Provinz + Überstundenbasis + Aufeinanderfolgende Stunden + Tagesgesamtstunden + PDF-Datei + je Einsatz geprüft + Person + Personal + Einstellungsort + Abrechnungsregeln + Rundung, Mindestwerte, Überstundenbasis und Reisegrenzen, die die Engine auf jeden Tagesbericht unter diesem Plan anwendet. + Portal-zu-Portal (Reisezeit gilt als Einsatzzeit) + Vorbelegen + Zuschlag + Zuschläge + Feste Stundenzuschläge je Band (Nacht, Gefahr, Leitung); sie addieren sich und multiplizieren nie. + Vorschau + Profil + Qualifiziert + Menge + Satz + Tarifeintrag + Tarifplan + Tarifpläne + Tariftabellen für Auftragnehmer: Zertifizierungen, Mannschaften, Fahrzeuge, Ausrüstung, Zuschläge und die Abrechnungsregeln. + Entfernen + Wieder öffnen + Angefragt + Gewünschtes Ende + Gewünschter Beginn + Erforderliche Zertifizierungen + JSON-Liste aus Code + Mindestanzahl, die eine Mannschaft dieser Stärke mitführen muss. + Angebot erneut senden + Reaktionszeit (Minuten) + Rolle nicht vorhanden + Rundung (Minuten) + Erfüllt + Speichern + Die Änderung konnte nicht gespeichert werden. + Gespeichert. + Plan + Einsatz planen + Platz + Plätze + Kontakt auswählen… + Angebot senden + Senden an + Gesendet + Serviceanfragenummer + Aktivieren + Zurück zum Entwurf + Als abgelaufen markieren + Aussetzen + Beenden + Aktive anzeigen + Alle anzeigen + Reihenfolge + Phase + Angebotsabgabe + Tagesbericht + Einsatzbeginn + Rechnungseinreichung + Beginn + Zustand + Status + Zwischensumme + Steuer (geschätzt) + Steuerpflichtig + Zahlungsziel (Tage netto) + Bedingungen + Bis (Std.) + Ab (Std.) + Bis zum Datum + Stufe max (Std.) + Stufe min (Std.) + Titel + Summe vor Steuern + Reiseobergrenze pro Tag (Stunden) + Anreise per Flugzeug + Ohne Einheitenplatz eingesetzte Personen werden nach ihrem eigenen Zertifizierungseintrag abgerechnet. + Nicht zugeordnetes Personal + Einheit + Einheitentyp + Einheiten + Sicherheitsstopp (Stunden) + Standardplan verwenden + Gültig bis + Angebot ansehen + Bereits auf diesem Dienstplan. + Eine erforderliche Zertifizierung läuft während des Einsatzes ab. + Einer eingesetzten Person fehlt eine erforderliche Zertifizierung. + Inventarausgabe fehlgeschlagen; der Artikel wurde als freie Position hinzugefügt. + Eine eingesetzte Person hat nicht die Rolle des Platzes. + Eine Person oder Einheit ist bereits in einem überlappenden Einsatz. + Zeitraum + Zurückziehen + Je Einheit: freie Positionen oder Inventar-Asset-IDs mit einem Tagessatzeintrag aus dem Plan. + Angebot Nr. {0} — {1}: Einsatzdaten, Einheiten, Mannschaftsplätze, Ausrüstung, Sätze, Prüfung. + Bestätigen Sie Zertifizierungseintrag und Zuschläge je Person, die Mannschaftsfamilie je Einheit und den Eintrag je Ausrüstung; der Tageswert ist eine Schätzung. + Besetzen Sie die Plätze aus dem Dienstplan: Status, Besetzung, Rollen und Zertifizierungen je Person; abgelaufene oder ausgesetzte Zertifizierungen rot, im Zeitraum ablaufende gelb, überlappende Einsätze markiert. Teilbesetzte Mannschaften werden nach der tatsächlichen Stärke abgerechnet. + Einsatzdaten + Ausrüstung + Sätze & Zuschläge + Prüfen & erstellen + Mannschaftsplätze + Einheiten + Markieren Sie die einzusetzenden Einheiten und ordnen Sie jede einer Angebotsposition zu; deren Mannschaftsfamilie bestimmt den Satz. + Ja + Dieses Angebot hat bereits einen Einsatz. + Die Einsatzmeldung braucht einen Namen. + Der Kundenkontakt wurde nicht gefunden. + Wählen Sie den Kundenkontakt. + Der Vertrag gehört zu einem anderen Kontakt. + Der Vertrag wurde nicht gefunden. + Das gewünschte Ende liegt vor dem gewünschten Beginn. + Der Rabatt muss zwischen 0 und 100 Prozent liegen. + Die E-Mail wurde nicht gesendet; prüfen Sie die Adresse und die E-Mail-Einstellungen der Abteilung. + Eine Position braucht eine Beschreibung, eine positive Menge und einen nicht negativen Satz. + Nur Entwürfe oder eingereichte Angebote können bearbeitet werden. + Fügen Sie vor dem Einreichen mindestens eine Position hinzu. + Keine Empfänger-E-Mail: geben Sie eine ein oder hinterlegen Sie die Rechnungs-E-Mail des Kontakts. + Nur ein angenommenes Angebot kann geplant werden. + Das Angebot wurde nicht gefunden. + Das Angebots-PDF konnte nicht erzeugt werden. + Advanced Data Protection hat das Schreiben abgelehnt; prüfen Sie den Schutzstatus der Abteilung. + Der Tarifplan wurde nicht gefunden. + Dieser Statuswechsel ist vom aktuellen Angebotsstatus aus nicht erlaubt. + Das Angebot braucht einen Titel. + Das Ablaufdatum liegt vor dem Gültigkeitsbeginn. + Die Datei ist geschützt; legen Sie sie zuerst mit einer Protected-Data-Freigabe offen. + Die Datei ist größer als 30 MB. + Dieser Dateityp ist nicht erlaubt. + Das Dokument braucht einen Namen. + Das Compliance-Dokument wurde nicht gefunden. + Der Dokumenttyp ist ungültig. + Der Einsatz hat keinen Kundenkontakt zur Rechnungsstellung. + Nur ein abrechenbarer Einsatz kann in Rechnung gestellt werden. + Es gibt nichts abzurechnen: keine genehmigten, nicht abgerechneten Tagesberichte haben Kosten ergeben. + Ein aktiver Vertrag kann nicht gelöscht werden; beenden Sie ihn zuerst. + Der Kundenkontakt wurde nicht gefunden. + Wählen Sie den Kundenkontakt. + Das Enddatum liegt vor dem Startdatum. + Der Rabatt muss zwischen 0 und 100 Prozent liegen. + Das Enddatum des Vertrags ist vorbei; verlängern Sie ihn vor der Aktivierung. + Der Vertrag braucht einen Namen. + Der Vertrag wurde nicht gefunden. + Advanced Data Protection hat das Schreiben abgelehnt; prüfen Sie den Schutzstatus der Abteilung. + Eine Dokumentanforderung hat eine unbekannte Phase oder einen unbekannten Dokumenttyp. + Dieser Statuswechsel ist vom aktuellen Vertragsstatus aus nicht erlaubt. + Die Vertragsart ist ungültig. + Derselbe Bandtyp kommt im Eintrag zweimal vor. + Ein Band hat einen unbekannten Typ oder einen negativen Satz. + Ein Mannschaftseintrag braucht eine Stärke. + Das Ablaufdatum liegt vor dem Gültigkeitsbeginn. + Eintragsart oder Abrechnungsbasis ist ungültig. + Der Eintrag braucht einen Namen. + Der Tarifeintrag wurde nicht gefunden. + Das JSON ist kein gültiger Tarifplan-Export. + Der Plan wird von einem aktiven oder Entwurfsvertrag verwendet und kann nicht gelöscht werden. + Der Plan braucht einen Namen. + Der Tarifplan wurde nicht gefunden. + Das Regel-JSON ist ungültig. + Zuschläge dürfen nicht negativ sein. + Der Zuschlag braucht einen Namen. + Der Zuschlag wurde nicht gefunden. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx new file mode 100644 index 000000000..f4a0d98d0 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Αποδεκτή + Ενέργειες + Ενεργοποίηση τώρα + Ενεργό + Προσθήκη + Προσθήκη ζώνης + Προσθήκη εγγράφου συμμόρφωσης + Προσθήκη καταχώρισης + Προσθήκη γραμμής + Προσθήκη ατόμου + Προσθήκη προσαύξησης + Προσθήκη απαίτησης + Διεύθυνση + Η προσφορά περιέχει προστατευμένα δεδομένα πελάτη. + Οι αριθμοί και τα αρχεία των εγγράφων συμμόρφωσης είναι προστατευμένα. + Η σύμβαση περιέχει προστατευμένα δεδομένα πελάτη. + Αέρας + Προειδοποίηση (ημέρες) + Όλα + Ποσό + Πίσω + Προσαρμοσμένη + Ημερήσια ανάπτυξη + Ημερήσια αναμονή + Ανάπτυξη + Χιλιομετρική ανά km + Εκτός επαρχίας ανά άτομο/ημέρα + Υπερωρία 1 + Υπερωρία 2 + Ημερήσια αποζημίωση γεύματος + Ιδιωτική διαμονή/ημέρα + Αναμονή + Ζώνη + Ζώνες + Τα όρια ανάπτυξης/ΥΠ1/ΥΠ2, οι ημερήσιες βαθμίδες, οι δωρεάν μονάδες και οι κωδικοί γευμάτων είναι δεδομένα εδώ — ποτέ κώδικας. + Βασική τιμή + Ημερήσια + Πάγιο + Ωριαία + Ανά χιλιόμετρο + Ανά άτομο ανά ημέρα + Προσφορά + Αποδεκτή προσφορά + Η προσφορά απευθύνεται στο προφίλ χρέωσης της επαφής. + Η σύμβαση παρέχει τιμοκατάλογο, έκπτωση και όρους. + Δημιουργήθηκε προσφορά + Απορρίφθηκε προσφορά + Έληξε προσφορά + Γραμμή προσφοράς + Προσφορά αρ. + Οι γραμμές παίρνουν την τιμή τους από αυτόν τον τιμοκατάλογο κατά την προσθήκη. + Απεστάλη προσφορά + Αποδεκτή + Απορρίφθηκε + Πρόχειρο + Έληξε + Υποβλήθηκε + Αποσύρθηκε + Προσφορές + Κοστολογημένες εκτιμήσεις για πελάτες και συμβάσεις· μια αποδεκτή προσφορά προγραμματίζει την ανάπτυξη. + Χρέωση + Βάση χρέωσης + Κλήση + Όνομα κλήσης + Φύση κλήσης + Προτεραιότητα + Διακριτικό κλήσης + Τύπος κλήσης + Ακύρωση + Ελάχιστο ακύρωσης (ώρες) + Τα οχήματα χρεώνουν πλήρη ημέρα σε ημέρα ακύρωσης + Πιστοποίηση ληγμένη ή σε αναστολή + Η πιστοποίηση λήγει εντός της περιόδου ανάπτυξης + Κωδικός πιστοποίησης + Προεπισκόπηση χρεώσεων + Ζητήθηκε διαμονή σε ημέρα που την παρείχε ο φορέας. + Μια καταχώριση τιμής δεν έχει τη ζώνη για ορισμένες ώρες. + Δεν υπάρχει καταχώριση για το συμπληρωμένο μέγεθος· χρησιμοποιήθηκε η πλησιέστερη χαμηλότερη τιμή. + Χωρίς εγκεκριμένες, μη τιμολογημένες αναφορές. + Ζητήθηκε αποζημίωση σε ημέρα που ο φορέας παρείχε γεύματα. + Ζητήθηκε ημερήσια αποζημίωση εκτός παραθύρου δικαιώματος. + Ποσό ημερήσιας αποζημίωσης διαφέρει από την τιμή καταλόγου. + Ένα αντικείμενο δεν έχει καταχώριση τιμής και παραλείφθηκε. + Δεν ισχύει τιμοκατάλογος για την ανάπτυξη. + Οι ώρες ταξιδιού περιορίστηκαν από την πολιτική. + Ο υπολογισμός χρεώσεων δεν προετοιμάστηκε. + Κλωνοποίηση + Δημιουργεί νέο κατάλογο με αντίγραφα κάθε καταχώρισης, ζώνης και προσαύξησης — η συνήθης αρχή μιας ανανέωσης. + Κωδικός + Λίστα συμμόρφωσης + Τύπος εγγράφου + Έγγραφα συμμόρφωσης + {0} έγγραφο/α συμμόρφωσης λήγουν ή έχουν λήξει. + PDF ή εικόνα έως 30 MB· αφήστε κενό για να διατηρηθεί το αποθηκευμένο αρχείο. + Ασφάλιση, αποζημίωση εργαζομένων, SAM/CAGE, άδειες και εγγυήσεις με ειδοποιήσεις λήξης· ικανοποιούν απαιτήσεις συμβάσεων και συνοδεύουν τον φάκελο τιμολογίου. + Διαγραφή του στοιχείου; + Διαγραφή του πρόχειρου; + Διαγραφή της σύμβασης; + Διαγραφή του τιμοκαταλόγου; + Δημιουργία πρόχειρου τιμολογίου από αυτές τις χρεώσεις; + Απόσυρση της προσφοράς; + Επικαλυπτόμενη ανάπτυξη + Κενό συνεχούς βάρδιας (λεπτά) + Σύμβαση + Συνδεδεμένες προσφορές, αναπτύξεις, τιμολόγια και κατάσταση συμμόρφωσης της σύμβασης. + Σύμβαση προς λήξη + Αριθμός σύμβασης + Σύμβαση → προεπιλογή προφίλ → πρώτος ενεργός κατάλογος. + Ενεργή + Άλλαξε η κατάσταση σύμβασης + Πρόχειρο + Έληξε + Σε αναστολή + Τερματίστηκε + Τύπος σύμβασης + Σύμβαση-πλαίσιο υπηρεσιών + Άλλο + Έργο + Μόνιμη συμφωνία + Χρέωση εργολάβου + Συμβάσεις + Συμβάσεις υπηρεσιών με τιμοκατάλογο, όρους, διεύθυνση υποβολής και απαιτήσεις εγγράφων. + Μετατράπηκε σε ανάπτυξη + αντίγραφο + Προσθήκη στο ημερολόγιο τμήματος + Δημιουργία κλήσης και ανάπτυξης + Δημιουργία πρόχειρου + ατόμων πλήρωμα + Μέγεθος πληρώματος + Νόμισμα + Πελάτης + E-mail πελάτη + Ημερήσια εγγύηση (ώρες) + Ημερομηνία + Ημέρες + Λόγος απόρριψης + Απορρίφθηκε + Διαγραφή + Διαγραφή καταλόγου + Τοποθεσία παράδοσης + Ανάπτυξη + Συνημμένο ανάπτυξης + Αναπτύξεις + Περιγραφή + Έκπτωση + Αλληλουχία εκπτώσεων + Έκπτωση % + Εγγύηση + Επαγγελματική άδεια + Κωδικός CAGE + Πιστοποιητικό ασφάλισης + Άλλο + Εγγραφή SAM + Φορολογική εγγραφή + Βεβαίωση αποζημίωσης εργαζομένων + Αριθμός εγγράφου + Απαιτήσεις εγγράφων + Τι περιμένει ο πελάτης σε κάθε στάδιο· ένας τύπος εγγράφου συμμόρφωσης ικανοποιείται από τρέχον έγγραφο τμήματος, αλλιώς από συνημμένο ανάπτυξης. + Πρότυπο εγγράφου + Επεξεργασία + Επεξεργασία προσφοράς + Επικεφαλίδα, γραμμές με τιμές, προσαυξήσεις και εκτίμηση. + Επεξεργασία εγγράφου συμμόρφωσης + Επεξεργασία σύμβασης + Επικεφαλίδα, εμπορικοί όροι και απαιτήσεις εγγράφων ανά στάδιο. + Επεξεργασία τιμοκαταλόγου + Οι τιμές είναι ρητά ποσά ανά ζώνη· οι οικογένειες πληρωμάτων μοιράζονται κλειδί ομάδας και διαφέρουν στο μέγεθος. + Ισχύει από + Λήξη + Καταχωρίσεις τιμών + Οι καταχωρίσεις προσωπικού αντιστοιχούν σε κωδικό πιστοποίησης· οι καταχωρίσεις πληρώματος μοιράζονται κλειδί ομάδας με μία ανά μέγεθος· οχήματα και εξοπλισμός δένονται με το στοιχείο λίστας. + Καταχώριση τιμής + Τύπος καταχώρισης + Πλήρωμα + Εξοπλισμός + Προσωπικό (πιστοποίηση) + Υπηρεσία + Όχημα + Εξοπλισμός + Εκτίμηση + Ο φόρος ακολουθεί το προφίλ χρέωσης του πελάτη· οι πραγματικές χρεώσεις τις ημερήσιες αναφορές χρόνου. + Εκτιμώμενη ημερήσια χρέωση πελάτη + Εκτιμώμενο σύνολο + Λήγει στις + Εξαγωγή JSON + Αρχείο + Όνομα είδους + Δωρεάν/ημέρα + Έκπτωση καυσίμου ανά λίτρο + Δημιουργία τιμολογίου + Δημιουργεί πρόχειρο τιμολόγιο από τις παραπάνω χρεώσεις· οι τιμολογημένες αναφορές γίνονται Τιμολογημένες. + Κλειδί ομάδας + π.χ. type6-crew + Συντάξτε προσφορά από τον τιμοκατάλογο του πελάτη, στείλτε τη σε PDF και αποδεχθείτε τη για να ξεκινήσει ο οδηγός ανάπτυξης. + Κάθε έγγραφο έχει δικό του χρόνο προειδοποίησης· οι διαχειριστές ειδοποιούνται όταν μπει στο παράθυρο ή λήξει. + Η σύμβαση ορίζει τιμοκατάλογο, έκπτωση και όρους πληρωμής για κάθε προσφορά και ανάπτυξη· οι απαιτήσεις εγγράφων τροφοδοτούν τη λίστα συμμόρφωσης και τον φάκελο τιμολογίου. + Η σύμβαση δείχνει έναν κατάλογο· αλλιώς ισχύει ο προεπιλεγμένος του προφίλ ή ο πρώτος ενεργός. Κλωνοποιήστε κατάλογο για την επόμενη περίοδο. + Ώρες/ημέρα + Εισαγωγή + Εισαγωγή JSON + Επικολλήστε ή ανεβάστε κατάλογο εξαγόμενο από το Resgrid· τα id και οι σύνδεσμοι τμήματος αφαιρούνται ώστε να μοιράζεται μεταξύ τμημάτων. + Ανενεργό + Αριθμός συμβάντος + Id είδους αποθήκης + E-mail υποβολής τιμολογίων + Πού αποστέλλονται εξ ορισμού τα τιμολόγια ανάπτυξης και ο φάκελός τους. + Τιμολόγια + Εκδότης + Ετικέτα + Τύπος γραμμής + Πλήρωμα + Εξοπλισμός + Ελεύθερη + Προσωπικό + Υπηρεσία + Όχημα + Γραμμές + Επιλέξτε καταχώριση τιμοκαταλόγου για να παγώσει η τιμή, ή πληκτρολογήστε ελεύθερη γραμμή. Ποσότητα × τιμή × ώρες/ημέρα × ημέρες. + Διαχείριση προσφορών + Δημιουργία, επεξεργασία, αποστολή, αποδοχή και μετατροπή προσφορών. Οι διαχειριστές μπορούν πάντα. + Διαχείριση συμβάσεων + Δημιουργία και επεξεργασία συμβάσεων, απαιτήσεων εγγράφων και εγγράφων συμμόρφωσης. Οι διαχειριστές μπορούν πάντα. + Υποχρεωτικό + Σήμανση ως αποδεκτής + Σήμανση ως απορριφθείσας + Σήμανση ως υποβληθείσας + Μέγ. ημέρες ανάπτυξης + Κωδικός γεύματος + Δικαίωμα γευμάτων + Παράθυρα JSON ανά κωδικό γεύματος, π.χ. [{"MealCode":"B","StartsBeforeMinutes":420}]· ημερήσια αποζημίωση εκτός παραθύρου προειδοποιεί, δεν μπλοκάρει. + Λείπει + Προσυμπλήρωση με πολλαπλασιαστή + Βάση + αναμονή + πολλαπλασιαστές ΥΠ δημιουργούν τις ωριαίες ζώνες· οι αποθηκευμένες τιμές μένουν ρητά ποσά. + Όνομα + πλησιέστερη χαμηλότερη τιμή + Νέα προσφορά + Επιλέξτε πελάτη και, αν ισχύει, σύμβαση· ο τιμοκατάλογος και η έκπτωση ακολουθούν. + Νέα σύμβαση + Νέος τιμοκατάλογος + Επόμενο + Όχι + Δεν υπάρχουν προσφορές. + Δεν υπάρχουν εγκεκριμένες, μη τιμολογημένες αναφορές χρόνου. + Η μεταφορά χωρίς 8ωρη ανάπαυση ξεκινά την ημέρα στη ζώνη υπερωριών + Δεν υπάρχουν έγγραφα συμμόρφωσης. + Χωρίς σύμβαση + Δεν υπάρχουν συμβάσεις. + Χωρίς αναπτύξεις. + Δεν υπάρχουν καταχωρίσεις. + Χωρίς τιμολόγια. + Δεν υπάρχουν τιμοκατάλογοι. + Χωρίς απαιτήσεις εγγράφων. + Χωρίς τιμοκατάλογο + Δεν ισχύει τιμοκατάλογος: οι γραμμές χρειάζονται πληκτρολογημένη τιμή. + Κανένα + Σημειώσεις + Άνοιγμα + προαιρετικό + …ή επικολλήστε JSON + Εκτός επαρχίας + Βάση υπερωριών + Συνεχόμενες ώρες + Συνολικές ημερήσιες ώρες + Αρχείο PDF + ελέγχεται ανά ανάπτυξη + Άτομο + Προσωπικό + Σημείο πρόσληψης + Πολιτική χρέωσης + Στρογγυλοποίηση, ελάχιστα, βάση υπερωριών και όρια ταξιδιού που εφαρμόζονται σε κάθε αναφορά χρόνου. + Πόρτα σε πόρτα (το ταξίδι χρεώνεται ως χρόνος ανάπτυξης) + Προσυμπλήρωση + Προσαύξηση + Προσαυξήσεις + Σταθερές ωριαίες προσαυξήσεις ανά ζώνη (νύχτα, κίνδυνος, επικεφαλής)· αθροίζονται, δεν πολλαπλασιάζονται. + Προεπισκόπηση + Προφίλ + Πληροί τα κριτήρια + Ποσ. + Τιμή + Καταχώριση τιμής + Τιμοκατάλογος + Τιμοκατάλογοι + Πίνακες τιμών εργολάβου: πιστοποιήσεις, πληρώματα, οχήματα, εξοπλισμός, προσαυξήσεις και πολιτική χρέωσης. + Αφαίρεση + Επανάνοιγμα + Ζητούμενο + Ζητούμενη λήξη + Ζητούμενη έναρξη + Απαιτούμενες πιστοποιήσεις + Λίστα JSON με κωδικό + ελάχιστο πλήθος που πρέπει να φέρει πλήρωμα αυτού του μεγέθους. + Επαναποστολή προσφοράς + Χρόνος απόκρισης (λεπτά) + Δεν κατέχει τον ρόλο + Στρογγυλοποίηση (λεπτά) + Ικανοποιήθηκε + Αποθήκευση + Η αλλαγή δεν αποθηκεύτηκε. + Αποθηκεύτηκε. + Κατάλογος + Προγραμματισμός ανάπτυξης + Θέση + Θέσεις + Επιλέξτε επαφή… + Αποστολή προσφοράς + Αποστολή σε + Απεστάλη + Αριθμός αιτήματος υπηρεσίας + Ενεργοποίηση + Πίσω σε πρόχειρο + Σήμανση ως ληγμένης + Αναστολή + Τερματισμός + Εμφάνιση ενεργών + Εμφάνιση όλων + Σειρά + Στάδιο + Υποβολή προσφοράς + Ημερήσια αναφορά χρόνου + Έναρξη ανάπτυξης + Υποβολή τιμολογίου + Έναρξη + Κατάσταση + Κατάσταση + Υποσύνολο + Φόρος (εκτίμηση) + Φορολογητέο + Όροι (καθαρές ημέρες) + Όροι + Έως (ώ) + Από (ώ) + Έως ημερομηνία + Μέγ. βαθμίδα (ώ) + Ελάχ. βαθμίδα (ώ) + Τίτλος + Σύνολο προ φόρων + Όριο ταξιδιού ανά ημέρα (ώρες) + Ταξίδι αεροπορικώς + Άτομα που αναπτύσσονται χωρίς θέση μονάδας χρεώνονται με τη δική τους καταχώριση πιστοποίησης. + Μη ανατεθειμένο προσωπικό + Μονάδα + Τύπος μονάδας + Μονάδες + Παύση ασφαλείας (ώρες) + Χρήση προεπιλεγμένου καταλόγου + Ισχύει έως + Προβολή προσφοράς + Ήδη στη λίστα. + Απαιτούμενη πιστοποίηση λήγει κατά την ανάπτυξη. + Άτομο σε θέση δεν έχει απαιτούμενη πιστοποίηση. + Η χορήγηση αποθήκης απέτυχε· το είδος προστέθηκε ως ελεύθερο κείμενο. + Άτομο σε θέση δεν κατέχει τον ρόλο της θέσης. + Κάποιος ή μια μονάδα είναι ήδη σε επικαλυπτόμενη ανάπτυξη. + Περίοδος + Απόσυρση + Ανά μονάδα: ελεύθερα είδη ή id παγίων αποθήκης με καταχώριση ημερήσιας τιμής από τον κατάλογο. + Προσφορά αρ. {0} — {1}: στοιχεία κλήσης, μονάδες, θέσεις πληρώματος, εξοπλισμός, τιμές, επισκόπηση. + Επιβεβαιώστε καταχώριση πιστοποίησης και προσαυξήσεις ανά άτομο, οικογένεια πληρώματος ανά μονάδα και καταχώριση ανά εξοπλισμό· το ημερήσιο ποσό είναι εκτίμηση. + Συμπληρώστε θέσεις από τη λίστα: κατάσταση, στελέχωση, ρόλοι και πιστοποιήσεις ανά άτομο· ληγμένες ή σε αναστολή κόκκινες, όσες λήγουν στην περίοδο πορτοκαλί, επικαλυπτόμενες αναπτύξεις σημειώνονται. Μερικά πληρώματα χρεώνονται στο συμπληρωμένο μέγεθος. + Στοιχεία κλήσης + Εξοπλισμός + Τιμές και προσαυξήσεις + Επισκόπηση και δημιουργία + Θέσεις πληρώματος + Μονάδες + Επιλέξτε τις μονάδες προς ανάπτυξη και αντιστοιχίστε καθεμία σε γραμμή πληρώματος της προσφοράς· η οικογένεια της γραμμής ορίζει την τιμή. + Ναι + Η προσφορά έχει ήδη ανάπτυξη. + Η κλήση χρειάζεται όνομα. + Η επαφή πελάτη δεν βρέθηκε. + Επιλέξτε την επαφή πελάτη. + Η σύμβαση ανήκει σε άλλη επαφή. + Η σύμβαση δεν βρέθηκε. + Η ζητούμενη λήξη προηγείται της έναρξης. + Η έκπτωση πρέπει να είναι μεταξύ 0 και 100 τοις εκατό. + Το e-mail δεν στάλθηκε· ελέγξτε τη διεύθυνση και τις ρυθμίσεις e-mail του τμήματος. + Η γραμμή χρειάζεται περιγραφή, θετική ποσότητα και μη αρνητική τιμή. + Μόνο πρόχειρες ή υποβληθείσες προσφορές επεξεργάζονται. + Προσθέστε τουλάχιστον μία γραμμή πριν την υποβολή. + Χωρίς e-mail παραλήπτη: πληκτρολογήστε ένα ή ορίστε το e-mail χρέωσης της επαφής. + Μόνο αποδεκτή προσφορά μπορεί να προγραμματιστεί. + Η προσφορά δεν βρέθηκε. + Το PDF της προσφοράς δεν δημιουργήθηκε. + Το Advanced Data Protection απέρριψε την εγγραφή· ελέγξτε την κατάσταση προστασίας του τμήματος. + Ο τιμοκατάλογος δεν βρέθηκε. + Αυτή η αλλαγή κατάστασης δεν επιτρέπεται από την τρέχουσα κατάσταση της προσφοράς. + Η προσφορά χρειάζεται τίτλο. + Η ημερομηνία λήξης προηγείται της έναρξης ισχύος. + Το αρχείο είναι προστατευμένο· αποκαλύψτε το πρώτα με άδεια προστατευμένων δεδομένων. + Το αρχείο ξεπερνά τα 30 MB. + Αυτός ο τύπος αρχείου δεν επιτρέπεται. + Το έγγραφο χρειάζεται όνομα. + Το έγγραφο συμμόρφωσης δεν βρέθηκε. + Ο τύπος εγγράφου δεν είναι έγκυρος. + Η ανάπτυξη δεν έχει επαφή πελάτη για τιμολόγηση. + Μόνο χρεώσιμη ανάπτυξη μπορεί να τιμολογηθεί. + Δεν υπάρχει τίποτα προς τιμολόγηση: καμία εγκεκριμένη, μη τιμολογημένη αναφορά δεν παρήγαγε χρεώσεις. + Ενεργή σύμβαση δεν διαγράφεται· τερματίστε τη πρώτα. + Η επαφή πελάτη δεν βρέθηκε. + Επιλέξτε την επαφή πελάτη. + Η ημερομηνία λήξης προηγείται της έναρξης. + Η έκπτωση πρέπει να είναι μεταξύ 0 και 100 τοις εκατό. + Η ημερομηνία λήξης της σύμβασης πέρασε· παρατείνετέ τη πριν την ενεργοποίηση. + Η σύμβαση χρειάζεται όνομα. + Η σύμβαση δεν βρέθηκε. + Το Advanced Data Protection απέρριψε την εγγραφή· ελέγξτε την κατάσταση προστασίας του τμήματος. + Μια απαίτηση εγγράφου έχει άγνωστο στάδιο ή τύπο εγγράφου. + Αυτή η αλλαγή κατάστασης δεν επιτρέπεται από την τρέχουσα κατάσταση της σύμβασης. + Ο τύπος σύμβασης δεν είναι έγκυρος. + Ο ίδιος τύπος ζώνης εμφανίζεται δύο φορές στην καταχώριση. + Μια ζώνη έχει άγνωστο τύπο ή αρνητική τιμή. + Η καταχώριση πληρώματος χρειάζεται μέγεθος. + Η ημερομηνία λήξης προηγείται της έναρξης ισχύος. + Ο τύπος καταχώρισης ή η βάση χρέωσης δεν είναι έγκυρη. + Η καταχώριση χρειάζεται όνομα. + Η καταχώριση τιμής δεν βρέθηκε. + Το JSON δεν είναι έγκυρη εξαγωγή τιμοκαταλόγου. + Ο κατάλογος χρησιμοποιείται από ενεργή ή πρόχειρη σύμβαση και δεν διαγράφεται. + Ο κατάλογος χρειάζεται όνομα. + Ο τιμοκατάλογος δεν βρέθηκε. + Το JSON πολιτικής δεν είναι έγκυρο. + Οι προσαυξήσεις δεν μπορούν να είναι αρνητικές. + Η προσαύξηση χρειάζεται όνομα. + Η προσαύξηση δεν βρέθηκε. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx new file mode 100644 index 000000000..61df303a0 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Accepted + Actions + Activate now + Active + Add + Add band + Add compliance document + Add entry + Add line + Add person + Add premium + Add requirement + Address + This bid carries protected customer data. + Compliance document numbers and files are protected. + This contract carries protected customer data. + Air + Alert lead (days) + All + Amount + Back + Custom + Daily deployment + Daily standby + Deployment + Mileage per km + Out-of-province per person/day + Overtime 1 + Overtime 2 + Per diem meal + Private accommodation/day + Standby + Band + Bands + Deployment/OT1/OT2 thresholds, daily tiers, free units and meal codes are data here — never code. + Base rate + Daily + Fixed + Hourly + Per kilometre + Per person per day + Bid + Bid accepted + The bid is addressed to this contact's billing profile. + A contract supplies its rate schedule, discount and terms. + Bid created + Bid declined + Bid expired + Bid line + Bid # + Lines snapshot their rate from this schedule when added. + Bid sent + Accepted + Declined + Draft + Expired + Submitted + Withdrawn + Bids + Priced estimates for customer contacts and contracts; an accepted bid schedules the deployment call. + Billing + Billing basis + Call + Call name + Nature of call + Priority + Call sign + Call type + Cancel + Cancellation minimum (hours) + Vehicles bill a full day on a cancellation day + Certification expired or suspended + Certification expires within the deployment window + Certification code + Charge preview + Accommodation was claimed on a day the agency supplied it. + A rate entry lacks the band needed for some hours. + No entry for the filled crew size; the nearest lower rate was used. + No approved, unbilled time reports. + A per diem was claimed on a day the agency supplied meals. + A per diem was claimed outside its eligibility window. + A per diem amount differs from the schedule rate. + A subject has no rate entry and was skipped. + No rate schedule applies to this deployment. + Travel hours were capped by the policy. + The charge run could not be prepared. + Clone + Creates a new schedule with copies of every entry, band and premium — the usual start of a renewal. + Code + Compliance checklist + Document type + Compliance documents + {0} compliance document(s) are expiring or expired. + PDF or image up to 30 MB; leave empty to keep the stored file. + Insurance, workers' compensation, SAM/CAGE, licences and bonds with expiry alerts; they satisfy contract requirements and ride the invoice packet. + Delete this item? + Delete this draft bid? + Delete this contract? + Delete this rate schedule? + Generate a draft invoice from these charges? + Withdraw this bid? + Overlapping deployment + Continuous-run gap (minutes) + Contract + Linked bids, deployments, invoices and the compliance state of this contract. + Contract expiring + Contract number + Contract → contact profile default → first active schedule. + Active + Contract status changed + Draft + Expired + Suspended + Terminated + Contract type + Master services + Other + Project + Standing arrangement + Contractor Billing + Contracts + Service contracts with their rate schedule, terms, submission address and document requirements. + Converted to a deployment + copy + Add to the department calendar + Create call and deployment + Create draft + person crew + Crew size + Currency + Customer + Customer e-mail + Daily guarantee (hours) + Date + Days + Decline reason + Declined + Delete + Delete schedule + Delivery location + Deployment + Deployment attachment + Deployments + Description + Discount + Discount cascade + Discount % + Bond + Business licence + CAGE code + Insurance certificate + Other + SAM registration + Tax registration + Workers' comp clearance + Document number + Document requirements + What the customer expects at each stage; a compliance document type is satisfied by a current department document, otherwise a deployment attachment. + Document template + Edit + Edit bid + Header, lines with rate snapshots, premiums and the estimate. + Edit compliance document + Edit contract + Header, commercial terms and the document requirements per stage. + Edit rate schedule + Rates are explicit dollars per band; crew families share a group key and differ by crew size. + Effective on + End + Rate entries + Personnel entries match a certification code; crew entries share a group key and one entry per crew size; vehicle and equipment entries are pinned to the roster item. + Rate entry + Entry type + Crew + Equipment + Personnel (certification) + Service + Vehicle + Equipment + Estimate + Tax follows the customer's billing profile; actual charges follow the daily time reports. + Estimated customer charge per day + Estimated total + Expires on + Export JSON + File + Item name + Free/day + Fuel deduction per litre + Generate invoice + Creates a draft invoice from the charges above; the billed time reports move to Billed. + Group key + e.g. type6-crew + Draft a bid from the customer's rate schedule, send it as a PDF, then accept it to launch the deployment wizard. + Each document carries its own alert lead; department admins are notified when it enters the window or lapses. + A contract fixes the rate schedule, discount and payment terms for every bid and deployment under it; document requirements drive the compliance checklist and the invoice packet. + A contract points at a schedule; otherwise the contact's profile default or the first active schedule applies. Clone a schedule for the next season. + Hours/day + Import + Import JSON + Paste or upload a schedule exported from Resgrid; ids and department links are dropped, so a schedule can be shared between departments. + Inactive + Incident number + Inventory item id + Invoice submission e-mail + Where deployment invoices and their packet are sent by default. + Invoices + Issuer + Label + Line type + Crew + Equipment + Free-form + Personnel + Service + Vehicle + Lines + Pick a rate entry to snapshot its rate, or type a free-form line. Quantity × rate × hours/day × days. + Manage bids + Create, edit, send, accept and convert bids. Department administrators always can. + Manage contracts + Create and edit service contracts, document requirements and compliance documents. Department administrators always can. + Mandatory + Mark as accepted + Mark as declined + Mark as submitted + Max deployment days + Meal code + Meal eligibility + JSON windows per meal code, e.g. [{"MealCode":"B","StartsBeforeMinutes":420}]; a per diem outside its window is warned, not blocked. + Missing + Multiplier prefill + Base + standby + OT multipliers draft the hourly bands; the stored values stay explicit dollars. + Name + nearest lower rate + New bid + Pick the customer and, when one applies, the contract; the rate schedule and discount follow. + New contract + New rate schedule + Next + No + No bids yet. + No approved, unbilled time reports to charge. + No-clear-8 carry-over starts the day in the overtime band + No compliance documents yet. + No contract + No contracts yet. + No deployments. + No rate entries yet. + No invoices. + No rate schedules yet. + No document requirements. + No rate schedule + No rate schedule applies: lines need a typed rate. + None + Notes + Open + optional + …or paste JSON + Out of province + Overtime basis + Consecutive hours + Daily total hours + PDF + checked per deployment + Person + Personnel + Point of hire + Billing policy + Rounding, minimums, overtime basis and travel caps the engine applies to every time report under this schedule. + Portal to portal (travel bills as deployment time) + Prefill + Premium + Premiums + Flat hourly adders per band (night, hazard, lead); they stack and never multiply. + Preview + Profile + Qualified + Qty + Rate + Rate entry + Rate schedule + Rate schedules + Contractor rate tables: certifications, crews, vehicles, equipment, premiums and the billing policy. + Remove + Reopen + Requested + Requested end + Requested start + Required certifications + JSON list of code + minimum count a crew of this size must carry. + Resend bid + Response time (minutes) + Role not held + Rounding (minutes) + Satisfied + Save + The change could not be saved. + Saved. + Schedule + Schedule deployment call + Seat + Seats + Select a contact… + Send bid + Send to + Sent + Service request number + Activate + Back to draft + Mark expired + Suspend + Terminate + Show active + Show all + Order + Stage + Bid submission + Daily time report + Deployment start + Invoice submission + Start + State + Status + Subtotal + Tax (estimated) + Taxable + Terms (net days) + Terms + To (h) + From (h) + Through date + Tier max (h) + Tier min (h) + Title + Total before tax + Travel cap per day (hours) + Travel via air + People deployed without a unit seat bill under their own certification entry. + Unassigned personnel + Unit + Unit type + Units + Unsafe stand-down (hours) + Use the default schedule + Valid until + View bid + Already on this roster. + A required certification expires during the deployment. + A seated person lacks a required certification. + Inventory issue failed; the item was added as free text. + A seated person does not hold the seat's role. + Someone or a unit is already seated on an overlapping deployment. + Window + Withdraw + Per unit: free-text items or inventory asset ids with a daily-rate entry from the schedule. + Bid #{0} — {1}: call details, units, crew seats, equipment, rates, review. + Confirm the certification entry and premiums per person, the crew family per unit and the entry per equipment item; the daily figure is an estimate. + Fill seats from the roster: status, staffing, roles and typed certifications show per person; expired or suspended certifications are red, those expiring inside the window amber, overlapping deployments flagged. Partial crews bill at the filled size. + Call details + Equipment + Rates & premiums + Review & create + Crew seats + Units + Tick the units to deploy and map each to a bid crew line; the line's crew family sets the rate. + Yes + This bid already has a deployment. + The call needs a name. + The customer contact was not found. + Pick the customer contact. + The contract belongs to a different contact. + The contract was not found. + The requested end is before the requested start. + The discount must be between 0 and 100 percent. + The e-mail was not sent; check the address and the department's e-mail settings. + A line needs a description, a positive quantity and a non-negative rate. + Only draft or submitted bids can be edited. + Add at least one line before submitting. + No recipient e-mail: type one or set the contact's billing e-mail. + Only an accepted bid can be scheduled. + The bid was not found. + The bid PDF could not be rendered. + Advanced Data Protection refused the write; check the department's protection status. + The rate schedule was not found. + That status change is not allowed from the bid's current status. + The bid needs a title. + The expiry date is before the effective date. + The file is protected; reveal it with a Protected Data Grant first. + The file is larger than 30 MB. + That file type is not allowed. + The document needs a name. + The compliance document was not found. + The document type is not valid. + The deployment has no customer contact to invoice. + Only a billable deployment can be invoiced. + There is nothing to invoice: no approved, unbilled time reports produced charges. + An active contract cannot be deleted; terminate it first. + The customer contact was not found. + Pick the customer contact. + The end date is before the start date. + The discount must be between 0 and 100 percent. + The contract's end date has passed; extend it before activating. + The contract needs a name. + The contract was not found. + Advanced Data Protection refused the write; check the department's protection status. + A document requirement has an unknown stage or document type. + That status change is not allowed from the contract's current status. + The contract type is not valid. + The same band type appears twice on the entry. + A band has an unknown type or a negative rate. + A crew entry needs a crew size. + The expiry date is before the effective date. + The entry type or billing basis is not valid. + The entry needs a name. + The rate entry was not found. + The JSON is not a valid rate schedule export. + The schedule is used by an active or draft contract and cannot be deleted. + The schedule needs a name. + The rate schedule was not found. + The policy JSON is not valid. + Premium adders cannot be negative. + The premium needs a name. + The premium was not found. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx new file mode 100644 index 000000000..8101830d2 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Aceptada + Acciones + Activar ahora + Activo + Añadir + Añadir banda + Añadir documento de cumplimiento + Añadir entrada + Añadir línea + Añadir persona + Añadir recargo + Añadir requisito + Dirección + Esta oferta contiene datos de cliente protegidos. + Los números y archivos de los documentos de cumplimiento están protegidos. + Este contrato contiene datos de cliente protegidos. + Aéreo + Aviso previo (días) + Todos + Importe + Atrás + Personalizada + Despliegue diario + Espera diaria + Despliegue + Kilometraje por km + Fuera de provincia por persona/día + Horas extra 1 + Horas extra 2 + Dieta de comida + Alojamiento privado/día + Espera + Banda + Bandas + Umbrales de despliegue/HE1/HE2, tramos diarios, unidades libres y códigos de comida son datos aquí, nunca código. + Tarifa base + Diario + Fijo + Por hora + Por kilómetro + Por persona y día + Oferta + Oferta aceptada + La oferta se dirige al perfil de facturación de este contacto. + Un contrato aporta su tabla de tarifas, descuento y condiciones. + Oferta creada + Oferta rechazada + Oferta vencida + Línea de oferta + Oferta n.º + Las líneas toman su tarifa de esta tabla al añadirse. + Oferta enviada + Aceptada + Rechazada + Borrador + Vencida + Presentada + Retirada + Ofertas + Estimaciones con precio para contactos y contratos; una oferta aceptada programa el despliegue. + Facturación + Base de facturación + Llamada + Nombre de la llamada + Naturaleza de la llamada + Prioridad + Indicativo + Tipo de llamada + Cancelar + Mínimo por cancelación (horas) + Los vehículos facturan un día completo en día de cancelación + Certificación vencida o suspendida + La certificación vence durante el despliegue + Código de certificación + Vista previa de cargos + Se reclamó alojamiento en un día en que la agencia lo proveyó. + A una entrada de tarifa le falta la banda para algunas horas. + No hay entrada para el tamaño cubierto; se usó la tarifa inferior más cercana. + No hay partes aprobados sin facturar. + Se reclamó una dieta en un día en que la agencia proveyó comidas. + Se reclamó una dieta fuera de su ventana de elegibilidad. + Un importe de dieta difiere de la tarifa de la tabla. + Un sujeto no tiene entrada de tarifa y se omitió. + No aplica ninguna tabla de tarifas a este despliegue. + Las horas de viaje se limitaron por la política. + No se pudo preparar el cálculo de cargos. + Clonar + Crea una nueva tabla con copias de cada entrada, banda y recargo: el inicio habitual de una renovación. + Código + Lista de cumplimiento + Tipo de documento + Documentos de cumplimiento + {0} documento(s) de cumplimiento están por vencer o vencidos. + PDF o imagen de hasta 30 MB; deje vacío para conservar el archivo guardado. + Seguros, compensación laboral, SAM/CAGE, licencias y fianzas con alertas de vencimiento; cumplen requisitos contractuales y viajan en el paquete de factura. + ¿Eliminar este elemento? + ¿Eliminar este borrador de oferta? + ¿Eliminar este contrato? + ¿Eliminar esta tabla de tarifas? + ¿Generar un borrador de factura con estos cargos? + ¿Retirar esta oferta? + Despliegue solapado + Brecha de turno continuo (minutos) + Contrato + Ofertas, despliegues y facturas vinculados y el estado de cumplimiento de este contrato. + Contrato por vencer + Número de contrato + Contrato → predeterminada del perfil → primera tabla activa. + Activo + Estado de contrato cambiado + Borrador + Vencido + Suspendido + Terminado + Tipo de contrato + Servicios maestros + Otro + Proyecto + Acuerdo permanente + Facturación de contratista + Contratos + Contratos de servicio con su tabla de tarifas, condiciones, dirección de envío y requisitos documentales. + Convertida en despliegue + copia + Añadir al calendario del departamento + Crear llamada y despliegue + Crear borrador + personas en dotación + Tamaño de la dotación + Moneda + Cliente + Correo del cliente + Garantía diaria (horas) + Fecha + Días + Motivo del rechazo + Rechazada + Eliminar + Eliminar tabla + Lugar de entrega + Despliegue + Adjunto del despliegue + Despliegues + Descripción + Descuento + Cascada de descuentos + Descuento % + Fianza + Licencia comercial + Código CAGE + Certificado de seguro + Otro + Registro SAM + Registro fiscal + Certificado de compensación laboral + Número de documento + Requisitos documentales + Lo que espera el cliente en cada fase; un tipo de documento de cumplimiento se satisface con un documento vigente del departamento, si no, con un adjunto del despliegue. + Plantilla de documento + Editar + Editar oferta + Cabecera, líneas con tarifas, recargos y estimación. + Editar documento de cumplimiento + Editar contrato + Cabecera, condiciones comerciales y requisitos documentales por fase. + Editar tabla de tarifas + Las tarifas son importes explícitos por banda; las familias de dotación comparten clave de grupo y difieren en tamaño. + Vigente desde + Fin + Entradas de tarifa + Las entradas de personal coinciden con un código de certificación; las de dotación comparten clave de grupo con una entrada por tamaño; vehículos y equipo se fijan al elemento de la lista. + Entrada de tarifa + Tipo de entrada + Dotación + Equipo + Personal (certificación) + Servicio + Vehículo + Equipo + Estimación + Los impuestos siguen el perfil de facturación del cliente; los cargos reales siguen los partes diarios. + Cargo estimado al cliente por día + Total estimado + Vence el + Exportar JSON + Archivo + Nombre del elemento + Gratis/día + Deducción de combustible por litro + Generar factura + Crea un borrador de factura con los cargos anteriores; los partes facturados pasan a Facturado. + Clave de grupo + p. ej. crew-tipo6 + Redacte una oferta con la tabla de tarifas del cliente, envíela en PDF y acéptela para iniciar el asistente de despliegue. + Cada documento tiene su propio plazo de alerta; se notifica a los administradores cuando entra en la ventana o vence. + Un contrato fija la tabla de tarifas, el descuento y las condiciones de pago de cada oferta y despliegue; los requisitos documentales alimentan la lista de cumplimiento y el paquete de factura. + Un contrato apunta a una tabla; si no, aplica la predeterminada del perfil del contacto o la primera activa. Clone una tabla para la próxima temporada. + Horas/día + Importar + Importar JSON + Pegue o cargue una tabla exportada desde Resgrid; se descartan ids y enlaces de departamento, así que puede compartirse entre departamentos. + Inactivo + Número de incidente + Id de artículo de inventario + Correo de envío de facturas + Adonde se envían por defecto las facturas de despliegue y su paquete. + Facturas + Emisor + Etiqueta + Tipo de línea + Dotación + Equipo + Libre + Personal + Servicio + Vehículo + Líneas + Elija una entrada de tarifa para copiar su valor o escriba una línea libre. Cantidad × tarifa × horas/día × días. + Gestionar ofertas + Crear, editar, enviar, aceptar y convertir ofertas. Los administradores siempre pueden. + Gestionar contratos + Crear y editar contratos, requisitos documentales y documentos de cumplimiento. Los administradores siempre pueden. + Obligatorio + Marcar como aceptada + Marcar como rechazada + Marcar como presentada + Días máximos de despliegue + Código de comida + Elegibilidad de comidas + Ventanas JSON por código de comida, p. ej. [{"MealCode":"B","StartsBeforeMinutes":420}]; una dieta fuera de ventana se avisa, no se bloquea. + Falta + Prellenado por multiplicador + Base + espera + multiplicadores de HE generan las bandas horarias; los valores guardados siguen siendo importes explícitos. + Nombre + tarifa inferior más cercana + Nueva oferta + Elija el cliente y, si aplica, el contrato; la tabla de tarifas y el descuento se derivan. + Nuevo contrato + Nueva tabla de tarifas + Siguiente + No + Aún no hay ofertas. + No hay partes aprobados sin facturar. + El arrastre sin descanso de 8 h inicia el día en banda de horas extra + Aún no hay documentos de cumplimiento. + Sin contrato + Aún no hay contratos. + Sin despliegues. + Aún no hay entradas de tarifa. + Sin facturas. + Aún no hay tablas de tarifas. + Sin requisitos documentales. + Sin tabla de tarifas + No aplica ninguna tabla de tarifas: las líneas necesitan una tarifa manual. + Ninguno + Notas + Abrir + opcional + …o pegue JSON + Fuera de provincia + Base de horas extra + Horas consecutivas + Total diario de horas + Archivo PDF + se verifica por despliegue + Persona + Personal + Punto de contratación + Política de facturación + Redondeo, mínimos, base de horas extra y topes de viaje que el motor aplica a cada parte bajo esta tabla. + Puerta a puerta (el viaje se factura como despliegue) + Prellenar + Recargo + Recargos + Adicionales fijos por hora y banda (noche, riesgo, jefe); se suman y nunca se multiplican. + Vista previa + Perfil + Cualificado + Cant. + Tarifa + Entrada de tarifa + Tabla de tarifas + Tablas de tarifas + Tablas de tarifas de contratista: certificaciones, dotaciones, vehículos, equipo, recargos y la política de facturación. + Quitar + Reabrir + Solicitado + Fin solicitado + Inicio solicitado + Certificaciones requeridas + Lista JSON de código + cantidad mínima que debe tener una dotación de este tamaño. + Reenviar oferta + Tiempo de respuesta (minutos) + Sin el rol requerido + Redondeo (minutos) + Cumplido + Guardar + No se pudo guardar el cambio. + Guardado. + Tabla + Programar despliegue + Puesto + Puestos + Seleccione un contacto… + Enviar oferta + Enviar a + Enviada + Número de solicitud de servicio + Activar + Volver a borrador + Marcar como vencido + Suspender + Terminar + Mostrar activas + Mostrar todo + Orden + Fase + Presentación de oferta + Parte diario + Inicio del despliegue + Presentación de factura + Inicio + Estado + Estado + Subtotal + Impuestos (estimado) + Gravable + Condiciones (días netos) + Condiciones + Hasta (h) + Desde (h) + Hasta la fecha + Tramo máx. (h) + Tramo mín. (h) + Título + Total antes de impuestos + Tope de viaje por día (horas) + Viaje aéreo + Las personas desplegadas sin puesto de unidad se facturan por su propia entrada de certificación. + Personal sin asignar + Unidad + Tipo de unidad + Unidades + Suspensión por seguridad (horas) + Usar la tabla predeterminada + Válida hasta + Ver oferta + Ya está en esta lista. + Una certificación requerida vence durante el despliegue. + A una persona asignada le falta una certificación requerida. + La salida de inventario falló; el elemento se añadió como texto libre. + Una persona asignada no tiene el rol del puesto. + Alguien o una unidad ya está asignado a un despliegue solapado. + Periodo + Retirar + Por unidad: elementos libres o ids de activos de inventario con una entrada de tarifa diaria de la tabla. + Oferta n.º {0} — {1}: detalles de la llamada, unidades, puestos de dotación, equipo, tarifas, revisión. + Confirme la entrada de certificación y los recargos por persona, la familia de dotación por unidad y la entrada por equipo; la cifra diaria es una estimación. + Cubra los puestos desde la plantilla: estado, dotación, roles y certificaciones por persona; las vencidas o suspendidas en rojo, las que vencen en el periodo en ámbar, los despliegues solapados marcados. Las dotaciones parciales se facturan por el tamaño cubierto. + Detalles de la llamada + Equipo + Tarifas y recargos + Revisar y crear + Puestos de dotación + Unidades + Marque las unidades a desplegar y asigne cada una a una línea de dotación de la oferta; su familia fija la tarifa. + + Esta oferta ya tiene un despliegue. + La llamada necesita un nombre. + No se encontró el contacto del cliente. + Seleccione el contacto del cliente. + El contrato pertenece a otro contacto. + No se encontró el contrato. + El fin solicitado es anterior al inicio solicitado. + El descuento debe estar entre 0 y 100 por ciento. + No se envió el correo; revise la dirección y la configuración de correo del departamento. + Una línea necesita descripción, cantidad positiva y tarifa no negativa. + Solo se pueden editar ofertas en borrador o presentadas. + Añada al menos una línea antes de presentar. + Sin correo de destinatario: escriba uno o configure el correo de facturación del contacto. + Solo una oferta aceptada puede programarse. + No se encontró la oferta. + No se pudo generar el PDF de la oferta. + Advanced Data Protection rechazó la escritura; revise el estado de protección del departamento. + No se encontró la tabla de tarifas. + Ese cambio de estado no está permitido desde el estado actual de la oferta. + La oferta necesita un título. + La fecha de vencimiento es anterior a la de vigencia. + El archivo está protegido; revélelo primero con una concesión de datos protegidos. + El archivo supera los 30 MB. + Ese tipo de archivo no está permitido. + El documento necesita un nombre. + No se encontró el documento de cumplimiento. + El tipo de documento no es válido. + El despliegue no tiene contacto de cliente a quien facturar. + Solo un despliegue facturable puede facturarse. + No hay nada que facturar: ningún parte aprobado sin facturar generó cargos. + Un contrato activo no puede eliminarse; termínelo primero. + No se encontró el contacto del cliente. + Seleccione el contacto del cliente. + La fecha de fin es anterior a la de inicio. + El descuento debe estar entre 0 y 100 por ciento. + La fecha de fin del contrato ya pasó; extiéndala antes de activar. + El contrato necesita un nombre. + No se encontró el contrato. + Advanced Data Protection rechazó la escritura; revise el estado de protección del departamento. + Un requisito documental tiene fase o tipo de documento desconocido. + Ese cambio de estado no está permitido desde el estado actual del contrato. + El tipo de contrato no es válido. + El mismo tipo de banda aparece dos veces en la entrada. + Una banda tiene tipo desconocido o tarifa negativa. + Una entrada de dotación necesita un tamaño. + La fecha de vencimiento es anterior a la de vigencia. + El tipo de entrada o la base de facturación no es válido. + La entrada necesita un nombre. + No se encontró la entrada de tarifa. + El JSON no es una exportación válida de tabla de tarifas. + La tabla está en uso por un contrato activo o en borrador y no puede eliminarse. + La tabla necesita un nombre. + No se encontró la tabla de tarifas. + El JSON de la política no es válido. + Los recargos no pueden ser negativos. + El recargo necesita un nombre. + No se encontró el recargo. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx new file mode 100644 index 000000000..a4f5422ae --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Acceptée + Actions + Activer maintenant + Actif + Ajouter + Ajouter une tranche + Ajouter un document de conformité + Ajouter une entrée + Ajouter une ligne + Ajouter une personne + Ajouter une majoration + Ajouter une exigence + Adresse + Cette offre contient des données client protégées. + Les numéros et fichiers des documents de conformité sont protégés. + Ce contrat contient des données client protégées. + Avion + Préavis d'alerte (jours) + Tous + Montant + Retour + Personnalisée + Déploiement journalier + Attente journalière + Déploiement + Kilométrage + Hors province par personne/jour + Heures sup. 1 + Heures sup. 2 + Indemnité repas + Hébergement privé/jour + Attente + Tranche + Tranches + Seuils déploiement/HS1/HS2, paliers journaliers, unités gratuites et codes repas sont des données ici — jamais du code. + Taux de base + Journalier + Forfaitaire + Horaire + Au kilomètre + Par personne et par jour + Offre + Offre acceptée + L'offre est adressée au profil de facturation de ce contact. + Un contrat fournit sa grille tarifaire, sa remise et ses conditions. + Offre créée + Offre refusée + Offre expirée + Ligne d'offre + Offre n° + Les lignes figent leur taux depuis cette grille à l'ajout. + Offre envoyée + Acceptée + Refusée + Brouillon + Expirée + Soumise + Retirée + Offres + Estimations chiffrées pour les contacts et contrats clients ; une offre acceptée planifie le déploiement. + Facturation + Base de facturation + Appel + Nom de l'appel + Nature de l'appel + Priorité + Indicatif + Type d'appel + Annuler + Minimum d'annulation (heures) + Les véhicules facturent une journée complète en cas d'annulation + Certification expirée ou suspendue + La certification expire pendant le déploiement + Code de certification + Aperçu des frais + Un hébergement a été réclamé un jour où l'agence l'a fourni. + Une entrée tarifaire n'a pas la tranche nécessaire pour certaines heures. + Aucune entrée pour l'effectif pourvu ; le taux inférieur le plus proche a été utilisé. + Aucun rapport de temps approuvé non facturé. + Une indemnité a été réclamée un jour où l'agence a fourni les repas. + Une indemnité a été réclamée hors de sa fenêtre d'éligibilité. + Un montant d'indemnité diffère du taux de la grille. + Un sujet n'a pas d'entrée tarifaire et a été ignoré. + Aucune grille tarifaire ne s'applique à ce déploiement. + Les heures de déplacement ont été plafonnées par la règle. + Le calcul des frais n'a pas pu être préparé. + Cloner + Crée une nouvelle grille avec copies de chaque entrée, tranche et majoration — le point de départ habituel d'un renouvellement. + Code + Liste de conformité + Type de document + Documents de conformité + {0} document(s) de conformité arrivent à expiration ou sont expirés. + PDF ou image jusqu'à 30 Mo ; laissez vide pour conserver le fichier enregistré. + Assurances, indemnisation des travailleurs, SAM/CAGE, licences et cautions avec alertes d'expiration ; ils satisfont les exigences contractuelles et accompagnent le dossier de facture. + Supprimer cet élément ? + Supprimer ce brouillon d'offre ? + Supprimer ce contrat ? + Supprimer cette grille tarifaire ? + Générer une facture brouillon à partir de ces frais ? + Retirer cette offre ? + Déploiement chevauchant + Écart de séquence continue (minutes) + Contrat + Offres, déploiements, factures liés et état de conformité de ce contrat. + Contrat arrivant à expiration + Numéro de contrat + Contrat → grille par défaut du profil → première grille active. + Actif + Statut du contrat modifié + Brouillon + Expiré + Suspendu + Résilié + Type de contrat + Contrat-cadre de services + Autre + Projet + Accord permanent + Facturation contractant + Contrats + Contrats de service avec grille tarifaire, conditions, adresse de soumission et exigences documentaires. + Convertie en déploiement + copie + Ajouter au calendrier du service + Créer l'appel et le déploiement + Créer le brouillon + personnes en équipe + Effectif + Devise + Client + E-mail du client + Garantie journalière (heures) + Date + Jours + Motif du refus + Refusée + Supprimer + Supprimer la grille + Lieu de livraison + Déploiement + Pièce jointe du déploiement + Déploiements + Description + Remise + Cascade des remises + Remise % + Caution + Licence commerciale + Code CAGE + Attestation d'assurance + Autre + Enregistrement SAM + Immatriculation fiscale + Attestation d'indemnisation des travailleurs + Numéro de document + Exigences documentaires + Ce que le client attend à chaque étape ; un type de document de conformité est satisfait par un document du service à jour, sinon par une pièce jointe du déploiement. + Modèle de document + Modifier + Modifier l'offre + En-tête, lignes avec taux figés, majorations et estimation. + Modifier le document de conformité + Modifier le contrat + En-tête, conditions commerciales et exigences documentaires par étape. + Modifier la grille tarifaire + Les taux sont des montants explicites par tranche ; les familles d'équipe partagent une clé de groupe et diffèrent par l'effectif. + En vigueur le + Fin + Entrées tarifaires + Les entrées personnel correspondent à un code de certification ; les entrées équipe partagent une clé de groupe avec une entrée par effectif ; véhicules et matériel sont rattachés à l'élément de liste. + Entrée tarifaire + Type d'entrée + Équipe + Matériel + Personnel (certification) + Prestation + Véhicule + Matériel + Estimation + Les taxes suivent le profil de facturation du client ; les frais réels suivent les rapports de temps quotidiens. + Facturation client estimée par jour + Total estimé + Expire le + Exporter JSON + Fichier + Nom de l'élément + Gratuit/jour + Déduction carburant par litre + Générer la facture + Crée une facture brouillon à partir des frais ci-dessus ; les rapports facturés passent à Facturé. + Clé de groupe + p. ex. equipe-type6 + Rédigez une offre à partir de la grille tarifaire du client, envoyez-la en PDF puis acceptez-la pour lancer l'assistant de déploiement. + Chaque document a son propre délai d'alerte ; les administrateurs sont notifiés quand il entre dans la fenêtre ou expire. + Un contrat fixe la grille tarifaire, la remise et les conditions de paiement de chaque offre et déploiement ; les exigences documentaires pilotent la liste de conformité et le dossier de facture. + Un contrat désigne une grille ; sinon la grille par défaut du profil ou la première grille active s'applique. Clonez une grille pour la saison suivante. + Heures/jour + Importer + Importer JSON + Collez ou téléversez une grille exportée de Resgrid ; les identifiants et liens au service sont supprimés, la grille peut donc être partagée entre services. + Inactif + Numéro d'incident + Identifiant d'article d'inventaire + E-mail de soumission des factures + Destination par défaut des factures de déploiement et de leur dossier. + Factures + Émetteur + Libellé + Type de ligne + Équipe + Matériel + Libre + Personnel + Prestation + Véhicule + Lignes + Choisissez une entrée tarifaire pour figer son taux, ou saisissez une ligne libre. Quantité × taux × heures/jour × jours. + Gérer les offres + Créer, modifier, envoyer, accepter et convertir des offres. Les administrateurs le peuvent toujours. + Gérer les contrats + Créer et modifier contrats, exigences documentaires et documents de conformité. Les administrateurs le peuvent toujours. + Obligatoire + Marquer comme acceptée + Marquer comme refusée + Marquer comme soumise + Jours de déploiement max + Code repas + Éligibilité aux repas + Fenêtres JSON par code repas, p. ex. [{"MealCode":"B","StartsBeforeMinutes":420}] ; une indemnité hors fenêtre est signalée, pas bloquée. + Manquant + Préremplissage par multiplicateur + Base + attente + multiplicateurs HS génèrent les tranches horaires ; les valeurs enregistrées restent des montants explicites. + Nom + taux inférieur le plus proche + Nouvelle offre + Choisissez le client et, le cas échéant, le contrat ; la grille tarifaire et la remise en découlent. + Nouveau contrat + Nouvelle grille tarifaire + Suivant + Non + Aucune offre pour l'instant. + Aucun rapport de temps approuvé non facturé. + Le report sans repos de 8 h démarre la journée en tranche heures sup. + Aucun document de conformité pour l'instant. + Sans contrat + Aucun contrat pour l'instant. + Aucun déploiement. + Aucune entrée tarifaire pour l'instant. + Aucune facture. + Aucune grille tarifaire pour l'instant. + Aucune exigence documentaire. + Aucune grille tarifaire + Aucune grille tarifaire ne s'applique : les lignes nécessitent un taux saisi. + Aucun + Notes + Ouvrir + facultatif + …ou collez le JSON + Hors province + Base des heures sup. + Heures consécutives + Total quotidien d'heures + Fichier PDF + vérifié par déploiement + Personne + Personnel + Point d'embauche + Règles de facturation + Arrondi, minima, base des heures supplémentaires et plafonds de déplacement appliqués à chaque rapport sous cette grille. + Porte à porte (le déplacement est facturé en temps de déploiement) + Préremplir + Majoration + Majorations + Suppléments horaires fixes par tranche (nuit, risque, chef) ; ils s'additionnent et ne se multiplient jamais. + Aperçu + Profil + Qualifié + Qté + Taux + Entrée tarifaire + Grille tarifaire + Grilles tarifaires + Grilles tarifaires du contractant : certifications, équipes, véhicules, matériel, majorations et règles de facturation. + Retirer + Rouvrir + Demandé + Fin demandée + Début demandé + Certifications requises + Liste JSON de code + nombre minimum qu'une équipe de cet effectif doit détenir. + Renvoyer l'offre + Délai de réponse (minutes) + Rôle non détenu + Arrondi (minutes) + Satisfait + Enregistrer + La modification n'a pas pu être enregistrée. + Enregistré. + Grille + Planifier le déploiement + Poste + Postes + Choisir un contact… + Envoyer l'offre + Envoyer à + Envoyée + Numéro de demande de service + Activer + Repasser en brouillon + Marquer expiré + Suspendre + Résilier + Afficher les actives + Tout afficher + Ordre + Étape + Soumission de l'offre + Rapport de temps quotidien + Début du déploiement + Soumission de la facture + Début + État + Statut + Sous-total + Taxes (estimation) + Imposable + Conditions (jours nets) + Conditions + Jusqu'à (h) + À partir de (h) + Jusqu'au + Palier max (h) + Palier min (h) + Titre + Total hors taxes + Plafond de déplacement par jour (heures) + Déplacement en avion + Les personnes déployées sans poste d'unité sont facturées selon leur propre entrée de certification. + Personnel non affecté + Unité + Type d'unité + Unités + Arrêt pour sécurité (heures) + Utiliser la grille par défaut + Valable jusqu'au + Voir l'offre + Déjà sur cette liste. + Une certification requise expire pendant le déploiement. + Une personne affectée ne possède pas une certification requise. + La sortie d'inventaire a échoué ; l'élément a été ajouté en texte libre. + Une personne affectée ne détient pas le rôle du poste. + Une personne ou une unité est déjà affectée à un déploiement chevauchant. + Période + Retirer + Par unité : éléments libres ou identifiants d'actifs d'inventaire avec une entrée de taux journalier de la grille. + Offre n° {0} — {1} : détails de l'appel, unités, postes d'équipe, matériel, taux, vérification. + Confirmez l'entrée de certification et les majorations par personne, la famille d'équipe par unité et l'entrée par matériel ; le montant journalier est une estimation. + Pourvoyez les postes depuis l'effectif : statut, présence, rôles et certifications par personne ; expirées ou suspendues en rouge, expirant pendant la période en ambre, déploiements chevauchants signalés. Les équipes partielles sont facturées à l'effectif pourvu. + Détails de l'appel + Matériel + Taux et majorations + Vérifier et créer + Postes d'équipe + Unités + Cochez les unités à déployer et associez chacune à une ligne d'équipe de l'offre ; sa famille détermine le taux. + Oui + Cette offre a déjà un déploiement. + L'appel a besoin d'un nom. + Le contact client est introuvable. + Choisissez le contact client. + Le contrat appartient à un autre contact. + Le contrat est introuvable. + La fin demandée précède le début demandé. + La remise doit être comprise entre 0 et 100 pour cent. + L'e-mail n'a pas été envoyé ; vérifiez l'adresse et les paramètres e-mail du service. + Une ligne a besoin d'une description, d'une quantité positive et d'un taux non négatif. + Seules les offres en brouillon ou soumises peuvent être modifiées. + Ajoutez au moins une ligne avant de soumettre. + Aucun e-mail destinataire : saisissez-en un ou définissez l'e-mail de facturation du contact. + Seule une offre acceptée peut être planifiée. + L'offre est introuvable. + Le PDF de l'offre n'a pas pu être généré. + Advanced Data Protection a refusé l'écriture ; vérifiez l'état de protection du service. + La grille tarifaire est introuvable. + Ce changement de statut n'est pas autorisé depuis le statut actuel de l'offre. + L'offre a besoin d'un titre. + La date d'expiration précède la date d'effet. + Le fichier est protégé ; révélez-le d'abord avec une autorisation de données protégées. + Le fichier dépasse 30 Mo. + Ce type de fichier n'est pas autorisé. + Le document a besoin d'un nom. + Le document de conformité est introuvable. + Le type de document n'est pas valide. + Le déploiement n'a pas de contact client à facturer. + Seul un déploiement facturable peut être facturé. + Rien à facturer : aucun rapport approuvé non facturé n'a produit de frais. + Un contrat actif ne peut pas être supprimé ; résiliez-le d'abord. + Le contact client est introuvable. + Choisissez le contact client. + La date de fin précède la date de début. + La remise doit être comprise entre 0 et 100 pour cent. + La date de fin du contrat est dépassée ; prolongez-la avant d'activer. + Le contrat a besoin d'un nom. + Le contrat est introuvable. + Advanced Data Protection a refusé l'écriture ; vérifiez l'état de protection du service. + Une exigence documentaire a une étape ou un type de document inconnu. + Ce changement de statut n'est pas autorisé depuis le statut actuel du contrat. + Le type de contrat n'est pas valide. + Le même type de tranche apparaît deux fois sur l'entrée. + Une tranche a un type inconnu ou un taux négatif. + Une entrée équipe a besoin d'un effectif. + La date d'expiration précède la date d'effet. + Le type d'entrée ou la base de facturation n'est pas valide. + L'entrée a besoin d'un nom. + L'entrée tarifaire est introuvable. + Le JSON n'est pas un export de grille tarifaire valide. + La grille est utilisée par un contrat actif ou en brouillon et ne peut pas être supprimée. + La grille a besoin d'un nom. + La grille tarifaire est introuvable. + Le JSON de la règle n'est pas valide. + Les majorations ne peuvent pas être négatives. + La majoration a besoin d'un nom. + La majoration est introuvable. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx new file mode 100644 index 000000000..19d15ed4d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Accettata + Azioni + Attiva subito + Attivo + Aggiungi + Aggiungi fascia + Aggiungi documento di conformità + Aggiungi voce + Aggiungi riga + Aggiungi persona + Aggiungi maggiorazione + Aggiungi requisito + Indirizzo + Questa offerta contiene dati cliente protetti. + Numeri e file dei documenti di conformità sono protetti. + Questo contratto contiene dati cliente protetti. + Aereo + Preavviso (giorni) + Tutti + Importo + Indietro + Personalizzata + Intervento giornaliero + Attesa giornaliera + Intervento + Chilometraggio + Fuori provincia per persona/giorno + Straordinario 1 + Straordinario 2 + Diaria pasto + Alloggio privato/giorno + Attesa + Fascia + Fasce + Soglie intervento/STR1/STR2, scaglioni giornalieri, unità gratuite e codici pasto sono dati qui, mai codice. + Tariffa base + Giornaliero + Fisso + Orario + Per chilometro + Per persona al giorno + Offerta + Offerta accettata + L'offerta è indirizzata al profilo di fatturazione di questo contatto. + Un contratto fornisce listino, sconto e condizioni. + Offerta creata + Offerta rifiutata + Offerta scaduta + Riga offerta + Offerta n. + Le righe copiano la tariffa da questo listino quando aggiunte. + Offerta inviata + Accettata + Rifiutata + Bozza + Scaduta + Presentata + Ritirata + Offerte + Preventivi per contatti e contratti cliente; un'offerta accettata pianifica l'intervento. + Fatturazione + Base di fatturazione + Chiamata + Nome chiamata + Natura della chiamata + Priorità + Nominativo + Tipo di chiamata + Annulla + Minimo annullamento (ore) + I veicoli fatturano una giornata intera nel giorno di annullamento + Certificazione scaduta o sospesa + La certificazione scade durante l'intervento + Codice certificazione + Anteprima addebiti + È stato richiesto un alloggio in un giorno fornito dall'agenzia. + A una voce di listino manca la fascia per alcune ore. + Nessuna voce per la dimensione coperta; usata la tariffa inferiore più vicina. + Nessun rapporto approvato non fatturato. + Una diaria è stata richiesta in un giorno con pasti forniti dall'agenzia. + Una diaria è stata richiesta fuori dalla finestra di idoneità. + Un importo di diaria differisce dalla tariffa del listino. + Un soggetto non ha voce di listino ed è stato saltato. + Nessun listino si applica a questo intervento. + Le ore di viaggio sono state limitate dalla regola. + Il calcolo degli addebiti non è stato preparato. + Clona + Crea un nuovo listino con copie di ogni voce, fascia e maggiorazione: il tipico inizio di un rinnovo. + Codice + Checklist di conformità + Tipo di documento + Documenti di conformità + {0} documento/i di conformità in scadenza o scaduti. + PDF o immagine fino a 30 MB; lascia vuoto per mantenere il file salvato. + Assicurazioni, infortuni sul lavoro, SAM/CAGE, licenze e fideiussioni con avvisi di scadenza; soddisfano i requisiti contrattuali e viaggiano nel pacchetto fattura. + Eliminare questo elemento? + Eliminare questa bozza di offerta? + Eliminare questo contratto? + Eliminare questo listino? + Generare una bozza di fattura da questi addebiti? + Ritirare questa offerta? + Intervento sovrapposto + Intervallo turno continuo (minuti) + Contratto + Offerte, interventi, fatture collegati e stato di conformità di questo contratto. + Contratto in scadenza + Numero contratto + Contratto → predefinito del profilo → primo listino attivo. + Attivo + Stato contratto cambiato + Bozza + Scaduto + Sospeso + Risolto + Tipo di contratto + Servizi quadro + Altro + Progetto + Accordo permanente + Fatturazione appaltatore + Contratti + Contratti di servizio con listino, condizioni, indirizzo di invio e requisiti documentali. + Convertita in intervento + copia + Aggiungi al calendario del dipartimento + Crea chiamata e intervento + Crea bozza + persone in squadra + Dimensione squadra + Valuta + Cliente + E-mail del cliente + Garanzia giornaliera (ore) + Data + Giorni + Motivo del rifiuto + Rifiutata + Elimina + Elimina listino + Luogo di consegna + Intervento + Allegato dell'intervento + Interventi + Descrizione + Sconto + Cascata sconti + Sconto % + Fideiussione + Licenza commerciale + Codice CAGE + Certificato assicurativo + Altro + Registrazione SAM + Registrazione fiscale + Attestazione infortuni sul lavoro + Numero documento + Requisiti documentali + Cosa si aspetta il cliente in ogni fase; un tipo di documento di conformità è soddisfatto da un documento del dipartimento in corso di validità, altrimenti da un allegato dell'intervento. + Modello documento + Modifica + Modifica offerta + Intestazione, righe con tariffe, maggiorazioni e stima. + Modifica documento di conformità + Modifica contratto + Intestazione, condizioni commerciali e requisiti documentali per fase. + Modifica listino + Le tariffe sono importi espliciti per fascia; le famiglie squadra condividono una chiave di gruppo e differiscono per dimensione. + Valido dal + Fine + Voci di listino + Le voci personale corrispondono a un codice certificazione; le voci squadra condividono una chiave di gruppo con una voce per dimensione; veicoli e attrezzature sono fissati all'elemento in lista. + Voce di listino + Tipo di voce + Squadra + Attrezzatura + Personale (certificazione) + Servizio + Veicolo + Attrezzature + Stima + Le imposte seguono il profilo di fatturazione del cliente; gli addebiti reali seguono i rapporti giornalieri. + Addebito cliente stimato al giorno + Totale stimato + Scade il + Esporta JSON + File + Nome elemento + Gratis/giorno + Detrazione carburante al litro + Genera fattura + Crea una bozza di fattura dagli addebiti sopra; i rapporti fatturati passano a Fatturato. + Chiave di gruppo + es. squadra-tipo6 + Prepara un'offerta dal listino del cliente, inviala in PDF e accettala per avviare la procedura guidata. + Ogni documento ha il proprio preavviso; gli amministratori vengono avvisati quando entra nella finestra o scade. + Un contratto fissa listino, sconto e termini di pagamento per ogni offerta e intervento; i requisiti documentali guidano la checklist di conformità e il pacchetto fattura. + Un contratto punta a un listino; altrimenti vale il predefinito del profilo o il primo listino attivo. Clona un listino per la prossima stagione. + Ore/giorno + Importa + Importa JSON + Incolla o carica un listino esportato da Resgrid; id e riferimenti al dipartimento vengono rimossi, così il listino è condivisibile tra dipartimenti. + Inattivo + Numero incidente + Id articolo inventario + E-mail invio fatture + Dove vengono inviati per impostazione predefinita fatture di intervento e pacchetto. + Fatture + Emittente + Etichetta + Tipo di riga + Squadra + Attrezzatura + Libera + Personale + Servizio + Veicolo + Righe + Scegli una voce del listino per copiarne la tariffa, oppure inserisci una riga libera. Quantità × tariffa × ore/giorno × giorni. + Gestisci offerte + Creare, modificare, inviare, accettare e convertire offerte. Gli amministratori possono sempre. + Gestisci contratti + Creare e modificare contratti, requisiti documentali e documenti di conformità. Gli amministratori possono sempre. + Obbligatorio + Segna come accettata + Segna come rifiutata + Segna come presentata + Giorni massimi di intervento + Codice pasto + Diritto ai pasti + Finestre JSON per codice pasto, es. [{"MealCode":"B","StartsBeforeMinutes":420}]; una diaria fuori finestra genera un avviso, non un blocco. + Mancante + Precompilazione con moltiplicatore + Base + attesa + moltiplicatori STR generano le fasce orarie; i valori salvati restano importi espliciti. + Nome + tariffa inferiore più vicina + Nuova offerta + Scegli il cliente e, se applicabile, il contratto; listino e sconto seguono. + Nuovo contratto + Nuovo listino + Avanti + No + Nessuna offerta. + Nessun rapporto approvato da fatturare. + Il riporto senza 8 ore di riposo inizia la giornata in fascia straordinario + Nessun documento di conformità. + Nessun contratto + Nessun contratto. + Nessun intervento. + Nessuna voce di listino. + Nessuna fattura. + Nessun listino. + Nessun requisito documentale. + Nessun listino + Nessun listino applicabile: le righe richiedono una tariffa digitata. + Nessuno + Note + Apri + facoltativo + …oppure incolla JSON + Fuori provincia + Base straordinari + Ore consecutive + Totale ore giornaliere + File PDF + verificato per intervento + Persona + Personale + Punto di assunzione + Regole di fatturazione + Arrotondamento, minimi, base straordinari e limiti di viaggio applicati a ogni rapporto sotto questo listino. + Porta a porta (il viaggio si fattura come intervento) + Precompila + Maggiorazione + Maggiorazioni + Supplementi orari fissi per fascia (notte, rischio, capo); si sommano e non si moltiplicano mai. + Anteprima + Profilo + Qualificato + Qtà + Tariffa + Voce di listino + Listino + Listini + Tabelle tariffarie appaltatore: certificazioni, squadre, veicoli, attrezzature, maggiorazioni e regole di fatturazione. + Rimuovi + Riapri + Richiesto + Fine richiesta + Inizio richiesto + Certificazioni richieste + Elenco JSON di codice + numero minimo che una squadra di questa dimensione deve avere. + Reinvia offerta + Tempo di risposta (minuti) + Ruolo non posseduto + Arrotondamento (minuti) + Soddisfatto + Salva + Impossibile salvare la modifica. + Salvato. + Listino + Pianifica intervento + Posto + Posti + Seleziona un contatto… + Invia offerta + Invia a + Inviata + Numero richiesta di servizio + Attiva + Torna a bozza + Segna scaduto + Sospendi + Risolvi + Mostra attivi + Mostra tutto + Ordine + Fase + Presentazione offerta + Rapporto giornaliero + Inizio intervento + Presentazione fattura + Inizio + Stato + Stato + Subtotale + Imposte (stima) + Imponibile + Termini (giorni netti) + Condizioni + A (h) + Da (h) + Fino al + Scaglione max (h) + Scaglione min (h) + Titolo + Totale al netto delle imposte + Limite viaggio al giorno (ore) + Viaggio aereo + Le persone impiegate senza posto in unità sono fatturate con la propria voce di certificazione. + Personale non assegnato + Unità + Tipo di unità + Unità + Sospensione per sicurezza (ore) + Usa il listino predefinito + Valida fino al + Vedi offerta + Già in questo organico. + Una certificazione richiesta scade durante l'intervento. + A una persona assegnata manca una certificazione richiesta. + Uscita dall'inventario non riuscita; l'elemento è stato aggiunto come testo libero. + Una persona assegnata non possiede il ruolo del posto. + Una persona o un'unità è già assegnata a un intervento sovrapposto. + Periodo + Ritira + Per unità: voci libere o id di asset di inventario con una voce a tariffa giornaliera dal listino. + Offerta n. {0} — {1}: dettagli chiamata, unità, posti squadra, attrezzature, tariffe, revisione. + Conferma voce certificazione e maggiorazioni per persona, famiglia squadra per unità e voce per attrezzatura; l'importo giornaliero è una stima. + Assegna i posti dall'organico: stato, presenza, ruoli e certificazioni per persona; scadute o sospese in rosso, in scadenza nel periodo in ambra, interventi sovrapposti segnalati. Le squadre parziali si fatturano alla dimensione coperta. + Dettagli chiamata + Attrezzature + Tariffe e maggiorazioni + Rivedi e crea + Posti squadra + Unità + Seleziona le unità da impiegare e collega ciascuna a una riga squadra dell'offerta; la famiglia della riga fissa la tariffa. + + Questa offerta ha già un intervento. + La chiamata richiede un nome. + Contatto cliente non trovato. + Scegli il contatto cliente. + Il contratto appartiene a un altro contatto. + Contratto non trovato. + La fine richiesta precede l'inizio richiesto. + Lo sconto deve essere tra 0 e 100 per cento. + L'e-mail non è stata inviata; verifica l'indirizzo e le impostazioni e-mail del dipartimento. + Una riga richiede descrizione, quantità positiva e tariffa non negativa. + Solo le offerte in bozza o presentate sono modificabili. + Aggiungi almeno una riga prima di presentare. + Nessuna e-mail destinatario: inseriscine una o imposta l'e-mail di fatturazione del contatto. + Solo un'offerta accettata può essere pianificata. + Offerta non trovata. + Impossibile generare il PDF dell'offerta. + Advanced Data Protection ha rifiutato la scrittura; verifica lo stato di protezione del dipartimento. + Listino non trovato. + Questo cambio di stato non è consentito dallo stato attuale dell'offerta. + L'offerta richiede un titolo. + La data di scadenza precede la data di validità. + Il file è protetto; rivelalo prima con un'autorizzazione ai dati protetti. + Il file supera i 30 MB. + Questo tipo di file non è consentito. + Il documento richiede un nome. + Documento di conformità non trovato. + Il tipo di documento non è valido. + L'intervento non ha un contatto cliente da fatturare. + Solo un intervento fatturabile può essere fatturato. + Nulla da fatturare: nessun rapporto approvato non fatturato ha prodotto addebiti. + Un contratto attivo non può essere eliminato; risolvilo prima. + Contatto cliente non trovato. + Scegli il contatto cliente. + La data di fine precede la data di inizio. + Lo sconto deve essere tra 0 e 100 per cento. + La data di fine del contratto è passata; prorogala prima di attivare. + Il contratto richiede un nome. + Contratto non trovato. + Advanced Data Protection ha rifiutato la scrittura; verifica lo stato di protezione del dipartimento. + Un requisito documentale ha fase o tipo di documento sconosciuto. + Questo cambio di stato non è consentito dallo stato attuale del contratto. + Il tipo di contratto non è valido. + Lo stesso tipo di fascia compare due volte nella voce. + Una fascia ha tipo sconosciuto o tariffa negativa. + Una voce squadra richiede una dimensione. + La data di scadenza precede la data di validità. + Tipo di voce o base di fatturazione non valido. + La voce richiede un nome. + Voce di listino non trovata. + Il JSON non è un'esportazione di listino valida. + Il listino è usato da un contratto attivo o in bozza e non può essere eliminato. + Il listino richiede un nome. + Listino non trovato. + Il JSON della regola non è valido. + Le maggiorazioni non possono essere negative. + La maggiorazione richiede un nome. + Maggiorazione non trovata. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx new file mode 100644 index 000000000..d54252aed --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Przyjęta + Akcje + Aktywuj teraz + Aktywny + Dodaj + Dodaj pasmo + Dodaj dokument zgodności + Dodaj pozycję + Dodaj pozycję + Dodaj osobę + Dodaj dodatek + Dodaj wymaganie + Adres + Ta oferta zawiera chronione dane klienta. + Numery i pliki dokumentów zgodności są chronione. + Ta umowa zawiera chronione dane klienta. + Lot + Wyprzedzenie alertu (dni) + Wszystkie + Kwota + Wstecz + Niestandardowe + Wyjazd dzienny + Gotowość dzienna + Wyjazd + Stawka za km + Poza prowincją na osobę/dzień + Nadgodziny 1 + Nadgodziny 2 + Dieta na posiłek + Nocleg prywatny/dzień + Gotowość + Pasmo + Pasma + Progi wyjazd/ND1/ND2, progi dzienne, jednostki wolne i kody posiłków to tutaj dane — nigdy kod. + Stawka bazowa + Dziennie + Ryczałt + Godzinowo + Za kilometr + Na osobę dziennie + Oferta + Oferta przyjęta + Oferta jest kierowana do profilu rozliczeniowego tego kontaktu. + Umowa dostarcza cennik, rabat i warunki. + Oferta utworzona + Oferta odrzucona + Oferta wygasła + Pozycja oferty + Oferta nr + Pozycje pobierają stawkę z tego cennika przy dodaniu. + Oferta wysłana + Przyjęta + Odrzucona + Szkic + Wygasła + Złożona + Wycofana + Oferty + Wyceny dla kontaktów i umów klientów; przyjęta oferta planuje wyjazd. + Rozliczenia + Podstawa rozliczenia + Wezwanie + Nazwa wezwania + Charakter wezwania + Priorytet + Znak wywoławczy + Typ wezwania + Anuluj + Minimum przy odwołaniu (godz.) + Pojazdy naliczają pełny dzień w dniu odwołania + Certyfikat wygasły lub zawieszony + Certyfikat wygasa w trakcie wyjazdu + Kod certyfikatu + Podgląd opłat + Nocleg zgłoszony w dniu, gdy agencja go zapewniła. + Pozycji cennika brakuje pasma dla części godzin. + Brak pozycji dla obsadzonej liczebności; użyto najbliższej niższej stawki. + Brak zatwierdzonych, nierozliczonych raportów. + Dieta zgłoszona w dniu, gdy agencja zapewniła posiłki. + Dieta zgłoszona poza oknem uprawnienia. + Kwota diety różni się od stawki cennika. + Podmiot nie ma pozycji cennika i został pominięty. + Do tego wyjazdu nie ma zastosowania żaden cennik. + Godziny podróży ograniczono zgodnie z zasadami. + Nie udało się przygotować naliczenia. + Klonuj + Tworzy nowy cennik z kopiami wszystkich pozycji, pasm i dodatków — typowy początek odnowienia. + Kod + Lista zgodności + Typ dokumentu + Dokumenty zgodności + {0} dokument(y) zgodności wygasa(ją) lub wygasł(y). + PDF lub obraz do 30 MB; pozostaw puste, aby zachować zapisany plik. + Ubezpieczenia, odszkodowania pracownicze, SAM/CAGE, licencje i gwarancje z alertami wygaśnięcia; spełniają wymagania umów i trafiają do pakietu faktury. + Usunąć ten element? + Usunąć ten szkic oferty? + Usunąć tę umowę? + Usunąć ten cennik? + Wygenerować szkic faktury z tych opłat? + Wycofać tę ofertę? + Nakładający się wyjazd + Przerwa ciągłości (minuty) + Umowa + Powiązane oferty, wyjazdy, faktury i stan zgodności tej umowy. + Umowa wygasa + Numer umowy + Umowa → domyślny cennik profilu → pierwszy aktywny cennik. + Aktywna + Zmieniono status umowy + Szkic + Wygasła + Zawieszona + Rozwiązana + Rodzaj umowy + Umowa ramowa + Inne + Projekt + Umowa stała + Rozliczenia wykonawcy + Umowy + Umowy o świadczenie usług z cennikiem, warunkami, adresem składania i wymaganiami dokumentowymi. + Przekształcona w wyjazd + kopia + Dodaj do kalendarza jednostki + Utwórz wezwanie i wyjazd + Utwórz szkic + osobowa załoga + Liczebność załogi + Waluta + Klient + E-mail klienta + Gwarancja dzienna (godz.) + Data + Dni + Powód odrzucenia + Odrzucona + Usuń + Usuń cennik + Miejsce dostawy + Wyjazd + Załącznik wyjazdu + Wyjazdy + Opis + Rabat + Kaskada rabatów + Rabat % + Gwarancja + Licencja działalności + Kod CAGE + Certyfikat ubezpieczenia + Inne + Rejestracja SAM + Rejestracja podatkowa + Zaświadczenie odszkodowań pracowniczych + Numer dokumentu + Wymagania dokumentowe + Czego klient oczekuje na każdym etapie; typ dokumentu zgodności spełnia aktualny dokument jednostki, w przeciwnym razie załącznik wyjazdu. + Szablon dokumentu + Edytuj + Edytuj ofertę + Nagłówek, pozycje ze stawkami, dodatki i szacunek. + Edytuj dokument zgodności + Edytuj umowę + Nagłówek, warunki handlowe i wymagania dokumentowe na etap. + Edytuj cennik + Stawki to jawne kwoty na pasmo; rodziny załóg dzielą klucz grupy i różnią się liczebnością. + Obowiązuje od + Koniec + Pozycje cennika + Pozycje personelu odpowiadają kodowi certyfikatu; pozycje załóg dzielą klucz grupy, po jednej na liczebność; pojazdy i sprzęt są przypięte do elementu listy. + Pozycja cennika + Rodzaj pozycji + Załoga + Sprzęt + Personel (certyfikat) + Usługa + Pojazd + Sprzęt + Szacunek + Podatek wynika z profilu rozliczeniowego klienta; rzeczywiste opłaty wynikają z dziennych raportów czasu. + Szacowana opłata dla klienta dziennie + Szacowana suma + Wygasa + Eksportuj JSON + Plik + Nazwa elementu + Wolne/dzień + Potrącenie za paliwo na litr + Wygeneruj fakturę + Tworzy szkic faktury z powyższych opłat; rozliczone raporty przechodzą do stanu Zafakturowane. + Klucz grupy + np. zaloga-typ6 + Przygotuj ofertę na podstawie cennika klienta, wyślij ją jako PDF, a następnie przyjmij, aby uruchomić kreator wyjazdu. + Każdy dokument ma własne wyprzedzenie alertu; administratorzy są powiadamiani, gdy wchodzi w okno lub wygasa. + Umowa ustala cennik, rabat i warunki płatności dla każdej oferty i wyjazdu; wymagania dokumentowe sterują listą zgodności i pakietem faktury. + Umowa wskazuje cennik; w przeciwnym razie obowiązuje domyślny cennik profilu lub pierwszy aktywny. Sklonuj cennik na kolejny sezon. + Godz./dzień + Importuj + Importuj JSON + Wklej lub prześlij cennik wyeksportowany z Resgrid; identyfikatory i powiązania z jednostką są usuwane, więc cennik można współdzielić. + Nieaktywny + Numer zdarzenia + Id pozycji magazynowej + E-mail do składania faktur + Dokąd domyślnie wysyłane są faktury wyjazdu i ich pakiet. + Faktury + Wystawca + Etykieta + Rodzaj pozycji + Załoga + Sprzęt + Dowolna + Personel + Usługa + Pojazd + Pozycje + Wybierz pozycję cennika, aby pobrać stawkę, lub wpisz pozycję dowolną. Ilość × stawka × godz./dzień × dni. + Zarządzaj ofertami + Tworzenie, edycja, wysyłanie, przyjmowanie i konwersja ofert. Administratorzy zawsze mogą. + Zarządzaj umowami + Tworzenie i edycja umów, wymagań dokumentowych i dokumentów zgodności. Administratorzy zawsze mogą. + Obowiązkowe + Oznacz jako przyjętą + Oznacz jako odrzuconą + Oznacz jako złożoną + Maks. dni wyjazdu + Kod posiłku + Uprawnienia do posiłków + Okna JSON na kod posiłku, np. [{"MealCode":"B","StartsBeforeMinutes":420}]; dieta poza oknem jest ostrzeżeniem, nie blokadą. + Brak + Wstępne wypełnienie mnożnikiem + Baza + gotowość + mnożniki nadgodzin tworzą pasma godzinowe; zapisane wartości pozostają jawnymi kwotami. + Nazwa + najbliższa niższa stawka + Nowa oferta + Wybierz klienta i, jeśli dotyczy, umowę; cennik i rabat wynikają z nich. + Nowa umowa + Nowy cennik + Dalej + Nie + Brak ofert. + Brak zatwierdzonych, nierozliczonych raportów czasu. + Przeniesienie bez 8 h przerwy zaczyna dzień w paśmie nadgodzin + Brak dokumentów zgodności. + Bez umowy + Brak umów. + Brak wyjazdów. + Brak pozycji cennika. + Brak faktur. + Brak cenników. + Brak wymagań dokumentowych. + Bez cennika + Brak obowiązującego cennika: pozycje wymagają wpisanej stawki. + Brak + Notatki + Otwórz + opcjonalne + …lub wklej JSON + Poza prowincją + Podstawa nadgodzin + Godziny ciągłe + Suma godzin dziennie + Plik PDF + sprawdzane per wyjazd + Osoba + Personel + Miejsce zatrudnienia + Zasady rozliczeń + Zaokrąglanie, minima, podstawa nadgodzin i limity podróży stosowane do każdego raportu w tym cenniku. + Od bramy do bramy (podróż liczona jak czas wyjazdu) + Wypełnij + Dodatek + Dodatki + Stałe dodatki godzinowe na pasmo (noc, ryzyko, kierownik); sumują się i nigdy nie mnożą. + Podgląd + Profil + Uprawniony + Ilość + Stawka + Pozycja cennika + Cennik + Cenniki + Tabele stawek wykonawcy: certyfikaty, załogi, pojazdy, sprzęt, dodatki i zasady rozliczeń. + Usuń + Otwórz ponownie + Wnioskowany + Wnioskowany koniec + Wnioskowany początek + Wymagane certyfikaty + Lista JSON kodów i minimalnej liczby, jaką musi mieć załoga tej wielkości. + Wyślij ponownie + Czas reakcji (minuty) + Brak roli + Zaokrąglanie (minuty) + Spełnione + Zapisz + Nie udało się zapisać zmiany. + Zapisano. + Cennik + Zaplanuj wyjazd + Miejsce + Miejsca + Wybierz kontakt… + Wyślij ofertę + Wyślij do + Wysłano + Numer zlecenia + Aktywuj + Wróć do szkicu + Oznacz jako wygasłą + Zawieś + Rozwiąż + Pokaż aktywne + Pokaż wszystko + Kolejność + Etap + Złożenie oferty + Dzienny raport czasu + Początek wyjazdu + Złożenie faktury + Początek + Stan + Status + Suma częściowa + Podatek (szacunkowo) + Opodatkowane + Termin (dni netto) + Warunki + Do (h) + Od (h) + Do dnia + Próg max (h) + Próg min (h) + Tytuł + Suma przed podatkiem + Limit podróży dziennie (godz.) + Podróż lotnicza + Osoby wysłane bez miejsca w jednostce rozliczane są według własnej pozycji certyfikatu. + Personel nieprzypisany + Jednostka + Typ jednostki + Jednostki + Wstrzymanie ze względów bezpieczeństwa (godz.) + Użyj domyślnego cennika + Ważna do + Pokaż ofertę + Już na tej liście. + Wymagany certyfikat wygasa w trakcie wyjazdu. + Przypisanej osobie brakuje wymaganego certyfikatu. + Wydanie z magazynu nie powiodło się; element dodano jako tekst dowolny. + Przypisana osoba nie posiada roli miejsca. + Ktoś lub jednostka jest już przypisany do nakładającego się wyjazdu. + Okres + Wycofaj + Na jednostkę: pozycje dowolne lub identyfikatory zasobów magazynowych z pozycją stawki dziennej z cennika. + Oferta nr {0} — {1}: szczegóły wezwania, jednostki, miejsca w załodze, sprzęt, stawki, przegląd. + Potwierdź pozycję certyfikatu i dodatki na osobę, rodzinę załogi na jednostkę i pozycję na sprzęt; kwota dzienna jest szacunkowa. + Obsadź miejsca z listy: status, obsada, role i certyfikaty na osobę; wygasłe lub zawieszone na czerwono, wygasające w okresie na żółto, nakładające się wyjazdy oznaczone. Niepełne załogi rozliczane są według obsadzonej liczebności. + Szczegóły wezwania + Sprzęt + Stawki i dodatki + Przegląd i utworzenie + Miejsca w załodze + Jednostki + Zaznacz jednostki do wysłania i przypisz każdą do pozycji załogi w ofercie; rodzina pozycji ustala stawkę. + Tak + Ta oferta ma już wyjazd. + Wezwanie wymaga nazwy. + Nie znaleziono kontaktu klienta. + Wybierz kontakt klienta. + Umowa należy do innego kontaktu. + Nie znaleziono umowy. + Wnioskowany koniec jest wcześniejszy niż początek. + Rabat musi mieścić się między 0 a 100 procent. + E-mail nie został wysłany; sprawdź adres i ustawienia poczty jednostki. + Pozycja wymaga opisu, dodatniej ilości i nieujemnej stawki. + Można edytować tylko oferty w wersji roboczej lub złożone. + Dodaj co najmniej jedną pozycję przed złożeniem. + Brak adresu odbiorcy: wpisz go lub ustaw e-mail rozliczeniowy kontaktu. + Zaplanować można tylko przyjętą ofertę. + Nie znaleziono oferty. + Nie udało się wygenerować pliku PDF oferty. + Advanced Data Protection odrzuciło zapis; sprawdź stan ochrony jednostki. + Nie znaleziono cennika. + Ta zmiana statusu nie jest dozwolona z bieżącego statusu oferty. + Oferta wymaga tytułu. + Data wygaśnięcia jest wcześniejsza niż data obowiązywania. + Plik jest chroniony; najpierw odsłoń go za pomocą uprawnienia do danych chronionych. + Plik przekracza 30 MB. + Ten typ pliku nie jest dozwolony. + Dokument wymaga nazwy. + Nie znaleziono dokumentu zgodności. + Typ dokumentu jest nieprawidłowy. + Wyjazd nie ma kontaktu klienta do zafakturowania. + Fakturować można tylko wyjazd rozliczalny. + Nie ma czego fakturować: żaden zatwierdzony, nierozliczony raport nie wygenerował opłat. + Aktywnej umowy nie można usunąć; najpierw ją rozwiąż. + Nie znaleziono kontaktu klienta. + Wybierz kontakt klienta. + Data zakończenia jest wcześniejsza niż data rozpoczęcia. + Rabat musi mieścić się między 0 a 100 procent. + Data zakończenia umowy minęła; przedłuż ją przed aktywacją. + Umowa wymaga nazwy. + Nie znaleziono umowy. + Advanced Data Protection odrzuciło zapis; sprawdź stan ochrony jednostki. + Wymaganie dokumentowe ma nieznany etap lub typ dokumentu. + Ta zmiana statusu nie jest dozwolona z bieżącego statusu umowy. + Rodzaj umowy jest nieprawidłowy. + Ten sam typ pasma występuje dwukrotnie w pozycji. + Pasmo ma nieznany typ lub ujemną stawkę. + Pozycja załogi wymaga liczebności. + Data wygaśnięcia jest wcześniejsza niż data obowiązywania. + Rodzaj pozycji lub podstawa rozliczenia jest nieprawidłowa. + Pozycja wymaga nazwy. + Nie znaleziono pozycji cennika. + JSON nie jest prawidłowym eksportem cennika. + Cennik jest używany przez aktywną lub szkicową umowę i nie można go usunąć. + Cennik wymaga nazwy. + Nie znaleziono cennika. + JSON zasad jest nieprawidłowy. + Dodatki nie mogą być ujemne. + Dodatek wymaga nazwy. + Nie znaleziono dodatku. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx new file mode 100644 index 000000000..61df303a0 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Accepted + Actions + Activate now + Active + Add + Add band + Add compliance document + Add entry + Add line + Add person + Add premium + Add requirement + Address + This bid carries protected customer data. + Compliance document numbers and files are protected. + This contract carries protected customer data. + Air + Alert lead (days) + All + Amount + Back + Custom + Daily deployment + Daily standby + Deployment + Mileage per km + Out-of-province per person/day + Overtime 1 + Overtime 2 + Per diem meal + Private accommodation/day + Standby + Band + Bands + Deployment/OT1/OT2 thresholds, daily tiers, free units and meal codes are data here — never code. + Base rate + Daily + Fixed + Hourly + Per kilometre + Per person per day + Bid + Bid accepted + The bid is addressed to this contact's billing profile. + A contract supplies its rate schedule, discount and terms. + Bid created + Bid declined + Bid expired + Bid line + Bid # + Lines snapshot their rate from this schedule when added. + Bid sent + Accepted + Declined + Draft + Expired + Submitted + Withdrawn + Bids + Priced estimates for customer contacts and contracts; an accepted bid schedules the deployment call. + Billing + Billing basis + Call + Call name + Nature of call + Priority + Call sign + Call type + Cancel + Cancellation minimum (hours) + Vehicles bill a full day on a cancellation day + Certification expired or suspended + Certification expires within the deployment window + Certification code + Charge preview + Accommodation was claimed on a day the agency supplied it. + A rate entry lacks the band needed for some hours. + No entry for the filled crew size; the nearest lower rate was used. + No approved, unbilled time reports. + A per diem was claimed on a day the agency supplied meals. + A per diem was claimed outside its eligibility window. + A per diem amount differs from the schedule rate. + A subject has no rate entry and was skipped. + No rate schedule applies to this deployment. + Travel hours were capped by the policy. + The charge run could not be prepared. + Clone + Creates a new schedule with copies of every entry, band and premium — the usual start of a renewal. + Code + Compliance checklist + Document type + Compliance documents + {0} compliance document(s) are expiring or expired. + PDF or image up to 30 MB; leave empty to keep the stored file. + Insurance, workers' compensation, SAM/CAGE, licences and bonds with expiry alerts; they satisfy contract requirements and ride the invoice packet. + Delete this item? + Delete this draft bid? + Delete this contract? + Delete this rate schedule? + Generate a draft invoice from these charges? + Withdraw this bid? + Overlapping deployment + Continuous-run gap (minutes) + Contract + Linked bids, deployments, invoices and the compliance state of this contract. + Contract expiring + Contract number + Contract → contact profile default → first active schedule. + Active + Contract status changed + Draft + Expired + Suspended + Terminated + Contract type + Master services + Other + Project + Standing arrangement + Contractor Billing + Contracts + Service contracts with their rate schedule, terms, submission address and document requirements. + Converted to a deployment + copy + Add to the department calendar + Create call and deployment + Create draft + person crew + Crew size + Currency + Customer + Customer e-mail + Daily guarantee (hours) + Date + Days + Decline reason + Declined + Delete + Delete schedule + Delivery location + Deployment + Deployment attachment + Deployments + Description + Discount + Discount cascade + Discount % + Bond + Business licence + CAGE code + Insurance certificate + Other + SAM registration + Tax registration + Workers' comp clearance + Document number + Document requirements + What the customer expects at each stage; a compliance document type is satisfied by a current department document, otherwise a deployment attachment. + Document template + Edit + Edit bid + Header, lines with rate snapshots, premiums and the estimate. + Edit compliance document + Edit contract + Header, commercial terms and the document requirements per stage. + Edit rate schedule + Rates are explicit dollars per band; crew families share a group key and differ by crew size. + Effective on + End + Rate entries + Personnel entries match a certification code; crew entries share a group key and one entry per crew size; vehicle and equipment entries are pinned to the roster item. + Rate entry + Entry type + Crew + Equipment + Personnel (certification) + Service + Vehicle + Equipment + Estimate + Tax follows the customer's billing profile; actual charges follow the daily time reports. + Estimated customer charge per day + Estimated total + Expires on + Export JSON + File + Item name + Free/day + Fuel deduction per litre + Generate invoice + Creates a draft invoice from the charges above; the billed time reports move to Billed. + Group key + e.g. type6-crew + Draft a bid from the customer's rate schedule, send it as a PDF, then accept it to launch the deployment wizard. + Each document carries its own alert lead; department admins are notified when it enters the window or lapses. + A contract fixes the rate schedule, discount and payment terms for every bid and deployment under it; document requirements drive the compliance checklist and the invoice packet. + A contract points at a schedule; otherwise the contact's profile default or the first active schedule applies. Clone a schedule for the next season. + Hours/day + Import + Import JSON + Paste or upload a schedule exported from Resgrid; ids and department links are dropped, so a schedule can be shared between departments. + Inactive + Incident number + Inventory item id + Invoice submission e-mail + Where deployment invoices and their packet are sent by default. + Invoices + Issuer + Label + Line type + Crew + Equipment + Free-form + Personnel + Service + Vehicle + Lines + Pick a rate entry to snapshot its rate, or type a free-form line. Quantity × rate × hours/day × days. + Manage bids + Create, edit, send, accept and convert bids. Department administrators always can. + Manage contracts + Create and edit service contracts, document requirements and compliance documents. Department administrators always can. + Mandatory + Mark as accepted + Mark as declined + Mark as submitted + Max deployment days + Meal code + Meal eligibility + JSON windows per meal code, e.g. [{"MealCode":"B","StartsBeforeMinutes":420}]; a per diem outside its window is warned, not blocked. + Missing + Multiplier prefill + Base + standby + OT multipliers draft the hourly bands; the stored values stay explicit dollars. + Name + nearest lower rate + New bid + Pick the customer and, when one applies, the contract; the rate schedule and discount follow. + New contract + New rate schedule + Next + No + No bids yet. + No approved, unbilled time reports to charge. + No-clear-8 carry-over starts the day in the overtime band + No compliance documents yet. + No contract + No contracts yet. + No deployments. + No rate entries yet. + No invoices. + No rate schedules yet. + No document requirements. + No rate schedule + No rate schedule applies: lines need a typed rate. + None + Notes + Open + optional + …or paste JSON + Out of province + Overtime basis + Consecutive hours + Daily total hours + PDF + checked per deployment + Person + Personnel + Point of hire + Billing policy + Rounding, minimums, overtime basis and travel caps the engine applies to every time report under this schedule. + Portal to portal (travel bills as deployment time) + Prefill + Premium + Premiums + Flat hourly adders per band (night, hazard, lead); they stack and never multiply. + Preview + Profile + Qualified + Qty + Rate + Rate entry + Rate schedule + Rate schedules + Contractor rate tables: certifications, crews, vehicles, equipment, premiums and the billing policy. + Remove + Reopen + Requested + Requested end + Requested start + Required certifications + JSON list of code + minimum count a crew of this size must carry. + Resend bid + Response time (minutes) + Role not held + Rounding (minutes) + Satisfied + Save + The change could not be saved. + Saved. + Schedule + Schedule deployment call + Seat + Seats + Select a contact… + Send bid + Send to + Sent + Service request number + Activate + Back to draft + Mark expired + Suspend + Terminate + Show active + Show all + Order + Stage + Bid submission + Daily time report + Deployment start + Invoice submission + Start + State + Status + Subtotal + Tax (estimated) + Taxable + Terms (net days) + Terms + To (h) + From (h) + Through date + Tier max (h) + Tier min (h) + Title + Total before tax + Travel cap per day (hours) + Travel via air + People deployed without a unit seat bill under their own certification entry. + Unassigned personnel + Unit + Unit type + Units + Unsafe stand-down (hours) + Use the default schedule + Valid until + View bid + Already on this roster. + A required certification expires during the deployment. + A seated person lacks a required certification. + Inventory issue failed; the item was added as free text. + A seated person does not hold the seat's role. + Someone or a unit is already seated on an overlapping deployment. + Window + Withdraw + Per unit: free-text items or inventory asset ids with a daily-rate entry from the schedule. + Bid #{0} — {1}: call details, units, crew seats, equipment, rates, review. + Confirm the certification entry and premiums per person, the crew family per unit and the entry per equipment item; the daily figure is an estimate. + Fill seats from the roster: status, staffing, roles and typed certifications show per person; expired or suspended certifications are red, those expiring inside the window amber, overlapping deployments flagged. Partial crews bill at the filled size. + Call details + Equipment + Rates & premiums + Review & create + Crew seats + Units + Tick the units to deploy and map each to a bid crew line; the line's crew family sets the rate. + Yes + This bid already has a deployment. + The call needs a name. + The customer contact was not found. + Pick the customer contact. + The contract belongs to a different contact. + The contract was not found. + The requested end is before the requested start. + The discount must be between 0 and 100 percent. + The e-mail was not sent; check the address and the department's e-mail settings. + A line needs a description, a positive quantity and a non-negative rate. + Only draft or submitted bids can be edited. + Add at least one line before submitting. + No recipient e-mail: type one or set the contact's billing e-mail. + Only an accepted bid can be scheduled. + The bid was not found. + The bid PDF could not be rendered. + Advanced Data Protection refused the write; check the department's protection status. + The rate schedule was not found. + That status change is not allowed from the bid's current status. + The bid needs a title. + The expiry date is before the effective date. + The file is protected; reveal it with a Protected Data Grant first. + The file is larger than 30 MB. + That file type is not allowed. + The document needs a name. + The compliance document was not found. + The document type is not valid. + The deployment has no customer contact to invoice. + Only a billable deployment can be invoiced. + There is nothing to invoice: no approved, unbilled time reports produced charges. + An active contract cannot be deleted; terminate it first. + The customer contact was not found. + Pick the customer contact. + The end date is before the start date. + The discount must be between 0 and 100 percent. + The contract's end date has passed; extend it before activating. + The contract needs a name. + The contract was not found. + Advanced Data Protection refused the write; check the department's protection status. + A document requirement has an unknown stage or document type. + That status change is not allowed from the contract's current status. + The contract type is not valid. + The same band type appears twice on the entry. + A band has an unknown type or a negative rate. + A crew entry needs a crew size. + The expiry date is before the effective date. + The entry type or billing basis is not valid. + The entry needs a name. + The rate entry was not found. + The JSON is not a valid rate schedule export. + The schedule is used by an active or draft contract and cannot be deleted. + The schedule needs a name. + The rate schedule was not found. + The policy JSON is not valid. + Premium adders cannot be negative. + The premium needs a name. + The premium was not found. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx new file mode 100644 index 000000000..d1b7c7100 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Antagen + Åtgärder + Aktivera nu + Aktiv + Lägg till + Lägg till band + Lägg till efterlevnadsdokument + Lägg till post + Lägg till rad + Lägg till person + Lägg till tillägg + Lägg till krav + Adress + Anbudet innehåller skyddade kunduppgifter. + Efterlevnadsdokumentens nummer och filer är skyddade. + Avtalet innehåller skyddade kunduppgifter. + Flyg + Förvarning (dagar) + Alla + Belopp + Tillbaka + Anpassad + Daglig insats + Daglig beredskap + Insats + Milersättning per km + Utanför provinsen per person/dag + Övertid 1 + Övertid 2 + Måltidstraktamente + Privat logi/dag + Beredskap + Band + Band + Trösklar för insats/ÖT1/ÖT2, dagsnivåer, fria enheter och måltidskoder är data här — aldrig kod. + Grundtaxa + Per dag + Fast + Per timme + Per kilometer + Per person och dag + Anbud + Anbud antaget + Anbudet riktas till kontaktens faktureringsprofil. + Ett avtal ger prislista, rabatt och villkor. + Anbud skapat + Anbud avböjt + Anbud utgånget + Anbudsrad + Anbud nr + Rader låser sin taxa från denna prislista när de läggs till. + Anbud skickat + Antaget + Avböjt + Utkast + Utgånget + Inlämnat + Tillbakadraget + Anbud + Prissatta uppskattningar för kundkontakter och avtal; ett antaget anbud schemalägger insatsen. + Fakturering + Faktureringsgrund + Larm + Larmnamn + Larmets art + Prioritet + Anropssignal + Larmtyp + Avbryt + Avbokningsminimum (timmar) + Fordon debiterar hel dag vid avbokning + Certifikat utgånget eller vilande + Certifikatet går ut under insatsperioden + Certifikatkod + Förhandsvisning av avgifter + Logi begärdes en dag då myndigheten tillhandahöll det. + En prislistepost saknar bandet som behövs för vissa timmar. + Ingen post för fylld besättningsstorlek; närmast lägre taxa användes. + Inga godkända, ofakturerade tidrapporter. + Ett traktamente begärdes en dag då myndigheten tillhandahöll måltider. + Ett traktamente begärdes utanför sitt berättigandefönster. + Ett traktamentesbelopp avviker från listans taxa. + Ett objekt saknar prislistepost och hoppades över. + Ingen prislista gäller för insatsen. + Restimmar begränsades av regeln. + Avgiftskörningen kunde inte förberedas. + Klona + Skapar en ny prislista med kopior av varje post, band och tillägg — den vanliga starten på en förnyelse. + Kod + Efterlevnadslista + Dokumenttyp + Efterlevnadsdokument + {0} efterlevnadsdokument går ut eller har gått ut. + PDF eller bild upp till 30 MB; lämna tomt för att behålla sparad fil. + Försäkringar, arbetsskadeförsäkring, SAM/CAGE, licenser och borgen med utgångsvarningar; de uppfyller avtalskrav och följer med fakturapaketet. + Ta bort objektet? + Ta bort anbudsutkastet? + Ta bort avtalet? + Ta bort prislistan? + Skapa ett fakturautkast från dessa avgifter? + Dra tillbaka anbudet? + Överlappande insats + Lucka för sammanhängande pass (minuter) + Avtal + Kopplade anbud, insatser, fakturor och avtalets efterlevnadsstatus. + Avtal går ut + Avtalsnummer + Avtal → profilens standard → första aktiva prislista. + Aktivt + Avtalsstatus ändrad + Utkast + Utgånget + Vilande + Uppsagt + Avtalstyp + Ramavtal + Annat + Projekt + Stående arrangemang + Entreprenörsfakturering + Avtal + Serviceavtal med prislista, villkor, inlämningsadress och dokumentkrav. + Omvandlad till insats + kopia + Lägg till i avdelningens kalender + Skapa larm och insats + Skapa utkast + personers besättning + Besättningsstorlek + Valuta + Kund + Kundens e-post + Daglig garanti (timmar) + Datum + Dagar + Skäl till avslag + Avböjd + Ta bort + Ta bort prislista + Leveransplats + Insats + Insatsbilaga + Insatser + Beskrivning + Rabatt + Rabattkedja + Rabatt % + Borgen + Näringstillstånd + CAGE-kod + Försäkringsintyg + Annat + SAM-registrering + Skatteregistrering + Arbetsskadeförsäkringsintyg + Dokumentnummer + Dokumentkrav + Vad kunden förväntar sig i varje steg; en efterlevnadsdokumenttyp uppfylls av ett aktuellt avdelningsdokument, annars av en insatsbilaga. + Dokumentmall + Redigera + Redigera anbud + Huvud, rader med taxor, tillägg och uppskattning. + Redigera efterlevnadsdokument + Redigera avtal + Huvud, kommersiella villkor och dokumentkrav per steg. + Redigera prislista + Taxor är uttryckliga belopp per band; besättningsfamiljer delar gruppnyckel och skiljer sig i storlek. + Gäller från + Slut + Prislisteposter + Personalposter matchar en certifikatkod; besättningsposter delar gruppnyckel med en post per storlek; fordon och utrustning knyts till listobjektet. + Prislistepost + Posttyp + Besättning + Utrustning + Personal (certifikat) + Tjänst + Fordon + Utrustning + Uppskattning + Skatt följer kundens faktureringsprofil; faktiska avgifter följer de dagliga tidrapporterna. + Uppskattad kundavgift per dag + Uppskattad summa + Upphör + Exportera JSON + Fil + Objektnamn + Fria/dag + Bränsleavdrag per liter + Skapa faktura + Skapar ett fakturautkast från avgifterna ovan; fakturerade tidrapporter blir Fakturerade. + Gruppnyckel + t.ex. typ6-besattning + Skapa ett anbud från kundens prislista, skicka det som PDF och anta det för att starta insatsguiden. + Varje dokument har egen förvarningstid; administratörer meddelas när det når fönstret eller förfaller. + Ett avtal fastställer prislista, rabatt och betalningsvillkor för varje anbud och insats; dokumentkrav styr efterlevnadslistan och fakturapaketet. + Ett avtal pekar på en prislista; annars gäller profilens standard eller första aktiva lista. Klona en lista för nästa säsong. + Timmar/dag + Importera + Importera JSON + Klistra in eller ladda upp en prislista exporterad från Resgrid; id:n och avdelningskopplingar tas bort så listan kan delas mellan avdelningar. + Inaktiv + Händelsenummer + Lagerartikel-id + E-post för fakturainlämning + Dit insatsfakturor och deras paket skickas som standard. + Fakturor + Utfärdare + Etikett + Radtyp + Besättning + Utrustning + Fri rad + Personal + Tjänst + Fordon + Rader + Välj en prislistepost för att låsa taxan, eller skriv en fri rad. Antal × taxa × timmar/dag × dagar. + Hantera anbud + Skapa, redigera, skicka, anta och omvandla anbud. Administratörer kan alltid. + Hantera avtal + Skapa och redigera avtal, dokumentkrav och efterlevnadsdokument. Administratörer kan alltid. + Obligatoriskt + Markera som antaget + Markera som avböjt + Markera som inlämnat + Max insatsdagar + Måltidskod + Måltidsberättigande + JSON-fönster per måltidskod, t.ex. [{"MealCode":"B","StartsBeforeMinutes":420}]; ett traktamente utanför fönstret varnas, blockeras inte. + Saknas + Förifyllning med multiplikator + Bas + beredskap + ÖT-multiplikatorer skapar timbanden; lagrade värden förblir uttryckliga belopp. + Namn + närmast lägre taxa + Nytt anbud + Välj kund och, om tillämpligt, avtal; prislista och rabatt följer. + Nytt avtal + Ny prislista + Nästa + Nej + Inga anbud ännu. + Inga godkända, ofakturerade tidrapporter. + Överföring utan 8 h vila startar dagen i övertidsbandet + Inga efterlevnadsdokument ännu. + Inget avtal + Inga avtal ännu. + Inga insatser. + Inga prislisteposter ännu. + Inga fakturor. + Inga prislistor ännu. + Inga dokumentkrav. + Ingen prislista + Ingen prislista gäller: rader behöver en inskriven taxa. + Ingen + Anteckningar + Öppna + valfritt + …eller klistra in JSON + Utanför provinsen + Övertidsgrund + Sammanhängande timmar + Dagens totala timmar + PDF-fil + kontrolleras per insats + Person + Personal + Anställningsort + Faktureringsregler + Avrundning, minimum, övertidsgrund och resetak som motorn tillämpar på varje tidrapport under listan. + Port till port (resa debiteras som insatstid) + Förifyll + Tillägg + Tillägg + Fasta timtillägg per band (natt, risk, ledare); de adderas och multipliceras aldrig. + Förhandsgranska + Profil + Kvalificerad + Antal + Taxa + Prislistepost + Prislista + Prislistor + Entreprenörens prislistor: certifikat, besättningar, fordon, utrustning, tillägg och faktureringsregler. + Ta bort + Öppna igen + Begärd + Begärt slut + Begärd start + Krävda certifikat + JSON-lista med kod + minsta antal som en besättning av denna storlek måste ha. + Skicka anbud igen + Svarstid (minuter) + Saknar rollen + Avrundning (minuter) + Uppfyllt + Spara + Ändringen kunde inte sparas. + Sparat. + Prislista + Schemalägg insats + Plats + Platser + Välj kontakt… + Skicka anbud + Skicka till + Skickad + Servicebegärannummer + Aktivera + Tillbaka till utkast + Markera utgånget + Vilandeställ + Säg upp + Visa aktiva + Visa alla + Ordning + Steg + Anbudsinlämning + Daglig tidrapport + Insatsstart + Fakturainlämning + Start + Tillstånd + Status + Delsumma + Skatt (uppskattad) + Skattepliktig + Villkor (nettodagar) + Villkor + Till (h) + Från (h) + Till och med + Nivå max (h) + Nivå min (h) + Titel + Summa före skatt + Resetak per dag (timmar) + Resa med flyg + Personer insatta utan enhetsplats debiteras efter egen certifikatpost. + Otilldelad personal + Enhet + Enhetstyp + Enheter + Säkerhetsstopp (timmar) + Använd standardprislistan + Giltig till + Visa anbud + Redan på denna roster. + Ett krävt certifikat går ut under insatsen. + En placerad person saknar ett krävt certifikat. + Lageruttag misslyckades; objektet lades till som fri text. + En placerad person saknar platsens roll. + Någon eller en enhet är redan placerad på en överlappande insats. + Period + Dra tillbaka + Per enhet: fria poster eller lagertillgångs-id:n med en dagstaxepost från prislistan. + Anbud nr {0} — {1}: larmuppgifter, enheter, besättningsplatser, utrustning, taxor, granskning. + Bekräfta certifikatpost och tillägg per person, besättningsfamilj per enhet och post per utrustning; dagsbeloppet är en uppskattning. + Fyll platser från rostern: status, bemanning, roller och certifikat per person; utgångna eller vilande i rött, de som går ut under perioden i gult, överlappande insatser flaggade. Delvis fyllda besättningar debiteras efter fylld storlek. + Larmuppgifter + Utrustning + Taxor och tillägg + Granska och skapa + Besättningsplatser + Enheter + Bocka för enheter att sätta in och koppla var och en till en besättningsrad i anbudet; radens familj sätter taxan. + Ja + Anbudet har redan en insats. + Larmet behöver ett namn. + Kundkontakten hittades inte. + Välj kundkontakten. + Avtalet tillhör en annan kontakt. + Avtalet hittades inte. + Begärt slut ligger före begärd start. + Rabatten måste vara mellan 0 och 100 procent. + E-posten skickades inte; kontrollera adressen och avdelningens e-postinställningar. + En rad behöver beskrivning, positivt antal och icke-negativ taxa. + Endast utkast eller inlämnade anbud kan redigeras. + Lägg till minst en rad innan inlämning. + Ingen mottagar-e-post: skriv en eller ange kontaktens fakturerings-e-post. + Endast ett antaget anbud kan schemaläggas. + Anbudet hittades inte. + Anbudets PDF kunde inte skapas. + Advanced Data Protection nekade skrivningen; kontrollera avdelningens skyddsstatus. + Prislistan hittades inte. + Den statusändringen tillåts inte från anbudets nuvarande status. + Anbudet behöver en titel. + Utgångsdatumet ligger före startdatumet. + Filen är skyddad; visa den först med ett Protected Data Grant. + Filen är större än 30 MB. + Den filtypen tillåts inte. + Dokumentet behöver ett namn. + Efterlevnadsdokumentet hittades inte. + Dokumenttypen är ogiltig. + Insatsen saknar kundkontakt att fakturera. + Endast en fakturerbar insats kan faktureras. + Inget att fakturera: inga godkända, ofakturerade tidrapporter gav avgifter. + Ett aktivt avtal kan inte tas bort; säg upp det först. + Kundkontakten hittades inte. + Välj kundkontakten. + Slutdatumet ligger före startdatumet. + Rabatten måste vara mellan 0 och 100 procent. + Avtalets slutdatum har passerat; förläng det innan aktivering. + Avtalet behöver ett namn. + Avtalet hittades inte. + Advanced Data Protection nekade skrivningen; kontrollera avdelningens skyddsstatus. + Ett dokumentkrav har okänt steg eller dokumenttyp. + Den statusändringen tillåts inte från avtalets nuvarande status. + Avtalstypen är ogiltig. + Samma bandtyp förekommer två gånger på posten. + Ett band har okänd typ eller negativ taxa. + En besättningspost behöver en storlek. + Utgångsdatumet ligger före startdatumet. + Posttypen eller faktureringsgrunden är ogiltig. + Posten behöver ett namn. + Prislisteposten hittades inte. + JSON är inte en giltig prislisteexport. + Prislistan används av ett aktivt avtal eller utkast och kan inte tas bort. + Prislistan behöver ett namn. + Prislistan hittades inte. + Regel-JSON är ogiltig. + Tillägg kan inte vara negativa. + Tillägget behöver ett namn. + Tillägget hittades inte. + diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx new file mode 100644 index 000000000..d9c8b6d62 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx @@ -0,0 +1,431 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Прийнято + Дії + Активувати зараз + Активний + Додати + Додати смугу + Додати документ відповідності + Додати позицію + Додати рядок + Додати особу + Додати надбавку + Додати вимогу + Адреса + Ця пропозиція містить захищені дані замовника. + Номери та файли документів відповідності захищені. + Цей договір містить захищені дані замовника. + Авіа + Попередження (днів) + Усі + Сума + Назад + Власна + Денне розгортання + Денне чергування + Розгортання + Пробіг за км + Поза провінцією на особу/день + Понаднормові 1 + Понаднормові 2 + Добові на харчування + Приватне житло/день + Чергування + Смуга + Смуги + Пороги розгортання/ПН1/ПН2, денні рівні, безкоштовні одиниці та коди харчування тут є даними — ніколи кодом. + Базова ставка + Подобово + Фіксовано + Погодинно + За кілометр + На особу за день + Пропозиція + Пропозицію прийнято + Пропозиція адресується платіжному профілю цього контакту. + Договір визначає тарифи, знижку та умови. + Пропозицію створено + Пропозицію відхилено + Пропозицію прострочено + Рядок пропозиції + Пропозиція № + Рядки фіксують ставку з цих тарифів під час додавання. + Пропозицію надіслано + Прийнято + Відхилено + Чернетка + Прострочено + Подано + Відкликано + Пропозиції + Оцінені пропозиції для контактів і договорів замовників; прийнята пропозиція планує розгортання. + Розрахунки + База нарахування + Виклик + Назва виклику + Характер виклику + Пріоритет + Позивний + Тип виклику + Скасувати + Мінімум при скасуванні (год) + Транспорт нараховує повний день у день скасування + Сертифікат прострочений або призупинений + Сертифікат закінчується в період розгортання + Код сертифіката + Попередній перегляд нарахувань + Проживання заявлено в день, коли його забезпечило агентство. + Тарифній позиції бракує смуги для деяких годин. + Немає позиції для заповненого розміру екіпажу; використано найближчу нижчу ставку. + Немає затверджених, ненарахованих звітів. + Добові заявлено в день, коли агентство забезпечило харчування. + Добові заявлено поза вікном права. + Сума добових відрізняється від ставки плану. + Суб'єкт не має тарифної позиції та пропущений. + До цього розгортання не застосовуються тарифи. + Години в дорозі обмежено політикою. + Не вдалося підготувати розрахунок нарахувань. + Клонувати + Створює новий план з копіями всіх позицій, смуг і надбавок — звичний початок продовження. + Код + Контрольний список відповідності + Тип документа + Документи відповідності + {0} документ(и) відповідності закінчуються або прострочені. + PDF або зображення до 30 МБ; залиште порожнім, щоб зберегти наявний файл. + Страхування, компенсації працівникам, SAM/CAGE, ліцензії та гарантії зі сповіщеннями про закінчення; вони задовольняють вимоги договорів і додаються до пакета рахунку. + Видалити цей елемент? + Видалити цю чернетку пропозиції? + Видалити цей договір? + Видалити цей тарифний план? + Сформувати чернетку рахунку з цих нарахувань? + Відкликати цю пропозицію? + Розгортання, що перетинається + Пауза безперервного циклу (хвилини) + Договір + Пов'язані пропозиції, розгортання, рахунки та стан відповідності цього договору. + Договір закінчується + Номер договору + Договір → типовий план профілю → перший активний план. + Активний + Статус договору змінено + Чернетка + Прострочено + Призупинено + Розірвано + Тип договору + Генеральний договір послуг + Інше + Проєкт + Постійна домовленість + Розрахунки підрядника + Договори + Договори на послуги з тарифами, умовами, адресою подання та вимогами до документів. + Перетворено на розгортання + копія + Додати до календаря підрозділу + Створити виклик і розгортання + Створити чернетку + особовий екіпаж + Розмір екіпажу + Валюта + Замовник + E-mail замовника + Денна гарантія (год) + Дата + Дні + Причина відхилення + Відхилено + Видалити + Видалити план + Місце доставки + Розгортання + Вкладення розгортання + Розгортання + Опис + Знижка + Ланцюжок знижок + Знижка % + Гарантія + Ліцензія на діяльність + Код CAGE + Страховий сертифікат + Інше + Реєстрація SAM + Податкова реєстрація + Довідка про компенсацію працівникам + Номер документа + Вимоги до документів + Що замовник очікує на кожному етапі; тип документа відповідності задовольняє чинний документ підрозділу, інакше — вкладення розгортання. + Шаблон документа + Редагувати + Редагувати пропозицію + Заголовок, рядки зі ставками, надбавки та оцінка. + Редагувати документ відповідності + Редагувати договір + Заголовок, комерційні умови та вимоги до документів на кожному етапі. + Редагувати тарифний план + Ставки — явні суми за смугою; сімейства екіпажів мають спільний ключ групи й різняться розміром. + Діє з + Кінець + Тарифні позиції + Позиції персоналу відповідають коду сертифіката; позиції екіпажів мають спільний ключ групи й по одній на розмір; транспорт і обладнання прив'язані до елемента реєстру. + Тарифна позиція + Тип позиції + Екіпаж + Обладнання + Персонал (сертифікат) + Послуга + Транспорт + Обладнання + Оцінка + Податок визначається платіжним профілем замовника; фактичні нарахування — щоденними звітами часу. + Орієнтовне денне нарахування замовнику + Орієнтовна сума + Закінчується + Експортувати JSON + Файл + Назва предмета + Безкошт./день + Відрахування за пальне за літр + Сформувати рахунок + Створює чернетку рахунку з нарахувань вище; нараховані звіти переходять у стан «Виставлено». + Ключ групи + напр. type6-crew + Складіть пропозицію за тарифами замовника, надішліть її як PDF, а потім прийміть, щоб запустити майстер розгортання. + Кожен документ має власний час попередження; адміністраторів сповіщають, коли він входить у вікно або втрачає чинність. + Договір фіксує тарифи, знижку та умови оплати для всіх пропозицій і розгортань; вимоги до документів формують контрольний список відповідності та пакет рахунку. + Договір посилається на план; інакше діє типовий план профілю або перший активний. Клонуйте план на наступний сезон. + Год/день + Імпортувати + Імпортувати JSON + Вставте або завантажте план, експортований з Resgrid; ідентифікатори та прив'язки до підрозділу відкидаються, тож план можна ділити між підрозділами. + Неактивний + Номер інциденту + Ідентифікатор товару інвентарю + E-mail для подання рахунків + Куди типово надсилаються рахунки за розгортання та їх пакет. + Рахунки + Видавець + Мітка + Тип рядка + Екіпаж + Обладнання + Довільний + Персонал + Послуга + Транспорт + Рядки + Оберіть тарифну позицію, щоб зафіксувати ставку, або введіть довільний рядок. Кількість × ставка × год/день × дні. + Керувати пропозиціями + Створення, редагування, надсилання, прийняття та перетворення пропозицій. Адміністратори завжди можуть. + Керувати договорами + Створення та редагування договорів, вимог до документів і документів відповідності. Адміністратори завжди можуть. + Обов'язково + Позначити як прийняту + Позначити як відхилену + Позначити як подану + Макс. днів розгортання + Код харчування + Право на харчування + Вікна JSON за кодом прийому їжі, напр. [{"MealCode":"B","StartsBeforeMinutes":420}]; добові поза вікном лише попереджаються, не блокуються. + Відсутній + Попереднє заповнення множником + База + чергування + множники ПН формують погодинні смуги; збережені значення лишаються явними сумами. + Назва + найближча нижча ставка + Нова пропозиція + Оберіть замовника та, за потреби, договір; тарифи та знижка визначаться автоматично. + Новий договір + Новий тарифний план + Далі + Ні + Пропозицій ще немає. + Немає затверджених, ненарахованих звітів часу. + Перенесення без 8 годин відпочинку починає день у смузі понаднормових + Документів відповідності ще немає. + Без договору + Договорів ще немає. + Розгортань немає. + Тарифних позицій ще немає. + Рахунків немає. + Тарифних планів ще немає. + Вимог до документів немає. + Без тарифів + Тарифи не застосовуються: рядкам потрібна введена ставка. + Немає + Примітки + Відкрити + необов'язково + …або вставте JSON + Поза провінцією + База понаднормових + Послідовні години + Загальні години за день + Файл PDF + перевіряється для кожного розгортання + Особа + Персонал + Місце найму + Політика розрахунків + Округлення, мінімуми, база понаднормових і ліміти на дорогу, які рушій застосовує до кожного звіту часу за цим планом. + Від воріт до воріт (дорога нараховується як час розгортання) + Заповнити + Надбавка + Надбавки + Фіксовані погодинні надбавки за смугою (ніч, небезпека, старший); додаються і ніколи не множаться. + Попередній перегляд + Профіль + Кваліфікований + К-сть + Ставка + Тарифна позиція + Тарифний план + Тарифні плани + Тарифи підрядника: сертифікати, екіпажі, транспорт, обладнання, надбавки та політика розрахунків. + Прибрати + Відкрити знову + Запитано + Запитане завершення + Запитаний початок + Необхідні сертифікати + Список JSON з кодом і мінімальною кількістю, яку має мати екіпаж такого розміру. + Надіслати повторно + Час реагування (хвилини) + Роль відсутня + Округлення (хвилини) + Виконано + Зберегти + Не вдалося зберегти зміну. + Збережено. + План + Запланувати виклик розгортання + Місце + Місця + Оберіть контакт… + Надіслати пропозицію + Надіслати + Надіслано + Номер запиту на послугу + Активувати + Повернути в чернетку + Позначити простроченим + Призупинити + Розірвати + Показати активні + Показати все + Порядок + Етап + Подання пропозиції + Щоденний звіт часу + Початок розгортання + Подання рахунку + Початок + Стан + Статус + Проміжна сума + Податок (орієнтовно) + Оподатковується + Умови (днів нетто) + Умови + До (год) + Від (год) + До дати + Макс. рівня (год) + Мін. рівня (год) + Назва + Разом без податку + Ліміт на дорогу за день (год) + Авіапереліт + Особи, розгорнуті без місця в підрозділі, нараховуються за власною позицією сертифіката. + Непризначений персонал + Підрозділ + Тип підрозділу + Підрозділи + Зупинка з міркувань безпеки (год) + Використати типовий план + Дійсна до + Переглянути пропозицію + Уже в цьому реєстрі. + Необхідний сертифікат закінчується під час розгортання. + Призначеній особі бракує необхідного сертифіката. + Видача з інвентарю не вдалася; предмет додано як довільний текст. + Призначена особа не має ролі місця. + Хтось або підрозділ уже призначений на розгортання, що перетинається. + Період + Відкликати + На підрозділ: довільні позиції або ідентифікатори активів інвентарю з позицією денної ставки з плану. + Пропозиція № {0} — {1}: деталі виклику, підрозділи, місця екіпажу, обладнання, ставки, перевірка. + Підтвердьте позицію сертифіката та надбавки для кожної особи, сімейство екіпажу для підрозділу та позицію для обладнання; денна сума — оцінка. + Заповніть місця з реєстру: статус, укомплектованість, ролі та сертифікати для кожної особи; прострочені чи призупинені — червоним, ті, що закінчуються в періоді, — жовтим, розгортання, що перетинаються, позначено. Неповні екіпажі нараховуються за заповненим розміром. + Деталі виклику + Обладнання + Ставки та надбавки + Перевірка та створення + Місця екіпажу + Підрозділи + Позначте підрозділи для розгортання та зіставте кожен із рядком екіпажу в пропозиції; сімейство рядка визначає ставку. + Так + Ця пропозиція вже має розгортання. + Виклик потребує назви. + Контакт замовника не знайдено. + Оберіть контакт замовника. + Договір належить іншому контакту. + Договір не знайдено. + Запитане завершення раніше за запитаний початок. + Знижка має бути від 0 до 100 відсотків. + Лист не надіслано; перевірте адресу та налаштування пошти підрозділу. + Рядок потребує опису, додатної кількості та невід'ємної ставки. + Редагувати можна лише чернетки або подані пропозиції. + Додайте принаймні один рядок перед поданням. + Немає e-mail одержувача: введіть його або задайте платіжний e-mail контакту. + Запланувати можна лише прийняту пропозицію. + Пропозицію не знайдено. + Не вдалося створити PDF пропозиції. + Advanced Data Protection відхилив запис; перевірте стан захисту підрозділу. + Тарифний план не знайдено. + Така зміна статусу неприпустима з поточного статусу пропозиції. + Пропозиція потребує назви. + Дата закінчення раніша за дату початку дії. + Файл захищений; спочатку розкрийте його через дозвіл на захищені дані. + Файл більший за 30 МБ. + Цей тип файлу не дозволено. + Документ потребує назви. + Документ відповідності не знайдено. + Тип документа недійсний. + Розгортання не має контакту замовника для рахунку. + Виставити рахунок можна лише за оплачуване розгортання. + Немає що виставляти: жоден затверджений ненарахований звіт не дав нарахувань. + Активний договір не можна видалити; спершу розірвіть його. + Контакт замовника не знайдено. + Оберіть контакт замовника. + Дата завершення раніша за дату початку. + Знижка має бути від 0 до 100 відсотків. + Дата завершення договору минула; продовжте її перед активацією. + Договір потребує назви. + Договір не знайдено. + Advanced Data Protection відхилив запис; перевірте стан захисту підрозділу. + Вимога до документа має невідомий етап або тип документа. + Така зміна статусу неприпустима з поточного статусу договору. + Тип договору недійсний. + Той самий тип смуги вказано двічі в позиції. + Смуга має невідомий тип або від'ємну ставку. + Позиція екіпажу потребує розміру. + Дата закінчення раніша за дату початку дії. + Тип позиції або база нарахування недійсні. + Позиція потребує назви. + Тарифну позицію не знайдено. + JSON не є дійсним експортом тарифного плану. + План використовується активним або чорновим договором і не може бути видалений. + План потребує назви. + Тарифний план не знайдено. + JSON політики недійсний. + Надбавки не можуть бути від'ємними. + Надбавка потребує назви. + Надбавку не знайдено. + diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx index 482ba58de..6979aa834 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx @@ -258,4 +258,9 @@ أُضيف مصروف انتشار أُرسل تقرير وقت تمت الموافقة على تقرير وقت + تم إنشاء تقرير وقت + تم إبطال تقرير وقت + أُضيف ملف انتشار + انتشار محمي + تقرير وقت محمي diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx index c78cd8e1a..f396c6cea 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx @@ -258,4 +258,9 @@ Einsatzspesen hinzugefügt Zeitbericht eingereicht Zeitbericht genehmigt + Zeitbericht erstellt + Zeitbericht storniert + Einsatzdatei hinzugefügt + Geschützter Einsatz + Geschützter Zeitbericht diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx index 671c7d9a5..c9f499926 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx @@ -258,4 +258,9 @@ Προστέθηκε έξοδο αποστολής Υποβλήθηκε αναφορά χρόνου Εγκρίθηκε αναφορά χρόνου + Δημιουργήθηκε αναφορά χρόνου + Ακυρώθηκε αναφορά χρόνου + Προστέθηκε αρχείο αποστολής + Προστατευμένη αποστολή + Προστατευμένη αναφορά χρόνου diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx index a7535a972..227e7f5c0 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx @@ -258,4 +258,9 @@ Deployment expense added Time report submitted Time report approved + Time report created + Time report voided + Deployment file added + Protected deployment + Protected time report diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx index 150864df7..fd5e509b8 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx @@ -258,4 +258,9 @@ Gasto de despliegue añadido Parte de horas enviado Parte de horas aprobado + Parte de horas creado + Parte de horas anulado + Archivo de despliegue añadido + Despliegue protegido + Parte de horas protegido diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx index 2190d64ac..5def6a7e8 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx @@ -258,4 +258,9 @@ Dépense de déploiement ajoutée Rapport de temps soumis Rapport de temps approuvé + Rapport de temps créé + Rapport de temps annulé + Fichier de déploiement ajouté + Déploiement protégé + Rapport de temps protégé diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx index 9dd59e079..48ffe374d 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx @@ -258,4 +258,9 @@ Spesa di impiego aggiunta Rapporto ore inviato Rapporto ore approvato + Rapporto ore creato + Rapporto ore annullato + File di impiego aggiunto + Impiego protetto + Rapporto ore protetto diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx index f6b68245d..1e8f5623e 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx @@ -258,4 +258,9 @@ Dodano wydatek dyslokacji Przesłano raport czasu Zatwierdzono raport czasu + Utworzono raport czasu + Unieważniono raport czasu + Dodano plik dyslokacji + Chroniona dyslokacja + Chroniony raport czasu diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx index a7535a972..227e7f5c0 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx @@ -258,4 +258,9 @@ Deployment expense added Time report submitted Time report approved + Time report created + Time report voided + Deployment file added + Protected deployment + Protected time report diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx index 0e44dd2be..89ec4a1a2 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx @@ -258,4 +258,9 @@ Insatsutlägg tillagt Tidrapport inskickad Tidrapport godkänd + Tidrapport skapad + Tidrapport makulerad + Insatsfil tillagd + Skyddad insats + Skyddad tidrapport diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx index ff34aac48..6a999cb0d 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx @@ -258,4 +258,9 @@ Додано витрату розгортання Звіт про час подано Звіт про час затверджено + Створено звіт про час + Звіт про час анульовано + Додано файл розгортання + Захищене розгортання + Захищений звіт про час diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx index b08cc6746..2af0628fb 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx @@ -356,4 +356,13 @@ تحصيل المدفوعات عبر الإنترنت معطّل على هذا الخادم. يفيد Stripe بأن Resgrid لم يعد لديها وصول إلى هذا الحساب. اربطه مجددًا للمتابعة. تعذر تأمين البيانات المحمية في هذا السجل، لذا لم يتم حفظ التغيير. + فاتورة محمية + هوية فوترة محمية + مفوتر من تقرير الوقت اليومي هذا + إرسال مع الحزمة + يرفق ملف PDF للفاتورة وملفات PDF لتقارير الوقت اليومية والإيصالات ومستندات الامتثال التي يتطلبها العقد في ملف zip واحد. + تنزيل الحزمة + فتح الانتشار + جدول الأسعار الافتراضي + فوترة المقاول: يُستخدم لعروض هذا العميل وعمليات انتشاره عندما لا يحدد أي عقد جدولًا. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx index f6e8ff055..37a7cb32a 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx @@ -356,4 +356,13 @@ Die Online-Zahlungsannahme ist auf diesem Server deaktiviert. Laut Stripe hat Resgrid keinen Zugriff mehr auf dieses Konto. Verbinden Sie es erneut, um fortzufahren. Geschützte Daten dieses Datensatzes konnten nicht gesichert werden; die Änderung wurde nicht gespeichert. + Geschützte Rechnung + Geschützte Rechnungsidentität + Aus diesem Tagesbericht abgerechnet + Mit Paket senden + Hängt Rechnungs-PDF, Tagesbericht-PDFs, Belege und die vom Vertrag geforderten Compliance-Dokumente als ein ZIP an. + Paket herunterladen + Einsatz öffnen + Standard-Tarifplan + Auftragnehmer-Abrechnung: gilt für Angebote und Einsätze dieses Kunden, wenn kein Vertrag einen Plan nennt. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx index 20d957c72..0d5373106 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx @@ -356,4 +356,13 @@ Η διαδικτυακή είσπραξη είναι απενεργοποιημένη σε αυτόν τον διακομιστή. Το Stripe αναφέρει ότι το Resgrid δεν έχει πλέον πρόσβαση σε αυτόν τον λογαριασμό. Συνδέστε τον ξανά για συνέχεια. Τα προστατευμένα δεδομένα αυτής της εγγραφής δεν ήταν δυνατό να ασφαλιστούν, οπότε η αλλαγή δεν αποθηκεύτηκε. + Προστατευμένο τιμολόγιο + Προστατευμένη ταυτότητα τιμολόγησης + Τιμολογήθηκε από αυτή την ημερήσια αναφορά χρόνου + Αποστολή με φάκελο + Επισυνάπτει το PDF τιμολογίου, τα PDF ημερήσιων αναφορών, τις αποδείξεις και τα έγγραφα συμμόρφωσης που απαιτεί η σύμβαση σε ένα zip. + Λήψη φακέλου + Άνοιγμα ανάπτυξης + Προεπιλεγμένος τιμοκατάλογος + Χρέωση εργολάβου: χρησιμοποιείται για προσφορές και αναπτύξεις του πελάτη όταν καμία σύμβαση δεν ορίζει κατάλογο. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx index a8d5c1f75..836aa1d80 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx @@ -356,4 +356,13 @@ Online payment collection is switched off on this server. Stripe reports that Resgrid no longer has access to this account. Connect it again to resume. Protected data on this record could not be secured, so the change was not saved. + Protected invoice + Protected billing identity + Billed from this daily time report + Send with packet + Attaches the invoice PDF, the daily time report PDFs, receipts and the compliance documents the contract requires, as one zip. + Download packet + Open deployment + Default rate schedule + Contractor billing: used for this customer's bids and deployments when no contract names a schedule. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx index 816071a1c..19ee54704 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx @@ -356,4 +356,13 @@ El cobro en línea está desactivado en este servidor. Stripe informa de que Resgrid ya no tiene acceso a esta cuenta. Conéctela de nuevo para continuar. No se pudieron proteger los datos de este registro, por lo que el cambio no se guardó. + Factura protegida + Identidad de facturación protegida + Facturado desde este parte diario + Enviar con paquete + Adjunta el PDF de la factura, los PDF de los partes diarios, recibos y documentos de cumplimiento requeridos por el contrato en un zip. + Descargar paquete + Abrir despliegue + Tabla de tarifas predeterminada + Facturación de contratista: se usa en las ofertas y despliegues de este cliente cuando ningún contrato indica una tabla. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx index 36a6c5a5c..2d39321ef 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx @@ -356,4 +356,13 @@ L'encaissement en ligne est désactivé sur ce serveur. Stripe indique que Resgrid n'a plus accès à ce compte. Reconnectez-le pour reprendre. Les données protégées de cet enregistrement n'ont pas pu être sécurisées ; la modification n'a pas été enregistrée. + Facture protégée + Identité de facturation protégée + Facturé à partir de ce rapport de temps quotidien + Envoyer avec le dossier + Joint le PDF de facture, les PDF des rapports de temps, les reçus et les documents de conformité exigés par le contrat, en un seul zip. + Télécharger le dossier + Ouvrir le déploiement + Grille tarifaire par défaut + Facturation contractant : utilisée pour les offres et déploiements de ce client quand aucun contrat ne désigne de grille. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx index 080ff2abf..511bc4108 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx @@ -356,4 +356,13 @@ L'incasso online è disattivato su questo server. Stripe segnala che Resgrid non ha più accesso a questo account. Ricollegalo per riprendere. I dati protetti di questo record non sono stati messi in sicurezza; la modifica non è stata salvata. + Fattura protetta + Identità di fatturazione protetta + Fatturato da questo rapporto giornaliero + Invia con pacchetto + Allega il PDF della fattura, i PDF dei rapporti giornalieri, le ricevute e i documenti di conformità richiesti dal contratto in un unico zip. + Scarica pacchetto + Apri intervento + Listino predefinito + Fatturazione appaltatore: usato per offerte e interventi di questo cliente quando nessun contratto indica un listino. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx index 3ef2ea8de..9bd510a53 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx @@ -356,4 +356,13 @@ Pobieranie płatności online jest wyłączone na tym serwerze. Stripe zgłasza, że Resgrid nie ma już dostępu do tego konta. Połącz je ponownie, aby wznowić. Nie udało się zabezpieczyć chronionych danych tego rekordu, więc zmiana nie została zapisana. + Chroniona faktura + Chroniona tożsamość rozliczeniowa + Zafakturowano z tego dziennego raportu czasu + Wyślij z pakietem + Dołącza PDF faktury, PDF-y dziennych raportów czasu, paragony i dokumenty zgodności wymagane umową jako jeden plik zip. + Pobierz pakiet + Otwórz wyjazd + Domyślny cennik + Rozliczenia wykonawcy: używany dla ofert i wyjazdów tego klienta, gdy żadna umowa nie wskazuje cennika. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx index a8d5c1f75..836aa1d80 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx @@ -356,4 +356,13 @@ Online payment collection is switched off on this server. Stripe reports that Resgrid no longer has access to this account. Connect it again to resume. Protected data on this record could not be secured, so the change was not saved. + Protected invoice + Protected billing identity + Billed from this daily time report + Send with packet + Attaches the invoice PDF, the daily time report PDFs, receipts and the compliance documents the contract requires, as one zip. + Download packet + Open deployment + Default rate schedule + Contractor billing: used for this customer's bids and deployments when no contract names a schedule. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx index d4999506c..d8dd5e442 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx @@ -356,4 +356,13 @@ Onlinebetalning är avstängd på denna server. Stripe uppger att Resgrid inte längre har åtkomst till detta konto. Anslut det igen för att fortsätta. Skyddade uppgifter på denna post kunde inte säkras, så ändringen sparades inte. + Skyddad faktura + Skyddad faktureringsidentitet + Fakturerad från denna dagliga tidrapport + Skicka med paket + Bifogar faktura-PDF, dagliga tidrapport-PDF:er, kvitton och de efterlevnadsdokument avtalet kräver som en zip. + Ladda ner paket + Öppna insats + Standardprislista + Entreprenörsfakturering: används för kundens anbud och insatser när inget avtal anger prislista. diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx index 290202c35..5221022ee 100644 --- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx @@ -356,4 +356,13 @@ Онлайн-збір платежів вимкнено на цьому сервері. Stripe повідомляє, що Resgrid більше не має доступу до цього рахунку. Підключіть його знову, щоб продовжити. Захищені дані цього запису не вдалося убезпечити, тому зміну не збережено. + Захищений рахунок + Захищена платіжна ідентичність + Виставлено з цього щоденного звіту часу + Надіслати з пакетом + Додає PDF рахунку, PDF щоденних звітів часу, чеки та документи відповідності, яких вимагає договір, одним zip. + Завантажити пакет + Відкрити розгортання + Типовий тарифний план + Розрахунки підрядника: використовується для пропозицій і розгортань цього замовника, коли договір не вказує план. diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 732c98159..7349ff298 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -279,6 +279,31 @@ public enum AuditLogTypes TimeReportVoided, DeploymentExpenseAdded, DeploymentExpenseUpdated, - DeploymentExpenseRemoved + DeploymentExpenseRemoved, + + // Workforce & Business Operations plan Phase C-M2 (contractor path). Append-only. + RateScheduleCreated, + RateScheduleUpdated, + RateScheduleDeleted, + RateScheduleEntryChanged, + RatePremiumChanged, + ServiceContractCreated, + ServiceContractUpdated, + ServiceContractStatusChanged, + ServiceContractDeleted, + ComplianceDocumentAdded, + ComplianceDocumentUpdated, + ComplianceDocumentRemoved, + BidCreated, + BidUpdated, + BidSent, + BidAccepted, + BidDeclined, + BidWithdrawn, + BidExpired, + BidConverted, + BidDeleted, + TimeReportBilled, + DeploymentInvoiceGenerated } } diff --git a/Core/Resgrid.Model/Certifications/CertificationModels.cs b/Core/Resgrid.Model/Certifications/CertificationModels.cs index b53b15bc8..e28ec67f1 100644 --- a/Core/Resgrid.Model/Certifications/CertificationModels.cs +++ b/Core/Resgrid.Model/Certifications/CertificationModels.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using Newtonsoft.Json; namespace Resgrid.Model @@ -394,10 +395,25 @@ public sealed class CertificationExpiryDashboard public List UnitTypes { get; set; } = new List(); public List PersonCells { get; set; } = new List(); public List UnitCells { get; set; } = new List(); + /// The "expiring within" window in days (the longest notification lead) the counts below use. + public int Horizon { get; set; } public int ExpiredCount { get; set; } public int ExpiringCount { get; set; } public int SuspendedCount { get; set; } public int PendingVerificationCount { get; set; } + + /// + /// Recomputes the four totals from the cells that are present, so a caller that trims the cells (the compliance + /// report applies the personnel visibility matrix) does not keep department-wide numbers over a filtered matrix. + /// + public void RecountTotals() + { + var all = PersonCells.Concat(UnitCells).ToList(); + ExpiredCount = all.Count(c => c.Status == (int)PersonnelCertificationStatuses.Expired || (c.DaysUntilExpiry.HasValue && c.DaysUntilExpiry < 0)); + ExpiringCount = all.Count(c => c.Status == (int)PersonnelCertificationStatuses.Active && c.DaysUntilExpiry.HasValue && c.DaysUntilExpiry >= 0 && c.DaysUntilExpiry <= Horizon); + SuspendedCount = PersonCells.Count(c => c.Status == (int)PersonnelCertificationStatuses.Suspended || c.Status == (int)PersonnelCertificationStatuses.Revoked) + UnitCells.Count(c => c.Status == (int)UnitCertificationStatuses.Suspended); + PendingVerificationCount = PersonCells.Count(c => c.Status == (int)PersonnelCertificationStatuses.PendingVerification); + } } /// A seed template for a department certification type (plan D1.2). diff --git a/Core/Resgrid.Model/Certifications/CertificationProtectedFields.cs b/Core/Resgrid.Model/Certifications/CertificationProtectedFields.cs index 2f860b251..4ef11aa0b 100644 --- a/Core/Resgrid.Model/Certifications/CertificationProtectedFields.cs +++ b/Core/Resgrid.Model/Certifications/CertificationProtectedFields.cs @@ -4,13 +4,15 @@ namespace Resgrid.Model.Certifications { /// - /// ADP catalog 27 (Workforce & Business Operations plan, Phase D2; registered with M0213/M0214): the free-text and + /// ADP catalog 26 (Workforce & Business Operations plan, Phase D2; registered with M0213/M0214): the free-text and /// document columns of unit certification records (Operational family, like UnitLogs) and certification credit entries (Personnel family). /// PersonnelCertifications itself stays catalog 6. Accessor maps drive the generic RMS write/read seams. /// public static class CertificationProtectedFields { - public const int CatalogVersion = 27; + public const int CatalogVersion = 26; + /// The 2026-09-19 completion pass (catalog 27): the free-text status reasons on both record tables. + public const int CompletionCatalogVersion = 27; public const string UnitFamily = "Operational"; public const string PersonnelFamily = "Personnel"; public const string UnitDataFieldId = "unitcertifications.data"; @@ -22,7 +24,8 @@ public static class CertificationProtectedFields ["unitcertifications.number"] = (u => u.Number, (u, v) => u.Number = v), ["unitcertifications.issuedby"] = (u => u.IssuedBy, (u, v) => u.IssuedBy = v), ["unitcertifications.notes"] = (u => u.Notes, (u, v) => u.Notes = v), - ["unitcertifications.filename"] = (u => u.FileName, (u, v) => u.FileName = v) + ["unitcertifications.filename"] = (u => u.FileName, (u, v) => u.FileName = v), + ["unitcertifications.statusreason"] = (u => u.StatusReason, (u, v) => u.StatusReason = v) }; public static readonly IReadOnlyDictionary Get, Action Set)> Credit = @@ -44,5 +47,12 @@ public static class CertificationProtectedFields yield return ("PersonnelCertificationCredits", "FileName", PersonnelFamily, false); yield return ("PersonnelCertificationCredits", "Data", PersonnelFamily, true); } + + /// Catalog 27 additions: (table, column, family). PersonnelCertifications.StatusReason joins the catalog-6 accessor map in ProtectedReadService. + public static IEnumerable<(string Table, string Column, string Family)> Completion() + { + yield return ("UnitCertifications", "StatusReason", UnitFamily); + yield return ("PersonnelCertifications", "StatusReason", PersonnelFamily); + } } } diff --git a/Core/Resgrid.Model/Certifications/CertificationWorkflowTriggers.cs b/Core/Resgrid.Model/Certifications/CertificationWorkflowTriggers.cs index 5df92620a..cefeee956 100644 --- a/Core/Resgrid.Model/Certifications/CertificationWorkflowTriggers.cs +++ b/Core/Resgrid.Model/Certifications/CertificationWorkflowTriggers.cs @@ -14,7 +14,13 @@ public static class CertificationWorkflowTriggers (int)WorkflowTriggerEventType.CertificationRoleRemoved, (int)WorkflowTriggerEventType.CertificationStatusChanged, (int)WorkflowTriggerEventType.UnitCertificationExpiring, - (int)WorkflowTriggerEventType.UnitCertificationExpired + (int)WorkflowTriggerEventType.UnitCertificationExpired, + // Lifecycle completion (registry 180-184, 2026-09-19). + (int)WorkflowTriggerEventType.UnitCertificationAdded, + (int)WorkflowTriggerEventType.UnitCertificationStatusChanged, + (int)WorkflowTriggerEventType.UnitCertificationRemoved, + (int)WorkflowTriggerEventType.CertificationRemoved, + (int)WorkflowTriggerEventType.CertificationCreditAdded }; public static bool IsCertification(int trigger) => Triggers.Contains(trigger); diff --git a/Core/Resgrid.Model/Events/CertificationEvents.cs b/Core/Resgrid.Model/Events/CertificationEvents.cs index cf545adfc..ca4c230dc 100644 --- a/Core/Resgrid.Model/Events/CertificationEvents.cs +++ b/Core/Resgrid.Model/Events/CertificationEvents.cs @@ -72,6 +72,65 @@ public class UnitCertificationExpiringEvent public int DaysUntilExpiry { get; set; } } + /// Trigger 183: a personnel certification record was soft-deleted. + public class CertificationRemovedEvent + { + public int DepartmentId { get; set; } + public PersonnelCertification Certification { get; set; } + public string TypeCode { get; set; } + public string TypeName { get; set; } + public string RemovedByUserId { get; set; } + } + + /// Trigger 184: a renewal credit (CEU / con-ed hours) was logged against a personnel certification. + public class CertificationCreditAddedEvent + { + public int DepartmentId { get; set; } + public PersonnelCertification Certification { get; set; } + public string TypeCode { get; set; } + public string TypeName { get; set; } + public int PersonnelCertificationCreditId { get; set; } + public DateTime CreditDate { get; set; } + public decimal Hours { get; set; } + public string Category { get; set; } + public string AddedByUserId { get; set; } + } + + /// Trigger 180: a unit certification record was created. + public class UnitCertificationAddedEvent + { + public int DepartmentId { get; set; } + public UnitCertification Certification { get; set; } + public string UnitName { get; set; } + public string TypeCode { get; set; } + public string TypeName { get; set; } + } + + /// Trigger 181: a unit certification's status changed (suspend / reinstate / re-activate on renewal). + public class UnitCertificationStatusChangedEvent + { + public int DepartmentId { get; set; } + public UnitCertification Certification { get; set; } + public string UnitName { get; set; } + public string TypeCode { get; set; } + public string TypeName { get; set; } + public int OldStatus { get; set; } + public int NewStatus { get; set; } + public string Reason { get; set; } + public string ChangedByUserId { get; set; } + } + + /// Trigger 182: a unit certification record was soft-deleted. + public class UnitCertificationRemovedEvent + { + public int DepartmentId { get; set; } + public UnitCertification Certification { get; set; } + public string UnitName { get; set; } + public string TypeCode { get; set; } + public string TypeName { get; set; } + public string RemovedByUserId { get; set; } + } + /// Trigger 93 / notification 29: the worker flipped a unit certification to Expired. public class UnitCertificationExpiredEvent { diff --git a/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs new file mode 100644 index 000000000..b30f11ce5 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs @@ -0,0 +1,550 @@ +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 contractor path — rate schedules, service contracts, + // compliance documents and bids (M0215–M0217). Rates are explicit dollars per band (decision 16); the crew size a + // unit bills at is resolved from filled seats per day (decision 17); policies ride the schedule as typed JSON + // (decision 21); discounts cascade profile → contract → bid → deployment/invoice (decision 14). + + /// What a rate schedule entry prices (plan C1). + public enum RateEntryTypes + { + PersonnelCertification = 0, + Crew = 1, + Vehicle = 2, + Equipment = 3, + Service = 4 + } + + public enum BillingBases + { + Hourly = 0, + Daily = 1, + PerPersonPerDay = 2, + PerKilometer = 3, + Fixed = 4 + } + + /// The band a dollar amount applies to. Thresholds, tiers and free units are data on the band, never code. + public enum RateBandTypes + { + Standby = 0, + Deployment = 1, + Overtime1 = 2, + Overtime2 = 3, + DailyStandby = 4, + DailyDeployment = 5, + OutOfProvincePerPersonDaily = 6, + MileagePerKm = 7, + PerDiemMeal = 8, + PrivateAccommodationDaily = 9, + Custom = 10 + } + + public enum ServiceContractTypes + { + StandingArrangement = 0, + Project = 1, + MasterServices = 2, + Other = 3 + } + + public enum ServiceContractStatuses + { + Draft = 0, + Active = 1, + Suspended = 2, + Expired = 3, + Terminated = 4 + } + + public enum DocumentRequirementStages + { + BidSubmission = 0, + DeploymentStart = 1, + DailyTimeReport = 2, + InvoiceSubmission = 3 + } + + public enum ComplianceDocumentTypes + { + InsuranceCertificate = 0, + WorkersCompClearance = 1, + SamRegistration = 2, + BusinessLicense = 3, + CageCode = 4, + TaxRegistration = 5, + Bond = 6, + Other = 7 + } + + public enum BidStatuses + { + Draft = 0, + Submitted = 1, + Accepted = 2, + Declined = 3, + Expired = 4, + Withdrawn = 5 + } + + /// Mirrors plus a free-form line. + public enum BidLineTypes + { + PersonnelCertification = 0, + Crew = 1, + Vehicle = 2, + Equipment = 3, + Service = 4, + FreeForm = 9 + } + + public enum OvertimeBases + { + /// Overtime thresholds apply to consecutive hours worked in a day (the contract-table default). + ConsecutiveHours = 0, + /// Overtime thresholds apply to the day's total hours across spans. + DailyTotalHours = 1 + } + + /// Typed policy JSON on a rate schedule (decision 21). Defaults follow the BCWS contract tables. + public sealed class RateSchedulePolicy + { + public int RoundingMinutes { get; set; } = 30; + public decimal CancellationMinimumHours { get; set; } = 4; + public bool CancellationVehiclesFullDay { get; set; } = true; + /// VIPR/CWN-style minimum billable hours on any mobilized day; null = none. + public decimal? DailyGuaranteeHours { get; set; } + /// Travel spans bill as deployment time (customer contract portal-to-portal); never from a MARS agreement. + public bool PortalToPortal { get; set; } + public decimal UnsafeStandDownHours { get; set; } = 8; + public bool NoClear8CarryOver { get; set; } = true; + public decimal TravelDayCapHours { get; set; } = 12; + public OvertimeBases OvertimeBasis { get; set; } = OvertimeBases.ConsecutiveHours; + /// Deducted per litre of agency-supplied fuel logged on a vehicle's time entries; null = no deduction line. + public decimal? FuelDeductionRatePerLitre { get; set; } + /// Deployment spans closer together than this are one continuous run for the consecutive-hours overtime basis. + public int ContinuousRunGapMinutes { get; set; } = 60; + public List MealEligibility { get; set; } = new List(); + + public static RateSchedulePolicy Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new RateSchedulePolicy(); + try { return JsonConvert.DeserializeObject(json) ?? new RateSchedulePolicy(); } + catch (JsonException) { return new RateSchedulePolicy(); } + } + + public string ToJson() => JsonConvert.SerializeObject(this); + } + + /// A meal per-diem is claimable when the shift covers the window (e.g. breakfast: on duty before 07:00). + public sealed class MealEligibilityWindow + { + public string MealCode { get; set; } + /// Minutes from midnight local; the span must start at or before this to qualify. + public int? StartsBeforeMinutes { get; set; } + /// Minutes from midnight local; the span must end at or after this to qualify. + public int? EndsAfterMinutes { get; set; } + } + + /// A qualification minimum a crew entry requires (NWCG position or certification code + count). + public sealed class RequiredCertification + { + public string Code { get; set; } + public int MinCount { get; set; } = 1; + + public static List Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonConvert.DeserializeObject>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + } + + public class RateSchedule : IEntity + { + [Required] + public string RateScheduleId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string Name { get; set; } + public string Description { get; set; } + public string Currency { get; set; } = "USD"; + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string PolicyJson { get; set; } + public bool IsActive { get; set; } = true; + 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 List Premiums { get; set; } = new List(); + [NotMapped] public RateSchedulePolicy Policy => RateSchedulePolicy.Parse(PolicyJson); + public bool IsCurrent(DateTime onUtc) => IsActive && !IsDeleted && (!EffectiveOn.HasValue || EffectiveOn.Value <= onUtc) && (!ExpiresOn.HasValue || ExpiresOn.Value >= onUtc); + + [NotMapped] public string TableName => "RateSchedules"; + [NotMapped] public string IdName => "RateScheduleId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => RateScheduleId; set => RateScheduleId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "Premiums", "Policy" }; + } + + public class RateScheduleEntry : IEntity + { + [Required] + public string RateScheduleEntryId { get; set; } + [Required] + public string RateScheduleId { get; set; } + public int DepartmentId { get; set; } + /// . + public int EntryType { get; set; } + [Required] + public string Name { get; set; } + public string Code { get; set; } + /// Crew rate family: siblings sharing a key differ by (decision 17). + public string GroupKey { get; set; } + public int? CrewSize { get; set; } + public string CertificationCode { get; set; } + public int? UnitTypeId { get; set; } + public string InventoryItemId { get; set; } + public string InventoryCategoryId { get; set; } + /// . + public int BillingBasis { get; set; } + public string RequiredCertificationsJson { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + 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 Bands { get; set; } = new List(); + [NotMapped] public List RequiredCertifications => RequiredCertification.Parse(RequiredCertificationsJson); + public RateScheduleEntryBand Band(RateBandTypes type) => Bands.FirstOrDefault(b => b.BandType == (int)type); + + [NotMapped] public string TableName => "RateScheduleEntries"; + [NotMapped] public string IdName => "RateScheduleEntryId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => RateScheduleEntryId; set => RateScheduleEntryId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Bands", "RequiredCertifications" }; + } + + public class RateScheduleEntryBand : IEntity + { + [Required] + public string RateScheduleEntryBandId { get; set; } + [Required] + public string RateScheduleEntryId { get; set; } + /// . + public int BandType { get; set; } + public decimal Rate { get; set; } + public decimal? ThresholdStartHours { get; set; } + public decimal? ThresholdEndHours { get; set; } + public decimal? DailyTierMinHours { get; set; } + public decimal? DailyTierMaxHours { get; set; } + public decimal? FreeUnitsPerDay { get; set; } + public bool RequiresAirTravel { get; set; } + public string MealCode { get; set; } + public string Label { get; set; } + public int SortOrder { get; set; } + + [NotMapped] public string TableName => "RateScheduleEntryBands"; + [NotMapped] public string IdName => "RateScheduleEntryBandId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => RateScheduleEntryBandId; set => RateScheduleEntryBandId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A flat hourly adder per band, stacking across premiums (decision 16: OT premium is a flat adder, never multiplied). + public class RatePremium : IEntity + { + [Required] + public string RatePremiumId { get; set; } + [Required] + public string RateScheduleId { get; set; } + public int DepartmentId { get; set; } + [Required] + public string Name { get; set; } + public string Code { get; set; } + public decimal StandbyAdder { get; set; } + public decimal DeploymentAdder { get; set; } + public decimal Overtime1Adder { get; set; } + public decimal Overtime2Adder { get; set; } + public bool IsActive { get; set; } = true; + 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; } + + public decimal AdderFor(RateBandTypes band) => band switch + { + RateBandTypes.Standby => StandbyAdder, + RateBandTypes.Deployment => DeploymentAdder, + RateBandTypes.Overtime1 => Overtime1Adder, + RateBandTypes.Overtime2 => Overtime2Adder, + _ => 0m + }; + + [NotMapped] public string TableName => "RatePremiums"; + [NotMapped] public string IdName => "RatePremiumId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => RatePremiumId; set => RatePremiumId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class ServiceContract : IEntity + { + [Required] + public string ServiceContractId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string ContactId { get; set; } + public string CustomerBillingProfileId { get; set; } + /// The customer's contract number (ministry / agency reference). + public string ContractNumber { get; set; } + [Required] + public string Name { get; set; } + /// . + public int ContractType { get; set; } + /// . + public int Status { get; set; } + public DateTime StartOn { get; set; } + public DateTime? EndOn { get; set; } + public string RateScheduleId { get; set; } + public decimal? DiscountPercent { get; set; } + public int? TermsNetDays { get; set; } + public string InvoiceSubmissionEmail { get; set; } + public int? MaxDeploymentDays { get; set; } + public int? ResponseTimeMinutes { get; set; } + public string PointOfHire { get; set; } + /// DTR / invoice / manifest PDF template variant (Generic default). + public string DocumentTemplateKey { 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 Requirements { get; set; } = new List(); + public bool IsLive(DateTime onUtc) => Status == (int)ServiceContractStatuses.Active && !IsDeleted && StartOn <= onUtc && (!EndOn.HasValue || EndOn.Value >= onUtc); + + [NotMapped] public string TableName => "ServiceContracts"; + [NotMapped] public string IdName => "ServiceContractId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => ServiceContractId; set => ServiceContractId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Requirements" }; + } + + public class ServiceContractDocumentRequirement : IEntity + { + [Required] + public string ServiceContractDocumentRequirementId { get; set; } + [Required] + public string ServiceContractId { get; set; } + [Required] + public string Name { get; set; } + /// . + public int Stage { get; set; } + /// ; a current department document of this type satisfies the requirement. + public int? ComplianceDocumentType { get; set; } + public bool IsMandatory { get; set; } = true; + public int SortOrder { get; set; } + + [NotMapped] public string TableName => "ServiceContractDocumentRequirements"; + [NotMapped] public string IdName => "ServiceContractDocumentRequirementId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => ServiceContractDocumentRequirementId; set => ServiceContractDocumentRequirementId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A department-level compliance document with expiry (decision 24). Sent to customers in invoice packets, so not under ADP. + public class DepartmentComplianceDocument : IEntity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int DepartmentComplianceDocumentId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int DocumentType { get; set; } + [Required] + public string Name { get; set; } + public string DocumentNumber { get; set; } + public string Issuer { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public int AlertLeadDays { get; set; } = 30; + 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 DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + public bool IsCurrent(DateTime onUtc) => !IsDeleted && (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= onUtc.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= onUtc.Date); + + [NotMapped] public string TableName => "DepartmentComplianceDocuments"; + [NotMapped] public string IdName => "DepartmentComplianceDocumentId"; + [NotMapped] public int IdType => 0; + [NotMapped] [JsonIgnore] public object IdValue { get => DepartmentComplianceDocumentId; set => DepartmentComplianceDocumentId = (int)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public class Bid : IEntity + { + [Required] + public string BidId { get; set; } + [Required] + public int DepartmentId { get; set; } + public int BidNumber { get; set; } + [Required] + public string ContactId { get; set; } + public string CustomerBillingProfileId { get; set; } + public string ServiceContractId { get; set; } + public string RateScheduleId { get; set; } + [Required] + public string Title { get; set; } + public string Description { get; set; } + /// . + public int Status { get; set; } + public DateTime? ValidUntil { get; set; } + public DateTime? RequestedStartOn { get; set; } + public DateTime? RequestedEndOn { get; set; } + public string IncidentNumber { get; set; } + public string DeliveryLocation { get; set; } + public decimal? DiscountPercent { get; set; } + public decimal EstimatedSubTotal { get; set; } + public decimal EstimatedDiscountAmount { get; set; } + public decimal EstimatedTaxAmount { get; set; } + public decimal EstimatedTotal { get; set; } + public string Notes { get; set; } + public string TermsText { get; set; } + public DateTime? SentOn { get; set; } + public string SentToEmail { get; set; } + public DateTime? AcceptedOn { get; set; } + public DateTime? DeclinedOn { get; set; } + public string DeclineReason { get; set; } + public int? ConvertedCallId { get; set; } + public string ConvertedDeploymentId { 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 LineItems { get; set; } = new List(); + [NotMapped] public bool IsEditable => Status is (int)BidStatuses.Draft or (int)BidStatuses.Submitted; + [NotMapped] public bool IsConverted => !string.IsNullOrWhiteSpace(ConvertedDeploymentId); + + [NotMapped] public string TableName => "Bids"; + [NotMapped] public string IdName => "BidId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => BidId; set => BidId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "LineItems", "IsEditable", "IsConverted" }; + } + + public class BidLineItem : IEntity + { + [Required] + public string BidLineItemId { get; set; } + [Required] + public string BidId { get; set; } + public int DepartmentId { get; set; } + public string RateScheduleEntryId { get; set; } + /// . + public int LineType { get; set; } + [Required] + public string Description { get; set; } + public int? CrewSize { get; set; } + public decimal Quantity { get; set; } = 1; + public decimal? EstimatedHoursPerDay { get; set; } + public decimal? EstimatedDays { get; set; } + /// Snapshot of the rate at authoring; the schedule may move afterwards. + public decimal UnitRate { get; set; } + public string PremiumIdsJson { get; set; } + public decimal EstimatedAmount { get; set; } + public bool Taxable { get; set; } = true; + public int SortOrder { get; set; } + + [NotMapped] public List PremiumIds => string.IsNullOrWhiteSpace(PremiumIdsJson) ? new List() : (JsonConvert.DeserializeObject>(PremiumIdsJson) ?? new List()); + + [NotMapped] public string TableName => "BidLineItems"; + [NotMapped] public string IdName => "BidLineItemId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => BidLineItemId; set => BidLineItemId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "PremiumIds" }; + } + + /// Per-department bid number counter (M0217); allocated atomically by the repository. + public class BidNumberSequence : IEntity + { + [Required] + public int DepartmentId { get; set; } + public int NextBidNumber { get; set; } = 1; + + [NotMapped] public string TableName => "BidNumberSequences"; + [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" }; + } + + /// Workflow payload contract for the bid and contract triggers (plan C8, registry 74–80; decision 22). + public static class ContractorWorkflowPayload + { + public const string Producer = "ContractorBilling"; + + public static readonly (string Variable, string Property)[] BidVariables = + { + ("id", "BidId"), ("number", "BidNumber"), ("title", "Title"), ("status", "Status"), ("old_status", "OldStatus"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), + ("contract_id", "ServiceContractId"), ("incident_number", "IncidentNumber"), ("valid_until", "ValidUntil"), ("requested_start_on", "RequestedStartOn"), ("requested_end_on", "RequestedEndOn"), + ("estimated_total", "EstimatedTotal"), ("currency", "Currency"), ("sent_on", "SentOn"), ("accepted_on", "AcceptedOn"), ("declined_on", "DeclinedOn"), ("converted_deployment_id", "ConvertedDeploymentId"), ("converted_call_id", "ConvertedCallId") + }; + + public static readonly (string Variable, string Property)[] ContractVariables = + { + ("id", "ServiceContractId"), ("number", "ContractNumber"), ("name", "Name"), ("status", "Status"), ("old_status", "OldStatus"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), + ("contract_type", "ContractType"), ("start_on", "StartOn"), ("end_on", "EndOn"), ("days_until_end", "DaysUntilEnd"), ("rate_schedule_id", "RateScheduleId") + }; + + public static readonly int[] BidTriggers = + { + (int)WorkflowTriggerEventType.BidCreated, (int)WorkflowTriggerEventType.BidSent, (int)WorkflowTriggerEventType.BidAccepted, (int)WorkflowTriggerEventType.BidDeclined, (int)WorkflowTriggerEventType.BidExpired + }; + + public static readonly int[] ContractTriggers = { (int)WorkflowTriggerEventType.ContractStatusChanged, (int)WorkflowTriggerEventType.ContractExpiring }; + + public static bool IsBid(int trigger) => BidTriggers.Contains(trigger); + public static bool IsContract(int trigger) => ContractTriggers.Contains(trigger); + public static bool IsContractor(int trigger) => IsBid(trigger) || IsContract(trigger); + } +} diff --git a/Core/Resgrid.Model/Invoicing/ContractorChargeModels.cs b/Core/Resgrid.Model/Invoicing/ContractorChargeModels.cs new file mode 100644 index 000000000..444db6656 --- /dev/null +++ b/Core/Resgrid.Model/Invoicing/ContractorChargeModels.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.Invoicing +{ + // Workforce & Business Operations plan, Phase C (C4): the contractor billing engine's input and output contracts. + // The calculator is pure — everything it needs arrives in and the result is a + // charge set the invoicing path turns into draft invoice lines. + + public sealed class ContractorChargeInput + { + public Deployment Deployment { get; set; } + /// The effective schedule with (each with bands) and loaded. + public RateSchedule Schedule { get; set; } + /// Approved, unbilled daily time reports with their entries. + public List Reports { get; set; } = new List(); + public List Expenses { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Equipment { get; set; } = new List(); + /// Display names keyed by DeploymentPersonnelId / DeploymentUnitId / DeploymentEquipmentId. + public Dictionary SubjectNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// The local date the deployment was cancelled, when it was (that day bills the cancellation minimums). + public DateTime? CancellationDate { get; set; } + /// Discount to snapshot on the invoice (contract → bid → deployment cascade resolved by the caller). + public decimal? DiscountPercent { get; set; } + } + + public enum ContractorChargeKinds + { + Hourly = 0, + Daily = 1, + Premium = 2, + OutOfProvince = 3, + Mileage = 4, + FuelDeduction = 5, + Expense = 6, + Fixed = 7 + } + + public sealed class ContractorChargeBand + { + public int BandType { get; set; } + public string Label { get; set; } + public decimal Hours { get; set; } + public decimal Rate { get; set; } + public decimal Amount { get; set; } + } + + public sealed class ContractorChargeLine + { + public DateTime Date { get; set; } + public string DeploymentTimeReportId { get; set; } + public int ReportNumber { get; set; } + public string IncidentNumber { get; set; } + public int? SubjectType { get; set; } + public string SubjectId { get; set; } + public string SubjectName { get; set; } + public string RateScheduleEntryId { get; set; } + public string EntryName { get; set; } + public string RatePremiumId { get; set; } + public string DeploymentExpenseId { get; set; } + public ContractorChargeKinds Kind { get; set; } + public string Description { get; set; } + public decimal Quantity { get; set; } + public decimal UnitRate { get; set; } + public decimal Amount { get; set; } + public bool Taxable { get; set; } = true; + public List Bands { get; set; } = new List(); + public int SortOrder { get; set; } + } + + public static class ContractorChargeWarningCodes + { + public const string NoReports = "no_reports"; + public const string ScheduleMissing = "schedule_missing"; + public const string RateEntryMissing = "rate_entry_missing"; + public const string CrewSizeFallback = "crew_size_fallback"; + public const string BandMissing = "band_missing"; + public const string TravelCapped = "travel_capped"; + public const string PerDiemMismatch = "per_diem_mismatch"; + public const string PerDiemIneligible = "per_diem_ineligible"; + public const string PerDiemAgencyMeals = "per_diem_agency_meals"; + public const string AccommodationAgencySupplied = "accommodation_agency_supplied"; + } + + public sealed class ContractorChargeWarning + { + public string Code { get; set; } + public string Message { get; set; } + public string DeploymentTimeReportId { get; set; } + public string SubjectId { get; set; } + public string DeploymentExpenseId { get; set; } + } + + public sealed class ContractorChargeSet + { + public string DeploymentId { get; set; } + public string Currency { get; set; } + public List Lines { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + public List ReportIds { get; set; } = new List(); + public decimal? DiscountPercent { get; set; } + public decimal SubTotal => Math.Round(Lines.Sum(l => l.Amount), 2, MidpointRounding.AwayFromZero); + public decimal DiscountAmount => DiscountPercent.HasValue && DiscountPercent.Value > 0 ? Math.Round(SubTotal * DiscountPercent.Value / 100m, 2, MidpointRounding.AwayFromZero) : 0m; + public decimal TotalBeforeTax => SubTotal - DiscountAmount; + public bool HasCharges => Lines.Count > 0; + } + + /// A caller-built e-mail attachment for IInvoicingService.SendInvoiceAsync (the contractor packet zip). + public sealed class InvoiceSendAttachment + { + public string FileName { get; set; } + public string ContentType { get; set; } = "application/zip"; + public byte[] Data { get; set; } + public List Contents { get; set; } = new List(); + } + + /// The invoice-submission packet: the invoice PDF plus the DTR PDFs, receipts, manifest and compliance documents the contract asks for. + public sealed class ContractorInvoicePacket + { + public string FileName { get; set; } + public byte[] Data { get; set; } + public List Contents { get; set; } = new List(); + public List MissingRequirements { get; set; } = new List(); + } + + /// One document requirement evaluated against the deployment's attachments and the department's current compliance documents (plan C4, v1 warn-only). + public sealed class ContractComplianceItem + { + public string ServiceContractDocumentRequirementId { get; set; } + public string Name { get; set; } + public int Stage { get; set; } + public int? ComplianceDocumentType { get; set; } + public bool IsMandatory { get; set; } + public bool Satisfied { get; set; } + public string SatisfiedBy { get; set; } + public int? DepartmentComplianceDocumentId { get; set; } + public int? DeploymentAttachmentId { get; set; } + public DateTime? ExpiresOn { get; set; } + } + + public sealed class ContractComplianceResult + { + public string ServiceContractId { get; set; } + public string DeploymentId { get; set; } + public List Items { get; set; } = new List(); + public bool AllMandatorySatisfied => Items.Where(i => i.IsMandatory).All(i => i.Satisfied); + } + + /// Everything the "Schedule Deployment Call" wizard needs to prefill from an accepted bid (plan C6). + public sealed class BidConversionContext + { + public Bid Bid { get; set; } + public ServiceContract Contract { get; set; } + public RateSchedule Schedule { get; set; } + public string ContactName { get; set; } + public decimal? EffectiveDiscountPercent { get; set; } + public string Currency { get; set; } + public bool AlreadyConverted => Bid != null && Bid.IsConverted; + } + + public sealed class BidConversionUnit + { + public int UnitId { get; set; } + public string CallSign { get; set; } + /// The bid crew line this unit fulfils (drives the crew rate family). + public string BidLineItemId { get; set; } + public string RateScheduleEntryId { get; set; } + public List Seats { get; set; } = new List(); + public List Equipment { get; set; } = new List(); + } + + public sealed class BidConversionSeat + { + public string UserId { get; set; } + public int? UnitRoleId { get; set; } + public string RateScheduleEntryId { get; set; } + public string CertificationCode { get; set; } + public List PremiumIds { get; set; } = new List(); + public string CallSign { get; set; } + } + + public sealed class BidConversionEquipment + { + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string FreeTextName { get; set; } + public string RateScheduleEntryId { get; set; } + public string BidLineItemId { get; set; } + } + + public sealed class BidConversionRequest + { + public string BidId { get; set; } + public string CallName { get; set; } + public string CallNature { get; set; } + public int CallPriority { get; set; } + public int? CallTypeId { get; set; } + public string Address { get; set; } + public string GeoLocation { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + public string PointOfHire { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string LocalTimeZoneId { get; set; } + public string Notes { get; set; } + public bool CreateCalendarItem { get; set; } = true; + /// Unrostered personnel (no unit seat) billed under their own certification entry. + public List UnassignedPersonnel { get; set; } = new List(); + public List Units { get; set; } = new List(); + } + + public sealed class BidConversionResult + { + public Bid Bid { get; set; } + public Deployment Deployment { get; set; } + public int CallId { get; set; } + public int? CalendarItemId { get; set; } + public List Warnings { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs index 1075432ea..c95fb2941 100644 --- a/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs +++ b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs @@ -21,7 +21,7 @@ public class CustomerBillingProfile : IEntity [Required] public string ContactId { get; set; } - /// ADP catalog 26 field (Phase B2) in an enrolled department. + /// Customer-facing: not under ADP (the customer reads it without a login). public string BillingEmail { get; set; } public int? BillingAddressId { get; set; } public bool UseContactMailingAddress { get; set; } = true; diff --git a/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs index decc9a719..bac6d20f0 100644 --- a/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs +++ b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs @@ -38,6 +38,9 @@ public class DepartmentBillingIdentity : IEntity public bool ShowPayOnlineOnDocuments { get; set; } = true; public DateTime UpdatedOn { get; set; } public string UpdatedByUserId { get; set; } + /// Reserved marker columns (never set): the registrations print on every invoice, so they are not under ADP. + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } [NotMapped] public string TableName => "DepartmentBillingIdentities"; diff --git a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs index cfbc78cfd..5b92c6ffa 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs @@ -53,6 +53,9 @@ public sealed class DeploymentPersonnelInput public string CertificationCode { get; set; } public string CallSign { get; set; } public string RmsExternalOrderFillId { get; set; } + /// Contractor billing (C-M2): the certification rate entry and premium adders this person bills under. + public string RateScheduleEntryId { get; set; } + public List PremiumIds { get; set; } /// Write the row even when a seat requirement fails (the wizard's partial-fill path, decision 17). public bool Force { get; set; } } @@ -64,6 +67,8 @@ public sealed class DeploymentEquipmentInput public string InventoryItemId { get; set; } public string FreeTextName { get; set; } public string Notes { get; set; } + /// Contractor billing (C-M2): the equipment/vehicle rate entry this item bills under. + public string RateScheduleEntryId { 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; } diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs index 2c7a7ee95..06a323699 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs @@ -233,7 +233,7 @@ public class DeploymentEquipment : IEntity [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. + /// A pre-numbered daily time report (DTR; the shift-ticket / CTR analog). Customer-signed and sent with invoices, so not under ADP. public class DeploymentTimeReport : IEntity { [Required] @@ -323,7 +323,7 @@ public class DeploymentTimeEntry : IEntity [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Hours", "SubjectId" }; } - /// A dated expense against a deployment. Description is an ADP catalog 28 field. + /// A dated expense against a deployment. Billed through to the customer, so not under ADP. public class DeploymentExpense : IEntity { [Required] @@ -359,7 +359,7 @@ public class DeploymentExpense : IEntity [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. + /// A file on a deployment (receipt, signed request, DTR PDF, manifest…). Sent to customers in invoice packets, so not under ADP. public class DeploymentAttachment : IEntity { [Key] @@ -402,40 +402,26 @@ public class TimeReportNumberSequence : IEntity [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. + /// + /// ADP catalog 27 accessor map for the deployment core: the finance wrapper's internal notes. Daily time reports, + /// entries, expenses and attachments are deliberately not cataloged — they are customer-facing (the customer signs + /// the DTR; receipts, DTR PDFs and manifests ride the invoice packet) and must read whole for people outside the + /// department. + /// 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 const int CatalogVersion = 27; - public static readonly IReadOnlyDictionary Get, Action Set)> Expense = - new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) + public static readonly IReadOnlyDictionary Get, Action Set)> DeploymentFields = + 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) + ["deployments.notes"] = (d => d.Notes, (d, v) => d.Notes = 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); + yield return ("Deployments", "Notes", false); } } @@ -450,24 +436,23 @@ public static readonly (string Variable, string Property)[] Variables = ("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") + ("report_number", "ReportNumber"), ("report_date", "ReportDate"), ("report_id", "TimeReportId"), ("report_status", "ReportStatus"), + ("expense_type", "ExpenseType"), ("expense_amount", "ExpenseAmount"), ("expense_currency", "ExpenseCurrency"), + ("attachment_id", "AttachmentId"), ("attachment_type", "AttachmentType"), ("attachment_name", "AttachmentName") }; public static readonly int[] Triggers = { (int)WorkflowTriggerEventType.DeploymentCreated, (int)WorkflowTriggerEventType.DeploymentStatusChanged, (int)WorkflowTriggerEventType.DeploymentRosterChanged, - (int)WorkflowTriggerEventType.DeploymentExpenseAdded, (int)WorkflowTriggerEventType.TimeReportSubmitted, (int)WorkflowTriggerEventType.TimeReportApproved + (int)WorkflowTriggerEventType.DeploymentExpenseAdded, (int)WorkflowTriggerEventType.TimeReportSubmitted, (int)WorkflowTriggerEventType.TimeReportApproved, + // Lifecycle completion (registry 185-187, 2026-09-19). + (int)WorkflowTriggerEventType.TimeReportCreated, (int)WorkflowTriggerEventType.TimeReportVoided, (int)WorkflowTriggerEventType.DeploymentAttachmentAdded }; 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 - }; + /// Registry 74-80 (bids, contracts) were hidden here until the contractor-billing milestone (C-M2, 2026-09-19) published them under ; nothing is reserved now. + public static readonly int[] Reserved = Array.Empty(); 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 index 410117b61..eb9f7bf99 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs @@ -3,17 +3,20 @@ 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. + /// Department-configurable deployment and contractor permissions (Workforce & Business Operations plan, C8; + /// registry 116-119). All fall back to department administrators. Rostered members always see their own + /// deployments and file time entries without any of them; ManageMutualAidReimbursement (79) joins this list with + /// the Cal OES MARS milestone. /// 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) + new RecordPermissionDescriptor(PermissionTypes.ApproveTimeReports, PermissionActions.DepartmentAdminsOnly, false, "ApproveTimeReportsNote", false), + // Contractor path (C-M2, 2026-09-19): bids and service contracts / compliance documents. + new RecordPermissionDescriptor(PermissionTypes.ManageBids, PermissionActions.DepartmentAdminsOnly, false, "ManageBidsNote", false), + new RecordPermissionDescriptor(PermissionTypes.ManageContracts, PermissionActions.DepartmentAdminsOnly, false, "ManageContractsNote", false) }; } } diff --git a/Core/Resgrid.Model/Invoicing/Invoice.cs b/Core/Resgrid.Model/Invoicing/Invoice.cs index 8ab192342..c7fa723b7 100644 --- a/Core/Resgrid.Model/Invoicing/Invoice.cs +++ b/Core/Resgrid.Model/Invoicing/Invoice.cs @@ -41,12 +41,12 @@ public class Invoice : IEntity /// Snapshot of the applied tax components with per-component amounts (decision 23); null when a flat rate applied. public string TaxComponentsJson { get; set; } - /// ADP catalog 26 field (Phase B2) in an enrolled department. + /// Customer-facing: not under ADP (the customer reads it without a login). public string Notes { get; set; } public string TermsText { get; set; } public DateTime? SentOn { get; set; } - /// ADP catalog 26 field (Phase B2) in an enrolled department. + /// Customer-facing: not under ADP (the customer reads it without a login). public string SentToEmail { get; set; } public DateTime? PaidOn { get; set; } public DateTime? VoidedOn { get; set; } diff --git a/Core/Resgrid.Model/Invoicing/InvoicePayment.cs b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs index 34bc306d0..39d1a79cb 100644 --- a/Core/Resgrid.Model/Invoicing/InvoicePayment.cs +++ b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs @@ -25,7 +25,7 @@ public class InvoicePayment : IEntity /// . public int Method { get; set; } - /// Check number, remittance reference. ADP catalog 26 field (Phase B2). + /// Check number, remittance reference. Printed on the customer's receipt; not under ADP. public string Reference { get; set; } /// Provider payment / charge / capture id (Phase B2); null for manual records. @@ -41,16 +41,16 @@ public class InvoicePayment : IEntity public decimal? ProviderFeeAmount { get; set; } public decimal? NetAmount { get; set; } - /// ADP catalog 26 field (Phase B2). + /// Customer-facing (receipt); not under ADP. public string PayerEmail { get; set; } /// "Visa •••• 4242", "ACH" — display only. public string PaymentMethodSummary { get; set; } - /// ADP catalog 26 field (Phase B2). + /// Customer-facing (receipt); not under ADP. public string ReceiptUrl { get; set; } - /// ADP catalog 26 field (Phase B2). + /// Customer-facing (receipt); not under ADP. public string Notes { get; set; } public DateTime PaidOn { get; set; } diff --git a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs index 667c01869..ae1f722e7 100644 --- a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs @@ -404,48 +404,4 @@ public class OnlinePaymentsStatus /// The gate that fails first, as a resource key (payments_*), or null. public string BlockedReason { get; set; } } - - /// - /// The Phase B columns Advanced Data Protection catalogs (ADP catalog 26, plan B2.2): customer-contact and - /// payment-reference text under the Contacts family. Amounts, statuses, numbers and dates stay metadata. - /// - public static class InvoicingProtectedFields - { - public const int CatalogVersion = 26; - public const string Family = "Contacts"; - - public static readonly IReadOnlyDictionary Get, Action Set)> BillingProfile = - new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) - { - ["customerbillingprofiles.billingemail"] = (p => p.BillingEmail, (p, v) => p.BillingEmail = v) - }; - - public static readonly IReadOnlyDictionary Get, Action Set)> Invoice = - new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) - { - ["invoices.senttoemail"] = (i => i.SentToEmail, (i, v) => i.SentToEmail = v), - ["invoices.notes"] = (i => i.Notes, (i, v) => i.Notes = v) - }; - - public static readonly IReadOnlyDictionary Get, Action Set)> Payment = - new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase) - { - ["invoicepayments.payeremail"] = (p => p.PayerEmail, (p, v) => p.PayerEmail = v), - ["invoicepayments.reference"] = (p => p.Reference, (p, v) => p.Reference = v), - ["invoicepayments.receipturl"] = (p => p.ReceiptUrl, (p, v) => p.ReceiptUrl = v), - ["invoicepayments.notes"] = (p => p.Notes, (p, v) => p.Notes = v) - }; - - /// Every cataloged field id, for tests and the catalog builder. - public static IEnumerable<(string Table, string Column)> All() - { - yield return ("CustomerBillingProfiles", "BillingEmail"); - yield return ("Invoices", "SentToEmail"); - yield return ("Invoices", "Notes"); - yield return ("InvoicePayments", "PayerEmail"); - yield return ("InvoicePayments", "Reference"); - yield return ("InvoicePayments", "ReceiptUrl"); - yield return ("InvoicePayments", "Notes"); - } - } } diff --git a/Core/Resgrid.Model/Providers/IEmailProvider.cs b/Core/Resgrid.Model/Providers/IEmailProvider.cs index b91228a75..556b086ff 100644 --- a/Core/Resgrid.Model/Providers/IEmailProvider.cs +++ b/Core/Resgrid.Model/Providers/IEmailProvider.cs @@ -46,7 +46,8 @@ Task SendReportDeliveryMail(string email, string subject, string messageBo /// Customer invoice with the PDF attached (Workforce & Business Operations plan, Phase B). payUrl is null until online payments are enabled (Phase B2). Task SendInvoiceMail(string email, string subject, string messageBody, string sentOn, - string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding); + string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding, + string attachmentContentType = "application/pdf"); Task SendCommunicationTestMail(string email, CommunicationTestEmailContent content); diff --git a/Core/Resgrid.Model/Repositories/IContractorRepositories.cs b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs new file mode 100644 index 000000000..2f56e9c05 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs @@ -0,0 +1,82 @@ +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 (C2): contractor path repositories (registry M0215–M0217). + + public interface IRateScheduleRepository : IRepository + { + Task GetByIdForDepartmentAsync(string rateScheduleId, int departmentId); + Task> GetForDepartmentAsync(int departmentId, bool includeInactive); + } + + public interface IRateScheduleEntryRepository : IRepository + { + Task> GetByScheduleAsync(string rateScheduleId, bool includeInactive); + Task> GetByIdsAsync(IEnumerable rateScheduleEntryIds); + } + + public interface IRateScheduleEntryBandRepository : IRepository + { + Task> GetByScheduleAsync(string rateScheduleId); + Task> GetByEntryAsync(string rateScheduleEntryId); + Task DeleteByEntryAsync(string rateScheduleEntryId, CancellationToken cancellationToken = default); + } + + public interface IRatePremiumRepository : IRepository + { + Task> GetByScheduleAsync(string rateScheduleId, bool includeInactive); + } + + public interface IServiceContractRepository : IRepository + { + Task GetByIdForDepartmentAsync(string serviceContractId, int departmentId); + Task> GetForDepartmentAsync(int departmentId, int? status); + Task> GetByContactIdAsync(int departmentId, string contactId); + /// Active contracts whose EndOn falls inside the window (expiry sweep). + Task> GetEndingBetweenAsync(DateTime fromUtc, DateTime toUtc); + /// Active contracts whose EndOn is already behind . + Task> GetLapsedAsync(DateTime asOfUtc); + } + + public interface IServiceContractDocumentRequirementRepository : IRepository + { + Task> GetByContractAsync(string serviceContractId); + Task DeleteByContractAsync(string serviceContractId, CancellationToken cancellationToken = default); + } + + public interface IDepartmentComplianceDocumentRepository : IRepository + { + /// Document rows without their bytes. + Task> GetForDepartmentAsync(int departmentId); + Task GetByIdWithDataAsync(int departmentComplianceDocumentId); + /// Documents (all departments, no bytes) whose expiry is inside [asOf, asOf + AlertLeadDays] or already past. + Task> GetExpiringAsync(DateTime asOfUtc); + } + + public interface IBidRepository : IRepository + { + Task GetByIdForDepartmentAsync(string bidId, int departmentId); + Task> GetForDepartmentAsync(int departmentId, int? status, int skip, int take); + Task CountForDepartmentAsync(int departmentId, int? status); + Task> GetByContactIdAsync(int departmentId, string contactId); + Task> GetByContractAsync(string serviceContractId); + /// Submitted bids (all departments) whose ValidUntil is behind . + Task> GetExpiryCandidatesAsync(DateTime asOfUtc); + } + + public interface IBidLineItemRepository : IRepository + { + Task> GetByBidAsync(string bidId); + } + + public interface IBidNumberSequenceRepository : IRepository + { + /// Atomically returns the next bid number for the department and advances the sequence (dialect-specific SQL). + Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs index fb719bf6a..83c6c137e 100644 --- a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs @@ -48,6 +48,8 @@ public interface IDeploymentTimeReportRepository : IRepository GetByDeploymentAndDateAsync(string deploymentId, DateTime reportDate); /// Approved reports not yet on an invoice (the billing engine's input). Task> GetUnbilledApprovedAsync(int departmentId, string deploymentId = null); + /// All departments: Approved, unbilled reports approved on or before (finance reminder sweep, worker 32). + Task> GetUnbilledApprovedBeforeAsync(DateTime approvedBeforeUtc); } public interface IDeploymentTimeEntryRepository : IRepository @@ -67,7 +69,11 @@ public interface IDeploymentAttachmentRepository : IRepositoryAttachment rows without their bytes. Task> GetByDeploymentAsync(string deploymentId); + /// One attachment row without its bytes. + Task GetMetadataByIdAsync(int deploymentAttachmentId); Task GetByIdWithDataAsync(int deploymentAttachmentId); + /// Soft-deletes the row in place (no blob round trip); 1 when a live row of the department was marked. + Task MarkDeletedAsync(int deploymentAttachmentId, int departmentId, CancellationToken cancellationToken = default); } public interface ITimeReportNumberSequenceRepository : IRepository diff --git a/Core/Resgrid.Model/RoleMembershipException.cs b/Core/Resgrid.Model/RoleMembershipException.cs new file mode 100644 index 000000000..b13b62f4e --- /dev/null +++ b/Core/Resgrid.Model/RoleMembershipException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// A role membership change refused by IPersonnelRolesService: the message is the domain code the callers + /// already match (certifications_role_requirements_unmet, roles_member_not_in_department) and + /// names the one member the refusal is about, so a caller reports that member rather than + /// every addition it sent. + /// + public sealed class RoleMembershipException : InvalidOperationException + { + public const string RequirementsUnmet = "certifications_role_requirements_unmet"; + public const string NotInDepartment = "roles_member_not_in_department"; + + public RoleMembershipException(string code, string userId) : base(code) + { + UserId = userId; + } + + /// The member the change was refused for. + public string UserId { get; } + } +} diff --git a/Core/Resgrid.Model/Services/IBidsService.cs b/Core/Resgrid.Model/Services/IBidsService.cs new file mode 100644 index 000000000..5691dfdaf --- /dev/null +++ b/Core/Resgrid.Model/Services/IBidsService.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Contractor bids (Workforce & Business Operations plan, C4; decisions 14, 22). A bid is a priced estimate + /// for a customer contact under an optional service contract; accepting one launches the deployment wizard whose + /// last step is . Lifecycle changes publish the registry 74–78 triggers + /// through the domain outbox. Callers authorize. + /// + public interface IBidsService + { + Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status = null, int skip = 0, int take = 100); + Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status = null); + Task> GetBidsByContactIdAsync(string contactId, int departmentId); + /// The bid with its lines; null when missing or deleted. + Task GetBidByIdAsync(string bidId, int departmentId); + + /// Allocates the number and resolves the rate schedule and discount (contract → profile) for a new draft. + Task CreateDraftBidAsync(int departmentId, string contactId, string serviceContractId, string title, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Header fields of a Draft or Submitted bid; numbers, status, estimates and provenance stay server-owned. + Task SaveBidAsync(Bid bid, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Upserts lines by id (stale lines removed), snapshots rates from the schedule for lines without one, recalculates estimates. + Task SaveBidLineItemsAsync(string bidId, int departmentId, List lineItems, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecalculateEstimatesAsync(string bidId, int departmentId, CancellationToken cancellationToken = default); + + /// Draft → Submitted without e-mail (hand delivery). + Task SubmitBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Renders the PDF, e-mails it, marks the bid Submitted/Sent and publishes BidSent. + Task SendBidAsync(string bidId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task AcceptBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeclineBidAsync(string bidId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task WithdrawBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ExpireBidAsync(string bidId, int departmentId, CancellationToken cancellationToken = default); + /// Soft-deletes a Draft bid. + Task DeleteBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + Task RenderBidHtmlAsync(string bidId, int departmentId); + Task GetBidPdfAsync(string bidId, int departmentId); + + /// Everything the "Schedule Deployment Call" wizard prefills from an accepted bid. + Task GetBidConversionContextAsync(string bidId, int departmentId); + /// Transactional: Call (+ contact) → Deployment (+ roster, equipment, rate snapshots) → calendar item; stamps the bid with both ids. + Task ConvertBidToDeploymentAsync(BidConversionRequest request, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Daily sweep: Submitted bids past ValidUntil expire (worker 31). Returns the number expired. + Task RunExpirySweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IContractorBillingEngine.cs b/Core/Resgrid.Model/Services/IContractorBillingEngine.cs new file mode 100644 index 000000000..8116ca7fe --- /dev/null +++ b/Core/Resgrid.Model/Services/IContractorBillingEngine.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// The contractor billing engine (Workforce & Business Operations plan, C4): approved, unbilled daily time + /// reports → charge set → draft invoice with deployment/DTR provenance, plus the invoice-submission packet + /// (invoice PDF, DTR PDFs, receipts, manifest and compliance documents). The arithmetic lives in the pure + /// calculator; this service loads the graph and writes the invoice. Callers authorize. + /// + public interface IContractorBillingEngine + { + /// Charges for every Approved, unbilled DTR of the deployment dated on or before (all when null). Never writes. + Task CalculateDeploymentChargesAsync(string deploymentId, int departmentId, DateTime? throughDate = null); + + /// Charge set → draft invoice (lines carry CallId + DeploymentTimeReportId; header carries DeploymentId/ServiceContractId, contract terms, discount snapshot); the DTRs move to Billed. + Task GenerateInvoiceFromDeploymentAsync(string deploymentId, int departmentId, DateTime? throughDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// The packet for an invoice generated from a deployment: invoice PDF + DTR PDFs + receipts + manifest + compliance documents the contract requires at invoice submission. Protected files the caller cannot reveal are listed as missing. + Task BuildInvoicePacketAsync(string invoiceId, int departmentId); + + /// Sends the invoice with the packet attached (contract submission address by default). + Task SendDeploymentInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Daily sweep (worker 32): billable deployments with approved DTRs unbilled longer than , or completed with any unbilled DTR, digest to department administrators. Returns departments notified. + Task RunFinanceReminderSweepAsync(DateTime asOfUtc, int unbilledDays, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IDeploymentService.cs b/Core/Resgrid.Model/Services/IDeploymentService.cs index 5fe5e4197..4d38b2b9f 100644 --- a/Core/Resgrid.Model/Services/IDeploymentService.cs +++ b/Core/Resgrid.Model/Services/IDeploymentService.cs @@ -34,7 +34,7 @@ public interface IDeploymentService /// 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 AddUnitAsync(string deploymentId, int departmentId, int unitId, string callSign, string notes, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default, string rateScheduleEntryId = null); 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); @@ -42,6 +42,8 @@ public interface IDeploymentService 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); + /// Schedule conflicts for a window before a deployment exists (the bid conversion wizard). + Task> GetWindowConflictsAsync(int departmentId, DateTime windowStart, DateTime windowEnd, IEnumerable userIds, IEnumerable unitIds); Task GetCrewSizeForUnitAsync(string deploymentUnitId, int departmentId); Task> GetAttachmentsAsync(string deploymentId, int departmentId); diff --git a/Core/Resgrid.Model/Services/IEmailService.cs b/Core/Resgrid.Model/Services/IEmailService.cs index 50f249626..c51d72e8d 100644 --- a/Core/Resgrid.Model/Services/IEmailService.cs +++ b/Core/Resgrid.Model/Services/IEmailService.cs @@ -209,7 +209,7 @@ Task SendUserCancellationNotificationToTeamAsync(Department department, Pa Task SendReportDeliveryAsync(EmailNotification email, int departmentId, string reportUrl, string reportName); /// Customer invoice with the PDF attached (Workforce & Business Operations plan, Phase B). Honors DoNotBroadcast and department e-mail branding. - Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel); + Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel, string attachmentContentType = "application/pdf"); /// /// Sends a contact-method verification code to the user's email address. diff --git a/Core/Resgrid.Model/Services/IInvoicingService.cs b/Core/Resgrid.Model/Services/IInvoicingService.cs index 730e7306d..54bf21960 100644 --- a/Core/Resgrid.Model/Services/IInvoicingService.cs +++ b/Core/Resgrid.Model/Services/IInvoicingService.cs @@ -100,6 +100,10 @@ public interface IInvoicingService Task GetInvoicePdfAsync(string invoiceId, int departmentId); /// E-mails the invoice PDF to the customer (to the address given, else the billing profile's e-mail). A Draft is marked Sent first; a Sent invoice is re-sent without a status change. Returns the invoice. Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Sends with a caller-built attachment (the contractor invoice packet) in place of the bare PDF. + Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, InvoiceSendAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Contractor billing (plan C4): stamps a draft with its deployment/contract provenance and the contract's terms and submission address. + Task LinkInvoiceToDeploymentAsync(string invoiceId, int departmentId, string deploymentId, string serviceContractId, int? termsNetDays, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); // Department billing identity Task GetDepartmentBillingIdentityAsync(int departmentId); diff --git a/Core/Resgrid.Model/Services/IPersonnelRolesService.cs b/Core/Resgrid.Model/Services/IPersonnelRolesService.cs index 617fb6981..860847c29 100644 --- a/Core/Resgrid.Model/Services/IPersonnelRolesService.cs +++ b/Core/Resgrid.Model/Services/IPersonnelRolesService.cs @@ -45,8 +45,9 @@ public interface IPersonnelRolesService /// /// 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. + /// the previous membership in place. Every gained member must belong to the role's department. Throws + /// naming the member: roles_member_not_in_department for a stranger, + /// certifications_role_requirements_unmet for a member blocked under Enforce. /// Task ReplaceRoleMembersAsync(PersonnelRole role, IEnumerable userIds, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null); @@ -79,7 +80,8 @@ public interface IPersonnelRolesService /// Workforce & Business Operations plan Phase D4: evaluates a member against each role's certification /// requirements under the department's enforcement mode. Enforce lists blocked roles, WarnOnly lists warnings, /// Off returns an empty check. Callers show the result before mutating membership; the mutation methods - /// re-run it as a backstop and throw certifications_role_requirements_unmet under Enforce. + /// re-run it as a backstop and throw (certifications_role_requirements_unmet, + /// naming the member) under Enforce. /// Task CheckRoleMembershipAsync(int departmentId, string userId, IEnumerable roleIds); diff --git a/Core/Resgrid.Model/Services/IRateScheduleService.cs b/Core/Resgrid.Model/Services/IRateScheduleService.cs new file mode 100644 index 000000000..42f0f39e1 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRateScheduleService.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Contractor rate schedules (Workforce & Business Operations plan, C4): the schedule graph (entries, bands, + /// premiums, policy), cloning for contract renewals, the contract → profile → department-default resolution + /// the billing engine relies on, multiplier prefill for band authoring and JSON import/export. Callers authorize. + /// + public interface IRateScheduleService + { + Task> GetSchedulesForDepartmentAsync(int departmentId, bool includeInactive = false); + /// The schedule with its entries (each with bands) and premiums; null when missing or deleted. + Task GetScheduleByIdAsync(string rateScheduleId, int departmentId, bool includeInactive = false); + Task SaveScheduleAsync(RateSchedule schedule, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteScheduleAsync(string rateScheduleId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// A new schedule with copies of every entry, band and premium (contract renewals, next season). + Task CloneScheduleAsync(string rateScheduleId, int departmentId, string newName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Saves an entry and replaces its bands with . + Task SaveEntryAsync(RateScheduleEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task GetEntryByIdAsync(string rateScheduleEntryId, int departmentId); + Task DeleteEntryAsync(string rateScheduleEntryId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SavePremiumAsync(RatePremium premium, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeletePremiumAsync(string ratePremiumId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Contract schedule → contact's billing profile default → department default (the first active schedule). Null when the department has none. + Task GetEffectiveScheduleForContactAsync(string contactId, int departmentId, string serviceContractId = null); + + /// Draft hourly bands from a base rate and overtime multipliers; stored explicit per decision 16. + List PrefillHourlyBands(decimal baseRate, decimal? standbyRate, decimal overtime1Multiplier, decimal? overtime1StartHours, decimal? overtime2Multiplier, decimal? overtime2StartHours); + + Task ExportScheduleJsonAsync(string rateScheduleId, int departmentId); + Task ImportScheduleJsonAsync(int departmentId, string json, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IServiceContractService.cs b/Core/Resgrid.Model/Services/IServiceContractService.cs new file mode 100644 index 000000000..aa71eb374 --- /dev/null +++ b/Core/Resgrid.Model/Services/IServiceContractService.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Model.Services +{ + /// + /// Service contracts and department compliance documents (Workforce & Business Operations plan, C4; + /// decisions 14, 22, 24). Status transitions publish , + /// the expiry sweep publishes , compliance is evaluated + /// warn-only in v1. Callers authorize. + /// + public interface IServiceContractService + { + Task> GetContractsForDepartmentAsync(int departmentId, ServiceContractStatuses? status = null); + Task> GetContractsByContactIdAsync(string contactId, int departmentId); + /// The contract with its document requirements; null when missing or deleted. + Task GetContractByIdAsync(string serviceContractId, int departmentId); + Task SaveContractAsync(ServiceContract contract, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SetContractStatusAsync(string serviceContractId, int departmentId, ServiceContractStatuses status, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteContractAsync(string serviceContractId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Replaces the contract's document requirements with the given set. + Task> SaveRequirementsAsync(string serviceContractId, int departmentId, List requirements, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Department compliance documents without bytes. + Task> GetComplianceDocumentsAsync(int departmentId); + Task GetComplianceDocumentAsync(int departmentComplianceDocumentId, int departmentId, bool includeData); + /// Saves the document row; replaces the stored file when supplied. + Task SaveComplianceDocumentAsync(DepartmentComplianceDocument document, byte[] data, string fileName, string contentType, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteComplianceDocumentAsync(int departmentComplianceDocumentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Evaluates the deployment's contract requirements against its attachments and the department's current compliance documents (warn-only). + Task GetContractComplianceAsync(string deploymentId, int departmentId); + /// The same evaluation for a contract without a deployment (requirements at BidSubmission / InvoiceSubmission stage). + Task GetContractComplianceForContractAsync(string serviceContractId, int departmentId); + + /// Daily sweep: contracts ending within their lead window publish ContractExpiring once per day, lapsed ones move to Expired; expiring compliance documents notify department admins. Returns the number of contracts touched. + Task RunExpirySweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/ITimeTrackingService.cs b/Core/Resgrid.Model/Services/ITimeTrackingService.cs index b69f57aae..dba85d01c 100644 --- a/Core/Resgrid.Model/Services/ITimeTrackingService.cs +++ b/Core/Resgrid.Model/Services/ITimeTrackingService.cs @@ -15,6 +15,8 @@ namespace Resgrid.Model.Services public interface ITimeTrackingService { Task> GetTimeReportsAsync(string deploymentId, int departmentId); + /// Personnel hours across the deployment's non-void reports, read in one pass for the deployment page. + Task GetPersonnelHoursAsync(string deploymentId, int departmentId); Task GetTimeReportByIdAsync(string deploymentTimeReportId, int departmentId); Task> GetUnbilledApprovedReportsAsync(int departmentId, string deploymentId = null); @@ -29,6 +31,8 @@ public interface ITimeTrackingService 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); + /// Approved → Billed for the reports an invoice now covers (contractor billing, plan C4). Reports already billed or not Approved are skipped. + Task MarkTimeReportsBilledAsync(IEnumerable deploymentTimeReportIds, int departmentId, string invoiceId, 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); diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 61824c7fa..4674f0935 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -407,13 +407,36 @@ pair.Variable is "number" or "status" or "old_status" ? "int" case WorkflowTriggerEventType.DeploymentExpenseAdded: case WorkflowTriggerEventType.TimeReportSubmitted: case WorkflowTriggerEventType.TimeReportApproved: + case WorkflowTriggerEventType.TimeReportCreated: + case WorkflowTriggerEventType.TimeReportVoided: + case WorkflowTriggerEventType.DeploymentAttachmentAdded: 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" + list.Add(new TemplateVariableDescriptor("deployment." + pair.Variable, "Deployment " + pair.Variable.Replace('_', ' ') + (pair.Variable is "subject_name" or "attachment_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 "report_status" or "expense_type" or "attachment_id" or "attachment_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.BidCreated: + case WorkflowTriggerEventType.BidSent: + case WorkflowTriggerEventType.BidAccepted: + case WorkflowTriggerEventType.BidDeclined: + case WorkflowTriggerEventType.BidExpired: + foreach (var pair in Invoicing.ContractorWorkflowPayload.BidVariables) + list.Add(new TemplateVariableDescriptor("bid." + pair.Variable, "Bid " + pair.Variable.Replace('_', ' ') + (pair.Variable == "contact_name" ? "; REDACTED on a protected row" : ""), + pair.Variable is "number" or "status" or "old_status" or "converted_call_id" ? "int" + : pair.Variable == "estimated_total" ? "decimal" + : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) || pair.Variable == "valid_until" ? "datetime" : "string", false)); + list.Add(new TemplateVariableDescriptor("bid.url", "Authenticated bid link", "string", false)); + break; + case WorkflowTriggerEventType.ContractStatusChanged: + case WorkflowTriggerEventType.ContractExpiring: + foreach (var pair in Invoicing.ContractorWorkflowPayload.ContractVariables) + list.Add(new TemplateVariableDescriptor("contract." + pair.Variable, "Contract " + pair.Variable.Replace('_', ' ') + (pair.Variable == "contact_name" ? "; REDACTED on a protected row" : ""), + pair.Variable is "status" or "old_status" or "contract_type" or "days_until_end" ? "int" + : pair.Variable.EndsWith("_on", System.StringComparison.Ordinal) ? "datetime" : "string", false)); + list.Add(new TemplateVariableDescriptor("contract.url", "Authenticated contract link", "string", false)); + break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: @@ -850,6 +873,8 @@ pair.Variable is "status" or "old_status" or "finance_mode" or "call_id" or "sub case WorkflowTriggerEventType.CertificationRenewed: case WorkflowTriggerEventType.CertificationExpired: case WorkflowTriggerEventType.CertificationStatusChanged: + case WorkflowTriggerEventType.CertificationRemoved: + case WorkflowTriggerEventType.CertificationCreditAdded: list.AddRange(new[] { new TemplateVariableDescriptor("certification.id", "Certification ID", "int", false), @@ -874,7 +899,18 @@ pair.Variable is "status" or "old_status" or "finance_mode" or "call_id" or "sub { new TemplateVariableDescriptor("certification.old_status", "Status before the change", "int", false), new TemplateVariableDescriptor("certification.new_status", "Status after the change", "int", false), - new TemplateVariableDescriptor("certification.reason", "Reason given for the change", "string", false), + new TemplateVariableDescriptor("certification.reason", "Reason given for the change (REDACTED on a protected row)", "string", false), + }); + if (eventType == WorkflowTriggerEventType.CertificationRemoved) + list.Add(new TemplateVariableDescriptor("certification.removed_by_user_id", "User who removed the record", "string", false)); + if (eventType == WorkflowTriggerEventType.CertificationCreditAdded) + list.AddRange(new[] + { + new TemplateVariableDescriptor("credit.id", "Credit entry ID", "int", false), + new TemplateVariableDescriptor("credit.date", "Credit date", "datetime", false), + new TemplateVariableDescriptor("credit.hours", "Hours credited", "decimal", false), + new TemplateVariableDescriptor("credit.category", "Credit category", "string", false), + new TemplateVariableDescriptor("credit.added_by_user_id", "User who logged the credit", "string", false), }); break; @@ -893,6 +929,9 @@ pair.Variable is "status" or "old_status" or "finance_mode" or "call_id" or "sub case WorkflowTriggerEventType.UnitCertificationExpiring: case WorkflowTriggerEventType.UnitCertificationExpired: + case WorkflowTriggerEventType.UnitCertificationAdded: + case WorkflowTriggerEventType.UnitCertificationStatusChanged: + case WorkflowTriggerEventType.UnitCertificationRemoved: list.AddRange(new[] { new TemplateVariableDescriptor("unit_certification.id", "Unit certification ID", "int", false), @@ -906,6 +945,15 @@ pair.Variable is "status" or "old_status" or "finance_mode" or "call_id" or "sub new TemplateVariableDescriptor("unit_certification.days_until_expiry", "Days until expiry (negative once expired)", "int", false), new TemplateVariableDescriptor("unit_certification.status", "Record status (0 Active, 1 Expired, 2 Suspended)", "int", false), }); + if (eventType == WorkflowTriggerEventType.UnitCertificationStatusChanged) + list.AddRange(new[] + { + new TemplateVariableDescriptor("unit_certification.old_status", "Status before the change", "int", false), + new TemplateVariableDescriptor("unit_certification.new_status", "Status after the change", "int", false), + new TemplateVariableDescriptor("unit_certification.reason", "Reason given for the change (REDACTED on a protected row)", "string", false), + }); + if (eventType == WorkflowTriggerEventType.UnitCertificationRemoved) + list.Add(new TemplateVariableDescriptor("unit_certification.removed_by_user_id", "User who removed the record", "string", false)); break; case WorkflowTriggerEventType.FormSubmitted: diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index d80bebc35..92c19886a 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -230,7 +230,19 @@ public enum WorkflowTriggerEventType InventoryReturnOverdue = 166, InventoryAssetStatusChanged = 64, InventoryPurchaseOrderReceived = 65, - ControlledSubstanceRecorded = 66 + ControlledSubstanceRecorded = 66, + + // Workforce & Business Operations lifecycle completion (registry 180-187, 2026-09-19): the Phase D and Phase C + // mutations the original blocks left without a trigger. 180-184 ride the in-process certification events; + // 185-187 ride the Deployments outbox producer. + UnitCertificationAdded = 180, + UnitCertificationStatusChanged = 181, + UnitCertificationRemoved = 182, + CertificationRemoved = 183, + CertificationCreditAdded = 184, + TimeReportCreated = 185, + TimeReportVoided = 186, + DeploymentAttachmentAdded = 187 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 5eb489b8a..d09bb8d6d 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -104,42 +104,22 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Text("CallReferences", "Note") }), - // Workforce & Business Operations plan, Phase B (ADP catalog 26): rows carry their own IsProtected marker. - AdpTableBinding.Direct("CustomerBillingProfiles", "CustomerBillingProfileId", pkIsNumeric: false, "DepartmentId", new[] - { - Text("CustomerBillingProfiles", "BillingEmail") - }) with { ProtectedMarkerColumn = "IsProtected" }, - AdpTableBinding.Direct("Invoices", "InvoiceId", pkIsNumeric: false, "DepartmentId", new[] - { - Text("Invoices", "SentToEmail"), Text("Invoices", "Notes") - }) with { ProtectedMarkerColumn = "IsProtected" }, - AdpTableBinding.Direct("InvoicePayments", "InvoicePaymentId", pkIsNumeric: false, "DepartmentId", new[] - { - Text("InvoicePayments", "PayerEmail"), Text("InvoicePayments", "Reference"), Text("InvoicePayments", "ReceiptUrl"), Text("InvoicePayments", "Notes") - }) with { ProtectedMarkerColumn = "IsProtected" }, - - // Workforce & Business Operations plan, Phase D (ADP catalog 27): unit certification records and credit entries. + // Workforce & Business Operations plan, Phase D (ADP catalog 26): unit certification records and credit entries. AdpTableBinding.Direct("UnitCertifications", "UnitCertificationId", pkIsNumeric: true, "DepartmentId", new[] { - Text("UnitCertifications", "Number"), Text("UnitCertifications", "IssuedBy"), Text("UnitCertifications", "Notes"), Text("UnitCertifications", "FileName"), Binary("UnitCertifications", "Data") + Text("UnitCertifications", "Number"), Text("UnitCertifications", "IssuedBy"), Text("UnitCertifications", "Notes"), Text("UnitCertifications", "FileName"), Binary("UnitCertifications", "Data"), + Text("UnitCertifications", "StatusReason") }) with { ProtectedMarkerColumn = "IsProtected" }, AdpTableBinding.Direct("PersonnelCertificationCredits", "PersonnelCertificationCreditId", pkIsNumeric: true, "DepartmentId", new[] { 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[] + // Workforce & Business Operations plan, Phase C (ADP catalog 27): the deployment wrapper's internal notes only. + // Customer-facing rows (invoices, bids, contracts, DTRs, receipts, compliance documents) are not bound. + AdpTableBinding.Direct("Deployments", "DeploymentId", pkIsNumeric: false, "DepartmentId", new[] { - Text("DeploymentAttachments", "Name"), Text("DeploymentAttachments", "FileName"), Binary("DeploymentAttachments", "Data") + Text("Deployments", "Notes") }) with { ProtectedMarkerColumn = "IsProtected" }, AdpTableBinding.Direct("Contacts", "ContactId", pkIsNumeric: false, "DepartmentId", new[] @@ -226,7 +206,9 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => Text("PersonnelCertifications", "Area"), Text("PersonnelCertifications", "IssuedBy"), Text("PersonnelCertifications", "Filename"), - Binary("PersonnelCertifications", "Data") + Binary("PersonnelCertifications", "Data"), + // Completion pass (ADP catalog 27): the status reason behind a suspension / revocation. + Text("PersonnelCertifications", "StatusReason") }) with { ProtectedMarkerColumn = "IsProtected" }, // Catalog v7: member messaging. Both are Direct on their OWN DepartmentId (M0137) diff --git a/Core/Resgrid.Services/CertificationService.Protection.cs b/Core/Resgrid.Services/CertificationService.Protection.cs index af435e753..6783a9302 100644 --- a/Core/Resgrid.Services/CertificationService.Protection.cs +++ b/Core/Resgrid.Services/CertificationService.Protection.cs @@ -67,11 +67,11 @@ private async Task ProtectBinaryAsync(int departmentId, string fieldId, string r throw new InvalidOperationException("certifications_protected_write_refused"); } - /// User-facing read: protected values become REDACTED (no grant is carried on this path yet). Never throws. + /// User-facing read: a caller presenting a valid Protected Data Grant sees the values, everyone else sees REDACTED. Never throws. private async Task ResolveReadAsync(IReadOnlyList rows, Func rowKey, IReadOnlyDictionary Get, Action Set)> accessors, int departmentId) where T : class { if (_protectedRead == null || rows == null || rows.Count == 0) return; - try { await _protectedRead.Value.ResolveRecordsEntitiesForReadAsync(departmentId, rows.Select(r => (r, rowKey(r))).ToList(), accessors, null, null); } + try { await _protectedRead.Value.ResolveRecordsEntitiesForReadAsync(departmentId, rows.Select(r => (r, rowKey(r))).ToList(), accessors, _grant?.GrantToken, _grant?.UserId); } catch (Exception ex) { Logging.LogException(ex, "Protected certification rows could not be resolved for read."); } } @@ -79,7 +79,7 @@ private async Task ResolveReadAsync(IReadOnlyList rows, Func ro private async Task ResolveBinaryReadAsync(int departmentId, string fieldId, string rowKey, byte[] data, Action apply) { if (_protectedRead == null || data == null) return; - try { await _protectedRead.Value.ResolveRecordsBinaryForReadAsync(departmentId, fieldId, rowKey, data, apply, null, null); } + try { await _protectedRead.Value.ResolveRecordsBinaryForReadAsync(departmentId, fieldId, rowKey, data, apply, _grant?.GrantToken, _grant?.UserId); } catch (Exception ex) { Logging.LogException(ex, "A protected certification file could not be resolved for read."); } } } diff --git a/Core/Resgrid.Services/CertificationService.Sweep.cs b/Core/Resgrid.Services/CertificationService.Sweep.cs index 7214d9df6..0cb27c128 100644 --- a/Core/Resgrid.Services/CertificationService.Sweep.cs +++ b/Core/Resgrid.Services/CertificationService.Sweep.cs @@ -68,6 +68,7 @@ async Task Name(string userId) record.StatusChangedOn = DateTime.UtcNow; record.StatusChangedByUserId = SystemUserId; record.StatusReason = "expired"; + await ProtectRecordBeforeSaveAsync(record, departmentId, cancellationToken); await _personnelCertificationRepository.SaveOrUpdateAsync(record, cancellationToken); Audit(departmentId, SystemUserId, AuditLogTypes.CertificationStatusChanged, before, Snapshot(record)); _eventAggregator.SendMessage(new CertificationExpiredEvent { DepartmentId = departmentId, Certification = record, TypeCode = type.Code, TypeName = type.Type }); @@ -109,7 +110,7 @@ async Task Name(string userId) // The meta read carries no bytes; the update must not null the stored file. var full = await _unitRecords.GetByIdWithDataAsync(record.UnitCertificationId); if (full != null) record.Data = full.Data; - await _unitRecords.SaveOrUpdateAsync(record, cancellationToken); + await SaveProtectedAsync(_unitRecords, record, full, u => u.UnitCertificationId.ToString(), CertificationProtectedFields.Unit, MarkProtected, departmentId, cancellationToken); record.Data = null; Audit(departmentId, SystemUserId, AuditLogTypes.UnitCertificationStatusChanged, before, Snapshot(record)); _eventAggregator.SendMessage(new UnitCertificationExpiredEvent { DepartmentId = departmentId, Certification = record, UnitName = unitName, TypeCode = type.Code, TypeName = type.Type }); diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs index be7092aea..45ea32f1c 100644 --- a/Core/Resgrid.Services/CertificationService.cs +++ b/Core/Resgrid.Services/CertificationService.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Certifications; using Resgrid.Model.Events; @@ -38,6 +39,8 @@ public partial class CertificationService : ICertificationService private readonly Lazy _departmentSettings; private readonly Lazy _communication; private readonly Lazy _protectedRead; + /// The caller's Protected Data Grant (request-bound in the web hosts, workload elsewhere); a grant holder reads decrypted values. + private readonly IProtectedGrantContext _grant; public const string SystemUserId = "system"; private static readonly Regex CodeCleaner = new Regex("[^A-Za-z0-9._-]+", RegexOptions.Compiled); @@ -56,8 +59,10 @@ public CertificationService(IDepartmentCertificationTypeRepository departmentCer Lazy departments, Lazy departmentSettings, Lazy communication, - Lazy protectedRead = null) + Lazy protectedRead = null, + IProtectedGrantContext grant = null) { + _grant = grant; _departmentCertificationTypeRepository = departmentCertificationTypeRepository; _personnelCertificationRepository = personnelCertificationRepository; _protectedWriteService = protectedWriteService; @@ -375,6 +380,7 @@ public async Task SetCertificationStatusAsync(int certif record.VerifiedOn = record.StatusChangedOn; record.VerifiedByUserId = userId; } + await ProtectRecordBeforeSaveAsync(record, departmentId, cancellationToken); var saved = await _personnelCertificationRepository.SaveOrUpdateAsync(record, cancellationToken); Audit(departmentId, userId, AuditLogTypes.CertificationStatusChanged, before, Snapshot(saved)); _eventAggregator.SendMessage(new CertificationStatusChangedEvent { DepartmentId = departmentId, Certification = saved, TypeCode = type?.Code, TypeName = type?.Type, OldStatus = oldStatus, NewStatus = saved.Status, Reason = record.StatusReason, ChangedByUserId = userId }); @@ -394,6 +400,7 @@ public async Task VerifyCertificationAsync(int certifica record.StatusChangedOn = record.VerifiedOn; record.StatusChangedByUserId = userId; record.StatusReason = null; + await ProtectRecordBeforeSaveAsync(record, departmentId, cancellationToken); var saved = await _personnelCertificationRepository.SaveOrUpdateAsync(record, cancellationToken); Audit(departmentId, userId, AuditLogTypes.CertificationVerified, before, Snapshot(saved)); _eventAggregator.SendMessage(new CertificationStatusChangedEvent { DepartmentId = departmentId, Certification = saved, TypeCode = type?.Code, TypeName = type?.Type, OldStatus = oldStatus, NewStatus = saved.Status, Reason = "verified", ChangedByUserId = userId }); @@ -430,13 +437,27 @@ public async Task RenewCertificationAsync(int certificat return saved; } + /// + /// Catalog-6/28 write seam for an existing personnel record whose cataloged text (now including StatusReason) may have + /// changed: the row is enveloped before the save so no plaintext reaches the table. A pristine copy is not needed — + /// the record came straight from the repository, so untouched columns still hold their envelopes. + /// + private async Task ProtectRecordBeforeSaveAsync(PersonnelCertification record, int departmentId, CancellationToken cancellationToken) + { + if (_protectedWriteService?.Value == null) return; + var write = await _protectedWriteService.Value.PrepareCertificationWriteAsync(departmentId, record, null, null, null, workloadCaller: true, cancellationToken); + if (write != null && !write.Success) + throw new InvalidOperationException($"Protected write blocked ({write.Reason}); certification {record.PersonnelCertificationId} was NOT saved."); + } + public async Task SoftDeleteCertificationAsync(int certificationId, int departmentId, string userId, CancellationToken cancellationToken = default) { - var (record, _) = await LoadRecordAsync(certificationId, departmentId); + var (record, type) = await LoadRecordAsync(certificationId, departmentId); var before = Snapshot(record); record.IsDeleted = true; await _personnelCertificationRepository.SaveOrUpdateAsync(record, cancellationToken); Audit(departmentId, userId, AuditLogTypes.CertificationRemoved, before, Snapshot(record)); + _eventAggregator.SendMessage(new CertificationRemovedEvent { DepartmentId = departmentId, Certification = record, TypeCode = type?.Code, TypeName = type?.Type, RemovedByUserId = userId }); return true; } @@ -492,6 +513,11 @@ public async Task AddCertificationCreditAsync(Pers saved = await _credits.SaveOrUpdateAsync(saved, cancellationToken); } Audit(saved.DepartmentId, userId, AuditLogTypes.CertificationCreditAdded, null, Snapshot(new { saved.PersonnelCertificationCreditId, saved.PersonnelCertificationId, saved.CreditDate, saved.Hours, saved.Category })); + _eventAggregator.SendMessage(new CertificationCreditAddedEvent + { + DepartmentId = saved.DepartmentId, Certification = record, TypeCode = type?.Code, TypeName = type?.Type, PersonnelCertificationCreditId = saved.PersonnelCertificationCreditId, + CreditDate = saved.CreditDate, Hours = saved.Hours, Category = saved.Category, AddedByUserId = userId + }); return saved; } @@ -614,6 +640,10 @@ public async Task SaveUnitCertificationAsync(UnitCertificatio saved.Data = existing.Data; Audit(saved.DepartmentId, userId, existing == null ? AuditLogTypes.UnitCertificationAdded : AuditLogTypes.UnitCertificationUpdated, existing == null ? null : Snapshot(existing), Snapshot(saved)); + if (existing == null) + _eventAggregator.SendMessage(new UnitCertificationAddedEvent { DepartmentId = saved.DepartmentId, Certification = WithoutBytes(saved), UnitName = unit.Name, TypeCode = type.Code, TypeName = type.Type }); + else if (existing.Status != saved.Status) + _eventAggregator.SendMessage(new UnitCertificationStatusChangedEvent { DepartmentId = saved.DepartmentId, Certification = WithoutBytes(saved), UnitName = unit.Name, TypeCode = type.Code, TypeName = type.Type, OldStatus = existing.Status, NewStatus = saved.Status, Reason = saved.StatusReason, ChangedByUserId = userId }); return WithoutBytes(saved); } @@ -625,12 +655,17 @@ public async Task SetUnitCertificationStatusAsync(int unitCer if (status == UnitCertificationStatuses.Expired) throw new InvalidOperationException("certifications_status_invalid"); var before = Snapshot(row); + var pristine = row.CloneJson(); + var oldStatus = row.Status; row.Status = (int)status; row.StatusChangedOn = DateTime.UtcNow; row.StatusChangedByUserId = userId; row.StatusReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); - var saved = await _unitRecords.SaveOrUpdateAsync(row, cancellationToken); + // StatusReason is a catalog field: the reason is enveloped before it reaches the table, the file bytes stay as stored. + var saved = await SaveProtectedAsync(_unitRecords, row, pristine, u => u.UnitCertificationId.ToString(), CertificationProtectedFields.Unit, MarkProtected, departmentId, cancellationToken); Audit(departmentId, userId, AuditLogTypes.UnitCertificationStatusChanged, before, Snapshot(saved)); + var (unitName, type) = await UnitEventContextAsync(saved); + _eventAggregator.SendMessage(new UnitCertificationStatusChangedEvent { DepartmentId = departmentId, Certification = WithoutBytes(saved), UnitName = unitName, TypeCode = type?.Code, TypeName = type?.Type, OldStatus = oldStatus, NewStatus = saved.Status, Reason = saved.StatusReason, ChangedByUserId = userId }); return WithoutBytes(saved); } @@ -645,9 +680,20 @@ public async Task DeleteUnitCertificationAsync(int unitCertificationId, in row.EditedByUserId = userId; await _unitRecords.SaveOrUpdateAsync(row, cancellationToken); Audit(departmentId, userId, AuditLogTypes.UnitCertificationRemoved, before, Snapshot(row)); + var (unitName, type) = await UnitEventContextAsync(row); + _eventAggregator.SendMessage(new UnitCertificationRemovedEvent { DepartmentId = departmentId, Certification = WithoutBytes(row), UnitName = unitName, TypeCode = type?.Code, TypeName = type?.Type, RemovedByUserId = userId }); return true; } + /// Unit name and catalog type for a unit-certification event; lookup failures leave them blank rather than failing the mutation. + private async Task<(string UnitName, DepartmentCertificationType Type)> UnitEventContextAsync(UnitCertification row) + { + string unitName = null; DepartmentCertificationType type = null; + try { unitName = (await _units.Value.GetUnitByIdAsync(row.UnitId))?.Name; } catch (Exception ex) { Logging.LogException(ex, "Unit name could not be read for a certification event."); } + try { type = await GetCertificationTypeByIdAsync(row.DepartmentCertificationTypeId); } catch (Exception ex) { Logging.LogException(ex, "Certification type could not be read for a unit certification event."); } + return (unitName, type); + } + #endregion #region Requirements and settings @@ -824,11 +870,8 @@ public async Task GetExpiryDashboardAsync(int depa dashboard.UnitCells.Add(Cell(group.Key.UnitId.ToString(), unitName, type, best.UnitCertificationId, best.Status, best.ExpiresOn, today)); } - var all = dashboard.PersonCells.Concat(dashboard.UnitCells).ToList(); - dashboard.ExpiredCount = all.Count(c => c.Status == (int)PersonnelCertificationStatuses.Expired || (c.DaysUntilExpiry.HasValue && c.DaysUntilExpiry < 0)); - dashboard.ExpiringCount = all.Count(c => c.Status == (int)PersonnelCertificationStatuses.Active && c.DaysUntilExpiry.HasValue && c.DaysUntilExpiry >= 0 && c.DaysUntilExpiry <= horizon); - dashboard.SuspendedCount = dashboard.PersonCells.Count(c => c.Status == (int)PersonnelCertificationStatuses.Suspended || c.Status == (int)PersonnelCertificationStatuses.Revoked) + dashboard.UnitCells.Count(c => c.Status == (int)UnitCertificationStatuses.Suspended); - dashboard.PendingVerificationCount = dashboard.PersonCells.Count(c => c.Status == (int)PersonnelCertificationStatuses.PendingVerification); + dashboard.Horizon = horizon; + dashboard.RecountTotals(); return dashboard; } diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index 7ee201b78..37957559e 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -450,7 +450,7 @@ public async Task SendInviteAsync(Invite invite, string senderName, string return true; } - public async Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel) + public async Task SendInvoiceAsync(EmailNotification email, int departmentId, string invoiceUrl, string payUrl, string invoiceLabel, string attachmentContentType = "application/pdf") { if (email == null || string.IsNullOrWhiteSpace(email.To) || email.AttachmentData == null) return false; @@ -459,7 +459,7 @@ public async Task SendInvoiceAsync(EmailNotification email, int department var branding = await GetEmailBrandingAsync(departmentId); return await _emailProvider.SendInvoiceMail(email.To, email.Subject, email.Body ?? string.Empty, DateTime.UtcNow.ToString("G") + " UTC", - invoiceLabel, email.AttachmentName, email.AttachmentData, invoiceUrl, payUrl, branding); + invoiceLabel, email.AttachmentName, email.AttachmentData, invoiceUrl, payUrl, branding, attachmentContentType); } public async Task SendReportDeliveryAsync(EmailNotification email, int departmentId, string reportUrl, string reportName) diff --git a/Core/Resgrid.Services/Invoicing/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs new file mode 100644 index 000000000..5b74e7e9d --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/BidsService.cs @@ -0,0 +1,747 @@ +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 Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +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 +{ + /// + /// Contractor bids (Workforce & Business Operations plan, C4; decisions 14, 17, 22). Numbers come from the + /// per-department sequence, rates are snapshotted onto lines when authored, the discount cascades contract → + /// profile → bid, lifecycle changes publish registry 74–78 through the domain outbox, and an accepted bid converts + /// transactionally into a Call + Deployment (+ calendar item). Bid rows are not under Advanced Data Protection — + /// the customer receives the bid — and the delivery render decrypts only the customer's own Contact row (Contacts + /// family) through the invoicing workload lane. Callers authorize. + /// + public class BidsService : IBidsService + { + private readonly IBidRepository _bids; + private readonly IBidLineItemRepository _lines; + private readonly IBidNumberSequenceRepository _sequence; + private readonly IServiceContractRepository _contracts; + private readonly ICustomerBillingProfileRepository _profiles; + private readonly IDepartmentBillingIdentityRepository _identities; + private readonly IRateScheduleService _rateSchedules; + private readonly IDeploymentService _deployments; + private readonly IContactsService _contactsService; + private readonly IDepartmentsService _departmentsService; + private readonly ICallsService _callsService; + private readonly ICalendarService _calendarService; + private readonly IEmailService _emailService; + private readonly IPdfProvider _pdfProvider; + private readonly IDomainEventOutboxService _outbox; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + private readonly Lazy _protectedRead; + + public BidsService(IBidRepository bids, IBidLineItemRepository lines, IBidNumberSequenceRepository sequence, IServiceContractRepository contracts, + ICustomerBillingProfileRepository profiles, IDepartmentBillingIdentityRepository identities, IRateScheduleService rateSchedules, IDeploymentService deployments, + IContactsService contactsService, IDepartmentsService departmentsService, ICallsService callsService, ICalendarService calendarService, IEmailService emailService, + IPdfProvider pdfProvider, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, IUnitOfWork unitOfWork, + Lazy protectedRead = null) + { + _bids = bids; + _lines = lines; + _sequence = sequence; + _contracts = contracts; + _profiles = profiles; + _identities = identities; + _rateSchedules = rateSchedules; + _deployments = deployments; + _contactsService = contactsService; + _departmentsService = departmentsService; + _callsService = callsService; + _calendarService = calendarService; + _emailService = emailService; + _pdfProvider = pdfProvider; + _outbox = outbox; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + _protectedRead = protectedRead; + } + + #region Reads + + public async Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status = null, int skip = 0, int take = 100) + { + return (await _bids.GetForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null, skip, take))?.ToList() ?? new List(); + } + + public Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status = null) => _bids.CountForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null); + + public async Task> GetBidsByContactIdAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) return new List(); + return (await _bids.GetByContactIdAsync(departmentId, contactId))?.ToList() ?? new List(); + } + + public Task GetBidByIdAsync(string bidId, int departmentId) => LoadAsync(bidId, departmentId); + + private async Task LoadAsync(string bidId, int departmentId) + { + if (string.IsNullOrWhiteSpace(bidId)) return null; + var bid = await _bids.GetByIdForDepartmentAsync(bidId, departmentId); + if (bid == null || bid.IsDeleted) return null; + bid.LineItems = (await _lines.GetByBidAsync(bidId))?.OrderBy(l => l.SortOrder).ToList() ?? new List(); + return bid; + } + + private async Task RequireEditableAsync(string bidId, int departmentId) + { + var bid = await _bids.GetByIdForDepartmentAsync(bidId, departmentId); + if (bid == null || bid.IsDeleted) throw new InvalidOperationException("bids_not_found"); + if (!bid.IsEditable) throw new InvalidOperationException("bids_locked"); + return bid; + } + + #endregion + + #region Draft, header, lines + + public async Task CreateDraftBidAsync(int departmentId, string contactId, string serviceContractId, string title, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(contactId)) throw new InvalidOperationException("bids_contact_required"); + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != departmentId || contact.IsDeleted) throw new InvalidOperationException("bids_contact_not_found"); + ServiceContract contract = null; + if (!string.IsNullOrWhiteSpace(serviceContractId)) + { + contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + if (contract == null || contract.IsDeleted) throw new InvalidOperationException("bids_contract_not_found"); + if (!string.Equals(contract.ContactId, contactId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("bids_contract_contact_mismatch"); + } + var profile = await _profiles.GetByContactIdAsync(contactId, departmentId); + var schedule = await _rateSchedules.GetEffectiveScheduleForContactAsync(contactId, departmentId, contract?.ServiceContractId); + + var now = DateTime.UtcNow; + var bid = new Bid + { + DepartmentId = departmentId, + BidNumber = await _sequence.GetNextNumberAsync(departmentId, cancellationToken), + ContactId = contactId, + CustomerBillingProfileId = profile?.CustomerBillingProfileId, + ServiceContractId = contract?.ServiceContractId, + RateScheduleId = schedule?.RateScheduleId, + Title = string.IsNullOrWhiteSpace(title) ? $"Bid for {contact.Name}" : title.Trim(), + Status = (int)BidStatuses.Draft, + // Decision 14: the discount cascades contract → profile; the bid may still override it. + DiscountPercent = contract?.DiscountPercent ?? profile?.DefaultDiscountPercent, + ValidUntil = now.Date.AddDays(30), + AddedOn = now, + AddedByUserId = userId + }; + var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.BidCreated, ipAddress, userAgent, null, saved); + await PublishAsync(saved, WorkflowTriggerEventType.BidCreated, null, cancellationToken); + return await GetBidByIdAsync(saved.BidId, departmentId); + } + + public async Task SaveBidAsync(Bid bid, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (bid == null) throw new ArgumentNullException(nameof(bid)); + if (string.IsNullOrWhiteSpace(bid.Title)) throw new InvalidOperationException("bids_title_required"); + if (bid.DiscountPercent.HasValue && (bid.DiscountPercent < 0 || bid.DiscountPercent > 100)) throw new InvalidOperationException("bids_discount_invalid"); + if (bid.RequestedStartOn.HasValue && bid.RequestedEndOn.HasValue && bid.RequestedEndOn < bid.RequestedStartOn) throw new InvalidOperationException("bids_dates_invalid"); + var existing = await RequireEditableAsync(bid.BidId, bid.DepartmentId); + var before = Snapshot(existing); + + if (!string.IsNullOrWhiteSpace(bid.ServiceContractId) && !string.Equals(bid.ServiceContractId, existing.ServiceContractId, StringComparison.OrdinalIgnoreCase)) + { + var contract = await _contracts.GetByIdForDepartmentAsync(bid.ServiceContractId, bid.DepartmentId); + if (contract == null || contract.IsDeleted || !string.Equals(contract.ContactId, existing.ContactId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("bids_contract_not_found"); + } + if (!string.IsNullOrWhiteSpace(bid.RateScheduleId) && !string.Equals(bid.RateScheduleId, existing.RateScheduleId, StringComparison.OrdinalIgnoreCase)) + { + if (await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, bid.DepartmentId, includeInactive: true) == null) throw new InvalidOperationException("bids_schedule_not_found"); + } + + existing.ServiceContractId = Trim(bid.ServiceContractId); + existing.RateScheduleId = Trim(bid.RateScheduleId); + existing.Title = bid.Title.Trim(); + existing.Description = Trim(bid.Description); + existing.ValidUntil = bid.ValidUntil; + existing.RequestedStartOn = bid.RequestedStartOn; + existing.RequestedEndOn = bid.RequestedEndOn; + existing.IncidentNumber = Trim(bid.IncidentNumber); + existing.DeliveryLocation = Trim(bid.DeliveryLocation); + existing.DiscountPercent = bid.DiscountPercent; + existing.Notes = Trim(bid.Notes); + existing.TermsText = Trim(bid.TermsText); + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + + var saved = await _bids.SaveOrUpdateAsync(existing, cancellationToken); + var recalculated = await RecalculateEstimatesAsync(saved.BidId, saved.DepartmentId, cancellationToken); + Audit(existing.DepartmentId, userId, AuditLogTypes.BidUpdated, ipAddress, userAgent, before, recalculated); + return recalculated; + } + + public async Task SaveBidLineItemsAsync(string bidId, int departmentId, List lineItems, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var bid = await RequireEditableAsync(bidId, departmentId); + var incoming = (lineItems ?? new List()).Where(l => l != null).ToList(); + if (incoming.Any(l => string.IsNullOrWhiteSpace(l.Description) || l.Quantity <= 0 || l.UnitRate < 0 || !Enum.IsDefined(typeof(BidLineTypes), l.LineType))) + throw new InvalidOperationException("bids_line_invalid"); + + var schedule = string.IsNullOrWhiteSpace(bid.RateScheduleId) ? null : await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, departmentId, includeInactive: true); + var existing = (await _lines.GetByBidAsync(bidId))?.ToList() ?? new List(); + var before = existing.CloneJsonToString(); + var keep = new HashSet(StringComparer.OrdinalIgnoreCase); + var order = 0; + foreach (var line in incoming.OrderBy(l => l.SortOrder)) + { + var target = string.IsNullOrWhiteSpace(line.BidLineItemId) ? null : existing.FirstOrDefault(e => string.Equals(e.BidLineItemId, line.BidLineItemId, StringComparison.OrdinalIgnoreCase)); + target ??= new BidLineItem { BidId = bidId, DepartmentId = departmentId }; + target.RateScheduleEntryId = Trim(line.RateScheduleEntryId); + target.LineType = line.LineType; + target.Description = line.Description.Trim(); + target.CrewSize = line.CrewSize; + target.Quantity = line.Quantity; + target.EstimatedHoursPerDay = line.EstimatedHoursPerDay; + target.EstimatedDays = line.EstimatedDays; + target.PremiumIdsJson = line.PremiumIds.Count == 0 ? null : JsonConvert.SerializeObject(line.PremiumIds.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList()); + target.Taxable = line.Taxable; + target.SortOrder = order++; + // The rate snapshots at authoring (a moving schedule never changes a sent bid); a blank rate is filled from the entry. + target.UnitRate = line.UnitRate > 0 || schedule == null ? line.UnitRate : SnapshotRate(schedule, target); + target.EstimatedAmount = Estimate(target); + var saved = await _lines.SaveOrUpdateAsync(target, cancellationToken); + keep.Add(saved.BidLineItemId); + } + foreach (var stale in existing.Where(e => !keep.Contains(e.BidLineItemId))) + await _lines.DeleteAsync(stale, cancellationToken); + + var recalculated = await RecalculateEstimatesAsync(bidId, departmentId, cancellationToken); + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.BidUpdated, ipAddress, userAgent); + audit.Before = before; + audit.After = recalculated.LineItems.CloneJsonToString(); + _eventAggregator.SendMessage(audit); + return recalculated; + } + + /// Deployment-band rate (hourly) or daily-deployment rate (daily) plus the selected premiums' deployment adders. + public static decimal SnapshotRate(RateSchedule schedule, BidLineItem line) + { + var entry = schedule?.Entries?.FirstOrDefault(e => string.Equals(e.RateScheduleEntryId, line.RateScheduleEntryId, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return 0m; + var band = (BillingBases)entry.BillingBasis switch + { + BillingBases.Daily or BillingBases.PerPersonPerDay => entry.Band(RateBandTypes.DailyDeployment) ?? entry.Band(RateBandTypes.Deployment), + BillingBases.PerKilometer => entry.Band(RateBandTypes.MileagePerKm), + _ => entry.Band(RateBandTypes.Deployment) ?? entry.Band(RateBandTypes.DailyDeployment) ?? entry.Bands.FirstOrDefault() + }; + var rate = band?.Rate ?? 0m; + foreach (var id in line.PremiumIds) + { + var premium = schedule.Premiums?.FirstOrDefault(p => string.Equals(p.RatePremiumId, id, StringComparison.OrdinalIgnoreCase) && p.IsActive && !p.IsDeleted); + if (premium != null) rate += premium.DeploymentAdder; + } + return rate; + } + + /// Quantity × rate × hours/day (when given) × days (when given); basis-agnostic so daily lines leave hours blank. + public static decimal Estimate(BidLineItem line) => + Math.Round(line.Quantity * line.UnitRate * (line.EstimatedHoursPerDay ?? 1m) * (line.EstimatedDays ?? 1m), 2, MidpointRounding.AwayFromZero); + + public async Task RecalculateEstimatesAsync(string bidId, int departmentId, CancellationToken cancellationToken = default) + { + var bid = await LoadAsync(bidId, departmentId); + if (bid == null) throw new InvalidOperationException("bids_not_found"); + var profile = string.IsNullOrWhiteSpace(bid.CustomerBillingProfileId) ? null : await _profiles.GetByIdForDepartmentAsync(bid.CustomerBillingProfileId, departmentId); + + // Same rules as the invoice (decision 14/23): subtotal → discount → tax on the taxable base. + var shadow = new Invoice { DiscountPercent = bid.DiscountPercent }; + var lines = bid.LineItems.Select(l => new InvoiceLineItem { Amount = l.EstimatedAmount, Taxable = l.Taxable }).ToList(); + InvoicingService.ComputeTotals(shadow, lines, profile); + bid.EstimatedSubTotal = shadow.SubTotal; + bid.EstimatedDiscountAmount = shadow.DiscountAmount; + bid.EstimatedTaxAmount = shadow.TaxAmount; + bid.EstimatedTotal = shadow.Total; + var lineItems = bid.LineItems; + var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken); + saved.LineItems = lineItems; + return saved; + } + + #endregion + + #region Lifecycle + + public static bool IsValidTransition(BidStatuses from, BidStatuses to) => (from, to) switch + { + (BidStatuses.Draft, BidStatuses.Submitted) => true, + (BidStatuses.Draft, BidStatuses.Withdrawn) => true, + (BidStatuses.Submitted, BidStatuses.Accepted) => true, + (BidStatuses.Submitted, BidStatuses.Declined) => true, + (BidStatuses.Submitted, BidStatuses.Withdrawn) => true, + (BidStatuses.Submitted, BidStatuses.Expired) => true, + (BidStatuses.Expired, BidStatuses.Submitted) => true, + (BidStatuses.Declined, BidStatuses.Submitted) => true, + _ => false + }; + + private async Task TransitionAsync(string bidId, int departmentId, BidStatuses to, AuditLogTypes auditType, WorkflowTriggerEventType? trigger, string userId, string ipAddress, string userAgent, Action apply, CancellationToken cancellationToken) + { + var bid = await _bids.GetByIdForDepartmentAsync(bidId, departmentId); + if (bid == null || bid.IsDeleted) throw new InvalidOperationException("bids_not_found"); + var from = (BidStatuses)bid.Status; + if (!IsValidTransition(from, to)) throw new InvalidOperationException("bids_status_transition_invalid"); + if (to == BidStatuses.Submitted && !(await _lines.GetByBidAsync(bidId))?.Any() == true) throw new InvalidOperationException("bids_no_lines"); + + var before = Snapshot(bid); + bid.Status = (int)to; + apply?.Invoke(bid); + bid.EditedOn = DateTime.UtcNow; + bid.EditedByUserId = userId; + var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken); + Audit(departmentId, userId, auditType, ipAddress, userAgent, before, saved); + if (trigger.HasValue) await PublishAsync(saved, trigger.Value, (int)from, cancellationToken); + return await GetBidByIdAsync(bidId, departmentId); + } + + public Task SubmitBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + TransitionAsync(bidId, departmentId, BidStatuses.Submitted, AuditLogTypes.BidSent, WorkflowTriggerEventType.BidSent, userId, ipAddress, userAgent, b => { b.SentOn ??= DateTime.UtcNow; }, cancellationToken); + + public async Task SendBidAsync(string bidId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var bid = await LoadAsync(bidId, departmentId); + if (bid == null) throw new InvalidOperationException("bids_not_found"); + if (bid.Status is not ((int)BidStatuses.Draft or (int)BidStatuses.Submitted)) throw new InvalidOperationException("bids_status_transition_invalid"); + if (bid.LineItems.Count == 0) throw new InvalidOperationException("bids_no_lines"); + + var profile = string.IsNullOrWhiteSpace(bid.CustomerBillingProfileId) ? null : await _profiles.GetByIdForDepartmentAsync(bid.CustomerBillingProfileId, departmentId); + var contact = await _contactsService.GetContactByIdAsync(bid.ContactId); + await ResolveContactForWorkloadAsync(contact, departmentId); + var recipient = !string.IsNullOrWhiteSpace(toEmail) ? toEmail.Trim() : profile?.BillingEmail ?? contact?.Email; + if (string.IsNullOrWhiteSpace(recipient) || ProtectedDataEnvelope.HasEnvelopePrefix(recipient) || recipient == ProtectedDataEnvelope.RedactionValue) throw new InvalidOperationException("bids_no_recipient_email"); + + var pdf = await GetBidPdfCoreAsync(bidId, departmentId, workload: true); + if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("bids_pdf_unavailable"); + + var label = $"Bid #{bid.BidNumber}"; + var notification = new EmailNotification + { + To = recipient, + Subject = $"{label} from {await DepartmentDisplayNameAsync(departmentId)}: {bid.Title}", + Body = $"{label} — {bid.Title} — estimated {InvoicingService.FormatMoney(bid.EstimatedTotal, await CurrencyAsync(bid))} is attached." + (bid.ValidUntil.HasValue ? $" This bid is valid until {bid.ValidUntil.Value:yyyy-MM-dd}." : string.Empty), + AttachmentName = $"bid-{bid.BidNumber}.pdf", + AttachmentData = pdf + }; + var sent = await _emailService.SendInvoiceAsync(notification, departmentId, null, null, label); + if (!sent) + { + Logging.LogError($"Bid {bidId} e-mail to the customer was not sent (department {departmentId})."); + throw new InvalidOperationException("bids_email_not_sent"); + } + + if (bid.Status == (int)BidStatuses.Draft) + return await TransitionAsync(bidId, departmentId, BidStatuses.Submitted, AuditLogTypes.BidSent, WorkflowTriggerEventType.BidSent, userId, ipAddress, userAgent, b => { b.SentOn = DateTime.UtcNow; b.SentToEmail = recipient; }, cancellationToken); + + // Already submitted: record the (re)send without a status change. + var before = Snapshot(bid); + bid.SentOn = DateTime.UtcNow; + bid.SentToEmail = recipient; + bid.EditedOn = bid.SentOn; + bid.EditedByUserId = userId; + var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.BidSent, ipAddress, userAgent, before, saved); + await PublishAsync(saved, WorkflowTriggerEventType.BidSent, (int)BidStatuses.Submitted, cancellationToken); + return await GetBidByIdAsync(bidId, departmentId); + } + + public Task AcceptBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + TransitionAsync(bidId, departmentId, BidStatuses.Accepted, AuditLogTypes.BidAccepted, WorkflowTriggerEventType.BidAccepted, userId, ipAddress, userAgent, b => { b.AcceptedOn = DateTime.UtcNow; }, cancellationToken); + + public Task DeclineBidAsync(string bidId, int departmentId, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + TransitionAsync(bidId, departmentId, BidStatuses.Declined, AuditLogTypes.BidDeclined, WorkflowTriggerEventType.BidDeclined, userId, ipAddress, userAgent, b => { b.DeclinedOn = DateTime.UtcNow; b.DeclineReason = Trim(reason); }, cancellationToken); + + public Task WithdrawBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + TransitionAsync(bidId, departmentId, BidStatuses.Withdrawn, AuditLogTypes.BidWithdrawn, null, userId, ipAddress, userAgent, null, cancellationToken); + + public Task ExpireBidAsync(string bidId, int departmentId, CancellationToken cancellationToken = default) => + TransitionAsync(bidId, departmentId, BidStatuses.Expired, AuditLogTypes.BidExpired, WorkflowTriggerEventType.BidExpired, null, null, null, null, cancellationToken); + + public async Task DeleteBidAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var bid = await _bids.GetByIdForDepartmentAsync(bidId, departmentId); + if (bid == null || bid.IsDeleted) return false; + if (bid.Status != (int)BidStatuses.Draft) throw new InvalidOperationException("bids_locked"); + var before = Snapshot(bid); + bid.IsDeleted = true; + bid.EditedOn = DateTime.UtcNow; + bid.EditedByUserId = userId; + await _bids.SaveOrUpdateAsync(bid, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.BidDeleted, ipAddress, userAgent, before, bid); + return true; + } + + public async Task RunExpirySweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + var expired = 0; + var enabled = new Dictionary(); + foreach (var bid in (await _bids.GetExpiryCandidatesAsync(asOfUtc))?.ToList() ?? new List()) + { + if (!enabled.TryGetValue(bid.DepartmentId, out var ok)) { ok = departmentEnabled == null || await departmentEnabled(bid.DepartmentId); enabled[bid.DepartmentId] = ok; } + if (!ok) continue; + try { await ExpireBidAsync(bid.BidId, bid.DepartmentId, cancellationToken); expired++; } + catch (Exception ex) { Logging.LogException(ex, $"Bid {bid.BidId} could not be expired."); } + } + return expired; + } + + #endregion + + #region Rendering + + public Task RenderBidHtmlAsync(string bidId, int departmentId) => RenderBidHtmlCoreAsync(bidId, departmentId, workload: false); + public Task GetBidPdfAsync(string bidId, int departmentId) => GetBidPdfCoreAsync(bidId, departmentId, workload: false); + + private async Task GetBidPdfCoreAsync(string bidId, int departmentId, bool workload) + { + var html = await RenderBidHtmlCoreAsync(bidId, departmentId, workload); + return html == null ? null : _pdfProvider.ConvertHtmlToPdf(html); + } + + private async Task RenderBidHtmlCoreAsync(string bidId, int departmentId, bool workload) + { + var bid = await LoadAsync(bidId, departmentId); + if (bid == null) return null; + + var identity = await _identities.GetByDepartmentIdAsync(departmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var contact = await _contactsService.GetContactByIdAsync(bid.ContactId); + // Bid rows are never protected; the customer's Contact row may be, so the delivery render decrypts it for the PDF. + if (workload) await ResolveContactForWorkloadAsync(contact, departmentId); + var contract = string.IsNullOrWhiteSpace(bid.ServiceContractId) ? null : await _contracts.GetByIdForDepartmentAsync(bid.ServiceContractId, departmentId); + var model = new BidRenderModel + { + Bid = bid, + Currency = await CurrencyAsync(bid), + DepartmentName = string.IsNullOrWhiteSpace(identity?.LegalBusinessName) ? department?.Name : identity.LegalBusinessName, + CustomerName = ProtectedDataEnvelope.SafeDisplay(contact?.Name), + ContractName = contract?.Name, + ContractNumber = contract?.ContractNumber, + FooterText = identity?.InvoiceFooterText + }; + return RenderBidHtml(model); + } + + public sealed class BidRenderModel + { + public Bid Bid { get; set; } + public string Currency { get; set; } + public string DepartmentName { get; set; } + public string CustomerName { get; set; } + public string ContractName { get; set; } + public string ContractNumber { get; set; } + public string FooterText { get; set; } + } + + /// Pure HTML rendering; HTML-encodes every user value. + public static string RenderBidHtml(BidRenderModel model) + { + var bid = model.Bid; + var currency = model.Currency ?? "USD"; + var sb = new StringBuilder(); + sb.Append("").Append(E($"Bid #{bid.BidNumber}")).Append(""); + sb.Append(""); + sb.Append("

").Append(E(model.DepartmentName)).Append("

"); + sb.Append("

Bid #").Append(bid.BidNumber).Append(" ").Append(E(((BidStatuses)bid.Status).ToString())).Append("

"); + sb.Append("

").Append(E(bid.Title)).Append("

"); + if (!string.IsNullOrWhiteSpace(bid.Description)) sb.Append("

").Append(E(bid.Description)).Append("

"); + sb.Append(""); + Row(sb, "Prepared for", model.CustomerName); + Row(sb, "Contract", string.IsNullOrWhiteSpace(model.ContractName) ? null : $"{model.ContractName}{(string.IsNullOrWhiteSpace(model.ContractNumber) ? string.Empty : $" ({model.ContractNumber})")}"); + Row(sb, "Incident", bid.IncidentNumber); + Row(sb, "Delivery location", bid.DeliveryLocation); + Row(sb, "Requested", bid.RequestedStartOn.HasValue ? $"{bid.RequestedStartOn:yyyy-MM-dd}{(bid.RequestedEndOn.HasValue ? $" to {bid.RequestedEndOn:yyyy-MM-dd}" : string.Empty)}" : null); + Row(sb, "Valid until", bid.ValidUntil?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + Row(sb, "Sent", bid.SentOn?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + sb.Append("
"); + + sb.Append(""); + foreach (var line in bid.LineItems ?? new List()) + sb.Append(""); + sb.Append("
DescriptionQtyHours/dayDaysRateEstimate
").Append(E(line.Description)).Append(line.CrewSize.HasValue ? $" ({line.CrewSize}-person)" : string.Empty) + .Append("").Append(line.Quantity.ToString("0.##", CultureInfo.InvariantCulture)) + .Append("").Append(line.EstimatedHoursPerDay?.ToString("0.##", CultureInfo.InvariantCulture) ?? "") + .Append("").Append(line.EstimatedDays?.ToString("0.##", CultureInfo.InvariantCulture) ?? "") + .Append("").Append(InvoicingService.FormatMoney(line.UnitRate, currency, 4)) + .Append("").Append(InvoicingService.FormatMoney(line.EstimatedAmount, currency)).Append("
"); + + sb.Append(""); + sb.Append(""); + if (bid.EstimatedDiscountAmount > 0) sb.Append(""); + if (bid.EstimatedTaxAmount > 0) sb.Append(""); + sb.Append(""); + sb.Append("
Subtotal").Append(InvoicingService.FormatMoney(bid.EstimatedSubTotal, currency)).Append("
Discount").Append(bid.DiscountPercent.HasValue ? $" ({bid.DiscountPercent.Value.ToString("0.##", CultureInfo.InvariantCulture)}%)" : string.Empty).Append("-").Append(InvoicingService.FormatMoney(bid.EstimatedDiscountAmount, currency)).Append("
Tax (estimated)").Append(InvoicingService.FormatMoney(bid.EstimatedTaxAmount, currency)).Append("
Estimated total").Append(InvoicingService.FormatMoney(bid.EstimatedTotal, currency)).Append("
"); + sb.Append("

Estimates are based on the requested hours and days; actual charges follow the daily time reports and the rate schedule in force.

"); + if (!string.IsNullOrWhiteSpace(bid.TermsText)) sb.Append("

Terms

").Append(E(bid.TermsText)).Append("
"); + if (!string.IsNullOrWhiteSpace(model.FooterText)) sb.Append("
").Append(E(model.FooterText)).Append("
"); + sb.Append(""); + return sb.ToString(); + } + + private static void Row(StringBuilder sb, string label, string value) + { + if (string.IsNullOrWhiteSpace(value)) return; + sb.Append("").Append(E(label)).Append("").Append(E(value)).Append(""); + } + + private static string E(string value) => WebUtility.HtmlEncode(value ?? string.Empty); + + #endregion + + #region Conversion + + public async Task GetBidConversionContextAsync(string bidId, int departmentId) + { + var bid = await GetBidByIdAsync(bidId, departmentId); + if (bid == null) return null; + var contract = string.IsNullOrWhiteSpace(bid.ServiceContractId) ? null : await _contracts.GetByIdForDepartmentAsync(bid.ServiceContractId, departmentId); + var schedule = string.IsNullOrWhiteSpace(bid.RateScheduleId) ? await _rateSchedules.GetEffectiveScheduleForContactAsync(bid.ContactId, departmentId, bid.ServiceContractId) : await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, departmentId); + var contact = await _contactsService.GetContactByIdAsync(bid.ContactId); + return new BidConversionContext + { + Bid = bid, + Contract = contract, + Schedule = schedule, + ContactName = ProtectedDataEnvelope.SafeDisplay(contact?.Name), + EffectiveDiscountPercent = bid.DiscountPercent ?? contract?.DiscountPercent, + Currency = await CurrencyAsync(bid) + }; + } + + public async Task ConvertBidToDeploymentAsync(BidConversionRequest request, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + var bid = await LoadAsync(request.BidId, departmentId); + if (bid == null) throw new InvalidOperationException("bids_not_found"); + if (bid.Status != (int)BidStatuses.Accepted) throw new InvalidOperationException("bids_not_accepted"); + if (bid.IsConverted) throw new InvalidOperationException("bids_already_converted"); + if (string.IsNullOrWhiteSpace(request.CallName)) throw new InvalidOperationException("bids_call_name_required"); + if (request.StartOn.HasValue && request.EndOn.HasValue && request.EndOn < request.StartOn) throw new InvalidOperationException("bids_dates_invalid"); + + var contract = string.IsNullOrWhiteSpace(bid.ServiceContractId) ? null : await _contracts.GetByIdForDepartmentAsync(bid.ServiceContractId, departmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + + return await TransactionAsync(async () => + { + var call = new Call + { + DepartmentId = departmentId, + ReportingUserId = userId, + Name = request.CallName.Trim(), + NatureOfCall = string.IsNullOrWhiteSpace(request.CallNature) ? bid.Title : request.CallNature.Trim(), + IncidentNumber = Trim(request.IncidentNumber) ?? bid.IncidentNumber, + Priority = request.CallPriority, + Type = request.CallTypeId?.ToString(), + Address = Trim(request.Address), + GeoLocationData = Trim(request.GeoLocation), + LoggedOn = DateTime.UtcNow, + State = (int)CallStates.Active, + CallSource = (int)CallSources.User, + ExternalIdentifier = $"bid:{bid.BidNumber}", + Notes = Trim(request.Notes), + Contacts = new List { new CallContact { DepartmentId = departmentId, ContactId = bid.ContactId, CallContactType = 0 } } + }; + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + + var deployment = await _deployments.SaveDeploymentAsync(new Deployment + { + DepartmentId = departmentId, + CallId = savedCall.CallId, + FinanceMode = (int)DeploymentFinanceModes.Billable, + BidId = bid.BidId, + ServiceContractId = bid.ServiceContractId, + RateScheduleId = bid.RateScheduleId, + ContactId = bid.ContactId, + Name = request.CallName.Trim(), + Status = (int)DeploymentStatuses.Planned, + IncidentNumber = Trim(request.IncidentNumber) ?? bid.IncidentNumber, + ServiceRequestNumber = Trim(request.ServiceRequestNumber), + PointOfHire = Trim(request.PointOfHire) ?? contract?.PointOfHire, + StartOn = request.StartOn ?? bid.RequestedStartOn, + EndOn = request.EndOn ?? bid.RequestedEndOn, + MaxDays = request.MaxDays ?? contract?.MaxDeploymentDays, + OutOfProvince = request.OutOfProvince, + TravelViaAir = request.TravelViaAir, + LocalTimeZoneId = Trim(request.LocalTimeZoneId) ?? department?.TimeZone, + Currency = await CurrencyAsync(bid), + DiscountPercent = bid.DiscountPercent ?? contract?.DiscountPercent, + Notes = Trim(request.Notes) + }, userId, ipAddress, userAgent, cancellationToken); + + var result = new BidConversionResult { CallId = savedCall.CallId, Deployment = deployment }; + foreach (var unit in request.Units ?? new List()) + { + var unitRow = await _deployments.AddUnitAsync(deployment.DeploymentId, departmentId, unit.UnitId, unit.CallSign, null, userId, ipAddress, userAgent, cancellationToken, unit.RateScheduleEntryId); + result.Warnings.AddRange(unitRow.Warnings); + if (unitRow.Unit == null) continue; + foreach (var seat in unit.Seats ?? new List()) + { + var seated = await _deployments.AddPersonnelAsync(deployment.DeploymentId, departmentId, new DeploymentPersonnelInput + { + UserId = seat.UserId, DeploymentUnitId = unitRow.Unit.DeploymentUnitId, UnitRoleId = seat.UnitRoleId, CertificationCode = seat.CertificationCode, CallSign = seat.CallSign, + RateScheduleEntryId = seat.RateScheduleEntryId, PremiumIds = seat.PremiumIds, Force = true + }, userId, ipAddress, userAgent, cancellationToken); + result.Warnings.AddRange(seated.Warnings); + } + foreach (var equipment in unit.Equipment ?? new List()) + { + var issued = await _deployments.AddEquipmentAsync(deployment.DeploymentId, departmentId, new DeploymentEquipmentInput + { + DeploymentUnitId = unitRow.Unit.DeploymentUnitId, InventoryAssetId = equipment.InventoryAssetId, InventoryItemId = equipment.InventoryItemId, FreeTextName = equipment.FreeTextName, RateScheduleEntryId = equipment.RateScheduleEntryId + }, userId, ipAddress, userAgent, cancellationToken); + result.Warnings.AddRange(issued.Warnings); + } + } + foreach (var seat in request.UnassignedPersonnel ?? new List()) + { + var seated = await _deployments.AddPersonnelAsync(deployment.DeploymentId, departmentId, new DeploymentPersonnelInput + { + UserId = seat.UserId, CertificationCode = seat.CertificationCode, CallSign = seat.CallSign, RateScheduleEntryId = seat.RateScheduleEntryId, PremiumIds = seat.PremiumIds, Force = true + }, userId, ipAddress, userAgent, cancellationToken); + result.Warnings.AddRange(seated.Warnings); + } + + if (request.CreateCalendarItem && deployment.StartOn.HasValue) + { + try + { + var timeZone = department?.TimeZone ?? "UTC"; + var start = DateTimeHelpers.GetLocalDateTime(deployment.StartOn.Value, timeZone); + var end = DateTimeHelpers.GetLocalDateTime(deployment.EndOn ?? deployment.StartOn.Value.AddHours(8), timeZone); + var item = await _calendarService.AddNewCalendarItemAsync(new CalendarItem + { + DepartmentId = departmentId, Title = deployment.Name, Start = start, End = end <= start ? start.AddHours(1) : end, + Description = $"Deployment for bid #{bid.BidNumber}" + (string.IsNullOrWhiteSpace(deployment.IncidentNumber) ? string.Empty : $" — incident {deployment.IncidentNumber}"), + Location = Trim(request.Address), CreatorUserId = userId, IsAllDay = false, ItemType = 0, Public = false + }, timeZone, cancellationToken); + if (item != null && item.CalendarItemId > 0) + { + result.CalendarItemId = item.CalendarItemId; + deployment.CalendarItemId = item.CalendarItemId; + deployment = await _deployments.SaveDeploymentAsync(deployment, userId, ipAddress, userAgent, cancellationToken); + } + } + catch (Exception ex) { Logging.LogException(ex, $"Bid {bid.BidId}: calendar item was not created."); } + } + + var before = Snapshot(bid); + bid.ConvertedCallId = savedCall.CallId; + bid.ConvertedDeploymentId = deployment.DeploymentId; + bid.EditedOn = DateTime.UtcNow; + bid.EditedByUserId = userId; + var lineItems = bid.LineItems; + var savedBid = await _bids.SaveOrUpdateAsync(bid, cancellationToken); + savedBid.LineItems = lineItems; + Audit(departmentId, userId, AuditLogTypes.BidConverted, ipAddress, userAgent, before, savedBid); + result.Bid = await GetBidByIdAsync(bid.BidId, departmentId); + result.Deployment = await _deployments.GetDeploymentByIdAsync(deployment.DeploymentId, departmentId) ?? deployment; + return result; + }, cancellationToken); + } + + #endregion + + #region Helpers + + private async Task CurrencyAsync(Bid bid) + { + var schedule = string.IsNullOrWhiteSpace(bid.RateScheduleId) ? null : await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, bid.DepartmentId, includeInactive: true); + return schedule?.Currency ?? "USD"; + } + + /// Decrypts a protected customer contact for a system workload (bid delivery). Never throws; leaves the row as-is on failure. + private async Task ResolveContactForWorkloadAsync(Contact contact, int departmentId) + { + if (contact == null || _protectedRead?.Value == null) return; + try { await _protectedRead.Value.ResolveRecordsEntitiesForWorkloadAsync(departmentId, "invoicing", new[] { (contact, contact.ContactId) }, ProtectedReadService.ContactFieldAccessors); } + catch (Exception ex) { Logging.LogException(ex, $"Contact {contact.ContactId} could not be resolved for the bid workload."); } + } + + private async Task DepartmentDisplayNameAsync(int departmentId) + { + var identity = await _identities.GetByDepartmentIdAsync(departmentId); + if (!string.IsNullOrWhiteSpace(identity?.LegalBusinessName)) return identity.LegalBusinessName; + return (await _departmentsService.GetDepartmentByIdAsync(departmentId))?.Name ?? "Resgrid"; + } + + private async Task PublishAsync(Bid bid, WorkflowTriggerEventType trigger, int? oldStatus, CancellationToken cancellationToken) + { + try + { + string contactName = null; + try { contactName = (await _contactsService.GetContactByIdAsync(bid.ContactId))?.Name; } catch (Exception ex) { Logging.LogException(ex, "Bid event: contact name lookup failed."); } + await _outbox.EnqueueAsync(bid.DepartmentId, ContractorWorkflowPayload.Producer, new DomainEventEnvelope + { + EventName = trigger.ToString(), + AggregateType = "Bid", + AggregateId = bid.BidId, + AggregateVersion = 0, + Trigger = trigger, + OccurredOn = DateTime.UtcNow, + CorrelationId = bid.BidId, + Payload = new + { + bid.BidId, bid.BidNumber, bid.Title, bid.Status, OldStatus = oldStatus, bid.ContactId, + ContactName = ProtectedDataEnvelope.SafeDisplay(contactName), + bid.ServiceContractId, bid.IncidentNumber, bid.ValidUntil, bid.RequestedStartOn, bid.RequestedEndOn, bid.EstimatedTotal, + Currency = await CurrencyAsync(bid), bid.SentOn, bid.AcceptedOn, bid.DeclinedOn, bid.ConvertedDeploymentId, bid.ConvertedCallId + } + }, cancellationToken); + } + catch (Exception ex) { Logging.LogException(ex, $"Bid {bid.BidId} {trigger} could not be published."); } + } + + 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; + } + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + internal static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + if (clone is Bid bid) bid.LineItems = null; + return clone.CloneJsonToString(); + } + + 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 : Snapshot(after); + _eventAggregator.SendMessage(audit); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs new file mode 100644 index 000000000..ad0d291ad --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +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 +{ + /// + /// Contractor billing engine (Workforce & Business Operations plan, C4). Loads the deployment graph, the + /// effective rate schedule (deployment → contract → profile → department default) and the approved unbilled + /// DTRs, runs , and turns the result into a Phase B draft invoice whose + /// lines carry DTR provenance. The packet builder bundles the supporting documents a contract requires at invoice + /// submission. Callers authorize. + /// + public class ContractorBillingEngine : IContractorBillingEngine + { + private readonly IDeploymentService _deployments; + private readonly ITimeTrackingService _timeTracking; + private readonly IDeploymentTimeEntryRepository _entries; + private readonly IRateScheduleService _rateSchedules; + private readonly IServiceContractService _contracts; + private readonly IInvoicingService _invoicing; + private readonly IUnitsService _unitsService; + private readonly IUserProfileService _userProfileService; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + private readonly IDeploymentTimeReportRepository _reports; + private readonly IDeploymentRepository _deploymentRows; + private readonly IDepartmentsService _departmentsService; + private readonly Lazy _communication; + private readonly Lazy _departmentSettings; + private static readonly HashSet RemindedToday = new HashSet(); + + public ContractorBillingEngine(IDeploymentService deployments, ITimeTrackingService timeTracking, IDeploymentTimeEntryRepository entries, IRateScheduleService rateSchedules, + IServiceContractService contracts, IInvoicingService invoicing, IUnitsService unitsService, IUserProfileService userProfileService, IEventAggregator eventAggregator, IUnitOfWork unitOfWork, + IDeploymentTimeReportRepository reports = null, IDeploymentRepository deploymentRows = null, IDepartmentsService departmentsService = null, + Lazy communication = null, Lazy departmentSettings = null) + { + _reports = reports; + _deploymentRows = deploymentRows; + _departmentsService = departmentsService; + _communication = communication; + _departmentSettings = departmentSettings; + _deployments = deployments; + _timeTracking = timeTracking; + _entries = entries; + _rateSchedules = rateSchedules; + _contracts = contracts; + _invoicing = invoicing; + _unitsService = unitsService; + _userProfileService = userProfileService; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + } + + public async Task CalculateDeploymentChargesAsync(string deploymentId, int departmentId, DateTime? throughDate = null) + { + var input = await BuildInputAsync(deploymentId, departmentId, throughDate); + return input == null ? null : ContractorChargeCalculator.Calculate(input); + } + + /// Everything the pure calculator needs; null when the deployment does not exist. + internal async Task BuildInputAsync(string deploymentId, int departmentId, DateTime? throughDate) + { + var deployment = await _deployments.GetDeploymentByIdAsync(deploymentId, departmentId); + if (deployment == null) return null; + + var contract = string.IsNullOrWhiteSpace(deployment.ServiceContractId) ? null : await _contracts.GetContractByIdAsync(deployment.ServiceContractId, departmentId); + var schedule = string.IsNullOrWhiteSpace(deployment.RateScheduleId) ? null : await _rateSchedules.GetScheduleByIdAsync(deployment.RateScheduleId, departmentId, includeInactive: true); + schedule ??= await _rateSchedules.GetEffectiveScheduleForContactAsync(deployment.ContactId, departmentId, deployment.ServiceContractId); + + var reports = (await _timeTracking.GetUnbilledApprovedReportsAsync(departmentId, deploymentId)) + .Where(r => !throughDate.HasValue || r.ReportDate.Date <= throughDate.Value.Date).OrderBy(r => r.ReportDate).ToList(); + var entries = (await _entries.GetByDeploymentAsync(deploymentId))?.ToList() ?? new List(); + foreach (var report in reports) + report.Entries = entries.Where(e => string.Equals(e.DeploymentTimeReportId, report.DeploymentTimeReportId, StringComparison.OrdinalIgnoreCase)).OrderBy(e => e.StartTime).ToList(); + + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var unit in deployment.Units) names[unit.DeploymentUnitId] = unit.UnitName ?? unit.CallSign ?? $"Unit {unit.UnitId}"; + foreach (var person in deployment.Personnel) names[person.DeploymentPersonnelId] = person.DisplayName ?? person.CallSign ?? person.UserId; + foreach (var equipment in deployment.Equipment) names[equipment.DeploymentEquipmentId] = equipment.FreeTextName ?? equipment.InventoryAssetId ?? equipment.InventoryItemId ?? "Equipment"; + + return new ContractorChargeInput + { + Deployment = deployment, + Schedule = schedule, + Reports = reports, + Expenses = await _timeTracking.GetExpensesAsync(deploymentId, departmentId), + Personnel = deployment.Personnel, + Units = deployment.Units, + Equipment = deployment.Equipment, + SubjectNames = names, + CancellationDate = deployment.Status == (int)DeploymentStatuses.Cancelled ? LocalDate(deployment.StatusChangedOn, deployment.LocalTimeZoneId) : null, + // Decision 14: deployment (from the bid) → contract; the profile default is already on the draft when neither applies. + DiscountPercent = deployment.DiscountPercent ?? contract?.DiscountPercent + }; + } + + public async Task GenerateInvoiceFromDeploymentAsync(string deploymentId, int departmentId, DateTime? throughDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var input = await BuildInputAsync(deploymentId, departmentId, throughDate); + if (input == null) throw new InvalidOperationException("deployments_not_found"); + var deployment = input.Deployment; + if (deployment.FinanceMode != (int)DeploymentFinanceModes.Billable) throw new InvalidOperationException("contractor_deployment_not_billable"); + if (string.IsNullOrWhiteSpace(deployment.ContactId)) throw new InvalidOperationException("contractor_deployment_no_contact"); + var charges = ContractorChargeCalculator.Calculate(input); + if (!charges.HasCharges) throw new InvalidOperationException("contractor_no_charges"); + var contract = string.IsNullOrWhiteSpace(deployment.ServiceContractId) ? null : await _contracts.GetContractByIdAsync(deployment.ServiceContractId, departmentId); + + return await TransactionAsync(async () => + { + var invoice = await _invoicing.CreateDraftInvoiceAsync(departmentId, deployment.ContactId, userId, ipAddress, userAgent, charges.Currency, cancellationToken); + invoice = await _invoicing.LinkInvoiceToDeploymentAsync(invoice.InvoiceId, departmentId, deployment.DeploymentId, deployment.ServiceContractId, contract?.TermsNetDays, userId, ipAddress, userAgent, cancellationToken); + + if (charges.DiscountPercent.HasValue && charges.DiscountPercent != invoice.DiscountPercent) + { + invoice.DiscountPercent = charges.DiscountPercent; + invoice = await _invoicing.SaveInvoiceAsync(invoice, userId, ipAddress, userAgent, cancellationToken); + } + + var lines = charges.Lines.Select((line, index) => new InvoiceLineItem + { + InvoiceId = invoice.InvoiceId, DepartmentId = departmentId, CallId = deployment.CallId, DeploymentTimeReportId = line.DeploymentTimeReportId, + Description = line.Description, Quantity = line.Quantity, UnitRate = line.UnitRate, Amount = line.Amount, Taxable = line.Taxable, SortOrder = index + }).ToList(); + invoice = await _invoicing.SaveInvoiceLineItemsAsync(invoice.InvoiceId, departmentId, lines, userId, ipAddress, userAgent, cancellationToken); + + await _timeTracking.MarkTimeReportsBilledAsync(charges.ReportIds, departmentId, invoice.InvoiceId, userId, ipAddress, userAgent, cancellationToken); + + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.DeploymentInvoiceGenerated, ipAddress, userAgent); + audit.After = new { deployment.DeploymentId, invoice.InvoiceId, invoice.InvoiceNumber, Reports = charges.ReportIds, Lines = lines.Count, charges.SubTotal, Warnings = charges.Warnings.Select(w => w.Code).Distinct().ToList() }.CloneJsonToString(); + _eventAggregator.SendMessage(audit); + return await _invoicing.GetInvoiceByIdAsync(invoice.InvoiceId, departmentId); + }, cancellationToken); + } + + #region Packet + + public async Task BuildInvoicePacketAsync(string invoiceId, int departmentId) + { + var invoice = await _invoicing.GetInvoiceByIdAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + var packet = new ContractorInvoicePacket { FileName = $"invoice-{invoice.InvoiceNumber}-packet.zip" }; + + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var invoicePdf = await _invoicing.GetInvoicePdfAsync(invoiceId, departmentId); + if (invoicePdf != null && invoicePdf.Length > 0) Add(zip, packet, $"invoice-{invoice.InvoiceNumber}.pdf", invoicePdf); + + if (!string.IsNullOrWhiteSpace(invoice.DeploymentId)) + { + var deployment = await _deployments.GetDeploymentByIdAsync(invoice.DeploymentId, departmentId); + var reportIds = (invoice.LineItems ?? new List()).Select(l => l.DeploymentTimeReportId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + foreach (var reportId in reportIds) + { + try + { + var report = await _timeTracking.GetTimeReportByIdAsync(reportId, departmentId); + var pdf = report == null ? null : await _timeTracking.GetTimeReportPdfAsync(reportId, departmentId); + if (pdf != null && pdf.Length > 0) Add(zip, packet, $"dtr/dtr-{report.ReportNumber}-{report.ReportDate:yyyy-MM-dd}.pdf", pdf); + } + catch (Exception ex) { Logging.LogException(ex, $"Invoice packet: DTR {reportId} PDF skipped."); } + } + + if (deployment != null) + { + // Receipts referenced by billable expenses on the billed reports, plus the manifest when the contract asks for one. + var expenses = (await _timeTracking.GetExpensesAsync(invoice.DeploymentId, departmentId)).Where(e => e.Billable && e.ReceiptAttachmentId.HasValue && (string.IsNullOrWhiteSpace(e.DeploymentTimeReportId) || reportIds.Contains(e.DeploymentTimeReportId, StringComparer.OrdinalIgnoreCase))).ToList(); + foreach (var expense in expenses) + await AddAttachmentAsync(zip, packet, departmentId, expense.ReceiptAttachmentId.Value, "receipts"); + + var compliance = await _contracts.GetContractComplianceAsync(invoice.DeploymentId, departmentId); + foreach (var item in compliance?.Items.Where(i => i.Stage == (int)DocumentRequirementStages.InvoiceSubmission) ?? Enumerable.Empty()) + { + if (!item.Satisfied) { packet.MissingRequirements.Add(item.Name); continue; } + if (item.DepartmentComplianceDocumentId.HasValue) + { + var document = await _contracts.GetComplianceDocumentAsync(item.DepartmentComplianceDocumentId.Value, departmentId, includeData: true); + if (document?.Data != null && document.Data.Length > 0) Add(zip, packet, $"compliance/{Safe(document.FileName ?? $"{document.Name}.bin")}", document.Data); + else packet.MissingRequirements.Add($"{item.Name} (no file)"); + } + else if (item.DeploymentAttachmentId.HasValue) + { + if (!await AddAttachmentAsync(zip, packet, departmentId, item.DeploymentAttachmentId.Value, "documents")) + packet.MissingRequirements.Add($"{item.Name} (no file)"); + } + } + } + } + } + packet.Data = stream.ToArray(); + return packet; + } + + private async Task AddAttachmentAsync(ZipArchive zip, ContractorInvoicePacket packet, int departmentId, int attachmentId, string folder) + { + try + { + var attachment = await _deployments.GetAttachmentAsync(attachmentId, departmentId, includeData: true); + if (attachment?.Data == null || attachment.Data.Length == 0) return false; + var name = attachment.FileName ?? $"attachment-{attachmentId}.bin"; + Add(zip, packet, $"{folder}/{Safe(name)}", attachment.Data); + return true; + } + catch (Exception ex) { Logging.LogException(ex, $"Invoice packet: attachment {attachmentId} skipped."); return false; } + } + + private static void Add(ZipArchive zip, ContractorInvoicePacket packet, string path, byte[] data) + { + var unique = path; + var n = 1; + while (packet.Contents.Contains(unique, StringComparer.OrdinalIgnoreCase)) + unique = Path.ChangeExtension(path, null) + $"-{++n}" + Path.GetExtension(path); + var entry = zip.CreateEntry(unique, CompressionLevel.Optimal); + using var target = entry.Open(); + target.Write(data, 0, data.Length); + packet.Contents.Add(unique); + } + + private static string Safe(string fileName) + { + var invalid = Path.GetInvalidFileNameChars(); + var cleaned = new string((fileName ?? "file").Select(c => invalid.Contains(c) || c == '/' || c == '\\' ? '_' : c).ToArray()).Trim(); + return string.IsNullOrWhiteSpace(cleaned) ? "file" : cleaned; + } + + public async Task SendDeploymentInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await _invoicing.GetInvoiceByIdAsync(invoiceId, departmentId); + if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); + var recipient = toEmail; + if (string.IsNullOrWhiteSpace(recipient) && !string.IsNullOrWhiteSpace(invoice.ServiceContractId)) + { + var contract = await _contracts.GetContractByIdAsync(invoice.ServiceContractId, departmentId); + if (!string.IsNullOrWhiteSpace(contract?.InvoiceSubmissionEmail)) recipient = contract.InvoiceSubmissionEmail; + } + var packet = await BuildInvoicePacketAsync(invoiceId, departmentId); + var attachment = new InvoiceSendAttachment { FileName = packet.FileName, ContentType = "application/zip", Data = packet.Data, Contents = packet.Contents }; + return await _invoicing.SendInvoiceAsync(invoiceId, departmentId, recipient, attachment, userId, ipAddress, userAgent, cancellationToken); + } + + #endregion + + #region Reminder sweep + + public async Task RunFinanceReminderSweepAsync(DateTime asOfUtc, int unbilledDays, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + if (_reports == null || _deploymentRows == null || _departmentsService == null || _communication?.Value == null) return 0; + var stale = (await _reports.GetUnbilledApprovedBeforeAsync(asOfUtc.AddDays(-Math.Max(0, unbilledDays))))?.ToList() ?? new List(); + var recent = (await _reports.GetUnbilledApprovedBeforeAsync(asOfUtc))?.Where(r => !stale.Any(s => s.DeploymentTimeReportId == r.DeploymentTimeReportId)).ToList() ?? new List(); + var notified = 0; + var dayKey = asOfUtc.Date.GetHashCode(); + foreach (var group in stale.Concat(recent).GroupBy(r => r.DepartmentId)) + { + if (departmentEnabled != null && !await departmentEnabled(group.Key)) continue; + var key = HashCode.Combine(dayKey, group.Key); + lock (RemindedToday) { if (RemindedToday.Contains(key)) continue; } + var deployments = (await _deploymentRows.GetByIdsAsync(group.Key, group.Select(r => r.DeploymentId).Distinct()))?.Where(d => d.FinanceMode == (int)DeploymentFinanceModes.Billable).ToList() ?? new List(); + var lines = new List(); + foreach (var deployment in deployments) + { + var staleCount = stale.Count(r => r.DeploymentId == deployment.DeploymentId); + var openCount = group.Count(r => r.DeploymentId == deployment.DeploymentId); + var completed = deployment.Status is (int)DeploymentStatuses.Completed or (int)DeploymentStatuses.Cancelled; + if (staleCount == 0 && !completed) continue; + lines.Add(completed + ? $"{deployment.Name}: {openCount} approved time report(s) unbilled on a completed deployment." + : $"{deployment.Name}: {staleCount} approved time report(s) unbilled for more than {unbilledDays} days."); + } + if (lines.Count == 0) continue; + lock (RemindedToday) { RemindedToday.Add(key); if (RemindedToday.Count > 50_000) RemindedToday.Clear(); } + await NotifyAdminsAsync(group.Key, "Deployment billing: " + string.Join(" ", lines)); + notified++; + } + return notified; + } + + private async Task NotifyAdminsAsync(int departmentId, string message) + { + try + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); + foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Deployment billing"); + } + catch (Exception ex) { Logging.LogException(ex, $"Deployment billing reminder for department {departmentId} failed."); } + } + + #endregion + + #region Helpers + + private static DateTime? LocalDate(DateTime? utc, string timeZone) + { + if (!utc.HasValue) return null; + try { return string.IsNullOrWhiteSpace(timeZone) ? utc.Value.Date : DateTimeHelpers.GetLocalDateTime(utc.Value, timeZone).Date; } + catch { return utc.Value.Date; } + } + + 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/ContractorChargeCalculator.cs b/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs new file mode 100644 index 000000000..0a56d831c --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs @@ -0,0 +1,542 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Services.Invoicing +{ + /// + /// The pure contractor billing calculator (plan C4, decisions 16/17/21). Turns approved daily time reports into a + /// charge set: one line per billing day per subject, premium adders per person, out-of-province per diems, + /// mileage and fuel deductions per vehicle, and billable expenses passed through. No I/O — the engine service + /// loads the graph and hands it in. + /// + public static class ContractorChargeCalculator + { + public static ContractorChargeSet Calculate(ContractorChargeInput input) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var deployment = input.Deployment ?? throw new ArgumentException("Deployment is required.", nameof(input)); + var set = new ContractorChargeSet + { + DeploymentId = deployment.DeploymentId, + Currency = input.Schedule?.Currency ?? deployment.Currency ?? "USD", + DiscountPercent = input.DiscountPercent + }; + + if (input.Schedule == null) + { + set.Warnings.Add(Warn(ContractorChargeWarningCodes.ScheduleMissing, "No rate schedule applies to this deployment.")); + return set; + } + + var reports = (input.Reports ?? new List()).Where(r => r != null && !r.IsDeleted).OrderBy(r => r.ReportDate).ThenBy(r => r.ReportNumber).ToList(); + if (reports.Count == 0) + { + set.Warnings.Add(Warn(ContractorChargeWarningCodes.NoReports, "No approved, unbilled time reports.")); + } + + var policy = input.Schedule.Policy; + var sort = 0; + foreach (var report in reports) + { + set.ReportIds.Add(report.DeploymentTimeReportId); + var day = report.ReportDate.Date; + var isCancellationDay = input.CancellationDate.HasValue && input.CancellationDate.Value.Date == day; + var entries = (report.Entries ?? new List()).OrderBy(e => e.StartTime).ThenBy(e => e.SortOrder).ToList(); + + foreach (var group in entries.GroupBy(e => (e.SubjectType, SubjectKey(e))).OrderBy(g => g.Key.SubjectType).ThenBy(g => g.Key.Item2)) + { + var subjectType = (DeploymentTimeSubjectTypes)group.Key.SubjectType; + var subjectId = group.Key.Item2; + if (string.IsNullOrWhiteSpace(subjectId)) continue; + var subjectName = input.SubjectNames != null && input.SubjectNames.TryGetValue(subjectId, out var name) ? name : subjectId; + var spans = group.ToList(); + + var (entry, entryWarning) = ResolveEntry(input, subjectType, subjectId, spans, day); + if (entryWarning != null) { entryWarning.DeploymentTimeReportId = report.DeploymentTimeReportId; entryWarning.SubjectId = subjectId; set.Warnings.Add(entryWarning); } + if (entry == null) continue; + + var lines = new List(); + switch ((BillingBases)entry.BillingBasis) + { + case BillingBases.Hourly: + lines.AddRange(HourlyLines(input, report, day, isCancellationDay, subjectType, subjectId, subjectName, entry, spans, policy, set.Warnings)); + break; + case BillingBases.Daily: + case BillingBases.PerPersonPerDay: + lines.AddRange(DailyLines(input, report, day, isCancellationDay, subjectType, subjectId, subjectName, entry, spans, policy, set.Warnings)); + break; + case BillingBases.Fixed: + lines.AddRange(FixedLines(report, day, isCancellationDay, subjectType, subjectId, subjectName, entry, spans, set.Warnings)); + break; + case BillingBases.PerKilometer: + break; // the mileage rule below is the whole charge + } + + if (subjectType == DeploymentTimeSubjectTypes.Personnel) + lines.AddRange(OutOfProvinceLines(input, report, day, subjectId, subjectName, entry, spans)); + if (subjectType != DeploymentTimeSubjectTypes.Personnel) + lines.AddRange(MileageAndFuelLines(report, day, subjectType, subjectId, subjectName, entry, spans, policy)); + + foreach (var line in lines) { line.SortOrder = sort++; set.Lines.Add(line); } + } + } + + foreach (var line in ExpenseLines(input, reports, policy, set.Warnings)) { line.SortOrder = sort++; set.Lines.Add(line); } + return set; + } + + #region Entry resolution + + private static string SubjectKey(DeploymentTimeEntry e) => (DeploymentTimeSubjectTypes)e.SubjectType switch + { + DeploymentTimeSubjectTypes.Personnel => e.DeploymentPersonnelId, + DeploymentTimeSubjectTypes.Unit => e.DeploymentUnitId, + _ => e.DeploymentEquipmentId + }; + + private static (RateScheduleEntry Entry, ContractorChargeWarning Warning) ResolveEntry(ContractorChargeInput input, DeploymentTimeSubjectTypes subjectType, string subjectId, List spans, DateTime day) + { + var entries = (input.Schedule.Entries ?? new List()).Where(e => e != null && !e.IsDeleted && e.IsActive).ToList(); + RateScheduleEntry Find(string id) => string.IsNullOrWhiteSpace(id) ? null : entries.FirstOrDefault(e => string.Equals(e.RateScheduleEntryId, id, StringComparison.OrdinalIgnoreCase)); + + switch (subjectType) + { + case DeploymentTimeSubjectTypes.Personnel: + { + var person = input.Personnel?.FirstOrDefault(p => string.Equals(p.DeploymentPersonnelId, subjectId, StringComparison.OrdinalIgnoreCase)); + var pinned = Find(person?.RateScheduleEntryId); + if (pinned != null) return (pinned, null); + var code = spans.Select(s => s.CertificationCode).FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)) ?? person?.CertificationCode; + var byCode = string.IsNullOrWhiteSpace(code) ? null : entries.FirstOrDefault(e => e.EntryType == (int)RateEntryTypes.PersonnelCertification && string.Equals(e.CertificationCode, code, StringComparison.OrdinalIgnoreCase)); + return byCode != null ? (byCode, null) : (null, Warn(ContractorChargeWarningCodes.RateEntryMissing, $"No personnel rate entry for certification '{code ?? "(none)"}'.")); + } + case DeploymentTimeSubjectTypes.Unit: + { + var unit = input.Units?.FirstOrDefault(u => string.Equals(u.DeploymentUnitId, subjectId, StringComparison.OrdinalIgnoreCase)); + var pinned = Find(unit?.RateScheduleEntryId); + if (pinned == null) return (null, Warn(ContractorChargeWarningCodes.RateEntryMissing, "No crew or vehicle rate entry is pinned to this unit.")); + if (string.IsNullOrWhiteSpace(pinned.GroupKey)) return (pinned, null); + + // Decision 17: the unit bills at the crew size actually filled that day; the family sibling with that size wins. + var crewSize = spans.Where(s => s.CrewSizeSnapshot.HasValue).Select(s => s.CrewSizeSnapshot.Value).DefaultIfEmpty(0).Max(); + if (crewSize <= 0) + crewSize = input.Personnel?.Count(p => string.Equals(p.DeploymentUnitId, subjectId, StringComparison.OrdinalIgnoreCase) && p.AddedOn.Date <= day && (!p.RemovedOn.HasValue || p.RemovedOn.Value.Date > day)) ?? 0; + if (crewSize <= 0) return (pinned, null); + + var family = entries.Where(e => string.Equals(e.GroupKey, pinned.GroupKey, StringComparison.OrdinalIgnoreCase) && e.CrewSize.HasValue).OrderBy(e => e.CrewSize).ToList(); + var exact = family.FirstOrDefault(e => e.CrewSize == crewSize); + if (exact != null) return (exact, null); + var lower = family.LastOrDefault(e => e.CrewSize < crewSize); + if (lower != null) return (lower, Warn(ContractorChargeWarningCodes.CrewSizeFallback, $"No {crewSize}-person entry in family '{pinned.GroupKey}'; billed at the {lower.CrewSize}-person rate.")); + return (pinned, Warn(ContractorChargeWarningCodes.CrewSizeFallback, $"No entry at or below {crewSize} persons in family '{pinned.GroupKey}'; billed at the pinned entry.")); + } + default: + { + var equipment = input.Equipment?.FirstOrDefault(e => string.Equals(e.DeploymentEquipmentId, subjectId, StringComparison.OrdinalIgnoreCase)); + var pinned = Find(equipment?.RateScheduleEntryId); + if (pinned != null) return (pinned, null); + var byItem = string.IsNullOrWhiteSpace(equipment?.InventoryItemId) ? null : entries.FirstOrDefault(e => string.Equals(e.InventoryItemId, equipment.InventoryItemId, StringComparison.OrdinalIgnoreCase)); + return byItem != null ? (byItem, null) : (null, Warn(ContractorChargeWarningCodes.RateEntryMissing, "No equipment rate entry is pinned to this item.")); + } + } + } + + #endregion + + #region Hourly + + private sealed class HourBuckets + { + public decimal Standby; + public decimal TravelFlat; + public List<(RateBandTypes Band, decimal Hours)> Deployment = new List<(RateBandTypes, decimal)>(); + public bool Mobilized; + public bool TravelCapped; + public decimal DeploymentTotal => Deployment.Sum(d => d.Hours); + } + + private static IEnumerable HourlyLines(ContractorChargeInput input, DeploymentTimeReport report, DateTime day, bool isCancellationDay, DeploymentTimeSubjectTypes subjectType, string subjectId, string subjectName, + RateScheduleEntry entry, List spans, RateSchedulePolicy policy, List warnings) + { + var buckets = BuildHourBuckets(report, isCancellationDay, entry, spans, policy); + if (buckets.TravelCapped) warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.TravelCapped, Message = $"Travel hours capped at {policy.TravelDayCapHours:0.##}h.", DeploymentTimeReportId = report.DeploymentTimeReportId, SubjectId = subjectId }); + + var line = new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)subjectType, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.Hourly + }; + + void AddBand(RateBandTypes bandType, decimal hours, string label) + { + if (hours <= 0) return; + var band = entry.Band(bandType) ?? (bandType == RateBandTypes.Overtime2 ? entry.Band(RateBandTypes.Overtime1) : null); + if (band == null && bandType != RateBandTypes.Deployment) band = entry.Band(RateBandTypes.Deployment); + if (band == null) + { + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.BandMissing, Message = $"Entry '{entry.Name}' has no {bandType} rate; {hours:0.##}h unbilled.", DeploymentTimeReportId = report.DeploymentTimeReportId, SubjectId = subjectId }); + return; + } + line.Bands.Add(new ContractorChargeBand { BandType = (int)bandType, Label = label, Hours = hours, Rate = band.Rate, Amount = Money(hours * band.Rate) }); + } + + foreach (var (bandType, hours) in buckets.Deployment.GroupBy(d => d.Band).Select(g => (g.Key, g.Sum(d => d.Hours))).OrderBy(b => b.Key)) + AddBand(bandType, hours, BandLabel(bandType)); + AddBand(RateBandTypes.Deployment, buckets.TravelFlat, "Travel"); + AddBand(RateBandTypes.Standby, buckets.Standby, "Standby"); + + if (line.Bands.Count == 0) yield break; + line.Quantity = line.Bands.Sum(b => b.Hours); + line.Amount = line.Bands.Sum(b => b.Amount); + line.UnitRate = line.Quantity > 0 ? Math.Round(line.Amount / line.Quantity, 4, MidpointRounding.AwayFromZero) : 0; + line.Description = Describe(day, report, entry.Name, subjectName, $"{line.Quantity:0.##}h" + BandBreakdown(line.Bands)); + yield return line; + + if (subjectType != DeploymentTimeSubjectTypes.Personnel) yield break; + foreach (var premiumLine in PremiumLines(input, report, day, subjectId, subjectName, entry, line.Bands)) + yield return premiumLine; + } + + private static HourBuckets BuildHourBuckets(DeploymentTimeReport report, bool isCancellationDay, RateScheduleEntry entry, List spans, RateSchedulePolicy policy) + { + var b = new HourBuckets { Mobilized = spans.Count > 0 }; + decimal Net(DeploymentTimeEntry e) => Math.Max(0m, (decimal)(e.EndTime - e.StartTime).TotalHours - e.UnpaidBreakMinutes / 60m); + + b.Standby = Round(spans.Where(s => s.EntryType == (int)DeploymentTimeEntryTypes.Standby).Sum(Net), policy.RoundingMinutes); + var travel = spans.Where(s => s.EntryType == (int)DeploymentTimeEntryTypes.Travel).Sum(Net); + if (policy.TravelDayCapHours > 0 && travel > policy.TravelDayCapHours) { travel = policy.TravelDayCapHours; b.TravelCapped = true; } + travel = Round(travel, policy.RoundingMinutes); + + // Deployment hours split across Deployment / Overtime1 / Overtime2 by the entry's thresholds. Travel joins them only + // portal-to-portal (customer contracts); otherwise it bills flat at the deployment rate and never earns overtime. + var work = spans.Where(s => s.EntryType == (int)DeploymentTimeEntryTypes.Deployment || (policy.PortalToPortal && s.EntryType == (int)DeploymentTimeEntryTypes.Travel)).ToList(); + if (!policy.PortalToPortal) b.TravelFlat = travel; + // Portal-to-portal still honours the travel cap: the excess above the cap comes off the day's work hours. + var travelExcess = policy.PortalToPortal ? Math.Max(0m, spans.Where(s => s.EntryType == (int)DeploymentTimeEntryTypes.Travel).Sum(Net) - travel) : 0m; + + var ot1 = entry.Band(RateBandTypes.Overtime1)?.ThresholdStartHours; + var ot2 = entry.Band(RateBandTypes.Overtime2)?.ThresholdStartHours; + var carry = report.NoClear8 && policy.NoClear8CarryOver && ot1.HasValue ? ot1.Value : 0m; + + var runs = policy.OvertimeBasis == OvertimeBases.ConsecutiveHours ? ContinuousRuns(work, policy.ContinuousRunGapMinutes) : new List> { work }; + var first = true; + foreach (var run in runs) + { + var hours = Round(run.Sum(Net), policy.RoundingMinutes); + if (travelExcess > 0) { var cut = Math.Min(hours, Round(travelExcess, policy.RoundingMinutes)); hours -= cut; travelExcess -= cut; } + if (hours <= 0) continue; + foreach (var piece in Split(hours, first ? carry : 0m, ot1, ot2)) b.Deployment.Add(piece); + first = false; + } + + // Minimums lift the day's deployment hours; the lift bills at the deployment band. + var actual = b.DeploymentTotal; + var minimum = 0m; + if (isCancellationDay) minimum = Math.Max(minimum, policy.CancellationMinimumHours); + if (report.UnsafeConditionsStandDown) minimum = Math.Max(minimum, policy.UnsafeStandDownHours); + if (b.Mobilized && policy.DailyGuaranteeHours.HasValue) minimum = Math.Max(minimum, policy.DailyGuaranteeHours.Value); + if (minimum > actual) b.Deployment.Add((RateBandTypes.Deployment, minimum - actual)); + if (minimum > 0) b.Mobilized = true; + return b; + } + + private static List> ContinuousRuns(List spans, int gapMinutes) + { + var runs = new List>(); + List current = null; + DateTime? lastEnd = null; + foreach (var span in spans.OrderBy(s => s.StartTime)) + { + if (current == null || !lastEnd.HasValue || (span.StartTime - lastEnd.Value).TotalMinutes > Math.Max(0, gapMinutes)) + { + current = new List(); + runs.Add(current); + } + current.Add(span); + lastEnd = lastEnd.HasValue && lastEnd.Value > span.EndTime ? lastEnd : span.EndTime; + } + return runs; + } + + /// Walks from across the Deployment / Overtime1 / Overtime2 thresholds. + public static IEnumerable<(RateBandTypes Band, decimal Hours)> Split(decimal hours, decimal offset, decimal? ot1Start, decimal? ot2Start) + { + var position = offset; + var remaining = hours; + while (remaining > 0) + { + RateBandTypes band; + decimal? boundary; + if (ot1Start.HasValue && position < ot1Start.Value) { band = RateBandTypes.Deployment; boundary = ot1Start; } + else if (ot2Start.HasValue && position < ot2Start.Value) { band = ot1Start.HasValue ? RateBandTypes.Overtime1 : RateBandTypes.Deployment; boundary = ot2Start; } + else { band = ot2Start.HasValue ? RateBandTypes.Overtime2 : ot1Start.HasValue ? RateBandTypes.Overtime1 : RateBandTypes.Deployment; boundary = null; } + var take = boundary.HasValue ? Math.Min(remaining, boundary.Value - position) : remaining; + if (take <= 0) take = remaining; + yield return (band, take); + position += take; + remaining -= take; + } + } + + private static IEnumerable PremiumLines(ContractorChargeInput input, DeploymentTimeReport report, DateTime day, string subjectId, string subjectName, RateScheduleEntry entry, List bands) + { + var person = input.Personnel?.FirstOrDefault(p => string.Equals(p.DeploymentPersonnelId, subjectId, StringComparison.OrdinalIgnoreCase)); + if (person == null || string.IsNullOrWhiteSpace(person.PremiumIdsJson)) yield break; + List ids; + try { ids = Newtonsoft.Json.JsonConvert.DeserializeObject>(person.PremiumIdsJson) ?? new List(); } + catch (Newtonsoft.Json.JsonException) { yield break; } + + foreach (var premium in (input.Schedule.Premiums ?? new List()).Where(p => p != null && !p.IsDeleted && p.IsActive && ids.Contains(p.RatePremiumId, StringComparer.OrdinalIgnoreCase))) + { + var line = new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, + RatePremiumId = premium.RatePremiumId, Kind = ContractorChargeKinds.Premium + }; + foreach (var band in bands) + { + var adder = premium.AdderFor((RateBandTypes)band.BandType); + if (adder == 0 || band.Hours <= 0) continue; + line.Bands.Add(new ContractorChargeBand { BandType = band.BandType, Label = band.Label, Hours = band.Hours, Rate = adder, Amount = Money(band.Hours * adder) }); + } + if (line.Bands.Count == 0) continue; + line.Quantity = line.Bands.Sum(b => b.Hours); + line.Amount = line.Bands.Sum(b => b.Amount); + line.UnitRate = line.Quantity > 0 ? Math.Round(line.Amount / line.Quantity, 4, MidpointRounding.AwayFromZero) : 0; + line.Description = Describe(day, report, $"{premium.Name} premium", subjectName, $"{line.Quantity:0.##}h" + BandBreakdown(line.Bands)); + yield return line; + } + } + + #endregion + + #region Daily / fixed + + private static IEnumerable DailyLines(ContractorChargeInput input, DeploymentTimeReport report, DateTime day, bool isCancellationDay, DeploymentTimeSubjectTypes subjectType, string subjectId, string subjectName, + RateScheduleEntry entry, List spans, RateSchedulePolicy policy, List warnings) + { + var hasDeployment = spans.Any(s => s.EntryType == (int)DeploymentTimeEntryTypes.Deployment); + if (spans.Count == 0 && !isCancellationDay) yield break; + + // A cancellation day bills the full deployment day for vehicles/equipment when the policy says so; people always do. + var fullDay = hasDeployment || (isCancellationDay && (subjectType == DeploymentTimeSubjectTypes.Personnel || policy.CancellationVehiclesFullDay)); + var bandType = fullDay ? RateBandTypes.DailyDeployment : RateBandTypes.DailyStandby; + var hoursWorked = Round(spans.Where(s => s.EntryType != (int)DeploymentTimeEntryTypes.Standby).Sum(s => Math.Max(0m, (decimal)(s.EndTime - s.StartTime).TotalHours - s.UnpaidBreakMinutes / 60m)), policy.RoundingMinutes); + + var candidates = entry.Bands.Where(b => b.BandType == (int)bandType).OrderBy(b => b.DailyTierMinHours ?? 0).ToList(); + var band = candidates.FirstOrDefault(b => (b.DailyTierMinHours.HasValue || b.DailyTierMaxHours.HasValue) && (b.DailyTierMinHours ?? 0) <= hoursWorked && (!b.DailyTierMaxHours.HasValue || hoursWorked <= b.DailyTierMaxHours.Value)) + ?? candidates.FirstOrDefault(b => !b.DailyTierMinHours.HasValue && !b.DailyTierMaxHours.HasValue) + ?? (bandType == RateBandTypes.DailyStandby ? null : candidates.LastOrDefault()); + if (band == null && bandType == RateBandTypes.DailyStandby) { band = entry.Band(RateBandTypes.DailyDeployment); } + if (band == null) + { + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.BandMissing, Message = $"Entry '{entry.Name}' has no {bandType} rate.", DeploymentTimeReportId = report.DeploymentTimeReportId, SubjectId = subjectId }); + yield break; + } + + var quantity = 1m; + if ((BillingBases)entry.BillingBasis == BillingBases.PerPersonPerDay && subjectType == DeploymentTimeSubjectTypes.Unit) + { + var crew = spans.Where(s => s.CrewSizeSnapshot.HasValue).Select(s => s.CrewSizeSnapshot.Value).DefaultIfEmpty(0).Max(); + if (crew <= 0) crew = input.Personnel?.Count(p => string.Equals(p.DeploymentUnitId, subjectId, StringComparison.OrdinalIgnoreCase) && p.AddedOn.Date <= day && (!p.RemovedOn.HasValue || p.RemovedOn.Value.Date > day)) ?? 0; + quantity = Math.Max(1, crew); + } + + var label = bandType == RateBandTypes.DailyStandby ? "Standby day" : "Deployment day"; + var line = new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)subjectType, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.Daily, + Quantity = quantity, UnitRate = band.Rate, Amount = Money(quantity * band.Rate) + }; + line.Bands.Add(new ContractorChargeBand { BandType = band.BandType, Label = band.Label ?? label, Hours = hoursWorked, Rate = band.Rate, Amount = line.Amount }); + line.Description = Describe(day, report, entry.Name, subjectName, quantity == 1 ? $"{label.ToLowerInvariant()} @ {band.Rate:0.00}" : $"{quantity:0.##} × {label.ToLowerInvariant()} @ {band.Rate:0.00}" + (hoursWorked > 0 ? $" ({hoursWorked:0.##}h worked)" : string.Empty)); + yield return line; + } + + private static IEnumerable FixedLines(DeploymentTimeReport report, DateTime day, bool isCancellationDay, DeploymentTimeSubjectTypes subjectType, string subjectId, string subjectName, RateScheduleEntry entry, List spans, List warnings) + { + if (spans.Count == 0 && !isCancellationDay) yield break; + var band = entry.Band(RateBandTypes.Deployment) ?? entry.Band(RateBandTypes.DailyDeployment) ?? entry.Band(RateBandTypes.Custom) ?? entry.Bands.FirstOrDefault(); + if (band == null) + { + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.BandMissing, Message = $"Entry '{entry.Name}' has no rate.", DeploymentTimeReportId = report.DeploymentTimeReportId, SubjectId = subjectId }); + yield break; + } + yield return new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)subjectType, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.Fixed, + Quantity = 1, UnitRate = band.Rate, Amount = Money(band.Rate), Description = Describe(day, report, entry.Name, subjectName, $"fixed @ {band.Rate:0.00}") + }; + } + + #endregion + + #region Out-of-province, mileage, fuel + + private static IEnumerable OutOfProvinceLines(ContractorChargeInput input, DeploymentTimeReport report, DateTime day, string subjectId, string subjectName, RateScheduleEntry entry, List spans) + { + if (!input.Deployment.OutOfProvince) yield break; + if (!spans.Any(s => s.EntryType == (int)DeploymentTimeEntryTypes.Deployment)) yield break; + var band = entry.Band(RateBandTypes.OutOfProvincePerPersonDaily) + ?? input.Schedule.Entries?.Where(e => e != null && e.IsActive && !e.IsDeleted).OrderBy(e => e.EntryType == (int)RateEntryTypes.Service ? 0 : 1).Select(e => e.Band(RateBandTypes.OutOfProvincePerPersonDaily)).FirstOrDefault(b => b != null); + if (band == null) yield break; + if (band.RequiresAirTravel && !input.Deployment.TravelViaAir) yield break; + yield return new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.OutOfProvince, + Quantity = 1, UnitRate = band.Rate, Amount = Money(band.Rate), Taxable = false, Description = Describe(day, report, band.Label ?? "Out-of-province per diem", subjectName, $"1 day @ {band.Rate:0.00}") + }; + } + + private static IEnumerable MileageAndFuelLines(DeploymentTimeReport report, DateTime day, DeploymentTimeSubjectTypes subjectType, string subjectId, string subjectName, RateScheduleEntry entry, List spans, RateSchedulePolicy policy) + { + var km = spans.Where(s => s.MileageKm.HasValue).Sum(s => s.MileageKm.Value); + var band = entry.Band(RateBandTypes.MileagePerKm); + if (km > 0 && band != null) + { + var billable = Math.Max(0m, km - (band.FreeUnitsPerDay ?? 0m)); + if (billable > 0) + yield return new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)subjectType, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.Mileage, + Quantity = billable, UnitRate = band.Rate, Amount = Money(billable * band.Rate), + Description = Describe(day, report, entry.Name, subjectName, $"{billable:0.##} km @ {band.Rate:0.00}" + ((band.FreeUnitsPerDay ?? 0) > 0 ? $" ({km:0.##} km − {band.FreeUnitsPerDay:0.##} free)" : string.Empty)) + }; + } + + var litres = spans.Where(s => s.FuelDeductionLitres.HasValue).Sum(s => s.FuelDeductionLitres.Value); + if (litres > 0 && policy.FuelDeductionRatePerLitre.HasValue && policy.FuelDeductionRatePerLitre.Value > 0) + yield return new ContractorChargeLine + { + Date = day, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + SubjectType = (int)subjectType, SubjectId = subjectId, SubjectName = subjectName, RateScheduleEntryId = entry.RateScheduleEntryId, EntryName = entry.Name, Kind = ContractorChargeKinds.FuelDeduction, + Quantity = litres, UnitRate = -policy.FuelDeductionRatePerLitre.Value, Amount = -Money(litres * policy.FuelDeductionRatePerLitre.Value), + Description = Describe(day, report, "Agency-supplied fuel deduction", subjectName, $"{litres:0.##} L @ −{policy.FuelDeductionRatePerLitre.Value:0.00}") + }; + } + + #endregion + + #region Expenses + + private static IEnumerable ExpenseLines(ContractorChargeInput input, List reports, RateSchedulePolicy policy, List warnings) + { + if (reports.Count == 0) yield break; + var reportIds = new HashSet(reports.Select(r => r.DeploymentTimeReportId), StringComparer.OrdinalIgnoreCase); + var byDate = reports.GroupBy(r => r.ReportDate.Date).ToDictionary(g => g.Key, g => g.First()); + var perDiemBands = (input.Schedule.Entries ?? new List()).Where(e => e != null).SelectMany(e => e.Bands ?? new List()).Where(b => b.BandType == (int)RateBandTypes.PerDiemMeal && !string.IsNullOrWhiteSpace(b.MealCode)).ToList(); + + foreach (var expense in (input.Expenses ?? new List()).Where(e => e != null && !e.IsDeleted && e.Billable).OrderBy(e => e.ExpenseDate).ThenBy(e => e.AddedOn)) + { + DeploymentTimeReport report = null; + if (!string.IsNullOrWhiteSpace(expense.DeploymentTimeReportId)) + { + if (!reportIds.Contains(expense.DeploymentTimeReportId)) continue; + report = reports.First(r => string.Equals(r.DeploymentTimeReportId, expense.DeploymentTimeReportId, StringComparison.OrdinalIgnoreCase)); + } + else if (!byDate.TryGetValue(expense.ExpenseDate.Date, out report)) continue; + + var dayEntries = report.Entries ?? new List(); + if (expense.ExpenseType == (int)DeploymentExpenseTypes.PerDiemMeal) + { + var band = perDiemBands.FirstOrDefault(b => string.Equals(b.MealCode, expense.MealCode, StringComparison.OrdinalIgnoreCase)); + if (band != null && band.Rate != expense.Amount) + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.PerDiemMismatch, Message = $"Per diem '{expense.MealCode}' claimed {expense.Amount:0.00}; the schedule rate is {band.Rate:0.00}.", DeploymentTimeReportId = report.DeploymentTimeReportId, DeploymentExpenseId = expense.DeploymentExpenseId }); + if (dayEntries.Any(e => e.AgencySuppliedMeals)) + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.PerDiemAgencyMeals, Message = $"Per diem '{expense.MealCode}' claimed on a day the agency supplied meals.", DeploymentTimeReportId = report.DeploymentTimeReportId, DeploymentExpenseId = expense.DeploymentExpenseId }); + var window = policy.MealEligibility?.FirstOrDefault(w => string.Equals(w.MealCode, expense.MealCode, StringComparison.OrdinalIgnoreCase)); + if (window != null && !MeetsWindow(window, dayEntries)) + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.PerDiemIneligible, Message = $"Per diem '{expense.MealCode}' claimed outside its eligibility window.", DeploymentTimeReportId = report.DeploymentTimeReportId, DeploymentExpenseId = expense.DeploymentExpenseId }); + } + else if (expense.ExpenseType is (int)DeploymentExpenseTypes.Accommodation or (int)DeploymentExpenseTypes.PrivateAccommodation && dayEntries.Any(e => e.AgencySuppliedAccommodation)) + { + warnings.Add(new ContractorChargeWarning { Code = ContractorChargeWarningCodes.AccommodationAgencySupplied, Message = "Accommodation claimed on a day the agency supplied accommodation.", DeploymentTimeReportId = report.DeploymentTimeReportId, DeploymentExpenseId = expense.DeploymentExpenseId }); + } + + yield return new ContractorChargeLine + { + Date = expense.ExpenseDate.Date, DeploymentTimeReportId = report.DeploymentTimeReportId, ReportNumber = report.ReportNumber, IncidentNumber = report.IncidentNumber, + DeploymentExpenseId = expense.DeploymentExpenseId, Kind = ContractorChargeKinds.Expense, Quantity = 1, UnitRate = expense.Amount, Amount = Money(expense.Amount), Taxable = false, + Description = Describe(expense.ExpenseDate.Date, report, ExpenseLabel(expense), null, $"{expense.Amount:0.00}" + (string.IsNullOrWhiteSpace(expense.Currency) || string.Equals(expense.Currency, input.Schedule.Currency, StringComparison.OrdinalIgnoreCase) ? string.Empty : $" {expense.Currency}")) + }; + } + } + + private static bool MeetsWindow(MealEligibilityWindow window, List entries) + { + if (entries.Count == 0) return false; + var starts = window.StartsBeforeMinutes.HasValue ? entries.Any(e => e.StartTime.TimeOfDay.TotalMinutes <= window.StartsBeforeMinutes.Value) : true; + var ends = window.EndsAfterMinutes.HasValue ? entries.Any(e => e.EndTime.TimeOfDay.TotalMinutes >= window.EndsAfterMinutes.Value || e.EndTime.Date > e.StartTime.Date) : true; + return starts && ends; + } + + private static string ExpenseLabel(DeploymentExpense expense) + { + var type = (DeploymentExpenseTypes)expense.ExpenseType switch + { + DeploymentExpenseTypes.PerDiemMeal => "Per diem" + (string.IsNullOrWhiteSpace(expense.MealCode) ? string.Empty : $" ({expense.MealCode})"), + DeploymentExpenseTypes.Accommodation => "Accommodation", + DeploymentExpenseTypes.PrivateAccommodation => "Private accommodation", + DeploymentExpenseTypes.Ferry => "Ferry", + DeploymentExpenseTypes.Fuel => "Fuel", + DeploymentExpenseTypes.SupplyRestock => "Supply restock", + _ => "Expense" + }; + var description = expense.Description; + if (string.IsNullOrWhiteSpace(description)) return type; + return $"{type} — {description.Trim()}"; + } + + #endregion + + #region Helpers + + public static decimal Round(decimal hours, int roundingMinutes) + { + if (hours <= 0) return 0; + if (roundingMinutes <= 0) return Math.Round(hours, 2, MidpointRounding.AwayFromZero); + var step = roundingMinutes / 60m; + return Math.Round(hours / step, 0, MidpointRounding.AwayFromZero) * step; + } + + private static decimal Money(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + + private static string BandLabel(RateBandTypes band) => band switch + { + RateBandTypes.Deployment => "Deployment", + RateBandTypes.Overtime1 => "OT1", + RateBandTypes.Overtime2 => "OT2", + RateBandTypes.Standby => "Standby", + _ => band.ToString() + }; + + private static string BandBreakdown(List bands) + { + if (bands.Count == 0) return string.Empty; + if (bands.Count == 1) return $" @ {bands[0].Rate:0.00}" + (bands[0].Label == "Deployment" ? string.Empty : $" {bands[0].Label}"); + return " (" + string.Join(", ", bands.Select(b => $"{b.Hours:0.##}h {b.Label} @ {b.Rate:0.00}")) + ")"; + } + + private static string Describe(DateTime day, DeploymentTimeReport report, string entryName, string subjectName, string detail) + { + var parts = new List { day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), $"DTR #{report.ReportNumber}" }; + if (!string.IsNullOrWhiteSpace(report.IncidentNumber)) parts.Add($"Incident {report.IncidentNumber}"); + parts.Add(entryName); + if (!string.IsNullOrWhiteSpace(subjectName)) parts.Add(subjectName); + parts.Add(detail); + return string.Join(" — ", parts); + } + + private static ContractorChargeWarning Warn(string code, string message) => new ContractorChargeWarning { Code = code, Message = message }; + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs index 2813a4eaa..ecc3d356d 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.Protection.cs @@ -11,16 +11,15 @@ 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. + /// Advanced Data Protection seam for ADP catalog 27: the deployment wrapper's internal notes only. Daily time + /// reports, entries, expenses and attachments are customer-facing (the customer signs the DTR; receipts, DTR PDFs + /// and manifests ride the invoice packet) and are deliberately not protected so people outside the department can + /// read them. Writes run as a workload caller (encrypted at rest, sentinel-safe); user reads honour the caller's + /// Protected Data Grant. 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 static void MarkProtected(Deployment 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 @@ -39,7 +38,7 @@ internal async Task SaveProtectedAsync(IRepository repository, T entity } /// 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, + internal 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); @@ -56,37 +55,12 @@ private async Task InsertProtectedAsync(IRepository repository, T entit 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. + /// User-facing read: a caller presenting a valid Protected Data Grant sees the values, everyone else sees REDACTED. 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); } + try { await _protectedRead.Value.ResolveRecordsEntitiesForReadAsync(departmentId, rows.Select(r => (r, rowKey(r))).ToList(), accessors, _grant?.GrantToken, _grant?.UserId); } 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 index 43646ab2e..075bd4479 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -43,6 +43,8 @@ public partial class DeploymentService : IDeploymentService private readonly Lazy _inventoryIssuance; private readonly Lazy _protectedWrite; private readonly Lazy _protectedRead; + /// The caller's Protected Data Grant (request-bound in the web hosts, workload elsewhere); a grant holder reads decrypted values. + private readonly IProtectedGrantContext _grant; public DeploymentService(IDeploymentRepository deployments, IDeploymentUnitRepository units, IDeploymentPersonnelRepository personnel, IDeploymentEquipmentRepository equipment, IDeploymentAttachmentRepository attachments, @@ -50,8 +52,9 @@ public DeploymentService(IDeploymentRepository deployments, IDeploymentUnitRepos 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) + Lazy inventoryIssuance = null, Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) { + _grant = grant; _deployments = deployments; _units = units; _personnel = personnel; @@ -82,6 +85,7 @@ public async Task GetDeploymentByIdAsync(string deploymentId, int de var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); if (deployment == null || deployment.IsDeleted) return null; await LoadRosterAsync(deployment); + await ResolveDeploymentsAsync(new[] { deployment }, departmentId); return deployment; } @@ -90,6 +94,7 @@ public async Task GetDeploymentByCallIdAsync(int callId, int departm var deployment = await _deployments.GetByCallIdAsync(callId, departmentId); if (deployment == null) return null; await LoadRosterAsync(deployment); + await ResolveDeploymentsAsync(new[] { deployment }, departmentId); return deployment; } @@ -99,11 +104,16 @@ public async Task GetDeploymentByExternalOrderIdAsync(string rmsExte var deployment = await _deployments.GetByExternalOrderIdAsync(rmsExternalOrderId, departmentId); if (deployment == null) return null; await LoadRosterAsync(deployment); + await ResolveDeploymentsAsync(new[] { deployment }, departmentId); 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 async Task> GetDeploymentsForDepartmentAsync(int departmentId, bool openOnly, int skip = 0, int take = 100) + { + var deployments = (await _deployments.GetForDepartmentAsync(departmentId, openOnly, skip, take))?.ToList() ?? new List(); + await ResolveDeploymentsAsync(deployments, departmentId); + return deployments; + } public Task CountDeploymentsForDepartmentAsync(int departmentId, bool openOnly) => _deployments.CountForDepartmentAsync(departmentId, openOnly); @@ -113,6 +123,7 @@ public async Task> GetDeploymentsForUserAsync(int departmentId, 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(); + await ResolveDeploymentsAsync(deployments, departmentId); return openOnly ? deployments.Where(d => d.IsOpen).ToList() : deployments; } @@ -123,6 +134,9 @@ public async Task IsRosteredAsync(string deploymentId, int departmentId, s return rows != null && rows.Any(r => r.DepartmentId == departmentId && r.UserId == userId); } + private Task ResolveDeploymentsAsync(IReadOnlyList deployments, int departmentId) => + ResolveReadAsync(deployments, d => d.DeploymentId, DeploymentProtectedFields.DeploymentFields, departmentId); + private async Task LoadRosterAsync(Deployment deployment) { deployment.Units = (await _units.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); @@ -210,12 +224,14 @@ public async Task SaveDeploymentAsync(Deployment deployment, string 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); + // Catalog 27: Notes are enveloped before the save; a REDACTED value posted back from an unrevealed edit page keeps the stored envelope. + var saved = await SaveProtectedAsync(_deployments, deployment, existing, d => d.DeploymentId, DeploymentProtectedFields.DeploymentFields, MarkProtected, deployment.DepartmentId, cancellationToken); audit.After = Snapshot(saved); _eventAggregator.SendMessage(audit); if (isNew) await PublishAsync(saved, WorkflowTriggerEventType.DeploymentCreated, cancellationToken: cancellationToken); await LoadRosterAsync(saved); + await ResolveDeploymentsAsync(new[] { saved }, saved.DepartmentId); return saved; } @@ -378,7 +394,7 @@ public async Task GetExternalContextAsync(string depl #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) + public async Task AddUnitAsync(string deploymentId, int departmentId, int unitId, string callSign, string notes, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default, string rateScheduleEntryId = null) { var deployment = await RequireOpenAsync(deploymentId, departmentId); var unit = await _unitsService.GetUnitByIdAsync(unitId); @@ -388,7 +404,7 @@ public async Task AddUnitAsync(string deploymentId, int 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 }; + var row = new DeploymentUnit { DeploymentId = deploymentId, DepartmentId = departmentId, UnitId = unitId, CallSign = Trim(callSign), Notes = Trim(notes), RateScheduleEntryId = Trim(rateScheduleEntryId), AddedOn = DateTime.UtcNow }; result.Unit = await _units.SaveOrUpdateAsync(row, cancellationToken); result.Unit.UnitName = unit.Name; @@ -447,7 +463,8 @@ public async Task AddPersonnelAsync(string deploymentId, 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 + CertificationCode = Trim(input.CertificationCode), CallSign = Trim(input.CallSign), RmsExternalOrderFillId = Trim(input.RmsExternalOrderFillId), AddedOn = DateTime.UtcNow, + RateScheduleEntryId = Trim(input.RateScheduleEntryId), PremiumIdsJson = input.PremiumIds == null || input.PremiumIds.Count == 0 ? null : Newtonsoft.Json.JsonConvert.SerializeObject(input.PremiumIds.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToList()) }; result.Personnel = await _personnel.SaveOrUpdateAsync(row, cancellationToken); result.Personnel.DisplayName = profile.FullName.AsFirstNameLastName; @@ -489,7 +506,7 @@ public async Task AddEquipmentAsync(string deploymentId, 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 + FreeTextName = Trim(input.FreeTextName), Notes = Trim(input.Notes), RateScheduleEntryId = Trim(input.RateScheduleEntryId), AddedOn = DateTime.UtcNow }; // Inventory plan M1: an Issue transaction referencing the deployment when the module is present (plan C4; absent = free-text row). @@ -541,6 +558,15 @@ public async Task> GetRosterWarningsAsync(string d return warnings; } + public async Task> GetWindowConflictsAsync(int departmentId, DateTime windowStart, DateTime windowEnd, IEnumerable userIds, IEnumerable unitIds) + { + var probe = new Deployment { DepartmentId = departmentId, DeploymentId = string.Empty, StartOn = windowStart, EndOn = windowEnd }; + var warnings = new List(); + warnings.AddRange(await PersonnelConflictsAsync(probe, userIds?.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct().ToList() ?? new List())); + warnings.AddRange(await UnitConflictsAsync(probe, unitIds?.Distinct().ToList() ?? new List())); + return warnings; + } + public async Task GetCrewSizeForUnitAsync(string deploymentUnitId, int departmentId) { var unit = await _units.GetByIdAsync(deploymentUnitId); @@ -623,18 +649,14 @@ public async Task> GetAttachmentsAsync(string deploym { 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; + return (await _attachments.GetByDeploymentAsync(deploymentId))?.Where(a => a.DepartmentId == departmentId).ToList() ?? new List(); } 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; + if (!includeData) row.Data = null; return row; } @@ -653,17 +675,20 @@ public async Task SaveAttachmentAsync(DeploymentAttachment attachment.IsDeleted = false; attachment.AddedOn = DateTime.UtcNow; attachment.AddedByUserId = userId; - var saved = await SaveProtectedAttachmentAsync(attachment, cancellationToken); + var saved = await _attachments.SaveOrUpdateAsync(attachment, cancellationToken); Audit(attachment.DepartmentId, userId, AuditLogTypes.DeploymentAttachmentAdded, ipAddress, userAgent, null, WithoutBytes(saved)); + await PublishAttachmentAsync(deployment, saved, cancellationToken); 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); + // Metadata read and a targeted flag update: an attachment can be MaxAttachmentBytes, and a soft delete has no + // reason to pull the blob down and write it back. + var row = await _attachments.GetMetadataByIdAsync(deploymentAttachmentId); if (row == null || row.DepartmentId != departmentId || row.IsDeleted) return false; + if (await _attachments.MarkDeletedAsync(deploymentAttachmentId, departmentId, cancellationToken) == 0) return false; row.IsDeleted = true; - await _attachments.SaveOrUpdateAsync(row, cancellationToken); Audit(departmentId, userId, AuditLogTypes.DeploymentAttachmentRemoved, ipAddress, userAgent, null, WithoutBytes(row)); return true; } @@ -684,7 +709,7 @@ internal static DeploymentAttachment WithoutBytes(DeploymentAttachment row) /// 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) + DeploymentTimeReport report = null, DeploymentExpense expense = null, DeploymentAttachment attachment = null, CancellationToken cancellationToken = default) { try { @@ -704,11 +729,12 @@ private async Task PublishAsync(Deployment deployment, WorkflowTriggerEventType 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, + SubjectName = subject?.Name, 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 + ReportNumber = report?.ReportNumber, ReportDate = report?.ReportDate, TimeReportId = report?.DeploymentTimeReportId, ReportStatus = report?.Status, + ExpenseType = expense?.ExpenseType, ExpenseAmount = expense?.Amount, ExpenseCurrency = expense?.Currency ?? deployment.Currency, + AttachmentId = attachment?.DeploymentAttachmentId, AttachmentType = attachment?.AttachmentType, AttachmentName = attachment?.Name } }, cancellationToken); } @@ -725,6 +751,9 @@ internal Task PublishTimeReportAsync(Deployment deployment, WorkflowTriggerEvent internal Task PublishExpenseAsync(Deployment deployment, DeploymentExpense expense, CancellationToken cancellationToken) => PublishAsync(deployment, WorkflowTriggerEventType.DeploymentExpenseAdded, expense: expense, cancellationToken: cancellationToken); + private Task PublishAttachmentAsync(Deployment deployment, DeploymentAttachment attachment, CancellationToken cancellationToken) => + PublishAsync(deployment, WorkflowTriggerEventType.DeploymentAttachmentAdded, attachment: attachment, 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); diff --git a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs index 5f2c5420c..9b38b4bc6 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs @@ -74,14 +74,12 @@ public class InvoicePaymentsService : IInvoicePaymentsService private readonly IEmailService _emailService; private readonly ICacheProvider _cacheProvider; private readonly IEventAggregator _eventAggregator; - private readonly Lazy _protectedRead; public InvoicePaymentsService(IFeatureToggleService featureToggleService, IStripeConnectEndpointProbe endpointProbe, IPaymentConnectProvider provider, IDepartmentPaymentConnectionRepository connections, IInvoicePaymentRequestRepository requests, IPaymentProviderEventRepository events, IInvoiceRepository invoices, ICustomerBillingProfileRepository profiles, IInvoicePaymentRepository payments, IDepartmentBillingIdentityRepository identities, IInvoicingService invoicing, IBusinessOperationsAccessService access, - IDepartmentsService departmentsService, IEmailService emailService, ICacheProvider cacheProvider, IEventAggregator eventAggregator, - Lazy protectedRead = null) + IDepartmentsService departmentsService, IEmailService emailService, ICacheProvider cacheProvider, IEventAggregator eventAggregator) { _featureToggleService = featureToggleService; _endpointProbe = endpointProbe; @@ -99,7 +97,6 @@ public InvoicePaymentsService(IFeatureToggleService featureToggleService, IStrip _emailService = emailService; _cacheProvider = cacheProvider; _eventAggregator = eventAggregator; - _protectedRead = protectedRead; } #region Availability and status @@ -355,7 +352,6 @@ public async Task CreatePaymentRequestAsync(string invoic } var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); - await ResolveWorkloadAsync(profile == null ? new List() : new List { profile }, p => p.CustomerBillingProfileId, InvoicingProtectedFields.BillingProfile, departmentId); // The row is allocated first so the provider receives the request id as its reference (plan B2.1 client_reference_id). var request = new InvoicePaymentRequest @@ -389,7 +385,7 @@ public async Task CreatePaymentRequestAsync(string invoic InvoiceNumber = invoice.InvoiceNumber, Amount = request.Amount, Currency = request.Currency, - CustomerEmail = ProtectedDataEnvelope.HasEnvelopePrefix(profile?.BillingEmail) || profile?.BillingEmail == ProtectedDataEnvelope.RedactionValue ? null : profile?.BillingEmail, + CustomerEmail = profile?.BillingEmail, PaymentMethodTypes = methods, SuccessUrl = payBase + "/return", CancelUrl = payBase + "/cancel", @@ -568,7 +564,8 @@ public async Task ApplyProviderEventAsync(PaymentProviderE LiveMode = envelope.LiveMode, ReceivedOn = now, Outcome = (int)PaymentEventOutcomes.Ignored, - PayloadJson = rawBody + // The ledger keeps the reconciliation shape only; payer PII is stripped before the row is written (ADP: no department at receipt time). + PayloadJson = PaymentWebhookPayloadMinimizer.Minimize(rawBody) }; try { @@ -1150,12 +1147,7 @@ private async Task DepartmentDisplayNameAsync(int departmentId) return department?.Name ?? "Resgrid"; } - private async Task ResolveWorkloadAsync(IReadOnlyList rows, Func key, IReadOnlyDictionary Get, Action Set)> accessors, int departmentId) where T : class - { - if (_protectedRead == null || rows == null || rows.Count == 0) return; - try { await _protectedRead.Value.ResolveRecordsEntitiesForWorkloadAsync(departmentId, "invoice-payments", rows.Select(r => (r, key(r))).ToList(), accessors); } - catch (Exception ex) { Logging.LogException(ex, "Protected invoicing rows could not be resolved for the payments workload."); } - } + private static AuditEvent NewAuditEvent(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent) { diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs index 2d67780a8..e0c47f460 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs @@ -18,18 +18,18 @@ public partial class InvoicingService { public Task RenderInvoiceHtmlAsync(string invoiceId, int departmentId) => RenderInvoiceHtmlCoreAsync(invoiceId, departmentId, workload: false); - /// User rendering shows REDACTED for protected values; the delivery workload (e-mail, PDF attachment) decrypts them. + /// Invoice data is never protected; the delivery workload additionally decrypts the customer contact so the PDF the customer receives is whole. private async Task RenderInvoiceHtmlCoreAsync(string invoiceId, int departmentId, bool workload) { - var invoice = workload ? await GetInvoiceForWorkloadAsync(invoiceId, departmentId) : await GetInvoiceByIdAsync(invoiceId, departmentId); + var invoice = await GetInvoiceByIdAsync(invoiceId, departmentId); if (invoice == null) return null; var identity = await GetDepartmentBillingIdentityAsync(departmentId); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); - if (workload) await ResolveWorkloadAsync(profile); else await ResolveReadAsync(profile); var contact = await _contactsService.GetContactByIdAsync(invoice.ContactId); + if (workload) await ResolveContactForWorkloadAsync(contact, departmentId); var remitTo = identity?.RemitToAddressId.HasValue == true ? await SafeAddressAsync(identity.RemitToAddressId.Value) : null; Address billTo = null; @@ -48,9 +48,9 @@ private async Task RenderInvoiceHtmlCoreAsync(string invoiceId, int depa TaxRegistrationNumber = identity?.TaxRegistrationNumber, SecondaryTaxRegistrationNumber = identity?.SecondaryTaxRegistrationNumber, FooterText = identity?.InvoiceFooterText, - CustomerName = invoice.IsProtected ? ProtectedDataEnvelope.RedactionValue : contact?.Name, - CustomerEmail = invoice.IsProtected ? null : (profile?.BillingEmail ?? contact?.Email), - BillTo = invoice.IsProtected ? null : billTo, + CustomerName = ProtectedDataEnvelope.SafeDisplay(contact?.Name), + CustomerEmail = profile?.BillingEmail ?? ProtectedDataEnvelope.SafeDisplay(contact?.Email), + BillTo = billTo, TaxComponents = ParseTaxComponents(invoice.TaxComponentsJson), // Phase B2: the pay-page link is printed only when the department shows it on documents and online payment is offered right now. PayUrl = identity?.ShowPayOnlineOnDocuments == false ? null : await PayUrlAsync(invoice) @@ -67,14 +67,16 @@ private async Task GetInvoicePdfCoreAsync(string invoiceId, int departme return html == null ? null : _pdfProvider.ConvertHtmlToPdf(html); } - public async Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + public Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SendInvoiceAsync(invoiceId, departmentId, toEmail, null, userId, ipAddress, userAgent, cancellationToken); + + public async Task SendInvoiceAsync(string invoiceId, int departmentId, string toEmail, InvoiceSendAttachment attachment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); if (invoice.Status == (int)InvoiceStatus.Void) throw new InvalidOperationException("invoicing_invoice_void"); var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); - await ResolveWorkloadAsync(profile); var recipient = string.IsNullOrWhiteSpace(toEmail) ? profile?.BillingEmail : toEmail.Trim(); if (string.IsNullOrWhiteSpace(recipient)) throw new InvalidOperationException("invoicing_no_recipient_email"); @@ -82,7 +84,8 @@ public async Task SendInvoiceAsync(string invoiceId, int departmentId, if (invoice.Status == (int)InvoiceStatus.Draft) invoice = await MarkSentAsync(invoiceId, departmentId, recipient, userId, ipAddress, userAgent, cancellationToken); - var pdf = await GetInvoicePdfCoreAsync(invoiceId, departmentId, workload: true); + var useCallerAttachment = attachment?.Data != null && attachment.Data.Length > 0 && !string.IsNullOrWhiteSpace(attachment.FileName); + var pdf = useCallerAttachment ? attachment.Data : await GetInvoicePdfCoreAsync(invoiceId, departmentId, workload: true); if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("invoicing_pdf_unavailable"); var label = $"Invoice #{invoice.InvoiceNumber}"; @@ -90,12 +93,13 @@ public async Task SendInvoiceAsync(string invoiceId, int departmentId, { To = recipient, Subject = $"{label} from {await DepartmentDisplayNameAsync(departmentId)}", - Body = $"{label} for {FormatMoney(invoice.Total, invoice.Currency)} is attached." + (invoice.DueOn.HasValue ? $" Payment is due by {invoice.DueOn.Value:yyyy-MM-dd}." : string.Empty), - AttachmentName = $"invoice-{invoice.InvoiceNumber}.pdf", + Body = $"{label} for {FormatMoney(invoice.Total, invoice.Currency)} is attached." + (invoice.DueOn.HasValue ? $" Payment is due by {invoice.DueOn.Value:yyyy-MM-dd}." : string.Empty) + + (useCallerAttachment && attachment.Contents.Count > 0 ? " The packet also contains: " + string.Join("; ", attachment.Contents) + "." : string.Empty), + AttachmentName = useCallerAttachment ? attachment.FileName : $"invoice-{invoice.InvoiceNumber}.pdf", AttachmentData = pdf }; - var sent = await _emailService.SendInvoiceAsync(notification, departmentId, InvoiceUrl(invoice.InvoiceId), await PayUrlAsync(invoice), label); + var sent = await _emailService.SendInvoiceAsync(notification, departmentId, InvoiceUrl(invoice.InvoiceId), await PayUrlAsync(invoice), label, useCallerAttachment ? attachment.ContentType ?? "application/octet-stream" : "application/pdf"); if (!sent) { // The invoice stays issued (its number and dates are final) but is not stamped as sent: the caller sees @@ -106,14 +110,13 @@ public async Task SendInvoiceAsync(string invoiceId, int departmentId, if (invoice.Status != (int)InvoiceStatus.Draft && (invoice.SentToEmail != recipient || invoice.SentOn == null)) { - var pristine = invoice.CloneJson(); var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceSent, ipAddress, userAgent); audit.Before = Snapshot(invoice); invoice.SentOn = DateTime.UtcNow; invoice.SentToEmail = recipient; invoice.EditedOn = invoice.SentOn; invoice.EditedByUserId = userId; - await SaveProtectedAsync(_invoices, invoice, pristine, i => i.InvoiceId, InvoicingProtectedFields.Invoice, MarkProtected, departmentId, cancellationToken); + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); audit.After = Snapshot(invoice); _eventAggregator.SendMessage(audit); } @@ -199,7 +202,7 @@ public static string RenderInvoiceHtml(InvoiceRenderModel model) sb.Append(""); if (!string.IsNullOrWhiteSpace(invoice.TermsText)) sb.Append("

Terms

").Append(E(invoice.TermsText)).Append("
"); - if (!string.IsNullOrWhiteSpace(invoice.Notes) && !invoice.IsProtected) sb.Append("

Notes

").Append(E(invoice.Notes)).Append("
"); + if (!string.IsNullOrWhiteSpace(invoice.Notes)) sb.Append("

Notes

").Append(E(invoice.Notes)).Append("
"); if (!string.IsNullOrWhiteSpace(model.PayUrl)) sb.Append("

Pay this invoice online

"); if (!string.IsNullOrWhiteSpace(model.FooterText)) sb.Append("
").Append(E(model.FooterText)).Append("
"); sb.Append(""); diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Protection.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Protection.cs index 298989142..95182c30a 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.Protection.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Protection.cs @@ -14,20 +14,16 @@ namespace Resgrid.Services.Invoicing { /// /// Phase B2 seams on the invoicing service: the pay-page URL for documents and Workflow payloads, the dispute - /// lifecycle, and the Advanced Data Protection write/read seam for the catalog-26 columns (customer e-mail, - /// invoice notes, payer e-mail, payment reference/receipt/notes). Every dependency here is optional: without the - /// payments service no pay URL is offered, and without the protection services rows are written and read as-is. + /// lifecycle, and the customer-contact decrypt for delivery renders. Every dependency here is optional: without + /// the payments service no pay URL is offered, and without the protection services contacts render as stored. /// The invoicing UI has no step-up grant plumbing yet, so user reads see REDACTED for protected values and every /// write runs as a workload caller (encrypted at rest, sentinel-safe); the reveal path is a follow-up. /// public partial class InvoicingService { private readonly Lazy _paymentsService; - private readonly Lazy _protectedWrite; private readonly Lazy _protectedRead; - private const string WorkloadPurpose = "invoicing"; - public async Task ApplyPaymentDisputeAsync(string invoicePaymentId, int departmentId, InvoiceDisputeStages stage, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { var payment = await _payments.GetByIdForDepartmentAsync(invoicePaymentId, departmentId); @@ -80,83 +76,21 @@ private async Task PayUrlAsync(Invoice invoice) } } - // ---- Advanced Data Protection seam (catalog 26) ---------------------------------------------------------- - - private static void MarkProtected(CustomerBillingProfile row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion, InvoicingProtectedFields.CatalogVersion); } - private static void MarkProtected(Invoice row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion, InvoicingProtectedFields.CatalogVersion); } - private static void MarkProtected(InvoicePayment row) { row.IsProtected = true; row.ProtectedCatalogVersion = Math.Max(row.ProtectedCatalogVersion, InvoicingProtectedFields.CatalogVersion); } - - /// Encrypts the cataloged columns (when the department enforces protection) and saves. lets the sentinel policy keep an untouched envelope when the caller sent REDACTED back. - private 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 == null) - return await repository.SaveOrUpdateAsync(entity, cancellationToken); - - var key = rowKey(entity); - if (string.IsNullOrWhiteSpace(key)) - 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("invoicing_protected_write_refused"); - return await repository.SaveOrUpdateAsync(entity, cancellationToken); - } - - /// A new row has no key until it is inserted, and the key is part of the envelope binding: the cataloged columns are held back, the row allocated, then encrypted and written (the work-order precedent). - 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 - { - if (_protectedWrite == null) - return await repository.SaveOrUpdateAsync(entity, cancellationToken); - - 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); + // ---- Customer-facing renders --------------------------------------------------------------------------- + // + // Invoice, line, payment, billing-profile and billing-identity columns are not under Advanced Data Protection: + // customers who are not signed in read invoices (PDF, pay page) and must see them whole. The customer's own + // Contact row may be protected (Contacts family), so the delivery render decrypts its name through the + // broker's "invoicing" workload lane; a user render shows what the caller may see. - 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("invoicing_protected_write_refused"); - return await repository.SaveOrUpdateAsync(allocated, cancellationToken); - } - - /// User-facing read: protected values become REDACTED (no grant is carried on this path yet). Never throws. - private async Task ResolveReadAsync(IReadOnlyList rows, Func rowKey, IReadOnlyDictionary Get, Action Set)> accessors, int departmentId) where T : class - { - if (_protectedRead == 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 invoicing rows could not be resolved for read."); } - } - - /// System read (delivery, payments): protected values are decrypted for the workload. Never throws. - private async Task ResolveWorkloadAsync(IReadOnlyList rows, Func rowKey, IReadOnlyDictionary Get, Action Set)> accessors, int departmentId) where T : class - { - if (_protectedRead == null || rows == null || rows.Count == 0) return; - try { await _protectedRead.Value.ResolveRecordsEntitiesForWorkloadAsync(departmentId, WorkloadPurpose, rows.Select(r => (r, rowKey(r))).ToList(), accessors); } - catch (Exception ex) { Logging.LogException(ex, "Protected invoicing rows could not be resolved for the workload."); } - } - - private Task ResolveReadAsync(Invoice invoice) => invoice == null ? Task.CompletedTask : ResolveReadAsync(new[] { invoice }, i => i.InvoiceId, InvoicingProtectedFields.Invoice, invoice.DepartmentId); - private Task ResolveReadAsync(IReadOnlyList invoices, int departmentId) => ResolveReadAsync(invoices, i => i.InvoiceId, InvoicingProtectedFields.Invoice, departmentId); - private Task ResolveReadAsync(IReadOnlyList payments, int departmentId) => ResolveReadAsync(payments, p => p.InvoicePaymentId, InvoicingProtectedFields.Payment, departmentId); - private Task ResolveReadAsync(CustomerBillingProfile profile) => profile == null ? Task.CompletedTask : ResolveReadAsync(new[] { profile }, p => p.CustomerBillingProfileId, InvoicingProtectedFields.BillingProfile, profile.DepartmentId); - private Task ResolveReadAsync(IReadOnlyList profiles, int departmentId) => ResolveReadAsync(profiles, p => p.CustomerBillingProfileId, InvoicingProtectedFields.BillingProfile, departmentId); - private Task ResolveWorkloadAsync(CustomerBillingProfile profile) => profile == null ? Task.CompletedTask : ResolveWorkloadAsync(new[] { profile }, p => p.CustomerBillingProfileId, InvoicingProtectedFields.BillingProfile, profile.DepartmentId); - private Task ResolveWorkloadAsync(Invoice invoice) => invoice == null ? Task.CompletedTask : ResolveWorkloadAsync(new[] { invoice }, i => i.InvoiceId, InvoicingProtectedFields.Invoice, invoice.DepartmentId); + private const string WorkloadPurpose = "invoicing"; - /// The invoice with children, decrypted for a system workload (delivery). - private async Task GetInvoiceForWorkloadAsync(string invoiceId, int departmentId) + /// Decrypts a protected customer contact for a system workload (delivery). Never throws; leaves the row as-is on failure. + private async Task ResolveContactForWorkloadAsync(Contact contact, int departmentId) { - var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); - if (invoice == null) return null; - await LoadChildrenAsync(invoice); - await ResolveWorkloadAsync(invoice); - await ResolveWorkloadAsync(invoice.Payments, p => p.InvoicePaymentId, InvoicingProtectedFields.Payment, departmentId); - return invoice; + if (contact == null || _protectedRead?.Value == null) return; + try { await _protectedRead.Value.ResolveRecordsEntitiesForWorkloadAsync(departmentId, WorkloadPurpose, new[] { (contact, contact.ContactId) }, ProtectedReadService.ContactFieldAccessors); } + catch (Exception ex) { Logging.LogException(ex, $"Contact {contact.ContactId} could not be resolved for the invoice workload."); } } } } diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.cs index e518984db..4bebcc6b6 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.cs @@ -31,6 +31,8 @@ public partial class InvoicingService : IInvoicingService private readonly IInvoicePaymentRepository _payments; private readonly IInvoiceNumberSequenceRepository _sequence; private readonly IDepartmentBillingIdentityRepository _identities; + /// Contractor billing (C-M2): the linked contract's terms override the profile's net days when the invoice issues. + private readonly IServiceContractRepository _serviceContracts; private readonly IContactsService _contactsService; private readonly ICallsService _callsService; private readonly IUnitsService _unitsService; @@ -48,11 +50,12 @@ public InvoicingService(ICustomerBillingProfileRepository profiles, IRateCardRep IContactsService contactsService, ICallsService callsService, IUnitsService unitsService, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, IPdfProvider pdfProvider, IEmailService emailService, IDepartmentsService departmentsService, IAddressService addressService, IUnitOfWork unitOfWork, - Lazy paymentsService = null, Lazy protectedWrite = null, Lazy protectedRead = null) + Lazy paymentsService = null, Lazy protectedRead = null, + IServiceContractRepository serviceContracts = null) { + _serviceContracts = serviceContracts; _unitOfWork = unitOfWork; _paymentsService = paymentsService; - _protectedWrite = protectedWrite; _protectedRead = protectedRead; _pdfProvider = pdfProvider; _emailService = emailService; @@ -77,23 +80,17 @@ public InvoicingService(ICustomerBillingProfileRepository profiles, IRateCardRep public async Task GetBillingProfileByContactIdAsync(string contactId, int departmentId) { - var profile = await _profiles.GetByContactIdAsync(contactId, departmentId); - await ResolveReadAsync(profile); - return profile; + return await _profiles.GetByContactIdAsync(contactId, departmentId); } public async Task GetBillingProfileByIdAsync(string customerBillingProfileId, int departmentId) { - var profile = await _profiles.GetByIdForDepartmentAsync(customerBillingProfileId, departmentId); - await ResolveReadAsync(profile); - return profile; + return await _profiles.GetByIdForDepartmentAsync(customerBillingProfileId, departmentId); } public async Task> GetBillingProfilesForDepartmentAsync(int departmentId) { - var profiles = (await _profiles.GetAllForDepartmentAsync(departmentId))?.ToList() ?? new List(); - await ResolveReadAsync(profiles, departmentId); - return profiles; + return (await _profiles.GetAllForDepartmentAsync(departmentId))?.ToList() ?? new List(); } public async Task SaveBillingProfileAsync(CustomerBillingProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) @@ -124,8 +121,6 @@ public async Task SaveBillingProfileAsync(CustomerBillin profile.CustomerBillingProfileId = existing.CustomerBillingProfileId; profile.AddedOn = existing.AddedOn; profile.AddedByUserId = existing.AddedByUserId; - profile.IsProtected = existing.IsProtected; - profile.ProtectedCatalogVersion = existing.ProtectedCatalogVersion; profile.EditedOn = now; profile.EditedByUserId = userId; } @@ -139,7 +134,7 @@ public async Task SaveBillingProfileAsync(CustomerBillin } profile.IsDeleted = false; - var saved = await SaveProtectedAsync(_profiles, profile, existing, p => p.CustomerBillingProfileId, InvoicingProtectedFields.BillingProfile, MarkProtected, profile.DepartmentId, cancellationToken); + var saved = await _profiles.SaveOrUpdateAsync(profile, cancellationToken); audit.After = Snapshot(saved); _eventAggregator.SendMessage(audit); return saved; @@ -321,7 +316,6 @@ public async Task GetEffectiveRateCardForContactAsync(string contactId public async Task> GetInvoicesForDepartmentAsync(int departmentId, InvoiceListFilter filter) { var invoices = (await _invoices.GetForDepartmentAsync(departmentId, filter ?? new InvoiceListFilter()))?.ToList() ?? new List(); - await ResolveReadAsync(invoices, departmentId); return invoices; } @@ -331,7 +325,6 @@ public Task CountInvoicesForDepartmentAsync(int departmentId, InvoiceListFi public async Task> GetInvoicesByContactIdAsync(string contactId, int departmentId) { var invoices = (await _invoices.GetByContactIdAsync(contactId, departmentId))?.ToList() ?? new List(); - await ResolveReadAsync(invoices, departmentId); return invoices; } @@ -340,8 +333,6 @@ public async Task GetInvoiceByIdAsync(string invoiceId, int departmentI var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); if (invoice == null) return null; await LoadChildrenAsync(invoice); - await ResolveReadAsync(invoice); - await ResolveReadAsync(invoice.Payments, departmentId); return invoice; } @@ -380,7 +371,6 @@ public async Task SaveInvoiceAsync(Invoice invoice, string userId, stri { if (invoice == null) throw new ArgumentNullException(nameof(invoice)); var existing = await RequireDraftAsync(invoice.InvoiceId, invoice.DepartmentId); - var pristine = existing.CloneJson(); ValidatePercent(invoice.DiscountPercent, nameof(invoice.DiscountPercent)); var audit = NewAuditEvent(existing.DepartmentId, userId, AuditLogTypes.InvoiceUpdated, ipAddress, userAgent); @@ -396,13 +386,31 @@ public async Task SaveInvoiceAsync(Invoice invoice, string userId, stri existing.EditedOn = DateTime.UtcNow; existing.EditedByUserId = userId; - await SaveProtectedAsync(_invoices, existing, pristine, i => i.InvoiceId, InvoicingProtectedFields.Invoice, MarkProtected, existing.DepartmentId, cancellationToken); + await _invoices.SaveOrUpdateAsync(existing, cancellationToken); var recalculated = await RecalculateTotalsAsync(existing.InvoiceId, existing.DepartmentId, cancellationToken); audit.After = Snapshot(recalculated); _eventAggregator.SendMessage(audit); return recalculated; } + public async Task LinkInvoiceToDeploymentAsync(string invoiceId, int departmentId, string deploymentId, string serviceContractId, int? termsNetDays, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var existing = await RequireDraftAsync(invoiceId, departmentId); + var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceUpdated, ipAddress, userAgent); + audit.Before = Snapshot(existing); + existing.DeploymentId = string.IsNullOrWhiteSpace(deploymentId) ? null : deploymentId.Trim(); + existing.ServiceContractId = string.IsNullOrWhiteSpace(serviceContractId) ? null : serviceContractId.Trim(); + // Contract terms override the profile's net days (decision 14 cascade); the due date is derived when the invoice issues. + if (termsNetDays.HasValue && termsNetDays.Value > 0 && existing.IssuedOn.HasValue) existing.DueOn = existing.IssuedOn.Value.AddDays(termsNetDays.Value); + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + await _invoices.SaveOrUpdateAsync(existing, cancellationToken); + var result = await GetInvoiceByIdAsync(invoiceId, departmentId); + audit.After = Snapshot(result); + _eventAggregator.SendMessage(audit); + return result; + } + public Task SaveDraftAsync(Invoice invoice, List lineItems, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { if (invoice == null) throw new ArgumentNullException(nameof(invoice)); @@ -424,13 +432,22 @@ public Task SaveInvoiceLineItemsAsync(string invoiceId, int departmentI var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceUpdated, ipAddress, userAgent); audit.Before = Snapshot(invoice); - await _lineItems.DeleteByInvoiceIdAsync(invoiceId, departmentId, cancellationToken); + // Lines keep their ids across a save (provenance links and audit history follow the row); stale lines are deleted. + var current = (await _lineItems.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToDictionary(l => l.InvoiceLineItemId, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var incoming = (lineItems ?? new List()).Where(x => x != null).ToList(); + var kept = new HashSet(incoming.Where(l => !string.IsNullOrWhiteSpace(l.InvoiceLineItemId) && current.ContainsKey(l.InvoiceLineItemId)).Select(l => l.InvoiceLineItemId), StringComparer.OrdinalIgnoreCase); + foreach (var line in incoming) + { + if (string.IsNullOrWhiteSpace(line.Description)) throw new ArgumentException("Every line needs a description.", nameof(lineItems)); + } + foreach (var stale in current.Values.Where(l => !kept.Contains(l.InvoiceLineItemId))) + await _lineItems.DeleteAsync(stale, cancellationToken); var sort = 0; var minimums = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var line in (lineItems ?? new List()).Where(x => x != null)) + foreach (var line in incoming) { - if (string.IsNullOrWhiteSpace(line.Description)) throw new ArgumentException("Every line needs a description.", nameof(lineItems)); - line.InvoiceLineItemId = null; + var existingLine = !string.IsNullOrWhiteSpace(line.InvoiceLineItemId) && current.TryGetValue(line.InvoiceLineItemId, out var found) ? found : null; + if (existingLine == null) line.InvoiceLineItemId = null; line.InvoiceId = invoiceId; line.DepartmentId = departmentId; line.Amount = RoundMoney(line.Quantity * line.UnitRate); @@ -584,22 +601,20 @@ public async Task MarkSentAsync(string invoiceId, int departmentId, str if (lines.Count == 0) throw new InvalidOperationException("invoicing_invoice_has_no_lines"); var profile = await _profiles.GetByIdForDepartmentAsync(invoice.CustomerBillingProfileId, departmentId); - await ResolveWorkloadAsync(profile); - var pristine = invoice.CloneJson(); var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceSent, ipAddress, userAgent); audit.Before = Snapshot(invoice); var now = DateTime.UtcNow; ComputeTotals(invoice, lines, profile); invoice.IssuedOn ??= now; - invoice.DueOn ??= invoice.IssuedOn.Value.AddDays(profile?.TermsNetDays ?? 30); + invoice.DueOn ??= invoice.IssuedOn.Value.AddDays(await TermsNetDaysAsync(invoice, profile)); invoice.SentOn = now; invoice.SentToEmail = string.IsNullOrWhiteSpace(sentToEmail) ? profile?.BillingEmail : sentToEmail.Trim(); invoice.Status = (int)InvoiceStatus.Sent; invoice.EditedOn = now; invoice.EditedByUserId = userId; - await SaveProtectedAsync(_invoices, invoice, pristine, i => i.InvoiceId, InvoicingProtectedFields.Invoice, MarkProtected, departmentId, cancellationToken); + await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); audit.After = Snapshot(invoice); _eventAggregator.SendMessage(audit); await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceSent, oldStatus: (int)InvoiceStatus.Draft, cancellationToken: cancellationToken); @@ -665,7 +680,7 @@ public async Task RecordPaymentAsync(InvoicePayment payment, str InvoicePayment saved; try { - saved = await InsertProtectedAsync(_payments, payment, p => p.InvoicePaymentId, InvoicingProtectedFields.Payment, MarkProtected, payment.DepartmentId, cancellationToken); + saved = await _payments.SaveOrUpdateAsync(payment, cancellationToken); } catch (Exception ex) when (online && IsUniqueViolation(ex)) { @@ -772,8 +787,10 @@ public async Task GetAccountsReceivableAgingAsync(int depart #region Department billing identity - public async Task GetDepartmentBillingIdentityAsync(int departmentId) => - await _identities.GetByDepartmentIdAsync(departmentId) ?? new DepartmentBillingIdentity { DepartmentId = departmentId, AllowedPaymentMethodsCsv = "card", PayLinkExpiryDays = 30, ShowPayOnlineOnDocuments = true }; + public async Task GetDepartmentBillingIdentityAsync(int departmentId) + { + return await _identities.GetByDepartmentIdAsync(departmentId) ?? new DepartmentBillingIdentity { DepartmentId = departmentId, AllowedPaymentMethodsCsv = "card", PayLinkExpiryDays = 30, ShowPayOnlineOnDocuments = true }; + } public async Task SaveDepartmentBillingIdentityAsync(DepartmentBillingIdentity identity, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { @@ -839,6 +856,16 @@ private static bool IsUniqueViolation(Exception ex) return false; } + private async Task TermsNetDaysAsync(Invoice invoice, CustomerBillingProfile profile) + { + if (_serviceContracts != null && !string.IsNullOrWhiteSpace(invoice.ServiceContractId)) + { + var contract = await _serviceContracts.GetByIdForDepartmentAsync(invoice.ServiceContractId, invoice.DepartmentId); + if (contract?.TermsNetDays > 0) return contract.TermsNetDays.Value; + } + return profile?.TermsNetDays ?? 30; + } + private async Task RequireDraftAsync(string invoiceId, int departmentId) { var invoice = await _invoices.GetByIdForDepartmentAsync(invoiceId, departmentId); @@ -928,19 +955,16 @@ public static List ParseTaxComponents(string json) } } - /// Publishes the lifecycle trigger through the domain outbox (plan decision 22). Payload = identifiers, status, amounts, dates; contact name only when the row is not protected. + /// Publishes the lifecycle trigger through the domain outbox (plan decision 22). Payload = identifiers, status, amounts, dates; the contact name reads REDACTED when the contact row is protected. private async Task PublishAsync(Invoice invoice, WorkflowTriggerEventType trigger, InvoicePayment payment = null, int? oldStatus = null, CancellationToken cancellationToken = default) { string contactName = null; - if (!invoice.IsProtected) + try { - try - { - var contact = await _contactsService.GetContactByIdAsync(invoice.ContactId); - contactName = contact == null ? null : (string.IsNullOrWhiteSpace(contact.CompanyName) ? $"{contact.FirstName} {contact.LastName}".Trim() : contact.CompanyName); - } - catch (Exception ex) { Logging.LogException(ex, "Invoice workflow payload: contact name could not be read."); } + var contact = await _contactsService.GetContactByIdAsync(invoice.ContactId); + contactName = contact == null ? null : ProtectedDataEnvelope.SafeDisplay(string.IsNullOrWhiteSpace(contact.CompanyName) ? $"{contact.FirstName} {contact.LastName}".Trim() : contact.CompanyName); } + catch (Exception ex) { Logging.LogException(ex, "Invoice workflow payload: contact name could not be read."); } var payUrl = await PayUrlAsync(invoice); try @@ -957,7 +981,7 @@ private async Task PublishAsync(Invoice invoice, WorkflowTriggerEventType trigge Payload = new { invoice.InvoiceId, invoice.InvoiceNumber, invoice.Status, invoice.ContactId, - ContactName = invoice.IsProtected ? ProtectedDataEnvelope.RedactionValue : contactName, + ContactName = contactName, invoice.Currency, invoice.SubTotal, invoice.DiscountAmount, invoice.TaxAmount, invoice.Total, invoice.AmountPaid, invoice.Balance, invoice.IssuedOn, invoice.DueOn, invoice.SentOn, invoice.PaidOn, PaymentAmount = payment?.Amount, PaymentMethod = payment == null ? null : ((InvoicePaymentMethods)payment.Method).ToString(), PaymentId = payment?.InvoicePaymentId, diff --git a/Core/Resgrid.Services/Invoicing/PaymentWebhookPayloadMinimizer.cs b/Core/Resgrid.Services/Invoicing/PaymentWebhookPayloadMinimizer.cs new file mode 100644 index 000000000..77dc46e17 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/PaymentWebhookPayloadMinimizer.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Strips the payer-identifying parts of a provider webhook body before it is kept on the payment event ledger + /// (PaymentConnectEvents.PayloadJson). The ledger exists for replay and reconciliation — event ids, types, + /// amounts, intents, statuses — and never needs the payer's name, e-mail, phone, address or card details, which + /// would otherwise sit outside Advanced Data Protection (the ledger row has no department at receipt time, so it + /// cannot be enveloped per department). Unparseable bodies are dropped rather than stored raw. + /// + public static class PaymentWebhookPayloadMinimizer + { + /// Property names removed wherever they appear (Stripe checkout/session/charge/payment-intent shapes and their Paddle equivalents). + public static readonly IReadOnlyCollection DroppedProperties = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "email", "name", "phone", "address", "billing_details", "customer_details", "customer_email", "customer_name", "receipt_email", + "shipping", "shipping_details", "individual", "tax_ids", "payment_method_details", "payment_method_options", "card", "owner", + "customer", "billing_address", "ip_address", "user_agent", "description", "statement_descriptor", "receipt_url" + }; + + public static string Minimize(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + JToken token; + try { token = JToken.Parse(json); } + catch (JsonException) { return null; } + Strip(token); + return token.ToString(Formatting.None); + } + + private static void Strip(JToken token) + { + switch (token) + { + case JObject obj: + foreach (var property in obj.Properties().ToList()) + { + // A bare "customer" id string is kept (reconciliation); an expanded customer object is payer PII. + if (DroppedProperties.Contains(property.Name) && !(property.Name.Equals("customer", StringComparison.OrdinalIgnoreCase) && property.Value.Type == JTokenType.String)) + property.Remove(); + else + Strip(property.Value); + } + break; + case JArray array: + foreach (var item in array) Strip(item); + break; + } + } + } +} diff --git a/Core/Resgrid.Services/Invoicing/RateScheduleService.cs b/Core/Resgrid.Services/Invoicing/RateScheduleService.cs new file mode 100644 index 000000000..11ce0da53 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/RateScheduleService.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Contractor rate schedules (Workforce & Business Operations plan, C4; decisions 16, 17, 21). Bands are + /// explicit dollars, crew families share a , the policy rides the + /// schedule as typed JSON. Nothing here is protected data (rates are commercial, not personal). Callers authorize. + /// + public class RateScheduleService : IRateScheduleService + { + private readonly IRateScheduleRepository _schedules; + private readonly IRateScheduleEntryRepository _entries; + private readonly IRateScheduleEntryBandRepository _bands; + private readonly IRatePremiumRepository _premiums; + private readonly IServiceContractRepository _contracts; + private readonly ICustomerBillingProfileRepository _profiles; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + + public RateScheduleService(IRateScheduleRepository schedules, IRateScheduleEntryRepository entries, IRateScheduleEntryBandRepository bands, IRatePremiumRepository premiums, + IServiceContractRepository contracts, ICustomerBillingProfileRepository profiles, IEventAggregator eventAggregator, IUnitOfWork unitOfWork) + { + _schedules = schedules; + _entries = entries; + _bands = bands; + _premiums = premiums; + _contracts = contracts; + _profiles = profiles; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + } + + #region Schedules + + public async Task> GetSchedulesForDepartmentAsync(int departmentId, bool includeInactive = false) => + (await _schedules.GetForDepartmentAsync(departmentId, includeInactive))?.ToList() ?? new List(); + + public async Task GetScheduleByIdAsync(string rateScheduleId, int departmentId, bool includeInactive = false) + { + if (string.IsNullOrWhiteSpace(rateScheduleId)) return null; + var schedule = await _schedules.GetByIdForDepartmentAsync(rateScheduleId, departmentId); + if (schedule == null || schedule.IsDeleted) return null; + await LoadGraphAsync(schedule, includeInactive); + return schedule; + } + + private async Task LoadGraphAsync(RateSchedule schedule, bool includeInactive) + { + schedule.Entries = (await _entries.GetByScheduleAsync(schedule.RateScheduleId, includeInactive))?.ToList() ?? new List(); + var bands = (await _bands.GetByScheduleAsync(schedule.RateScheduleId))?.ToList() ?? new List(); + foreach (var entry in schedule.Entries) + entry.Bands = bands.Where(b => string.Equals(b.RateScheduleEntryId, entry.RateScheduleEntryId, StringComparison.OrdinalIgnoreCase)).OrderBy(b => b.SortOrder).ThenBy(b => b.BandType).ToList(); + schedule.Premiums = (await _premiums.GetByScheduleAsync(schedule.RateScheduleId, includeInactive))?.ToList() ?? new List(); + } + + public async Task SaveScheduleAsync(RateSchedule schedule, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (schedule == null) throw new ArgumentNullException(nameof(schedule)); + if (string.IsNullOrWhiteSpace(schedule.Name)) throw new InvalidOperationException("rateschedules_name_required"); + if (schedule.EffectiveOn.HasValue && schedule.ExpiresOn.HasValue && schedule.ExpiresOn < schedule.EffectiveOn) throw new InvalidOperationException("rateschedules_dates_invalid"); + schedule.Currency = NormalizeCurrency(schedule.Currency); + schedule.PolicyJson = string.IsNullOrWhiteSpace(schedule.PolicyJson) ? new RateSchedulePolicy().ToJson() : RateSchedulePolicy.Parse(schedule.PolicyJson).ToJson(); + + var existing = string.IsNullOrWhiteSpace(schedule.RateScheduleId) ? null : await _schedules.GetByIdForDepartmentAsync(schedule.RateScheduleId, schedule.DepartmentId); + var now = DateTime.UtcNow; + RateSchedule saved; + if (existing == null) + { + schedule.RateScheduleId = null; + schedule.AddedOn = now; + schedule.AddedByUserId = userId; + schedule.IsDeleted = false; + saved = await _schedules.SaveOrUpdateAsync(schedule, cancellationToken); + Audit(schedule.DepartmentId, userId, AuditLogTypes.RateScheduleCreated, ipAddress, userAgent, null, saved); + } + else + { + if (existing.IsDeleted) throw new InvalidOperationException("rateschedules_not_found"); + var before = Snapshot(existing); + existing.Name = schedule.Name.Trim(); + existing.Description = Trim(schedule.Description); + existing.Currency = schedule.Currency; + existing.EffectiveOn = schedule.EffectiveOn; + existing.ExpiresOn = schedule.ExpiresOn; + existing.PolicyJson = schedule.PolicyJson; + existing.IsActive = schedule.IsActive; + existing.EditedOn = now; + existing.EditedByUserId = userId; + saved = await _schedules.SaveOrUpdateAsync(existing, cancellationToken); + Audit(schedule.DepartmentId, userId, AuditLogTypes.RateScheduleUpdated, ipAddress, userAgent, before, saved); + } + return await GetScheduleByIdAsync(saved.RateScheduleId, saved.DepartmentId, includeInactive: true); + } + + public async Task DeleteScheduleAsync(string rateScheduleId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var existing = await _schedules.GetByIdForDepartmentAsync(rateScheduleId, departmentId); + if (existing == null || existing.IsDeleted) return false; + var inUse = (await _contracts.GetForDepartmentAsync(departmentId, null))?.Any(c => string.Equals(c.RateScheduleId, rateScheduleId, StringComparison.OrdinalIgnoreCase) && c.Status is (int)ServiceContractStatuses.Active or (int)ServiceContractStatuses.Draft) ?? false; + if (inUse) throw new InvalidOperationException("rateschedules_in_use"); + + var before = Snapshot(existing); + existing.IsDeleted = true; + existing.IsActive = false; + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + await _schedules.SaveOrUpdateAsync(existing, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.RateScheduleDeleted, ipAddress, userAgent, before, existing); + return true; + } + + public async Task CloneScheduleAsync(string rateScheduleId, int departmentId, string newName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var source = await GetScheduleByIdAsync(rateScheduleId, departmentId, includeInactive: true); + if (source == null) throw new InvalidOperationException("rateschedules_not_found"); + var name = string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName.Trim(); + return await CreateGraphAsync(departmentId, source, name, userId, ipAddress, userAgent, cancellationToken); + } + + /// Writes a full schedule graph from a template (clone or import), allocating new ids and remapping premium references. + private async Task CreateGraphAsync(int departmentId, RateSchedule template, string name, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var schedule = new RateSchedule + { + DepartmentId = departmentId, Name = name, Description = Trim(template.Description), Currency = NormalizeCurrency(template.Currency), + EffectiveOn = template.EffectiveOn, ExpiresOn = template.ExpiresOn, PolicyJson = RateSchedulePolicy.Parse(template.PolicyJson).ToJson(), + IsActive = true, AddedOn = now, AddedByUserId = userId + }; + var saved = await _schedules.SaveOrUpdateAsync(schedule, cancellationToken); + + foreach (var entry in (template.Entries ?? new List()).Where(e => !e.IsDeleted).OrderBy(e => e.SortOrder)) + { + var copy = new RateScheduleEntry + { + RateScheduleId = saved.RateScheduleId, DepartmentId = departmentId, EntryType = entry.EntryType, Name = entry.Name, Code = entry.Code, GroupKey = entry.GroupKey, CrewSize = entry.CrewSize, + CertificationCode = entry.CertificationCode, UnitTypeId = entry.UnitTypeId, InventoryItemId = entry.InventoryItemId, InventoryCategoryId = entry.InventoryCategoryId, BillingBasis = entry.BillingBasis, + RequiredCertificationsJson = entry.RequiredCertificationsJson, SortOrder = entry.SortOrder, IsActive = entry.IsActive, AddedOn = now, AddedByUserId = userId + }; + var savedEntry = await _entries.SaveOrUpdateAsync(copy, cancellationToken); + foreach (var band in (entry.Bands ?? new List()).OrderBy(b => b.SortOrder)) + await _bands.SaveOrUpdateAsync(CopyBand(band, savedEntry.RateScheduleEntryId), cancellationToken); + } + + foreach (var premium in (template.Premiums ?? new List()).Where(p => !p.IsDeleted)) + await _premiums.SaveOrUpdateAsync(new RatePremium + { + RateScheduleId = saved.RateScheduleId, DepartmentId = departmentId, Name = premium.Name, Code = premium.Code, + StandbyAdder = premium.StandbyAdder, DeploymentAdder = premium.DeploymentAdder, Overtime1Adder = premium.Overtime1Adder, Overtime2Adder = premium.Overtime2Adder, + IsActive = premium.IsActive, AddedOn = now, AddedByUserId = userId + }, cancellationToken); + + var result = await GetScheduleByIdAsync(saved.RateScheduleId, departmentId, includeInactive: true); + Audit(departmentId, userId, AuditLogTypes.RateScheduleCreated, ipAddress, userAgent, null, result); + return result; + } + + private static RateScheduleEntryBand CopyBand(RateScheduleEntryBand band, string entryId) => new RateScheduleEntryBand + { + RateScheduleEntryId = entryId, BandType = band.BandType, Rate = band.Rate, ThresholdStartHours = band.ThresholdStartHours, ThresholdEndHours = band.ThresholdEndHours, + DailyTierMinHours = band.DailyTierMinHours, DailyTierMaxHours = band.DailyTierMaxHours, FreeUnitsPerDay = band.FreeUnitsPerDay, RequiresAirTravel = band.RequiresAirTravel, + MealCode = Trim(band.MealCode), Label = Trim(band.Label), SortOrder = band.SortOrder + }; + + #endregion + + #region Entries, bands, premiums + + public async Task GetEntryByIdAsync(string rateScheduleEntryId, int departmentId) + { + if (string.IsNullOrWhiteSpace(rateScheduleEntryId)) return null; + var entry = await _entries.GetByIdAsync(rateScheduleEntryId); + if (entry == null || entry.IsDeleted || entry.DepartmentId != departmentId) return null; + entry.Bands = (await _bands.GetByEntryAsync(entry.RateScheduleEntryId))?.ToList() ?? new List(); + return entry; + } + + public async Task SaveEntryAsync(RateScheduleEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + var schedule = await _schedules.GetByIdForDepartmentAsync(entry.RateScheduleId, entry.DepartmentId); + if (schedule == null || schedule.IsDeleted) throw new InvalidOperationException("rateschedules_not_found"); + if (string.IsNullOrWhiteSpace(entry.Name)) throw new InvalidOperationException("rateschedules_entry_name_required"); + if (!Enum.IsDefined(typeof(RateEntryTypes), entry.EntryType) || !Enum.IsDefined(typeof(BillingBases), entry.BillingBasis)) throw new InvalidOperationException("rateschedules_entry_invalid"); + if (entry.EntryType == (int)RateEntryTypes.Crew && (!entry.CrewSize.HasValue || entry.CrewSize <= 0)) throw new InvalidOperationException("rateschedules_crew_size_required"); + var bands = (entry.Bands ?? new List()).Where(b => b != null).ToList(); + if (bands.Any(b => !Enum.IsDefined(typeof(RateBandTypes), b.BandType) || b.Rate < 0)) throw new InvalidOperationException("rateschedules_band_invalid"); + if (bands.GroupBy(b => b.BandType).Any(g => g.Count() > 1 && g.Key is not ((int)RateBandTypes.DailyDeployment or (int)RateBandTypes.DailyStandby or (int)RateBandTypes.PerDiemMeal or (int)RateBandTypes.Custom))) + throw new InvalidOperationException("rateschedules_band_duplicate"); + if (!string.IsNullOrWhiteSpace(entry.RequiredCertificationsJson)) { _ = RequiredCertification.Parse(entry.RequiredCertificationsJson); } + + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(entry.RateScheduleEntryId) ? null : await _entries.GetByIdAsync(entry.RateScheduleEntryId); + if (existing != null && (existing.DepartmentId != entry.DepartmentId || existing.IsDeleted)) throw new InvalidOperationException("rateschedules_entry_not_found"); + var before = existing == null ? null : Snapshot(existing); + + var target = existing ?? new RateScheduleEntry { RateScheduleId = schedule.RateScheduleId, DepartmentId = entry.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.EntryType = entry.EntryType; + target.Name = entry.Name.Trim(); + target.Code = Trim(entry.Code); + target.GroupKey = Trim(entry.GroupKey); + target.CrewSize = entry.CrewSize; + target.CertificationCode = Trim(entry.CertificationCode); + target.UnitTypeId = entry.UnitTypeId; + target.InventoryItemId = Trim(entry.InventoryItemId); + target.InventoryCategoryId = Trim(entry.InventoryCategoryId); + target.BillingBasis = entry.BillingBasis; + target.RequiredCertificationsJson = Trim(entry.RequiredCertificationsJson); + target.SortOrder = entry.SortOrder; + target.IsActive = entry.IsActive; + if (existing != null) { target.EditedOn = now; target.EditedByUserId = userId; } + + var saved = await _entries.SaveOrUpdateAsync(target, cancellationToken); + if (existing != null) await _bands.DeleteByEntryAsync(saved.RateScheduleEntryId, cancellationToken); + var order = 0; + foreach (var band in bands.OrderBy(b => b.SortOrder).ThenBy(b => b.BandType)) + { + var copy = CopyBand(band, saved.RateScheduleEntryId); + copy.SortOrder = order++; + await _bands.SaveOrUpdateAsync(copy, cancellationToken); + } + Touch(schedule, userId, now); + await _schedules.SaveOrUpdateAsync(schedule, cancellationToken); + var result = await GetEntryByIdAsync(saved.RateScheduleEntryId, entry.DepartmentId); + Audit(entry.DepartmentId, userId, AuditLogTypes.RateScheduleEntryChanged, ipAddress, userAgent, before, result); + return result; + } + + public async Task DeleteEntryAsync(string rateScheduleEntryId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var existing = await _entries.GetByIdAsync(rateScheduleEntryId); + if (existing == null || existing.IsDeleted || existing.DepartmentId != departmentId) return false; + var before = Snapshot(existing); + existing.IsDeleted = true; + existing.IsActive = false; + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + await _entries.SaveOrUpdateAsync(existing, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.RateScheduleEntryChanged, ipAddress, userAgent, before, existing); + return true; + } + + public async Task SavePremiumAsync(RatePremium premium, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (premium == null) throw new ArgumentNullException(nameof(premium)); + var schedule = await _schedules.GetByIdForDepartmentAsync(premium.RateScheduleId, premium.DepartmentId); + if (schedule == null || schedule.IsDeleted) throw new InvalidOperationException("rateschedules_not_found"); + if (string.IsNullOrWhiteSpace(premium.Name)) throw new InvalidOperationException("rateschedules_premium_name_required"); + if (premium.StandbyAdder < 0 || premium.DeploymentAdder < 0 || premium.Overtime1Adder < 0 || premium.Overtime2Adder < 0) throw new InvalidOperationException("rateschedules_premium_invalid"); + + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(premium.RatePremiumId) ? null : await _premiums.GetByIdAsync(premium.RatePremiumId); + if (existing != null && (existing.DepartmentId != premium.DepartmentId || existing.IsDeleted)) throw new InvalidOperationException("rateschedules_premium_not_found"); + var before = existing == null ? null : Snapshot(existing); + var target = existing ?? new RatePremium { RateScheduleId = schedule.RateScheduleId, DepartmentId = premium.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.Name = premium.Name.Trim(); + target.Code = Trim(premium.Code); + target.StandbyAdder = premium.StandbyAdder; + target.DeploymentAdder = premium.DeploymentAdder; + target.Overtime1Adder = premium.Overtime1Adder; + target.Overtime2Adder = premium.Overtime2Adder; + target.IsActive = premium.IsActive; + if (existing != null) { target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _premiums.SaveOrUpdateAsync(target, cancellationToken); + Touch(schedule, userId, now); + await _schedules.SaveOrUpdateAsync(schedule, cancellationToken); + Audit(premium.DepartmentId, userId, AuditLogTypes.RatePremiumChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task DeletePremiumAsync(string ratePremiumId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var existing = await _premiums.GetByIdAsync(ratePremiumId); + if (existing == null || existing.IsDeleted || existing.DepartmentId != departmentId) return false; + var before = Snapshot(existing); + existing.IsDeleted = true; + existing.IsActive = false; + existing.EditedOn = DateTime.UtcNow; + existing.EditedByUserId = userId; + await _premiums.SaveOrUpdateAsync(existing, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.RatePremiumChanged, ipAddress, userAgent, before, existing); + return true; + } + + #endregion + + #region Resolution, prefill, import/export + + public async Task GetEffectiveScheduleForContactAsync(string contactId, int departmentId, string serviceContractId = null) + { + // Contract → profile → department default; a schedule outside its effective window or inactive is skipped. + var now = DateTime.UtcNow; + async Task Current(string id) + { + var schedule = await GetScheduleByIdAsync(id, departmentId); + return schedule != null && schedule.IsCurrent(now) ? schedule : null; + } + + if (!string.IsNullOrWhiteSpace(serviceContractId)) + { + var contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + var fromContract = contract == null || contract.IsDeleted ? null : await Current(contract.RateScheduleId); + if (fromContract != null) return fromContract; + } + if (!string.IsNullOrWhiteSpace(contactId)) + { + var profile = await _profiles.GetByContactIdAsync(contactId, departmentId); + var fromProfile = profile == null || profile.IsDeleted ? null : await Current(profile.DefaultRateScheduleId); + if (fromProfile != null) return fromProfile; + } + var schedules = await GetSchedulesForDepartmentAsync(departmentId); + var first = schedules.Where(s => s.IsCurrent(now)).OrderBy(s => s.EffectiveOn ?? DateTime.MinValue).ThenBy(s => s.AddedOn).ThenBy(s => s.Name).FirstOrDefault(); + return first == null ? null : await GetScheduleByIdAsync(first.RateScheduleId, departmentId); + } + + public List PrefillHourlyBands(decimal baseRate, decimal? standbyRate, decimal overtime1Multiplier, decimal? overtime1StartHours, decimal? overtime2Multiplier, decimal? overtime2StartHours) + { + if (baseRate < 0) throw new ArgumentOutOfRangeException(nameof(baseRate)); + var list = new List(); + var order = 0; + if (standbyRate.HasValue) list.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Standby, Rate = Money(standbyRate.Value), Label = "Standby", SortOrder = order++ }); + list.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Deployment, Rate = Money(baseRate), Label = "Deployment", ThresholdStartHours = 0, ThresholdEndHours = overtime1StartHours, SortOrder = order++ }); + if (overtime1Multiplier > 0 && overtime1StartHours.HasValue) + list.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Overtime1, Rate = Money(baseRate * overtime1Multiplier), Label = $"Overtime ×{overtime1Multiplier:0.##}", ThresholdStartHours = overtime1StartHours, ThresholdEndHours = overtime2StartHours, SortOrder = order++ }); + if (overtime2Multiplier.HasValue && overtime2Multiplier > 0 && overtime2StartHours.HasValue) + list.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Overtime2, Rate = Money(baseRate * overtime2Multiplier.Value), Label = $"Overtime ×{overtime2Multiplier.Value:0.##}", ThresholdStartHours = overtime2StartHours, SortOrder = order++ }); + return list; + } + + public async Task ExportScheduleJsonAsync(string rateScheduleId, int departmentId) + { + var schedule = await GetScheduleByIdAsync(rateScheduleId, departmentId, includeInactive: true); + if (schedule == null) return null; + var export = new RateScheduleExport + { + Name = schedule.Name, Description = schedule.Description, Currency = schedule.Currency, EffectiveOn = schedule.EffectiveOn, ExpiresOn = schedule.ExpiresOn, Policy = schedule.Policy, + Entries = schedule.Entries.Where(e => !e.IsDeleted).OrderBy(e => e.SortOrder).Select(e => new RateScheduleExport.Entry + { + EntryType = e.EntryType, Name = e.Name, Code = e.Code, GroupKey = e.GroupKey, CrewSize = e.CrewSize, CertificationCode = e.CertificationCode, BillingBasis = e.BillingBasis, + RequiredCertifications = e.RequiredCertifications, SortOrder = e.SortOrder, IsActive = e.IsActive, + Bands = e.Bands.OrderBy(b => b.SortOrder).Select(b => new RateScheduleExport.Band + { + BandType = b.BandType, Rate = b.Rate, ThresholdStartHours = b.ThresholdStartHours, ThresholdEndHours = b.ThresholdEndHours, DailyTierMinHours = b.DailyTierMinHours, DailyTierMaxHours = b.DailyTierMaxHours, + FreeUnitsPerDay = b.FreeUnitsPerDay, RequiresAirTravel = b.RequiresAirTravel, MealCode = b.MealCode, Label = b.Label, SortOrder = b.SortOrder + }).ToList() + }).ToList(), + Premiums = schedule.Premiums.Where(p => !p.IsDeleted).Select(p => new RateScheduleExport.Premium { Name = p.Name, Code = p.Code, StandbyAdder = p.StandbyAdder, DeploymentAdder = p.DeploymentAdder, Overtime1Adder = p.Overtime1Adder, Overtime2Adder = p.Overtime2Adder, IsActive = p.IsActive }).ToList() + }; + return JsonConvert.SerializeObject(export, Formatting.Indented); + } + + public async Task ImportScheduleJsonAsync(int departmentId, string json, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(json)) throw new InvalidOperationException("rateschedules_import_invalid"); + RateScheduleExport export; + try { export = JsonConvert.DeserializeObject(json); } + catch (JsonException) { throw new InvalidOperationException("rateschedules_import_invalid"); } + if (export == null || string.IsNullOrWhiteSpace(export.Name)) throw new InvalidOperationException("rateschedules_import_invalid"); + if (export.Entries?.Any(e => string.IsNullOrWhiteSpace(e.Name) || !Enum.IsDefined(typeof(RateEntryTypes), e.EntryType) || !Enum.IsDefined(typeof(BillingBases), e.BillingBasis) || (e.Bands?.Any(b => !Enum.IsDefined(typeof(RateBandTypes), b.BandType) || b.Rate < 0) ?? false)) ?? false) + throw new InvalidOperationException("rateschedules_import_invalid"); + + // Import is a template, never a cross-department reference: ids, department scoping and inventory links are dropped. + var template = new RateSchedule + { + Name = export.Name, Description = export.Description, Currency = export.Currency, EffectiveOn = export.EffectiveOn, ExpiresOn = export.ExpiresOn, PolicyJson = (export.Policy ?? new RateSchedulePolicy()).ToJson(), + Entries = (export.Entries ?? new List()).Select(e => new RateScheduleEntry + { + EntryType = e.EntryType, Name = e.Name, Code = e.Code, GroupKey = e.GroupKey, CrewSize = e.CrewSize, CertificationCode = e.CertificationCode, BillingBasis = e.BillingBasis, + RequiredCertificationsJson = e.RequiredCertifications == null || e.RequiredCertifications.Count == 0 ? null : JsonConvert.SerializeObject(e.RequiredCertifications), SortOrder = e.SortOrder, IsActive = e.IsActive, + Bands = (e.Bands ?? new List()).Select(b => new RateScheduleEntryBand + { + BandType = b.BandType, Rate = b.Rate, ThresholdStartHours = b.ThresholdStartHours, ThresholdEndHours = b.ThresholdEndHours, DailyTierMinHours = b.DailyTierMinHours, DailyTierMaxHours = b.DailyTierMaxHours, + FreeUnitsPerDay = b.FreeUnitsPerDay, RequiresAirTravel = b.RequiresAirTravel, MealCode = b.MealCode, Label = b.Label, SortOrder = b.SortOrder + }).ToList() + }).ToList(), + Premiums = (export.Premiums ?? new List()).Where(p => !string.IsNullOrWhiteSpace(p.Name)).Select(p => new RatePremium { Name = p.Name, Code = p.Code, StandbyAdder = p.StandbyAdder, DeploymentAdder = p.DeploymentAdder, Overtime1Adder = p.Overtime1Adder, Overtime2Adder = p.Overtime2Adder, IsActive = p.IsActive }).ToList() + }; + return await CreateGraphAsync(departmentId, template, export.Name.Trim(), userId, ipAddress, userAgent, cancellationToken); + } + + /// The portable schedule document (no ids, no department references). + public sealed class RateScheduleExport + { + public int FormatVersion { get; set; } = 1; + public string Name { get; set; } + public string Description { get; set; } + public string Currency { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public RateSchedulePolicy Policy { get; set; } + public List Entries { get; set; } = new List(); + public List Premiums { get; set; } = new List(); + + public sealed class Entry + { + public int EntryType { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string GroupKey { get; set; } + public int? CrewSize { get; set; } + public string CertificationCode { get; set; } + public int BillingBasis { get; set; } + public List RequiredCertifications { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + public List Bands { get; set; } = new List(); + } + + public sealed class Band + { + public int BandType { get; set; } + public decimal Rate { get; set; } + public decimal? ThresholdStartHours { get; set; } + public decimal? ThresholdEndHours { get; set; } + public decimal? DailyTierMinHours { get; set; } + public decimal? DailyTierMaxHours { get; set; } + public decimal? FreeUnitsPerDay { get; set; } + public bool RequiresAirTravel { get; set; } + public string MealCode { get; set; } + public string Label { get; set; } + public int SortOrder { get; set; } + } + + public sealed class Premium + { + public string Name { get; set; } + public string Code { get; set; } + public decimal StandbyAdder { get; set; } + public decimal DeploymentAdder { get; set; } + public decimal Overtime1Adder { get; set; } + public decimal Overtime2Adder { get; set; } + public bool IsActive { get; set; } = true; + } + } + + #endregion + + #region Helpers + + private static void Touch(RateSchedule schedule, string userId, DateTime now) { schedule.EditedOn = now; schedule.EditedByUserId = userId; } + private static decimal Money(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string NormalizeCurrency(string currency) => string.IsNullOrWhiteSpace(currency) || currency.Trim().Length != 3 ? "USD" : currency.Trim().ToUpperInvariant(); + + private static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + if (clone is RateSchedule schedule) { schedule.Entries = null; schedule.Premiums = null; } + return clone.CloneJsonToString(); + } + + 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 : Snapshot(after); + _eventAggregator.SendMessage(audit); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/ServiceContractService.cs b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs new file mode 100644 index 000000000..6147aefb8 --- /dev/null +++ b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs @@ -0,0 +1,468 @@ +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.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Invoicing +{ + /// + /// Service contracts and department compliance documents (Workforce & Business Operations plan, C4; + /// decisions 14, 22, 24). Status changes and the expiry sweep publish through the domain outbox under the + /// producer. Nothing here is under Advanced Data Protection: + /// contracts, submission addresses and compliance documents are customer-facing (they ride the invoice packet) + /// and must read whole for people outside the department. Callers authorize. + /// + public class ServiceContractService : IServiceContractService + { + /// Days before EndOn the sweep starts announcing a contract (once per calendar day). + public const int ExpiryLeadDays = 30; + private static readonly HashSet ExpiringAnnounced = new HashSet(); + + private readonly IServiceContractRepository _contracts; + private readonly IServiceContractDocumentRequirementRepository _requirements; + private readonly IDepartmentComplianceDocumentRepository _documents; + private readonly IDeploymentRepository _deployments; + private readonly IDeploymentAttachmentRepository _attachments; + private readonly ICustomerBillingProfileRepository _profiles; + private readonly IContactsService _contactsService; + private readonly IDepartmentsService _departmentsService; + private readonly IDomainEventOutboxService _outbox; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + private readonly Lazy _communication; + private readonly Lazy _departmentSettings; + + public ServiceContractService(IServiceContractRepository contracts, IServiceContractDocumentRequirementRepository requirements, IDepartmentComplianceDocumentRepository documents, + IDeploymentRepository deployments, IDeploymentAttachmentRepository attachments, ICustomerBillingProfileRepository profiles, IContactsService contactsService, + IDepartmentsService departmentsService, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, IUnitOfWork unitOfWork, + Lazy communication = null, Lazy departmentSettings = null) + { + _contracts = contracts; + _requirements = requirements; + _documents = documents; + _deployments = deployments; + _attachments = attachments; + _profiles = profiles; + _contactsService = contactsService; + _departmentsService = departmentsService; + _outbox = outbox; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + _communication = communication; + _departmentSettings = departmentSettings; + } + + #region Contracts + + public async Task> GetContractsForDepartmentAsync(int departmentId, ServiceContractStatuses? status = null) + { + return (await _contracts.GetForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null))?.ToList() ?? new List(); + } + + public async Task> GetContractsByContactIdAsync(string contactId, int departmentId) + { + if (string.IsNullOrWhiteSpace(contactId)) return new List(); + return (await _contracts.GetByContactIdAsync(departmentId, contactId))?.ToList() ?? new List(); + } + + public async Task GetContractByIdAsync(string serviceContractId, int departmentId) + { + if (string.IsNullOrWhiteSpace(serviceContractId)) return null; + var contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + if (contract == null || contract.IsDeleted) return null; + contract.Requirements = (await _requirements.GetByContractAsync(serviceContractId))?.ToList() ?? new List(); + return contract; + } + + public async Task SaveContractAsync(ServiceContract contract, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (contract == null) throw new ArgumentNullException(nameof(contract)); + if (string.IsNullOrWhiteSpace(contract.Name)) throw new InvalidOperationException("contracts_name_required"); + if (string.IsNullOrWhiteSpace(contract.ContactId)) throw new InvalidOperationException("contracts_contact_required"); + if (contract.EndOn.HasValue && contract.EndOn.Value < contract.StartOn) throw new InvalidOperationException("contracts_dates_invalid"); + if (contract.DiscountPercent.HasValue && (contract.DiscountPercent < 0 || contract.DiscountPercent > 100)) throw new InvalidOperationException("contracts_discount_invalid"); + if (!Enum.IsDefined(typeof(ServiceContractTypes), contract.ContractType)) throw new InvalidOperationException("contracts_type_invalid"); + var contact = await _contactsService.GetContactByIdAsync(contract.ContactId); + if (contact == null || contact.DepartmentId != contract.DepartmentId || contact.IsDeleted) throw new InvalidOperationException("contracts_contact_not_found"); + if (string.IsNullOrWhiteSpace(contract.CustomerBillingProfileId)) + contract.CustomerBillingProfileId = (await _profiles.GetByContactIdAsync(contract.ContactId, contract.DepartmentId))?.CustomerBillingProfileId; + + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(contract.ServiceContractId) ? null : await _contracts.GetByIdForDepartmentAsync(contract.ServiceContractId, contract.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("contracts_not_found"); + var before = existing == null ? null : Snapshot(existing); + + var target = existing ?? new ServiceContract { DepartmentId = contract.DepartmentId, Status = (int)ServiceContractStatuses.Draft, AddedOn = now, AddedByUserId = userId }; + target.ContactId = contract.ContactId; + target.CustomerBillingProfileId = contract.CustomerBillingProfileId; + target.ContractNumber = Trim(contract.ContractNumber); + target.Name = contract.Name.Trim(); + target.ContractType = contract.ContractType; + target.StartOn = contract.StartOn; + target.EndOn = contract.EndOn; + target.RateScheduleId = Trim(contract.RateScheduleId); + target.DiscountPercent = contract.DiscountPercent; + target.TermsNetDays = contract.TermsNetDays; + target.InvoiceSubmissionEmail = Trim(contract.InvoiceSubmissionEmail); + target.MaxDeploymentDays = contract.MaxDeploymentDays; + target.ResponseTimeMinutes = contract.ResponseTimeMinutes; + target.PointOfHire = Trim(contract.PointOfHire); + target.DocumentTemplateKey = Trim(contract.DocumentTemplateKey); + target.Notes = Trim(contract.Notes); + if (existing == null && contract.Status == (int)ServiceContractStatuses.Active) target.Status = (int)ServiceContractStatuses.Active; + if (existing != null) { target.EditedOn = now; target.EditedByUserId = userId; } + + var saved = await _contracts.SaveOrUpdateAsync(target, cancellationToken); + Audit(contract.DepartmentId, userId, existing == null ? AuditLogTypes.ServiceContractCreated : AuditLogTypes.ServiceContractUpdated, ipAddress, userAgent, before, saved); + if (existing == null && saved.Status == (int)ServiceContractStatuses.Active) + await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)ServiceContractStatuses.Draft, cancellationToken); + return await GetContractByIdAsync(saved.ServiceContractId, contract.DepartmentId); + } + + public static bool IsValidTransition(ServiceContractStatuses from, ServiceContractStatuses to) => (from, to) switch + { + (ServiceContractStatuses.Draft, ServiceContractStatuses.Active) => true, + (ServiceContractStatuses.Draft, ServiceContractStatuses.Terminated) => true, + (ServiceContractStatuses.Active, ServiceContractStatuses.Suspended) => true, + (ServiceContractStatuses.Active, ServiceContractStatuses.Expired) => true, + (ServiceContractStatuses.Active, ServiceContractStatuses.Terminated) => true, + (ServiceContractStatuses.Suspended, ServiceContractStatuses.Active) => true, + (ServiceContractStatuses.Suspended, ServiceContractStatuses.Terminated) => true, + (ServiceContractStatuses.Expired, ServiceContractStatuses.Active) => true, + _ => false + }; + + public async Task SetContractStatusAsync(string serviceContractId, int departmentId, ServiceContractStatuses status, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + if (contract == null || contract.IsDeleted) throw new InvalidOperationException("contracts_not_found"); + var from = (ServiceContractStatuses)contract.Status; + if (from == status) return await GetContractByIdAsync(serviceContractId, departmentId); + if (!IsValidTransition(from, status)) throw new InvalidOperationException("contracts_status_transition_invalid"); + if (status == ServiceContractStatuses.Active && contract.EndOn.HasValue && contract.EndOn.Value < DateTime.UtcNow.Date) throw new InvalidOperationException("contracts_ended"); + + var before = Snapshot(contract); + contract.Status = (int)status; + contract.EditedOn = DateTime.UtcNow; + contract.EditedByUserId = userId; + var saved = await _contracts.SaveOrUpdateAsync(contract, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ServiceContractStatusChanged, ipAddress, userAgent, before, saved); + await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)from, cancellationToken); + return await GetContractByIdAsync(serviceContractId, departmentId); + } + + public async Task DeleteContractAsync(string serviceContractId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + if (contract == null || contract.IsDeleted) return false; + if (contract.Status == (int)ServiceContractStatuses.Active) throw new InvalidOperationException("contracts_active"); + var before = Snapshot(contract); + contract.IsDeleted = true; + contract.EditedOn = DateTime.UtcNow; + contract.EditedByUserId = userId; + await _contracts.SaveOrUpdateAsync(contract, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ServiceContractDeleted, ipAddress, userAgent, before, contract); + return true; + } + + public async Task> SaveRequirementsAsync(string serviceContractId, int departmentId, List requirements, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var contract = await _contracts.GetByIdForDepartmentAsync(serviceContractId, departmentId); + if (contract == null || contract.IsDeleted) throw new InvalidOperationException("contracts_not_found"); + var incoming = (requirements ?? new List()).Where(r => r != null && !string.IsNullOrWhiteSpace(r.Name)).ToList(); + if (incoming.Any(r => !Enum.IsDefined(typeof(DocumentRequirementStages), r.Stage) || (r.ComplianceDocumentType.HasValue && !Enum.IsDefined(typeof(ComplianceDocumentTypes), r.ComplianceDocumentType.Value)))) + throw new InvalidOperationException("contracts_requirement_invalid"); + + var before = (await _requirements.GetByContractAsync(serviceContractId))?.ToList().CloneJsonToString(); + await _requirements.DeleteByContractAsync(serviceContractId, cancellationToken); + var order = 0; + var saved = new List(); + foreach (var requirement in incoming.OrderBy(r => r.SortOrder)) + saved.Add(await _requirements.SaveOrUpdateAsync(new ServiceContractDocumentRequirement + { + ServiceContractId = serviceContractId, Name = requirement.Name.Trim(), Stage = requirement.Stage, ComplianceDocumentType = requirement.ComplianceDocumentType, IsMandatory = requirement.IsMandatory, SortOrder = order++ + }, cancellationToken)); + contract.EditedOn = DateTime.UtcNow; + contract.EditedByUserId = userId; + await _contracts.SaveOrUpdateAsync(contract, cancellationToken); + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.ServiceContractUpdated, ipAddress, userAgent); + audit.Before = before; + audit.After = saved.CloneJsonToString(); + _eventAggregator.SendMessage(audit); + return saved; + } + + #endregion + + #region Compliance documents + + public async Task> GetComplianceDocumentsAsync(int departmentId) + { + return (await _documents.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + } + + public async Task GetComplianceDocumentAsync(int departmentComplianceDocumentId, int departmentId, bool includeData) + { + var document = await _documents.GetByIdWithDataAsync(departmentComplianceDocumentId); + if (document == null || document.IsDeleted || document.DepartmentId != departmentId) return null; + if (!includeData) document.Data = null; + return document; + } + + public async Task SaveComplianceDocumentAsync(DepartmentComplianceDocument document, byte[] data, string fileName, string contentType, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + if (string.IsNullOrWhiteSpace(document.Name)) throw new InvalidOperationException("compliance_name_required"); + if (!Enum.IsDefined(typeof(ComplianceDocumentTypes), document.DocumentType)) throw new InvalidOperationException("compliance_type_invalid"); + if (document.EffectiveOn.HasValue && document.ExpiresOn.HasValue && document.ExpiresOn < document.EffectiveOn) throw new InvalidOperationException("compliance_dates_invalid"); + if (data != null && data.Length > DeploymentService.MaxAttachmentBytes) throw new InvalidOperationException("compliance_file_too_large"); + + var now = DateTime.UtcNow; + var existing = document.DepartmentComplianceDocumentId > 0 ? await _documents.GetByIdWithDataAsync(document.DepartmentComplianceDocumentId) : null; + if (existing != null && (existing.IsDeleted || existing.DepartmentId != document.DepartmentId)) throw new InvalidOperationException("compliance_not_found"); + var before = existing == null ? null : Snapshot(existing); + + var target = existing ?? new DepartmentComplianceDocument { DepartmentId = document.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.DocumentType = document.DocumentType; + target.Name = document.Name.Trim(); + target.DocumentNumber = Trim(document.DocumentNumber); + target.Issuer = Trim(document.Issuer); + target.EffectiveOn = document.EffectiveOn; + target.ExpiresOn = document.ExpiresOn; + target.AlertLeadDays = Math.Clamp(document.AlertLeadDays, 0, 365); + if (existing != null) { target.EditedOn = now; target.EditedByUserId = userId; } + + var replacingFile = data != null && data.Length > 0; + if (replacingFile) + { + target.FileName = Trim(fileName); + target.FileType = Trim(contentType); + target.FileSize = data.Length; + target.Data = data; + } + var saved = await _documents.SaveOrUpdateAsync(target, cancellationToken); + Audit(document.DepartmentId, userId, existing == null ? AuditLogTypes.ComplianceDocumentAdded : AuditLogTypes.ComplianceDocumentUpdated, ipAddress, userAgent, before, saved); + return await GetComplianceDocumentAsync(saved.DepartmentComplianceDocumentId, document.DepartmentId, includeData: false); + } + + public async Task DeleteComplianceDocumentAsync(int departmentComplianceDocumentId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var document = await _documents.GetByIdWithDataAsync(departmentComplianceDocumentId); + if (document == null || document.IsDeleted || document.DepartmentId != departmentId) return false; + var before = Snapshot(document); + document.IsDeleted = true; + document.EditedOn = DateTime.UtcNow; + document.EditedByUserId = userId; + await _documents.SaveOrUpdateAsync(document, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ComplianceDocumentRemoved, ipAddress, userAgent, before, document); + return true; + } + + #endregion + + #region Compliance evaluation + + public async Task GetContractComplianceAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null || deployment.IsDeleted) return null; + var result = new ContractComplianceResult { DeploymentId = deploymentId, ServiceContractId = deployment.ServiceContractId }; + if (string.IsNullOrWhiteSpace(deployment.ServiceContractId)) return result; + var attachments = (await _attachments.GetByDeploymentAsync(deploymentId))?.ToList() ?? new List(); + await EvaluateAsync(result, deployment.ServiceContractId, departmentId, attachments, null); + return result; + } + + public async Task GetContractComplianceForContractAsync(string serviceContractId, int departmentId) + { + var result = new ContractComplianceResult { ServiceContractId = serviceContractId }; + await EvaluateAsync(result, serviceContractId, departmentId, new List(), null); + return result; + } + + private async Task EvaluateAsync(ContractComplianceResult result, string serviceContractId, int departmentId, List attachments, DateTime? asOf) + { + var requirements = (await _requirements.GetByContractAsync(serviceContractId))?.OrderBy(r => r.SortOrder).ToList() ?? new List(); + var documents = (await _documents.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + var now = asOf ?? DateTime.UtcNow; + foreach (var requirement in requirements) + { + var item = new ContractComplianceItem + { + ServiceContractDocumentRequirementId = requirement.ServiceContractDocumentRequirementId, Name = requirement.Name, Stage = requirement.Stage, + ComplianceDocumentType = requirement.ComplianceDocumentType, IsMandatory = requirement.IsMandatory + }; + // A current department document of the requirement's type satisfies it; otherwise a deployment attachment whose + // name contains the requirement name does (signed service requests, manifests, DTR PDFs). + var document = requirement.ComplianceDocumentType.HasValue + ? documents.Where(d => d.DocumentType == requirement.ComplianceDocumentType.Value && d.IsCurrent(now)).OrderByDescending(d => d.ExpiresOn ?? DateTime.MaxValue).FirstOrDefault() + : null; + if (document != null) + { + item.Satisfied = true; + item.SatisfiedBy = document.Name; + item.DepartmentComplianceDocumentId = document.DepartmentComplianceDocumentId; + item.ExpiresOn = document.ExpiresOn; + } + else + { + var attachment = attachments.FirstOrDefault(a => !a.IsDeleted && MatchesRequirement(a, requirement)); + if (attachment != null) + { + item.Satisfied = true; + item.SatisfiedBy = attachment.Name; + item.DeploymentAttachmentId = attachment.DeploymentAttachmentId; + } + } + result.Items.Add(item); + } + } + + private static bool MatchesRequirement(DeploymentAttachment attachment, ServiceContractDocumentRequirement requirement) + { + if (requirement.Stage == (int)DocumentRequirementStages.DeploymentStart && attachment.AttachmentType == (int)DeploymentAttachmentTypes.SignedServiceRequest) return true; + if (requirement.Stage == (int)DocumentRequirementStages.DailyTimeReport && attachment.AttachmentType == (int)DeploymentAttachmentTypes.TimeReportPdf) return true; + if (requirement.Stage == (int)DocumentRequirementStages.InvoiceSubmission && attachment.AttachmentType == (int)DeploymentAttachmentTypes.Manifest && requirement.Name.IndexOf("manifest", StringComparison.OrdinalIgnoreCase) >= 0) return true; + var name = attachment.Name; + return !string.IsNullOrWhiteSpace(name) && name.IndexOf(requirement.Name, StringComparison.OrdinalIgnoreCase) >= 0; + } + + #endregion + + #region Expiry sweep + + public async Task RunExpirySweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + var touched = 0; + var enabledCache = new Dictionary(); + async Task Enabled(int departmentId) + { + if (enabledCache.TryGetValue(departmentId, out var cached)) return cached; + var enabled = departmentEnabled == null || await departmentEnabled(departmentId); + enabledCache[departmentId] = enabled; + return enabled; + } + + // Lapsed: Active with EndOn in the past → Expired (one status event each). + foreach (var contract in (await _contracts.GetLapsedAsync(asOfUtc))?.ToList() ?? new List()) + { + if (!await Enabled(contract.DepartmentId)) continue; + try + { + var before = Snapshot(contract); + contract.Status = (int)ServiceContractStatuses.Expired; + contract.EditedOn = asOfUtc; + var saved = await _contracts.SaveOrUpdateAsync(contract, cancellationToken); + Audit(contract.DepartmentId, null, AuditLogTypes.ServiceContractStatusChanged, null, null, before, saved); + await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)ServiceContractStatuses.Active, cancellationToken); + touched++; + } + catch (Exception ex) { Logging.LogException(ex, $"Contract {contract.ServiceContractId} could not be expired."); } + } + + // Expiring: Active with EndOn inside the lead window → ContractExpiring once per day per contract. + var dayKey = asOfUtc.Date.GetHashCode(); + foreach (var contract in (await _contracts.GetEndingBetweenAsync(asOfUtc, asOfUtc.AddDays(ExpiryLeadDays)))?.ToList() ?? new List()) + { + if (!await Enabled(contract.DepartmentId)) continue; + var key = HashCode.Combine(dayKey, contract.ServiceContractId); + lock (ExpiringAnnounced) { if (!ExpiringAnnounced.Add(key)) continue; } + await PublishAsync(contract, WorkflowTriggerEventType.ContractExpiring, null, cancellationToken, daysUntilEnd: contract.EndOn.HasValue ? (int)Math.Ceiling((contract.EndOn.Value - asOfUtc).TotalDays) : (int?)null); + touched++; + } + + // Compliance documents inside their own lead window (or lapsed) → one admin notification per document per day. + var expiring = (await _documents.GetExpiringAsync(asOfUtc))?.ToList() ?? new List(); + foreach (var group in expiring.GroupBy(d => d.DepartmentId)) + { + if (!await Enabled(group.Key)) continue; + var lines = new List(); + foreach (var document in group) + { + var key = HashCode.Combine(dayKey, "doc", document.DepartmentComplianceDocumentId); + lock (ExpiringAnnounced) { if (!ExpiringAnnounced.Add(key)) continue; } + var days = document.ExpiresOn.HasValue ? (int)Math.Ceiling((document.ExpiresOn.Value.Date - asOfUtc.Date).TotalDays) : 0; + lines.Add(days < 0 ? $"{document.Name} expired on {document.ExpiresOn:yyyy-MM-dd}." : $"{document.Name} expires in {days} day{(days == 1 ? "" : "s")} ({document.ExpiresOn:yyyy-MM-dd})."); + } + if (lines.Count > 0) await NotifyAdminsAsync(group.Key, "Compliance documents: " + string.Join(" ", lines)); + } + lock (ExpiringAnnounced) { if (ExpiringAnnounced.Count > 50_000) ExpiringAnnounced.Clear(); } + return touched; + } + + private async Task NotifyAdminsAsync(int departmentId, string message) + { + if (_communication?.Value == null) return; + try + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); + foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Compliance documents"); + } + catch (Exception ex) { Logging.LogException(ex, $"Compliance document notification for department {departmentId} failed."); } + } + + #endregion + + #region Helpers + + private async Task PublishAsync(ServiceContract contract, WorkflowTriggerEventType trigger, int? oldStatus, CancellationToken cancellationToken, int? daysUntilEnd = null) + { + try + { + string contactName = null; + try { var contact = await _contactsService.GetContactByIdAsync(contract.ContactId); contactName = contact?.Name; } catch (Exception ex) { Logging.LogException(ex, "Contract event: contact name lookup failed."); } + await _outbox.EnqueueAsync(contract.DepartmentId, ContractorWorkflowPayload.Producer, new DomainEventEnvelope + { + EventName = trigger.ToString(), + AggregateType = "ServiceContract", + AggregateId = contract.ServiceContractId, + AggregateVersion = 0, + Trigger = trigger, + OccurredOn = DateTime.UtcNow, + CorrelationId = contract.ServiceContractId, + Payload = new + { + contract.ServiceContractId, contract.ContractNumber, contract.Name, contract.Status, OldStatus = oldStatus, contract.ContactId, + ContactName = ProtectedDataEnvelope.SafeDisplay(contactName), + contract.ContractType, contract.StartOn, contract.EndOn, DaysUntilEnd = daysUntilEnd, contract.RateScheduleId + } + }, cancellationToken); + } + catch (Exception ex) { Logging.LogException(ex, $"Contract {contract.ServiceContractId} {trigger} could not be published."); } + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + internal static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + switch (clone) + { + case ServiceContract contract: contract.Requirements = null; break; + case DepartmentComplianceDocument document: document.Data = null; break; + } + return clone.CloneJsonToString(); + } + + 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 : Snapshot(after); + _eventAggregator.SendMessage(audit); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs index 04c80eaae..0679a9ab8 100644 --- a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs +++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs @@ -82,30 +82,35 @@ public async Task> GetTimeReportsAsync(string deploym 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 GetPersonnelHoursAsync(string deploymentId, int departmentId) + { + // Two reads for the whole deployment (the CSV export's shape) instead of a report + entries pair per report. + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId, departmentId); + if (deployment == null) return 0m; + var counted = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.DepartmentId == departmentId && !r.IsDeleted && r.Status != (int)DeploymentTimeReportStatuses.Void) + .Select(r => r.DeploymentTimeReportId).ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = await _entries.GetByDeploymentAsync(deploymentId); + return entries?.Where(e => e.DepartmentId == departmentId && e.DeploymentTimeReportId != null && counted.Contains(e.DeploymentTimeReportId) && e.SubjectType == (int)DeploymentTimeSubjectTypes.Personnel).Sum(e => e.Hours) ?? 0m; + } + 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 @@ -161,6 +166,7 @@ await _entries.SaveOrUpdateAsync(new DeploymentTimeEntry } Audit(departmentId, userId, AuditLogTypes.TimeReportCreated, ipAddress, userAgent, null, saved); + if (Core != null) await Core.PublishTimeReportAsync(deployment, WorkflowTriggerEventType.TimeReportCreated, saved, cancellationToken); return await GetTimeReportByIdAsync(saved.DeploymentTimeReportId, departmentId); }, cancellationToken); } @@ -222,11 +228,20 @@ public async Task SaveTimeEntriesAsync(string deploymentTi var before = DeploymentService.Snapshot(report); await TransactionAsync(async () => { - await _entries.DeleteByReportAsync(deploymentTimeReportId, cancellationToken); + // Entries keep their ids across a save: the catalog-28 envelope on an entry's notes is bound to the entry's row key, + // so an untouched entry (REDACTED posted back) is updated in place and a stale one deleted, never re-inserted. + var current = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToDictionary(e => e.DeploymentTimeEntryId, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var kept = new HashSet(incoming.Where(e => !string.IsNullOrWhiteSpace(e.DeploymentTimeEntryId) && current.ContainsKey(e.DeploymentTimeEntryId)).Select(e => e.DeploymentTimeEntryId), StringComparer.OrdinalIgnoreCase); + foreach (var stale in current.Values.Where(e => !kept.Contains(e.DeploymentTimeEntryId))) + await _entries.DeleteAsync(stale, cancellationToken); var sort = 0; foreach (var entry in incoming.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime)) { - entry.DeploymentTimeEntryId = null; + var existingEntry = !string.IsNullOrWhiteSpace(entry.DeploymentTimeEntryId) && current.TryGetValue(entry.DeploymentTimeEntryId, out var found) ? found : null; + if (existingEntry == null) + { + entry.DeploymentTimeEntryId = null; + } entry.DeploymentTimeReportId = deploymentTimeReportId; entry.DeploymentId = report.DeploymentId; entry.DepartmentId = departmentId; @@ -324,6 +339,26 @@ public async Task ApproveTimeReportAsync(string deployment return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); } + public async Task MarkTimeReportsBilledAsync(IEnumerable deploymentTimeReportIds, int departmentId, string invoiceId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(invoiceId)) throw new ArgumentNullException(nameof(invoiceId)); + var count = 0; + foreach (var id in (deploymentTimeReportIds ?? Enumerable.Empty()).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct()) + { + var report = await _reports.GetByIdForDepartmentAsync(id, departmentId); + if (report == null || report.IsDeleted || report.Status != (int)DeploymentTimeReportStatuses.Approved || !string.IsNullOrWhiteSpace(report.InvoiceId)) continue; + var before = DeploymentService.Snapshot(report); + report.Status = (int)DeploymentTimeReportStatuses.Billed; + report.InvoiceId = invoiceId; + report.EditedOn = DateTime.UtcNow; + report.EditedByUserId = userId; + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.TimeReportBilled, ipAddress, userAgent, before, saved); + count++; + } + return count; + } + 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); @@ -333,11 +368,14 @@ public async Task VoidTimeReportAsync(string deploymentTim 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()}"); + if (!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); + var deployment = await _deployments.GetByIdForDepartmentAsync(report.DeploymentId, departmentId); + if (Core != null && deployment != null) await Core.PublishTimeReportAsync(deployment, WorkflowTriggerEventType.TimeReportVoided, saved, cancellationToken); return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); } @@ -346,16 +384,13 @@ public async Task SignTimeReportAsync(string deploymentTim 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); + var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); Audit(departmentId, userId, AuditLogTypes.TimeReportUpdated, ipAddress, userAgent, before, saved); return await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); } @@ -431,7 +466,7 @@ public static string RenderTimeReportHtml(DeploymentTimeReport report, Deploymen 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("
"); + .Append("Customer: ").Append(E(report.CustomerSignerName)).Append(report.CustomerSignedOn.HasValue ? " · " + D(report.CustomerSignedOn) : string.Empty).Append(""); sb.Append("

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

"); return sb.ToString(); } @@ -446,6 +481,11 @@ public async Task ExportTimeEntriesCsvAsync(string deploymentId, int dep 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() }; + // A leading =, +, -, @, tab or CR makes Excel/Sheets evaluate the cell as a formula on import (the + // RecordsExportRenderer guard). Only free text can carry one: numbers and dates are formatted above, so a + // negative deduction stays numeric. + if (value is string && (text.TrimStart().FirstOrDefault() is '=' or '+' or '-' or '@' || text.StartsWith('\t') || text.StartsWith('\r'))) + text = "'" + text; return text.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0 ? "\"" + text.Replace("\"", "\"\"") + "\"" : text; } var sb = new StringBuilder(); @@ -473,7 +513,6 @@ public async Task> GetExpensesAsync(string deploymentId, 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; } @@ -481,7 +520,6 @@ public async Task GetExpenseByIdAsync(string deploymentExpens { 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; } @@ -504,6 +542,9 @@ public async Task SaveExpenseAsync(DeploymentExpense expense, { existing = await _expenses.GetByIdForDepartmentAsync(expense.DeploymentExpenseId, expense.DepartmentId); if (existing == null || existing.IsDeleted) throw new InvalidOperationException("expenses_not_found"); + // The callers authorize the submitted deployment; a row that belongs to another deployment is not found from + // there, so an expense can neither be rewritten nor re-linked across deployments. + if (!string.Equals(existing.DeploymentId, expense.DeploymentId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("expenses_not_found"); expense.ReceiptAttachmentId ??= existing.ReceiptAttachmentId; expense.AddedOn = existing.AddedOn; expense.AddedByUserId = existing.AddedByUserId; @@ -536,9 +577,7 @@ public async Task SaveExpenseAsync(DeploymentExpense expense, } 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); + var saved = await _expenses.SaveOrUpdateAsync(expense, 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); diff --git a/Core/Resgrid.Services/PersonnelRolesService.cs b/Core/Resgrid.Services/PersonnelRolesService.cs index b320f7c76..b34ba511c 100644 --- a/Core/Resgrid.Services/PersonnelRolesService.cs +++ b/Core/Resgrid.Services/PersonnelRolesService.cs @@ -127,11 +127,12 @@ public async Task> GetAllRolesForDepartmentAsync(int departm var incoming = role?.Users?.Where(u => u != null && !string.IsNullOrWhiteSpace(u.UserId)).Select(u => u.UserId).Distinct().ToList() ?? new List(); var previous = role != null && role.PersonnelRoleId > 0 ? (await _personnelRoleUsersRepository.GetAllMembersOfRoleAsync(role.PersonnelRoleId))?.Select(m => m.UserId).ToHashSet() ?? new HashSet() : new HashSet(); var added = incoming.Where(u => !previous.Contains(u)).ToList(); + await EnsureDepartmentMembersAsync(role.DepartmentId, added); 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"); + throw new RoleMembershipException(RoleMembershipException.RequirementsUnmet, userId); } var saved = await _personnelRolesRepository.SaveOrUpdateAsync(role, cancellationToken); @@ -153,12 +154,14 @@ public async Task> GetAllRolesForDepartmentAsync(int departm 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. + // grace period does not block a rename, and nothing is removed for a save that will be refused. The department + // check comes first: the caller authorizes the role, not the user ids it posts. + await EnsureDepartmentMembersAsync(role.DepartmentId, added); 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"); + throw new RoleMembershipException(RoleMembershipException.RequirementsUnmet, userId); } // Delete-then-cascade under one transaction: the repository cascades the Users collection on the role save, @@ -189,6 +192,22 @@ public async Task> GetAllRolesForDepartmentAsync(int departm return saved; } + /// + /// Every member a role gains must belong to the role's department: the role is what the caller is authorized + /// for, the user ids are posted values. One members read; a stranger is refused before anything is written. + /// + private async Task EnsureDepartmentMembersAsync(int departmentId, IReadOnlyCollection userIds) + { + if (userIds == null || userIds.Count == 0) + return; + + var members = (await _departmentMemberRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId))?.Where(m => m != null && !string.IsNullOrWhiteSpace(m.UserId)).Select(m => m.UserId).ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + var stranger = userIds.FirstOrDefault(u => !members.Contains(u)); + if (stranger != null) + throw new RoleMembershipException(RoleMembershipException.NotInDepartment, stranger); + } + public async Task GetRoleByDepartmentAndNameAsync(int departmentId, string name) { return await _personnelRolesRepository.GetRoleByDepartmentAndNameAsync(departmentId, name.Trim()); @@ -299,7 +318,7 @@ where users.Select(x => x.UserId).Contains(rolesGroup.Key) // change leaves the existing membership exactly as it was. var gaining = wanted.Where(r => !current.Contains(r.PersonnelRoleId)).Select(r => r.PersonnelRoleId).ToList(); if (gaining.Count > 0 && (await CheckRoleMembershipAsync(departmentId, userId, gaining)).IsBlocked) - throw new InvalidOperationException("certifications_role_requirements_unmet"); + throw new RoleMembershipException(RoleMembershipException.RequirementsUnmet, userId); await RemoveUserFromAllRolesAsync(userId, departmentId, cancellationToken); diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 3ca057cbb..547114a2b 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -711,14 +711,7 @@ void Prevention(string table, string column, ProtectedFieldClassification classi "InventoryVendors" or "InventoryPurchaseOrders" or "InventoryPurchaseOrderItems" => Resgrid.Model.Inventories.InventoryTables.PurchasingCatalogVersion, _ => Resgrid.Model.Inventories.InventoryTables.CatalogVersion })); - // Workforce & Business Operations plan, Phase B (catalog 26, registered with M0212): customer-contact and - // payment-reference text on the invoicing tables, under the Contacts family. Amounts, statuses, numbers and - // dates stay metadata; DepartmentPaymentConnections are system credentials and are never cataloged. - foreach (var (table, column) in Resgrid.Model.Invoicing.InvoicingProtectedFields.All()) - list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ContactsFamily, table, column, ProtectedFieldStorageKind.Text, - ProtectedFieldClassification.Pii, PermissionTypes.ViewProtectedContactData, PermissionTypes.ViewProtectedContactData, Resgrid.Model.Invoicing.InvoicingProtectedFields.CatalogVersion)); - - // Workforce & Business Operations plan, Phase D (catalog 27, registered with M0213/M0214): the free-text and + // Workforce & Business Operations plan, Phase D (catalog 26, registered with M0213/M0214): the free-text and // document columns of unit certification records (Operational family, beside UnitLogs) and of certification // credit entries (Personnel family). PersonnelCertifications itself stays catalog 6; statuses, dates and the // type reference are routing metadata. @@ -731,15 +724,24 @@ void Prevention(string table, string column, ProtectedFieldClassification classi personnel ? PermissionTypes.ViewProtectedPersonnelData : PermissionTypes.EditProtectedCallData, Resgrid.Model.Certifications.CertificationProtectedFields.CatalogVersion)); } + // Completion pass (catalog 27): the free-text status reason on both certification record tables. + foreach (var (table, column, family) in Resgrid.Model.Certifications.CertificationProtectedFields.Completion()) + { + var personnel = family == Resgrid.Model.Certifications.CertificationProtectedFields.PersonnelFamily; + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", personnel ? PersonnelFamily : OperationalFamily, table, column, + ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, + personnel ? PermissionTypes.ViewProtectedPersonnelData : PermissionTypes.ViewProtectedOperationalData, + personnel ? PermissionTypes.ViewProtectedPersonnelData : PermissionTypes.EditProtectedCallData, + Resgrid.Model.Certifications.CertificationProtectedFields.CompletionCatalogVersion)); + } - // 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. + // Workforce & Business Operations plan, Phase C (catalog 27): the deployment wrapper's internal notes (Contacts + // family). Everything a customer receives — invoices, bids, contracts, daily time reports, receipts, manifests, + // compliance documents, the billing identity — is deliberately NOT cataloged: customers who are not signed in + // read those documents (PDF, pay page, invoice packet), so they must render whole without a grant. 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, + binary ? ProtectedFieldStorageKind.Binary : ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedContactData, PermissionTypes.ViewProtectedContactData, Resgrid.Model.Invoicing.DeploymentProtectedFields.CatalogVersion)); return list; } diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 63678ade2..152ba2a5a 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -109,7 +109,9 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic ["personnelcertifications.type"] = (c => c.Type, (c, v) => c.Type = v), ["personnelcertifications.area"] = (c => c.Area, (c, v) => c.Area = v), ["personnelcertifications.issuedby"] = (c => c.IssuedBy, (c, v) => c.IssuedBy = v), - ["personnelcertifications.filename"] = (c => c.Filename, (c, v) => c.Filename = v) + ["personnelcertifications.filename"] = (c => c.Filename, (c, v) => c.Filename = v), + // Catalog 27 (Workforce & Business Operations completion pass): the free-text reason behind a suspension / revocation. + ["personnelcertifications.statusreason"] = (c => c.StatusReason, (c, v) => c.StatusReason = v) }; /// The rgdpb binary certification document field id. diff --git a/Core/Resgrid.Services/Search/SystemActionCatalog.cs b/Core/Resgrid.Services/Search/SystemActionCatalog.cs index 42f8cf1c9..39b563641 100644 --- a/Core/Resgrid.Services/Search/SystemActionCatalog.cs +++ b/Core/Resgrid.Services/Search/SystemActionCatalog.cs @@ -35,6 +35,8 @@ public static class SystemActionCatalog private const string Invoicing = "Invoicing"; private const string Certifications = "Certifications"; private const string Deployments = "Deployments"; + private const string Bids = "Bids"; + private const string ServiceContracts = "ServiceContracts"; private const string Group = "Group"; private const string Protocols = "Protocols"; private const string Forms = "Forms"; @@ -158,6 +160,13 @@ private static SystemActionDefinition Act(string key, string title, string descr 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), + // Workforce & Business Operations plan, Phase C-M2 (contractor path; Business Ops add-on behind Invoicing.ContractorBilling). + Nav("bids", "Bids", "Priced estimates for customer contacts and contracts", "/User/Bids", new[] { "bid", "bids", "quote", "estimate", "proposal", "tender" }, Bids, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), + Act("new-bid", "New Bid", "Draft a bid for a customer", "/User/Bids/New", SystemActionCategories.Create, new[] { "bid", "quote", "estimate" }, Bids, Create, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), + Nav("contracts", "Contracts", "Service contracts, document requirements and compliance", "/User/Contracts", new[] { "contract", "contracts", "agreement", "standing arrangement", "master services" }, ServiceContracts, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), + Act("new-contract", "New Contract", "Create a service contract for a customer", "/User/Contracts/New", SystemActionCategories.Create, new[] { "contract", "agreement" }, ServiceContracts, Update, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), + Nav("compliance-documents", "Compliance Documents", "Insurance, workers' comp, SAM, licences and bonds with expiry alerts", "/User/Contracts/Compliance", new[] { "compliance", "insurance", "workers comp", "sam", "cage", "bond", "licence", "license" }, ServiceContracts, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), + Nav("rate-schedules", "Rate Schedules", "Contractor rate tables: certifications, crews, vehicles, equipment, premiums and policies", "/User/RateSchedules", new[] { "rate schedule", "rates", "crew rate", "overtime", "premium", "per diem", "mileage" }, Invoicing, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.ContractorBilling), // ---- Messaging / chat Nav("inbox", "Inbox", "Your messages inbox", "/User/Messages/Inbox", new[] { "messages", "mail", "read" }, Messages, View, SystemActionModules.Messaging), diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 1319477bd..c056e23cc 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -44,6 +44,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M2: the contractor path. + 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 4c260a4fa..8ede04156 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -99,13 +99,37 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.DeploymentExpenseAdded: case WorkflowTriggerEventType.TimeReportSubmitted: case WorkflowTriggerEventType.TimeReportApproved: + case WorkflowTriggerEventType.TimeReportCreated: + case WorkflowTriggerEventType.TimeReportVoided: + case WorkflowTriggerEventType.DeploymentAttachmentAdded: 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"; } + if (eventType is WorkflowTriggerEventType.TimeReportSubmitted or WorkflowTriggerEventType.TimeReportApproved or WorkflowTriggerEventType.TimeReportCreated or WorkflowTriggerEventType.TimeReportVoided) { sampleDeployment["report_number"] = 57; sampleDeployment["report_date"] = "2026-09-18T00:00:00Z"; sampleDeployment["report_id"] = "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"; sampleDeployment["report_status"] = eventType == WorkflowTriggerEventType.TimeReportCreated ? 0 : eventType == WorkflowTriggerEventType.TimeReportSubmitted ? 1 : eventType == WorkflowTriggerEventType.TimeReportApproved ? 2 : 4; } + if (eventType == WorkflowTriggerEventType.DeploymentAttachmentAdded) { sampleDeployment["attachment_id"] = 312; sampleDeployment["attachment_type"] = 1; sampleDeployment["attachment_name"] = "Signed service request"; } + break; + case WorkflowTriggerEventType.BidCreated: + case WorkflowTriggerEventType.BidSent: + case WorkflowTriggerEventType.BidAccepted: + case WorkflowTriggerEventType.BidDeclined: + case WorkflowTriggerEventType.BidExpired: + obj["bid"] = new ScriptObject { ["id"] = "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", ["number"] = 12, ["title"] = "Type 6 engine, Ridge Fire", ["status"] = eventType == WorkflowTriggerEventType.BidCreated ? 0 : eventType == WorkflowTriggerEventType.BidSent ? 1 : eventType == WorkflowTriggerEventType.BidAccepted ? 2 : eventType == WorkflowTriggerEventType.BidDeclined ? 3 : 4, ["contact_id"] = "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", ["contact_name"] = "Province Wildfire Service", ["incident_number"] = "CA-BTU-012345", ["valid_until"] = "2026-10-18T00:00:00Z", ["estimated_total"] = 18450.00m, ["currency"] = "USD", ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Bids/View/7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d" }; + var sampleBid = (ScriptObject)obj["bid"]; + foreach (var variable in Resgrid.Model.Invoicing.ContractorWorkflowPayload.BidVariables) if (!sampleBid.ContainsKey(variable.Variable)) sampleBid[variable.Variable] = null; + if (eventType != WorkflowTriggerEventType.BidCreated) { sampleBid["old_status"] = eventType == WorkflowTriggerEventType.BidSent ? 0 : 1; sampleBid["sent_on"] = "2026-09-18T15:00:00Z"; } + if (eventType == WorkflowTriggerEventType.BidAccepted) sampleBid["accepted_on"] = "2026-09-19T09:30:00Z"; + if (eventType == WorkflowTriggerEventType.BidDeclined) sampleBid["declined_on"] = "2026-09-19T09:30:00Z"; + break; + case WorkflowTriggerEventType.ContractStatusChanged: + case WorkflowTriggerEventType.ContractExpiring: + obj["contract"] = new ScriptObject { ["id"] = "9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a", ["number"] = "WFS-2026-0417", ["name"] = "2026 wildfire standing arrangement", ["status"] = 1, ["contact_id"] = "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", ["contact_name"] = "Province Wildfire Service", ["contract_type"] = 0, ["start_on"] = "2026-04-01T00:00:00Z", ["end_on"] = "2026-10-31T00:00:00Z", ["url"] = $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Contracts/View/9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a" }; + var sampleContract = (ScriptObject)obj["contract"]; + foreach (var variable in Resgrid.Model.Invoicing.ContractorWorkflowPayload.ContractVariables) if (!sampleContract.ContainsKey(variable.Variable)) sampleContract[variable.Variable] = null; + if (eventType == WorkflowTriggerEventType.ContractStatusChanged) sampleContract["old_status"] = 0; + if (eventType == WorkflowTriggerEventType.ContractExpiring) sampleContract["days_until_end"] = 21; break; case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: @@ -274,6 +298,8 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve break; case WorkflowTriggerEventType.CertificationExpiring: + case WorkflowTriggerEventType.CertificationRemoved: + case WorkflowTriggerEventType.CertificationCreditAdded: case WorkflowTriggerEventType.CertificationAdded: case WorkflowTriggerEventType.CertificationRenewed: case WorkflowTriggerEventType.CertificationExpired: @@ -300,7 +326,19 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve cert["new_status"] = 2; cert["reason"] = "Pending investigation"; } + if (eventType == WorkflowTriggerEventType.CertificationRemoved) + cert["removed_by_user_id"] = "sample-admin-id"; obj["certification"] = cert; + if (eventType == WorkflowTriggerEventType.CertificationCreditAdded) + { + var credit = new ScriptObject(); + credit["id"] = 7101; + credit["date"] = DateTime.Today; + credit["hours"] = 4.0m; + credit["category"] = "Continuing education"; + credit["added_by_user_id"] = "sample-admin-id"; + obj["credit"] = credit; + } break; case WorkflowTriggerEventType.CertificationRoleRemoved: @@ -315,6 +353,9 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve obj["removal"] = removal; break; + case WorkflowTriggerEventType.UnitCertificationAdded: + case WorkflowTriggerEventType.UnitCertificationStatusChanged: + case WorkflowTriggerEventType.UnitCertificationRemoved: case WorkflowTriggerEventType.UnitCertificationExpiring: case WorkflowTriggerEventType.UnitCertificationExpired: var unitCert = new ScriptObject(); @@ -328,6 +369,8 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve unitCert["expires_on"] = eventType == WorkflowTriggerEventType.UnitCertificationExpired ? DateTime.Today.AddDays(-1) : DateTime.Today.AddDays(14); unitCert["days_until_expiry"] = eventType == WorkflowTriggerEventType.UnitCertificationExpired ? -1 : 14; unitCert["status"] = eventType == WorkflowTriggerEventType.UnitCertificationExpired ? 1 : 0; + if (eventType == WorkflowTriggerEventType.UnitCertificationStatusChanged) { unitCert["old_status"] = 0; unitCert["new_status"] = 2; unitCert["reason"] = "Out of service pending repair"; } + if (eventType == WorkflowTriggerEventType.UnitCertificationRemoved) unitCert["removed_by_user_id"] = "sample-admin-id"; obj["unit_certification"] = unitCert; break; diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index ebcd42e52..423b99af4 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -327,11 +327,69 @@ public async Task BuildContextAsync( var c = (ScriptObject)scriptObject["certification"]; c["old_status"] = evt.OldStatus; c["new_status"] = evt.NewStatus; - c["reason"] = evt.Reason ?? string.Empty; + c["reason"] = ProtectedDataEnvelope.SafeDisplay(evt.Reason) ?? string.Empty; triggeringUserId = evt.Certification.UserId; } break; } + case WorkflowTriggerEventType.CertificationRemoved: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt?.Certification != null) + { + MapCertificationVariables(scriptObject, evt.Certification, DaysUntil(evt.Certification.ExpiresOn), evt.TypeCode, evt.TypeName); + ((ScriptObject)scriptObject["certification"])["removed_by_user_id"] = evt.RemovedByUserId ?? string.Empty; + triggeringUserId = evt.Certification.UserId; + } + break; + } + case WorkflowTriggerEventType.CertificationCreditAdded: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt?.Certification != null) + { + MapCertificationVariables(scriptObject, evt.Certification, DaysUntil(evt.Certification.ExpiresOn), evt.TypeCode, evt.TypeName); + var credit = new ScriptObject(); + credit["id"] = evt.PersonnelCertificationCreditId; + credit["date"] = evt.CreditDate; + credit["hours"] = evt.Hours; + credit["category"] = evt.Category; + credit["added_by_user_id"] = evt.AddedByUserId ?? string.Empty; + scriptObject["credit"] = credit; + triggeringUserId = evt.Certification.UserId; + } + break; + } + case WorkflowTriggerEventType.UnitCertificationAdded: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt?.Certification != null) + MapUnitCertificationVariables(scriptObject, evt.Certification, evt.UnitName, evt.TypeCode, evt.TypeName, DaysUntil(evt.Certification.ExpiresOn)); + break; + } + case WorkflowTriggerEventType.UnitCertificationStatusChanged: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt?.Certification != null) + { + MapUnitCertificationVariables(scriptObject, evt.Certification, evt.UnitName, evt.TypeCode, evt.TypeName, DaysUntil(evt.Certification.ExpiresOn)); + var u = (ScriptObject)scriptObject["unit_certification"]; + u["old_status"] = evt.OldStatus; + u["new_status"] = evt.NewStatus; + u["reason"] = ProtectedDataEnvelope.SafeDisplay(evt.Reason) ?? string.Empty; + } + break; + } + case WorkflowTriggerEventType.UnitCertificationRemoved: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt?.Certification != null) + { + MapUnitCertificationVariables(scriptObject, evt.Certification, evt.UnitName, evt.TypeCode, evt.TypeName, DaysUntil(evt.Certification.ExpiresOn)); + ((ScriptObject)scriptObject["unit_certification"])["removed_by_user_id"] = evt.RemovedByUserId ?? string.Empty; + } + break; + } case WorkflowTriggerEventType.CertificationRoleRemoved: { var evt = TryDeserialize(eventPayloadJson); @@ -519,6 +577,9 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.DeploymentExpenseAdded: case WorkflowTriggerEventType.TimeReportSubmitted: case WorkflowTriggerEventType.TimeReportApproved: + case WorkflowTriggerEventType.TimeReportCreated: + case WorkflowTriggerEventType.TimeReportVoided: + case WorkflowTriggerEventType.DeploymentAttachmentAdded: { var deploymentEvent = TryDeserialize(eventPayloadJson); var deploymentPayload = deploymentEvent?.Payload ?? new JObject(); var deployment = new ScriptObject(); @@ -530,6 +591,31 @@ public async Task BuildContextAsync( scriptObject["deployment"] = deployment; break; } + case WorkflowTriggerEventType.BidCreated: + case WorkflowTriggerEventType.BidSent: + case WorkflowTriggerEventType.BidAccepted: + case WorkflowTriggerEventType.BidDeclined: + case WorkflowTriggerEventType.BidExpired: + { + var bidEvent = TryDeserialize(eventPayloadJson); + var bidPayload = bidEvent?.Payload ?? new JObject(); var bid = new ScriptObject(); + foreach (var pair in Resgrid.Model.Invoicing.ContractorWorkflowPayload.BidVariables) bid[pair.Variable] = ToScriptValue(bidPayload[pair.Property]); + var bidId = bidPayload["BidId"]?.Type == JTokenType.String ? bidPayload["BidId"].Value() : null; + bid["url"] = string.IsNullOrWhiteSpace(bidId) ? string.Empty : $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Bids/View/{bidId}"; + scriptObject["bid"] = bid; + break; + } + case WorkflowTriggerEventType.ContractStatusChanged: + case WorkflowTriggerEventType.ContractExpiring: + { + var contractEvent = TryDeserialize(eventPayloadJson); + var contractPayload = contractEvent?.Payload ?? new JObject(); var contract = new ScriptObject(); + foreach (var pair in Resgrid.Model.Invoicing.ContractorWorkflowPayload.ContractVariables) contract[pair.Variable] = ToScriptValue(contractPayload[pair.Property]); + var contractId = contractPayload["ServiceContractId"]?.Type == JTokenType.String ? contractPayload["ServiceContractId"].Value() : null; + contract["url"] = string.IsNullOrWhiteSpace(contractId) ? string.Empty : $"{(Resgrid.Config.SystemBehaviorConfig.ResgridBaseUrl ?? string.Empty).TrimEnd('/')}/User/Contracts/View/{contractId}"; + scriptObject["contract"] = contract; + break; + } case WorkflowTriggerEventType.WorkOrderCreated: case WorkflowTriggerEventType.WorkOrderStatusChanged: case WorkflowTriggerEventType.WorkOrderAssigned: diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 503c37ccf..371dd2286 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -107,6 +107,12 @@ private void RegisterListeners() _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CertificationStatusChanged, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.UnitCertificationExpiring, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.UnitCertificationExpired, e)); + // Lifecycle completion (registry 180-184). + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.UnitCertificationAdded, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.UnitCertificationStatusChanged, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.UnitCertificationRemoved, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CertificationRemoved, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CertificationCreditAdded, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.FormSubmitted, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.PersonnelRoleChanged, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.GroupAdded, e)); diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index f713993bd..0de439770 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1779,6 +1779,20 @@ public static RecordClaimGrant[] RecordClaimGrants(PermissionTypes type) }; case PermissionTypes.ApproveTimeReports: return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.TimeReports, ResgridClaimTypes.Actions.Approve) }; + case PermissionTypes.ManageBids: + return new[] + { + new RecordClaimGrant(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Create), + new RecordClaimGrant(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Update), + new RecordClaimGrant(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Delete), + new RecordClaimGrant(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.View) + }; + case PermissionTypes.ManageContracts: + return new[] + { + new RecordClaimGrant(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.Update), + new RecordClaimGrant(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.View) + }; case PermissionTypes.ManageChecklists: return new[] { new RecordClaimGrant(ResgridClaimTypes.Resources.Checklist, ResgridClaimTypes.Actions.Update) }; case PermissionTypes.ViewChecklistResults: diff --git a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs index 34d15d7a5..1001b4a77 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs @@ -65,6 +65,8 @@ public static class Resources public const string Certifications = "Certifications"; public const string Deployments = "Deployments"; public const string TimeReports = "TimeReports"; + public const string Bids = "Bids"; + public const string ServiceContracts = "ServiceContracts"; public const string Checklist = "Checklist"; public const string ChecklistResults = "ChecklistResults"; // Resources diff --git a/Providers/Resgrid.Providers.Claims/ResgridResources.cs b/Providers/Resgrid.Providers.Claims/ResgridResources.cs index f1b35fb50..63d756025 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -208,6 +208,12 @@ public static class ResgridResources public const string Deployments_View = "Deployments_View"; public const string Deployments_Update = "Deployments_Update"; public const string TimeReports_Approve = "TimeReports_Approve"; + public const string Bids_View = "Bids_View"; + public const string Bids_Create = "Bids_Create"; + public const string Bids_Update = "Bids_Update"; + public const string Bids_Delete = "Bids_Delete"; + public const string ServiceContracts_View = "ServiceContracts_View"; + public const string ServiceContracts_Update = "ServiceContracts_Update"; public const string Checklist_Update = "Checklist_Update"; public const string ChecklistResults_View = "ChecklistResults_View"; public const string RecordDefinition_Update = "RecordDefinition_Update"; diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs index c50cdd73a..fcbf28aa8 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs @@ -658,10 +658,13 @@ public async Task SendReportDeliveryMail(string email, string subject, str } public async Task SendInvoiceMail(string email, string subject, string messageBody, string sentOn, - string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding) + string invoiceLabel, string attachmentFilename, byte[] attachmentData, string invoiceUrl, string payUrl, DepartmentEmailBranding branding, + string attachmentContentType = "application/pdf") { if (attachmentData == null || String.IsNullOrWhiteSpace(email)) return false; + if (String.IsNullOrWhiteSpace(attachmentContentType)) attachmentContentType = "application/pdf"; + var attachmentTypeLabel = attachmentContentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase) ? "ZIP" : attachmentContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) ? "PDF" : "file"; var templateModel = new Dictionary { @@ -670,7 +673,7 @@ public async Task SendInvoiceMail(string email, string subject, string mes { "body", HtmlToTextHelper.ConvertHtml(messageBody) }, { "attachment_name", attachmentFilename }, { "attachment_size", StringHelpers.GetSizeInMemory(attachmentData.LongLength) }, - { "attachment_type", "PDF" }, + { "attachment_type", attachmentTypeLabel }, { "invoice_links", String.IsNullOrWhiteSpace(invoiceUrl) ? Array.Empty>() : new[] { new Dictionary { { "url", invoiceUrl } } } }, { "pay_links", String.IsNullOrWhiteSpace(payUrl) ? Array.Empty>() : new[] { new Dictionary { { "url", payUrl } } } }, { "timestamp", sentOn } @@ -689,7 +692,7 @@ public async Task SendInvoiceMail(string email, string subject, string mes newEmail.To.Add(email); newEmail.AttachmentName = attachmentFilename; newEmail.AttachmentData = attachmentData; - newEmail.AttachmentContentType = "application/pdf"; + newEmail.AttachmentContentType = attachmentContentType; newEmail.From = DONOTREPLY_EMAIL; newEmail.Subject = subject; diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs index 2c6aa7458..0aee6e304 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs @@ -16,6 +16,11 @@ public override void Up() Alter.Table("Invoices").AddColumn("DeploymentId").AsString(36).Nullable(); if (!Schema.Table("InvoiceLineItems").Column("DeploymentTimeReportId").Exists()) Alter.Table("InvoiceLineItems").AddColumn("DeploymentTimeReportId").AsString(36).Nullable(); + // ADP catalog 28 marker for the department billing identity (tax registrations, SAM/CAGE, workers' comp account). + if (!Schema.Table("DepartmentBillingIdentities").Column("IsProtected").Exists()) + Alter.Table("DepartmentBillingIdentities").AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("DepartmentBillingIdentities").Column("ProtectedCatalogVersion").Exists()) + Alter.Table("DepartmentBillingIdentities").AddColumn("ProtectedCatalogVersion").AsInt32().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()) @@ -310,6 +315,8 @@ public override void Down() 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("DepartmentBillingIdentities").Column("ProtectedCatalogVersion").Exists()) Delete.Column("ProtectedCatalogVersion").FromTable("DepartmentBillingIdentities"); + if (Schema.Table("DepartmentBillingIdentities").Column("IsProtected").Exists()) Delete.Column("IsProtected").FromTable("DepartmentBillingIdentities"); 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/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs index 65a2a7288..1d7ef5de6 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs @@ -16,6 +16,11 @@ public override void Up() Alter.Table("invoices").AddColumn("deploymentid").AsString(36).Nullable(); if (!Schema.Table("invoicelineitems").Column("deploymenttimereportid").Exists()) Alter.Table("invoicelineitems").AddColumn("deploymenttimereportid").AsString(36).Nullable(); + // ADP catalog 28 marker for the department billing identity (tax registrations, SAM/CAGE, workers' comp account). + if (!Schema.Table("departmentbillingidentities").Column("isprotected").Exists()) + Alter.Table("departmentbillingidentities").AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("departmentbillingidentities").Column("protectedcatalogversion").Exists()) + Alter.Table("departmentbillingidentities").AddColumn("protectedcatalogversion").AsInt32().Nullable(); Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_deployment ON invoices (deploymentid) WHERE deploymentid IS NOT NULL;"); if (!Schema.Table("caloesmarsagencyprofiles").Exists()) @@ -310,6 +315,8 @@ public override void Down() 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 departmentbillingidentities DROP COLUMN IF EXISTS protectedcatalogversion;"); + Execute.Sql("ALTER TABLE departmentbillingidentities DROP COLUMN IF EXISTS isprotected;"); 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/ContractorRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs new file mode 100644 index 000000000..b21784176 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs @@ -0,0 +1,202 @@ +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 (C2): contractor path repositories (registry M0215–M0217). + + public class RateScheduleRepository : RmsRepositoryBase, IRateScheduleRepository + { + public RateScheduleRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + private static string True => IsPostgres ? "TRUE" : "1"; + + public Task GetByIdForDepartmentAsync(string rateScheduleId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RateSchedules")} WHERE {Col("RateScheduleId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = rateScheduleId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId, bool includeInactive) => + QueryAsync( + $"SELECT * FROM {Tbl("RateSchedules")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (includeInactive ? string.Empty : $" AND {Col("IsActive")} = {True}") + + $" ORDER BY {Col("Name")}", new { DepartmentId = departmentId }); + } + + public class RateScheduleEntryRepository : RmsRepositoryBase, IRateScheduleEntryRepository + { + public RateScheduleEntryRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByScheduleAsync(string rateScheduleId, bool includeInactive) => + QueryAsync( + $"SELECT * FROM {Tbl("RateScheduleEntries")} WHERE {Col("RateScheduleId")} = {P}Id AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")}" + (includeInactive ? string.Empty : $" AND {Col("IsActive")} = {(IsPostgres ? "TRUE" : "1")}") + + $" ORDER BY {Col("SortOrder")}, {Col("Name")}", new { Id = rateScheduleId }); + + public Task> GetByIdsAsync(IEnumerable rateScheduleEntryIds) => + QueryAsync($"SELECT * FROM {Tbl("RateScheduleEntries")} WHERE {InList("RateScheduleEntryId", "Ids")}", new { Ids = InListValue(rateScheduleEntryIds) }); + } + + public class RateScheduleEntryBandRepository : RmsRepositoryBase, IRateScheduleEntryBandRepository + { + public RateScheduleEntryBandRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByScheduleAsync(string rateScheduleId) => + QueryAsync( + $"SELECT b.* FROM {Tbl("RateScheduleEntryBands")} b JOIN {Tbl("RateScheduleEntries")} e ON e.{Col("RateScheduleEntryId")} = b.{Col("RateScheduleEntryId")} " + + $"WHERE e.{Col("RateScheduleId")} = {P}Id ORDER BY b.{Col("SortOrder")}, b.{Col("BandType")}", new { Id = rateScheduleId }); + + public Task> GetByEntryAsync(string rateScheduleEntryId) => + QueryAsync($"SELECT * FROM {Tbl("RateScheduleEntryBands")} WHERE {Col("RateScheduleEntryId")} = {P}Id ORDER BY {Col("SortOrder")}, {Col("BandType")}", new { Id = rateScheduleEntryId }); + + public async Task DeleteByEntryAsync(string rateScheduleEntryId, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("RateScheduleEntryBands")} WHERE {Col("RateScheduleEntryId")} = {P}Id", new { Id = rateScheduleEntryId }, cancellationToken); + return true; + } + } + + public class RatePremiumRepository : RmsRepositoryBase, IRatePremiumRepository + { + public RatePremiumRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByScheduleAsync(string rateScheduleId, bool includeInactive) => + QueryAsync( + $"SELECT * FROM {Tbl("RatePremiums")} WHERE {Col("RateScheduleId")} = {P}Id AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")}" + (includeInactive ? string.Empty : $" AND {Col("IsActive")} = {(IsPostgres ? "TRUE" : "1")}") + + $" ORDER BY {Col("Name")}", new { Id = rateScheduleId }); + } + + public class ServiceContractRepository : RmsRepositoryBase, IServiceContractRepository + { + public ServiceContractRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + private const int Active = (int)ServiceContractStatuses.Active; + + public Task GetByIdForDepartmentAsync(string serviceContractId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("ServiceContracts")} WHERE {Col("ServiceContractId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = serviceContractId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId, int? status) => + QueryAsync( + $"SELECT * FROM {Tbl("ServiceContracts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty) + + $" ORDER BY {Col("StartOn")} DESC", new { DepartmentId = departmentId, Status = status ?? 0 }); + + public Task> GetByContactIdAsync(int departmentId, string contactId) => + QueryAsync($"SELECT * FROM {Tbl("ServiceContracts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("StartOn")} DESC", new { DepartmentId = departmentId, ContactId = contactId }); + + public Task> GetEndingBetweenAsync(DateTime fromUtc, DateTime toUtc) => + QueryAsync($"SELECT * FROM {Tbl("ServiceContracts")} WHERE {Col("Status")} = {Active} AND {Col("IsDeleted")} = {False} AND {Col("EndOn")} >= {P}From AND {Col("EndOn")} <= {P}To", new { From = DatabaseTimestamp(fromUtc), To = DatabaseTimestamp(toUtc) }); + + public Task> GetLapsedAsync(DateTime asOfUtc) => + QueryAsync($"SELECT * FROM {Tbl("ServiceContracts")} WHERE {Col("Status")} = {Active} AND {Col("IsDeleted")} = {False} AND {Col("EndOn")} < {P}AsOf", new { AsOf = DatabaseTimestamp(asOfUtc) }); + } + + public class ServiceContractDocumentRequirementRepository : RmsRepositoryBase, IServiceContractDocumentRequirementRepository + { + public ServiceContractDocumentRequirementRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByContractAsync(string serviceContractId) => + QueryAsync($"SELECT * FROM {Tbl("ServiceContractDocumentRequirements")} WHERE {Col("ServiceContractId")} = {P}Id ORDER BY {Col("SortOrder")}", new { Id = serviceContractId }); + + public async Task DeleteByContractAsync(string serviceContractId, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("ServiceContractDocumentRequirements")} WHERE {Col("ServiceContractId")} = {P}Id", new { Id = serviceContractId }, cancellationToken); + return true; + } + } + + public class DepartmentComplianceDocumentRepository : RmsRepositoryBase, IDepartmentComplianceDocumentRepository + { + public DepartmentComplianceDocumentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static readonly string[] Meta = { "DepartmentComplianceDocumentId", "DepartmentId", "DocumentType", "Name", "DocumentNumber", "Issuer", "EffectiveOn", "ExpiresOn", "AlertLeadDays", "FileName", "FileType", "FileSize", "IsDeleted", "AddedOn", "AddedByUserId", "EditedOn", "EditedByUserId", "IsProtected", "ProtectedCatalogVersion" }; + private static string False => IsPostgres ? "FALSE" : "0"; + + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT {Cols(Meta)} FROM {Tbl("DepartmentComplianceDocuments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} ORDER BY {Col("DocumentType")}, {Col("Name")}", new { DepartmentId = departmentId }); + + public Task GetByIdWithDataAsync(int departmentComplianceDocumentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DepartmentComplianceDocuments")} WHERE {Col("DepartmentComplianceDocumentId")} = {P}Id", new { Id = departmentComplianceDocumentId }); + + public Task> GetExpiringAsync(DateTime asOfUtc) + { + // ExpiresOn within the row's own lead window, or already past: the sweep decides which notification to send. + var window = IsPostgres + ? $"{Col("ExpiresOn")} <= ({P}AsOf + ({Col("AlertLeadDays")} * INTERVAL '1 day'))" + : $"{Col("ExpiresOn")} <= DATEADD(day, {Col("AlertLeadDays")}, {P}AsOf)"; + return QueryAsync($"SELECT {Cols(Meta)} FROM {Tbl("DepartmentComplianceDocuments")} WHERE {Col("IsDeleted")} = {False} AND {Col("ExpiresOn")} IS NOT NULL AND {window} ORDER BY {Col("DepartmentId")}, {Col("ExpiresOn")}", new { AsOf = DatabaseTimestamp(asOfUtc) }); + } + } + + public class BidRepository : RmsRepositoryBase, IBidRepository + { + public BidRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private static string False => IsPostgres ? "FALSE" : "0"; + + public Task GetByIdForDepartmentAsync(string bidId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("BidId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = bidId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId, int? status, int skip, int take) => + QueryAsync( + $"SELECT * FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty) + + $" ORDER BY {Col("BidNumber")} DESC {Paging()}", + new { DepartmentId = departmentId, Status = status ?? 0, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 500) }); + + public Task CountForDepartmentAsync(int departmentId, int? status) => + ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty), new { DepartmentId = departmentId, Status = status ?? 0 }); + + public Task> GetByContactIdAsync(int departmentId, string contactId) => + QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("BidNumber")} DESC", new { DepartmentId = departmentId, ContactId = contactId }); + + public Task> GetByContractAsync(string serviceContractId) => + QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("ServiceContractId")} = {P}Id AND {Col("IsDeleted")} = {False} ORDER BY {Col("BidNumber")} DESC", new { Id = serviceContractId }); + + public Task> GetExpiryCandidatesAsync(DateTime asOfUtc) => + QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("Status")} = {(int)BidStatuses.Submitted} AND {Col("IsDeleted")} = {False} AND {Col("ValidUntil")} IS NOT NULL AND {Col("ValidUntil")} < {P}AsOf", new { AsOf = DatabaseTimestamp(asOfUtc) }); + } + + public class BidLineItemRepository : RmsRepositoryBase, IBidLineItemRepository + { + public BidLineItemRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByBidAsync(string bidId) => + QueryAsync($"SELECT * FROM {Tbl("BidLineItems")} WHERE {Col("BidId")} = {P}Id ORDER BY {Col("SortOrder")}", new { Id = bidId }); + } + + public class BidNumberSequenceRepository : RmsRepositoryBase, IBidNumberSequenceRepository + { + public BidNumberSequenceRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetNextNumberAsync(int departmentId, CancellationToken cancellationToken = default) + { + // The invoice/time-report 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("BidNumberSequences")} ({Col("DepartmentId")}, {Col("NextBidNumber")}) VALUES ({P}DepartmentId, 2) " + + $"ON CONFLICT ({Col("DepartmentId")}) DO UPDATE SET {Col("NextBidNumber")} = {Tbl("BidNumberSequences")}.{Col("NextBidNumber")} + 1 " + + $"RETURNING {Col("NextBidNumber")} - 1"; + else + sql = $"MERGE {Tbl("BidNumberSequences")} WITH (HOLDLOCK) AS t USING (SELECT {P}DepartmentId AS DepartmentId) AS s ON t.[DepartmentId] = s.DepartmentId " + + "WHEN MATCHED THEN UPDATE SET [NextBidNumber] = t.[NextBidNumber] + 1 " + + "WHEN NOT MATCHED THEN INSERT ([DepartmentId], [NextBidNumber]) VALUES (s.DepartmentId, 2) " + + "OUTPUT inserted.[NextBidNumber] - 1;"; + return ScalarAsync(sql, new { DepartmentId = departmentId }, cancellationToken); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs index 795343bb7..1a3412cbf 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs @@ -110,6 +110,11 @@ public Task> GetUnbilledApprovedAsync(int depa $"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 Task> GetUnbilledApprovedBeforeAsync(DateTime approvedBeforeUtc) => + QueryAsync( + $"SELECT * FROM {Tbl("DeploymentTimeReports")} WHERE {Col("IsDeleted")} = {False} AND {Col("Status")} = {P}Approved AND {Col("InvoiceId")} IS NULL AND {Col("ApprovedOn")} IS NOT NULL AND {Col("ApprovedOn")} <= {P}Before ORDER BY {Col("DepartmentId")}, {Col("DeploymentId")}, {Col("ReportDate")}", + new { Approved = (int)DeploymentTimeReportStatuses.Approved, Before = DatabaseTimestamp(approvedBeforeUtc) }); } public class DeploymentTimeEntryRepository : RmsRepositoryBase, IDeploymentTimeEntryRepository @@ -151,8 +156,15 @@ public DeploymentAttachmentRepository(IConnectionProvider connectionProvider, Sq 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 GetMetadataByIdAsync(int deploymentAttachmentId) => + QueryFirstOrDefaultAsync($"SELECT {Cols(Meta)} FROM {Tbl("DeploymentAttachments")} WHERE {Col("DeploymentAttachmentId")} = {P}Id", new { Id = deploymentAttachmentId }); + public Task GetByIdWithDataAsync(int deploymentAttachmentId) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DeploymentAttachments")} WHERE {Col("DeploymentAttachmentId")} = {P}Id", new { Id = deploymentAttachmentId }); + + public Task MarkDeletedAsync(int deploymentAttachmentId, int departmentId, CancellationToken cancellationToken = default) => + ExecuteAsync($"UPDATE {Tbl("DeploymentAttachments")} SET {Col("IsDeleted")} = {(IsPostgres ? "TRUE" : "1")} WHERE {Col("DeploymentAttachmentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")}", + new { Id = deploymentAttachmentId, DepartmentId = departmentId }, cancellationToken); } public class TimeReportNumberSequenceRepository : RmsRepositoryBase, ITimeReportNumberSequenceRepository diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs index df136da64..c365dc628 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs @@ -353,7 +353,7 @@ public async Task UpsertAsync(DepartmentBillingIdenti { "LegalBusinessName", "RemitToAddressId", "TaxRegistrationNumber", "SecondaryTaxRegistrationNumber", "SamUei", "CageCode", "WorkersCompAccountNumber", "InvoiceFooterText", "OnlinePaymentsEnabled", "DefaultPaymentConnectionId", "AllowedPaymentMethodsCsv", - "PayLinkExpiryDays", "ShowPayOnlineOnDocuments", "UpdatedOn", "UpdatedByUserId" + "PayLinkExpiryDays", "ShowPayOnlineOnDocuments", "UpdatedOn", "UpdatedByUserId", "IsProtected", "ProtectedCatalogVersion" }; var setList = string.Join(", ", columns.Select(c => $"{Col(c)} = {P}{c}")); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index cc335a881..aa26b6f44 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -42,6 +42,17 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M2: contractor path (M0215–M0217). + 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().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 1171b574b..0e45dfc9d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -256,6 +256,17 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M2: contractor path (M0215–M0217). + 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().InstancePerLifetimeScope(); // Indoor Maps Repositories diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index ebd1720f0..0a62c9542 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -42,6 +42,17 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M2: contractor path (M0215–M0217). + 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().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 f4f146f45..221004f0e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -42,6 +42,17 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M2: contractor path (M0215–M0217). + 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().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index 52230fbc7..e90381e79 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -135,5 +135,13 @@ "DeploymentRosterChanged": 83, "DeploymentExpenseAdded": 84, "TimeReportSubmitted": 85, - "TimeReportApproved": 86 + "TimeReportApproved": 86, + "UnitCertificationAdded": 180, + "UnitCertificationStatusChanged": 181, + "UnitCertificationRemoved": 182, + "CertificationRemoved": 183, + "CertificationCreditAdded": 184, + "TimeReportCreated": 185, + "TimeReportVoided": 186, + "DeploymentAttachmentAdded": 187 } diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index c38d21c44..82951ba51 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -52,6 +52,8 @@ public void Permission_types_50_to_67_are_the_registry_names() // 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.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); @@ -137,6 +139,13 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.ContractExpiring).Should().Be(80); ((int)WorkflowTriggerEventType.DeploymentCreated).Should().Be(81); ((int)WorkflowTriggerEventType.TimeReportApproved).Should().Be(86); + // Workforce & Business Operations lifecycle completion took 180-187 on 2026-09-19 (registry: new allocations start at 180; 177-179 stay Enhanced AI). + ((int)WorkflowTriggerEventType.UnitCertificationAdded).Should().Be(180); + ((int)WorkflowTriggerEventType.CertificationCreditAdded).Should().Be(184); + ((int)WorkflowTriggerEventType.TimeReportCreated).Should().Be(185); + ((int)WorkflowTriggerEventType.DeploymentAttachmentAdded).Should().Be(187); + foreach (var value in Enumerable.Range(177, 3)) + Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for the Enhanced AI add-on"); 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/Services/CertificationServiceTests.cs b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs index 04feb2152..804432703 100644 --- a/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs @@ -257,6 +257,10 @@ public async Task Verify_status_and_renew_follow_the_lifecycle_and_publish_once_ (await _service.SoftDeleteCertificationAsync(renewed.PersonnelCertificationId, Dept, "chief")).Should().BeTrue(); _records.Single(r => r.PersonnelCertificationId == renewed.PersonnelCertificationId).IsDeleted.Should().BeTrue(); Audits.Last().Type.Should().Be(AuditLogTypes.CertificationRemoved); + // Lifecycle completion (trigger 183): the removal reaches the Workflow Engine with the holder and the type. + var removed = _published.OfType().Single(); + removed.Certification.PersonnelCertificationId.Should().Be(renewed.PersonnelCertificationId); + removed.RemovedByUserId.Should().Be("chief"); } [Test] @@ -285,6 +289,10 @@ public async Task Credits_attach_to_person_records_only_and_roll_up() await _service.AddCertificationCreditAsync(new PersonnelCertificationCredit { PersonnelCertificationId = record.PersonnelCertificationId, DepartmentId = Dept, Hours = 4, Category = "Trauma" }, "u1"); (await _service.GetCertificationCreditTotalsAsync(new[] { record.PersonnelCertificationId }))[record.PersonnelCertificationId].Should().Be(16.5m); Audits.Count(a => a.Type == AuditLogTypes.CertificationCreditAdded).Should().Be(2); + // Lifecycle completion (trigger 184): each credit reaches the Workflow Engine with its hours and category. + _published.OfType().Select(e => e.Hours).Should().BeEquivalentTo(new[] { 12.5m, 4m }); + _published.OfType().Last().Category.Should().Be("Trauma"); + _published.OfType().Should().OnlyContain(e => e.Certification.PersonnelCertificationId == record.PersonnelCertificationId && e.AddedByUserId == "u1"); (await FluentActions.Awaiting(() => _service.AddCertificationCreditAsync(new PersonnelCertificationCredit { PersonnelCertificationId = record.PersonnelCertificationId, DepartmentId = Dept, Hours = 0 }, "u1")).Should().ThrowAsync()).Which.Message.Should().Be("certifications_credit_hours_invalid"); var credit = _credits.First(); (await _service.DeleteCertificationCreditAsync(credit.PersonnelCertificationCreditId, Dept, "chief")).Should().BeTrue(); @@ -305,13 +313,20 @@ public async Task Unit_records_require_a_unit_scoped_type_and_a_unit_in_the_depa saved.UnitCertificationId.Should().BeGreaterThan(0); saved.AddedByUserId.Should().Be("chief"); saved.FileSize.Should().Be(3); _unitRecords.Single().Data.Should().Equal(1, 2, 3); Audits.Last().Type.Should().Be(AuditLogTypes.UnitCertificationAdded); + _published.OfType().Single().Certification.UnitCertificationId.Should().Be(saved.UnitCertificationId); + _published.OfType().Single().Certification.Data.Should().BeNull("the event carries no file bytes"); var edited = await _service.SaveUnitCertificationAsync(new UnitCertification { UnitCertificationId = saved.UnitCertificationId, UnitId = 7, DepartmentId = Dept, DepartmentCertificationTypeId = 2, Number = "INSP-2", ExpiresOn = Today.AddMonths(7) }, "chief"); _unitRecords.Single().Data.Should().Equal(new byte[] { 1, 2, 3 }, "no new file keeps the stored one"); edited.EditedByUserId.Should().Be("chief"); var suspended = await _service.SetUnitCertificationStatusAsync(saved.UnitCertificationId, Dept, UnitCertificationStatuses.Suspended, "failed re-test", "chief"); suspended.Status.Should().Be((int)UnitCertificationStatuses.Suspended); + _unitRecords.Single().Data.Should().Equal(new byte[] { 1, 2, 3 }, "a status change through the catalog-28 seam keeps the stored file"); + // Lifecycle completion (triggers 181/182): status changes and removals reach the Workflow Engine. + var statusChange = _published.OfType().Single(); + statusChange.OldStatus.Should().Be((int)UnitCertificationStatuses.Active); statusChange.NewStatus.Should().Be((int)UnitCertificationStatuses.Suspended); statusChange.Reason.Should().Be("failed re-test"); statusChange.ChangedByUserId.Should().Be("chief"); (await _service.DeleteUnitCertificationAsync(saved.UnitCertificationId, Dept, "chief")).Should().BeTrue(); + _published.OfType().Single().RemovedByUserId.Should().Be("chief"); (await _service.GetUnitCertificationsAsync(7)).Should().BeEmpty(); (await FluentActions.Awaiting(() => _service.AddCertificationCreditAsync(new PersonnelCertificationCredit { PersonnelCertificationId = 1, DepartmentId = Dept, Hours = 1 }, "u1")).Should().ThrowAsync()).Which.Message.Should().Be("certifications_record_not_found"); } @@ -492,7 +507,9 @@ public void SetUp() _roles.Setup(r => r.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 12, DepartmentId = Dept, Name = "Paramedic" }, new PersonnelRole { PersonnelRoleId = 13, DepartmentId = Dept, Name = "Driver" } }); var aggregator = new Mock(); aggregator.Setup(a => a.SendMessage(It.IsAny())).Callback((AuditEvent a) => _audits.Add(a)); - _service = new PersonnelRolesService(_roles.Object, _roleUsers.Object, Mock.Of(), Mock.Of(), aggregator.Object, Mock.Of(), new Lazy(() => _certifications.Object)); + var members = new Mock(); + members.Setup(m => m.GetAllDepartmentMembersUnlimitedAsync(Dept)).ReturnsAsync(new[] { "u1", "existing", "newcomer" }.Select(u => new DepartmentMember { DepartmentId = Dept, UserId = u }).ToList()); + _service = new PersonnelRolesService(_roles.Object, _roleUsers.Object, Mock.Of(), members.Object, aggregator.Object, Mock.Of(), new Lazy(() => _certifications.Object)); } [Test] @@ -521,10 +538,14 @@ public async Task Saving_a_role_with_members_gates_and_audits_the_newcomers_only _memberships.Add(new PersonnelRoleUser { PersonnelRoleUserId = 1, PersonnelRoleId = 12, DepartmentId = Dept, UserId = "existing" }); _roles.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((PersonnelRole r, CancellationToken _, bool __) => r); var role = new PersonnelRole { PersonnelRoleId = 12, DepartmentId = Dept, Name = "Paramedic", Users = new List { new PersonnelRoleUser { UserId = "existing" }, new PersonnelRoleUser { UserId = "newcomer" } } }; - await FluentActions.Awaiting(() => _service.SaveRoleAsync(role, default, "admin")).Should().ThrowAsync().WithMessage("certifications_role_requirements_unmet"); + (await FluentActions.Awaiting(() => _service.SaveRoleAsync(role, default, "admin")).Should().ThrowAsync().WithMessage("certifications_role_requirements_unmet")).Which.UserId.Should().Be("newcomer", "the refusal names the member it is about"); _qualified = true; await _service.SaveRoleAsync(role, default, "admin"); _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.RoleMemberAdded).Which.After.Should().Contain("newcomer"); + + // A user id outside the department is refused before the certification gate and before any write. + role.Users.Add(new PersonnelRoleUser { UserId = "stranger" }); + (await FluentActions.Awaiting(() => _service.SaveRoleAsync(role, default, "admin")).Should().ThrowAsync().WithMessage("roles_member_not_in_department")).Which.UserId.Should().Be("stranger"); } } } diff --git a/Tests/Resgrid.Tests/Services/ContractorBillingLocalizationTests.cs b/Tests/Resgrid.Tests/Services/ContractorBillingLocalizationTests.cs new file mode 100644 index 000000000..649e1daa0 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ContractorBillingLocalizationTests.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 contractor path (Workforce & Business Operations + /// plan, Phase C-M2): rate schedules, contracts, compliance documents, bids and the deployment wizard. Every + /// resource key a view, controller, the Security page rows, the workflow trigger labels, the deployment billing tab, + /// the roster/charge warning codes or the services' error codes reference must exist, and every supported culture + /// must carry a complete, non-English translation. + /// + [TestFixture] + public class ContractorBillingLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + + [Test] + public void Contractor_views_controllers_and_service_errors_resolve_real_resource_keys() + { + var root = RepositoryRoot(); + var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User"); + var own = new[] { "RateSchedules", "Contracts", "Bids", "DeploymentWizard" }.SelectMany(d => Directory.GetFiles(Path.Combine(web, "Views", d), "*.cshtml")) + .Concat(new[] + { + Path.Combine(web, "Controllers", "RateSchedulesController.cs"), Path.Combine(web, "Controllers", "ContractsController.cs"), + Path.Combine(web, "Controllers", "BidsController.cs"), Path.Combine(web, "Controllers", "DeploymentWizardController.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "RateScheduleService.cs"), Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "ServiceContractService.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "BidsService.cs"), Path.Combine(root, "Core", "Resgrid.Services", "Invoicing", "ContractorBillingEngine.cs") + }).ToList(); + var shared = new[] + { + Path.Combine(web, "Views", "Security", "Index.cshtml"), Path.Combine(web, "Views", "Shared", "_Navigation.cshtml"), Path.Combine(web, "Views", "Workflows", "New.cshtml"), + Path.Combine(web, "Views", "Shared", "_ContractorShell.cshtml"), Path.Combine(web, "Views", "Shared", "_ContractorMessage.cshtml"), + Path.Combine(web, "Views", "Deployments", "View.cshtml"), Path.Combine(web, "Views", "Invoicing", "View.cshtml") + }; + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in own.Concat(shared)) + { + var source = File.ReadAllText(file); + var pattern = own.Contains(file) + ? """(?)""" + : """_?contractorLocalizer\["([^"]+)"\]|contractorStrings\["([^"]+)"\]"""; + 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("ContractStatus" + status); keys.Add("SetStatus" + status); } + foreach (var type in Enum.GetNames()) keys.Add("ContractType" + type); + foreach (var stage in Enum.GetNames()) keys.Add("Stage" + stage); + foreach (var type in Enum.GetNames()) keys.Add("DocType" + type); + foreach (var status in Enum.GetNames()) keys.Add("BidStatus" + status); + foreach (var type in Enum.GetNames()) keys.Add("LineType" + type); + foreach (var type in Enum.GetNames()) keys.Add("EntryType" + type); + foreach (var basis in Enum.GetNames()) keys.Add("Basis" + basis); + foreach (var band in Enum.GetNames()) keys.Add("Band" + band); + foreach (var code in typeof(Resgrid.Model.Invoicing.ContractorChargeWarningCodes).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue())) keys.Add("ChargeWarning" + code); + 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 permission in new[] { PermissionTypes.ManageBids, PermissionTypes.ManageContracts }) { keys.Add(permission.ToString()); keys.Add(permission + "Note"); } + foreach (var trigger in Resgrid.Model.Invoicing.ContractorWorkflowPayload.BidTriggers.Concat(Resgrid.Model.Invoicing.ContractorWorkflowPayload.ContractTriggers)) keys.Add(((WorkflowTriggerEventType)trigger).ToString()); + + var resources = Read(Path.Combine(ResourceDirectory(), "ContractorBilling.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[] { "BandType", "Name", "Optional", "Person", "Status" }, + ["es"] = new[] { "No", "Subtotal" }, + ["fr"] = new[] { "Actions", "Code", "Date", "Description", "EntryTypePersonnelCertification", "LineTypePersonnelCertification", "Notes", "Personnel" }, + ["it"] = new[] { "File", "No" }, + ["pl"] = new[] { "Status" }, + ["sv"] = new[] { "BandType", "Person", "StartOn", "Status" }, + }; + + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "ContractorBilling.resx")); + var file = Path.Combine(ResourceDirectory(), "ContractorBilling." + 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.ContractorBilling.ContractorBilling", 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", "ContractorBilling"); + + private static Dictionary Read(string file) + { + var document = XDocument.Load(file); + return document.Root!.Elements("data").ToDictionary(e => (string)e.Attribute("name")!, e => (string)e.Element("value")!, StringComparer.Ordinal); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs new file mode 100644 index 000000000..e0e6295e6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +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.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase C-M2 (contractor path): rate schedules (graph, cascade, clone, + /// import/export, prefill), service contracts (status machine, compliance evaluation, expiry sweep, protected + /// compliance documents), bids (numbering, discount cascade, rate snapshots, lifecycle events, send, conversion, + /// expiry sweep) and the billing engine's invoice generation and packet. + /// + [TestFixture] + public class ContractorBillingServiceTests + { + private const int DeptId = 7; + private const string User = "manager"; + private static readonly DateTime Day = new DateTime(2026, 9, 18, 0, 0, 0, DateTimeKind.Utc); + + private List _schedules; + private List _entries; + private List _bands; + private List _premiums; + private List _contracts; + private List _requirements; + private List _documents; + private List _bids; + private List _lines; + private List _profiles; + private List _published; + private List _audits; + private Mock _contactsService; + private Mock _deployments; + private Mock _calls; + private Mock _calendar; + private Mock _email; + private Mock _deploymentRows; + private Mock _attachments; + private RateScheduleService _rates; + private ServiceContractService _contractService; + private BidsService _bidsService; + + [SetUp] + public void SetUp() + { + _schedules = new List(); _entries = new List(); _bands = new List(); _premiums = new List(); + _contracts = new List(); _requirements = new List(); _documents = new List(); + _bids = new List(); _lines = new List(); _profiles = new List(); _published = new List(); _audits = new List(); + + var schedules = Repo(_schedules, s => s.RateScheduleId, (s, id) => s.RateScheduleId = id); + schedules.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _schedules.FirstOrDefault(s => s.RateScheduleId == id)); + schedules.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync((int _, bool inactive) => _schedules.Where(s => !s.IsDeleted && (inactive || s.IsActive)).ToList()); + var entries = Repo(_entries, e => e.RateScheduleEntryId, (e, id) => e.RateScheduleEntryId = id); + entries.Setup(r => r.GetByScheduleAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool inactive) => _entries.Where(e => e.RateScheduleId == id && !e.IsDeleted && (inactive || e.IsActive)).ToList()); + var bands = Repo(_bands, b => b.RateScheduleEntryBandId, (b, id) => b.RateScheduleEntryBandId = id); + bands.Setup(r => r.GetByScheduleAsync(It.IsAny())).ReturnsAsync((string id) => _bands.Where(b => _entries.Any(e => e.RateScheduleId == id && e.RateScheduleEntryId == b.RateScheduleEntryId)).ToList()); + bands.Setup(r => r.GetByEntryAsync(It.IsAny())).ReturnsAsync((string id) => _bands.Where(b => b.RateScheduleEntryId == id).ToList()); + bands.Setup(r => r.DeleteByEntryAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, CancellationToken _) => { _bands.RemoveAll(b => b.RateScheduleEntryId == id); return true; }); + var premiums = Repo(_premiums, p => p.RatePremiumId, (p, id) => p.RatePremiumId = id); + premiums.Setup(r => r.GetByScheduleAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool inactive) => _premiums.Where(p => p.RateScheduleId == id && !p.IsDeleted && (inactive || p.IsActive)).ToList()); + var contracts = Repo(_contracts, c => c.ServiceContractId, (c, id) => c.ServiceContractId = id); + contracts.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _contracts.FirstOrDefault(c => c.ServiceContractId == id)); + contracts.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int? status) => _contracts.Where(c => !c.IsDeleted && (!status.HasValue || c.Status == status)).ToList()); + contracts.Setup(r => r.GetByContactIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string contactId) => _contracts.Where(c => c.ContactId == contactId && !c.IsDeleted).ToList()); + contracts.Setup(r => r.GetLapsedAsync(It.IsAny())).ReturnsAsync((DateTime asOf) => _contracts.Where(c => c.Status == (int)ServiceContractStatuses.Active && c.EndOn < asOf).ToList()); + contracts.Setup(r => r.GetEndingBetweenAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DateTime from, DateTime to) => _contracts.Where(c => c.Status == (int)ServiceContractStatuses.Active && c.EndOn >= from && c.EndOn <= to).ToList()); + var requirements = Repo(_requirements, r => r.ServiceContractDocumentRequirementId, (r, id) => r.ServiceContractDocumentRequirementId = id); + requirements.Setup(r => r.GetByContractAsync(It.IsAny())).ReturnsAsync((string id) => _requirements.Where(x => x.ServiceContractId == id).ToList()); + requirements.Setup(r => r.DeleteByContractAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, CancellationToken _) => { _requirements.RemoveAll(x => x.ServiceContractId == id); return true; }); + var documents = new Mock(); + documents.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentComplianceDocument d, CancellationToken _, bool __) => { if (d.DepartmentComplianceDocumentId == 0) d.DepartmentComplianceDocumentId = _documents.Count + 1; _documents.RemoveAll(x => x.DepartmentComplianceDocumentId == d.DepartmentComplianceDocumentId); _documents.Add(d); return d; }); + documents.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _documents.Where(d => !d.IsDeleted).Select(d => { var copy = Resgrid.Framework.ObjectCopier.CloneJson(d); copy.Data = null; return copy; }).ToList()); + documents.Setup(r => r.GetByIdWithDataAsync(It.IsAny())).ReturnsAsync((int id) => { var d = _documents.FirstOrDefault(x => x.DepartmentComplianceDocumentId == id); return d == null ? null : Resgrid.Framework.ObjectCopier.CloneJson(d); }); + documents.Setup(r => r.GetExpiringAsync(It.IsAny())).ReturnsAsync((DateTime asOf) => _documents.Where(d => !d.IsDeleted && d.ExpiresOn.HasValue && d.ExpiresOn <= asOf.AddDays(d.AlertLeadDays)).ToList()); + var bids = Repo(_bids, b => b.BidId, (b, id) => b.BidId = id); + bids.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _bids.FirstOrDefault(b => b.BidId == id)); + bids.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, int? status, int __, int ___) => _bids.Where(b => !b.IsDeleted && (!status.HasValue || b.Status == status)).ToList()); + bids.Setup(r => r.GetByContactIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string contactId) => _bids.Where(b => b.ContactId == contactId && !b.IsDeleted).ToList()); + bids.Setup(r => r.GetExpiryCandidatesAsync(It.IsAny())).ReturnsAsync((DateTime asOf) => _bids.Where(b => b.Status == (int)BidStatuses.Submitted && b.ValidUntil < asOf).ToList()); + var lines = Repo(_lines, l => l.BidLineItemId, (l, id) => l.BidLineItemId = id); + lines.Setup(r => r.GetByBidAsync(It.IsAny())).ReturnsAsync((string id) => _lines.Where(l => l.BidId == id).ToList()); + lines.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync((BidLineItem l, CancellationToken _) => { _lines.RemoveAll(x => x.BidLineItemId == l.BidLineItemId); return true; }); + var sequence = new Mock(); + var next = 100; + sequence.Setup(s => s.GetNextNumberAsync(DeptId, It.IsAny())).ReturnsAsync(() => ++next); + var profiles = new Mock(); + profiles.Setup(p => p.GetByContactIdAsync(It.IsAny(), DeptId)).ReturnsAsync((string contactId, int _) => _profiles.FirstOrDefault(p => p.ContactId == contactId)); + profiles.Setup(p => p.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _profiles.FirstOrDefault(p => p.CustomerBillingProfileId == id)); + var identities = new Mock(); + identities.Setup(i => i.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentBillingIdentity { DepartmentId = DeptId, LegalBusinessName = "Test County Fire Ltd." }); + + _contactsService = new Mock(); + _contactsService.Setup(c => c.GetContactByIdAsync("customer")).ReturnsAsync(new Contact { ContactId = "customer", DepartmentId = DeptId, ContactType = 1, CompanyName = "Province Wildfire", Email = "ap@province.example" }); + _contactsService.Setup(c => c.GetContactByIdAsync("other")).ReturnsAsync(new Contact { ContactId = "other", DepartmentId = DeptId, ContactType = 1, CompanyName = "Other Agency" }); + var departments = new Mock(); + 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.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + var outbox = new Mock(); + outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, e, ___) => _published.Add(e)).ReturnsAsync(new DomainEventOutboxEntry()); + var events = new Mock(); + events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + _deploymentRows = new Mock(); + _attachments = new Mock(); + _attachments.Setup(a => a.GetByDeploymentAsync(It.IsAny())).ReturnsAsync(new List()); + _deployments = new Mock(); + _calls = new Mock(); + _calendar = new Mock(); + _email = new Mock(); + _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + var pdf = new Mock(); + pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(html => System.Text.Encoding.UTF8.GetBytes(html)); + + _rates = new RateScheduleService(schedules.Object, entries.Object, bands.Object, premiums.Object, contracts.Object, profiles.Object, events.Object, null); + _contractService = new ServiceContractService(contracts.Object, requirements.Object, documents.Object, _deploymentRows.Object, _attachments.Object, profiles.Object, _contactsService.Object, departments.Object, outbox.Object, events.Object, null); + _bidsService = new BidsService(bids.Object, lines.Object, sequence.Object, contracts.Object, profiles.Object, identities.Object, _rates, _deployments.Object, _contactsService.Object, departments.Object, _calls.Object, _calendar.Object, _email.Object, pdf.Object, outbox.Object, events.Object, null); + } + + private static Mock Repo(List store, Func id, Action setId) where TRepo : class, IRepository where T : class, IEntity + { + var mock = new Mock(); + mock.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((T entity, CancellationToken _, bool __) => { if (string.IsNullOrWhiteSpace(id(entity))) setId(entity, Guid.NewGuid().ToString()); store.RemoveAll(x => id(x) == id(entity)); store.Add(entity); return entity; }); + mock.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((object key) => store.FirstOrDefault(x => id(x) == (string)key)); + return mock; + } + + private async Task SeedScheduleAsync() + { + var schedule = await _rates.SaveScheduleAsync(new RateSchedule { DepartmentId = DeptId, Name = "2026 wildfire", Currency = "CAD" }, User, null, null); + var bands = _rates.PrefillHourlyBands(40, 20, 1.5m, 8, 2m, 12); + await _rates.SaveEntryAsync(new RateScheduleEntry { RateScheduleId = schedule.RateScheduleId, DepartmentId = DeptId, EntryType = (int)RateEntryTypes.PersonnelCertification, Name = "FFT2", CertificationCode = "FFT2", BillingBasis = (int)BillingBases.Hourly, Bands = bands }, User, null, null); + await _rates.SaveEntryAsync(new RateScheduleEntry { RateScheduleId = schedule.RateScheduleId, DepartmentId = DeptId, EntryType = (int)RateEntryTypes.Crew, Name = "Type 6 (3)", GroupKey = "t6", CrewSize = 3, BillingBasis = (int)BillingBases.Hourly, Bands = new List { new RateScheduleEntryBand { BandType = (int)RateBandTypes.Deployment, Rate = 300 } } }, User, null, null); + await _rates.SavePremiumAsync(new RatePremium { RateScheduleId = schedule.RateScheduleId, DepartmentId = DeptId, Name = "Night", DeploymentAdder = 5, Overtime1Adder = 7.5m }, User, null, null); + return await _rates.GetScheduleByIdAsync(schedule.RateScheduleId, DeptId); + } + + #region Rate schedules + + [Test] + public async Task Schedule_graph_saves_bands_prefilled_from_multipliers_and_clones_with_new_ids() + { + var schedule = await SeedScheduleAsync(); + schedule.Entries.Should().HaveCount(2); + var fft2 = schedule.Entries.Single(e => e.Name == "FFT2"); + fft2.Bands.Select(b => (b.BandType, b.Rate)).Should().BeEquivalentTo(new[] { ((int)RateBandTypes.Standby, 20m), ((int)RateBandTypes.Deployment, 40m), ((int)RateBandTypes.Overtime1, 60m), ((int)RateBandTypes.Overtime2, 80m) }); + fft2.Band(RateBandTypes.Overtime2).ThresholdStartHours.Should().Be(12); + schedule.Premiums.Should().ContainSingle(p => p.Name == "Night"); + _audits.Should().Contain(a => a.Type == AuditLogTypes.RateScheduleCreated).And.Contain(a => a.Type == AuditLogTypes.RateScheduleEntryChanged).And.Contain(a => a.Type == AuditLogTypes.RatePremiumChanged); + + var clone = await _rates.CloneScheduleAsync(schedule.RateScheduleId, DeptId, "2027 wildfire", User, null, null); + clone.RateScheduleId.Should().NotBe(schedule.RateScheduleId); + clone.Entries.Should().HaveCount(2); + clone.Entries.Select(e => e.RateScheduleEntryId).Should().NotIntersectWith(schedule.Entries.Select(e => e.RateScheduleEntryId)); + clone.Entries.Single(e => e.Name == "FFT2").Bands.Should().HaveCount(4); + clone.Premiums.Should().ContainSingle(); + } + + [Test] + public async Task Entry_validation_rejects_crew_without_size_and_duplicate_bands() + { + var schedule = await _rates.SaveScheduleAsync(new RateSchedule { DepartmentId = DeptId, Name = "S" }, User, null, null); + (await FluentActions.Awaiting(() => _rates.SaveEntryAsync(new RateScheduleEntry { RateScheduleId = schedule.RateScheduleId, DepartmentId = DeptId, EntryType = (int)RateEntryTypes.Crew, Name = "Crew" }, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("rateschedules_crew_size_required"); + var dup = new List { new RateScheduleEntryBand { BandType = 1, Rate = 1 }, new RateScheduleEntryBand { BandType = 1, Rate = 2 } }; + (await FluentActions.Awaiting(() => _rates.SaveEntryAsync(new RateScheduleEntry { RateScheduleId = schedule.RateScheduleId, DepartmentId = DeptId, EntryType = 0, Name = "X", Bands = dup }, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("rateschedules_band_duplicate"); + } + + [Test] + public async Task Effective_schedule_cascades_contract_then_profile_then_department_default() + { + var defaultSchedule = await _rates.SaveScheduleAsync(new RateSchedule { DepartmentId = DeptId, Name = "A department default" }, User, null, null); + var profileSchedule = await _rates.SaveScheduleAsync(new RateSchedule { DepartmentId = DeptId, Name = "Profile" }, User, null, null); + var contractSchedule = await _rates.SaveScheduleAsync(new RateSchedule { DepartmentId = DeptId, Name = "Contract" }, User, null, null); + (await _rates.GetEffectiveScheduleForContactAsync("customer", DeptId)).RateScheduleId.Should().Be(defaultSchedule.RateScheduleId); + + _profiles.Add(new CustomerBillingProfile { CustomerBillingProfileId = "prof", ContactId = "customer", DepartmentId = DeptId, Active = true, DefaultRateScheduleId = profileSchedule.RateScheduleId }); + (await _rates.GetEffectiveScheduleForContactAsync("customer", DeptId)).RateScheduleId.Should().Be(profileSchedule.RateScheduleId); + + var contract = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", StartOn = Day, RateScheduleId = contractSchedule.RateScheduleId }, User, null, null); + (await _rates.GetEffectiveScheduleForContactAsync("customer", DeptId, contract.ServiceContractId)).RateScheduleId.Should().Be(contractSchedule.RateScheduleId); + + // An expired contract schedule falls through to the profile. + contractSchedule.ExpiresOn = Day.AddYears(-1); + await _rates.SaveScheduleAsync(contractSchedule, User, null, null); + (await _rates.GetEffectiveScheduleForContactAsync("customer", DeptId, contract.ServiceContractId)).RateScheduleId.Should().Be(profileSchedule.RateScheduleId); + } + + [Test] + public async Task Export_import_round_trips_the_graph_without_ids_and_delete_refuses_a_schedule_in_use() + { + var schedule = await SeedScheduleAsync(); + var json = await _rates.ExportScheduleJsonAsync(schedule.RateScheduleId, DeptId); + json.Should().NotContain(schedule.RateScheduleId).And.Contain("\"GroupKey\": \"t6\""); + var imported = await _rates.ImportScheduleJsonAsync(DeptId, json, User, null, null); + imported.RateScheduleId.Should().NotBe(schedule.RateScheduleId); + imported.Currency.Should().Be("CAD"); + imported.Entries.Should().HaveCount(2); + imported.Entries.Single(e => e.Name == "FFT2").Band(RateBandTypes.Overtime1).Rate.Should().Be(60); + imported.Premiums.Single().Overtime1Adder.Should().Be(7.5m); + (await FluentActions.Awaiting(() => _rates.ImportScheduleJsonAsync(DeptId, "{ nope", User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("rateschedules_import_invalid"); + + await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", StartOn = Day, RateScheduleId = schedule.RateScheduleId }, User, null, null); + (await FluentActions.Awaiting(() => _rates.DeleteScheduleAsync(schedule.RateScheduleId, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("rateschedules_in_use"); + } + + #endregion + + #region Contracts and compliance + + [Test] + public async Task Contract_status_machine_publishes_79_and_refuses_bad_transitions() + { + var contract = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", ContractNumber = "WFS-1", StartOn = Day, EndOn = Day.AddMonths(6), TermsNetDays = 45, InvoiceSubmissionEmail = "ap@province.example" }, User, null, null); + contract.Status.Should().Be((int)ServiceContractStatuses.Draft); + (await FluentActions.Awaiting(() => _contractService.SetContractStatusAsync(contract.ServiceContractId, DeptId, ServiceContractStatuses.Suspended, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("contracts_status_transition_invalid"); + + var active = await _contractService.SetContractStatusAsync(contract.ServiceContractId, DeptId, ServiceContractStatuses.Active, User, null, null); + active.Status.Should().Be((int)ServiceContractStatuses.Active); + var evt = _published.Single(e => e.Trigger == WorkflowTriggerEventType.ContractStatusChanged); + evt.AggregateType.Should().Be("ServiceContract"); + var payload = JObject.FromObject(evt.Payload); + payload["OldStatus"].Value().Should().Be(0); + payload["ContactName"].Value().Should().Be("Province Wildfire"); + payload["ContractNumber"].Value().Should().Be("WFS-1"); + (await FluentActions.Awaiting(() => _contractService.DeleteContractAsync(contract.ServiceContractId, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("contracts_active"); + (await FluentActions.Awaiting(() => _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Bad", StartOn = Day, EndOn = Day.AddDays(-1) }, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("contracts_dates_invalid"); + } + + [Test] + public async Task Compliance_evaluation_uses_current_department_documents_and_deployment_attachments() + { + var contract = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", StartOn = Day }, User, null, null); + await _contractService.SaveRequirementsAsync(contract.ServiceContractId, DeptId, new List + { + new ServiceContractDocumentRequirement { Name = "Insurance", Stage = (int)DocumentRequirementStages.InvoiceSubmission, ComplianceDocumentType = (int)ComplianceDocumentTypes.InsuranceCertificate, IsMandatory = true }, + new ServiceContractDocumentRequirement { Name = "Signed service request", Stage = (int)DocumentRequirementStages.DeploymentStart, IsMandatory = true }, + new ServiceContractDocumentRequirement { Name = "Bond", Stage = (int)DocumentRequirementStages.BidSubmission, ComplianceDocumentType = (int)ComplianceDocumentTypes.Bond, IsMandatory = false } + }, User, null, null); + await _contractService.SaveComplianceDocumentAsync(new DepartmentComplianceDocument { DepartmentId = DeptId, DocumentType = (int)ComplianceDocumentTypes.InsuranceCertificate, Name = "COI 2026", DocumentNumber = "POL-9", ExpiresOn = Day.AddMonths(3) }, new byte[] { 1, 2, 3 }, "coi.pdf", "application/pdf", User, null, null); + await _contractService.SaveComplianceDocumentAsync(new DepartmentComplianceDocument { DepartmentId = DeptId, DocumentType = (int)ComplianceDocumentTypes.Bond, Name = "Old bond", ExpiresOn = Day.AddYears(-1) }, null, null, null, User, null, null); + + var forContract = await _contractService.GetContractComplianceForContractAsync(contract.ServiceContractId, DeptId); + forContract.Items.Should().HaveCount(3); + forContract.Items.Single(i => i.Name == "Insurance").Satisfied.Should().BeTrue(); + forContract.Items.Single(i => i.Name == "Insurance").SatisfiedBy.Should().Be("COI 2026"); + forContract.Items.Single(i => i.Name == "Bond").Satisfied.Should().BeFalse("an expired document does not satisfy"); + forContract.Items.Single(i => i.Name == "Signed service request").Satisfied.Should().BeFalse(); + forContract.AllMandatorySatisfied.Should().BeFalse(); + + _deploymentRows.Setup(d => d.GetByIdForDepartmentAsync("dep-1", DeptId)).ReturnsAsync(new Deployment { DeploymentId = "dep-1", DepartmentId = DeptId, ServiceContractId = contract.ServiceContractId }); + _attachments.Setup(a => a.GetByDeploymentAsync("dep-1")).ReturnsAsync(new List { new DeploymentAttachment { DeploymentAttachmentId = 5, AttachmentType = (int)DeploymentAttachmentTypes.SignedServiceRequest, Name = "Signed request" } }); + var forDeployment = await _contractService.GetContractComplianceAsync("dep-1", DeptId); + forDeployment.Items.Single(i => i.Name == "Signed service request").DeploymentAttachmentId.Should().Be(5); + forDeployment.AllMandatorySatisfied.Should().BeTrue(); + (await _contractService.GetComplianceDocumentAsync(1, DeptId, includeData: true)).Data.Should().Equal(1, 2, 3); + (await _contractService.GetComplianceDocumentsAsync(DeptId)).Should().OnlyContain(d => d.Data == null, "list reads never carry bytes"); + } + + [Test] + public async Task Expiry_sweep_expires_lapsed_contracts_announces_expiring_ones_once_a_day_and_is_department_gated() + { + var lapsed = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Lapsed", StartOn = Day.AddYears(-1), EndOn = Day.AddDays(-1), Status = (int)ServiceContractStatuses.Active }, User, null, null); + var ending = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Ending", StartOn = Day.AddYears(-1), EndOn = Day.AddDays(10), Status = (int)ServiceContractStatuses.Active }, User, null, null); + _published.Clear(); + var sweepDay = Day.AddYears(1).AddDays(new Random().Next(0, 300)); + lapsed.EndOn = sweepDay.AddDays(-1); ending.EndOn = sweepDay.AddDays(10); + await _contractService.RunExpirySweepAsync(sweepDay, null); + _contracts.Single(c => c.ServiceContractId == lapsed.ServiceContractId).Status.Should().Be((int)ServiceContractStatuses.Expired); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.ContractStatusChanged && e.AggregateId == lapsed.ServiceContractId); + var expiring = _published.Single(e => e.Trigger == WorkflowTriggerEventType.ContractExpiring); + expiring.AggregateId.Should().Be(ending.ServiceContractId); + JObject.FromObject(expiring.Payload)["DaysUntilEnd"].Value().Should().Be(10); + + _published.Clear(); + await _contractService.RunExpirySweepAsync(sweepDay.AddHours(2), null); + _published.Should().NotContain(e => e.Trigger == WorkflowTriggerEventType.ContractExpiring, "one announcement per contract per day"); + + _published.Clear(); + ending.EndOn = sweepDay.AddDays(30).AddDays(5); + (await _contractService.RunExpirySweepAsync(sweepDay.AddDays(30), _ => Task.FromResult(false))).Should().Be(0, "departments without the entitlement are skipped"); + _published.Should().BeEmpty(); + } + + #endregion + + #region Bids + + private async Task<(Bid Bid, RateSchedule Schedule)> SeedBidAsync(decimal? contractDiscount = 10, decimal? profileDiscount = 5) + { + var schedule = await SeedScheduleAsync(); + _profiles.Add(new CustomerBillingProfile { CustomerBillingProfileId = "prof", ContactId = "customer", DepartmentId = DeptId, Active = true, DefaultDiscountPercent = profileDiscount, TaxRate = 5 }); + var contract = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", StartOn = Day, RateScheduleId = schedule.RateScheduleId, DiscountPercent = contractDiscount, TermsNetDays = 45, Status = (int)ServiceContractStatuses.Active }, User, null, null); + var bid = await _bidsService.CreateDraftBidAsync(DeptId, "customer", contract.ServiceContractId, "Type 6 engine, Ridge Fire", User, null, null); + return (bid, schedule); + } + + [Test] + public async Task Draft_bid_takes_the_next_number_the_contract_schedule_and_the_discount_cascade_and_publishes_74() + { + var (bid, schedule) = await SeedBidAsync(); + bid.BidNumber.Should().Be(101); + bid.RateScheduleId.Should().Be(schedule.RateScheduleId); + bid.DiscountPercent.Should().Be(10, "contract beats profile"); + bid.CustomerBillingProfileId.Should().Be("prof"); + bid.ValidUntil.Should().NotBeNull(); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.BidCreated && e.AggregateType == "Bid"); + JObject.FromObject(_published.Single(e => e.Trigger == WorkflowTriggerEventType.BidCreated).Payload)["Currency"].Value().Should().Be("CAD"); + + var noContract = await _bidsService.CreateDraftBidAsync(DeptId, "customer", null, null, User, null, null); + noContract.DiscountPercent.Should().Be(5, "profile default when no contract"); + noContract.Title.Should().Be("Bid for Province Wildfire"); + (await FluentActions.Awaiting(() => _bidsService.CreateDraftBidAsync(DeptId, "other", bid.ServiceContractId, null, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_contract_contact_mismatch"); + } + + [Test] + public async Task Lines_snapshot_the_entry_rate_plus_premiums_and_estimates_follow_the_invoice_rules() + { + var (bid, schedule) = await SeedBidAsync(); + var fft2 = schedule.Entries.Single(e => e.Name == "FFT2"); + var night = schedule.Premiums.Single(); + var saved = await _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List + { + new BidLineItem { RateScheduleEntryId = fft2.RateScheduleEntryId, LineType = (int)BidLineTypes.PersonnelCertification, Description = "FFT2 × 3", Quantity = 3, EstimatedHoursPerDay = 10, EstimatedDays = 5, PremiumIdsJson = "[\"" + night.RatePremiumId + "\"]" }, + new BidLineItem { LineType = (int)BidLineTypes.FreeForm, Description = "Mobilization", Quantity = 1, UnitRate = 500, Taxable = false } + }, User, null, null); + var personnel = saved.LineItems.Single(l => l.LineType == (int)BidLineTypes.PersonnelCertification); + personnel.UnitRate.Should().Be(45, "deployment band 40 + night adder 5"); + personnel.EstimatedAmount.Should().Be(3 * 45 * 10 * 5); + saved.EstimatedSubTotal.Should().Be(6750 + 500); + saved.EstimatedDiscountAmount.Should().Be(725); + // 5 % tax on the taxable base after the pro-rata discount: (6750 − 725 × 6750/7250) × 5 %. + saved.EstimatedTaxAmount.Should().Be(Math.Round((6750m - Math.Round(725m * 6750m / 7250m, 2)) * 0.05m, 2)); + saved.EstimatedTotal.Should().Be(saved.EstimatedSubTotal - saved.EstimatedDiscountAmount + saved.EstimatedTaxAmount); + + // Re-saving with one line keeps its id and removes the other. + var again = await _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List { new BidLineItem { BidLineItemId = personnel.BidLineItemId, RateScheduleEntryId = fft2.RateScheduleEntryId, Description = "FFT2 × 3", Quantity = 3, UnitRate = 45, EstimatedHoursPerDay = 8, EstimatedDays = 5 } }, User, null, null); + again.LineItems.Should().ContainSingle().Which.BidLineItemId.Should().Be(personnel.BidLineItemId); + (await FluentActions.Awaiting(() => _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List { new BidLineItem { Description = "", Quantity = 1 } }, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_line_invalid"); + } + + [Test] + public async Task Lifecycle_sends_the_pdf_publishes_75_to_78_and_locks_after_acceptance() + { + var (bid, schedule) = await SeedBidAsync(); + (await FluentActions.Awaiting(() => _bidsService.SubmitBidAsync(bid.BidId, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_no_lines"); + await _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List { new BidLineItem { Description = "Engine", Quantity = 1, UnitRate = 1000, EstimatedDays = 3 } }, User, null, null); + (await FluentActions.Awaiting(() => _bidsService.AcceptBidAsync(bid.BidId, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_status_transition_invalid"); + + EmailNotification sent = null; + _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), DeptId, null, null, "Bid #101", It.IsAny())).Callback((n, _, __, ___, ____, _____) => sent = n).ReturnsAsync(true); + var submitted = await _bidsService.SendBidAsync(bid.BidId, DeptId, null, User, null, null); + submitted.Status.Should().Be((int)BidStatuses.Submitted); + submitted.SentToEmail.Should().Be("ap@province.example", "the contact e-mail is the default recipient"); + sent.AttachmentName.Should().Be("bid-101.pdf"); + System.Text.Encoding.UTF8.GetString(sent.AttachmentData).Should().Contain("Bid #101").And.Contain("Test County Fire Ltd.").And.Contain("Engine"); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.BidSent); + + var accepted = await _bidsService.AcceptBidAsync(bid.BidId, DeptId, User, null, null); + accepted.AcceptedOn.Should().NotBeNull(); + accepted.IsEditable.Should().BeFalse(); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.BidAccepted); + (await FluentActions.Awaiting(() => _bidsService.SaveBidAsync(new Bid { BidId = bid.BidId, DepartmentId = DeptId, Title = "x" }, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_locked"); + + var second = await _bidsService.CreateDraftBidAsync(DeptId, "customer", null, "Second", User, null, null); + await _bidsService.SaveBidLineItemsAsync(second.BidId, DeptId, new List { new BidLineItem { Description = "Line", Quantity = 1, UnitRate = 1 } }, User, null, null); + await _bidsService.SubmitBidAsync(second.BidId, DeptId, User, null, null); + var declined = await _bidsService.DeclineBidAsync(second.BidId, DeptId, "Too expensive", User, null, null); + declined.DeclineReason.Should().Be("Too expensive"); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.BidDeclined && e.AggregateId == second.BidId); + _audits.Select(a => a.Type).Should().Contain(new[] { AuditLogTypes.BidCreated, AuditLogTypes.BidSent, AuditLogTypes.BidAccepted, AuditLogTypes.BidDeclined }); + } + + [Test] + public async Task Expiry_sweep_expires_submitted_bids_past_valid_until_and_publishes_78() + { + var (bid, _) = await SeedBidAsync(); + await _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List { new BidLineItem { Description = "Line", Quantity = 1, UnitRate = 1 } }, User, null, null); + await _bidsService.SubmitBidAsync(bid.BidId, DeptId, User, null, null); + _bids.Single().ValidUntil = Day.AddDays(-1); + (await _bidsService.RunExpirySweepAsync(Day, _ => Task.FromResult(false))).Should().Be(0); + (await _bidsService.RunExpirySweepAsync(Day, null)).Should().Be(1); + _bids.Single().Status.Should().Be((int)BidStatuses.Expired); + _published.Should().ContainSingle(e => e.Trigger == WorkflowTriggerEventType.BidExpired); + (await _bidsService.SubmitBidAsync(bid.BidId, DeptId, User, null, null)).Status.Should().Be((int)BidStatuses.Submitted, "an expired bid can be re-submitted"); + } + + [Test] + public async Task Conversion_creates_the_call_and_deployment_with_rate_snapshots_and_stamps_the_bid() + { + var (bid, schedule) = await SeedBidAsync(); + var crew = schedule.Entries.Single(e => e.EntryType == (int)RateEntryTypes.Crew); + var fft2 = schedule.Entries.Single(e => e.Name == "FFT2"); + await _bidsService.SaveBidLineItemsAsync(bid.BidId, DeptId, new List { new BidLineItem { RateScheduleEntryId = crew.RateScheduleEntryId, LineType = (int)BidLineTypes.Crew, Description = "Type 6", Quantity = 1, CrewSize = 3 } }, User, null, null); + await _bidsService.SubmitBidAsync(bid.BidId, DeptId, User, null, null); + var request = new BidConversionRequest { BidId = bid.BidId, CallName = "Ridge Fire", CallPriority = 2, StartOn = Day, EndOn = Day.AddDays(3), Address = "Ridge Rd", Units = new List { new BidConversionUnit { UnitId = 1, RateScheduleEntryId = crew.RateScheduleEntryId, CallSign = "E6", Seats = new List { new BidConversionSeat { UserId = "alice", RateScheduleEntryId = fft2.RateScheduleEntryId, PremiumIds = new List { schedule.Premiums[0].RatePremiumId } } } } } }; + (await FluentActions.Awaiting(() => _bidsService.ConvertBidToDeploymentAsync(request, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_not_accepted"); + await _bidsService.AcceptBidAsync(bid.BidId, DeptId, User, null, null); + + Call savedCall = null; + _calls.Setup(c => c.SaveCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Call c, CancellationToken _) => { c.CallId = 42; savedCall = c; return c; }); + Deployment savedDeployment = null; + _deployments.Setup(d => d.SaveDeploymentAsync(It.IsAny(), User, null, null, It.IsAny())).ReturnsAsync((Deployment d, string _, string __, string ___, CancellationToken ____) => { d.DeploymentId ??= "dep-1"; savedDeployment = d; return d; }); + _deployments.Setup(d => d.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(() => savedDeployment); + DeploymentPersonnelInput seated = null; + _deployments.Setup(d => d.AddUnitAsync("dep-1", DeptId, 1, "E6", null, User, null, null, It.IsAny(), crew.RateScheduleEntryId)).ReturnsAsync(new DeploymentRosterResult { Unit = new DeploymentUnit { DeploymentUnitId = "du-1", UnitId = 1 } }); + _deployments.Setup(d => d.AddPersonnelAsync("dep-1", DeptId, It.IsAny(), User, null, null, It.IsAny())).Callback((_, __, input, ___, ____, _____, ______) => seated = input).ReturnsAsync(new DeploymentRosterResult { Personnel = new DeploymentPersonnel { DeploymentPersonnelId = "dp-1" }, Warnings = new List { new DeploymentRosterWarning { Code = DeploymentRosterWarning.CertificationExpiring, SubjectId = "alice" } } }); + _calendar.Setup(c => c.AddNewCalendarItemAsync(It.IsAny(), "Pacific Standard Time", It.IsAny())).ReturnsAsync((CalendarItem item, string _, CancellationToken __) => { item.CalendarItemId = 9; return item; }); + + var result = await _bidsService.ConvertBidToDeploymentAsync(request, DeptId, User, null, null); + result.CallId.Should().Be(42); + savedCall.Contacts.Should().ContainSingle(c => c.ContactId == "customer"); + savedCall.ExternalIdentifier.Should().Be("bid:101"); + savedDeployment.BidId.Should().Be(bid.BidId); + savedDeployment.FinanceMode.Should().Be((int)DeploymentFinanceModes.Billable); + savedDeployment.RateScheduleId.Should().Be(schedule.RateScheduleId); + savedDeployment.DiscountPercent.Should().Be(10); + savedDeployment.CalendarItemId.Should().Be(9); + seated.RateScheduleEntryId.Should().Be(fft2.RateScheduleEntryId); + seated.PremiumIds.Should().ContainSingle(); + seated.DeploymentUnitId.Should().Be("du-1"); + result.Warnings.Should().ContainSingle(w => w.Code == DeploymentRosterWarning.CertificationExpiring); + result.Bid.ConvertedCallId.Should().Be(42); + result.Bid.ConvertedDeploymentId.Should().Be("dep-1"); + _audits.Should().Contain(a => a.Type == AuditLogTypes.BidConverted); + (await FluentActions.Awaiting(() => _bidsService.ConvertBidToDeploymentAsync(request, DeptId, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("bids_already_converted"); + } + + #endregion + + #region Engine + + [Test] + public async Task Engine_generates_a_draft_invoice_with_dtr_provenance_and_marks_the_reports_billed() + { + var schedule = await SeedScheduleAsync(); + var fft2 = schedule.Entries.Single(e => e.Name == "FFT2"); + var deployment = new Deployment { DeploymentId = "dep-1", DepartmentId = DeptId, FinanceMode = (int)DeploymentFinanceModes.Billable, ContactId = "customer", RateScheduleId = schedule.RateScheduleId, Name = "Ridge Fire", CallId = 42, DiscountPercent = 10, Personnel = new List { new DeploymentPersonnel { DeploymentPersonnelId = "dp-1", UserId = "alice", RateScheduleEntryId = fft2.RateScheduleEntryId, DisplayName = "Alice Smith" } } }; + _deployments.Setup(d => d.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(deployment); + var timeTracking = new Mock(); + timeTracking.Setup(t => t.GetUnbilledApprovedReportsAsync(DeptId, "dep-1")).ReturnsAsync(new List { new DeploymentTimeReport { DeploymentTimeReportId = "r1", DeploymentId = "dep-1", DepartmentId = DeptId, ReportNumber = 3, ReportDate = Day, Status = (int)DeploymentTimeReportStatuses.Approved } }); + timeTracking.Setup(t => t.GetExpensesAsync("dep-1", DeptId)).ReturnsAsync(new List()); + var entries = new Mock(); + entries.Setup(e => e.GetByDeploymentAsync("dep-1")).ReturnsAsync(new List { new DeploymentTimeEntry { DeploymentTimeEntryId = "e1", DeploymentTimeReportId = "r1", SubjectType = 0, DeploymentPersonnelId = "dp-1", EntryType = 0, StartTime = Day.AddHours(6), EndTime = Day.AddHours(16) } }); + var invoicing = new Mock(); + var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = DeptId, InvoiceNumber = 500, ContactId = "customer", Currency = "CAD", DiscountPercent = 5, Status = 0, LineItems = new List() }; + invoicing.Setup(i => i.CreateDraftInvoiceAsync(DeptId, "customer", User, null, null, "CAD", It.IsAny())).ReturnsAsync(invoice); + invoicing.Setup(i => i.LinkInvoiceToDeploymentAsync("inv-1", DeptId, "dep-1", null, null, User, null, null, It.IsAny())).ReturnsAsync(invoice); + invoicing.Setup(i => i.SaveInvoiceAsync(It.IsAny(), User, null, null, It.IsAny())).ReturnsAsync((Invoice i, string _, string __, string ___, CancellationToken ____) => i); + List savedLines = null; + invoicing.Setup(i => i.SaveInvoiceLineItemsAsync("inv-1", DeptId, It.IsAny>(), User, null, null, It.IsAny())).Callback, string, string, string, CancellationToken>((_, __, l, ___, ____, _____, ______) => savedLines = l).ReturnsAsync(invoice); + invoicing.Setup(i => i.GetInvoiceByIdAsync("inv-1", DeptId)).ReturnsAsync(invoice); + var events = new Mock(); + var engine = new ContractorBillingEngine(_deployments.Object, timeTracking.Object, entries.Object, _rates, _contractService, invoicing.Object, new Mock().Object, new Mock().Object, events.Object, null); + + var preview = await engine.CalculateDeploymentChargesAsync("dep-1", DeptId); + preview.Lines.Should().ContainSingle(l => l.Kind == ContractorChargeKinds.Hourly); + preview.SubTotal.Should().Be(8 * 40 + 2 * 60); + preview.DiscountPercent.Should().Be(10); + + var generated = await engine.GenerateInvoiceFromDeploymentAsync("dep-1", DeptId, null, User, null, null); + generated.InvoiceId.Should().Be("inv-1"); + invoice.DiscountPercent.Should().Be(10, "the deployment discount is snapshotted over the profile default"); + savedLines.Should().ContainSingle(); + savedLines[0].DeploymentTimeReportId.Should().Be("r1"); + savedLines[0].CallId.Should().Be(42); + savedLines[0].Description.Should().Contain("DTR #3").And.Contain("Alice Smith"); + timeTracking.Verify(t => t.MarkTimeReportsBilledAsync(It.Is>(ids => ids.Single() == "r1"), DeptId, "inv-1", User, null, null, It.IsAny()), Times.Once); + events.Verify(e => e.SendMessage(It.Is(a => a.Type == AuditLogTypes.DeploymentInvoiceGenerated)), Times.Once); + + timeTracking.Setup(t => t.GetUnbilledApprovedReportsAsync(DeptId, "dep-1")).ReturnsAsync(new List()); + (await FluentActions.Awaiting(() => engine.GenerateInvoiceFromDeploymentAsync("dep-1", DeptId, null, User, null, null)).Should().ThrowAsync()).Which.Message.Should().Be("contractor_no_charges"); + } + + [Test] + public async Task Packet_bundles_the_invoice_pdf_dtr_pdfs_receipts_and_required_compliance_documents() + { + var contract = await _contractService.SaveContractAsync(new ServiceContract { DepartmentId = DeptId, ContactId = "customer", Name = "Standing", StartOn = Day, InvoiceSubmissionEmail = "ap@province.example" }, User, null, null); + await _contractService.SaveRequirementsAsync(contract.ServiceContractId, DeptId, new List { new ServiceContractDocumentRequirement { Name = "Insurance", Stage = (int)DocumentRequirementStages.InvoiceSubmission, ComplianceDocumentType = (int)ComplianceDocumentTypes.InsuranceCertificate }, new ServiceContractDocumentRequirement { Name = "Bond", Stage = (int)DocumentRequirementStages.InvoiceSubmission, ComplianceDocumentType = (int)ComplianceDocumentTypes.Bond } }, User, null, null); + await _contractService.SaveComplianceDocumentAsync(new DepartmentComplianceDocument { DepartmentId = DeptId, DocumentType = (int)ComplianceDocumentTypes.InsuranceCertificate, Name = "COI", ExpiresOn = Day.AddYears(1) }, new byte[] { 9, 9 }, "coi.pdf", "application/pdf", User, null, null); + var deployment = new Deployment { DeploymentId = "dep-1", DepartmentId = DeptId, ServiceContractId = contract.ServiceContractId }; + _deploymentRows.Setup(d => d.GetByIdForDepartmentAsync("dep-1", DeptId)).ReturnsAsync(deployment); + _deployments.Setup(d => d.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(deployment); + _deployments.Setup(d => d.GetAttachmentAsync(5, DeptId, true)).ReturnsAsync(new DeploymentAttachment { DeploymentAttachmentId = 5, FileName = "receipt.jpg", Data = new byte[] { 1 } }); + var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = DeptId, InvoiceNumber = 500, DeploymentId = "dep-1", ServiceContractId = contract.ServiceContractId, LineItems = new List { new InvoiceLineItem { DeploymentTimeReportId = "r1" } } }; + var invoicing = new Mock(); + invoicing.Setup(i => i.GetInvoiceByIdAsync("inv-1", DeptId)).ReturnsAsync(invoice); + invoicing.Setup(i => i.GetInvoicePdfAsync("inv-1", DeptId)).ReturnsAsync(new byte[] { 7 }); + InvoiceSendAttachment sentAttachment = null; + invoicing.Setup(i => i.SendInvoiceAsync("inv-1", DeptId, "ap@province.example", It.IsAny(), User, null, null, It.IsAny())).Callback((_, __, ___, a, ____, _____, ______, _______) => sentAttachment = a).ReturnsAsync(invoice); + var timeTracking = new Mock(); + timeTracking.Setup(t => t.GetTimeReportByIdAsync("r1", DeptId)).ReturnsAsync(new DeploymentTimeReport { DeploymentTimeReportId = "r1", ReportNumber = 3, ReportDate = Day }); + timeTracking.Setup(t => t.GetTimeReportPdfAsync("r1", DeptId)).ReturnsAsync(new byte[] { 8 }); + timeTracking.Setup(t => t.GetExpensesAsync("dep-1", DeptId)).ReturnsAsync(new List { new DeploymentExpense { DeploymentExpenseId = "x1", DeploymentTimeReportId = "r1", Billable = true, ReceiptAttachmentId = 5 } }); + var engine = new ContractorBillingEngine(_deployments.Object, timeTracking.Object, new Mock().Object, _rates, _contractService, invoicing.Object, new Mock().Object, new Mock().Object, new Mock().Object, null); + + var packet = await engine.BuildInvoicePacketAsync("inv-1", DeptId); + packet.FileName.Should().Be("invoice-500-packet.zip"); + packet.Contents.Should().BeEquivalentTo("invoice-500.pdf", "dtr/dtr-3-2026-09-18.pdf", "receipts/receipt.jpg", "compliance/coi.pdf"); + packet.MissingRequirements.Should().Equal("Bond"); + using (var zip = new ZipArchive(new MemoryStream(packet.Data), ZipArchiveMode.Read)) + zip.Entries.Select(e => e.FullName).Should().BeEquivalentTo(packet.Contents); + + await engine.SendDeploymentInvoiceAsync("inv-1", DeptId, null, User, null, null); + sentAttachment.ContentType.Should().Be("application/zip"); + sentAttachment.Contents.Should().HaveCount(4); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/ContractorChargeCalculatorTests.cs b/Tests/Resgrid.Tests/Services/ContractorChargeCalculatorTests.cs new file mode 100644 index 000000000..b765218ab --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ContractorChargeCalculatorTests.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model.Invoicing; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// + /// The pure contractor billing calculator (Workforce & Business Operations plan, C4 steps 1–7; decisions 16, 17, 21). + /// Every rule is exercised on a hand-built graph so the arithmetic is pinned independently of any repository. + /// + [TestFixture] + public class ContractorChargeCalculatorTests + { + private static readonly DateTime Day = new DateTime(2026, 9, 18, 0, 0, 0, DateTimeKind.Utc); + + #region Builders + + private static RateScheduleEntry Hourly(string id, decimal deployment, decimal? ot1 = null, decimal? ot2 = null, decimal? standby = null, string cert = null, string groupKey = null, int? crew = null, int type = (int)RateEntryTypes.PersonnelCertification) + { + var entry = new RateScheduleEntry { RateScheduleEntryId = id, Name = id, EntryType = type, BillingBasis = (int)BillingBases.Hourly, CertificationCode = cert, GroupKey = groupKey, CrewSize = crew, IsActive = true }; + entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Deployment, Rate = deployment, ThresholdStartHours = 0 }); + if (ot1.HasValue) entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Overtime1, Rate = ot1.Value, ThresholdStartHours = 8 }); + if (ot2.HasValue) entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Overtime2, Rate = ot2.Value, ThresholdStartHours = 12 }); + if (standby.HasValue) entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.Standby, Rate = standby.Value }); + return entry; + } + + private static DeploymentTimeEntry Span(int subjectType, string subjectId, DeploymentTimeEntryTypes type, int startHour, int endHour, int unpaidBreak = 0, int? crew = null, decimal? km = null, decimal? litres = null, bool agencyMeals = false) + { + var e = new DeploymentTimeEntry { DeploymentTimeEntryId = Guid.NewGuid().ToString(), DeploymentTimeReportId = "r1", SubjectType = subjectType, EntryType = (int)type, StartTime = Day.AddHours(startHour), EndTime = Day.AddHours(endHour), UnpaidBreakMinutes = unpaidBreak, CrewSizeSnapshot = crew, MileageKm = km, FuelDeductionLitres = litres, AgencySuppliedMeals = agencyMeals }; + if (subjectType == (int)DeploymentTimeSubjectTypes.Personnel) e.DeploymentPersonnelId = subjectId; + else if (subjectType == (int)DeploymentTimeSubjectTypes.Unit) e.DeploymentUnitId = subjectId; + else e.DeploymentEquipmentId = subjectId; + return e; + } + + private static ContractorChargeInput Input(RateSchedule schedule, params DeploymentTimeEntry[] entries) + { + var report = new DeploymentTimeReport { DeploymentTimeReportId = "r1", ReportNumber = 7, ReportDate = Day, IncidentNumber = "INC-1", Status = (int)DeploymentTimeReportStatuses.Approved, Entries = entries.ToList() }; + return new ContractorChargeInput + { + Deployment = new Deployment { DeploymentId = "d1", DepartmentId = 1, Currency = "USD" }, + Schedule = schedule, + Reports = new List { report }, + Personnel = new List { new DeploymentPersonnel { DeploymentPersonnelId = "p1", UserId = "u1", RateScheduleEntryId = "fft2", DeploymentUnitId = "unit1", AddedOn = Day.AddDays(-1) }, new DeploymentPersonnel { DeploymentPersonnelId = "p2", UserId = "u2", CertificationCode = "ENGB", DeploymentUnitId = "unit1", AddedOn = Day.AddDays(-1) } }, + Units = new List { new DeploymentUnit { DeploymentUnitId = "unit1", UnitId = 1, RateScheduleEntryId = "crew3" } }, + Equipment = new List { new DeploymentEquipment { DeploymentEquipmentId = "eq1", RateScheduleEntryId = "pump" } }, + SubjectNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["p1"] = "Alvarez", ["p2"] = "Chen", ["unit1"] = "Engine 6", ["eq1"] = "Pump" } + }; + } + + private static RateSchedule Schedule(params RateScheduleEntry[] entries) => new RateSchedule { RateScheduleId = "s1", Currency = "USD", PolicyJson = new RateSchedulePolicy().ToJson(), Entries = entries.ToList() }; + + #endregion + + [Test] + public void Hourly_day_splits_across_deployment_and_overtime_thresholds_after_unpaid_breaks_and_rounding() + { + var schedule = Schedule(Hourly("fft2", 40, 60, 80)); + // 06:00–19:20 with a 30-minute unpaid break = 12h50m → 13h at 30-minute rounding: 8h @ 40 + 4h @ 60 + 1h @ 80. + var set = ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 6, 19).With(e => { e.EndTime = Day.AddHours(19).AddMinutes(20); e.UnpaidBreakMinutes = 30; }))); + var line = set.Lines.Single(l => l.Kind == ContractorChargeKinds.Hourly); + line.Bands.Select(b => (b.BandType, b.Hours, b.Rate)).Should().BeEquivalentTo(new[] { ((int)RateBandTypes.Deployment, 8m, 40m), ((int)RateBandTypes.Overtime1, 4m, 60m), ((int)RateBandTypes.Overtime2, 1m, 80m) }); + line.Amount.Should().Be(320 + 240 + 80); + line.Description.Should().StartWith("2026-09-18 — DTR #7 — Incident INC-1 — fft2 — Alvarez — 13h ("); + set.SubTotal.Should().Be(640); + } + + [Test] + public void No_clear_8_carry_over_starts_the_day_in_the_overtime_band_and_standby_never_earns_overtime() + { + var schedule = Schedule(Hourly("fft2", 40, 60, 80, standby: 20)); + var input = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 6, 12), Span(0, "p1", DeploymentTimeEntryTypes.Standby, 12, 22)); + input.Reports[0].NoClear8 = true; + var line = ContractorChargeCalculator.Calculate(input).Lines.Single(l => l.Kind == ContractorChargeKinds.Hourly); + // 6h of deployment start at the OT1 threshold (8): 4h OT1 (8→12) then 2h OT2; 10h standby flat. + line.Bands.Should().ContainEquivalentOf(new { BandType = (int)RateBandTypes.Overtime1, Hours = 4m, Rate = 60m }, o => o.ExcludingMissingMembers()); + line.Bands.Should().ContainEquivalentOf(new { BandType = (int)RateBandTypes.Overtime2, Hours = 2m, Rate = 80m }, o => o.ExcludingMissingMembers()); + line.Bands.Should().ContainEquivalentOf(new { BandType = (int)RateBandTypes.Standby, Hours = 10m, Rate = 20m }, o => o.ExcludingMissingMembers()); + line.Amount.Should().Be(240 + 160 + 200); + } + + [Test] + public void Consecutive_basis_splits_overtime_per_continuous_run_while_daily_total_splits_once() + { + var schedule = Schedule(Hourly("fft2", 40, 60)); + // Two 6-hour runs separated by 3 hours: consecutive basis → no overtime; daily total → 12h = 8 + 4 OT1. + var consecutive = ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 4, 10), Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 13, 19))); + consecutive.Lines.Single().Bands.Should().OnlyContain(b => b.BandType == (int)RateBandTypes.Deployment); + consecutive.SubTotal.Should().Be(12 * 40); + + schedule.PolicyJson = new RateSchedulePolicy { OvertimeBasis = OvertimeBases.DailyTotalHours }.ToJson(); + var total = ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 4, 10), Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 13, 19))); + total.Lines.Single().Bands.Should().ContainEquivalentOf(new { BandType = (int)RateBandTypes.Overtime1, Hours = 4m }, o => o.ExcludingMissingMembers()); + total.SubTotal.Should().Be(8 * 40 + 4 * 60); + } + + [Test] + public void Travel_is_capped_and_bills_flat_unless_portal_to_portal() + { + var schedule = Schedule(Hourly("fft2", 40, 60)); + schedule.PolicyJson = new RateSchedulePolicy { TravelDayCapHours = 10 }.ToJson(); + var set = ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Travel, 0, 14), Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 14, 18))); + set.Warnings.Should().Contain(w => w.Code == ContractorChargeWarningCodes.TravelCapped); + var line = set.Lines.Single(); + line.Bands.Should().ContainEquivalentOf(new { Label = "Travel", Hours = 10m, Rate = 40m }, o => o.ExcludingMissingMembers()); + line.Bands.Should().ContainEquivalentOf(new { Label = "Deployment", Hours = 4m }, o => o.ExcludingMissingMembers()); + line.Bands.Should().NotContain(b => b.BandType == (int)RateBandTypes.Overtime1, "travel never accrues overtime"); + + schedule.PolicyJson = new RateSchedulePolicy { TravelDayCapHours = 10, PortalToPortal = true }.ToJson(); + var portal = ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Travel, 0, 14), Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 14, 18))).Lines.Single(); + portal.Bands.Sum(b => b.Hours).Should().Be(14); + portal.Bands.Should().ContainEquivalentOf(new { BandType = (int)RateBandTypes.Overtime1, Hours = 6m }, o => o.ExcludingMissingMembers()); + } + + [Test] + public void Minimums_lift_the_day_to_cancellation_unsafe_stand_down_and_daily_guarantee_hours() + { + var schedule = Schedule(Hourly("fft2", 40, 60)); + schedule.PolicyJson = new RateSchedulePolicy { DailyGuaranteeHours = 6 }.ToJson(); + ContractorChargeCalculator.Calculate(Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 10))).SubTotal.Should().Be(6 * 40, "a mobilized day bills the guarantee"); + + var cancellation = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 9)); + cancellation.CancellationDate = Day; + schedule.PolicyJson = new RateSchedulePolicy { CancellationMinimumHours = 4 }.ToJson(); + ContractorChargeCalculator.Calculate(cancellation).SubTotal.Should().Be(4 * 40); + + var unsafeDay = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 9)); + unsafeDay.Reports[0].UnsafeConditionsStandDown = true; + schedule.PolicyJson = new RateSchedulePolicy { UnsafeStandDownHours = 8 }.ToJson(); + ContractorChargeCalculator.Calculate(unsafeDay).SubTotal.Should().Be(8 * 40); + } + + [Test] + public void Unit_bills_at_the_filled_crew_size_and_falls_back_to_the_nearest_lower_sibling_with_a_warning() + { + var schedule = Schedule( + Hourly("crew3", 300, groupKey: "t6", crew: 3, type: (int)RateEntryTypes.Crew), + Hourly("crew4", 380, groupKey: "t6", crew: 4, type: (int)RateEntryTypes.Crew), + Hourly("crew5", 450, groupKey: "t6", crew: 5, type: (int)RateEntryTypes.Crew)); + var exact = ContractorChargeCalculator.Calculate(Input(schedule, Span(1, "unit1", DeploymentTimeEntryTypes.Deployment, 8, 12, crew: 4))); + exact.Lines.Single().RateScheduleEntryId.Should().Be("crew4"); + exact.Warnings.Should().BeEmpty(); + + var fallback = ContractorChargeCalculator.Calculate(Input(schedule, Span(1, "unit1", DeploymentTimeEntryTypes.Deployment, 8, 12, crew: 6))); + fallback.Lines.Single().RateScheduleEntryId.Should().Be("crew5"); + fallback.Warnings.Should().Contain(w => w.Code == ContractorChargeWarningCodes.CrewSizeFallback); + + // No snapshot on the entries: the seated roster (two people on unit1) decides; 2 is below the family → pinned entry + warning. + var roster = ContractorChargeCalculator.Calculate(Input(schedule, Span(1, "unit1", DeploymentTimeEntryTypes.Deployment, 8, 12))); + roster.Lines.Single().RateScheduleEntryId.Should().Be("crew3"); + roster.Warnings.Should().Contain(w => w.Code == ContractorChargeWarningCodes.CrewSizeFallback); + } + + [Test] + public void Daily_subjects_pick_deployment_or_standby_tier_by_hours_and_cancellation_bills_a_full_day_per_policy() + { + var pump = new RateScheduleEntry { RateScheduleEntryId = "pump", Name = "Pump", EntryType = (int)RateEntryTypes.Equipment, BillingBasis = (int)BillingBases.Daily, IsActive = true }; + pump.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.DailyDeployment, Rate = 100, DailyTierMinHours = 0, DailyTierMaxHours = 4, Label = "Half day" }); + pump.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.DailyDeployment, Rate = 180, DailyTierMinHours = 4.01m, Label = "Full day" }); + pump.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.DailyStandby, Rate = 50 }); + var schedule = Schedule(pump); + + ContractorChargeCalculator.Calculate(Input(schedule, Span(2, "eq1", DeploymentTimeEntryTypes.Deployment, 8, 11))).Lines.Single().Amount.Should().Be(100); + ContractorChargeCalculator.Calculate(Input(schedule, Span(2, "eq1", DeploymentTimeEntryTypes.Deployment, 8, 18))).Lines.Single().Amount.Should().Be(180); + ContractorChargeCalculator.Calculate(Input(schedule, Span(2, "eq1", DeploymentTimeEntryTypes.Standby, 8, 18))).Lines.Single().Amount.Should().Be(50); + + // The cancellation-day DTR lists the pump with a one-hour standby span and no deployment hours. + var cancelled = Input(schedule, Span(2, "eq1", DeploymentTimeEntryTypes.Standby, 8, 9)); + cancelled.CancellationDate = Day; + ContractorChargeCalculator.Calculate(cancelled).Lines.Single().Bands.Single().Rate.Should().Be(100, "a cancellation day with no deployment hours bills the deployment day at the lowest tier"); + schedule.PolicyJson = new RateSchedulePolicy { CancellationVehiclesFullDay = false }.ToJson(); + ContractorChargeCalculator.Calculate(cancelled).Lines.Single().Amount.Should().Be(50, "vehicles fall back to the standby day when the policy withholds the full day"); + } + + [Test] + public void Premiums_stack_per_band_and_never_multiply() + { + var schedule = Schedule(Hourly("fft2", 40, 60)); + schedule.Premiums.Add(new RatePremium { RatePremiumId = "night", Name = "Night", DeploymentAdder = 5, Overtime1Adder = 7.5m, IsActive = true }); + schedule.Premiums.Add(new RatePremium { RatePremiumId = "lead", Name = "Lead", DeploymentAdder = 3, Overtime1Adder = 3, IsActive = true }); + var input = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 6, 16)); + input.Personnel[0].PremiumIdsJson = JsonConvert.SerializeObject(new[] { "night", "lead" }); + var set = ContractorChargeCalculator.Calculate(input); + set.Lines.Where(l => l.Kind == ContractorChargeKinds.Premium).Should().HaveCount(2); + set.Lines.Single(l => l.RatePremiumId == "night").Amount.Should().Be(8 * 5 + 2 * 7.5m); + set.Lines.Single(l => l.RatePremiumId == "lead").Amount.Should().Be(8 * 3 + 2 * 3); + set.SubTotal.Should().Be(8 * 40 + 2 * 60 + 55 + 30); + } + + [Test] + public void Out_of_province_per_diem_needs_the_flag_and_air_travel_when_the_band_requires_it() + { + var entry = Hourly("fft2", 40); + entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.OutOfProvincePerPersonDaily, Rate = 75, RequiresAirTravel = true, Label = "OOP" }); + var schedule = Schedule(entry); + var input = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 12)); + ContractorChargeCalculator.Calculate(input).Lines.Should().NotContain(l => l.Kind == ContractorChargeKinds.OutOfProvince); + input.Deployment.OutOfProvince = true; + ContractorChargeCalculator.Calculate(input).Lines.Should().NotContain(l => l.Kind == ContractorChargeKinds.OutOfProvince, "the band requires air travel"); + input.Deployment.TravelViaAir = true; + var line = ContractorChargeCalculator.Calculate(input).Lines.Single(l => l.Kind == ContractorChargeKinds.OutOfProvince); + line.Amount.Should().Be(75); + line.Taxable.Should().BeFalse(); + } + + [Test] + public void Mileage_subtracts_free_units_and_fuel_deducts_per_litre_from_the_policy() + { + var pump = Hourly("pump", 10, type: (int)RateEntryTypes.Vehicle); + pump.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.MileagePerKm, Rate = 0.5m, FreeUnitsPerDay = 100 }); + var schedule = Schedule(pump); + schedule.PolicyJson = new RateSchedulePolicy { FuelDeductionRatePerLitre = 1.2m }.ToJson(); + var set = ContractorChargeCalculator.Calculate(Input(schedule, Span(2, "eq1", DeploymentTimeEntryTypes.Deployment, 8, 10, km: 260, litres: 50))); + set.Lines.Single(l => l.Kind == ContractorChargeKinds.Mileage).Amount.Should().Be(160 * 0.5m); + set.Lines.Single(l => l.Kind == ContractorChargeKinds.FuelDeduction).Amount.Should().Be(-60); + set.SubTotal.Should().Be(20 + 80 - 60); + } + + [Test] + public void Billable_expenses_pass_through_with_per_diem_validation_warnings() + { + var entry = Hourly("fft2", 40); + entry.Bands.Add(new RateScheduleEntryBand { BandType = (int)RateBandTypes.PerDiemMeal, Rate = 18, MealCode = "B" }); + var schedule = Schedule(entry); + schedule.PolicyJson = new RateSchedulePolicy { MealEligibility = new List { new MealEligibilityWindow { MealCode = "B", StartsBeforeMinutes = 7 * 60 } } }.ToJson(); + var input = Input(schedule, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 9, 17, agencyMeals: true)); + input.Expenses = new List + { + new DeploymentExpense { DeploymentExpenseId = "x1", DeploymentTimeReportId = "r1", ExpenseDate = Day, ExpenseType = (int)DeploymentExpenseTypes.PerDiemMeal, MealCode = "B", Amount = 20, Billable = true }, + new DeploymentExpense { DeploymentExpenseId = "x2", ExpenseDate = Day, ExpenseType = (int)DeploymentExpenseTypes.Ferry, Amount = 45, Billable = true, Description = "Ferry crossing" }, + new DeploymentExpense { DeploymentExpenseId = "x3", ExpenseDate = Day, ExpenseType = (int)DeploymentExpenseTypes.Fuel, Amount = 99, Billable = false } + }; + var set = ContractorChargeCalculator.Calculate(input); + var expenses = set.Lines.Where(l => l.Kind == ContractorChargeKinds.Expense).ToList(); + expenses.Should().HaveCount(2); + expenses.Should().OnlyContain(l => !l.Taxable); + expenses.Single(l => l.DeploymentExpenseId == "x2").Description.Should().Contain("Ferry — Ferry crossing"); + set.Warnings.Select(w => w.Code).Should().Contain(new[] { ContractorChargeWarningCodes.PerDiemMismatch, ContractorChargeWarningCodes.PerDiemIneligible, ContractorChargeWarningCodes.PerDiemAgencyMeals }); + } + + [Test] + public void Missing_schedule_or_entry_warns_instead_of_charging() + { + var none = Input(null, Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 12)); + var set = ContractorChargeCalculator.Calculate(none); + set.HasCharges.Should().BeFalse(); + set.Warnings.Should().ContainSingle(w => w.Code == ContractorChargeWarningCodes.ScheduleMissing); + + var byCode = ContractorChargeCalculator.Calculate(Input(Schedule(Hourly("engb", 55, cert: "ENGB")), Span(0, "p2", DeploymentTimeEntryTypes.Deployment, 8, 12))); + byCode.Lines.Single().RateScheduleEntryId.Should().Be("engb", "a person without a pinned entry matches the certification code"); + + var unmatched = ContractorChargeCalculator.Calculate(Input(Schedule(Hourly("crew3", 300, type: (int)RateEntryTypes.Crew)), Span(0, "p2", DeploymentTimeEntryTypes.Deployment, 8, 12))); + unmatched.HasCharges.Should().BeFalse(); + unmatched.Warnings.Should().ContainSingle(w => w.Code == ContractorChargeWarningCodes.RateEntryMissing && w.SubjectId == "p2"); + } + + [Test] + public void Discount_snapshot_reduces_the_total_before_tax() + { + var input = Input(Schedule(Hourly("fft2", 100)), Span(0, "p1", DeploymentTimeEntryTypes.Deployment, 8, 12)); + input.DiscountPercent = 10; + var set = ContractorChargeCalculator.Calculate(input); + set.SubTotal.Should().Be(400); + set.DiscountAmount.Should().Be(40); + set.TotalBeforeTax.Should().Be(360); + set.ReportIds.Should().Equal("r1"); + } + + [Test] + public void Rounding_and_split_helpers_behave() + { + ContractorChargeCalculator.Round(7.74m, 30).Should().Be(7.5m); + ContractorChargeCalculator.Round(7.75m, 30).Should().Be(8m); + ContractorChargeCalculator.Round(7.74m, 0).Should().Be(7.74m); + ContractorChargeCalculator.Split(10, 0, 8, 12).Should().Equal((RateBandTypes.Deployment, 8m), (RateBandTypes.Overtime1, 2m)); + ContractorChargeCalculator.Split(6, 0, null, null).Should().Equal((RateBandTypes.Deployment, 6m)); + ContractorChargeCalculator.Split(6, 8, 8, null).Should().Equal((RateBandTypes.Overtime1, 6m)); + } + } + + internal static class TestEntryExtensions + { + public static DeploymentTimeEntry With(this DeploymentTimeEntry entry, Action apply) { apply(entry); return entry; } + } +} diff --git a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs index 616a05f79..e883eb78b 100644 --- a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs @@ -118,6 +118,8 @@ public void SetUp() .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)); + _attachments.Setup(r => r.GetMetadataByIdAsync(It.IsAny())).ReturnsAsync((int id) => { var a = _storedAttachments.FirstOrDefault(x => x.DeploymentAttachmentId == id); return a == null ? null : new DeploymentAttachment { DeploymentAttachmentId = a.DeploymentAttachmentId, DeploymentId = a.DeploymentId, DepartmentId = a.DepartmentId, AttachmentType = a.AttachmentType, Name = a.Name, FileName = a.FileName, FileType = a.FileType, FileSize = a.FileSize, IsDeleted = a.IsDeleted }; }); + _attachments.Setup(r => r.MarkDeletedAsync(It.IsAny(), DeptId, It.IsAny())).ReturnsAsync((int id, int _, CancellationToken __) => { var a = _storedAttachments.FirstOrDefault(x => x.DeploymentAttachmentId == id && !x.IsDeleted); if (a == null) return 0; a.IsDeleted = true; return 1; }); _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); @@ -386,6 +388,23 @@ public async Task Attachments_reject_empty_and_oversized_uploads() (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 Deleting_an_attachment_flags_the_row_without_moving_its_bytes() + { + var d = await NewDeploymentAsync(); + var saved = await _service.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = d.DeploymentId, DepartmentId = DeptId, FileName = "map.pdf", FileType = "application/pdf", Data = new byte[] { 1, 2, 3 } }, Manager, null, null); + _attachments.Invocations.Clear(); + + (await _service.DeleteAttachmentAsync(saved.DeploymentAttachmentId, DeptId, Manager, null, null)).Should().BeTrue(); + + _storedAttachments.Single().IsDeleted.Should().BeTrue(); + _attachments.Verify(r => r.GetByIdWithDataAsync(It.IsAny()), Times.Never, "the soft delete reads metadata only"); + _attachments.Verify(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, "the flag is set in place"); + _audits.Should().ContainSingle(a => a.Type == AuditLogTypes.DeploymentAttachmentRemoved).Which.After.Should().NotContain("\"Data\":\"AQID\""); + (await _service.DeleteAttachmentAsync(saved.DeploymentAttachmentId, DeptId, Manager, null, null)).Should().BeFalse("a deleted row is not deleted twice"); + (await _service.DeleteAttachmentAsync(saved.DeploymentAttachmentId, DeptId + 1, Manager, null, null)).Should().BeFalse("another department's id is not found"); + } + [Test] public async Task Member_scope_lists_only_rostered_deployments() { diff --git a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs index b8ba0e970..f77148ceb 100644 --- a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs @@ -66,7 +66,7 @@ public void SetUp() _unitOfWork = new FakeUnitOfWork(); _departments.Setup(d => d.GetDepartmentByIdAsync(7, It.IsAny())).ReturnsAsync(new Department { DepartmentId = 7, Name = "Test County Fire" }); _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny())).Returns(html => System.Text.Encoding.UTF8.GetBytes(html)); - _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); _published = new List(); _audits = new List(); @@ -442,11 +442,13 @@ public void Render_shows_department_identity_customer_lines_discount_tax_compone } [Test] - public void Render_hides_customer_details_and_notes_on_a_protected_invoice() + public void Render_prints_notes_whole_and_never_leaks_an_enveloped_contact_name() { - var invoice = new Invoice { InvoiceId = "inv-1", InvoiceNumber = 7, Status = (int)InvoiceStatus.Sent, Currency = "USD", Total = 10m, Notes = "secret", IsProtected = true, LineItems = new List() }; - var html = InvoicingService.RenderInvoiceHtml(new InvoiceRenderModel { Invoice = invoice, DepartmentName = "D", CustomerName = ProtectedDataEnvelope.RedactionValue }); - html.Should().Contain(ProtectedDataEnvelope.RedactionValue).And.NotContain("secret"); + // The customer reads the invoice without a login: its notes are always printed. The bill-to name comes from the + // (still ADP-protected) Contact row; when the render lane could not decrypt it the safe display value is printed, never ciphertext. + var invoice = new Invoice { InvoiceId = "inv-1", InvoiceNumber = 7, Status = (int)InvoiceStatus.Sent, Currency = "USD", Total = 10m, Notes = "Deliver to gate 4", LineItems = new List() }; + var html = InvoicingService.RenderInvoiceHtml(new InvoiceRenderModel { Invoice = invoice, DepartmentName = "D", CustomerName = ProtectedDataEnvelope.SafeDisplay("rgdp:1:1:acme==") }); + html.Should().Contain("Deliver to gate 4").And.Contain(ProtectedDataEnvelope.RedactionValue).And.NotContain("rgdp:"); } [Test] @@ -460,8 +462,8 @@ public async Task Send_marks_a_draft_sent_then_emails_the_pdf_to_the_billing_ema _payments.Setup(p => p.GetByInvoiceIdAsync("inv-1", 7)).ReturnsAsync(new List()); _identities.Setup(i => i.GetByDepartmentIdAsync(7)).ReturnsAsync(new DepartmentBillingIdentity { DepartmentId = 7, LegalBusinessName = "Test County Fire District" }); EmailNotification captured = null; - _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), 7, It.IsAny(), null, "Invoice #1042")) - .Callback((n, _, __, ___, ____) => captured = n).ReturnsAsync(true); + _email.Setup(e => e.SendInvoiceAsync(It.IsAny(), 7, It.IsAny(), null, "Invoice #1042", It.IsAny())) + .Callback((n, _, __, ___, ____, _____) => captured = n).ReturnsAsync(true); var result = await Build().SendInvoiceAsync("inv-1", 7, null, "user-1", null, null); @@ -488,7 +490,7 @@ public async Task Send_refuses_void_invoices_and_invoices_without_a_recipient() _profiles.Setup(p => p.GetByIdForDepartmentAsync("profile-1", 7)).ReturnsAsync(Profile()); var noRecipient = async () => await Build().SendInvoiceAsync("inv-s", 7, " ", "u", null, null); await noRecipient.Should().ThrowAsync().WithMessage("invoicing_no_recipient_email"); - _email.Verify(e => e.SendInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _email.Verify(e => e.SendInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Test] @@ -509,18 +511,24 @@ public async Task Overdue_sweep_skips_departments_whose_entitlement_lapsed() // ---------------------------------------------------------------- workflow payload hygiene [Test] - public async Task Workflow_payload_redacts_the_contact_name_on_a_protected_invoice() + public async Task Workflow_payload_carries_the_contact_name_as_safe_display_and_omits_free_text() { _profiles.Setup(p => p.GetByContactIdAsync("contact-1", 7)).ReturnsAsync(Profile()); _sequence.Setup(s => s.GetNextNumberAsync(7, It.IsAny())).ReturnsAsync(1); _invoices.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Invoice i, CancellationToken _, bool __) => { i.InvoiceId ??= "inv-x"; i.IsProtected = true; return i; }); + .ReturnsAsync((Invoice i, CancellationToken _, bool __) => { i.InvoiceId ??= "inv-x"; return i; }); await Build().CreateDraftInvoiceAsync(7, "contact-1", "user-1", null, null); var payload = Newtonsoft.Json.Linq.JObject.FromObject(_published.Single().Payload); - ((string)payload["ContactName"]).Should().Be(ProtectedDataEnvelope.RedactionValue); + ((string)payload["ContactName"]).Should().Be("Acme Logistics"); payload.Properties().Select(p => p.Name).Should().NotContain(new[] { "Notes", "SentToEmail", "VoidReason", "BillingEmail" }); + + // An enveloped Contact row (ADP still covers Contacts) reaches the payload as the safe display value, never as ciphertext. + _published.Clear(); + _contacts.Setup(c => c.GetContactByIdAsync(It.IsAny())).ReturnsAsync(new Contact { ContactId = "contact-1", DepartmentId = 7, CompanyName = "rgdp:1:1:acme==" }); + await Build().CreateDraftInvoiceAsync(7, "contact-1", "user-1", null, null); + ((string)Newtonsoft.Json.Linq.JObject.FromObject(_published.Single().Payload)["ContactName"]).Should().Be(ProtectedDataEnvelope.RedactionValue); } [Test] diff --git a/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs index 4307bf837..2e446eecc 100644 --- a/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/PersonnelRolesServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data.Common; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -161,6 +162,10 @@ public class when_replacing_a_roles_members : with_the_personnel_roles_service private void Arrange() { + _departmentMembersRepositoryMock.Setup(x => x.GetAllDepartmentMembersUnlimitedAsync(1)).ReturnsAsync(new List + { + new DepartmentMember { DepartmentId = 1, UserId = "keep" }, new DepartmentMember { DepartmentId = 1, UserId = "drop" }, new DepartmentMember { DepartmentId = 1, UserId = "new" } + }); _personnelRoleUsersRepositoryMock.Setup(x => x.GetAllMembersOfRoleAsync(6787)).ReturnsAsync(new List(_current)); _personnelRoleUsersRepositoryMock .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) @@ -205,6 +210,32 @@ public void a_failed_save_rolls_the_membership_delete_back() _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); } + [Test] + public void a_user_outside_the_department_is_refused_by_name_before_anything_is_written() + { + Arrange(); + + var ex = Assert.ThrowsAsync(async () => await _personnelRolesService.ReplaceRoleMembersAsync(Role(), new[] { "keep", "stranger", "new" })); + + ex.Message.Should().Be(RoleMembershipException.NotInDepartment); + ex.UserId.Should().Be("stranger"); + _repositoryCallOrder.Should().BeEmpty("the refusal comes before the transaction, the deletes and the save"); + _eventAggregatorMock.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + } + + [Test] + public async Task a_standing_member_who_has_since_left_the_department_does_not_block_the_save() + { + Arrange(); + _departmentMembersRepositoryMock.Setup(x => x.GetAllDepartmentMembersUnlimitedAsync(1)).ReturnsAsync(new List { new DepartmentMember { DepartmentId = 1, UserId = "new" } }); + + // "keep" is already on the role: only the members the role gains are checked, the way the certification gate works. + var saved = await _personnelRolesService.ReplaceRoleMembersAsync(Role(), new[] { "keep", "new" }); + + saved.Users.Select(u => u.UserId).Should().BeEquivalentTo("keep", "new"); + _repositoryCallOrder.Should().EndWith("commit"); + } + [Test] public async Task an_unchanged_membership_is_rewritten_in_the_transaction_without_membership_audits() { diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index a41f34925..b4a11cb0e 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -351,12 +351,13 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "RmsRecordValues", // Contacts pre-plans, catalog v12 (Contacts plan Phase A). "ContactPreplans", "ContactPreplanHazards", "ContactAttachments", - // Invoicing, catalog v26 (Workforce & Business Operations plan, Phase B2): read through the generic Records resolvers with InvoicingProtectedFields accessors (InvoicingService.Protection.cs). - "CustomerBillingProfiles", "Invoices", "InvoicePayments", - // Certifications, catalog v27 (Workforce & Business Operations plan, Phase D): read through the generic Records resolvers with CertificationProtectedFields accessors (CertificationService.Protection.cs). + // Workforce & Business Operations: invoices, receipts, bids, contracts, compliance documents, daily time reports, + // expenses and attachments are read by customers without a login (pay page, bid e-mail, invoice packet) and are + // deliberately NOT bound. Only what stays inside the department is: + // Certifications, catalog v26 (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", + // Deployment core, catalog v27 (Phase C): the wrapper's internal notes, read with DeploymentProtectedFields accessors (DeploymentService.Protection.cs). + "Deployments", // 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 index 25ba59a06..5bb7fec9c 100644 --- a/Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/TimeTrackingServiceTests.cs @@ -100,10 +100,11 @@ public void SetUp() _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; }); + .ReturnsAsync((DeploymentTimeEntry e, CancellationToken _, bool __) => { e.DeploymentTimeEntryId ??= Guid.NewGuid().ToString(); _storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId); _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)); + _entries.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentTimeEntry e, CancellationToken _) => _storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId) > 0); _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)); @@ -288,5 +289,57 @@ public async Task Csv_export_lists_every_entry_with_its_report_context() 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\""); } + + [Test] + public async Task Csv_export_neutralises_spreadsheet_formulas_in_free_text_but_not_numbers() + { + 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 = "=HYPERLINK(\"http://evil\")"; + _storedEntries.Single().CertificationCode = "@ENGB"; + _storedEntries.Single().FuelDeductionLitres = -12.5m; + _storedReports.Single().IncidentNumber = "+1"; + var csv = await _service.ExportTimeEntriesCsvAsync("dep-1", DeptId); + + var line = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries)[1]; + line.Should().Contain("\"'=HYPERLINK(\"\"http://evil\"\")\"", "a leading = is prefixed before quoting"); + line.Should().Contain(",'@ENGB,"); + line.Should().Contain(",'+1,"); + line.Should().Contain(",-12.5,", "a negative number is not free text"); + } + + [Test] + public async Task Personnel_hours_sum_the_deployment_in_one_pass_and_skip_void_reports() + { + var live = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, Crew, null, null); + await _service.SaveTimeEntriesAsync(live.DeploymentTimeReportId, DeptId, new List { Entry("per-1", 6, 14), Entry("unit-1", 6, 14) }, Crew, null, null); + var voided = await _service.CreateTimeReportAsync("dep-1", DeptId, Day.AddDays(1), Crew, null, null); + await _service.SaveTimeEntriesAsync(voided.DeploymentTimeReportId, DeptId, new List { Entry("per-1", 6, 10, day: Day.AddDays(1)) }, Crew, null, null); + await _service.VoidTimeReportAsync(voided.DeploymentTimeReportId, DeptId, "duplicate", Approver, null, null); + _reports.Invocations.Clear(); _entries.Invocations.Clear(); + + (await _service.GetPersonnelHoursAsync("dep-1", DeptId)).Should().Be(7.5m, "8h less the 30 minute unpaid break, personnel only, the void report skipped"); + + _reports.Verify(r => r.GetByDeploymentAsync("dep-1"), Times.Once); + _entries.Verify(r => r.GetByDeploymentAsync("dep-1"), Times.Once); + _reports.Verify(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny()), Times.Never); + (await _service.GetPersonnelHoursAsync("dep-1", DeptId + 1)).Should().Be(0m); + } + + [Test] + public async Task An_existing_expense_cannot_be_rewritten_or_relinked_from_another_deployment() + { + var expense = await _service.SaveExpenseAsync(new DeploymentExpense { DeploymentId = "dep-1", DepartmentId = DeptId, ExpenseType = (int)DeploymentExpenseTypes.Fuel, Amount = 10m }, null, null, null, Crew, null, null); + var other = new Deployment { DeploymentId = "dep-2", DepartmentId = DeptId, Name = "Other", Status = (int)DeploymentStatuses.Active, Currency = "USD", LocalTimeZoneId = "UTC" }; + _deployments.Setup(r => r.GetByIdForDepartmentAsync("dep-2", DeptId)).ReturnsAsync(other); + + // A caller authorized for dep-2 submits dep-1's expense id with DeploymentId = dep-2. + (await FluentActions.Awaiting(() => _service.SaveExpenseAsync(new DeploymentExpense { DeploymentExpenseId = expense.DeploymentExpenseId, DeploymentId = "dep-2", DepartmentId = DeptId, ExpenseType = (int)DeploymentExpenseTypes.Fuel, Amount = 999m }, null, null, null, Crew, null, null)) + .Should().ThrowAsync()).Which.Message.Should().Be("expenses_not_found"); + + _storedExpenses.Single().DeploymentId.Should().Be("dep-1"); + _storedExpenses.Single().Amount.Should().Be(10m); + _audits.Should().NotContain(a => a.Type == AuditLogTypes.DeploymentExpenseUpdated); + } } } diff --git a/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs new file mode 100644 index 000000000..260aa66b7 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs @@ -0,0 +1,290 @@ +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.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// + /// The 2026-09-19 completion pass over the Workforce & Business Operations phases: every lifecycle mutation reaches + /// the Workflow Engine (triggers 180-187 join 52-57/94-95, 81-86 and 23/87-93), and Advanced Data Protection covers + /// only what stays inside the department (deployment notes, certification status reasons; catalog 27). Everything a + /// customer reads without a login — invoices, receipts, bids, contracts, compliance documents, daily time reports, + /// expenses and attachments — is stored whole so the pay page, bid e-mail and invoice packet render for them. + /// + [TestFixture] + public class WorkforceProtectionAndEventsTests + { + private const int DeptId = 7; + + private sealed class Grant : IProtectedGrantContext + { + public string GrantToken { get; set; } + public string UserId { get; set; } + public bool IsWorkloadCaller => UserId == null; + } + + #region Catalog and bindings + + [Test] + public void Catalog_27_keeps_only_internal_fields_and_binds_no_customer_facing_table() + { + var catalog = new ProtectedFieldCatalog(); + catalog.Version.Should().Be(27, "Phase D rides 26 and the completion pass 27; nothing is deployed on either yet"); + catalog.GetAll().Where(f => f.AddedInCatalogVersion == 27).Select(f => f.FieldId).Should().BeEquivalentTo(new[] + { + "personnelcertifications.statusreason", "unitcertifications.statusreason", "deployments.notes" + }); + catalog.GetAll().Where(f => f.AddedInCatalogVersion == 26).Select(f => f.FieldId).Should().Contain(new[] { "unitcertifications.notes", "unitcertifications.data", "personnelcertificationcredits.description" }); + + // Customers read these without a login (pay page, bid e-mail, invoice packet): none of it may sit under ADP. + var customerFacing = new[] + { + "Invoices", "InvoiceLineItems", "InvoicePayments", "CustomerBillingProfiles", "DepartmentBillingIdentities", + "Bids", "BidLineItems", "ServiceContracts", "DepartmentComplianceDocuments", + "DeploymentTimeReports", "DeploymentTimeEntries", "DeploymentExpenses", "DeploymentAttachments" + }; + foreach (var table in customerFacing) + { + catalog.GetAll().Should().NotContain(f => string.Equals(f.TableName, table, StringComparison.OrdinalIgnoreCase), table); + AdpTableBindings.V1.Should().NotContain(b => string.Equals(b.TableName, table, StringComparison.OrdinalIgnoreCase), table); + } + foreach (var column in AdpTableBindings.V1.SelectMany(b => b.Columns)) + catalog.GetById(column.FieldId).Should().NotBeNull(column.FieldId); + AdpTableBindings.V1.Single(b => b.TableName == "Deployments").Columns.Select(c => c.FieldId).Should().BeEquivalentTo(new[] { "deployments.notes" }); + // The accessor maps drive the seams that remain. + DeploymentProtectedFields.DeploymentFields.Keys.Should().BeEquivalentTo(new[] { "deployments.notes" }); + DeploymentProtectedFields.All().Should().ContainSingle(); + ProtectedReadService.CertificationFieldAccessors.Keys.Should().Contain("personnelcertifications.statusreason"); + CertificationProtectedFields.Unit.Keys.Should().Contain("unitcertifications.statusreason"); + } + + [Test] + public void Workload_purpose_allow_list_covers_invoice_delivery() + { + Resgrid.Config.DataProtectionConfig.BrokerWorkloadPurposes.Split(',').Select(p => p.Trim()).Should().Contain("invoicing", + "invoice, receipt and bid renders decrypt the customer's Contact row through this lane; a missing purpose fails closed and would print REDACTED as the bill-to name"); + } + + #endregion + + #region Webhook ledger + + [Test] + public void Webhook_ledger_payload_keeps_reconciliation_ids_and_drops_payer_identity() + { + var raw = "{\"id\":\"evt_1\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"id\":\"cs_1\",\"amount_total\":121500,\"currency\":\"usd\",\"payment_intent\":\"pi_1\",\"customer\":\"cus_1\",\"customer_details\":{\"email\":\"payer@example.com\",\"name\":\"Pat Payer\",\"phone\":\"+15555550100\"},\"metadata\":{\"invoiceId\":\"inv-1\"},\"payment_method_details\":{\"card\":{\"last4\":\"4242\"}}}}}"; + var minimized = PaymentWebhookPayloadMinimizer.Minimize(raw); + var json = JObject.Parse(minimized); + json["id"].Value().Should().Be("evt_1"); + json["data"]["object"]["payment_intent"].Value().Should().Be("pi_1"); + json["data"]["object"]["customer"].Value().Should().Be("cus_1", "a bare customer id is a reconciliation key"); + json["data"]["object"]["metadata"]["invoiceId"].Value().Should().Be("inv-1"); + minimized.Should().NotContain("payer@example.com").And.NotContain("Pat Payer").And.NotContain("5555550100").And.NotContain("4242"); + PaymentWebhookPayloadMinimizer.Minimize("{\"id\":\"evt_1\"}").Should().Be("{\"id\":\"evt_1\"}"); + PaymentWebhookPayloadMinimizer.Minimize("not json").Should().BeNull(); + } + + #endregion + + #region Invoicing stays whole + + [Test] + public async Task Line_items_keep_their_ids_and_invoice_text_is_stored_whole_without_a_protection_seam() + { + var stored = new List { new InvoiceLineItem { InvoiceLineItemId = "line-a", InvoiceId = "inv-1", DepartmentId = DeptId, Description = "Standby crew", Quantity = 1, UnitRate = 10, Amount = 10 }, new InvoiceLineItem { InvoiceLineItemId = "line-b", InvoiceId = "inv-1", DepartmentId = DeptId, Description = "old", Quantity = 1, UnitRate = 5, Amount = 5 } }; + var invoices = new Mock(); + var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = DeptId, Status = (int)InvoiceStatus.Draft, ContactId = "c1", Currency = "USD" }; + invoices.Setup(r => r.GetByIdForDepartmentAsync("inv-1", DeptId)).ReturnsAsync(invoice); + invoices.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((Invoice i, CancellationToken _, bool __) => i); + var lines = new Mock(); + lines.Setup(r => r.GetByInvoiceIdAsync("inv-1", DeptId)).ReturnsAsync(() => stored.ToList()); + lines.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((InvoiceLineItem l, CancellationToken _, bool __) => { l.InvoiceLineItemId ??= Guid.NewGuid().ToString(); stored.RemoveAll(x => x.InvoiceLineItemId == l.InvoiceLineItemId); stored.Add(l); return l; }); + lines.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync((InvoiceLineItem l, CancellationToken _) => stored.RemoveAll(x => x.InvoiceLineItemId == l.InvoiceLineItemId) > 0); + var payments = new Mock(); + payments.Setup(r => r.GetByInvoiceIdAsync("inv-1", DeptId)).ReturnsAsync(new List()); + var identities = new Mock(); + DepartmentBillingIdentity storedIdentity = null; + identities.Setup(r => r.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(() => storedIdentity); + identities.Setup(r => r.UpsertAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DepartmentBillingIdentity i, CancellationToken _) => { storedIdentity = i; return i; }); + var protectedRead = new Mock(MockBehavior.Strict); + var service = new InvoicingService(new Mock().Object, new Mock().Object, new Mock().Object, invoices.Object, lines.Object, payments.Object, + new Mock().Object, identities.Object, new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, null, + null, new Lazy(() => protectedRead.Object)); + + // Edits line-a's text, keeps line-b, adds a new line: the customer reads every description on the invoice, so they are stored as typed. + await service.SaveInvoiceLineItemsAsync("inv-1", DeptId, new List + { + new InvoiceLineItem { InvoiceLineItemId = "line-a", Description = "Standby crew (day 2)", Quantity = 1, UnitRate = 10 }, + new InvoiceLineItem { InvoiceLineItemId = "line-b", Description = "Transport for J. Doe", Quantity = 2, UnitRate = 5 }, + new InvoiceLineItem { Description = "Mileage", Quantity = 10, UnitRate = 1 } + }, "clerk", null, null); + + stored.Select(l => l.InvoiceLineItemId).Should().Contain(new[] { "line-a", "line-b" }, "existing lines keep their row keys so provenance links and audit history follow the row"); + stored.Should().HaveCount(3); + stored.Select(l => l.Description).Should().BeEquivalentTo(new[] { "Standby crew (day 2)", "Transport for J. Doe", "Mileage" }); + stored.Should().OnlyContain(l => !l.IsProtected && l.ProtectedCatalogVersion == 0, "nothing on an invoice is enveloped"); + await FluentActions.Awaiting(() => service.SaveInvoiceLineItemsAsync("inv-1", DeptId, new List { new InvoiceLineItem { Description = " ", Quantity = 1, UnitRate = 1 } }, "clerk", null, null)).Should().ThrowAsync("every line needs a description"); + + // The department's registrations print on every invoice: saved and read back verbatim, never through the read seam. + var identity = await service.SaveDepartmentBillingIdentityAsync(new DepartmentBillingIdentity { DepartmentId = DeptId, TaxRegistrationNumber = "12-3456789", SamUei = "ABC123DEF456", PayLinkExpiryDays = 30 }, "clerk", null, null); + identity.IsProtected.Should().BeFalse(); + (await service.GetDepartmentBillingIdentityAsync(DeptId)).TaxRegistrationNumber.Should().Be("12-3456789"); + (await service.GetInvoiceByIdAsync("inv-1", DeptId)).LineItems.Select(l => l.Description).Should().Contain("Transport for J. Doe"); + protectedRead.VerifyNoOtherCalls(); + } + + #endregion + + #region Deployment notes seam and events + + [Test] + public async Task Deployment_notes_stay_enveloped_while_attachments_and_time_reports_are_stored_whole_and_the_completion_triggers_publish() + { + var published = new List(); + var outbox = new Mock(); + outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, e, ___) => published.Add(e)).ReturnsAsync(new DomainEventOutboxEntry()); + var write = new Mock(); + var enveloped = new List(); + write.Setup(w => w.PrepareRecordsEntityWriteAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny, Action)>>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback, Action)>, Action, string, string, bool, CancellationToken>((_, e, __, k, a, mark, ___, ____, _____, ______) => { enveloped.Add("deployment:" + k); foreach (var acc in a) if (!string.IsNullOrEmpty(acc.Value.Item1(e))) acc.Value.Item2(e, "rgdp:" + acc.Value.Item1(e)); mark(); }) + .ReturnsAsync(new ProtectedWriteResult { Success = true, Changed = true }); + var read = new Mock(MockBehavior.Strict); + var readGrants = new List(); + read.Setup(r => r.ResolveRecordsEntitiesForReadAsync(It.IsAny(), It.IsAny>(), It.IsAny, Action)>>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback, IReadOnlyDictionary, Action)>, string, string, CancellationToken>((_, rows, a, g, __, ___) => { readGrants.Add(g); foreach (var row in rows) foreach (var acc in a) if (acc.Value.Item1(row.Item1)?.StartsWith("rgdp:") == true) acc.Value.Item2(row.Item1, g == "grant-123" ? acc.Value.Item1(row.Item1).Substring(5) : ProtectedDataEnvelope.RedactionValue); }) + .ReturnsAsync(new ProtectedReadResult()); + var storedDeployments = new List(); + var deployments = new Mock(); + // The fake repository hands out copies, as Dapper does: a read resolved in memory must never "decrypt" the stored row. + deployments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((Deployment d, CancellationToken _, bool __) => { d.DeploymentId ??= "dep-1"; storedDeployments.RemoveAll(x => x.DeploymentId == d.DeploymentId); storedDeployments.Add(Resgrid.Framework.ObjectCopier.CloneJson(d)); return d; }); + deployments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => { var d = storedDeployments.FirstOrDefault(x => x.DeploymentId == id); return d == null ? null : Resgrid.Framework.ObjectCopier.CloneJson(d); }); + var units = new Mock(); units.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync(new List()); + var personnel = new Mock(); personnel.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync(new List()); + var equipment = new Mock(); equipment.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync(new List()); + var storedAttachments = new List(); + var attachments = new Mock(); + attachments.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentAttachment a, CancellationToken _, bool __) => { if (a.DeploymentAttachmentId == 0) a.DeploymentAttachmentId = 41; storedAttachments.RemoveAll(x => x.DeploymentAttachmentId == a.DeploymentAttachmentId); storedAttachments.Add(a); return a; }); + var events = new Mock(); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test", TimeZone = "UTC" }); + var grant = new Grant { GrantToken = "grant-123", UserId = "clerk" }; + var deploymentService = new DeploymentService(deployments.Object, units.Object, personnel.Object, equipment.Object, attachments.Object, departments.Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, outbox.Object, events.Object, new Mock().Object, null, + null, new Lazy(() => write.Object), new Lazy(() => read.Object), grant); + + // Deployment notes are enveloped on save; a grant holder reads them back, everyone else sees REDACTED. + var saved = await deploymentService.SaveDeploymentAsync(new Deployment { DepartmentId = DeptId, Name = "Ridge Fire", Notes = "Contact Jane on site" }, "clerk", null, null); + enveloped.Should().Contain("deployment:dep-1"); + storedDeployments.Single().Notes.Should().Be("rgdp:Contact Jane on site"); + storedDeployments.Single().IsProtected.Should().BeTrue(); + saved.Notes.Should().Be("Contact Jane on site", "the grant holder who just saved reads the value back"); + grant.GrantToken = null; + (await deploymentService.GetDeploymentByIdAsync("dep-1", DeptId)).Notes.Should().Be(ProtectedDataEnvelope.RedactionValue); + readGrants.Should().Contain("grant-123").And.Contain((string)null); + grant.GrantToken = "grant-123"; + + // An unrevealed edit posts REDACTED back: the seam receives the stored row as the sentinel source. + var edit = await deploymentService.GetDeploymentByIdAsync("dep-1", DeptId); + edit.Notes = ProtectedDataEnvelope.RedactionValue; + await deploymentService.SaveDeploymentAsync(edit, "clerk", null, null); + write.Verify(w => w.PrepareRecordsEntityWriteAsync(DeptId, It.IsAny(), It.Is(x => x != null && x.Notes == "rgdp:Contact Jane on site"), "dep-1", It.IsAny, Action)>>(), It.IsAny(), null, null, true, It.IsAny()), Times.Once); + + // Attachment added: trigger 187 carries the name as typed; attachments go to customers in the invoice packet. + await deploymentService.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = "dep-1", DepartmentId = DeptId, AttachmentType = (int)DeploymentAttachmentTypes.SignedServiceRequest, Name = "Signed request", FileName = "signed.pdf", FileType = "application/pdf", Data = new byte[] { 1 } }, "clerk", null, null); + var attachmentEvent = published.Single(e => e.Trigger == WorkflowTriggerEventType.DeploymentAttachmentAdded); + var attachmentPayload = JObject.FromObject(attachmentEvent.Payload); + attachmentPayload["AttachmentId"].Value().Should().Be(41); + attachmentPayload["AttachmentType"].Value().Should().Be((int)DeploymentAttachmentTypes.SignedServiceRequest); + attachmentPayload["AttachmentName"].Value().Should().Be("Signed request"); + storedAttachments.Single().Name.Should().Be("Signed request"); + storedAttachments.Single().IsProtected.Should().BeFalse(); + + // Time reports: created / voided publish, the void reason is appended to the note as typed, entries keep their ids. + var storedReports = new List(); + var reports = new Mock(); + reports.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentTimeReport t, CancellationToken _, bool __) => { t.DeploymentTimeReportId ??= "rep-1"; 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(() => storedReports.ToList()); + var storedEntries = new List(); + var entries = new Mock(); + entries.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentTimeEntry e, CancellationToken _, bool __) => { e.DeploymentTimeEntryId ??= "ent-" + (storedEntries.Count + 1); storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId); storedEntries.Add(e); return e; }); + entries.Setup(r => r.GetByReportAsync(It.IsAny())).ReturnsAsync((string id) => storedEntries.Where(e => e.DeploymentTimeReportId == id).ToList()); + entries.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentTimeEntry e, CancellationToken _) => storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId) > 0); + var sequence = new Mock(); sequence.Setup(s => s.GetNextNumberAsync(DeptId, It.IsAny())).ReturnsAsync(1); + var expenses = new Mock(); + var timeTracking = new TimeTrackingService(deployments.Object, personnel.Object, units.Object, equipment.Object, reports.Object, entries.Object, expenses.Object, attachments.Object, sequence.Object, deploymentService, departments.Object, new Mock().Object, new Mock().Object, events.Object, new Mock().Object, null); + var roster = new List { new DeploymentPersonnel { DeploymentPersonnelId = "per-1", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "alice" } }; + personnel.Setup(r => r.GetByDeploymentAsync("dep-1")).ReturnsAsync(roster); + + var report = await timeTracking.CreateTimeReportAsync("dep-1", DeptId, new DateTime(2026, 9, 21), "alice", null, null); + published.Should().Contain(e => e.Trigger == WorkflowTriggerEventType.TimeReportCreated && e.CorrelationId == report.DeploymentTimeReportId); + JObject.FromObject(published.Single(e => e.Trigger == WorkflowTriggerEventType.TimeReportCreated).Payload)["ReportStatus"].Value().Should().Be((int)DeploymentTimeReportStatuses.Draft); + + var firstEntryId = report.Entries.Single().DeploymentTimeEntryId; + var batch = await timeTracking.SaveTimeEntriesAsync(report.DeploymentTimeReportId, DeptId, new List + { + new DeploymentTimeEntry { DeploymentTimeEntryId = firstEntryId, DeploymentPersonnelId = "per-1", StartTime = new DateTime(2026, 9, 21, 8, 0, 0), EndTime = new DateTime(2026, 9, 21, 12, 0, 0), Notes = "Relieved by J. Doe" }, + new DeploymentTimeEntry { DeploymentPersonnelId = "per-1", StartTime = new DateTime(2026, 9, 21, 13, 0, 0), EndTime = new DateTime(2026, 9, 21, 17, 0, 0), Notes = "Relief crew" } + }, "alice", null, null); + batch.Validation.IsValid.Should().BeTrue(); + storedEntries.Select(e => e.DeploymentTimeEntryId).Should().Contain(firstEntryId, "an existing entry is updated in place, never re-inserted under a new key"); + storedEntries.Select(e => e.Notes).Should().BeEquivalentTo(new[] { "Relieved by J. Doe", "Relief crew" }, "the customer signs the DTR; its notes are stored as typed"); + storedReports.Single().IsProtected.Should().BeFalse("nothing on a DTR is enveloped"); + enveloped.Should().OnlyContain(k => k.StartsWith("deployment:"), "the deployment wrapper's internal notes are the only seam left in Phase C"); + + storedReports.Single().Notes = "Crew note"; + var voided = await timeTracking.VoidTimeReportAsync(report.DeploymentTimeReportId, DeptId, "duplicate", "chief", null, null); + storedReports.Single().Notes.Should().Be("Crew note\nVoid: duplicate", "the reason is appended to the note as typed"); + published.Should().Contain(e => e.Trigger == WorkflowTriggerEventType.TimeReportVoided); + JObject.FromObject(published.Single(e => e.Trigger == WorkflowTriggerEventType.TimeReportVoided).Payload)["ReportStatus"].Value().Should().Be((int)DeploymentTimeReportStatuses.Void); + voided.Status.Should().Be((int)DeploymentTimeReportStatuses.Void); + } + + #endregion + + #region Workflow catalog coverage + + [Test] + public void Every_completion_trigger_has_catalog_variables_sample_data_and_a_baseline_entry() + { + var triggers = new[] + { + WorkflowTriggerEventType.UnitCertificationAdded, WorkflowTriggerEventType.UnitCertificationStatusChanged, WorkflowTriggerEventType.UnitCertificationRemoved, + WorkflowTriggerEventType.CertificationRemoved, WorkflowTriggerEventType.CertificationCreditAdded, + WorkflowTriggerEventType.TimeReportCreated, WorkflowTriggerEventType.TimeReportVoided, WorkflowTriggerEventType.DeploymentAttachmentAdded + }; + foreach (var trigger in triggers) + { + var names = WorkflowTemplateVariableCatalog.GetVariableCatalog(trigger).Select(v => v.Name).ToList(); + names.Should().Contain(n => n.StartsWith("certification.") || n.StartsWith("unit_certification.") || n.StartsWith("deployment."), trigger.ToString()); + WorkflowSampleDataGenerator.GenerateSampleData(trigger).Should().NotBeNull(trigger.ToString()); + } + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.CertificationCreditAdded).Select(v => v.Name).Should().Contain(new[] { "credit.hours", "credit.category" }); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.UnitCertificationStatusChanged).Select(v => v.Name).Should().Contain("unit_certification.old_status"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.DeploymentAttachmentAdded).Select(v => v.Name).Should().Contain(new[] { "deployment.attachment_id", "deployment.attachment_name" }); + CertificationWorkflowTriggers.Triggers.Should().Contain(new[] { 180, 181, 182, 183, 184 }); + DeploymentWorkflowPayload.Triggers.Should().Contain(new[] { 185, 186, 187 }); + DeploymentWorkflowPayload.Reserved.Should().BeEmpty("C-M2 published 74-80 under ContractorWorkflowPayload; nothing stays hidden from the picker"); + ContractorWorkflowPayload.BidTriggers.Concat(ContractorWorkflowPayload.ContractTriggers).Should().BeEquivalentTo(new[] { 74, 75, 76, 77, 78, 79, 80 }); + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs new file mode 100644 index 000000000..daa347df8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs @@ -0,0 +1,325 @@ +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 Newtonsoft.Json; +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.ContractorBilling; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Contractor bids and the deployment charge run (Workforce & Business Operations plan, C5). Gated by the + /// Invoicing.ContractorBilling entitlement; Bids_View reads, Bids_Create/Update/Delete write, the charge run and + /// invoice generation need Invoicing_Update. Conversion mirrors the MVC wizard's last step. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class BidsController : V4AuthenticatedApiControllerbase + { + private readonly IBidsService _bids; + private readonly IContractorBillingEngine _engine; + private readonly IBusinessOperationsAccessService _access; + + public BidsController(IBidsService bids, IContractorBillingEngine engine, IBusinessOperationsAccessService access) + { + _bids = bids; + _engine = engine; + _access = access; + } + + private Task EnabledAsync() => _access.CanUseContractorBillingAsync(DepartmentId); + 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); + } + + /// Whether the contractor path is available to this department and what the caller may do. Always answers. + [HttpGet("GetAccess")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAccess() + { + var admin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + var result = new ContractorAccessResult + { + Data = new ContractorAccessData + { + Enabled = await EnabledAsync(), + CanManageBids = admin || ClaimsAuthorizationHelper.CanManageBids(), + CanManageContracts = admin || ClaimsAuthorizationHelper.CanManageContracts(), + CanManageRateSchedules = admin || ClaimsAuthorizationHelper.CanManageInvoicing() + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetBids")] + [Authorize(Policy = ResgridResources.Bids_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetBids(int? status = null, string contactId = null, int skip = 0, int take = 100) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var bids = string.IsNullOrWhiteSpace(contactId) + ? await _bids.GetBidsForDepartmentAsync(DepartmentId, status.HasValue && Enum.IsDefined(typeof(BidStatuses), status.Value) ? (BidStatuses?)status.Value : null, skip, take) + : await _bids.GetBidsByContactIdAsync(contactId, DepartmentId); + var result = new BidsResult { Data = bids.Select(b => Map(b, false)).ToList(), PageSize = bids.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetBid")] + [Authorize(Policy = ResgridResources.Bids_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetBid(string id) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var bid = await _bids.GetBidByIdAsync(id, DepartmentId); + return bid == null ? NotFound() : Ok(bid); + } + + [HttpPost("NewBid")] + [Authorize(Policy = ResgridResources.Bids_Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> NewBid([FromBody] NewBidInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _bids.CreateDraftBidAsync(DepartmentId, input.ContactId, input.ServiceContractId, input.Title, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("UpdateBid")] + [Authorize(Policy = ResgridResources.Bids_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateBid([FromBody] SaveBidInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.Id)) return BadRequest(); + try + { + var bid = await _bids.SaveBidAsync(new Bid + { + BidId = input.Id, DepartmentId = DepartmentId, ServiceContractId = input.ServiceContractId, RateScheduleId = input.RateScheduleId, Title = input.Title, Description = input.Description, + ValidUntil = input.ValidUntil, RequestedStartOn = input.RequestedStartOn, RequestedEndOn = input.RequestedEndOn, IncidentNumber = input.IncidentNumber, DeliveryLocation = input.DeliveryLocation, + DiscountPercent = input.DiscountPercent, Notes = input.Notes, TermsText = input.TermsText + }, UserId, Ip, Agent, cancellationToken); + if (input.LineItems != null) + bid = await _bids.SaveBidLineItemsAsync(input.Id, DepartmentId, input.LineItems.Select(l => new BidLineItem + { + BidLineItemId = l.Id, RateScheduleEntryId = l.RateScheduleEntryId, LineType = l.LineType, Description = l.Description, CrewSize = l.CrewSize, Quantity = l.Quantity, + EstimatedHoursPerDay = l.EstimatedHoursPerDay, EstimatedDays = l.EstimatedDays, UnitRate = l.UnitRate, PremiumIdsJson = l.PremiumIds == null || l.PremiumIds.Count == 0 ? null : JsonConvert.SerializeObject(l.PremiumIds), + Taxable = l.Taxable, SortOrder = l.SortOrder + }).ToList(), UserId, Ip, Agent, cancellationToken); + return Ok(bid); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("SetBidStatus")] + [Authorize(Policy = ResgridResources.Bids_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SetBidStatus([FromBody] SetBidStatusInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null || !Enum.IsDefined(typeof(BidStatuses), input.Status)) return BadRequest(); + try + { + var bid = (BidStatuses)input.Status switch + { + BidStatuses.Submitted => await _bids.SubmitBidAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken), + BidStatuses.Accepted => await _bids.AcceptBidAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken), + BidStatuses.Declined => await _bids.DeclineBidAsync(input.Id, DepartmentId, input.Reason, UserId, Ip, Agent, cancellationToken), + BidStatuses.Withdrawn => await _bids.WithdrawBidAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken), + _ => throw new InvalidOperationException("bids_status_transition_invalid") + }; + return Ok(bid); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("SendBid")] + [Authorize(Policy = ResgridResources.Bids_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SendBid([FromBody] SendBidInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _bids.SendBidAsync(input.Id, DepartmentId, input.ToEmail, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteBid")] + [Authorize(Policy = ResgridResources.Bids_Delete)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteBid(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + try + { + if (!await _bids.DeleteBidAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + var result = new StandardApiResponseV4Base { PageSize = 0, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpGet("GetBidPdf")] + [Authorize(Policy = ResgridResources.Bids_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetBidPdf(string id) + { + if (!await EnabledAsync()) return Forbid(); + var bid = await _bids.GetBidByIdAsync(id, DepartmentId); + if (bid == null) return NotFound(); + var pdf = await _bids.GetBidPdfAsync(id, DepartmentId); + if (pdf == null || pdf.Length == 0) return NoContent(); + return File(pdf, "application/pdf", $"bid-{bid.BidNumber}.pdf"); + } + + [HttpGet("GetBidConversionContext")] + [Authorize(Policy = ResgridResources.Bids_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetBidConversionContext(string id) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var context = await _bids.GetBidConversionContextAsync(id, DepartmentId); + if (context == null) return NotFound(); + var result = new BidConversionContextResult + { + Data = new BidConversionContextData + { + Bid = Map(context.Bid, true), Contract = context.Contract == null ? null : ServiceContractsController.Map(context.Contract, true), Schedule = context.Schedule == null ? null : RateSchedulesController.Map(context.Schedule, true), + ContactName = context.ContactName, EffectiveDiscountPercent = context.EffectiveDiscountPercent, Currency = context.Currency, AlreadyConverted = context.AlreadyConverted + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("ConvertBidToDeployment")] + [Authorize(Policy = ResgridResources.Deployments_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ConvertBidToDeployment([FromBody] ConvertBidInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + if (!ClaimsAuthorizationHelper.CanManageBids() && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); + try + { + var conversion = await _bids.ConvertBidToDeploymentAsync(new BidConversionRequest + { + BidId = input.BidId, CallName = input.CallName, CallNature = input.CallNature, CallPriority = input.CallPriority, CallTypeId = input.CallTypeId, Address = input.Address, GeoLocation = input.GeoLocation, + StartOn = input.StartOn, EndOn = input.EndOn, MaxDays = input.MaxDays, IncidentNumber = input.IncidentNumber, ServiceRequestNumber = input.ServiceRequestNumber, PointOfHire = input.PointOfHire, + OutOfProvince = input.OutOfProvince, TravelViaAir = input.TravelViaAir, LocalTimeZoneId = input.LocalTimeZoneId, Notes = input.Notes, CreateCalendarItem = input.CreateCalendarItem, + UnassignedPersonnel = (input.UnassignedPersonnel ?? new List()).Select(MapSeat).ToList(), + Units = (input.Units ?? new List()).Select(u => new BidConversionUnit + { + UnitId = u.UnitId, CallSign = u.CallSign, BidLineItemId = u.BidLineItemId, RateScheduleEntryId = u.RateScheduleEntryId, + Seats = (u.Seats ?? new List()).Select(MapSeat).ToList(), + Equipment = (u.Equipment ?? new List()).Select(e => new BidConversionEquipment { InventoryAssetId = e.InventoryAssetId, InventoryItemId = e.InventoryItemId, FreeTextName = e.FreeTextName, RateScheduleEntryId = e.RateScheduleEntryId, BidLineItemId = e.BidLineItemId }).ToList() + }).ToList() + }, DepartmentId, UserId, Ip, Agent, cancellationToken); + var result = new BidConversionResultResult + { + Data = new BidConversionResultData { BidId = conversion.Bid?.BidId, CallId = conversion.CallId, DeploymentId = conversion.Deployment?.DeploymentId, CalendarItemId = conversion.CalendarItemId, Warnings = conversion.Warnings.Select(w => w.Code).Distinct().ToList() }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal) || ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + #region Charges + + /// Dry run of the contractor billing engine over the deployment's approved, unbilled DTRs. + [HttpGet("GetDeploymentCharges")] + [Authorize(Policy = ResgridResources.Invoicing_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetDeploymentCharges(string deploymentId, DateTime? throughDate = null) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var charges = await _engine.CalculateDeploymentChargesAsync(deploymentId, DepartmentId, throughDate); + if (charges == null) return NotFound(); + var result = new ContractorChargesResult + { + Data = new ContractorChargesData + { + DeploymentId = charges.DeploymentId, Currency = charges.Currency, SubTotal = charges.SubTotal, DiscountPercent = charges.DiscountPercent, DiscountAmount = charges.DiscountAmount, ReportIds = charges.ReportIds, + Lines = charges.Lines.Select(l => new ContractorChargeLineData { Date = l.Date, TimeReportId = l.DeploymentTimeReportId, ReportNumber = l.ReportNumber, SubjectType = l.SubjectType, SubjectId = l.SubjectId, SubjectName = l.SubjectName, EntryName = l.EntryName, Kind = (int)l.Kind, Description = l.Description, Quantity = l.Quantity, UnitRate = l.UnitRate, Amount = l.Amount, Taxable = l.Taxable }).ToList(), + Warnings = charges.Warnings.Select(w => new ContractorChargeWarningData { Code = w.Code, Message = w.Message, TimeReportId = w.DeploymentTimeReportId, SubjectId = w.SubjectId }).ToList() + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("GenerateDeploymentInvoice")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GenerateDeploymentInvoice([FromBody] GenerateDeploymentInvoiceInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var invoice = await _engine.GenerateInvoiceFromDeploymentAsync(input.DeploymentId, DepartmentId, input.ThroughDate, UserId, Ip, Agent, cancellationToken); + var result = new DeploymentInvoiceResult { Data = new DeploymentInvoiceData { InvoiceId = invoice.InvoiceId, InvoiceNumber = invoice.InvoiceNumber, Total = invoice.Total, Currency = invoice.Currency, LineCount = invoice.LineItems?.Count ?? 0 }, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("contractor_", StringComparison.Ordinal) || ex.Message.StartsWith("deployments_", StringComparison.Ordinal) || ex.Message.StartsWith("invoicing_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + #endregion + + #region Mapping + + private static BidConversionSeat MapSeat(ConvertBidSeatInput s) => new BidConversionSeat { UserId = s.UserId, UnitRoleId = s.UnitRoleId, RateScheduleEntryId = s.RateScheduleEntryId, CertificationCode = s.CertificationCode, PremiumIds = s.PremiumIds ?? new List(), CallSign = s.CallSign }; + + private ActionResult Ok(Bid bid) + { + var result = new BidResult { Data = Map(bid, true), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + internal static BidData Map(Bid b, bool graph) => new BidData + { + Id = b.BidId, BidNumber = b.BidNumber, ContactId = b.ContactId, CustomerBillingProfileId = b.CustomerBillingProfileId, ServiceContractId = b.ServiceContractId, RateScheduleId = b.RateScheduleId, Title = b.Title, + Description = b.Description, Status = b.Status, ValidUntil = b.ValidUntil, RequestedStartOn = b.RequestedStartOn, RequestedEndOn = b.RequestedEndOn, IncidentNumber = b.IncidentNumber, DeliveryLocation = b.DeliveryLocation, + DiscountPercent = b.DiscountPercent, EstimatedSubTotal = b.EstimatedSubTotal, EstimatedDiscountAmount = b.EstimatedDiscountAmount, EstimatedTaxAmount = b.EstimatedTaxAmount, EstimatedTotal = b.EstimatedTotal, + Notes = b.Notes, TermsText = b.TermsText, SentOn = b.SentOn, SentToEmail = b.SentToEmail, AcceptedOn = b.AcceptedOn, DeclinedOn = b.DeclinedOn, DeclineReason = b.DeclineReason, ConvertedCallId = b.ConvertedCallId, + ConvertedDeploymentId = b.ConvertedDeploymentId, AddedOn = b.AddedOn, UpdatedOn = b.EditedOn ?? b.AddedOn, + LineItems = graph ? (b.LineItems ?? new List()).Select(l => new BidLineData + { + Id = l.BidLineItemId, RateScheduleEntryId = l.RateScheduleEntryId, LineType = l.LineType, Description = l.Description, CrewSize = l.CrewSize, Quantity = l.Quantity, EstimatedHoursPerDay = l.EstimatedHoursPerDay, + EstimatedDays = l.EstimatedDays, UnitRate = l.UnitRate, PremiumIds = l.PremiumIds, EstimatedAmount = l.EstimatedAmount, Taxable = l.Taxable, SortOrder = l.SortOrder + }).ToList() : new List() + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs index ebc48365f..8cd2fd47c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs @@ -364,7 +364,7 @@ private ActionResult Done() 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 }; + 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, AddedOn = a.AddedOn, AddedByUserId = a.AddedByUserId }; #endregion } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs index 7733961a0..a4ab73f7e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs @@ -312,7 +312,7 @@ private static InvoiceResultData Convert(Invoice invoice, IReadOnlyDictionary()).OrderBy(x => x.PaidOn).Select(x => new InvoicePaymentData { InvoicePaymentId = x.InvoicePaymentId, Amount = x.Amount, Method = x.Method, MethodName = ((InvoicePaymentMethods)x.Method).ToString(), Status = x.Status, - RefundedAmount = x.RefundedAmount, Reference = invoice.IsProtected ? ProtectedDataEnvelope.RedactionValue : x.Reference, PaymentMethodSummary = x.PaymentMethodSummary, PaidOn = x.PaidOn, AddedOn = x.AddedOn + RefundedAmount = x.RefundedAmount, Reference = x.Reference, PaymentMethodSummary = x.PaymentMethodSummary, PaidOn = x.PaidOn, AddedOn = x.AddedOn }).ToList(); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RateSchedulesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RateSchedulesController.cs new file mode 100644 index 000000000..857fceb24 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RateSchedulesController.cs @@ -0,0 +1,249 @@ +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.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.ContractorBilling; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Contractor rate schedules (Workforce & Business Operations plan, C5): schedules, entries with bands, premiums, + /// clone and JSON import/export. Gated by the Invoicing.ContractorBilling entitlement; Invoicing_View reads, + /// Invoicing_Update writes. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class RateSchedulesController : V4AuthenticatedApiControllerbase + { + private readonly IRateScheduleService _rateSchedules; + private readonly IBusinessOperationsAccessService _access; + + public RateSchedulesController(IRateScheduleService rateSchedules, IBusinessOperationsAccessService access) + { + _rateSchedules = rateSchedules; + _access = access; + } + + private Task EnabledAsync() => _access.CanUseContractorBillingAsync(DepartmentId); + 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); + } + + [HttpGet("GetRateSchedules")] + [Authorize(Policy = ResgridResources.Invoicing_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetRateSchedules(bool includeInactive = false) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var schedules = await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId, includeInactive); + var result = new RateSchedulesResult { Data = schedules.Select(s => Map(s, false)).ToList(), PageSize = schedules.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetRateSchedule(string id, bool includeInactive = false) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var schedule = await _rateSchedules.GetScheduleByIdAsync(id, DepartmentId, includeInactive); + return schedule == null ? NotFound() : Ok(schedule); + } + + [HttpPost("SaveRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveRateSchedule([FromBody] SaveRateScheduleInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var saved = await _rateSchedules.SaveScheduleAsync(new RateSchedule + { + RateScheduleId = input.Id, DepartmentId = DepartmentId, Name = input.Name, Description = input.Description, Currency = input.Currency, + EffectiveOn = input.EffectiveOn, ExpiresOn = input.ExpiresOn, PolicyJson = input.PolicyJson, IsActive = input.IsActive + }, UserId, Ip, Agent, cancellationToken); + return Ok(saved); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteRateSchedule(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + try + { + if (!await _rateSchedules.DeleteScheduleAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Empty(); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("CloneRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CloneRateSchedule([FromBody] CloneRateScheduleInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _rateSchedules.CloneScheduleAsync(input.Id, DepartmentId, input.Name, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("SaveEntry")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveEntry([FromBody] SaveRateScheduleEntryInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + await _rateSchedules.SaveEntryAsync(new RateScheduleEntry + { + RateScheduleEntryId = input.Id, RateScheduleId = input.RateScheduleId, DepartmentId = DepartmentId, EntryType = input.EntryType, Name = input.Name, Code = input.Code, GroupKey = input.GroupKey, + CrewSize = input.CrewSize, CertificationCode = input.CertificationCode, UnitTypeId = input.UnitTypeId, InventoryItemId = input.InventoryItemId, InventoryCategoryId = input.InventoryCategoryId, + BillingBasis = input.BillingBasis, RequiredCertificationsJson = input.RequiredCertificationsJson, SortOrder = input.SortOrder, IsActive = input.IsActive, + Bands = (input.Bands ?? new List()).Select(b => new RateScheduleEntryBand + { + RateScheduleEntryBandId = b.Id, BandType = b.BandType, Rate = b.Rate, ThresholdStartHours = b.ThresholdStartHours, ThresholdEndHours = b.ThresholdEndHours, DailyTierMinHours = b.DailyTierMinHours, + DailyTierMaxHours = b.DailyTierMaxHours, FreeUnitsPerDay = b.FreeUnitsPerDay, RequiresAirTravel = b.RequiresAirTravel, MealCode = b.MealCode, Label = b.Label, SortOrder = b.SortOrder + }).ToList() + }, UserId, Ip, Agent, cancellationToken); + return Ok(await _rateSchedules.GetScheduleByIdAsync(input.RateScheduleId, DepartmentId, includeInactive: true)); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteEntry")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteEntry(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (!await _rateSchedules.DeleteEntryAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Empty(); + } + + [HttpPost("SavePremium")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SavePremium([FromBody] SaveRatePremiumInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + await _rateSchedules.SavePremiumAsync(new RatePremium + { + RatePremiumId = input.Id, RateScheduleId = input.RateScheduleId, DepartmentId = DepartmentId, Name = input.Name, Code = input.Code, + StandbyAdder = input.StandbyAdder, DeploymentAdder = input.DeploymentAdder, Overtime1Adder = input.Overtime1Adder, Overtime2Adder = input.Overtime2Adder, IsActive = input.IsActive + }, UserId, Ip, Agent, cancellationToken); + return Ok(await _rateSchedules.GetScheduleByIdAsync(input.RateScheduleId, DepartmentId, includeInactive: true)); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeletePremium")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeletePremium(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (!await _rateSchedules.DeletePremiumAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Empty(); + } + + [HttpGet("ExportRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ExportRateSchedule(string id) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var json = await _rateSchedules.ExportScheduleJsonAsync(id, DepartmentId); + if (json == null) return NotFound(); + var result = new RateScheduleExportResult { Data = json, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("ImportRateSchedule")] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ImportRateSchedule([FromBody] ImportRateScheduleInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try { return Ok(await _rateSchedules.ImportScheduleJsonAsync(DepartmentId, input.Json, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("rateschedules_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + #region Mapping + + private ActionResult Ok(RateSchedule schedule) + { + var result = new RateScheduleResult { Data = Map(schedule, true), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Empty() + { + var result = new StandardApiResponseV4Base { PageSize = 0, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + internal static RateScheduleData Map(RateSchedule s, bool graph) => new RateScheduleData + { + Id = s.RateScheduleId, Name = s.Name, Description = s.Description, Currency = s.Currency, EffectiveOn = s.EffectiveOn, ExpiresOn = s.ExpiresOn, PolicyJson = s.PolicyJson, IsActive = s.IsActive, + AddedOn = s.AddedOn, UpdatedOn = s.EditedOn ?? s.AddedOn, + Entries = graph ? (s.Entries ?? new List()).Select(Map).ToList() : new List(), + Premiums = graph ? (s.Premiums ?? new List()).Select(Map).ToList() : new List() + }; + + internal static RateScheduleEntryData Map(RateScheduleEntry e) => new RateScheduleEntryData + { + Id = e.RateScheduleEntryId, RateScheduleId = e.RateScheduleId, EntryType = e.EntryType, Name = e.Name, Code = e.Code, GroupKey = e.GroupKey, CrewSize = e.CrewSize, CertificationCode = e.CertificationCode, + UnitTypeId = e.UnitTypeId, InventoryItemId = e.InventoryItemId, InventoryCategoryId = e.InventoryCategoryId, BillingBasis = e.BillingBasis, RequiredCertificationsJson = e.RequiredCertificationsJson, + SortOrder = e.SortOrder, IsActive = e.IsActive, UpdatedOn = e.EditedOn ?? e.AddedOn, + Bands = (e.Bands ?? new List()).Select(b => new RateScheduleBandData + { + Id = b.RateScheduleEntryBandId, BandType = b.BandType, Rate = b.Rate, ThresholdStartHours = b.ThresholdStartHours, ThresholdEndHours = b.ThresholdEndHours, DailyTierMinHours = b.DailyTierMinHours, + DailyTierMaxHours = b.DailyTierMaxHours, FreeUnitsPerDay = b.FreeUnitsPerDay, RequiresAirTravel = b.RequiresAirTravel, MealCode = b.MealCode, Label = b.Label, SortOrder = b.SortOrder + }).ToList() + }; + + internal static RatePremiumData Map(RatePremium p) => new RatePremiumData + { + Id = p.RatePremiumId, RateScheduleId = p.RateScheduleId, Name = p.Name, Code = p.Code, StandbyAdder = p.StandbyAdder, DeploymentAdder = p.DeploymentAdder, Overtime1Adder = p.Overtime1Adder, Overtime2Adder = p.Overtime2Adder, + IsActive = p.IsActive, UpdatedOn = p.EditedOn ?? p.AddedOn + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs new file mode 100644 index 000000000..a9fb93efc --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs @@ -0,0 +1,270 @@ +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.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.ContractorBilling; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Service contracts, document requirements and department compliance documents (Workforce & Business + /// Operations plan, C5). Gated by the Invoicing.ContractorBilling entitlement; ServiceContracts_View reads, + /// ServiceContracts_Update writes. Compliance document bytes are served only through GetComplianceDocumentFile. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class ServiceContractsController : V4AuthenticatedApiControllerbase + { + private readonly IServiceContractService _contracts; + private readonly IBusinessOperationsAccessService _access; + + public ServiceContractsController(IServiceContractService contracts, IBusinessOperationsAccessService access) + { + _contracts = contracts; + _access = access; + } + + private Task EnabledAsync() => _access.CanUseContractorBillingAsync(DepartmentId); + 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); + } + + [HttpGet("GetContracts")] + [Authorize(Policy = ResgridResources.ServiceContracts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetContracts(int? status = null, string contactId = null) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var contracts = string.IsNullOrWhiteSpace(contactId) + ? await _contracts.GetContractsForDepartmentAsync(DepartmentId, status.HasValue && Enum.IsDefined(typeof(ServiceContractStatuses), status.Value) ? (ServiceContractStatuses?)status.Value : null) + : await _contracts.GetContractsByContactIdAsync(contactId, DepartmentId); + var result = new ServiceContractsResult { Data = contracts.Select(c => Map(c, false)).ToList(), PageSize = contracts.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetContract")] + [Authorize(Policy = ResgridResources.ServiceContracts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetContract(string id) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var contract = await _contracts.GetContractByIdAsync(id, DepartmentId); + return contract == null ? NotFound() : Ok(contract); + } + + [HttpPost("SaveContract")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveContract([FromBody] SaveServiceContractInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var saved = await _contracts.SaveContractAsync(new ServiceContract + { + ServiceContractId = input.Id, DepartmentId = DepartmentId, ContactId = input.ContactId, ContractNumber = input.ContractNumber, Name = input.Name, ContractType = input.ContractType, + StartOn = input.StartOn, EndOn = input.EndOn, RateScheduleId = input.RateScheduleId, DiscountPercent = input.DiscountPercent, TermsNetDays = input.TermsNetDays, + InvoiceSubmissionEmail = input.InvoiceSubmissionEmail, MaxDeploymentDays = input.MaxDeploymentDays, ResponseTimeMinutes = input.ResponseTimeMinutes, PointOfHire = input.PointOfHire, + DocumentTemplateKey = input.DocumentTemplateKey, Notes = input.Notes + }, UserId, Ip, Agent, cancellationToken); + return Ok(saved); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("contracts_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("SetContractStatus")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SetContractStatus([FromBody] SetContractStatusInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null || !Enum.IsDefined(typeof(ServiceContractStatuses), input.Status)) return BadRequest(); + try { return Ok(await _contracts.SetContractStatusAsync(input.Id, DepartmentId, (ServiceContractStatuses)input.Status, UserId, Ip, Agent, cancellationToken)); } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("contracts_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpDelete("DeleteContract")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteContract(string id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + try + { + if (!await _contracts.DeleteContractAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Empty(); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("contracts_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpPost("SaveRequirements")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveRequirements([FromBody] SaveContractRequirementsInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + try + { + var saved = await _contracts.SaveRequirementsAsync(input.ServiceContractId, DepartmentId, (input.Requirements ?? new List()).Select(r => new ServiceContractDocumentRequirement + { + ServiceContractDocumentRequirementId = r.Id, Name = r.Name, Stage = r.Stage, ComplianceDocumentType = r.ComplianceDocumentType, IsMandatory = r.IsMandatory, SortOrder = r.SortOrder + }).ToList(), UserId, Ip, Agent, cancellationToken); + var result = new ContractRequirementsResult { Data = saved.Select(Map).ToList(), PageSize = saved.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("contracts_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpGet("GetContractCompliance")] + [Authorize(Policy = ResgridResources.ServiceContracts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetContractCompliance(string contractId = null, string deploymentId = null) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var compliance = !string.IsNullOrWhiteSpace(deploymentId) ? await _contracts.GetContractComplianceAsync(deploymentId, DepartmentId) : await _contracts.GetContractComplianceForContractAsync(contractId, DepartmentId); + if (compliance == null) return NotFound(); + var result = new ContractComplianceApiResult + { + Data = new ContractComplianceData + { + ServiceContractId = compliance.ServiceContractId, DeploymentId = compliance.DeploymentId, AllMandatorySatisfied = compliance.AllMandatorySatisfied, + Items = compliance.Items.Select(i => new ContractComplianceItemData + { + RequirementId = i.ServiceContractDocumentRequirementId, Name = i.Name, Stage = i.Stage, ComplianceDocumentType = i.ComplianceDocumentType, IsMandatory = i.IsMandatory, Satisfied = i.Satisfied, + SatisfiedBy = i.SatisfiedBy, ComplianceDocumentId = i.DepartmentComplianceDocumentId, DeploymentAttachmentId = i.DeploymentAttachmentId, ExpiresOn = i.ExpiresOn + }).ToList() + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + #region Compliance documents + + [HttpGet("GetComplianceDocuments")] + [Authorize(Policy = ResgridResources.ServiceContracts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetComplianceDocuments() + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + var documents = await _contracts.GetComplianceDocumentsAsync(DepartmentId); + var result = new ComplianceDocumentsResult { Data = documents.Select(Map).ToList(), PageSize = documents.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("SaveComplianceDocument")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveComplianceDocument([FromBody] SaveComplianceDocumentInput input, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (input == null) return BadRequest(); + byte[] data = null; + if (!string.IsNullOrWhiteSpace(input.FileBase64)) + { + try { data = Convert.FromBase64String(input.FileBase64); } + catch (FormatException) { return Failed("compliance_file_invalid"); } + } + try + { + var saved = await _contracts.SaveComplianceDocumentAsync(new DepartmentComplianceDocument + { + DepartmentComplianceDocumentId = input.Id, DepartmentId = DepartmentId, DocumentType = input.DocumentType, Name = input.Name, DocumentNumber = input.DocumentNumber, Issuer = input.Issuer, + EffectiveOn = input.EffectiveOn, ExpiresOn = input.ExpiresOn, AlertLeadDays = input.AlertLeadDays + }, data, input.FileName, input.FileType, UserId, Ip, Agent, cancellationToken); + var result = new ComplianceDocumentResult { Data = Map(saved), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("compliance_", StringComparison.Ordinal)) { return Failed(ex.Message); } + } + + [HttpGet("GetComplianceDocumentFile")] + [Authorize(Policy = ResgridResources.ServiceContracts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetComplianceDocumentFile(int id) + { + if (!await EnabledAsync()) return Forbid(); + var document = await _contracts.GetComplianceDocumentAsync(id, DepartmentId, includeData: true); + if (document == null) return NotFound(); + if (document.Data == null || document.Data.Length == 0) return NoContent(); + return File(document.Data, document.FileType ?? "application/octet-stream", string.IsNullOrWhiteSpace(document.FileName) ? $"compliance-{id}.bin" : document.FileName); + } + + [HttpDelete("DeleteComplianceDocument")] + [Authorize(Policy = ResgridResources.ServiceContracts_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DeleteComplianceDocument(int id, CancellationToken cancellationToken) + { + if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); + if (!await _contracts.DeleteComplianceDocumentAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Empty(); + } + + #endregion + + #region Mapping + + private ActionResult Ok(ServiceContract contract) + { + var result = new ServiceContractResult { Data = Map(contract, true), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Empty() + { + var result = new StandardApiResponseV4Base { PageSize = 0, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + internal static ServiceContractData Map(ServiceContract c, bool graph) => new ServiceContractData + { + Id = c.ServiceContractId, ContactId = c.ContactId, CustomerBillingProfileId = c.CustomerBillingProfileId, ContractNumber = c.ContractNumber, Name = c.Name, ContractType = c.ContractType, Status = c.Status, + StartOn = c.StartOn, EndOn = c.EndOn, RateScheduleId = c.RateScheduleId, DiscountPercent = c.DiscountPercent, TermsNetDays = c.TermsNetDays, InvoiceSubmissionEmail = c.InvoiceSubmissionEmail, + MaxDeploymentDays = c.MaxDeploymentDays, ResponseTimeMinutes = c.ResponseTimeMinutes, PointOfHire = c.PointOfHire, DocumentTemplateKey = c.DocumentTemplateKey, Notes = c.Notes, + AddedOn = c.AddedOn, UpdatedOn = c.EditedOn ?? c.AddedOn, + Requirements = graph ? (c.Requirements ?? new List()).Select(Map).ToList() : new List() + }; + + internal static ContractRequirementData Map(ServiceContractDocumentRequirement r) => new ContractRequirementData + { + Id = r.ServiceContractDocumentRequirementId, Name = r.Name, Stage = r.Stage, ComplianceDocumentType = r.ComplianceDocumentType, IsMandatory = r.IsMandatory, SortOrder = r.SortOrder + }; + + internal static ComplianceDocumentData Map(DepartmentComplianceDocument d) => new ComplianceDocumentData + { + Id = d.DepartmentComplianceDocumentId, DocumentType = d.DocumentType, Name = d.Name, DocumentNumber = d.DocumentNumber, Issuer = d.Issuer, EffectiveOn = d.EffectiveOn, ExpiresOn = d.ExpiresOn, + AlertLeadDays = d.AlertLeadDays, FileName = d.FileName, FileType = d.FileType, FileSize = d.FileSize, IsCurrent = d.IsCurrent(DateTime.UtcNow), + AddedOn = d.AddedOn, UpdatedOn = d.EditedOn ?? d.AddedOn + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs index 624c7d40f..52ed4d391 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs @@ -123,6 +123,7 @@ public async Task> SaveTimeEntries([FromBody] Sav { var entries = (input.Entries ?? new List()).Select(e => new DeploymentTimeEntry { + DeploymentTimeEntryId = string.IsNullOrWhiteSpace(e.Id) ? null : e.Id, 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 @@ -289,8 +290,8 @@ private ActionResult Ok(TimeReportSaveResult save) { 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, + ContractorSignedOn = r.ContractorSignedOn, CustomerSignerName = 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, 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, @@ -304,7 +305,7 @@ private ActionResult Ok(TimeReportSaveResult save) 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, + Description = e.Description, Amount = e.Amount, Currency = e.Currency, PreApproved = e.PreApproved, Billable = e.Billable, ReceiptAttachmentId = e.ReceiptAttachmentId, AddedOn = e.AddedOn, UpdatedOn = e.EditedOn ?? e.AddedOn }; diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 4e2152d81..4063d222f 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -21,6 +21,10 @@ public static class ClaimsAuthorizationHelper 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 CanManageBids() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Update); + public static bool CanViewBids() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.View); + public static bool CanManageContracts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.Update); + public static bool CanViewContracts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.View); 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/ContractorBilling/ContractorBillingApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs new file mode 100644 index 000000000..47337218a --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.ContractorBilling +{ + // Workforce & Business Operations plan, Phase C5 (contractor path): rate schedules, service contracts, compliance + // documents and bids. Every data row carries UpdatedOn for delta-sync. Nothing here is under ADP: bids, contracts and + // compliance documents are sent to customers, who read them without a login. Compliance document bytes never ride list endpoints. + + #region Access + + public class ContractorAccessResult : StandardApiResponseV4Base + { + public ContractorAccessData Data { get; set; } + } + + public class ContractorAccessData + { + /// Invoicing.ContractorBilling entitlement (paid add-on + flag). + public bool Enabled { get; set; } + public bool CanManageBids { get; set; } + public bool CanManageContracts { get; set; } + public bool CanManageRateSchedules { get; set; } + } + + #endregion + + #region Rate schedules + + public class RateSchedulesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class RateScheduleResult : StandardApiResponseV4Base + { + public RateScheduleData Data { get; set; } + } + + public class RateScheduleData + { + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Currency { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string PolicyJson { get; set; } + public bool IsActive { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public List Entries { get; set; } = new List(); + public List Premiums { get; set; } = new List(); + } + + public class RateScheduleEntryData + { + public string Id { get; set; } + public string RateScheduleId { get; set; } + /// RateEntryTypes value. + public int EntryType { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string GroupKey { get; set; } + public int? CrewSize { get; set; } + public string CertificationCode { get; set; } + public int? UnitTypeId { get; set; } + public string InventoryItemId { get; set; } + public string InventoryCategoryId { get; set; } + /// BillingBases value. + public int BillingBasis { get; set; } + public string RequiredCertificationsJson { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } + public DateTime? UpdatedOn { get; set; } + public List Bands { get; set; } = new List(); + } + + public class RateScheduleBandData + { + public string Id { get; set; } + /// RateBandTypes value. + public int BandType { get; set; } + public decimal Rate { get; set; } + public decimal? ThresholdStartHours { get; set; } + public decimal? ThresholdEndHours { get; set; } + public decimal? DailyTierMinHours { get; set; } + public decimal? DailyTierMaxHours { get; set; } + public decimal? FreeUnitsPerDay { get; set; } + public bool RequiresAirTravel { get; set; } + public string MealCode { get; set; } + public string Label { get; set; } + public int SortOrder { get; set; } + } + + public class RatePremiumData + { + public string Id { get; set; } + public string RateScheduleId { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public decimal StandbyAdder { get; set; } + public decimal DeploymentAdder { get; set; } + public decimal Overtime1Adder { get; set; } + public decimal Overtime2Adder { get; set; } + public bool IsActive { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class SaveRateScheduleInput + { + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Currency { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string PolicyJson { get; set; } + public bool IsActive { get; set; } = true; + } + + public class SaveRateScheduleEntryInput + { + public string Id { get; set; } + public string RateScheduleId { get; set; } + public int EntryType { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string GroupKey { get; set; } + public int? CrewSize { get; set; } + public string CertificationCode { get; set; } + public int? UnitTypeId { get; set; } + public string InventoryItemId { get; set; } + public string InventoryCategoryId { get; set; } + public int BillingBasis { get; set; } + public string RequiredCertificationsJson { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + public List Bands { get; set; } = new List(); + } + + public class SaveRatePremiumInput + { + public string Id { get; set; } + public string RateScheduleId { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public decimal StandbyAdder { get; set; } + public decimal DeploymentAdder { get; set; } + public decimal Overtime1Adder { get; set; } + public decimal Overtime2Adder { get; set; } + public bool IsActive { get; set; } = true; + } + + public class CloneRateScheduleInput + { + public string Id { get; set; } + public string Name { get; set; } + } + + public class ImportRateScheduleInput + { + public string Json { get; set; } + } + + public class RateScheduleExportResult : StandardApiResponseV4Base + { + public string Data { get; set; } + } + + #endregion + + #region Contracts and compliance documents + + public class ServiceContractsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class ServiceContractResult : StandardApiResponseV4Base + { + public ServiceContractData Data { get; set; } + } + + public class ServiceContractData + { + public string Id { get; set; } + public string ContactId { get; set; } + public string CustomerBillingProfileId { get; set; } + public string ContractNumber { get; set; } + public string Name { get; set; } + /// ServiceContractTypes value. + public int ContractType { get; set; } + /// ServiceContractStatuses value. + public int Status { get; set; } + public DateTime StartOn { get; set; } + public DateTime? EndOn { get; set; } + public string RateScheduleId { get; set; } + public decimal? DiscountPercent { get; set; } + public int? TermsNetDays { get; set; } + public string InvoiceSubmissionEmail { get; set; } + public int? MaxDeploymentDays { get; set; } + public int? ResponseTimeMinutes { get; set; } + public string PointOfHire { get; set; } + public string DocumentTemplateKey { get; set; } + public string Notes { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public List Requirements { get; set; } = new List(); + } + + public class ContractRequirementData + { + public string Id { get; set; } + public string Name { get; set; } + /// DocumentRequirementStages value. + public int Stage { get; set; } + /// ComplianceDocumentTypes value; null = satisfied by a deployment attachment. + public int? ComplianceDocumentType { get; set; } + public bool IsMandatory { get; set; } + public int SortOrder { get; set; } + } + + public class SaveServiceContractInput + { + public string Id { get; set; } + public string ContactId { get; set; } + public string ContractNumber { get; set; } + public string Name { get; set; } + public int ContractType { get; set; } + public DateTime StartOn { get; set; } + public DateTime? EndOn { get; set; } + public string RateScheduleId { get; set; } + public decimal? DiscountPercent { get; set; } + public int? TermsNetDays { get; set; } + public string InvoiceSubmissionEmail { get; set; } + public int? MaxDeploymentDays { get; set; } + public int? ResponseTimeMinutes { get; set; } + public string PointOfHire { get; set; } + public string DocumentTemplateKey { get; set; } + public string Notes { get; set; } + } + + public class SetContractStatusInput + { + public string Id { get; set; } + public int Status { get; set; } + } + + public class SaveContractRequirementsInput + { + public string ServiceContractId { get; set; } + public List Requirements { get; set; } = new List(); + } + + public class ContractRequirementsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class ComplianceDocumentsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class ComplianceDocumentResult : StandardApiResponseV4Base + { + public ComplianceDocumentData Data { get; set; } + } + + public class ComplianceDocumentData + { + public int Id { get; set; } + /// ComplianceDocumentTypes value. + public int DocumentType { get; set; } + public string Name { get; set; } + public string DocumentNumber { get; set; } + public string Issuer { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public int AlertLeadDays { get; set; } + public string FileName { get; set; } + public string FileType { get; set; } + public int? FileSize { get; set; } + public bool IsCurrent { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class SaveComplianceDocumentInput + { + public int Id { get; set; } + public int DocumentType { get; set; } + public string Name { get; set; } + public string DocumentNumber { get; set; } + public string Issuer { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public int AlertLeadDays { get; set; } = 30; + /// Base64 file bytes; omit to keep the stored file. + public string FileBase64 { get; set; } + public string FileName { get; set; } + public string FileType { get; set; } + } + + public class ContractComplianceApiResult : StandardApiResponseV4Base + { + public ContractComplianceData Data { get; set; } + } + + public class ContractComplianceData + { + public string ServiceContractId { get; set; } + public string DeploymentId { get; set; } + public bool AllMandatorySatisfied { get; set; } + public List Items { get; set; } = new List(); + } + + public class ContractComplianceItemData + { + public string RequirementId { get; set; } + public string Name { get; set; } + public int Stage { get; set; } + public int? ComplianceDocumentType { get; set; } + public bool IsMandatory { get; set; } + public bool Satisfied { get; set; } + public string SatisfiedBy { get; set; } + public int? ComplianceDocumentId { get; set; } + public int? DeploymentAttachmentId { get; set; } + public DateTime? ExpiresOn { get; set; } + } + + #endregion + + #region Bids + + public class BidsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class BidResult : StandardApiResponseV4Base + { + public BidData Data { get; set; } + } + + public class BidData + { + public string Id { get; set; } + public int BidNumber { get; set; } + public string ContactId { get; set; } + public string CustomerBillingProfileId { get; set; } + public string ServiceContractId { get; set; } + public string RateScheduleId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + /// BidStatuses value. + public int Status { get; set; } + public DateTime? ValidUntil { get; set; } + public DateTime? RequestedStartOn { get; set; } + public DateTime? RequestedEndOn { get; set; } + public string IncidentNumber { get; set; } + public string DeliveryLocation { get; set; } + public decimal? DiscountPercent { get; set; } + public decimal EstimatedSubTotal { get; set; } + public decimal EstimatedDiscountAmount { get; set; } + public decimal EstimatedTaxAmount { get; set; } + public decimal EstimatedTotal { get; set; } + public string Notes { get; set; } + public string TermsText { get; set; } + public DateTime? SentOn { get; set; } + public string SentToEmail { get; set; } + public DateTime? AcceptedOn { get; set; } + public DateTime? DeclinedOn { get; set; } + public string DeclineReason { get; set; } + public int? ConvertedCallId { get; set; } + public string ConvertedDeploymentId { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + public List LineItems { get; set; } = new List(); + } + + public class BidLineData + { + public string Id { get; set; } + public string RateScheduleEntryId { get; set; } + /// BidLineTypes value. + public int LineType { get; set; } + public string Description { get; set; } + public int? CrewSize { get; set; } + public decimal Quantity { get; set; } = 1; + public decimal? EstimatedHoursPerDay { get; set; } + public decimal? EstimatedDays { get; set; } + public decimal UnitRate { get; set; } + public List PremiumIds { get; set; } = new List(); + public decimal EstimatedAmount { get; set; } + public bool Taxable { get; set; } = true; + public int SortOrder { get; set; } + } + + public class NewBidInput + { + public string ContactId { get; set; } + public string ServiceContractId { get; set; } + public string Title { get; set; } + } + + public class SaveBidInput + { + public string Id { get; set; } + public string ServiceContractId { get; set; } + public string RateScheduleId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public DateTime? ValidUntil { get; set; } + public DateTime? RequestedStartOn { get; set; } + public DateTime? RequestedEndOn { get; set; } + public string IncidentNumber { get; set; } + public string DeliveryLocation { get; set; } + public decimal? DiscountPercent { get; set; } + public string Notes { get; set; } + public string TermsText { get; set; } + /// When present the line set is replaced (upsert by id). + public List LineItems { get; set; } + } + + public class SetBidStatusInput + { + public string Id { get; set; } + /// BidStatuses target: Submitted (1), Accepted (2), Declined (3), Withdrawn (5). + public int Status { get; set; } + public string Reason { get; set; } + } + + public class SendBidInput + { + public string Id { get; set; } + public string ToEmail { get; set; } + } + + public class BidConversionContextResult : StandardApiResponseV4Base + { + public BidConversionContextData Data { get; set; } + } + + public class BidConversionContextData + { + public BidData Bid { get; set; } + public ServiceContractData Contract { get; set; } + public RateScheduleData Schedule { get; set; } + public string ContactName { get; set; } + public decimal? EffectiveDiscountPercent { get; set; } + public string Currency { get; set; } + public bool AlreadyConverted { get; set; } + } + + public class ConvertBidInput + { + public string BidId { get; set; } + public string CallName { get; set; } + public string CallNature { get; set; } + public int CallPriority { get; set; } + public int? CallTypeId { get; set; } + public string Address { get; set; } + public string GeoLocation { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public int? MaxDays { get; set; } + public string IncidentNumber { get; set; } + public string ServiceRequestNumber { get; set; } + public string PointOfHire { get; set; } + public bool OutOfProvince { get; set; } + public bool TravelViaAir { get; set; } + public string LocalTimeZoneId { get; set; } + public string Notes { get; set; } + public bool CreateCalendarItem { get; set; } = true; + public List UnassignedPersonnel { get; set; } = new List(); + public List Units { get; set; } = new List(); + } + + public class ConvertBidUnitInput + { + public int UnitId { get; set; } + public string CallSign { get; set; } + public string BidLineItemId { get; set; } + public string RateScheduleEntryId { get; set; } + public List Seats { get; set; } = new List(); + public List Equipment { get; set; } = new List(); + } + + public class ConvertBidSeatInput + { + public string UserId { get; set; } + public int? UnitRoleId { get; set; } + public string RateScheduleEntryId { get; set; } + public string CertificationCode { get; set; } + public List PremiumIds { get; set; } = new List(); + public string CallSign { get; set; } + } + + public class ConvertBidEquipmentInput + { + public string InventoryAssetId { get; set; } + public string InventoryItemId { get; set; } + public string FreeTextName { get; set; } + public string RateScheduleEntryId { get; set; } + public string BidLineItemId { get; set; } + } + + public class BidConversionResultResult : StandardApiResponseV4Base + { + public BidConversionResultData Data { get; set; } + } + + public class BidConversionResultData + { + public string BidId { get; set; } + public int CallId { get; set; } + public string DeploymentId { get; set; } + public int? CalendarItemId { get; set; } + public List Warnings { get; set; } = new List(); + } + + #endregion + + #region Charges + + public class ContractorChargesResult : StandardApiResponseV4Base + { + public ContractorChargesData Data { get; set; } + } + + public class ContractorChargesData + { + public string DeploymentId { get; set; } + public string Currency { get; set; } + public decimal SubTotal { get; set; } + public decimal? DiscountPercent { get; set; } + public decimal DiscountAmount { get; set; } + public List ReportIds { get; set; } = new List(); + public List Lines { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + } + + public class ContractorChargeLineData + { + public DateTime Date { get; set; } + public string TimeReportId { get; set; } + public int ReportNumber { get; set; } + public int? SubjectType { get; set; } + public string SubjectId { get; set; } + public string SubjectName { get; set; } + public string EntryName { get; set; } + /// ContractorChargeKinds value. + public int Kind { get; set; } + public string Description { get; set; } + public decimal Quantity { get; set; } + public decimal UnitRate { get; set; } + public decimal Amount { get; set; } + public bool Taxable { get; set; } + } + + public class ContractorChargeWarningData + { + public string Code { get; set; } + public string Message { get; set; } + public string TimeReportId { get; set; } + public string SubjectId { get; set; } + } + + public class GenerateDeploymentInvoiceInput + { + public string DeploymentId { get; set; } + public DateTime? ThroughDate { get; set; } + } + + public class DeploymentInvoiceResult : StandardApiResponseV4Base + { + public DeploymentInvoiceData Data { get; set; } + } + + public class DeploymentInvoiceData + { + public string InvoiceId { get; set; } + public int InvoiceNumber { get; set; } + public decimal Total { get; set; } + public string Currency { get; set; } + public int LineCount { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs index 98a7806ab..eea74ee85 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs @@ -4,7 +4,8 @@ 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. + // Only the deployment's internal Notes are under ADP (catalog 27) and read REDACTED without a grant; DTRs, expenses and + // attachments are sent to customers and are stored whole. Attachment bytes never ride these endpoints except the receipt upload input. public class DeploymentAccessResult : StandardApiResponseV4Base { @@ -229,7 +230,6 @@ public class DeploymentAttachmentData 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; } } @@ -272,7 +272,6 @@ public class TimeReportData 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(); @@ -376,7 +375,6 @@ public class ExpenseData 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; } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 406299f9d..1240bbd82 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -116,6 +116,19 @@ ID of the user + + + Contractor bids and the deployment charge run (Workforce & Business Operations plan, C5). Gated by the + Invoicing.ContractorBilling entitlement; Bids_View reads, Bids_Create/Update/Delete write, the charge run and + invoice generation need Invoicing_Update. Conversion mirrors the MVC wizard's last step. + + + + Whether the contractor path is available to this department and what the caller may do. Always answers. + + + Dry run of the contractor billing engine over the deployment's approved, unbilled DTRs. + Mobile or Tablet Device specific operations @@ -2875,6 +2888,13 @@ while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + + Contractor rate schedules (Workforce & Business Operations plan, C5): schedules, entries with bands, premiums, + clone and JSON import/export. Gated by the Invoicing.ContractorBilling entitlement; Invoicing_View reads, + Invoicing_Update writes. + + Independent feature availability and monthly offers. A flag alone is not a paid entitlement. @@ -3619,6 +3639,13 @@ DepartmentRightsResult object with the department rights and group memberships + + + Service contracts, document requirements and department compliance documents (Workforce & Business + Operations plan, C5). Gated by the Invoicing.ContractorBilling entitlement; ServiceContracts_View reads, + ServiceContracts_Update writes. Compliance document bytes are served only through GetComplianceDocumentFile. + + User authentication session inventory and revocation. @@ -11002,6 +11029,51 @@ The numeric code the user received. + + Invoicing.ContractorBilling entitlement (paid add-on + flag). + + + RateEntryTypes value. + + + BillingBases value. + + + RateBandTypes value. + + + ServiceContractTypes value. + + + ServiceContractStatuses value. + + + DocumentRequirementStages value. + + + ComplianceDocumentTypes value; null = satisfied by a deployment attachment. + + + ComplianceDocumentTypes value. + + + Base64 file bytes; omit to keep the stored file. + + + BidStatuses value. + + + BidLineTypes value. + + + When present the line set is replaced (upsert by id). + + + BidStatuses target: Submitted (1), Accepted (2), Declined (3), Withdrawn (5). + + + ContractorChargeKinds value. + Custom defined Status for Personnel and Units diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 2fcd74550..d8475de63 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -331,6 +331,12 @@ public void ConfigureServices(IServiceCollection services) 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.Bids_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Bids_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Create)); + options.AddPolicy(ResgridResources.Bids_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.Bids_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Delete)); + options.AddPolicy(ResgridResources.ServiceContracts_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.ServiceContracts_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.Update)); 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/BidsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs new file mode 100644 index 000000000..f985a86c2 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.ContractorBilling; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Contractor bids (Workforce & Business Operations plan, Phase C6): list with status filter, New (contact → + /// contract context), Edit (line editor fed by the rate schedule with rate snapshots, crew-size selector, premium + /// picker, discount cascade display, estimate totals), View (PDF, send, accept/decline/withdraw) and "Schedule + /// Deployment Call" hand-off to the wizard. Needs the Invoicing.ContractorBilling entitlement; Bids_View reads, + /// Bids_Create/Update/Delete (admins by default) edit. Bids are customer-facing and not under Advanced Data Protection. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class BidsController : SecureBaseController + { + private readonly IBidsService _bids; + private readonly IServiceContractService _contracts; + private readonly IRateScheduleService _rateSchedules; + private readonly IInvoicingService _invoicing; + private readonly IDeploymentService _deployments; + private readonly IContactsService _contactsService; + private readonly IBusinessOperationsAccessService _access; + private readonly IStringLocalizer _strings; + + public BidsController(IBidsService bids, IServiceContractService contracts, IRateScheduleService rateSchedules, IInvoicingService invoicing, IDeploymentService deployments, + IContactsService contactsService, IBusinessOperationsAccessService access, IStringLocalizer strings) + { + _bids = bids; + _contracts = contracts; + _rateSchedules = rateSchedules; + _invoicing = invoicing; + _deployments = deployments; + _contactsService = contactsService; + _access = access; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageBids(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewBids(); + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!CanView || !await _access.CanUseContractorBillingAsync(DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + await next(); + } + + private T Page(T view) where T : ContractorPageView + { + view.CanManageBids = CanManage; + view.CanManageContracts = IsAdmin || ClaimsAuthorizationHelper.CanManageContracts(); + view.CanManageRates = IsAdmin || ClaimsAuthorizationHelper.CanManageInvoicing(); + view.CanManageDeployments = IsAdmin || ClaimsAuthorizationHelper.CanManageDeployments(); + if (TempData["ContractorMessage"] is string message) view.Message = message; + if (TempData["ContractorSaved"] 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["ContractorMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["ContractorSaved"] = 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("bids_", StringComparison.Ordinal); + + private async Task ContactAsync(string contactId) + { + if (string.IsNullOrWhiteSpace(contactId)) return null; + try { return await _contactsService.GetContactByIdAsync(contactId); } + catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Bid page: contact unavailable."); return null; } + } + + #endregion + + [HttpGet] + public async Task Index(int? status = null, int page = 1) + { + var view = Page(new BidIndexView { StatusFilter = status, Page = Math.Max(1, page) }); + var filter = status.HasValue && Enum.IsDefined(typeof(BidStatuses), status.Value) ? (BidStatuses?)status.Value : null; + view.Total = await _bids.CountBidsForDepartmentAsync(DepartmentId, filter); + view.Bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, filter, (view.Page - 1) * view.PageSize, view.PageSize); + var wanted = new HashSet(view.Bids.Select(b => b.ContactId), StringComparer.OrdinalIgnoreCase); + if (wanted.Count > 0) + foreach (var contact in await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List()) + if (wanted.Contains(contact.ContactId)) view.ContactNames[contact.ContactId] = contact.Name; + return View(view); + } + + [HttpGet] + public async Task New(string contactId = null, string contractId = null) + { + if (!CanManage) return Unauthorized(); + var view = Page(new BidNewView { ContactId = contactId, ServiceContractId = contractId }); + view.Contacts = (await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List()).Where(c => !c.IsDeleted).OrderBy(c => c.Name).Select(c => new SelectListItem(c.Name, c.ContactId, string.Equals(c.ContactId, contactId, StringComparison.OrdinalIgnoreCase))).ToList(); + view.Contracts = (await _contracts.GetContractsForDepartmentAsync(DepartmentId)).Where(c => c.Status is (int)ServiceContractStatuses.Active or (int)ServiceContractStatuses.Draft).ToList(); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Create(string contactId, string serviceContractId, string title, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var bid = await _bids.CreateDraftBidAsync(DepartmentId, contactId, serviceContractId, title, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = bid.BidId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "New", new { contactId, contractId = serviceContractId }); } + } + + [HttpGet] + public async Task Edit(string id) + { + if (!CanManage) return Unauthorized(); + var bid = await _bids.GetBidByIdAsync(id, DepartmentId); + if (bid == null) return NotFound(); + if (!bid.IsEditable) return RedirectToAction("View", new { id }); + var view = Page(new BidEditView { Bid = bid }); + view.ContactName = (await ContactAsync(bid.ContactId))?.Name ?? bid.ContactId; + view.Contracts = (await _contracts.GetContractsByContactIdAsync(bid.ContactId, DepartmentId)).Where(c => c.Status is (int)ServiceContractStatuses.Active or (int)ServiceContractStatuses.Draft || string.Equals(c.ServiceContractId, bid.ServiceContractId, StringComparison.OrdinalIgnoreCase)).ToList(); + view.ContractDiscountPercent = view.Contracts.FirstOrDefault(c => string.Equals(c.ServiceContractId, bid.ServiceContractId, StringComparison.OrdinalIgnoreCase))?.DiscountPercent; + try { view.ProfileDiscountPercent = (await _invoicing.GetBillingProfileByContactIdAsync(bid.ContactId, DepartmentId))?.DefaultDiscountPercent; } catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Bid editor: billing profile unavailable."); } + var schedules = await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId, includeInactive: true); + view.Schedules = schedules.OrderBy(s => s.Name).Select(s => new SelectListItem(s.Name + (s.IsActive ? string.Empty : " (" + _strings["Inactive"].Value + ")"), s.RateScheduleId, string.Equals(s.RateScheduleId, bid.RateScheduleId, StringComparison.OrdinalIgnoreCase))).ToList(); + view.Schedule = string.IsNullOrWhiteSpace(bid.RateScheduleId) ? null : await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, DepartmentId); + view.Currency = view.Schedule?.Currency ?? "USD"; + view.LinesJson = JsonConvert.SerializeObject(bid.LineItems.Select(l => new { id = l.BidLineItemId, entryId = l.RateScheduleEntryId, lineType = l.LineType, description = l.Description, crewSize = l.CrewSize, quantity = l.Quantity, hoursPerDay = l.EstimatedHoursPerDay, days = l.EstimatedDays, unitRate = l.UnitRate, premiumIds = l.PremiumIds, taxable = l.Taxable, amount = l.EstimatedAmount })); + view.EntriesJson = JsonConvert.SerializeObject((view.Schedule?.Entries ?? new List()).Select(e => new + { + id = e.RateScheduleEntryId, name = e.Name, entryType = e.EntryType, basis = e.BillingBasis, groupKey = e.GroupKey, crewSize = e.CrewSize, code = e.Code, + rate = Resgrid.Services.Invoicing.BidsService.SnapshotRate(view.Schedule, new BidLineItem { RateScheduleEntryId = e.RateScheduleEntryId }) + })); + view.PremiumsJson = JsonConvert.SerializeObject((view.Schedule?.Premiums ?? new List()).Select(p => new { id = p.RatePremiumId, name = p.Name, deploymentAdder = p.DeploymentAdder })); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Save(BidInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null || string.IsNullOrWhiteSpace(input.BidId)) return BadRequest(); + List lines = null; + if (input.LinesJson != null) + { + try + { + var rows = JsonConvert.DeserializeObject>(input.LinesJson) ?? new List(); + lines = rows.Select((r, i) => new BidLineItem + { + BidLineItemId = r.Id, RateScheduleEntryId = r.EntryId, LineType = r.LineType, Description = r.Description, CrewSize = r.CrewSize, Quantity = r.Quantity, EstimatedHoursPerDay = r.HoursPerDay, + EstimatedDays = r.Days, UnitRate = r.UnitRate, PremiumIdsJson = r.PremiumIds == null || r.PremiumIds.Count == 0 ? null : JsonConvert.SerializeObject(r.PremiumIds), Taxable = r.Taxable, SortOrder = i + }).ToList(); + } + catch (JsonException) { return Refused(400, "bids_line_invalid", "Edit", new { id = input.BidId }); } + } + try + { + await _bids.SaveBidAsync(new Bid + { + BidId = input.BidId, DepartmentId = DepartmentId, ServiceContractId = input.ServiceContractId, RateScheduleId = input.RateScheduleId, Title = input.Title, Description = input.Description, ValidUntil = input.ValidUntil, + RequestedStartOn = input.RequestedStartOn, RequestedEndOn = input.RequestedEndOn, IncidentNumber = input.IncidentNumber, DeliveryLocation = input.DeliveryLocation, DiscountPercent = input.DiscountPercent, + Notes = input.Notes, TermsText = input.TermsText + }, UserId, Ip, Agent, cancellationToken); + if (lines != null) await _bids.SaveBidLineItemsAsync(input.BidId, DepartmentId, lines, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = input.BidId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Edit", new { id = input.BidId }); } + } + + private sealed class LineRow + { + public string Id { get; set; } + public string EntryId { get; set; } + public int LineType { get; set; } + public string Description { get; set; } + public int? CrewSize { get; set; } + public decimal Quantity { get; set; } = 1; + public decimal? HoursPerDay { get; set; } + public decimal? Days { get; set; } + public decimal UnitRate { get; set; } + public List PremiumIds { get; set; } + public bool Taxable { get; set; } = true; + } + + [HttpGet] + public async Task View(string id) + { + var bid = await _bids.GetBidByIdAsync(id, DepartmentId); + if (bid == null) return NotFound(); + var view = Page(new BidDetailView { Bid = bid }); + var contact = await ContactAsync(bid.ContactId); + view.ContactName = contact?.Name ?? bid.ContactId; + view.ContactEmail = ProtectedDataEnvelope.SafeDisplay(contact?.Email); + if (!string.IsNullOrWhiteSpace(bid.ServiceContractId)) view.ContractName = (await _contracts.GetContractByIdAsync(bid.ServiceContractId, DepartmentId))?.Name; + if (!string.IsNullOrWhiteSpace(bid.RateScheduleId)) + { + var schedule = await _rateSchedules.GetScheduleByIdAsync(bid.RateScheduleId, DepartmentId, includeInactive: true); + view.ScheduleName = schedule?.Name; + view.Currency = schedule?.Currency ?? "USD"; + } + if (bid.IsConverted) view.ConvertedDeployment = await _deployments.GetDeploymentByIdAsync(bid.ConvertedDeploymentId, DepartmentId); + return View(view); + } + + [HttpGet] + public async Task Pdf(string id) + { + var bid = await _bids.GetBidByIdAsync(id, DepartmentId); + if (bid == null) return NotFound(); + var pdf = await _bids.GetBidPdfAsync(id, DepartmentId); + if (pdf == null || pdf.Length == 0) return Refused(500, "bids_pdf_unavailable", "View", new { id }); + return File(pdf, "application/pdf", $"bid-{bid.BidNumber}.pdf"); + } + + [HttpGet] + public async Task Preview(string id) + { + var html = await _bids.RenderBidHtmlAsync(id, DepartmentId); + return html == null ? NotFound() : Content(html, "text/html"); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Send(string id, string toEmail, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try { await _bids.SendBidAsync(id, DepartmentId, toEmail, 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 SetStatus(string id, int status, string reason, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!Enum.IsDefined(typeof(BidStatuses), status)) return BadRequest(); + try + { + switch ((BidStatuses)status) + { + case BidStatuses.Submitted: await _bids.SubmitBidAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); break; + case BidStatuses.Accepted: await _bids.AcceptBidAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); break; + case BidStatuses.Declined: await _bids.DeclineBidAsync(id, DepartmentId, reason, UserId, Ip, Agent, cancellationToken); break; + case BidStatuses.Withdrawn: await _bids.WithdrawBidAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); break; + default: return Refused(400, "bids_status_transition_invalid", "View", new { id }); + } + return Saved("View", new { id }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(409, ex.Message, "View", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + if (!await _bids.DeleteBidAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Index"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(409, ex.Message, "View", new { id }); } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs index 1af54bb6b..a823feac8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs @@ -113,6 +113,39 @@ private async Task> PersonnelNamesAsync() return (names ?? new List()).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Name, StringComparer.OrdinalIgnoreCase); } + /// + /// ADP reveal endpoint (plan 7.2) for the record and unit pages. The grant rides the X-Resgrid-Protected-Grant + /// header: catalog 6 resolves here with the explicit grant, catalog 27/28 through the certification service's + /// request-bound grant context. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Reveal([FromForm] string kind, [FromForm] int id) + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.Equals(kind, "unit", StringComparison.OrdinalIgnoreCase)) + { + if (!CanView) return Unauthorized(); + var unit = await _units.GetUnitByIdAsync(id); + if (unit == null || unit.DepartmentId != DepartmentId) return NotFound(); + foreach (var row in await _certifications.GetUnitCertificationsAsync(id)) + foreach (var accessor in CertificationProtectedFields.Unit) fields[accessor.Key + ":" + row.UnitCertificationId] = accessor.Value.Get(row); + } + else + { + var record = await AuthorizedRecordAsync(id, false); + if (record == null) return Unauthorized(); + record.Data = null; + var resolved = await _protectedRead.ResolveCertificationsForReadAsync(DepartmentId, new[] { record }, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + if (resolved != null && resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + foreach (var accessor in Resgrid.Services.ProtectedReadService.CertificationFieldAccessors) fields[accessor.Key] = accessor.Value.Get(record); + foreach (var credit in await _certifications.GetCertificationCreditsAsync(id)) + foreach (var accessor in CertificationProtectedFields.Credit) fields[accessor.Key + ":" + credit.PersonnelCertificationCreditId] = accessor.Value.Get(credit); + } + return AdpRevealHelper.Answer(this, fields); + } + private async Task<(byte[] Data, string FileName, string FileType, string Error)> ReadUploadAsync(IFormFile file, CancellationToken cancellationToken) { if (file == null || file.Length == 0) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs new file mode 100644 index 000000000..0252dc417 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs @@ -0,0 +1,294 @@ +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 Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.ContractorBilling; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Service contracts and department compliance documents (Workforce & Business Operations plan, Phase C6): + /// contract list/editor with document requirements, the detail page with linked bids, deployments and invoices and + /// the compliance checklist, and the Compliance Documents page (expiry badges, upload, alert lead days). Needs the + /// Invoicing.ContractorBilling entitlement; ServiceContracts_View reads, ServiceContracts_Update (admins by + /// default) edits. Contracts and compliance documents are customer-facing and not under Advanced Data Protection. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None), RequestSizeLimit(32 * 1024 * 1024)] + public sealed class ContractsController : SecureBaseController + { + private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "csv", "heic" }; + + private readonly IServiceContractService _contracts; + private readonly IRateScheduleService _rateSchedules; + private readonly IBidsService _bids; + private readonly IDeploymentService _deployments; + private readonly IInvoicingService _invoicing; + private readonly IContactsService _contactsService; + private readonly IBusinessOperationsAccessService _access; + private readonly IStringLocalizer _strings; + + public ContractsController(IServiceContractService contracts, IRateScheduleService rateSchedules, IBidsService bids, IDeploymentService deployments, IInvoicingService invoicing, + IContactsService contactsService, IBusinessOperationsAccessService access, IStringLocalizer strings) + { + _contracts = contracts; + _rateSchedules = rateSchedules; + _bids = bids; + _deployments = deployments; + _invoicing = invoicing; + _contactsService = contactsService; + _access = access; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageContracts(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewContracts(); + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!CanView || !await _access.CanUseContractorBillingAsync(DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + await next(); + } + + private T Page(T view) where T : ContractorPageView + { + view.CanManageContracts = CanManage; + view.CanManageBids = IsAdmin || ClaimsAuthorizationHelper.CanManageBids(); + view.CanManageRates = IsAdmin || ClaimsAuthorizationHelper.CanManageInvoicing(); + view.CanManageDeployments = IsAdmin || ClaimsAuthorizationHelper.CanManageDeployments(); + if (TempData["ContractorMessage"] is string message) view.Message = message; + if (TempData["ContractorSaved"] 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["ContractorMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["ContractorSaved"] = 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("contracts_", StringComparison.Ordinal) || ex.Message.StartsWith("compliance_", StringComparison.Ordinal); + + private async Task> ContactNamesAsync(IEnumerable contactIds) + { + var wanted = new HashSet(contactIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase); + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (wanted.Count == 0) return names; + foreach (var contact in await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List()) + if (wanted.Contains(contact.ContactId)) names[contact.ContactId] = contact.Name; + return names; + } + + #endregion + + #region Contracts + + [HttpGet] + public async Task Index(int? status = null) + { + var view = Page(new ContractIndexView { StatusFilter = status }); + view.Contracts = await _contracts.GetContractsForDepartmentAsync(DepartmentId, status.HasValue && Enum.IsDefined(typeof(ServiceContractStatuses), status.Value) ? (ServiceContractStatuses?)status.Value : null); + view.ContactNames = await ContactNamesAsync(view.Contracts.Select(c => c.ContactId)); + foreach (var schedule in await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId, includeInactive: true)) view.ScheduleNames[schedule.RateScheduleId] = schedule.Name; + var now = DateTime.UtcNow; + view.ExpiringDocuments = (await _contracts.GetComplianceDocumentsAsync(DepartmentId)).Count(d => d.ExpiresOn.HasValue && d.ExpiresOn.Value.Date <= now.Date.AddDays(d.AlertLeadDays)); + return View(view); + } + + [HttpGet] + public async Task New(string contactId = null) + { + if (!CanManage) return Unauthorized(); + var view = Page(new ContractEditView { Contract = new ServiceContract { DepartmentId = DepartmentId, ContactId = contactId, StartOn = DateTime.UtcNow.Date, TermsNetDays = 30 } }); + await FillLookupsAsync(view); + return View("Edit", view); + } + + [HttpGet] + public async Task Edit(string id) + { + if (!CanManage) return Unauthorized(); + var contract = await _contracts.GetContractByIdAsync(id, DepartmentId); + if (contract == null) return NotFound(); + var view = Page(new ContractEditView { Contract = contract, RequirementsJson = JsonConvert.SerializeObject(contract.Requirements.Select(r => new { r.ServiceContractDocumentRequirementId, r.Name, r.Stage, r.ComplianceDocumentType, r.IsMandatory, r.SortOrder })) }); + await FillLookupsAsync(view); + return View(view); + } + + private async Task FillLookupsAsync(ContractEditView view) + { + view.Contacts = (await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List()).Where(c => !c.IsDeleted).OrderBy(c => c.Name) + .Select(c => new SelectListItem(c.Name, c.ContactId, string.Equals(c.ContactId, view.Contract.ContactId, StringComparison.OrdinalIgnoreCase))).ToList(); + view.Schedules = (await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId, includeInactive: true)).OrderBy(s => s.Name) + .Select(s => new SelectListItem(s.Name + (s.IsActive ? string.Empty : " (" + _strings["Inactive"].Value + ")"), s.RateScheduleId, string.Equals(s.RateScheduleId, view.Contract.RateScheduleId, StringComparison.OrdinalIgnoreCase))).ToList(); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Save(ContractInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + List requirements = null; + if (input.RequirementsJson != null) + { + try { requirements = JsonConvert.DeserializeObject>(input.RequirementsJson) ?? new List(); } + catch (JsonException) { return Refused(400, "contracts_requirement_invalid", string.IsNullOrWhiteSpace(input.ServiceContractId) ? "New" : "Edit", new { id = input.ServiceContractId }); } + } + try + { + var saved = await _contracts.SaveContractAsync(new ServiceContract + { + ServiceContractId = input.ServiceContractId, DepartmentId = DepartmentId, ContactId = input.ContactId, ContractNumber = input.ContractNumber, Name = input.Name, ContractType = input.ContractType, + StartOn = input.StartOn, EndOn = input.EndOn, RateScheduleId = input.RateScheduleId, DiscountPercent = input.DiscountPercent, TermsNetDays = input.TermsNetDays, InvoiceSubmissionEmail = input.InvoiceSubmissionEmail, + MaxDeploymentDays = input.MaxDeploymentDays, ResponseTimeMinutes = input.ResponseTimeMinutes, PointOfHire = input.PointOfHire, DocumentTemplateKey = input.DocumentTemplateKey, Notes = input.Notes, + Status = input.ActivateNow && string.IsNullOrWhiteSpace(input.ServiceContractId) ? (int)ServiceContractStatuses.Active : (int)ServiceContractStatuses.Draft + }, UserId, Ip, Agent, cancellationToken); + if (requirements != null) await _contracts.SaveRequirementsAsync(saved.ServiceContractId, DepartmentId, requirements, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id = saved.ServiceContractId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, string.IsNullOrWhiteSpace(input.ServiceContractId) ? "New" : "Edit", new { id = input.ServiceContractId }); } + } + + [HttpGet] + public async Task View(string id) + { + var contract = await _contracts.GetContractByIdAsync(id, DepartmentId); + if (contract == null) return NotFound(); + var view = Page(new ContractDetailView { Contract = contract }); + view.ContactName = (await ContactNamesAsync(new[] { contract.ContactId })).TryGetValue(contract.ContactId, out var name) ? name : contract.ContactId; + if (!string.IsNullOrWhiteSpace(contract.RateScheduleId)) view.ScheduleName = (await _rateSchedules.GetScheduleByIdAsync(contract.RateScheduleId, DepartmentId, includeInactive: true))?.Name; + view.Bids = (await _bids.GetBidsByContactIdAsync(contract.ContactId, DepartmentId)).Where(b => string.Equals(b.ServiceContractId, id, StringComparison.OrdinalIgnoreCase)).ToList(); + view.Deployments = (await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, openOnly: false, 0, 500)).Where(d => string.Equals(d.ServiceContractId, id, StringComparison.OrdinalIgnoreCase)).ToList(); + try { view.Invoices = (await _invoicing.GetInvoicesForDepartmentAsync(DepartmentId, new InvoiceListFilter { ContactId = contract.ContactId, Take = 200 })).Where(i => string.Equals(i.ServiceContractId, id, StringComparison.OrdinalIgnoreCase)).ToList(); } + catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Contract detail: invoices unavailable."); } + view.Compliance = await _contracts.GetContractComplianceForContractAsync(id, DepartmentId); + foreach (ServiceContractStatuses candidate in Enum.GetValues(typeof(ServiceContractStatuses))) + if (Resgrid.Services.Invoicing.ServiceContractService.IsValidTransition((ServiceContractStatuses)contract.Status, candidate)) view.NextStatuses.Add(candidate); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SetStatus(string id, int status, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!Enum.IsDefined(typeof(ServiceContractStatuses), status)) return BadRequest(); + try + { + await _contracts.SetContractStatusAsync(id, DepartmentId, (ServiceContractStatuses)status, UserId, Ip, Agent, cancellationToken); + return Saved("View", new { id }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(409, ex.Message, "View", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + if (!await _contracts.DeleteContractAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Index"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(409, ex.Message, "View", new { id }); } + } + + #endregion + + #region Compliance documents + + [HttpGet] + public async Task Compliance(int? edit = null) + { + var view = Page(new ComplianceView { EditingId = edit }); + view.Documents = await _contracts.GetComplianceDocumentsAsync(DepartmentId); + if (edit.HasValue) view.Editing = view.Documents.FirstOrDefault(d => d.DepartmentComplianceDocumentId == edit.Value); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveComplianceDocument(ComplianceDocumentInput input, IFormFile file, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + byte[] data = null; string fileName = null, fileType = null; + if (file != null && file.Length > 0) + { + var extension = FileHelper.GetFileExtensionWithoutDot(file.FileName); + if (!AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) return Refused(400, "compliance_file_type", "Compliance"); + if (file.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes) return Refused(400, "compliance_file_too_large", "Compliance"); + using var stream = file.OpenReadStream(); + data = await FileHelper.ReadAllBytesAsync(stream, cancellationToken); + fileName = FileHelper.GetSafeFileName(file.FileName); + fileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType; + } + try + { + await _contracts.SaveComplianceDocumentAsync(new DepartmentComplianceDocument + { + DepartmentComplianceDocumentId = input.DepartmentComplianceDocumentId, DepartmentId = DepartmentId, DocumentType = input.DocumentType, Name = input.Name, DocumentNumber = input.DocumentNumber, + Issuer = input.Issuer, EffectiveOn = input.EffectiveOn, ExpiresOn = input.ExpiresOn, AlertLeadDays = input.AlertLeadDays + }, data, fileName, fileType, UserId, Ip, Agent, cancellationToken); + return Saved("Compliance"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Compliance"); } + } + + [HttpGet] + public async Task ComplianceFile(int id) + { + var document = await _contracts.GetComplianceDocumentAsync(id, DepartmentId, includeData: true); + if (document == null) return NotFound(); + if (document.Data == null || document.Data.Length == 0) return NotFound(); + return File(document.Data, document.FileType ?? "application/octet-stream", string.IsNullOrWhiteSpace(document.FileName) ? $"compliance-{id}.bin" : document.FileName); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task DeleteComplianceDocument(int id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!await _contracts.DeleteComplianceDocumentAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Compliance"); + } + + #endregion + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs new file mode 100644 index 000000000..6bfe6cbe6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Certifications; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.ContractorBilling; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// "Schedule Deployment Call" wizard (Workforce & Business Operations plan, Phase C6): six server-rendered + /// INSPINIA steps prefilled from an accepted bid — call details, units, crew seats per unit (roster with status, + /// staffing, roles, typed certifications and overlap conflicts), equipment, rates and premiums, review — ending in + /// the transactional . Needs Bids_Update and + /// Deployments_Update (admins by default) plus the Invoicing.ContractorBilling entitlement. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class DeploymentWizardController : SecureBaseController + { + private readonly IBidsService _bids; + private readonly IDeploymentService _deployments; + private readonly IDepartmentsService _departments; + private readonly IUnitsService _units; + private readonly ICallsService _calls; + private readonly IPersonnelRolesService _roles; + private readonly ICertificationService _certifications; + private readonly IUserStateService _userStates; + private readonly IActionLogsService _actionLogs; + private readonly IBusinessOperationsAccessService _access; + private readonly IStringLocalizer _strings; + + public DeploymentWizardController(IBidsService bids, IDeploymentService deployments, IDepartmentsService departments, IUnitsService units, ICallsService calls, IPersonnelRolesService roles, + ICertificationService certifications, IUserStateService userStates, IActionLogsService actionLogs, IBusinessOperationsAccessService access, + IStringLocalizer strings) + { + _bids = bids; + _deployments = deployments; + _departments = departments; + _units = units; + _calls = calls; + _roles = roles; + _certifications = certifications; + _userStates = userStates; + _actionLogs = actionLogs; + _access = access; + _strings = strings; + } + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanRun => IsAdmin || (ClaimsAuthorizationHelper.CanManageBids() && ClaimsAuthorizationHelper.CanManageDeployments()); + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!CanRun || !await _access.CanUseContractorBillingAsync(DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + await next(); + } + + private string Ip => IpAddressHelper.GetRequestIP(Request, true); + private string Agent => $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + + [HttpGet] + public async Task Index(string bidId) + { + var context = await _bids.GetBidConversionContextAsync(bidId, DepartmentId); + if (context?.Bid == null) return NotFound(); + if (context.Bid.Status != (int)BidStatuses.Accepted || context.AlreadyConverted) + { + TempData["ContractorMessage"] = _strings[context.AlreadyConverted ? "bids_already_converted" : "bids_not_accepted"].Value; + return RedirectToAction("View", "Bids", new { id = bidId }); + } + + var view = new WizardView { Context = context, CanManageBids = true, CanManageDeployments = true }; + view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); + view.CallTypes = await _calls.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + view.Priorities = await _calls.GetActiveCallPrioritiesForDepartmentAsync(DepartmentId) ?? new List(); + await LoadRosterAsync(view, context); + view.ContextJson = JsonConvert.SerializeObject(new + { + bid = new { context.Bid.BidId, context.Bid.BidNumber, context.Bid.Title, context.Bid.IncidentNumber, context.Bid.RequestedStartOn, context.Bid.RequestedEndOn, context.Bid.DeliveryLocation, context.Bid.Description, lines = context.Bid.LineItems.Select(l => new { l.BidLineItemId, l.RateScheduleEntryId, l.LineType, l.Description, l.CrewSize, l.Quantity, l.PremiumIds }) }, + contract = context.Contract == null ? null : new { context.Contract.ServiceContractId, context.Contract.Name, context.Contract.PointOfHire, context.Contract.MaxDeploymentDays }, + schedule = context.Schedule == null ? null : new + { + context.Schedule.RateScheduleId, context.Schedule.Name, context.Schedule.Currency, + entries = context.Schedule.Entries.Select(e => new { e.RateScheduleEntryId, e.Name, e.EntryType, e.BillingBasis, e.GroupKey, e.CrewSize, e.CertificationCode, e.UnitTypeId, e.InventoryItemId, requiredCertifications = e.RequiredCertifications, rate = Resgrid.Services.Invoicing.BidsService.SnapshotRate(context.Schedule, new BidLineItem { RateScheduleEntryId = e.RateScheduleEntryId }) }), + premiums = context.Schedule.Premiums.Select(p => new { p.RatePremiumId, p.Name, p.DeploymentAdder, p.Overtime1Adder, p.StandbyAdder }) + }, + units = view.Units, personnel = view.Personnel, roles = view.Roles, timeZone = view.Department?.TimeZone + }); + return View(view); + } + + private async Task LoadRosterAsync(WizardView view, BidConversionContext context) + { + var windowStart = context.Bid.RequestedStartOn ?? DateTime.UtcNow; + var windowEnd = context.Bid.RequestedEndOn ?? windowStart.AddDays(14); + + // Units with type, live state, staffing count and their seats (unit roles) for step 2/3. + var units = await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); + var states = new Dictionary(); + try { foreach (var state in await _units.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId) ?? new List()) states[state.UnitId] = state.GetStatusText(); } + catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: unit states unavailable."); } + var seats = new Dictionary>(); + try { foreach (var role in await _units.GetAllRolesForDepartmentAsync(DepartmentId) ?? new List()) { if (!seats.TryGetValue(role.UnitId, out var list)) seats[role.UnitId] = list = new List(); list.Add(role); } } + catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: unit roles unavailable."); } + var unitConflicts = new HashSet(StringComparer.OrdinalIgnoreCase); + var personConflicts = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + foreach (var warning in await _deployments.GetWindowConflictsAsync(DepartmentId, windowStart, windowEnd, names.Select(n => n.UserId), units.Select(u => u.UnitId))) + { + if (warning.Code != DeploymentRosterWarning.ScheduleConflict) continue; + if (int.TryParse(warning.SubjectId, out _)) unitConflicts.Add(warning.SubjectId); else personConflicts.Add(warning.SubjectId); + } + } + catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: overlap warnings unavailable."); } + + view.Units = units.OrderBy(u => u.Name).Select(u => new WizardUnit + { + UnitId = u.UnitId, Name = u.Name, Type = u.Type, State = states.TryGetValue(u.UnitId, out var state) ? state : null, + Staffing = seats.TryGetValue(u.UnitId, out var unitSeats) ? unitSeats.Count : 0, Conflict = unitConflicts.Contains(u.UnitId.ToString()), + Seats = (seats.TryGetValue(u.UnitId, out var roleList) ? roleList : new List()).Select(r => new WizardSeat { UnitRoleId = r.UnitRoleId, Name = r.Name, PersonnelRoleRequired = r.PersonnelRoleRequired, PersonnelRoleId = r.PersonnelRoleId }).ToList() + }).ToList(); + + // Personnel roster with status, staffing, roles and typed certifications (Phase D) for step 3. + var people = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + var roleMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + try { roleMap = await _roles.GetAllRolesForUsersInDepartmentAsync(DepartmentId) ?? roleMap; } catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: roles unavailable."); } + var statusMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var staffingMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + try { foreach (var log in await _actionLogs.GetLastActionLogsForDepartmentAsync(DepartmentId) ?? new List()) statusMap[log.UserId] = log.GetActionText(); } catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: statuses unavailable."); } + try { foreach (var state in await _userStates.GetLatestStatesForDepartmentAsync(DepartmentId) ?? new List()) staffingMap[state.UserId] = state.GetStaffingText(); } catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: staffing unavailable."); } + var certifications = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var typeCodes = new Dictionary(); + try + { + foreach (var type in await _certifications.GetAllCertificationTypesByDepartmentAsync(DepartmentId) ?? new List()) typeCodes[type.DepartmentCertificationTypeId] = type.Code ?? type.Type; + foreach (var record in await _certifications.GetCertificationsForDepartmentAsync(DepartmentId) ?? new List()) + { + if (record.IsDeleted || string.IsNullOrWhiteSpace(record.UserId)) continue; + if (!certifications.TryGetValue(record.UserId, out var list)) certifications[record.UserId] = list = new List(); + list.Add(record); + } + } + catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: certifications unavailable."); } + + view.Personnel = people.OrderBy(p => p.Name).Select(p => new WizardPerson + { + UserId = p.UserId, Name = p.Name, Status = statusMap.TryGetValue(p.UserId, out var status) ? status : null, Staffing = staffingMap.TryGetValue(p.UserId, out var staffing) ? staffing : null, + RoleIds = roleMap.TryGetValue(p.UserId, out var roles) ? roles.Select(r => r.PersonnelRoleId).ToList() : new List(), + Conflict = personConflicts.Contains(p.UserId), + Certifications = (certifications.TryGetValue(p.UserId, out var certs) ? certs : new List()).Select(c => new WizardCertification + { + Code = c.DepartmentCertificationTypeId.HasValue && typeCodes.TryGetValue(c.DepartmentCertificationTypeId.Value, out var code) ? code : c.Type, Name = ProtectedDataEnvelope.SafeDisplay(c.Name), Status = c.Status, ExpiresOn = c.ExpiresOn, + ExpiringInWindow = c.ExpiresOn.HasValue && c.ExpiresOn.Value >= windowStart && c.ExpiresOn.Value <= windowEnd + }).ToList() + }).ToList(); + try { view.Roles = (await _roles.GetRolesForDepartmentAsync(DepartmentId) ?? new List()).Select(r => new WizardRole { PersonnelRoleId = r.PersonnelRoleId, Name = r.Name }).ToList(); } + catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: department roles unavailable."); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Create(WizardSubmitInput input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.BidId)) return BadRequest(); + BidConversionRequest request; + try { request = JsonConvert.DeserializeObject(input.RequestJson ?? string.Empty); } + catch (JsonException) { request = null; } + if (request == null) return BadRequest(); + request.BidId = input.BidId; + try + { + var result = await _bids.ConvertBidToDeploymentAsync(request, DepartmentId, UserId, Ip, Agent, cancellationToken); + TempData["ContractorSaved"] = true; + if (result.Warnings.Count > 0) TempData["ContractorMessage"] = string.Join(" ", result.Warnings.Select(w => w.Code).Distinct().Select(code => _strings["Warning" + code].Value)); + return Json(new { success = true, deploymentId = result.Deployment?.DeploymentId, callId = result.CallId, url = Url.Action("View", "Deployments", new { area = "User", id = result.Deployment?.DeploymentId }) }); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("bids_", StringComparison.Ordinal) || ex.Message.StartsWith("deployments_", StringComparison.Ordinal)) + { + var text = _strings[ex.Message]; + return StatusCode(400, new { success = false, message = text.ResourceNotFound ? _strings["SaveFailed"].Value : text.Value, code = ex.Message }); + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs index 04b7b1f5f..e63a5e4af 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs @@ -42,11 +42,20 @@ public sealed class DeploymentsController : SecureBaseController private readonly IUserProfileService _profiles; private readonly IRecordDeploymentsService _recordDeployments; private readonly IStringLocalizer _strings; + private readonly IContractorBillingEngine _engine; + private readonly IServiceContractService _contracts; + private readonly IInvoicingService _invoicing; + private readonly IBusinessOperationsAccessService _access; public DeploymentsController(IDeploymentService deployments, ITimeTrackingService timeTracking, IFeatureToggleService flags, IDepartmentsService departments, IUnitsService units, IContactsService contacts, ICallsService calls, IUserProfileService profiles, IRecordDeploymentsService recordDeployments, - IStringLocalizer strings) + IStringLocalizer strings, + IContractorBillingEngine engine, IServiceContractService contracts, IInvoicingService invoicing, IBusinessOperationsAccessService access) { + _engine = engine; + _contracts = contracts; + _invoicing = invoicing; + _access = access; _deployments = deployments; _timeTracking = timeTracking; _flags = flags; @@ -130,6 +139,22 @@ private async Task> PersonnelNamesAsync() return (names ?? new List()).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Name, StringComparer.OrdinalIgnoreCase); } + /// + /// ADP reveal endpoint (plan 7.2) for the deployment and edit pages: the wrapper's internal notes (catalog 27). The + /// grant rides the X-Resgrid-Protected-Grant header and the deployment service resolves the value for a grant + /// holder. Time reports, expenses and attachments are customer-facing and never protected. + /// + [HttpPost, ValidateAntiForgeryToken] + public async Task Reveal([FromForm] string kind, [FromForm] string id) + { + if (string.IsNullOrWhiteSpace(id)) return BadRequest(); + var deployment = await AccessibleAsync(id); + if (deployment == null) return NotFound(); + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var accessor in DeploymentProtectedFields.DeploymentFields) fields[accessor.Key] = accessor.Value.Get(deployment); + return AdpRevealHelper.Answer(this, fields); + } + 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); @@ -184,7 +209,7 @@ 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) }); + var view = Page(new DeploymentEditView { Contacts = await _contacts.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List(), Deployment = ToInput(deployment), IsProtected = deployment.IsProtected }); return View(view); } @@ -323,16 +348,39 @@ public async Task View(string id, string tab = "roster") 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); - } + view.TotalHours = await _timeTracking.GetPersonnelHoursAsync(id, DepartmentId); 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(); + + // Contractor billing (C-M2): the Billing tab previews the charge run, the contract compliance checklist and the invoices already generated. + view.ContractorBilling = CanManage && deployment.FinanceMode == (int)DeploymentFinanceModes.Billable && await _access.CanUseContractorBillingAsync(DepartmentId); + if (view.ContractorBilling && view.Tab == "billing") + { + try + { + view.Charges = await _engine.CalculateDeploymentChargesAsync(id, DepartmentId); + view.Compliance = await _contracts.GetContractComplianceAsync(id, DepartmentId); + if (!string.IsNullOrWhiteSpace(deployment.ContactId)) + view.Invoices = (await _invoicing.GetInvoicesForDepartmentAsync(DepartmentId, new Resgrid.Model.Repositories.InvoiceListFilter { ContactId = deployment.ContactId, Take = 200 })).Where(i => string.Equals(i.DeploymentId, id, StringComparison.OrdinalIgnoreCase)).ToList(); + } + catch (Exception ex) { Logging.LogException(ex, "Deployment billing tab could not be prepared."); } + } return View(view); } + /// Contractor billing (C-M2): charge run → draft invoice with DTR provenance; the billed DTRs move to Billed. + [HttpPost, ValidateAntiForgeryToken] + public async Task GenerateInvoice(string id, DateTime? throughDate, CancellationToken cancellationToken) + { + if (!CanManage || !await _access.CanUseContractorBillingAsync(DepartmentId)) return Unauthorized(); + try + { + var invoice = await _engine.GenerateInvoiceFromDeploymentAsync(id, DepartmentId, throughDate, UserId, Ip, Agent, cancellationToken); + return RedirectToAction("View", "Invoicing", new { area = "User", id = invoice.InvoiceId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex) || ex.Message.StartsWith("contractor_", StringComparison.Ordinal) || ex.Message.StartsWith("invoicing_", StringComparison.Ordinal)) { return Refused(400, ex.Message, "View", new { id, tab = "billing" }); } + } + [HttpPost, ValidateAntiForgeryToken] public async Task AddUnit(string id, int unitId, string callSign, string notes, CancellationToken cancellationToken) { @@ -448,7 +496,7 @@ 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(); + if (attachment.Data == null || attachment.Data.Length == 0) 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}" }; } @@ -545,6 +593,7 @@ public async Task SaveTimeReport(string id, string incidentNumber if (end <= start) end = end.AddDays(1); var entry = new DeploymentTimeEntry { + DeploymentTimeEntryId = string.IsNullOrWhiteSpace(row.Id) ? null : row.Id, 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++ }; @@ -664,10 +713,15 @@ await _timeTracking.SaveExpenseAsync(new DeploymentExpense [HttpPost, ValidateAntiForgeryToken] public async Task DeleteExpense(string id, string deploymentExpenseId, CancellationToken cancellationToken) { - var deployment = await AccessibleAsync(id); + // The v4 order: the expense names the deployment that is authorized, not the posted id, so a member rostered on + // one deployment cannot delete another deployment's expense by submitting its id. + var expense = await _timeTracking.GetExpenseByIdAsync(deploymentExpenseId, DepartmentId); + if (expense == null) return NotFound(); + var deployment = await AccessibleAsync(expense.DeploymentId); 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" }); } + var back = new { id = deployment.DeploymentId, tab = "expenses" }; + try { await _timeTracking.DeleteExpenseAsync(deploymentExpenseId, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", back); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", back); } } #endregion diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs index 1630befa2..9932b8cba 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs @@ -44,13 +44,19 @@ public sealed class InvoicingController : SecureBaseController private readonly IProtectedReadService _protectedRead; private readonly IInvoicePaymentsService _payments; private readonly IStringLocalizer _strings; + /// Contractor billing (C-M2): the invoice packet (invoice PDF + DTR PDFs + receipts + compliance documents) for deployment-generated invoices. + private readonly IContractorBillingEngine _contractorBilling; + private readonly IRateScheduleService _rateSchedules; private bool _canWrite; public InvoicingController(IInvoicingService invoicing, IBusinessOperationsAccessService access, IFeatureToggleService flags, IContactsService contacts, ICallsService calls, IUnitsService units, IAddressService addresses, IProtectedReadService protectedRead, - IStringLocalizer strings, IInvoicePaymentsService payments) + IStringLocalizer strings, IInvoicePaymentsService payments, + IContractorBillingEngine contractorBilling = null, IRateScheduleService rateSchedules = null) { + _contractorBilling = contractorBilling; + _rateSchedules = rateSchedules; _payments = payments; _invoicing = invoicing; _access = access; @@ -314,10 +320,44 @@ public async Task Save(InvoiceHeaderInput input, CancellationToke ContactNames = await ContactNamesAsync(new[] { invoice.ContactId }) }); await LoadOnlinePaymentsAsync(model); + model.ContractorPacket = _contractorBilling != null && !string.IsNullOrWhiteSpace(invoice.DeploymentId) && await _access.CanUseContractorBillingAsync(DepartmentId); model.Message = TempData["InvoicingMessage"] as string; return View("View", model); } + /// Contractor billing (C-M2): downloads the invoice-submission packet (zip). + [HttpGet] + [Authorize(Policy = ResgridResources.Invoicing_View)] + public async Task Packet(string id) + { + if (_contractorBilling == null || !await _access.CanUseContractorBillingAsync(DepartmentId)) return Unauthorized(); + var invoice = await _invoicing.GetInvoiceByIdAsync(id, DepartmentId); + if (invoice == null || string.IsNullOrWhiteSpace(invoice.DeploymentId)) return NotFound(); + var packet = await _contractorBilling.BuildInvoicePacketAsync(id, DepartmentId); + Response.Headers["X-Content-Type-Options"] = "nosniff"; + return File(packet.Data, "application/zip", packet.FileName); + } + + /// Contractor billing (C-M2): sends the invoice with the packet attached (contract submission address by default). + [HttpPost, ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Invoicing_Update)] + public async Task SendPacket(string id, string toEmail, CancellationToken cancellationToken) + { + if (_contractorBilling == null || !await _access.CanUseContractorBillingAsync(DepartmentId)) return Unauthorized(); + var invoice = await _invoicing.GetInvoiceByIdAsync(id, DepartmentId); + if (invoice == null || string.IsNullOrWhiteSpace(invoice.DeploymentId)) return NotFound(); + try + { + await _contractorBilling.SendDeploymentInvoiceAsync(id, DepartmentId, string.IsNullOrWhiteSpace(toEmail) ? null : toEmail.Trim(), UserId, Ip, UserAgent, cancellationToken); + TempData["InvoicingMessage"] = _strings["InvoiceSentMessage"].Value; + } + catch (Exception ex) when (IsInvoicingError(ex)) + { + TempData["InvoicingMessage"] = ErrorText(ex); + } + return RedirectToAction(nameof(View), new { id }); + } + /// Phase B2: the section is absent when the cluster does not offer payment collection; a fault degrades it, never the page. private async Task LoadOnlinePaymentsAsync(InvoiceDetailView model) { @@ -846,6 +886,8 @@ private async Task BuildBillingProfileAsync(Contact contact) model.BillingAddress = await _addresses.GetAddressByIdAsync(model.Profile.BillingAddressId.Value) ?? new Address(); model.TaxComponents = ParseTaxComponents(model.Profile.TaxComponentsJson); model.RateCards = (await _invoicing.GetRateCardsForDepartmentAsync(DepartmentId) ?? new List()).Where(x => x.Active).ToList(); + model.ContractorBilling = _rateSchedules != null && await _access.CanUseContractorBillingAsync(DepartmentId); + if (model.ContractorBilling) model.RateSchedules = await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId); model.Invoices = (await _invoicing.GetInvoicesByContactIdAsync(contact.ContactId, DepartmentId) ?? new List()).OrderByDescending(x => x.InvoiceNumber).ToList(); return model; } @@ -892,6 +934,7 @@ public async Task BillingProfile(BillingProfileInput input, Cance profile.TaxRate = input.TaxRate; profile.TaxComponentsJson = components.Count == 0 ? null : JsonConvert.SerializeObject(components); profile.DefaultRateCardId = string.IsNullOrWhiteSpace(input.DefaultRateCardId) ? null : input.DefaultRateCardId; + if (_rateSchedules != null && await _access.CanUseContractorBillingAsync(DepartmentId)) profile.DefaultRateScheduleId = string.IsNullOrWhiteSpace(input.DefaultRateScheduleId) ? null : input.DefaultRateScheduleId; profile.DefaultDiscountPercent = input.DefaultDiscountPercent; profile.PurchaseOrderRequired = input.PurchaseOrderRequired; profile.Notes = input.Notes; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 9eb8ff823..beaeb1394 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -2083,9 +2083,13 @@ public async Task EditRole(EditRoleModel model, IFormCollection c // 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") + catch (RoleMembershipException ex) { - ModelState.AddModelError("Role.Users", string.Format(_certificationLocalizer["RoleMembersBlocked"].Value, string.Join(", ", await PersonnelDisplayNamesAsync(incomingUsers.Where(u => !currentUsers.Contains(u)))))); + // The service names the one member it refused (the live re-check can differ from the check above); + // a stranger to the department is refused the same way rather than written as a member. + var refused = ex.UserId != null ? new[] { ex.UserId } : incomingUsers.Where(u => !currentUsers.Contains(u)); + var key = ex.Message == RoleMembershipException.NotInDepartment ? "RoleMembersNotInDepartment" : "RoleMembersBlocked"; + ModelState.AddModelError("Role.Users", string.Format(_certificationLocalizer[key].Value, string.Join(", ", await PersonnelDisplayNamesAsync(refused)))); model.Users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs new file mode 100644 index 000000000..647c7b4b4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RateSchedulesController.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.ContractorBilling; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Contractor rate schedules (Workforce & Business Operations plan, Phase C6): the schedule list, the editor + /// (entry grid grouped by type, band editor with threshold columns and multiplier prefill, premium editor, policy + /// panel) and JSON import/export. Needs the Invoicing.ContractorBilling entitlement; Invoicing_View reads, + /// Invoicing_Update (admins by default) edits. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class RateSchedulesController : SecureBaseController + { + private static readonly string[] Currencies = { "USD", "CAD", "EUR", "GBP", "AUD", "NZD", "MXN", "CHF", "SEK", "NOK", "DKK", "PLN" }; + + private readonly IRateScheduleService _rateSchedules; + private readonly IBusinessOperationsAccessService _access; + private readonly IUnitsService _units; + private readonly ICertificationService _certifications; + private readonly IStringLocalizer _strings; + + public RateSchedulesController(IRateScheduleService rateSchedules, IBusinessOperationsAccessService access, IUnitsService units, ICertificationService certifications, + IStringLocalizer strings) + { + _rateSchedules = rateSchedules; + _access = access; + _units = units; + _certifications = certifications; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageInvoicing(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewInvoicing(); + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!CanView || !await _access.CanUseContractorBillingAsync(DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + await next(); + } + + private T Page(T view) where T : ContractorPageView + { + view.CanManageRates = CanManage; + view.CanManageBids = IsAdmin || ClaimsAuthorizationHelper.CanManageBids(); + view.CanManageContracts = IsAdmin || ClaimsAuthorizationHelper.CanManageContracts(); + view.CanManageDeployments = IsAdmin || ClaimsAuthorizationHelper.CanManageDeployments(); + if (TempData["ContractorMessage"] is string message) view.Message = message; + if (TempData["ContractorSaved"] 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["ContractorMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["ContractorSaved"] = 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("rateschedules_", StringComparison.Ordinal); + + #endregion + + [HttpGet] + public async Task Index(bool all = false) + { + var view = Page(new RateScheduleIndexView { IncludeInactive = all }); + view.Schedules = await _rateSchedules.GetSchedulesForDepartmentAsync(DepartmentId, all); + return View(view); + } + + [HttpGet] + public async Task New() + { + if (!CanManage) return Unauthorized(); + var view = Page(new RateScheduleEditView { Schedule = new RateSchedule { DepartmentId = DepartmentId, Currency = "USD", IsActive = true } }); + await FillLookupsAsync(view); + return View("Edit", view); + } + + [HttpGet] + public async Task Edit(string id) + { + var schedule = await _rateSchedules.GetScheduleByIdAsync(id, DepartmentId, includeInactive: true); + if (schedule == null) return NotFound(); + var view = Page(new RateScheduleEditView { Schedule = schedule, Policy = schedule.Policy, ExportJson = await _rateSchedules.ExportScheduleJsonAsync(id, DepartmentId) }); + await FillLookupsAsync(view); + return View(view); + } + + private async Task FillLookupsAsync(RateScheduleEditView view) + { + view.Currencies = Currencies.Select(c => new SelectListItem(c, c, string.Equals(c, view.Schedule.Currency, StringComparison.OrdinalIgnoreCase))).ToList(); + try { view.UnitTypes = (await _units.GetUnitTypesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(t => t.Type).Select(t => new SelectListItem(t.Type, t.UnitTypeId.ToString())).ToList(); } + catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Rate schedule editor: unit types unavailable."); } + try { view.CertificationTypes = (await _certifications.GetAllCertificationTypesByDepartmentAsync(DepartmentId) ?? new List()).OrderBy(t => t.Type).Select(t => new SelectListItem(string.IsNullOrWhiteSpace(t.Code) ? t.Type : $"{t.Code} — {t.Type}", string.IsNullOrWhiteSpace(t.Code) ? t.Type : t.Code)).ToList(); } + catch (Exception ex) { Resgrid.Framework.Logging.LogException(ex, "Rate schedule editor: certification types unavailable."); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Save(RateScheduleInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + var policy = new RateSchedulePolicy + { + RoundingMinutes = input.RoundingMinutes, CancellationMinimumHours = input.CancellationMinimumHours, CancellationVehiclesFullDay = input.CancellationVehiclesFullDay, DailyGuaranteeHours = input.DailyGuaranteeHours, + PortalToPortal = input.PortalToPortal, UnsafeStandDownHours = input.UnsafeStandDownHours, NoClear8CarryOver = input.NoClear8CarryOver, TravelDayCapHours = input.TravelDayCapHours, + OvertimeBasis = Enum.IsDefined(typeof(OvertimeBases), input.OvertimeBasis) ? (OvertimeBases)input.OvertimeBasis : OvertimeBases.ConsecutiveHours, FuelDeductionRatePerLitre = input.FuelDeductionRatePerLitre, + ContinuousRunGapMinutes = input.ContinuousRunGapMinutes + }; + if (!string.IsNullOrWhiteSpace(input.MealEligibilityJson)) + { + try { policy.MealEligibility = JsonConvert.DeserializeObject>(input.MealEligibilityJson) ?? new List(); } + catch (JsonException) { return Refused(400, "rateschedules_policy_invalid", string.IsNullOrWhiteSpace(input.RateScheduleId) ? "New" : "Edit", new { id = input.RateScheduleId }); } + } + try + { + var saved = await _rateSchedules.SaveScheduleAsync(new RateSchedule + { + RateScheduleId = input.RateScheduleId, DepartmentId = DepartmentId, Name = input.Name, Description = input.Description, Currency = input.Currency, + EffectiveOn = input.EffectiveOn, ExpiresOn = input.ExpiresOn, IsActive = input.IsActive, PolicyJson = policy.ToJson() + }, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = saved.RateScheduleId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, string.IsNullOrWhiteSpace(input.RateScheduleId) ? "New" : "Edit", new { id = input.RateScheduleId }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + if (!await _rateSchedules.DeleteScheduleAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Index"); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(409, ex.Message, "Edit", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Clone(string id, string name, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + try + { + var clone = await _rateSchedules.CloneScheduleAsync(id, DepartmentId, name, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = clone.RateScheduleId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Edit", new { id }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SaveEntry(RateEntryInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + List bands; + if (input.PrefillBaseRate.HasValue) + bands = _rateSchedules.PrefillHourlyBands(input.PrefillBaseRate.Value, input.PrefillStandbyRate, input.PrefillOvertime1Multiplier ?? 1.5m, input.PrefillOvertime1StartHours ?? 8, input.PrefillOvertime2Multiplier, input.PrefillOvertime2StartHours); + else + { + try { bands = string.IsNullOrWhiteSpace(input.BandsJson) ? new List() : JsonConvert.DeserializeObject>(input.BandsJson) ?? new List(); } + catch (JsonException) { return Refused(400, "rateschedules_band_invalid", "Edit", new { id = input.RateScheduleId }); } + } + try + { + await _rateSchedules.SaveEntryAsync(new RateScheduleEntry + { + RateScheduleEntryId = input.RateScheduleEntryId, RateScheduleId = input.RateScheduleId, DepartmentId = DepartmentId, EntryType = input.EntryType, Name = input.Name, Code = input.Code, GroupKey = input.GroupKey, + CrewSize = input.CrewSize, CertificationCode = input.CertificationCode, UnitTypeId = input.UnitTypeId, InventoryItemId = input.InventoryItemId, BillingBasis = input.BillingBasis, + RequiredCertificationsJson = input.RequiredCertificationsJson, SortOrder = input.SortOrder, IsActive = input.IsActive, Bands = bands + }, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = input.RateScheduleId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Edit", new { id = input.RateScheduleId }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task DeleteEntry(string id, string scheduleId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!await _rateSchedules.DeleteEntryAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Edit", new { id = scheduleId }); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task SavePremium(RatePremiumInput input, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (input == null) return BadRequest(); + try + { + await _rateSchedules.SavePremiumAsync(new RatePremium + { + RatePremiumId = input.RatePremiumId, RateScheduleId = input.RateScheduleId, DepartmentId = DepartmentId, Name = input.Name, Code = input.Code, + StandbyAdder = input.StandbyAdder, DeploymentAdder = input.DeploymentAdder, Overtime1Adder = input.Overtime1Adder, Overtime2Adder = input.Overtime2Adder, IsActive = input.IsActive + }, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = input.RateScheduleId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Edit", new { id = input.RateScheduleId }); } + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task DeletePremium(string id, string scheduleId, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + if (!await _rateSchedules.DeletePremiumAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken)) return NotFound(); + return Saved("Edit", new { id = scheduleId }); + } + + [HttpGet] + public async Task Export(string id) + { + var json = await _rateSchedules.ExportScheduleJsonAsync(id, DepartmentId); + if (json == null) return NotFound(); + var schedule = await _rateSchedules.GetScheduleByIdAsync(id, DepartmentId, includeInactive: true); + return File(Encoding.UTF8.GetBytes(json), "application/json", $"rate-schedule-{FileHelper.GetSafeFileName(schedule?.Name ?? id)}.json"); + } + + [HttpPost, ValidateAntiForgeryToken, RequestSizeLimit(2 * 1024 * 1024)] + public async Task Import(Microsoft.AspNetCore.Http.IFormFile file, string json, CancellationToken cancellationToken) + { + if (!CanManage) return Unauthorized(); + var content = json; + if (file != null && file.Length > 0) + { + using var reader = new System.IO.StreamReader(file.OpenReadStream(), Encoding.UTF8); + content = await reader.ReadToEndAsync(); + } + try + { + var imported = await _rateSchedules.ImportScheduleJsonAsync(DepartmentId, content, UserId, Ip, Agent, cancellationToken); + return Saved("Edit", new { id = imported.RateScheduleId }); + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "Index"); } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 55f2d6c5d..90f93c64e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -265,6 +265,9 @@ public async Task CertificationComplianceReport() visible.Add(cell); } dashboard.PersonCells = visible; + // The totals came from the department-wide matrix; over the filtered cells they would both disagree with + // the rows on the page and count certification states of the members the matrix hides. + dashboard.RecountTotals(); } return new Resgrid.Web.Areas.User.Models.Certifications.CertificationComplianceReportView diff --git a/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs b/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs new file mode 100644 index 000000000..abc246bc1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.Invoicing; + +namespace Resgrid.Web.Areas.User.Models.ContractorBilling +{ + // Workforce & Business Operations plan, Phase C6 (contractor path): rate schedules, contracts, compliance documents, + // bids and the deployment wizard. Every page view carries the caller's abilities and the flash message. + + public class ContractorPageView + { + public bool CanManageBids { get; set; } + public bool CanManageContracts { get; set; } + public bool CanManageRates { get; set; } + public bool CanManageDeployments { get; set; } + public string Message { get; set; } + public bool SaveSuccess { get; set; } + } + + #region Rate schedules + + public class RateScheduleIndexView : ContractorPageView + { + public List Schedules { get; set; } = new List(); + public bool IncludeInactive { get; set; } + } + + public class RateScheduleEditView : ContractorPageView + { + public RateSchedule Schedule { get; set; } = new RateSchedule(); + public RateSchedulePolicy Policy { get; set; } = new RateSchedulePolicy(); + public bool IsNew => string.IsNullOrWhiteSpace(Schedule?.RateScheduleId); + public List Currencies { get; set; } = new List(); + public List UnitTypes { get; set; } = new List(); + public List CertificationTypes { get; set; } = new List(); + public string ExportJson { get; set; } + } + + public class RateScheduleInput + { + public string RateScheduleId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Currency { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public bool IsActive { get; set; } = true; + public int RoundingMinutes { get; set; } = 30; + public decimal CancellationMinimumHours { get; set; } = 4; + public bool CancellationVehiclesFullDay { get; set; } = true; + public decimal? DailyGuaranteeHours { get; set; } + public bool PortalToPortal { get; set; } + public decimal UnsafeStandDownHours { get; set; } = 8; + public bool NoClear8CarryOver { get; set; } = true; + public decimal TravelDayCapHours { get; set; } = 12; + public int OvertimeBasis { get; set; } + public decimal? FuelDeductionRatePerLitre { get; set; } + public int ContinuousRunGapMinutes { get; set; } = 60; + public string MealEligibilityJson { get; set; } + } + + public class RateEntryInput + { + public string RateScheduleEntryId { get; set; } + public string RateScheduleId { get; set; } + public int EntryType { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string GroupKey { get; set; } + public int? CrewSize { get; set; } + public string CertificationCode { get; set; } + public int? UnitTypeId { get; set; } + public string InventoryItemId { get; set; } + public int BillingBasis { get; set; } + public string RequiredCertificationsJson { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + /// Band rows as JSON (the band grid serialises itself). + public string BandsJson { get; set; } + public decimal? PrefillBaseRate { get; set; } + public decimal? PrefillStandbyRate { get; set; } + public decimal? PrefillOvertime1Multiplier { get; set; } + public decimal? PrefillOvertime1StartHours { get; set; } + public decimal? PrefillOvertime2Multiplier { get; set; } + public decimal? PrefillOvertime2StartHours { get; set; } + } + + public class RatePremiumInput + { + public string RatePremiumId { get; set; } + public string RateScheduleId { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public decimal StandbyAdder { get; set; } + public decimal DeploymentAdder { get; set; } + public decimal Overtime1Adder { get; set; } + public decimal Overtime2Adder { get; set; } + public bool IsActive { get; set; } = true; + } + + #endregion + + #region Contracts and compliance + + public class ContractIndexView : ContractorPageView + { + public List Contracts { get; set; } = new List(); + public Dictionary ContactNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary ScheduleNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public int? StatusFilter { get; set; } + public int ExpiringDocuments { get; set; } + } + + public class ContractEditView : ContractorPageView + { + public ServiceContract Contract { get; set; } = new ServiceContract { StartOn = DateTime.UtcNow.Date }; + public bool IsNew => string.IsNullOrWhiteSpace(Contract?.ServiceContractId); + public List Contacts { get; set; } = new List(); + public List Schedules { get; set; } = new List(); + public string RequirementsJson { get; set; } + } + + public class ContractInput + { + public string ServiceContractId { get; set; } + public string ContactId { get; set; } + public string ContractNumber { get; set; } + public string Name { get; set; } + public int ContractType { get; set; } + public DateTime StartOn { get; set; } + public DateTime? EndOn { get; set; } + public string RateScheduleId { get; set; } + public decimal? DiscountPercent { get; set; } + public int? TermsNetDays { get; set; } + public string InvoiceSubmissionEmail { get; set; } + public int? MaxDeploymentDays { get; set; } + public int? ResponseTimeMinutes { get; set; } + public string PointOfHire { get; set; } + public string DocumentTemplateKey { get; set; } + public string Notes { get; set; } + public bool ActivateNow { get; set; } + /// Requirement rows as JSON (the requirement grid serialises itself). + public string RequirementsJson { get; set; } + } + + public class ContractDetailView : ContractorPageView + { + public ServiceContract Contract { get; set; } + public string ContactName { get; set; } + public string ScheduleName { get; set; } + public List Bids { get; set; } = new List(); + public List Deployments { get; set; } = new List(); + public List Invoices { get; set; } = new List(); + public ContractComplianceResult Compliance { get; set; } + public List NextStatuses { get; set; } = new List(); + } + + public class ComplianceView : ContractorPageView + { + public List Documents { get; set; } = new List(); + public DepartmentComplianceDocument Editing { get; set; } + public int? EditingId { get; set; } + } + + public class ComplianceDocumentInput + { + public int DepartmentComplianceDocumentId { get; set; } + public int DocumentType { get; set; } + public string Name { get; set; } + public string DocumentNumber { get; set; } + public string Issuer { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public int AlertLeadDays { get; set; } = 30; + } + + #endregion + + #region Bids + + public class BidIndexView : ContractorPageView + { + public List Bids { get; set; } = new List(); + public Dictionary ContactNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public int? StatusFilter { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public int Total { get; set; } + } + + public class BidNewView : ContractorPageView + { + public List Contacts { get; set; } = new List(); + public List Contracts { get; set; } = new List(); + public string ContactId { get; set; } + public string ServiceContractId { get; set; } + public string Title { get; set; } + } + + public class BidEditView : ContractorPageView + { + public Bid Bid { get; set; } + public string ContactName { get; set; } + public string Currency { get; set; } = "USD"; + public RateSchedule Schedule { get; set; } + public List Schedules { get; set; } = new List(); + public List Contracts { get; set; } = new List(); + public decimal? ProfileDiscountPercent { get; set; } + public decimal? ContractDiscountPercent { get; set; } + public string LinesJson { get; set; } + public string EntriesJson { get; set; } + public string PremiumsJson { get; set; } + } + + public class BidInput + { + public string BidId { get; set; } + public string ServiceContractId { get; set; } + public string RateScheduleId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public DateTime? ValidUntil { get; set; } + public DateTime? RequestedStartOn { get; set; } + public DateTime? RequestedEndOn { get; set; } + public string IncidentNumber { get; set; } + public string DeliveryLocation { get; set; } + public decimal? DiscountPercent { get; set; } + public string Notes { get; set; } + public string TermsText { get; set; } + /// Line rows as JSON (the line editor serialises itself). + public string LinesJson { get; set; } + } + + public class BidDetailView : ContractorPageView + { + public Bid Bid { get; set; } + public string ContactName { get; set; } + public string ContactEmail { get; set; } + public string ContractName { get; set; } + public string ScheduleName { get; set; } + public string Currency { get; set; } = "USD"; + public Deployment ConvertedDeployment { get; set; } + } + + #endregion + + #region Deployment wizard + + public class WizardView : ContractorPageView + { + public BidConversionContext Context { get; set; } + public Department Department { get; set; } + public List CallTypes { get; set; } = new List(); + public List Priorities { get; set; } = new List(); + /// Units with their type, state and staffing for step 2. + public List Units { get; set; } = new List(); + /// Personnel roster with status, roles and typed certifications for step 3. + public List Personnel { get; set; } = new List(); + public List Roles { get; set; } = new List(); + public string ContextJson { get; set; } + } + + public class WizardUnit + { + public int UnitId { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public string State { get; set; } + public int Staffing { get; set; } + public List Seats { get; set; } = new List(); + public bool Conflict { get; set; } + } + + public class WizardSeat + { + public int UnitRoleId { get; set; } + public string Name { get; set; } + public bool PersonnelRoleRequired { get; set; } + public int? PersonnelRoleId { get; set; } + } + + public class WizardPerson + { + public string UserId { get; set; } + public string Name { get; set; } + public string Status { get; set; } + public string Staffing { get; set; } + public List RoleIds { get; set; } = new List(); + public List Certifications { get; set; } = new List(); + public bool Conflict { get; set; } + } + + public class WizardCertification + { + public string Code { get; set; } + public string Name { get; set; } + public int Status { get; set; } + public DateTime? ExpiresOn { get; set; } + public bool ExpiringInWindow { get; set; } + } + + public class WizardRole + { + public int PersonnelRoleId { get; set; } + public string Name { get; set; } + } + + public class WizardSubmitInput + { + public string BidId { get; set; } + /// The wizard posts the whole BidConversionRequest as JSON. + public string RequestJson { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs index 5c0f1a07b..2978836b4 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs @@ -32,6 +32,7 @@ public class DeploymentEditView : DeploymentPageView { public DeploymentInput Deployment { get; set; } = new DeploymentInput(); public List Contacts { get; set; } = new List(); + public bool IsProtected { get; set; } public bool IsNew => string.IsNullOrWhiteSpace(Deployment.DeploymentId); } @@ -89,6 +90,11 @@ public class DeploymentDetailView : DeploymentPageView public Dictionary UserNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public List Warnings { get; set; } = new List(); public bool IsRostered { get; set; } + /// Contractor billing (C-M2): the Billing tab is offered when the department holds the entitlement and the deployment is billable. + public bool ContractorBilling { get; set; } + public ContractorChargeSet Charges { get; set; } + public ContractComplianceResult Compliance { get; set; } + public List Invoices { get; set; } = new List(); public bool CanEditTime => CanManage || IsRostered; public string Tab { get; set; } = "roster"; public decimal TotalHours { get; set; } @@ -112,6 +118,8 @@ public class TimeReportEditView : DeploymentPageView public class TimeEntryInput { + /// The stored entry id; kept so an untouched entry (REDACTED notes posted back) updates in place. + public string Id { get; set; } public string SubjectId { get; set; } public int EntryType { get; set; } public string Start { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs index 50fe3c582..816d528a7 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs @@ -70,6 +70,8 @@ public sealed class InvoiceDetailView : InvoicingPageView public Invoice Invoice { get; set; } public CustomerBillingProfile Profile { get; set; } public string RenderedHtml { get; set; } + /// Contractor billing (C-M2): the packet download / send-with-packet actions are offered for deployment-generated invoices. + public bool ContractorPacket { get; set; } public string Message { get; set; } /// Phase B2: null when the cluster does not offer payment collection at all (the section is absent). public OnlinePaymentsStatus OnlinePayments { get; set; } @@ -173,6 +175,9 @@ public sealed class BillingProfileView : InvoicingPageView public List RateCards { get; set; } = new List(); public List Invoices { get; set; } = new List(); public bool IsProtectedContact { get; set; } + /// Contractor billing (C-M2): the profile's default rate schedule is offered when the department holds the entitlement. + public List RateSchedules { get; set; } = new List(); + public bool ContractorBilling { get; set; } public bool SaveSuccess { get; set; } public string Message { get; set; } } @@ -195,6 +200,7 @@ public sealed class BillingProfileInput public decimal?[] TaxPercent { get; set; } = new decimal?[0]; public string[] TaxRegistration { get; set; } = new string[0]; public string DefaultRateCardId { get; set; } + public string DefaultRateScheduleId { get; set; } public decimal? DefaultDiscountPercent { get; set; } public bool PurchaseOrderRequired { get; set; } public string Notes { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml new file mode 100644 index 000000000..27fe0c80f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml @@ -0,0 +1,140 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.BidEditView +@inject IStringLocalizer localizer +@{ + var b = Model.Bid; + ViewBag.Title = "Resgrid | " + localizer["EditBid"]; + ViewData["Title"] = localizer["BidNumber"].Value + " " + b.BidNumber; + ViewData["Subtitle"] = localizer["EditBidIntro"].Value; + ViewData["RootController"] = "Bids"; + ViewData["RootTitle"] = localizer["Bids"].Value; + var lineTypes = Enum.GetValues(typeof(Resgrid.Model.Invoicing.BidLineTypes)).Cast().ToList(); +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+ @Html.AntiForgeryToken() + + +
+
+
+
@localizer["Bid"] @await Html.PartialAsync("_BidStatusBadge", b.Status)
+
+

@Model.ContactName

+
+
+
+
@Html.DropDownList("RateScheduleId", Model.Schedules, localizer["NoSchedule"].Value, new { @class = "form-control" })@localizer["BidScheduleHelp"]
+
+
+
+
+
+
+

@localizer["DiscountCascade"]: @localizer["Profile"] @(Model.ProfileDiscountPercent?.ToString("0.##") ?? "—")% → @localizer["Contract"] @(Model.ContractDiscountPercent?.ToString("0.##") ?? "—")% → @localizer["Bid"]

+
+
+
@localizer["Cancel"]
+
+
+
+
+
+
@localizer["Estimate"]
+
+ + + + + +
@localizer["Subtotal"]@b.EstimatedSubTotal.ToString("N2") @Model.Currency
@localizer["Discount"]-@b.EstimatedDiscountAmount.ToString("N2") @Model.Currency
@localizer["TaxEstimated"]@b.EstimatedTaxAmount.ToString("N2") @Model.Currency
@localizer["EstimatedTotal"]@b.EstimatedTotal.ToString("N2") @Model.Currency
+

@localizer["EstimateHelp"]

+
+
+
+
+
+
@localizer["Lines"]
+
+ @if (Model.Schedule == null) {
@localizer["NoScheduleWarning"]
} +

@localizer["LinesHelp"]

+ + + +
@localizer["RateEntry"]@localizer["LineType"]@localizer["Description"]@localizer["CrewSize"]@localizer["Quantity"]@localizer["HoursPerDay"]@localizer["Days"]@localizer["Rate"]@localizer["Premiums"]@localizer["Taxable"]@localizer["Amount"]
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml new file mode 100644 index 000000000..1ab11ecf3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml @@ -0,0 +1,67 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.BidIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Bids"]; + ViewData["Title"] = localizer["Bids"].Value; + ViewData["Subtitle"] = localizer["BidsIntro"].Value; + ViewData["RootController"] = "Bids"; + ViewData["RootTitle"] = localizer["Bids"].Value; + var statuses = Enum.GetValues(typeof(Resgrid.Model.Invoicing.BidStatuses)).Cast().ToList(); + var pages = Math.Max(1, (int)Math.Ceiling(Model.Total / (double)Model.PageSize)); +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+
+
@localizer["Bids"]
+
+ @if (Model.CanManageContracts) { @localizer["Contracts"] } + @if (Model.CanManageBids) { @localizer["NewBid"] } +
+
+
+
@localizer["HelpBids"]
+
+ @localizer["AllStatuses"] + @foreach (var st in statuses) + { + @localizer["BidStatus" + st] + } +
+ @if (Model.Bids.Count == 0) + { +

@localizer["NoBids"]

+ } + else + { + + + + @foreach (var b in Model.Bids) + { + + + + + + + + + + + } + +
#@localizer["Title"]@localizer["Customer"]@localizer["Status"]@localizer["ValidUntil"]@localizer["Requested"]@localizer["EstimatedTotal"]
@b.BidNumber@b.Title @if (b.IsConverted) { }@(Model.ContactNames.TryGetValue(b.ContactId, out var contact) ? contact : b.ContactId)@await Html.PartialAsync("_BidStatusBadge", b.Status)@(b.ValidUntil?.ToString("yyyy-MM-dd") ?? "—")@(b.RequestedStartOn?.ToString("yyyy-MM-dd") ?? "—")@(b.RequestedEndOn.HasValue ? " → " + b.RequestedEndOn.Value.ToString("yyyy-MM-dd") : "")@b.EstimatedTotal.ToString("N2") @localizer["Open"]
+ @if (pages > 1) + { +
    + @for (var p = 1; p <= pages; p++) {
  • @p
  • } +
+ } + } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml new file mode 100644 index 000000000..0dfa8cad9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/New.cshtml @@ -0,0 +1,45 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.BidNewView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["NewBid"]; + ViewData["Title"] = localizer["NewBid"].Value; + ViewData["Subtitle"] = localizer["NewBidIntro"].Value; + ViewData["RootController"] = "Bids"; + ViewData["RootTitle"] = localizer["Bids"].Value; +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+
+
@localizer["NewBid"]
+
+
+ @Html.AntiForgeryToken() +
@Html.DropDownList("contactId", Model.Contacts, localizer["SelectContact"].Value, new { @class = "form-control", required = "required", id = "contactId" })@localizer["BidContactHelp"]
+
+ @localizer["BidContractHelp"]
+
+
@localizer["Cancel"]
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml new file mode 100644 index 000000000..40be93cd6 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml @@ -0,0 +1,108 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.BidDetailView +@inject IStringLocalizer localizer +@{ + var b = Model.Bid; + ViewBag.Title = "Resgrid | " + localizer["BidNumber"] + " " + b.BidNumber; + ViewData["Title"] = localizer["BidNumber"].Value + " " + b.BidNumber + " — " + b.Title; + ViewData["RootController"] = "Bids"; + ViewData["RootTitle"] = localizer["Bids"].Value; + var status = (Resgrid.Model.Invoicing.BidStatuses)b.Status; + string Money(decimal v) => v.ToString("N2") + " " + Model.Currency; +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+
+
+
@localizer["Bid"] @await Html.PartialAsync("_BidStatusBadge", b.Status)
+
+ @localizer["Pdf"] + @localizer["Preview"] + @if (Model.CanManageBids && b.IsEditable) { @localizer["Edit"] } +
+
+
+
+
@localizer["Customer"]
@Model.ContactName
+ @if (!string.IsNullOrWhiteSpace(Model.ContractName)) {
@localizer["Contract"]
@Model.ContractName
} +
@localizer["RateSchedule"]
@(Model.ScheduleName ?? "—")
+ @if (!string.IsNullOrWhiteSpace(b.Description)) {
@localizer["Description"]
@b.Description
} +
@localizer["IncidentNumber"]
@(b.IncidentNumber ?? "—")
+
@localizer["DeliveryLocation"]
@b.DeliveryLocation
+
@localizer["Requested"]
@(b.RequestedStartOn?.ToString("yyyy-MM-dd") ?? "—") → @(b.RequestedEndOn?.ToString("yyyy-MM-dd") ?? "—")
+
@localizer["ValidUntil"]
@(b.ValidUntil?.ToString("yyyy-MM-dd") ?? "—")
+ @if (b.SentOn.HasValue) {
@localizer["SentOn"]
@b.SentOn.Value.ToString("yyyy-MM-dd HH:mm") · @b.SentToEmail
} + @if (b.AcceptedOn.HasValue) {
@localizer["AcceptedOn"]
@b.AcceptedOn.Value.ToString("yyyy-MM-dd HH:mm")
} + @if (b.DeclinedOn.HasValue) {
@localizer["DeclinedOn"]
@b.DeclinedOn.Value.ToString("yyyy-MM-dd HH:mm") @b.DeclineReason
} + @if (!string.IsNullOrWhiteSpace(b.Notes)) {
@localizer["Notes"]
@b.Notes
} + @if (b.IsConverted) {
@localizer["Deployment"]
@(Model.ConvertedDeployment?.Name ?? b.ConvertedDeploymentId) @if (b.ConvertedCallId.HasValue) { · @localizer["Call"] @b.ConvertedCallId }
} +
+ + + + @foreach (var l in b.LineItems) + { + + } + + + + @if (b.EstimatedDiscountAmount > 0) { } + @if (b.EstimatedTaxAmount > 0) { } + + +
@localizer["Description"]@localizer["Quantity"]@localizer["HoursPerDay"]@localizer["Days"]@localizer["Rate"]@localizer["Amount"]
@l.Description @if (l.CrewSize.HasValue) { (@l.CrewSize-@localizer["Person"]) }@l.Quantity.ToString("0.##")@(l.EstimatedHoursPerDay?.ToString("0.##") ?? "")@(l.EstimatedDays?.ToString("0.##") ?? "")@l.UnitRate.ToString("N4")@l.EstimatedAmount.ToString("N2")
@localizer["Subtotal"]@Money(b.EstimatedSubTotal)
@localizer["Discount"] @(b.DiscountPercent.HasValue ? "(" + b.DiscountPercent.Value.ToString("0.##") + "%)" : "")-@Money(b.EstimatedDiscountAmount)
@localizer["TaxEstimated"]@Money(b.EstimatedTaxAmount)
@localizer["EstimatedTotal"]@Money(b.EstimatedTotal)
+ @if (!string.IsNullOrWhiteSpace(b.TermsText)) {

@localizer["TermsText"]

@b.TermsText

} +
+
+
+
+ @if (Model.CanManageBids) + { +
+
@localizer["Actions"]
+
+ @if (status is Resgrid.Model.Invoicing.BidStatuses.Draft or Resgrid.Model.Invoicing.BidStatuses.Submitted) + { +
@Html.AntiForgeryToken() +
+ +
+ } + @if (status == Resgrid.Model.Invoicing.BidStatuses.Draft) + { +
@Html.AntiForgeryToken()
+ } + @if (status is Resgrid.Model.Invoicing.BidStatuses.Submitted or Resgrid.Model.Invoicing.BidStatuses.Expired or Resgrid.Model.Invoicing.BidStatuses.Declined) + { + if (status == Resgrid.Model.Invoicing.BidStatuses.Submitted) + { +
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
+ } + else + { +
@Html.AntiForgeryToken()
+ } + } + @if (status is Resgrid.Model.Invoicing.BidStatuses.Draft or Resgrid.Model.Invoicing.BidStatuses.Submitted) + { +
@Html.AntiForgeryToken()
+ } + @if (status == Resgrid.Model.Invoicing.BidStatuses.Accepted && !b.IsConverted && Model.CanManageDeployments) + { + @localizer["ScheduleDeploymentCall"] + } + @if (status == Resgrid.Model.Invoicing.BidStatuses.Draft) + { +
@Html.AntiForgeryToken()
+ } +
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/_BidStatusBadge.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/_BidStatusBadge.cshtml new file mode 100644 index 000000000..f69166a55 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/_BidStatusBadge.cshtml @@ -0,0 +1,15 @@ +@model int +@inject IStringLocalizer localizer +@{ + var status = (Resgrid.Model.Invoicing.BidStatuses)Model; + var css = status switch + { + Resgrid.Model.Invoicing.BidStatuses.Draft => "label-default", + Resgrid.Model.Invoicing.BidStatuses.Submitted => "label-info", + Resgrid.Model.Invoicing.BidStatuses.Accepted => "label-primary", + Resgrid.Model.Invoicing.BidStatuses.Declined => "label-danger", + Resgrid.Model.Invoicing.BidStatuses.Expired => "label-warning", + _ => "label-default" + }; +} +@localizer["BidStatus" + status] diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml index b34a54026..7c39ebed0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml @@ -10,6 +10,14 @@ var canEdit = Model.IsSelf || Model.CanManage; var required = t?.RenewalCreditHoursRequired; var pct = required.HasValue && required.Value > 0 ? (int)Math.Min(100, Math.Round(Model.CreditHours / required.Value * 100)) : (int?)null; + // ADP reveal (plan 7.2): catalog 6/27/28 values render REDACTED; the step-up modal and the Reveal action fill the marked spans. + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = commonLocalizer["AdpProtectedCertifications"], + RevealController = "Certifications", + RevealAction = "Reveal", + RevealData = new Dictionary { { "kind", "record" }, { "id", r.PersonnelCertificationId.ToString() } } + }; } @await Html.PartialAsync("_Shell") @@ -20,27 +28,27 @@
-
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.Name)
+
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.Name)
@await Html.PartialAsync("_StatusBadge", r.Status)
- @if (r.IsProtected) + @if (r.IsProtected || Model.Credits.Any(c => c.IsProtected)) { -
@localizer["ProtectedNotice"]
+ }
@localizer["Member"]
@Model.HolderName
@localizer["TypeName"]
@(t != null ? $"{t.Type} ({t.Code})" : Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.Type) ?? "—") @if (t == null) { @localizer["Untyped"] }
-
@localizer["Number"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.Number)
-
@localizer["IssuedBy"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.IssuedBy)
+
@localizer["Number"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.Number)
+
@localizer["IssuedBy"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.IssuedBy)
@localizer["ReceivedOn"]
@r.RecievedOn?.ToString("yyyy-MM-dd")
@localizer["ExpiresOn"]
@(t?.NeverExpires == true ? localizer["NeverExpires"].Value : r.ExpiresOn?.ToString("yyyy-MM-dd") ?? "—") @if (Model.DaysUntilExpiry.HasValue) { (@string.Format(localizer[Model.DaysUntilExpiry < 0 ? "DaysAgo" : "DaysLeft"].Value, Math.Abs(Model.DaysUntilExpiry.Value))) }
- @if (!string.IsNullOrWhiteSpace(r.StatusReason) && r.StatusReason != "expired") + @if (!string.IsNullOrWhiteSpace(r.StatusReason) && r.Status != (int)Resgrid.Model.PersonnelCertificationStatuses.Expired) { -
@localizer["Reason"]
@r.StatusReason
+
@localizer["Reason"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.StatusReason)
} @if (r.VerifiedOn.HasValue) { @@ -114,7 +122,7 @@ @c.CreditDate.ToString("yyyy-MM-dd") @c.Hours.ToString("0.##") @c.Category - @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.Description) @if (!string.IsNullOrWhiteSpace(c.FileName)) { } + @Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(c.Description) @if (!string.IsNullOrWhiteSpace(c.FileName)) { } @if (Model.CanManage) { @@ -193,6 +201,10 @@ } @section Scripts { + @if (r.IsProtected || Model.Credits.Any(c => c.IsProtected)) + { + + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/Index.cshtml new file mode 100644 index 000000000..b0348b1b8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/Index.cshtml @@ -0,0 +1,65 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.ContractIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Contracts"]; + ViewData["Title"] = localizer["Contracts"].Value; + ViewData["Subtitle"] = localizer["ContractsIntro"].Value; + ViewData["RootController"] = "Contracts"; + ViewData["RootTitle"] = localizer["Contracts"].Value; + var statuses = Enum.GetValues(typeof(Resgrid.Model.Invoicing.ServiceContractStatuses)).Cast().ToList(); +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) + @if (Model.ExpiringDocuments > 0) + { +
@string.Format(localizer["ComplianceExpiringBanner"].Value, Model.ExpiringDocuments) @localizer["ComplianceDocuments"]
+ } +
+
+
@localizer["Contracts"]
+
+ @localizer["ComplianceDocuments"] + @if (Model.CanManageRates) { @localizer["RateSchedules"] } + @if (Model.CanManageContracts) { @localizer["NewContract"] } +
+
+
+
@localizer["HelpContracts"]
+
+ @localizer["AllStatuses"] + @foreach (var st in statuses) + { + @localizer["ContractStatus" + st] + } +
+ @if (Model.Contracts.Count == 0) + { +

@localizer["NoContracts"]

+ } + else + { + + + + @foreach (var c in Model.Contracts) + { + + + + + + + + + + + } + +
@localizer["Name"]@localizer["ContractNumber"]@localizer["Customer"]@localizer["ContractType"]@localizer["Status"]@localizer["Window"]@localizer["RateSchedule"]
@c.Name@c.ContractNumber@(Model.ContactNames.TryGetValue(c.ContactId, out var contact) ? contact : c.ContactId)@localizer["ContractType" + (Resgrid.Model.Invoicing.ServiceContractTypes)c.ContractType]@await Html.PartialAsync("_ContractStatusBadge", c.Status)@c.StartOn.ToString("yyyy-MM-dd") → @(c.EndOn.HasValue ? c.EndOn.Value.ToString("yyyy-MM-dd") : "—")@(string.IsNullOrWhiteSpace(c.RateScheduleId) ? "—" : Model.ScheduleNames.TryGetValue(c.RateScheduleId, out var schedule) ? schedule : c.RateScheduleId) @localizer["Open"]
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml new file mode 100644 index 000000000..c9b983891 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml @@ -0,0 +1,123 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.ContractDetailView +@inject IStringLocalizer localizer +@inject IStringLocalizer deploymentStrings +@{ + var c = Model.Contract; + ViewBag.Title = "Resgrid | " + c.Name; + ViewData["Title"] = c.Name; + ViewData["Subtitle"] = localizer["ContractDetailIntro"].Value; + ViewData["RootController"] = "Contracts"; + ViewData["RootTitle"] = localizer["Contracts"].Value; +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+
+
+
@localizer["Contract"] @await Html.PartialAsync("_ContractStatusBadge", c.Status)
+
+ @if (Model.CanManageContracts) { @localizer["Edit"] } + @if (Model.CanManageBids) { @localizer["NewBid"] } +
+
+
+
+
@localizer["Customer"]
@Model.ContactName
+
@localizer["ContractNumber"]
@(c.ContractNumber ?? "—")
+
@localizer["ContractType"]
@localizer["ContractType" + (Resgrid.Model.Invoicing.ServiceContractTypes)c.ContractType]
+
@localizer["Window"]
@c.StartOn.ToString("yyyy-MM-dd") → @(c.EndOn.HasValue ? c.EndOn.Value.ToString("yyyy-MM-dd") : "—")
+
@localizer["RateSchedule"]
@if (!string.IsNullOrWhiteSpace(c.RateScheduleId)) { @(Model.ScheduleName ?? c.RateScheduleId) } else { @localizer["UseDefaultSchedule"] }
+
@localizer["DiscountPercent"]
@(c.DiscountPercent.HasValue ? c.DiscountPercent.Value.ToString("0.##") + " %" : "—")
+
@localizer["TermsNetDays"]
@(c.TermsNetDays?.ToString() ?? "—")
+
@localizer["InvoiceSubmissionEmail"]
@c.InvoiceSubmissionEmail
+
@localizer["MaxDeploymentDays"]
@(c.MaxDeploymentDays?.ToString() ?? "—")
+
@localizer["ResponseTimeMinutes"]
@(c.ResponseTimeMinutes?.ToString() ?? "—")
+
@localizer["PointOfHire"]
@(c.PointOfHire ?? "—")
+ @if (!string.IsNullOrWhiteSpace(c.Notes)) {
@localizer["Notes"]
@c.Notes
} +
+ @if (Model.CanManageContracts && Model.NextStatuses.Count > 0) + { +
+ @foreach (var next in Model.NextStatuses) + { +
@Html.AntiForgeryToken()
+ } + @if (c.Status != (int)Resgrid.Model.Invoicing.ServiceContractStatuses.Active) + { +
@Html.AntiForgeryToken()
+ } +
+ } +
+
+
+
@localizer["DocumentRequirements"]
+
+ @if (c.Requirements.Count == 0) + { +

@localizer["NoRequirements"]

+ } + else + { + + + + @foreach (var r in c.Requirements) + { + var item = Model.Compliance?.Items.FirstOrDefault(i => i.ServiceContractDocumentRequirementId == r.ServiceContractDocumentRequirementId); + + + + + + + } + +
@localizer["Name"]@localizer["Stage"]@localizer["ComplianceDocumentType"]@localizer["Mandatory"]@localizer["Satisfied"]
@r.Name@localizer["Stage" + (Resgrid.Model.Invoicing.DocumentRequirementStages)r.Stage]@(r.ComplianceDocumentType.HasValue ? localizer["DocType" + (Resgrid.Model.Invoicing.ComplianceDocumentTypes)r.ComplianceDocumentType.Value].Value : localizer["DeploymentAttachment"].Value)@(r.IsMandatory ? localizer["Yes"] : localizer["No"])@if (item != null && item.Satisfied) { @localizer["Satisfied"] @item.SatisfiedBy @(item.ExpiresOn.HasValue ? "· " + localizer["ExpiresOn"] + " " + item.ExpiresOn.Value.ToString("yyyy-MM-dd") : "") } else if (r.ComplianceDocumentType.HasValue) { @localizer["Missing"] } else { @localizer["PerDeployment"] }
+ } +
+
+
+
+
+
@localizer["Bids"]
+
+ @if (Model.Bids.Count == 0) {

@localizer["NoBids"]

} + else + { + + @foreach (var b in Model.Bids) { } +
#@b.BidNumber @b.Title@await Html.PartialAsync("~/Areas/User/Views/Bids/_BidStatusBadge.cshtml", b.Status)@b.EstimatedTotal.ToString("N2")
+ } +
+
+
+
@localizer["Deployments"]
+
+ @if (Model.Deployments.Count == 0) {

@localizer["NoDeployments"]

} + else + { + + @foreach (var d in Model.Deployments) { } +
@d.Name@deploymentStrings["Status" + (Resgrid.Model.Invoicing.DeploymentStatuses)d.Status]@(d.StartOn?.ToString("yyyy-MM-dd") ?? "—")
+ } +
+
+
+
@localizer["Invoices"]
+
+ @if (Model.Invoices.Count == 0) {

@localizer["NoInvoices"]

} + else + { + + @foreach (var i in Model.Invoices) { } +
#@i.InvoiceNumber@(i.IssuedOn?.ToString("yyyy-MM-dd") ?? "—")@i.Total.ToString("N2") @i.Currency
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/_ContractStatusBadge.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/_ContractStatusBadge.cshtml new file mode 100644 index 000000000..4e8be272c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/_ContractStatusBadge.cshtml @@ -0,0 +1,14 @@ +@model int +@inject IStringLocalizer localizer +@{ + var status = (Resgrid.Model.Invoicing.ServiceContractStatuses)Model; + var css = status switch + { + Resgrid.Model.Invoicing.ServiceContractStatuses.Draft => "label-default", + Resgrid.Model.Invoicing.ServiceContractStatuses.Active => "label-primary", + Resgrid.Model.Invoicing.ServiceContractStatuses.Suspended => "label-warning", + Resgrid.Model.Invoicing.ServiceContractStatuses.Expired => "label-danger", + _ => "label-default" + }; +} +@localizer["ContractStatus" + status] diff --git a/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml new file mode 100644 index 000000000..012dc9d97 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml @@ -0,0 +1,253 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.WizardView +@inject IStringLocalizer localizer +@{ + var bid = Model.Context.Bid; + ViewBag.Title = "Resgrid | " + localizer["ScheduleDeploymentCall"]; + ViewData["Title"] = localizer["ScheduleDeploymentCall"].Value; + ViewData["Subtitle"] = string.Format(localizer["WizardIntro"].Value, bid.BidNumber, bid.Title); + ViewData["RootController"] = "Bids"; + ViewData["RootTitle"] = localizer["Bids"].Value; + var steps = new[] { "WizardStepCall", "WizardStepUnits", "WizardStepSeats", "WizardStepEquipment", "WizardStepRates", "WizardStepReview" }; +} + +@await Html.PartialAsync("_ContractorShell") + +
+ @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
+
@localizer["ScheduleDeploymentCall"]
+
+ + + +
+

@localizer["WizardStepCall"]

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + +
+ + + @localizer["Cancel"] +
+
+
+ + + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml index 2401f21b4..b35715193 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/Edit.cshtml @@ -5,12 +5,23 @@ ViewData["Title"] = (Model.IsNew ? localizer["NewDeployment"] : localizer["EditDeployment"]).Value; ViewData["Subtitle"] = localizer["EditIntro"].Value; var d = Model.Deployment; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["AdpProtectedDeployment"], + RevealController = "Deployments", + RevealAction = "Reveal", + RevealData = new Dictionary { { "kind", "deployment" }, { "id", d.DeploymentId ?? "" } } + }; } @await Html.PartialAsync("_Shell")
@await Html.PartialAsync("_Message", (Resgrid.Web.Areas.User.Models.Deployments.DeploymentPageView)Model) + @if (Model.IsProtected) + { + + }
@Html.AntiForgeryToken() @@ -40,7 +51,7 @@
@localizer["CallIdHelp"]
-
+
@@ -79,3 +90,10 @@
+ +@section Scripts { + @if (Model.IsProtected) + { + + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index 04c894562..965d8314d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -72,7 +72,7 @@ - + @if (Model.CanEdit) { } @e.Hours.ToString("0.00")h } @@ -119,7 +119,7 @@
@localizer["ContractorSignature"]
@(r.ContractorSignedOn.HasValue ? $"{Model.ContractorSignerName} · {LocalStamp(r.ContractorSignedOn)}" : "—")
-
@localizer["CustomerSignature"]
@(r.CustomerSignedOn.HasValue ? $"{Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(r.CustomerSignerName)} · {LocalStamp(r.CustomerSignedOn)}" : "—")
+
@localizer["CustomerSignature"]
@if (r.CustomerSignedOn.HasValue) { @r.CustomerSignerName · @LocalStamp(r.CustomerSignedOn) } else { }
@if ((Model.CanManage || Model.IsRostered) && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed) { @@ -143,7 +143,7 @@ @foreach (var e in Model.Expenses) { - @localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(e.Description)@e.Amount.ToString("N2") @e.Currency@if (e.ReceiptAttachmentId.HasValue) { } + @localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@e.Description@e.Amount.ToString("N2") @e.Currency@if (e.ReceiptAttachmentId.HasValue) { } } @@ -190,7 +190,7 @@ var add = document.getElementById('addRow'); if (add) add.addEventListener('click', function () { var rows = body.querySelectorAll('tr'); var clone; - if (rows.length) { clone = rows[rows.length - 1].cloneNode(true); clone.querySelectorAll('input[type=text],input[type=number]').forEach(function (i) { i.value = ''; }); } + if (rows.length) { clone = rows[rows.length - 1].cloneNode(true); clone.querySelectorAll('input[type=text],input[type=number],input.entry-id').forEach(function (i) { i.value = ''; i.removeAttribute('data-adp-field'); }); } else { clone = document.createElement('tr'); clone.innerHTML = '' + diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml index d01a73469..882f1e284 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -1,5 +1,6 @@ @model Resgrid.Web.Areas.User.Models.Deployments.DeploymentDetailView @inject IStringLocalizer localizer +@inject IStringLocalizer contractorStrings @{ var d = Model.Deployment; ViewBag.Title = "Resgrid | " + d.Name; @@ -12,6 +13,15 @@ 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()); } + // ADP reveal (plan 7.2): catalog 28 values render REDACTED; the step-up modal and the Reveal action fill the marked spans. + var adpProtected = d.IsProtected; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["AdpProtectedDeployment"], + RevealController = "Deployments", + RevealAction = "Reveal", + RevealData = new Dictionary { { "kind", "deployment" }, { "id", d.DeploymentId } } + }; var next = new List(); foreach (Resgrid.Model.Invoicing.DeploymentStatuses candidate in Enum.GetValues(typeof(Resgrid.Model.Invoicing.DeploymentStatuses))) { @@ -27,6 +37,10 @@ {
@localizer["Warning" + warning.Code] @(string.IsNullOrWhiteSpace(warning.Detail) ? "" : "— " + warning.Detail)
} + @if (adpProtected) + { + + }
@@ -66,7 +80,7 @@ }
@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
} + @if (!string.IsNullOrWhiteSpace(d.Notes)) {
@localizer["Notes"]
@Resgrid.Model.ProtectedDataEnvelope.SafeDisplay(d.Notes)
}
@@ -108,6 +122,10 @@
  • @localizer["TimeReports"]
  • @localizer["Expenses"]
  • @localizer["Files"]
  • + @if (Model.ContractorBilling) + { +
  • @contractorStrings["Billing"]
  • + } @if (Model.Tab == "roster") @@ -261,7 +279,7 @@ @foreach (var e in Model.Expenses) { - @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.ExpenseDate.ToString("yyyy-MM-dd")@localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@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) } @@ -278,6 +296,77 @@ @await Html.PartialAsync("_ExpenseForm", (d.DeploymentId, (string)null, d.Currency)) } } + else if (Model.Tab == "billing" && Model.ContractorBilling) + { + @* Contractor billing (C-M2): charge run preview, contract compliance checklist, generate invoice, linked invoices. *@ +
    +
    +

    @contractorStrings["ChargePreview"]

    + @if (Model.Charges == null) + { +

    @contractorStrings["ChargesUnavailable"]

    + } + else + { + foreach (var warning in Model.Charges.Warnings) + { +
    @contractorStrings["ChargeWarning" + warning.Code] @warning.Message
    + } + if (!Model.Charges.HasCharges) + { +

    @contractorStrings["NoCharges"]

    + } + else + { + + + + @foreach (var line in Model.Charges.Lines) + { + + } + + + + @if (Model.Charges.DiscountAmount > 0) { } + + +
    @contractorStrings["Date"]@contractorStrings["Description"]@contractorStrings["Quantity"]@contractorStrings["Rate"]@contractorStrings["Amount"]
    @line.Date.ToString("yyyy-MM-dd")@line.Description@line.Quantity.ToString("0.##")@line.UnitRate.ToString("N4")@line.Amount.ToString("N2")
    @contractorStrings["Subtotal"]@Model.Charges.SubTotal.ToString("N2") @Model.Charges.Currency
    @contractorStrings["Discount"] (@Model.Charges.DiscountPercent?.ToString("0.##")%)-@Model.Charges.DiscountAmount.ToString("N2") @Model.Charges.Currency
    @contractorStrings["TotalBeforeTax"]@Model.Charges.TotalBeforeTax.ToString("N2") @Model.Charges.Currency
    +
    + @Html.AntiForgeryToken() + + + @contractorStrings["GenerateInvoiceHelp"] +
    + } + } +
    +
    +

    @contractorStrings["ComplianceChecklist"]

    + @if (Model.Compliance == null || Model.Compliance.Items.Count == 0) + { +

    @contractorStrings["NoRequirements"]

    + } + else + { +
      + @foreach (var item in Model.Compliance.Items) + { +
    • @if (item.Satisfied) { } else { } @item.Name · @contractorStrings["Stage" + (Resgrid.Model.Invoicing.DocumentRequirementStages)item.Stage]@(item.IsMandatory ? "" : " · " + contractorStrings["Optional"].Value)
    • + } +
    + } +

    @contractorStrings["Invoices"]

    + @if (Model.Invoices.Count == 0) {

    @contractorStrings["NoInvoices"]

    } + else + { +
      + @foreach (var invoice in Model.Invoices) {
    • #@invoice.InvoiceNumber · @invoice.Total.ToString("N2") @invoice.Currency
    • } +
    + } +
    +
    + } else { @if (Model.Attachments.Count == 0) {

    @localizer["NoFiles"]

    } @@ -289,7 +378,7 @@ @foreach (var a in Model.Attachments) { - @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) + @a.Name@localizer["Attachment" + (Resgrid.Model.Invoicing.DeploymentAttachmentTypes)a.AttachmentType]@a.FileName@((a.FileSize ?? 0) / 1024) KB@Local(a.AddedOn) · @UserName(a.AddedByUserId) @if (Model.CanManage) {
    @Html.AntiForgeryToken()
    } } @@ -326,6 +415,10 @@
    @section Scripts { + @if (adpProtected) + { + + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Index.cshtml new file mode 100644 index 000000000..039aca9a5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Index.cshtml @@ -0,0 +1,72 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.RateScheduleIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["RateSchedules"]; + ViewData["Title"] = localizer["RateSchedules"].Value; + ViewData["Subtitle"] = localizer["RateSchedulesIntro"].Value; + ViewData["RootController"] = "RateSchedules"; + ViewData["RootTitle"] = localizer["RateSchedules"].Value; +} + +@await Html.PartialAsync("_ContractorShell") + +
    + @await Html.PartialAsync("_ContractorMessage", (Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView)Model) +
    +
    +
    @localizer["RateSchedules"]
    +
    + @(Model.IncludeInactive ? localizer["ShowActive"] : localizer["ShowAll"]) + @if (Model.CanManageRates) + { + + @localizer["NewRateSchedule"] + } +
    +
    +
    +
    @localizer["HelpRateSchedules"]
    + @if (Model.Schedules.Count == 0) + { +

    @localizer["NoRateSchedules"]

    + } + else + { + + + + @foreach (var s in Model.Schedules) + { + + + + + + + + + } + +
    @localizer["Name"]@localizer["Currency"]@localizer["EffectiveOn"]@localizer["ExpiresOn"]@localizer["Active"]
    @s.Name@if (!string.IsNullOrWhiteSpace(s.Description)) {
    @s.Description }
    @s.Currency@(s.EffectiveOn.HasValue ? s.EffectiveOn.Value.ToString("yyyy-MM-dd") : "—")@(s.ExpiresOn.HasValue ? s.ExpiresOn.Value.ToString("yyyy-MM-dd") : "—")@if (s.IsActive) { @localizer["Active"] } else { @localizer["Inactive"] } @localizer["Open"] @localizer["ExportJson"]
    + } +
    +
    +
    + +@if (Model.CanManageRates) +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 190b0a490..5ff378bce 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -6,6 +6,7 @@ @inject IStringLocalizer invoicingLocalizer @inject IStringLocalizer certificationLocalizer @inject IStringLocalizer deploymentLocalizer +@inject IStringLocalizer contractorLocalizer @inject IStringLocalizer checklistLocalizer @inject IStringLocalizer twoFactorLocalizer @{ @@ -29,6 +30,8 @@ case PermissionTypes.ManageCertificationSetup: return certificationLocalizer[row.Type.ToString()].Value; case PermissionTypes.ManageDeployments: case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type.ToString()].Value; + case PermissionTypes.ManageBids: + case PermissionTypes.ManageContracts: return contractorLocalizer[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; @@ -48,6 +51,8 @@ case PermissionTypes.ManageCertificationSetup: return certificationLocalizer[row.Type + "Note"].Value; case PermissionTypes.ManageDeployments: case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type + "Note"].Value; + case PermissionTypes.ManageBids: + case PermissionTypes.ManageContracts: return contractorLocalizer[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; @@ -505,6 +510,7 @@ if (row.Type == PermissionTypes.ManageInvoicing) { @invoicingLocalizer["Invoicing"] } if (row.Type == PermissionTypes.ManageCertifications) { @certificationLocalizer["Certifications"] } if (row.Type == PermissionTypes.ManageDeployments) { @deploymentLocalizer["Deployments"] } + if (row.Type == PermissionTypes.ManageBids) { @contractorLocalizer["ContractorBilling"] } @RecordsPermissionLabel(row) @RecordsPermissionNote(row) diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorMessage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorMessage.cshtml new file mode 100644 index 000000000..fb958d41d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorMessage.cshtml @@ -0,0 +1,10 @@ +@model Resgrid.Web.Areas.User.Models.ContractorBilling.ContractorPageView +@inject IStringLocalizer contractorStrings +@if (Model.SaveSuccess) +{ +
    @contractorStrings["Saved"]
    +} +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ +
    @Model.Message
    +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml new file mode 100644 index 000000000..180e76ee8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_ContractorShell.cshtml @@ -0,0 +1,34 @@ +@inject IStringLocalizer contractorStrings +@* + Page heading shared by the contractor-path screens (Workforce & Business Operations plan, Phase C6): + rate schedules, contracts, compliance documents, bids and the deployment wizard. + ViewData["Title"] heading + last crumb; ViewData["RootController"]/["RootTitle"] the module crumb; ViewData["Subtitle"] optional. +*@ +@{ + var title = (string)ViewData["Title"] ?? contractorStrings["ContractorBilling"].Value; + var subtitle = (string)ViewData["Subtitle"]; + var rootController = (string)ViewData["RootController"]; + var rootTitle = (string)ViewData["RootTitle"]; + var isRoot = string.IsNullOrWhiteSpace(rootController) || string.Equals(title, rootTitle, StringComparison.Ordinal); +} +
    +
    +

    @title

    + + @if (!string.IsNullOrEmpty(subtitle)) + { + @subtitle + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index c45a0861b..6d066b683 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -7,6 +7,8 @@ @inject IStringLocalizer invoicingLocalizer @inject IStringLocalizer certificationLocalizer @inject IStringLocalizer deploymentLocalizer +@inject IStringLocalizer contractorLocalizer +@inject Resgrid.Model.Services.IBusinessOperationsAccessService businessOperationsAccess @{ // 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. @@ -91,6 +93,22 @@ @deploymentLocalizer["DeploymentFinance"] } + @if ((ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewBids() || ClaimsAuthorizationHelper.CanViewContracts()) && SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUseContractorBillingAsync(ClaimsAuthorizationHelper.GetDepartmentId())) + { + @* Workforce & Business Operations plan, Phase C-M2 (contractor path): bids and contracts need the Invoicing.ContractorBilling entitlement (Business Ops add-on). *@ + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewBids()) + { +
  • + @contractorLocalizer["Bids"] +
  • + } + @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewContracts()) + { +
  • + @contractorLocalizer["Contracts"] +
  • + } + } @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 62347f85a..e2a65cd4e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Workflows/New.cshtml @@ -4,6 +4,7 @@ @inject IStringLocalizer checklistStrings @inject IStringLocalizer certificationStrings @inject IStringLocalizer deploymentStrings +@inject IStringLocalizer contractorStrings @model Resgrid.Model.Workflow @inject IStringLocalizer localizer @{ @@ -16,7 +17,7 @@ .Where(e => !usedEventTypes.Contains((int)e)) .Where(e => recordsTriggersAvailable || !Resgrid.Model.WorkflowTriggerEventTypes.IsRecordsTrigger(e)) .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() }) + .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.Invoicing.ContractorWorkflowPayload.IsContractor((int)e) ? contractorStrings[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/Helpers/AdpRevealHelper.cs b/Web/Resgrid.Web/Helpers/AdpRevealHelper.cs new file mode 100644 index 000000000..cd4516ce3 --- /dev/null +++ b/Web/Resgrid.Web/Helpers/AdpRevealHelper.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; + +namespace Resgrid.Web.Helpers +{ + /// + /// Shared answer shape for the per-module ADP reveal endpoints (plan 7.2). The reveal module writes every field + /// it is handed that is not null, empty, REDACTED or ciphertext; when the grant did not open anything the caller + /// gets the access-denied reason instead of a silent no-op. + /// + public static class AdpRevealHelper + { + public static IActionResult Answer(Controller controller, IDictionary fields) + { + var present = fields.Where(f => !string.IsNullOrEmpty(f.Value)).ToList(); + if (present.Count > 0 && present.All(f => f.Value == ProtectedDataEnvelope.RedactionValue || ProtectedDataEnvelope.HasEnvelopePrefix(f.Value))) + return controller.Json(new { success = false, error = "protected_access_denied" }); + return controller.Json(new { success = true, fields }); + } + } +} diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 6e0d46e8a..9c76fd318 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -323,6 +323,10 @@ public static bool CanDeleteContacts() 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 CanManageBids() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Update); + public static bool CanViewBids() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.View); + public static bool CanManageContracts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.Update); + public static bool CanViewContracts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.View); 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 95779e6bf..630507965 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -268,6 +268,12 @@ public void ConfigureServices(IServiceCollection services) 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.Bids_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Bids_Create, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Create)); + options.AddPolicy(ResgridResources.Bids_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.Bids_Delete, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Bids, ResgridClaimTypes.Actions.Delete)); + options.AddPolicy(ResgridResources.ServiceContracts_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.ServiceContracts_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.ServiceContracts, ResgridClaimTypes.Actions.Update)); 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/Workers/Resgrid.Workers.Console/Commands/BidExpirationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/BidExpirationCommand.cs new file mode 100644 index 000000000..40d34f4d2 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/BidExpirationCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 31 (Identifier Allocation Registry, Workforce & Business Operations plan C7): bid expiration. + public sealed class BidExpirationCommand : IQuidjiboCommand + { + public BidExpirationCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Commands/ComplianceExpiryCommand.cs b/Workers/Resgrid.Workers.Console/Commands/ComplianceExpiryCommand.cs new file mode 100644 index 000000000..03a65e538 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/ComplianceExpiryCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 33 (Identifier Allocation Registry, Workforce & Business Operations plan C7): contract and compliance document expiry. + public sealed class ComplianceExpiryCommand : IQuidjiboCommand + { + public ComplianceExpiryCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Commands/DeploymentFinanceReminderCommand.cs b/Workers/Resgrid.Workers.Console/Commands/DeploymentFinanceReminderCommand.cs new file mode 100644 index 000000000..5cd56efad --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/DeploymentFinanceReminderCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 32 (Identifier Allocation Registry, Workforce & Business Operations plan C7): deployment finance reminder. + public sealed class DeploymentFinanceReminderCommand : IQuidjiboCommand + { + public DeploymentFinanceReminderCommand(int id) { Id = id; } + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 0f7a43b1e..f7ab1e585 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -514,6 +514,26 @@ await Client.ScheduleAsync("Invoice Maintenance", Cron.MinuteIntervals(15), stoppingToken); + // Workers 31–33 (Identifier Allocation Registry, Workforce & Business Operations plan C7): contractor path sweeps. + // Daily ticks; each pass is a single indexed query when no department holds the Business Ops add-on. + _logger.Log(LogLevel.Information, "Scheduling Bid Expiration"); + await Client.ScheduleAsync("Bid Expiration", + new Commands.BidExpirationCommand(31), + Cron.Daily(4, 0), + stoppingToken); + + _logger.Log(LogLevel.Information, "Scheduling Deployment Finance Reminder"); + await Client.ScheduleAsync("Deployment Finance Reminder", + new Commands.DeploymentFinanceReminderCommand(32), + Cron.Daily(4, 15), + stoppingToken); + + _logger.Log(LogLevel.Information, "Scheduling Compliance Expiry"); + await Client.ScheduleAsync("Compliance Expiry", + new Commands.ComplianceExpiryCommand(33), + Cron.Daily(4, 30), + stoppingToken); + // Worker ID 34 (Identifier Allocation Registry, Workforce & Business Operations plan D5): certification expiry // sweep. Hourly tick; each department runs once per local day at CertificationConfig.SweepLocalHour. _logger.Log(LogLevel.Information, "Scheduling Certification Expiry"); diff --git a/Workers/Resgrid.Workers.Console/Tasks/BidExpirationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/BidExpirationTask.cs new file mode 100644 index 000000000..c8003f2cd --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/BidExpirationTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class BidExpirationTask : IQuidjiboHandler + { + public string Name => "Bid Expiration"; + public int Priority => 1; + public async Task ProcessAsync(BidExpirationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new BidExpirationLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Tasks/ComplianceExpiryTask.cs b/Workers/Resgrid.Workers.Console/Tasks/ComplianceExpiryTask.cs new file mode 100644 index 000000000..0b2a115ba --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/ComplianceExpiryTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class ComplianceExpiryTask : IQuidjiboHandler + { + public string Name => "Compliance Expiry"; + public int Priority => 1; + public async Task ProcessAsync(ComplianceExpiryCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new ComplianceExpiryLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Tasks/DeploymentFinanceReminderTask.cs b/Workers/Resgrid.Workers.Console/Tasks/DeploymentFinanceReminderTask.cs new file mode 100644 index 000000000..e61d73fbd --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/DeploymentFinanceReminderTask.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class DeploymentFinanceReminderTask : IQuidjiboHandler + { + public string Name => "Deployment Finance Reminder"; + public int Priority => 1; + public async Task ProcessAsync(DeploymentFinanceReminderCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new DeploymentFinanceReminderLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/BidExpirationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/BidExpirationLogic.cs new file mode 100644 index 000000000..5bfc0fbd5 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/BidExpirationLogic.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker 31 (Workforce & Business Operations plan C7), daily. Submitted bids past ValidUntil move to Expired + /// (audit + BidExpired through the domain outbox). Departments without the Invoicing.ContractorBilling + /// entitlement are skipped; cheap when nothing qualifies (one indexed query). + /// + public sealed class BidExpirationLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var bids = scope.Resolve(); + var access = scope.Resolve(); + var expired = await bids.RunExpirySweepAsync(DateTime.UtcNow, access.CanUseContractorBillingAsync, ct); + return Tuple.Create(true, $"Bid expiration: expired={expired}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Bid expiration worker failed."); + return Tuple.Create(false, "Bid expiration failed."); + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/ComplianceExpiryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ComplianceExpiryLogic.cs new file mode 100644 index 000000000..5503db2d4 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/ComplianceExpiryLogic.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker 33 (Workforce & Business Operations plan C7), daily. Active contracts whose EndOn is behind now move to + /// Expired (ContractStatusChanged); contracts ending inside the lead window publish ContractExpiring once per day; + /// compliance documents inside their own alert lead (or lapsed) notify the department administrators once per day. + /// Departments without the Invoicing.ContractorBilling entitlement are skipped. + /// + public sealed class ComplianceExpiryLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var contracts = scope.Resolve(); + var access = scope.Resolve(); + var touched = await contracts.RunExpirySweepAsync(DateTime.UtcNow, access.CanUseContractorBillingAsync, ct); + return Tuple.Create(true, $"Compliance expiry: contracts touched={touched}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Compliance expiry worker failed."); + return Tuple.Create(false, "Compliance expiry failed."); + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs new file mode 100644 index 000000000..7ea3bd5dd --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker 32 (Workforce & Business Operations plan C7), daily. Billable deployments with approved time reports + /// unbilled longer than the reminder window (default 21 days), or completed with any unbilled report, produce one + /// digest per department per day to the department administrators. Never generates or sends an invoice. The + /// Cal OES MARS duties join with that milestone. + /// + public sealed class DeploymentFinanceReminderLogic + { + public const int UnbilledDays = 21; + + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var engine = scope.Resolve(); + var access = scope.Resolve(); + var notified = await engine.RunFinanceReminderSweepAsync(DateTime.UtcNow, UnbilledDays, access.CanUseContractorBillingAsync, ct); + return Tuple.Create(true, $"Deployment finance reminder: departments notified={notified}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Deployment finance reminder worker failed."); + return Tuple.Create(false, "Deployment finance reminder failed."); + } + } + } +}