From 2cc3e52c406a53cd6f8d368c6e37ad8630ee4aee Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 19 Sep 2026 23:08:27 -0700 Subject: [PATCH] RG-T51 Workforce fixes, CAL OES Updates, Pay Data fixes --- Core/Resgrid.Config/CostRecoveryConfig.cs | 28 + Core/Resgrid.Config/DataProtectionConfig.cs | 2 +- Core/Resgrid.Config/WorkforceConfig.cs | 26 + .../Areas/User/CalOesMars/CalOesMars.ar.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.cs | 4 + .../Areas/User/CalOesMars/CalOesMars.de.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.el.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.en.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.es.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.fr.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.it.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.pl.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.sv.resx | 557 +++++++++++ .../Areas/User/CalOesMars/CalOesMars.uk.resx | 557 +++++++++++ .../Areas/User/Workforce/Workforce.ar.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.cs | 4 + .../Areas/User/Workforce/Workforce.de.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.el.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.en.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.es.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.fr.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.it.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.pl.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.sv.resx | 713 +++++++++++++ .../Areas/User/Workforce/Workforce.uk.resx | 713 +++++++++++++ Core/Resgrid.Model/AuditLogTypes.cs | 38 +- .../CalOesMars/CalOesMarsAuthorityProfile.cs | 151 +++ .../CalOesMars/CalOesMarsContracts.cs | 356 +++++++ .../CalOesMars/CalOesMarsEntities.cs | 410 ++++++++ .../CalOesMars/CalOesMarsEnums.cs | 189 ++++ Core/Resgrid.Model/FeatureFlagKeys.cs | 6 + .../Invoicing/DeploymentPermissionCatalog.cs | 8 +- Core/Resgrid.Model/PermissionTypes.cs | 20 +- .../Repositories/ICalOesMarsRepositories.cs | 69 ++ .../Repositories/IContractorRepositories.cs | 2 +- .../Repositories/IDeploymentRepositories.cs | 2 + .../Repositories/IInvoicingRepositories.cs | 2 + .../Repositories/IWorkforceRepositories.cs | 181 ++++ Core/Resgrid.Model/Services/IBidsService.cs | 4 +- .../IBusinessOperationsAccessService.cs | 5 +- .../ICalOesMarsReimbursementCalculator.cs | 27 + .../Services/ICalOesMarsService.cs | 141 +++ .../Services/IDeploymentService.cs | 2 + .../Services/IWorkforceServices.cs | 139 +++ .../Workforce/CaPayDataSchemaProfile.cs | 146 +++ .../Workforce/CompensationEntities.cs | 265 +++++ .../Workforce/CostingEntities.cs | 260 +++++ .../Workforce/PayDataEntities.cs | 242 +++++ .../Workforce/WorkforceContracts.cs | 344 +++++++ .../Workforce/WorkforceEntities.cs | 292 ++++++ .../Resgrid.Model/Workforce/WorkforceEnums.cs | 310 ++++++ .../Workforce/WorkforcePermissionCatalog.cs | 22 + .../Workforce/WorkforceProtectedFields.cs | 158 +++ Core/Resgrid.Services/AdpTableBindings.cs | 80 ++ .../BusinessOperationsAccessService.cs | 25 +- .../CalOesMarsReimbursementCalculator.cs | 209 ++++ .../CalOesMarsService.WorkItems.cs | 937 ++++++++++++++++++ .../CostRecovery/CalOesMarsService.cs | 743 ++++++++++++++ .../Resgrid.Services/Invoicing/BidsService.cs | 11 +- .../Invoicing/ContractorBillingEngine.cs | 7 +- .../Invoicing/DeploymentService.cs | 8 + .../Invoicing/InvoicingService.Delivery.cs | 2 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 9 + .../Search/SystemActionCatalog.cs | 19 + Core/Resgrid.Services/ServicesModule.cs | 10 + .../Workforce/CaPayDataReportingService.cs | 695 +++++++++++++ .../Workforce/CompensationCostService.cs | 315 ++++++ .../Workforce/FieldCostCalculator.cs | 274 +++++ .../Workforce/FieldCostingService.cs | 728 ++++++++++++++ .../Workforce/PayDataAggregator.cs | 210 ++++ .../Workforce/PayDataDemographicsService.cs | 158 +++ .../Workforce/WorkforceProtectionSeam.cs | 123 +++ .../Workforce/WorkforceService.cs | 580 +++++++++++ .../Resgrid.Providers.Claims/ClaimsLogic.cs | 41 +- .../ResgridClaimTypes.cs | 9 + .../ResgridResources.cs | 12 + ...AddWorkforceEmploymentAndEstablishments.cs | 209 ++++ ..._AddWorkforceCompensationAndAnnualFacts.cs | 186 ++++ .../M0222_AddResourceAndFieldCosting.cs | 202 ++++ .../M0223_AddCaliforniaPayDataReporting.cs | 181 ++++ .../M0224_SeedWorkforceFeaturesAndIndexes.cs | 30 + ...dWorkforceEmploymentAndEstablishmentsPg.cs | 209 ++++ ...ddWorkforceCompensationAndAnnualFactsPg.cs | 186 ++++ .../M0222_AddResourceAndFieldCostingPg.cs | 202 ++++ .../M0223_AddCaliforniaPayDataReportingPg.cs | 181 ++++ ...M0224_SeedWorkforceFeaturesAndIndexesPg.cs | 30 + .../CalOesMarsRepositories.cs | 155 +++ .../ContractorRepositories.cs | 5 +- .../DeploymentRepositories.cs | 5 + .../InvoicingRepositories.cs | 5 + .../Modules/ApiDataModule.cs | 32 + .../Modules/DataModule.cs | 32 + .../Modules/NonWebDataModule.cs | 32 + .../Modules/TestingDataModule.cs | 32 + .../WorkforceRepositories.cs | 316 ++++++ .../Rms/RmsIdentifierPinTests.cs | 6 + .../Services/CalOesMarsCalculatorTests.cs | 217 ++++ .../Services/CalOesMarsLocalizationTests.cs | 136 +++ .../Services/CalOesMarsServiceTests.cs | 425 ++++++++ .../Services/ContractorBillingServiceTests.cs | 3 +- .../Services/FieldCostCalculatorTests.cs | 131 +++ .../Services/PayDataAggregatorTests.cs | 170 ++++ .../Services/ProtectedReadServiceTests.cs | 6 + .../Services/WorkforceLocalizationTests.cs | 146 +++ .../WorkforceProtectionAndEventsTests.cs | 6 +- .../Services/WorkforceServicesTests.cs | 478 +++++++++ .../Controllers/v4/BidsController.cs | 2 +- .../Controllers/v4/CalOesMarsController.cs | 301 ++++++ .../Controllers/v4/FieldCostController.cs | 153 +++ .../v4/ServiceContractsController.cs | 2 + .../Controllers/v4/TimeReportsController.cs | 2 + .../Helpers/ClaimsAuthorizationHelper.cs | 12 + .../CalOesMars/CalOesMarsApiModels.cs | 211 ++++ .../Models/v4/Workforce/FieldCostApiModels.cs | 117 +++ .../Resgrid.Web.Services.xml | 44 + Web/Resgrid.Web.Services/Startup.cs | 12 + .../Areas/User/Controllers/BidsController.cs | 9 +- .../User/Controllers/CalOesMarsController.cs | 588 +++++++++++ .../User/Controllers/ContractsController.cs | 11 +- .../Controllers/DeploymentWizardController.cs | 5 +- .../User/Controllers/SecurityController.cs | 2 +- .../User/Controllers/WorkforceController.cs | 825 +++++++++++++++ .../Models/CostRecovery/CalOesMarsViews.cs | 147 +++ .../User/Models/Workforce/WorkforceViews.cs | 204 ++++ .../Areas/User/Views/CalOesMars/Agency.cshtml | 60 ++ .../User/Views/CalOesMars/Agreements.cshtml | 93 ++ .../User/Views/CalOesMars/Handoff.cshtml | 78 ++ .../Areas/User/Views/CalOesMars/Index.cshtml | 139 +++ .../User/Views/CalOesMars/Invoice.cshtml | 94 ++ .../Areas/User/Views/CalOesMars/Queue.cshtml | 84 ++ .../Areas/User/Views/CalOesMars/Rate.cshtml | 239 +++++ .../Areas/User/Views/CalOesMars/Rates.cshtml | 63 ++ .../Views/CalOesMars/Reconciliation.cshtml | 71 ++ .../User/Views/CalOesMars/Resources.cshtml | 97 ++ .../User/Views/CalOesMars/WorkItem.cshtml | 306 ++++++ .../CalOesMars/_WorkItemStateBadge.cshtml | 21 + .../User/Views/Deployments/TimeReport.cshtml | 2 +- .../Areas/User/Views/Security/Index.cshtml | 16 + .../Views/Shared/_CalOesMarsMessage.cshtml | 10 + .../User/Views/Shared/_CalOesMarsShell.cshtml | 50 + .../User/Views/Shared/_Navigation.cshtml | 28 + .../Views/Shared/_WorkforceMessage.cshtml | 10 + .../User/Views/Shared/_WorkforceShell.cshtml | 68 ++ .../User/Views/Workforce/AnnualFacts.cshtml | 126 +++ .../User/Views/Workforce/Compensation.cshtml | 55 + .../Workforce/CompensationProfile.cshtml | 169 ++++ .../User/Views/Workforce/Contractors.cshtml | 85 ++ .../Areas/User/Views/Workforce/CostRun.cshtml | 112 +++ .../User/Views/Workforce/CostRuns.cshtml | 81 ++ .../User/Views/Workforce/Demographics.cshtml | 85 ++ .../User/Views/Workforce/Employer.cshtml | 100 ++ .../Views/Workforce/Establishments.cshtml | 93 ++ .../Areas/User/Views/Workforce/Index.cshtml | 82 ++ .../Areas/User/Views/Workforce/PayData.cshtml | 82 ++ .../User/Views/Workforce/PayDataRun.cshtml | 184 ++++ .../User/Views/Workforce/ResourceCosts.cshtml | 161 +++ .../Areas/User/Views/Workforce/Usage.cshtml | 109 ++ .../User/Views/Workforce/WorkEntries.cshtml | 89 ++ .../Areas/User/Views/Workforce/Worker.cshtml | 164 +++ .../Areas/User/Views/Workforce/Workers.cshtml | 63 ++ .../User/Views/Workforce/Worksheet.cshtml | 77 ++ .../Views/Workforce/_CompensationTable.cshtml | 37 + .../Helpers/ClaimsAuthorizationHelper.cs | 12 + Web/Resgrid.Web/Startup.cs | 12 + .../PayDataReportingReadinessCommand.cs | 15 + Workers/Resgrid.Workers.Console/Program.cs | 8 + .../Tasks/PayDataReportingReadinessTask.cs | 22 + .../Logic/DeploymentFinanceReminderLogic.cs | 9 +- .../Logic/PayDataReportingReadinessLogic.cs | 38 + 171 files changed, 33194 insertions(+), 36 deletions(-) create mode 100644 Core/Resgrid.Config/CostRecoveryConfig.cs create mode 100644 Core/Resgrid.Config/WorkforceConfig.cs create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.cs create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx create mode 100644 Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsAuthorityProfile.cs create mode 100644 Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsContracts.cs create mode 100644 Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs create mode 100644 Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEnums.cs create mode 100644 Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs create mode 100644 Core/Resgrid.Model/Repositories/IWorkforceRepositories.cs create mode 100644 Core/Resgrid.Model/Services/ICalOesMarsReimbursementCalculator.cs create mode 100644 Core/Resgrid.Model/Services/ICalOesMarsService.cs create mode 100644 Core/Resgrid.Model/Services/IWorkforceServices.cs create mode 100644 Core/Resgrid.Model/Workforce/CaPayDataSchemaProfile.cs create mode 100644 Core/Resgrid.Model/Workforce/CompensationEntities.cs create mode 100644 Core/Resgrid.Model/Workforce/CostingEntities.cs create mode 100644 Core/Resgrid.Model/Workforce/PayDataEntities.cs create mode 100644 Core/Resgrid.Model/Workforce/WorkforceContracts.cs create mode 100644 Core/Resgrid.Model/Workforce/WorkforceEntities.cs create mode 100644 Core/Resgrid.Model/Workforce/WorkforceEnums.cs create mode 100644 Core/Resgrid.Model/Workforce/WorkforcePermissionCatalog.cs create mode 100644 Core/Resgrid.Model/Workforce/WorkforceProtectedFields.cs create mode 100644 Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs create mode 100644 Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs create mode 100644 Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs create mode 100644 Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs create mode 100644 Core/Resgrid.Services/Workforce/CompensationCostService.cs create mode 100644 Core/Resgrid.Services/Workforce/FieldCostCalculator.cs create mode 100644 Core/Resgrid.Services/Workforce/FieldCostingService.cs create mode 100644 Core/Resgrid.Services/Workforce/PayDataAggregator.cs create mode 100644 Core/Resgrid.Services/Workforce/PayDataDemographicsService.cs create mode 100644 Core/Resgrid.Services/Workforce/WorkforceProtectionSeam.cs create mode 100644 Core/Resgrid.Services/Workforce/WorkforceService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0220_AddWorkforceEmploymentAndEstablishments.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0221_AddWorkforceCompensationAndAnnualFacts.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0222_AddResourceAndFieldCosting.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0223_AddCaliforniaPayDataReporting.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0224_SeedWorkforceFeaturesAndIndexes.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0220_AddWorkforceEmploymentAndEstablishmentsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0221_AddWorkforceCompensationAndAnnualFactsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0222_AddResourceAndFieldCostingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0223_AddCaliforniaPayDataReportingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0224_SeedWorkforceFeaturesAndIndexesPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/WorkforceRepositories.cs create mode 100644 Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs create mode 100644 Tests/Resgrid.Tests/Services/CalOesMarsLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/FieldCostCalculatorTests.cs create mode 100644 Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/CostRecovery/CalOesMars/CalOesMarsApiModels.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Workforce/FieldCostApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/CostRecovery/CalOesMarsViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Workforce/WorkforceViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/CalOesMars/_WorkItemStateBadge.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsMessage.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceMessage.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceShell.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/AnnualFacts.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Compensation.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/CompensationProfile.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Contractors.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/CostRun.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/CostRuns.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Demographics.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Employer.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Establishments.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/PayData.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/PayDataRun.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/ResourceCosts.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Usage.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/WorkEntries.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Worker.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Workers.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/Worksheet.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Workforce/_CompensationTable.cshtml create mode 100644 Workers/Resgrid.Workers.Console/Commands/PayDataReportingReadinessCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/PayDataReportingReadinessTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/PayDataReportingReadinessLogic.cs diff --git a/Core/Resgrid.Config/CostRecoveryConfig.cs b/Core/Resgrid.Config/CostRecoveryConfig.cs new file mode 100644 index 00000000..f2d0ce9e --- /dev/null +++ b/Core/Resgrid.Config/CostRecoveryConfig.cs @@ -0,0 +1,28 @@ +namespace Resgrid.Config +{ + /// + /// Cal OES MARS cost recovery (Workforce & Business Operations plan, Phase C-M3 / C11). Environment keys: + /// RESGRID:CostRecoveryConfig:CalOesMarsPortalUrl, :ReminderEnabled, :F42DueDaysAfterRelease, :AnnualDeadlineLeadDays, + /// :AgreementExpiryLeadDays, :HandoffAttestationRequired. + /// + public static class CostRecoveryConfig + { + /// The official MARS portal the handoff view links to. Never a credential; operators pin the reviewed address per cluster. + public static string CalOesMarsPortalUrl = "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/"; + + /// Master switch for worker 32's MARS duties (value-minimized digests to MARS managers). + public static bool ReminderEnabled = true; + + /// Days after a resource's release without a ReadyForPortal / submitted F-42 before the digest names it. + public static int F42DueDaysAfterRelease = 14; + + /// Days ahead of an annual rate profile's expiry (Salary Survey / Administrative Rate) the digest starts warning. + public static int AnnualDeadlineLeadDays = 45; + + /// Days ahead of an agreement snapshot's end date the digest starts warning. + public static int AgreementExpiryLeadDays = 30; + + /// The handoff view requires an explicit actor attestation before it renders copy helpers. + public static bool HandoffAttestationRequired = true; + } +} diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 24df75c2..499fef86 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -60,7 +60,7 @@ public static class DataProtectionConfig /// 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,invoicing"; + public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting"; /// 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.Config/WorkforceConfig.cs b/Core/Resgrid.Config/WorkforceConfig.cs new file mode 100644 index 00000000..b9a3651e --- /dev/null +++ b/Core/Resgrid.Config/WorkforceConfig.cs @@ -0,0 +1,26 @@ +namespace Resgrid.Config +{ + /// + /// Protected workforce pay data, field costing and California pay data reporting (Workforce & Business Operations + /// plan, Phase E). Environment keys: RESGRID:WorkforceConfig:DailyOvertimeThresholdHours, :UsageConflictTolerancePercent, + /// :ExportArtifactRetentionDays, :FilingSeasonStartMonth, :FilingSeasonEndMonth, :ReadinessReminderEnabled. + /// + public static class WorkforceConfig + { + /// Deployment time-report hours per person per day above this count are priced at the Overtime pay code in a cost run (an estimate — the payroll system's approved cost wins when supplied). + public static decimal DailyOvertimeThresholdHours = 8m; + + /// An automatic (GPS / tracker) and a manual usage reading for the same unit, date and context that differ by more than this percentage are queued for review. + public static decimal UsageConflictTolerancePercent = 10m; + + /// Days a CRD export artifact stays downloadable before worker 49 purges its bytes (the run, its snapshots and rows stay). + public static int ExportArtifactRetentionDays = 30; + + /// California pay data filing season (inclusive months) during which worker 49 sends the value-free readiness reminder. + public static int FilingSeasonStartMonth = 1; + public static int FilingSeasonEndMonth = 5; + + /// Master switch for worker 49. + public static bool ReadinessReminderEnabled = true; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx new file mode 100644 index 00000000..0a189877 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + قائمة إجراءات الحادث + نشط + المبلغ الفعلي + الساعات الفعلية (DTR) + إضافة اتفاقية + إضافة مدخل + إضافة بند + إضافة مورد + إضافة تناوب + العنوان + لا تزال الاحتسابات المزدوجة المحتملة بانتظار المراجعة. + لا تزال بعض المدخلات بانتظار المراجعة. + لا توجد قاعدة تكاليف مباشرة مسموح بها؛ لا يمكن احتساب المعدل. + لا توجد مدخلات تكاليف فعلية؛ خيار الحد الأدنى هو الخيار الوحيد. + الطريقة الإدارية + محسوب من التكاليف الفعلية + الحد الأدنى (de minimis) + لا شيء + مدخلات المعدل الإداري + التكاليف الفعلية للعام السابق حسب الوظيفة والفئة، مصنّفة مباشرة / غير مباشرة / غير مسموح بها. لا تُدخل الميزانيات هنا أبدًا. + المعدل الإداري + ورقة المعدل الإداري + غير المباشر المسموح ÷ المباشر المسموح من المدخلات المقبولة، مقارنةً بخيار الحد الأدنى. تعيق علامات الاحتساب المزدوج غير المحلولة النتيجة. + العمر + الوكالة + فئة الوكالة + سجل الوكالة في MARS: معرّف MACS وجهات الاتصال والمعرّفات المطبوعة على كل مطالبة. + اسم الوكالة + ملف الوكالة + الاتفاقية + الاتفاقيات + طرق التعويض المعتمدة MOU / MOA / GBR حسب التصنيف. يختار F-42 الطريقة السارية عند الإرسال الأول. + كل التصنيفات + كل الأنواع + كل السنوات + المباشر المسموح + غير المباشر المسموح + المبلغ + الأسعار السنوية + التقديمات السنوية السارية + مرفق الموافقة + موافقة + المعتمِد + معرّف مرفق الانتشار + اختياري: الاتفاقية الموقّعة المرفوعة كمرفق انتشار. + المرجعية + المعدات الخاصة بالوكالة + مقدَّم من الوكالة + السعر الأساسي لـ Cal OES + خطاب أسعار Cal OES + جدول FEMA + ملف الجهة المرجعية + لا يوجد ملف مُراجَع يغطي هذا التاريخ + رجوع + قُبل السعر الأساسي لـ Cal OES + تعتمد الوكالة السعر الأساسي لـ Cal OES بدلًا من تقديم صفوف الاستبيان الخاصة بها. + الأساس + يومي + ثابت + بالساعة + لكل ميل + نسبة مئوية + محظور + عائق + الخانة + الوكالة المستجيبة + الآليات ومركبات الدعم والمعدات + المرفقات + التعليقات والفقد / التلف وأرقام الإمداد + الإرسال / الالتزام + الحادث + رقم أمر الحادث + الأفراد + رقم الطلب + المورد + العودة / إعادة الإرسال + تناوب الطاقم + التوقيعات والتفويض + احتساب المعدل الإداري + Cal OES MARS + احتساب + المعدل المحسوب + إلغاء + الفئة + الفئة + إقامة + وجبة + متنوع + استئجار + تحقق من التاريخ + قائمة التحقق + المجموع الاختباري + المدينة + التصنيف + عنوان التصنيف + إغلاق + تعليق + التعليقات + ملتزم + ساعات الالتزام + طريقة التعويض + الساعات الفعلية + من البوابة إلى البوابة + هل تريد حذف هذا السجل؟ + بريد جهة الاتصال + اسم جهة الاتصال + هاتف جهة الاتصال + نسخ + تعليق المراجع + مباشر + غير مباشر + غير مسموح به + نظرة سريعة + السجلات المشمولة + سجلات F-42 / المصروفات المقدَّمة التي تدفعها هذه الفاتورة؛ تُقارن إجمالياتها المتوقعة بالمبلغ المفوتر. + التاريخ + خيار الحد الأدنى + القرار من (الاسم / المنصب) + حذف + الانتشار + الوصف + المعرّف + التفاصيل + نوع المستند + مكافئ + قرار مجلس (GBR) + مذكرة اتفاق (MOA) + مذكرة تفاهم (MOU) + للتوثيق فقط + يوثّق السجل الاستجابة دون المطالبة بتعويض (مسار Documentation Only في MARS). + احتمال احتساب مزدوج + مسودة من الوحدات + حدد الوحدات وأنشئ صفوفًا لتلك التي ليس لها صف. + تعديل الوكالة + تعديل الاتفاقية + تعديل المورد + ساري من + مؤهل + مستبعد + غير مؤكد + النهاية + حزمة الأدلة + مستبعد (مُحمَّل على الحوادث) + مستبعد (غير مسموح) + المتوقع + التعويض المتوقع + تقدير من الأسعار والاتفاقية السارية عند الإرسال الأول. يحدد Cal OES المبلغ المسموح؛ ولا تدخل أي تكلفة داخلية في هذه البنود. + الإجمالي المتوقع + المتوقع مقابل الملاحظ + ينتهي في + اسم المورد الخارجي + الوكالة + الآليات + المرفقات + التعليقات + الإرسال + الحادث + تفويض الحادث + بنود المصروفات + الأمر + الأفراد + الطلب + المورد + توقيع الوكالة + العودة + التناوب + التوقيع + FEIN + الطلب / التعبئة + معرّف تعبئة الطلب الخارجي في RMS (مطلوب عندما يحتوي الطلب على أكثر من طلب فرعي) + مورّد FI$Cal + إصلاح + الوظيفة + أُنشئ + أنا الشخص المخوّل بإدخال هذا السجل في MARS وسأنسخ القيم المعروضة، لا ملف هذه الصفحة. + عرض نسخ جنبًا إلى جنب للإدخال اليدوي في بوابة MARS. لا يُخزَّن ولا يُحفظ مؤقتًا وليس تقديمًا. + شغّل قائمة التحقق حتى تخلو من الأخطاء؛ يُفتح التسليم من حالة جاهز للبوابة. + مُعدّ لـ MARS. يسجّل فتح هذا العرض قيد تدقيق ولا يغيّر شيئًا؛ يصبح السجل ملاحظًا في MARS فقط عندما يسجّل مدير ما تعرضه البوابة. + لا تزال قائمة التحقق تحتوي على أخطاء؛ قد يعيد MARS هذا السجل. + تُنسخ هذه القيم إلى نماذج F-42 والمطالبات وتُخزَّن كبيانات (خارج الحماية المتقدمة للبيانات). يؤدي تغيير معرّف إلى مسح آخر تحقق. + اترك التصنيف فارغًا لاتفاقية على مستوى الإدارة. الاتفاقية المشار إليها في سجل مقدَّم غير قابلة للتغيير؛ يؤدي تعديلها إلى إصدار جديد. + يرى أفراد الميدان مسوداتهم المرتبطة بالحادث؛ ويرى مديرو MARS قائمة الإدارة. لا يؤدي فتح تسليم أو تنزيل حزمة أبدًا إلى وسم سجل بأنه مقدَّم. + الأسعار بيانات مثبّتة على ملف مرجعي مُراجَع؛ خطاب الأسعار أو الاستبيان الجديد لقطة جديدة، ويحتفظ الإرسال السابق بما كان ساريًا عند بدايته. + تمنع العوائق وصول نماذج F-42 الجديدة إلى حالة جاهز للبوابة؛ ويُستحسن معالجة التحذيرات قبل الإرسال التالي. + فاتورة MARS عنصر عمل وليست فاتورة عميل من المرحلة B أبدًا: لا رقم فاتورة ولا تقادم ولا بريد. المدفوع دائمًا حقيقة دفع ملاحظة فقط. + تنسخ صفوف المسودة اسم الوحدة ولوحتها ورقم VIN في تلك اللحظة؛ راجعها وأضف نوع مورد MARS ثم سجّل ما يعرضه MARS. + الساعات + المعرّفات + مفوّض الحادث / AREP + مُحمَّل مباشرة على حادث (مستبعد) + يشمل تأمين البطالة + يشمل تعويض العمال + القادم + تاريخ الفاتورة + المتوقع مقابل الملاحظ، والقرار المحلي، وحقائق الدفع لفاتورة واحدة أنشأها MARS. + المفوتر + فواتير بانتظار الموافقة المحلية + العنصر + الصنف + آلية + معدات خاصة + مركبة خاصة + مركبة دعم + لوحة الترخيص + نوع البند + إداري + المعدل الإداري + آلية + الملحق A (غير الإطفاء) + مصروف + الوجبات والإقامة والنثريات + آلية رسمية + مركبة دعم رسمية + الأفراد + أميال المركبة الخاصة + أميال المركبة الخاصة + استئجار + استبيان الرواتب + معدات خاصة + مركبة دعم + F-42 المرتبط + قرار الوكالة المحلية + وافق على الفاتورة أو ارفضها بصفتك الممثل المخوّل؛ يتطلب الرفض تعليقًا. يُسجَّل القرار هنا وتدخله أنت في MARS. + الفقد / التلف + معرّف MACS + إدارة تعويض Cal OES MARS + سجلات الوكالة وموارد F-5 والأسعار السنوية والاتفاقيات، وتسليم البوابة، وحالات MARS الملاحظة، والموافقة على الفواتير/تسوية المدفوعات. يُعدّ الأعضاء المدرجون مسودات F-42 والمصروفات الخاصة بهم بدونها. + وضع علامة تم التحقق اليوم + فاتورة MARS + معرّف فاتورة MARS + هذا عنصر عمل MARS. ليس له رقم فاتورة من المرحلة B، ولا يظهر أبدًا في تقادم العملاء، ولا يمكن إرساله بالبريد أو دفعه عبر الإنترنت. + فواتير MARS + معرّف سجل MARS + معرّف مورد MARS + الطريقة المختارة + الأميال + مدرج في هذا الانتشار + تعارضات + الاسم + تقديم جديد + لا + لا يوجد ملف وكالة بعد. + لا توجد اتفاقيات بعد. + لا توجد مرفقات انتشار. + لا توجد انتشارات استرداد تكاليف للإعداد منها. + لا يخزّن Resgrid بيانات اعتماد MARS أو رموز MFA أو جلسات المتصفح ولا يكتب أبدًا في البوابة. + لا يوجد تقديم سنوي يغطي هذا التاريخ. + بدون انتشار + لا توجد فواتير MARS مسجّلة. + لا شيء في القائمة. + لا توجد تقديمات سنوية بعد. + كل ما يلزم لتقديم MARS متوفر. + لا توجد صفوف موارد F-5 بعد. + حزمة الأدلة ليست ملف استيراد مقبولًا في MARS. + لم يُحسب بعد. + غير ساري + غير مدرج في جرد F-5 + لم يتم التحقق بعد. + لوحظ في MARS + لوحظ في + الحالة الملاحظة + التقديم الملاحظ + عداد المسافة + المصادر الرسمية + فتح + فتح الانتشار + فتح عرض التسليم + فتح بوابة MARS + العناصر المفتوحة + المغادر + منصب overhead + مؤهل للعمل الإضافي + طريقة العمل الإضافي + بعد 8 ساعات يوميًا + بعد 12 ساعة يوميًا + لا شيء + حسب الاتفاقية (غير مُنمذج) + سعر العمل الإضافي + الملكية + CAL FIRE + Cal OES + وكالة محلية + أخرى + خاص + مستأجر + المدفوع + دُفع في + الجهة الدافعة + حالة الجهة الدافعة + مرجع الدفع + تسليم البوابة + مرجع حساب البوابة + تسمية لحساب MARS (ليست كلمة مرور أو رمزًا أبدًا). + الدور في البوابة + مؤهل من البوابة إلى البوابة + معتمد مسبقًا + إعداد مطالبة المصروفات + إعداد F-42 + إعداد سجل + نموذج F-42 واحد لكل مورد / طلب مأمور به؛ يحلّ إعادة الإرسال محل السابق. ترتبط مطالبات المصروفات بنموذج F-42 أو تسلك مسار السفر فقط. + مُعدّ لـ MARS — لا شيء هنا مقدَّم حتى يسجّل مدير ما لوحظ في البوابة. + قابل للطباعة + المصدر والتتبع + نماذج F-42 ومطالبات المصروفات للإعداد والتحقق والتسليم، مجمّعة حسب الانتشار؛ وبجانبها حالات MARS الملاحظة والفواتير. + الرتبة + الترويسة والبنود و(للمعدل الإداري) ورقة التكاليف الفعلية للعام السابق. + بنود الأسعار + تحتاج بنود الرواتب إلى تصنيف؛ وبنود المعدات إلى رمز مورد أو FEMA. الأسعار عادية / إضافية حسب الأساس. + هذا التقديم موقّع أو مقدَّم؛ تتطلب التعديلات إصدارًا جديدًا. + إصدار ملف الأسعار + مقبول + مسودة + مُراجَع + موقّع محليًا + مقدَّم في MARS + مُستبدل + استبيان الرواتب والملحق A والمعدل الإداري وخطاب أسعار Cal OES والمعدات الخاصة حسب سنة التقديم. + لا يوجد معدل إداري مسجّل لهذا التاريخ؛ لن يُقدَّر أي بند إداري. + لم يُدخل ملف الوكالة (معرّف MACS، جهات الاتصال، المعرّفات). + لم يتم التحقق من ملف الوكالة مقابل MARS خلال العام الماضي. + تنتهي اتفاقية ضمن فترة الإنذار. + لا توجد طريقة تعويض MOU/MOA/GBR تغطي هذا التاريخ؛ يُقدَّر الأفراد بالساعات الفعلية دون عمل إضافي. + الجاهزية حتى + لا يوجد ملف مرجعي مُراجَع من Cal OES يغطي هذا التاريخ؛ التقديمات الجديدة محظورة حتى إضافة ملف. + لوحة الجاهزية + لم يُسجَّل FEIN للوكالة. + لم يُسجَّل معرّف مورّد FI$Cal للوكالة. + جاهزية الوكالة وموارد F-5 والأسعار السنوية والاتفاقيات لتاريخ الإرسال، مع روابط إلى مواد Cal OES الرسمية. + فواتير أنشأها MARS بانتظار قرار الوكالة المحلي. + معرّف MACS للوكالة مفقود. + لا توجد صفوف مطابقة F-5؛ ستُوسم الآليات في F-42 بأنها غير مدرجة في الجرد. + ينتهي تقديم سنوي ضمن فترة الإنذار. + لا توجد بنود في خطاب أسعار Cal OES (الآليات، مركبات الدعم، أميال POV) تغطي هذا التاريخ. + تقديم سنوي ساري لم يُوقَّع محليًا. + صفوف موارد F-5 لا تطابق ما يعرضه MARS. + سجلات أعادها Cal OES لمراجعة الوكالة بانتظار المعالجة. + لا يوجد استبيان رواتب مُراجَع (أو سعر أساسي مقبول) يغطي هذا التاريخ؛ لا يمكن تقدير بنود الأفراد. + لم يُسجَّل SAM UEI للوكالة. + جاهز + جاهز للبوابة. + الإيصال + تسوية الفواتير والمدفوعات + فواتير MARS كما لوحظت، وقرار الموافقة / الرفض المحلي، وحالة الجهة الدافعة، وحقائق الدفع مقابل البنود المتوقعة. + تسجيل فاتورة MARS + سجّل الفاتورة التي أنشأها Cal OES كما تراها في البوابة، والسجلات المقدَّمة التي تغطيها. + تسجيل الملاحظة + تسجيل الدفع + فقط دفعة ملاحظة من الجهة الدافعة تضع الفاتورة وسجلاتها في حالة مدفوع. + تسجيل التقديم الملاحظ في MARS + نوع السجل + المعدل الإداري + ملف الوكالة + اتفاقية + الملحق A + مطالبة مصروفات + F-42 + فاتورة MARS + جرد موارد F-5 + استبيان الرواتب + المعدات الخاصة + مسجّل + إعادة إرسال + رفض + السجلات ذات الصلة + الإفراج ليس عودة؛ يحتاج F-42 إلى وقت العودة أو إعادة الإرسال. + أُفرج عنه + موقع الإبلاغ + الطلب + رمز المورد + جرد موارد F-5 + صنف المورد + موارد F-5 + نوع المورد + مطابقة وحدات وأصول Resgrid مع هويتها في MARS / F-5. تُعدّ الجرد وتسويه؛ ولا تكتب حالة FAST أبدًا. + الموقّع عن الوكالة + أُعيدت للمراجعة + السبب + حالة المراجعة + مسودة + تعارض + لوحظ في MARS + مُراجَع + المراجعة + مقبول + مستبعد + معلّق + روجع في + تشغيل قائمة التحقق + تسجيل SAM + حفظ + تعذر حفظ التغيير. + حفظ المدخلات + حفظ البنود + تم الحفظ. + الرقم التسلسلي + لوحظ قبوله + العودة إلى المسودة + وضع علامة مُراجَع + توقيع محلي + لوحظ تقديمه في MARS + استبدال + الخطورة + وقّعه (الاسم) + وُقّع في + الموقّع + المصدر + المستند المصدر + تاريخ المصدر + السطر المصدر + النظام المصدر + رابط المصدر + البداية + الحالة المحلية + معتمد + مغلق + للتوثيق فقط + مسودة + مرفوض من الوكالة المحلية + يحتاج مراجعة + مدفوع + بانتظار موافقة الوكالة المحلية + بانتظار الجهة الدافعة + جاهز للبوابة + أُعيد لمراجعة الوكالة + لوحظ في MARS (مراجعة Cal OES) + الحالة + السعر العادي + فريق الضربة / فرقة العمل + الموضوع + نوع الموضوع + مورد خارجي + أصل جرد + وحدة + التقديم + نوع التقديم + المعدل الإداري + الملحق A (غير الإطفاء) + خطاب أسعار Cal OES + استبيان الرواتب + المعدات الخاصة / رموز FEMA + يحل محل + أرقام الإمداد + المستندات الداعمة + الوكالة + الاتفاقيات + قائمة الإجراءات + الأسعار السنوية + الجاهزية + التسوية + موارد F-5 + سفر فقط (بدون F-42) + SAM UEI + الوحدة + معرّف الوحدة + تم التحقق + قائمة التحقق: {0} خطأ، {1} تحذير. + ملف الوكالة مفقود. + لا توجد اتفاقية MOU / MOA / GBR تغطي تاريخ الإرسال. + لا يوجد ملف مرجعي مُراجَع يغطي هذا السجل. + تصنيف شخص ليس في استبيان الرواتب ولا في القائمة المثبتة. + وقت الإرسال مفقود. + موسوم للتوثيق فقط: لا يُطالب بأي تعويض. + تظهر المركبة نفسها أكثر من مرة. + معتمِد المطالبة مفقود. + لم يُلاحظ تقديم F-42 المرتبط في MARS. + مطالبة المصروفات بلا بنود. + بند مصروف بلا إيصال. + توقيع المطالبة مفقود. + تفويض الحادث / AREP مفقود. + اسم الحادث أو رقمه مفقود. + معرّف MACS مفقود. + رقم أمر الحادث مفقود. + لا يوجد F-42 موقّع أو ورقي مرفق بالانتشار. + فترة التزام شخص تقع خارج نافذة الإرسال-العودة للمورد. + لا يوجد أفراد مدرجون. + لا توجد بنود أسعار سنوية سارية لتاريخ الإرسال. + المورد مُفرج عنه لكن لم يُسجَّل وقت عودة أو إعادة إرسال. + رقم الطلب مفقود. + لا يحمل رقم الطلب بادئة صالحة (E, O, C, S, A) ورقمًا. + نوع المورد أو صنفه أو منصب overhead مفقود. + توقيع الوكالة المستجيبة مفقود. + وقت العودة قبل وقت الإرسال. + تناوب طاقم بلا مرفق موافقة. + لا يوجد صف مطابقة F-5 لمركبة. + القيمة + الفرق + التحقق + ضع علامة تم التحقق بعد مقارنة الملف بسجل الوكالة في MARS. تحذر لوحة الجاهزية بعد عام. + تم التحقق في + الإصدار + تحذير + الفترة + ترتيب الخانات الرسمي، وروابط إلى حقائق الانتشار الثابتة، وقائمة التحقق، والتعويض المتوقع، وتسليم البوابة. + السنة + نعم + لا يمكن أن تكون مبالغ المدخلات سالبة. + تصنيف مدخل غير صالح. + السنة المالية لمدخل غير صالحة (السنوات السابقة فقط). + اسم الوكالة مطلوب. + لم يُعثر على ملف الوكالة. + الاتفاقية مشار إليها في عنصر عمل ولا يمكن حذفها. + نوع مستند الاتفاقية غير صالح. + طريقة التعويض غير صالحة. + لم يُعثر على الاتفاقية. + طريقة العمل الإضافي غير صالحة. + أكّد الإقرار قبل فتح عرض التسليم. + تاريخ الانتهاء قبل تاريخ البدء. + يتطلب القرار اسم / منصب صاحب القرار. + لم يُعثر على الانتشار. + لم يُعثر على F-42 المرتبط في هذا الانتشار. + هذا الطلب / التعبئة ليس على الطلب الخارجي للانتشار. + يحتوي الطلب على عدة طلبات فرعية؛ اختر الطلب / التعبئة لهذا F-42. + لا يمكن أن يكون المبلغ المفوتر سالبًا. + فاتورة MARS بهذا المعرّف مسجّلة بالفعل. + معرّف فاتورة MARS مطلوب. + سجّل الموافقة المحلية قبل الدفع. + الفاتورة ليست في حالة تسمح بتسجيل دفعة. + الفاتورة ليست بانتظار الموافقة المحلية. + السجل ليس جاهزًا للبوابة؛ شغّل قائمة التحقق أولًا. + لا يمكن أن يكون المبلغ المدفوع سالبًا. + التقديم بلا بنود ولا معدل إداري ولا سعر أساسي مقبول. + أساس بند السعر غير صالح. + تحتاج بنود الرواتب إلى رمز تصنيف. + نوع بند السعر غير صالح. + لا يمكن أن تكون الأسعار سالبة. + تحتاج بنود المعدات إلى رمز مورد أو FEMA. + هذا التقديم موقّع أو مقدَّم ولا يمكن تعديله بعد الآن. + لم يُعثر على التقديم السنوي. + سجّل التوقيع المحلي قبل ملاحظة حالة خارجية. + يجب أن يكون المعدل الإداري بين 0 و100 بالمئة. + يتطلب التوقيع اسم الموقّع. + تغيير الحالة هذا غير مسموح. + نوع التقديم غير صالح. + سنة التقديم غير صالحة. + يتطلب الرفض تعليقًا. + المورد الخارجي يحتاج إلى اسم. + لم يُعثر على صف المورد. + نوع موضوع المورد غير صالح. + لم يُعثر على الوحدة في هذه الإدارة. + الوحدة مطلوبة لمورد من نوع وحدة. + هذه الحالة الخارجية ليست في مفردات الملف المرجعي. + لوحظ السجل في MARS ولم يعد قابلًا للتعديل محليًا. + فقط نماذج F-42 ومطالبات المصروفات لها تعويض متوقع. + يمكن إغلاق السجلات المعتمدة أو للتوثيق فقط أو المرفوضة أو المدفوعة فقط. + لم يُلاحظ تقديم السجل في MARS. + لم يُعثر على السجل. + السجل ليس فاتورة MARS. + فقط نماذج F-42 ومطالبات المصروفات تُقدَّم إلى MARS. + السجل مدفوع أو مغلق؛ بنوده نهائية. + السجل ليس من النوع المتوقع. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.cs b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.cs new file mode 100644 index 00000000..b346b7c1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.CalOesMars +{ + public class CalOesMars { } +} diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx new file mode 100644 index 00000000..de30b487 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Einsatz-Aufgabenliste + Aktiv + Ist-Betrag + Ist-Stunden (DTR) + Vereinbarung hinzufügen + Eingabe hinzufügen + Zeile hinzufügen + Ressource hinzufügen + Wechsel hinzufügen + Adresse + Mögliche Doppelzählungen sind noch ungeprüft. + Einige Eingaben sind noch ungeprüft. + Keine zulässige direkte Kostenbasis; der Satz kann nicht berechnet werden. + Keine Ist-Kosten-Eingaben; die De-minimis-Option ist die einzige Wahl. + Verwaltungsmethode + Aus Ist-Kosten berechnet + De minimis + Keine + Eingaben Verwaltungssatz + Vorjahres-Ist-Kosten nach Funktion und Kategorie, klassifiziert als direkt/indirekt/nicht zulässig. Budgets werden hier nie erfasst. + Verwaltungssatz + Arbeitsblatt Verwaltungssatz + Zulässig indirekt ÷ zulässig direkt aus den akzeptierten Eingaben, verglichen mit der De-minimis-Option. Ungeklärte Doppelzählungen blockieren das Ergebnis. + Alter + Behörde + Behördenkategorie + Der MARS-Behördendatensatz: MACS-Kennung, Kontakte und die auf jedem Antrag gedruckten Kennungen. + Behördenname + Behördenprofil + Vereinbarung + Vereinbarungen + Genehmigte MOU/MOA/GBR-Vergütungsmethoden nach Klassifikation. Ein F-42 wählt die zum Erstaufgebot gültige. + Alle Klassifikationen + Alle Typen + Alle Jahre + Zulässig direkt + Zulässig indirekt + Betrag + Jahresraten + Gültige Jahreseinreichungen + Genehmigungsanhang + Genehmigen + Genehmiger + Anhang-ID des Einsatzes + Optional: die als Einsatzanhang hochgeladene unterzeichnete Vereinbarung. + Grundlage + Sonderausrüstung der Behörde + Von Behörde eingereicht + Cal OES-Basissatz + Cal OES Rate Letter + FEMA-Tabelle + Referenzprofil + Kein geprüftes Profil deckt dieses Datum ab + Zurück + Cal OES-Basissatz akzeptiert + Die Behörde übernimmt den Cal OES-Basissatz statt eigener Umfragezeilen. + Basis + Täglich + Pauschal + Stündlich + Pro Meile + Prozent + Blockiert + Blocker + Feld + Antwortende Behörde + Fahrzeuge, Begleitfahrzeuge und Ausrüstung + Anhänge + Kommentare, Verlust/Schaden, Versorgungsnummern + Alarmierung / Bindung + Einsatz + Auftragsnummer + Personal + Anforderungsnummer + Ressource + Rückkehr / Redispatch + Mannschaftswechsel + Unterschriften und Autorisierung + Verwaltungssatz berechnen + Cal OES MARS + Berechnen + Berechneter Satz + Abbrechen + Kategorie + Kategorie + Unterkunft + Verpflegung + Sonstiges + Miete + Datum prüfen + Checkliste + Prüfsumme + Ort + Klassifikation + Klassifikationsbezeichnung + Schließen + Kommentar + Kommentare + Gebunden + Gebundene Stunden + Vergütungsmethode + Ist-Stunden + Portal zu Portal + Diesen Eintrag löschen? + Kontakt-E-Mail + Kontaktname + Kontakttelefon + Kopieren + Prüferkommentar + Direkt + Indirekt + Nicht zulässig + Auf einen Blick + Abgedeckte Datensätze + Eingereichte F-42-/Spesendatensätze, die diese Rechnung bezahlt; ihre erwarteten Summen werden mit dem Rechnungsbetrag verglichen. + Datum + De-minimis-Option + Entscheidung von (Name / Titel) + Löschen + Einsatz + Beschreibung + Kennung + Detail + Dokumentart + Gleichwertig + GBR + MOA + MOU + Nur Dokumentation + Der Datensatz dokumentiert den Einsatz ohne Erstattungsanspruch (MARS-Pfad „Nur Dokumentation“). + Mögliche Doppelzählung + Aus Einheiten entwerfen + Einheiten auswählen und Zuordnungszeilen für die ohne Eintrag anlegen. + Behörde bearbeiten + Vereinbarung bearbeiten + Ressource bearbeiten + Gültig ab + Erstattungsfähig + Ausgeschlossen + Unsicher + Ende + Nachweispaket + Ausgeschlossen (Einsätzen berechnet) + Ausgeschlossen (nicht zulässig) + Erwartet + Erwartete Erstattung + Eine Schätzung aus den beim Erstaufgebot gültigen Raten und Vereinbarungen. Cal OES bestimmt den zulässigen Betrag; keine internen Kosten fließen ein. + Erwartete Summe + Erwartet gegenüber beobachtet + Läuft ab am + Name der externen Ressource + Behörde + Fahrzeuge + Anhänge + Kommentare + Alarmierung + Einsatz + Einsatzautorisierung + Spesenzeilen + Auftrag + Personal + Anforderung + Ressource + Unterschrift der Behörde + Rückkehr + Wechsel + Unterschrift + FEIN + Anforderung / Fill + RMS-Fill-ID der externen Anforderung (erforderlich bei mehreren Anforderungen) + FI$Cal-Lieferant + Beheben + Funktion + Erzeugt + Ich bin die befugte Person, die diesen Datensatz in MARS erfasst, und übernehme die angezeigten Werte, nicht die Datei dieser Seite. + Kopieransicht für die manuelle Eingabe im MARS-Portal. Nicht gespeichert, nicht zwischengespeichert und keine Einreichung. + Checkliste ausführen, bis sie fehlerfrei ist; die Übergabe öffnet ab „Bereit für Portal“. + Für MARS vorbereitet. Das Öffnen dieser Ansicht erzeugt einen Audit-Eintrag und ändert nichts; der Datensatz wird erst „In MARS beobachtet“, wenn ein Verantwortlicher den Portalstand erfasst. + Die Checkliste enthält noch Fehler; MARS kann diesen Datensatz zurückgeben. + Diese Werte werden auf F-42 und Anträge übernommen und als Daten gespeichert (nicht unter Advanced Data Protection). Eine Kennungsänderung setzt die letzte Verifizierung zurück. + Klassifikation leer lassen für eine abteilungsweite Vereinbarung. Eine von einem eingereichten Datensatz referenzierte Vereinbarung ist unveränderlich; Bearbeiten erzeugt eine neue Version. + Einsatzkräfte sehen ihre eigenen einsatzbezogenen Entwürfe; MARS-Verantwortliche die Abteilungsliste. Eine Übergabe zu öffnen oder ein Paket herunterzuladen markiert nie etwas als eingereicht. + Raten sind Daten, die an ein geprüftes Referenzprofil gebunden sind; ein neuer Rate Letter oder eine neue Umfrage ist ein neuer Snapshot, und ein früherer Einsatz behält den zu Beginn gültigen. + Blocker verhindern, dass neue F-42 den Status „Bereit für Portal“ erreichen; Warnungen sollten vor dem nächsten Einsatz behoben werden. + Eine MARS-Rechnung ist ein Vorgang, nie eine Phase-B-Kundenrechnung: keine Rechnungsnummer, keine Fälligkeitsliste, keine E-Mail. „Bezahlt“ ist immer nur eine beobachtete Zahlung. + Entwurfszeilen übernehmen Name, Kennzeichen und VIN der Einheit zu diesem Zeitpunkt; prüfen, MARS-Ressourcentyp ergänzen, dann den MARS-Stand erfassen. + Stunden + Kennungen + Einsatz-/AREP-Autorisierer + Direkt einem Einsatz berechnet (ausgeschlossen) + Inkl. Arbeitslosenversicherung + Inkl. Unfallversicherung + Ankommend + Rechnungsdatum + Erwartet gegenüber beobachtet, lokale Entscheidung und Zahlungsfakten für eine von MARS erzeugte Rechnung. + In Rechnung gestellt + Rechnungen mit ausstehender lokaler Freigabe + Punkt + Art + Fahrzeug + Sonderausrüstung + Privatfahrzeug + Begleitfahrzeug + Kennzeichen + Zeilenart + Verwaltung + Verwaltungssatz + Fahrzeug + Anlage A (Nicht-Brandbekämpfung) + Auslage + Verpflegung, Unterkunft, Nebenkosten + Offizielles Fahrzeug + Offizielles Begleitfahrzeug + Personal + POV-Meilen + POV-Meilen + Miete + Gehaltsumfrage + Sonderausrüstung + Begleitfahrzeug + Verknüpftes F-42 + Lokale Entscheidung + Rechnung als befugter Vertreter genehmigen oder ablehnen; eine Ablehnung braucht einen Kommentar. Die Entscheidung wird hier erfasst und von Ihnen in MARS eingetragen. + Verlust / Schaden + MACS-Kennung + Cal OES MARS-Erstattung verwalten + Behörden-, F-5-Ressourcen-, Jahresraten- und Vereinbarungsdaten, Portalübergabe, beobachtete MARS-Status sowie Rechnungsfreigabe/Zahlungsabgleich. Eingeteilte Mitglieder erstellen ihre eigenen F-42- und Spesenentwürfe ohne diese Berechtigung. + Heute als geprüft markieren + MARS-Rechnung + MARS-Rechnungs-ID + Dies ist ein MARS-Vorgang. Er hat keine Phase-B-Rechnungsnummer, erscheint nie in der Kunden-Fälligkeitsliste und kann nicht per E-Mail versandt oder online bezahlt werden. + MARS-Rechnungen + MARS-Datensatz-ID + MARS-Ressourcen-ID + Gewählte Methode + Meilen + Auf diesem Einsatz eingeteilt + Abweichungen + Name + Neue Einreichung + Nein + Noch kein Behördenprofil. + Noch keine Vereinbarungen. + Keine Einsatzanhänge. + Keine Kostenrückerstattungs-Einsätze zur Vorbereitung. + Resgrid speichert keine MARS-Zugangsdaten, MFA-Token oder Browsersitzungen und schreibt nie in das Portal. + Keine Jahreseinreichung deckt dieses Datum ab. + Kein Einsatz + Keine MARS-Rechnungen erfasst. + Nichts in der Liste. + Noch keine Jahreseinreichungen. + Alles für eine MARS-Einreichung ist vorhanden. + Noch keine F-5-Ressourcenzeilen. + Das Nachweispaket ist keine akzeptierte MARS-Importdatei. + Noch nicht berechnet. + Nicht aktuell + Nicht im F-5-Ressourcenbestand + Noch nicht geprüft. + In MARS beobachtet + Beobachtet am + Beobachteter Status + Beobachtete Einreichung + Kilometerzähler + Offizielle Quellen + Öffnen + Einsatz öffnen + Übergabeansicht öffnen + MARS-Portal öffnen + Offene Vorgänge + Abgehend + Overhead-Position + Überstundenberechtigt + Überstundenmethode + Nach 8 Stunden pro Tag + Nach 12 Stunden pro Tag + Keine + Laut Vereinbarung (nicht modelliert) + Überstundensatz + Eigentum + CAL FIRE + Cal OES + Lokale Behörde + Sonstige + Privat + Miete + Bezahlt + Bezahlt am + Zahlende Stelle + Status der zahlenden Stelle + Zahlungsreferenz + Portalübergabe + Portal-Kontoreferenz + Eine Bezeichnung für das MARS-Konto (nie ein Passwort oder Token). + Portalrolle + Portal-zu-Portal-berechtigt + Vorab genehmigt + Spesenantrag vorbereiten + F-42 vorbereiten + Datensatz vorbereiten + Ein F-42 pro angeforderter Ressource/Anforderung; ein Redispatch ersetzt den früheren. Spesenanträge verweisen auf ein F-42 oder nehmen den Nur-Reise-Pfad. + Für MARS vorbereitet — nichts ist eingereicht, bis ein Verantwortlicher das im Portal Beobachtete erfasst. + Druckansicht + Herkunft + F-42 und Spesenanträge zum Vorbereiten, Prüfen und Übergeben, gruppiert nach Einsatz; beobachtete MARS-Status und Rechnungen daneben. + Dienstgrad + Kopf, Zeilen und (für den Verwaltungssatz) das Arbeitsblatt der Vorjahres-Ist-Kosten. + Ratenzeilen + Gehaltszeilen brauchen eine Klassifikation; Ausrüstungszeilen einen Ressourcen- oder FEMA-Code. Raten sind Normal-/Überstundensätze je Basis. + Diese Einreichung ist unterzeichnet oder eingereicht; Änderungen erfordern eine neue Version. + Ratenprofil-Version + Akzeptiert + Entwurf + Geprüft + Lokal unterzeichnet + In MARS eingereicht + Ersetzt + Gehaltsumfrage, Anlage A, Verwaltungssatz, Cal OES Rate Letter und Sonderausrüstung nach Einreichungsjahr. + Für dieses Datum ist kein Verwaltungssatz erfasst; keine Verwaltungszeile wird geschätzt. + Das Behördenprofil (MACS-Kennung, Kontakte, Kennungen) wurde nicht erfasst. + Das Behördenprofil wurde im letzten Jahr nicht gegen MARS verifiziert. + Eine Vereinbarung endet innerhalb der Vorlaufzeit. + Keine MOU/MOA/GBR-Vergütungsmethode deckt dieses Datum ab; Personal wird nach Ist-Stunden ohne Überstunden geschätzt. + Bereitschaft zum + Kein geprüftes Cal OES-Referenzprofil deckt dieses Einsatzdatum ab; neue Einreichungen sind blockiert, bis eines ergänzt wird. + Bereitschaftsübersicht + Keine FEIN für die Behörde erfasst. + Keine FI$Cal-Lieferanten-ID für die Behörde erfasst. + Bereitschaft von Behörde, F-5-Ressourcen, Jahresraten und Vereinbarungen zu einem Einsatzdatum, mit Links zum offiziellen Cal OES-Material. + Von MARS erzeugte Rechnungen warten auf die lokale Entscheidung. + Die MACS-Behördenkennung fehlt. + Es gibt keine F-5-Ressourcenzuordnungen; Fahrzeuge auf einem F-42 werden als nicht im Bestand markiert. + Eine Jahreseinreichung läuft innerhalb der Vorlaufzeit ab. + Keine Cal OES-Rate-Letter-Zeilen (Fahrzeuge, Begleitfahrzeuge, POV-Meilen) decken dieses Datum ab. + Eine gültige Jahreseinreichung wurde lokal nicht unterzeichnet. + F-5-Ressourcen stimmen nicht mit MARS überein. + Von Cal OES zur Prüfung zurückgegebene Datensätze warten. + Keine geprüfte Gehaltsumfrage (oder akzeptierter Basissatz) deckt dieses Datum ab; Personalzeilen können nicht geschätzt werden. + Keine SAM UEI für die Behörde erfasst. + Bereit + Bereit für das Portal. + Beleg + Rechnungs- und Zahlungsabgleich + Von MARS erzeugte Rechnungen wie beobachtet, die lokale Freigabe-/Ablehnungsentscheidung, Status der zahlenden Stelle und Zahlungsfakten gegenüber den erwarteten Zeilen. + MARS-Rechnung erfassen + Die von Cal OES erzeugte Rechnung so erfassen, wie sie im Portal erscheint, samt der abgedeckten eingereichten Datensätze. + Beobachtung erfassen + Zahlung erfassen + Nur eine beobachtete Zahlung der zahlenden Stelle markiert die Rechnung und ihre Datensätze als bezahlt. + In MARS beobachtete Einreichung erfassen + Datensatztyp + Verwaltungssatz + Behördenprofil + Vereinbarung + Anlage A + Spesenantrag + F-42 + MARS-Rechnung + F-5-Ressourcenbestand + Gehaltsumfrage + Sonderausrüstung + Erfasst + Redispatch von + Ablehnen + Verwandte Datensätze + Freigabe ist keine Rückkehr; das F-42 braucht die Rückkehr- oder Redispatch-Zeit. + Freigegeben + Meldeort + Anforderung + Ressourcencode + F-5-Ressourcenbestand + Ressourcenart + F-5-Ressourcen + Ressourcentyp + Zuordnung von Resgrid-Einheiten und -Assets zu ihrer MARS/F-5-Identität. Bereitet den Bestand vor und gleicht ihn ab; schreibt nie FAST-Status. + Unterzeichner der Behörde + Zur Prüfung zurückgegeben + Begründung + Prüfstatus + Entwurf + Abweichung + In MARS beobachtet + Geprüft + Prüfung + Akzeptiert + Ausgeschlossen + Ausstehend + geprüft am + Checkliste prüfen + SAM-Registrierung + Speichern + Die Änderung konnte nicht gespeichert werden. + Eingaben speichern + Zeilen speichern + Gespeichert. + Seriennummer + Als akzeptiert beobachtet + Zurück zum Entwurf + Als geprüft markieren + Lokal unterzeichnen + Als in MARS eingereicht beobachtet + Ersetzen + Schwere + Unterzeichnet von (Name) + Unterzeichnet am + Unterzeichner + Quelle + Quellartefakt + Quelldatum + Quellzeile + Quellsystem + Quell-URL + Beginn + Zustand + Genehmigt + Geschlossen + Nur Dokumentation + Entwurf + Lokal abgelehnt + Prüfung nötig + Bezahlt + Lokale Freigabe ausstehend + Zahlende Stelle ausstehend + Bereit für Portal + Zur Prüfung zurückgegeben + In MARS beobachtet (Cal OES-Prüfung) + Status + Normalsatz + Strike Team / Task Force + Objekt + Objekttyp + Externe Ressource + Inventar-Asset + Einheit + Einreichung + Einreichungstyp + Verwaltungssatz + Anlage A (Nicht-Brandbekämpfung) + Cal OES Rate Letter + Gehaltsumfrage + Sonderausrüstung / FEMA-Codes + ersetzt + Versorgungsnummern + Begleitdokumente + Behörde + Vereinbarungen + Aufgabenliste + Jahresraten + Bereitschaft + Abgleich + F-5-Ressourcen + Nur Reise (kein F-42) + SAM UEI + Einheit + Einheitenkennung + Geprüft + Checkliste: {0} Fehler, {1} Warnung(en). + Das Behördenprofil fehlt. + Keine MOU/MOA/GBR-Vereinbarung deckt das Einsatzdatum ab. + Kein geprüftes Referenzprofil deckt diesen Datensatz ab. + Die Klassifikation einer Person ist weder in der Gehaltsumfrage noch in der Referenzliste. + Die Alarmierungszeit fehlt. + Als „Nur Dokumentation“ markiert: keine Erstattung beantragt. + Dasselbe Fahrzeug erscheint mehrfach. + Der Genehmiger des Antrags fehlt. + Das verknüpfte F-42 wurde nicht als in MARS eingereicht beobachtet. + Der Spesenantrag hat keine Zeilen. + Eine Spesenzeile hat keinen Beleg. + Die Antragsunterschrift fehlt. + Die Einsatz-/AREP-Autorisierung fehlt. + Einsatzname oder -nummer fehlt. + Die MACS-Kennung fehlt. + Die Auftragsnummer fehlt. + Kein unterzeichnetes oder Papier-F-42 ist dem Einsatz beigefügt. + Das Bindungsintervall einer Person liegt außerhalb des Alarmierungs-/Rückkehrfensters. + Kein Personal aufgeführt. + Für das Einsatzdatum sind keine Jahresratenzeilen gültig. + Die Ressource ist freigegeben, aber keine Rückkehr- oder Redispatch-Zeit erfasst. + Die Anforderungsnummer fehlt. + Die Anforderungsnummer hat kein gültiges Präfix (E, O, C, S, A) und keine Nummer. + Ressourcentyp, -art oder Overhead-Position fehlt. + Die Unterschrift der Behörde fehlt. + Die Rückkehrzeit liegt vor der Alarmierung. + Ein Mannschaftswechsel hat keinen Genehmigungsanhang. + Ein Fahrzeug hat keine F-5-Ressourcenzuordnung. + Wert + Abweichung + Verifizierung + Profil nach Abgleich mit dem Behördendatensatz in MARS als geprüft markieren. Die Bereitschaftsübersicht warnt nach einem Jahr. + Geprüft am + Version + Warnung + Zeitraum + Offizielle Feldreihenfolge, Quellverweise auf die unveränderlichen Einsatzfakten, Checkliste, erwartete Erstattung und Portalübergabe. + Jahr + Ja + Eingabebeträge dürfen nicht negativ sein. + Eine Eingabeklassifikation ist ungültig. + Ein Eingabe-Geschäftsjahr ist ungültig (nur Vorjahre). + Der Behördenname ist erforderlich. + Das Behördenprofil wurde nicht gefunden. + Die Vereinbarung wird von einem Vorgang referenziert und kann nicht gelöscht werden. + Die Dokumentart der Vereinbarung ist ungültig. + Die Vergütungsmethode ist ungültig. + Die Vereinbarung wurde nicht gefunden. + Die Überstundenmethode ist ungültig. + Bestätigen Sie die Zusicherung, bevor Sie die Übergabeansicht öffnen. + Das Enddatum liegt vor dem Startdatum. + Die Entscheidung braucht Name/Titel der entscheidenden Person. + Der Einsatz wurde nicht gefunden. + Das verknüpfte F-42 wurde bei diesem Einsatz nicht gefunden. + Diese Anforderung/dieser Fill gehört nicht zur externen Anforderung des Einsatzes. + Die Anforderung hat mehrere Requests; wählen Sie den Request/Fill für dieses F-42. + Der Rechnungsbetrag darf nicht negativ sein. + Eine MARS-Rechnung mit dieser ID ist bereits erfasst. + Die MARS-Rechnungs-ID ist erforderlich. + Vor einer Zahlung die lokale Freigabe erfassen. + Die Rechnung ist nicht in einem Zustand, in dem eine Zahlung erfasst werden kann. + Die Rechnung wartet nicht auf lokale Freigabe. + Der Datensatz ist nicht „Bereit für Portal“; zuerst die Checkliste ausführen. + Der gezahlte Betrag darf nicht negativ sein. + Die Einreichung hat keine Zeilen, keinen Verwaltungssatz und keinen akzeptierten Basissatz. + Eine Zeilenbasis ist ungültig. + Gehaltszeilen brauchen einen Klassifikationscode. + Eine Zeilenart ist ungültig. + Raten dürfen nicht negativ sein. + Ausrüstungszeilen brauchen einen Ressourcen- oder FEMA-Code. + Diese Einreichung ist unterzeichnet oder eingereicht und kann nicht mehr bearbeitet werden. + Die Jahreseinreichung wurde nicht gefunden. + Vor einer externen Statusbeobachtung die lokale Unterschrift erfassen. + Der Verwaltungssatz muss zwischen 0 und 100 Prozent liegen. + Zum Unterzeichnen ist der Name des Unterzeichners nötig. + Diese Statusänderung ist nicht zulässig. + Der Einreichungstyp ist ungültig. + Das Einreichungsjahr ist ungültig. + Eine Ablehnung braucht einen Kommentar. + Eine externe Ressource braucht einen Namen. + Die Ressourcenzeile wurde nicht gefunden. + Der Objekttyp der Ressource ist ungültig. + Die Einheit wurde in dieser Abteilung nicht gefunden. + Für eine Einheitenressource ist eine Einheit erforderlich. + Dieser externe Status ist nicht im Vokabular des Referenzprofils. + Der Datensatz wurde in MARS beobachtet und ist lokal nicht mehr bearbeitbar. + Nur F-42 und Spesenanträge haben eine erwartete Erstattung. + Nur genehmigte, Nur-Dokumentation-, abgelehnte oder bezahlte Datensätze können geschlossen werden. + Der Datensatz wurde nicht als in MARS eingereicht beobachtet. + Der Datensatz wurde nicht gefunden. + Der Datensatz ist keine MARS-Rechnung. + Nur F-42 und Spesenanträge werden bei MARS eingereicht. + Der Datensatz ist bezahlt oder geschlossen; die Zeilen sind endgültig. + Der Datensatz hat nicht den erwarteten Typ. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx new file mode 100644 index 00000000..c4bdb8cb --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Ουρά ενεργειών συμβάντος + Ενεργό + Πραγματικό ποσό + Πραγματικές ώρες (DTR) + Προσθήκη συμφωνίας + Προσθήκη εισόδου + Προσθήκη γραμμής + Προσθήκη πόρου + Προσθήκη εναλλαγής + Διεύθυνση + Πιθανές διπλές καταμετρήσεις εκκρεμούν ακόμη για έλεγχο. + Ορισμένες είσοδοι εκκρεμούν ακόμη για έλεγχο. + Καμία επιτρεπτή βάση άμεσου κόστους· το ποσοστό δεν υπολογίζεται. + Δεν υπάρχουν είσοδοι πραγματικού κόστους· η επιλογή de minimis είναι η μόνη. + Διοικητική μέθοδος + Υπολογισμένο από πραγματικά + De minimis + Καμία + Είσοδοι διοικητικού ποσοστού + Πραγματικά κόστη προηγούμενου έτους ανά λειτουργία και κατηγορία, ταξινομημένα άμεσα / έμμεσα / μη επιτρεπτά. Οι προϋπολογισμοί δεν μπαίνουν ποτέ εδώ. + Διοικητικό ποσοστό + Φύλλο διοικητικού ποσοστού + Επιτρεπτό έμμεσο ÷ επιτρεπτό άμεσο από τις αποδεκτές εισόδους, σε σύγκριση με την επιλογή de minimis. Οι άλυτες διπλές καταμετρήσεις μπλοκάρουν το αποτέλεσμα. + Ηλικία + Υπηρεσία + Κατηγορία υπηρεσίας + Η εγγραφή υπηρεσίας MARS: προσδιοριστικό MACS, επαφές και αναγνωριστικά που τυπώνονται σε κάθε αίτημα. + Όνομα υπηρεσίας + Προφίλ υπηρεσίας + Συμφωνία + Συμφωνίες + Εγκεκριμένες μέθοδοι αποζημίωσης MOU / MOA / GBR ανά ταξινόμηση. Ένα F-42 επιλέγει όση ίσχυε στην αρχική αποστολή. + Όλες οι ταξινομήσεις + Όλοι οι τύποι + Όλα τα έτη + Επιτρεπτό άμεσο + Επιτρεπτό έμμεσο + Ποσό + Ετήσιες τιμές + Ετήσιες υποβολές σε ισχύ + Συνημμένο έγκρισης + Έγκριση + Εγκρίνων + Αναγνωριστικό συνημμένου ανάπτυξης + Προαιρετικό: η υπογεγραμμένη συμφωνία που μεταφορτώθηκε ως συνημμένο ανάπτυξης. + Αρχή + Ειδικός εξοπλισμός υπηρεσίας + Υποβλήθηκε από υπηρεσία + Βασική τιμή Cal OES + Rate Letter Cal OES + Πίνακας FEMA + Προφίλ αρχής + Κανένα ελεγμένο προφίλ δεν καλύπτει την ημερομηνία + Πίσω + Αποδεκτή βασική τιμή Cal OES + Η υπηρεσία δέχεται τη βασική τιμή Cal OES αντί δικών της γραμμών έρευνας. + Βάση + Ημερήσια + Εφάπαξ + Ωριαία + Ανά μίλι + Ποσοστό + Αποκλεισμένο + Εμπόδιο + Πεδίο + Υπηρεσία ανταπόκρισης + Οχήματα, υποστήριξη και εξοπλισμός + Συνημμένα + Σχόλια, απώλεια / ζημιά, αριθμοί εφοδίων + Αποστολή / δέσμευση + Συμβάν + Αριθμός παραγγελίας συμβάντος + Προσωπικό + Αριθμός αιτήματος + Πόρος + Επιστροφή / επαναποστολή + Εναλλαγές πληρώματος + Υπογραφές και εξουσιοδότηση + Υπολογισμός διοικητικού ποσοστού + Cal OES MARS + Υπολογισμός + Υπολογισμένο ποσοστό + Ακύρωση + Κατηγορία + Κατηγορία + Διαμονή + Γεύμα + Διάφορα + Ενοικίαση + Έλεγχος ημερομηνίας + Λίστα ελέγχου + Άθροισμα ελέγχου + Πόλη + Ταξινόμηση + Τίτλος ταξινόμησης + Κλείσιμο + Σχόλιο + Σχόλια + Δεσμευμένο + Δεσμευμένες ώρες + Μέθοδος αποζημίωσης + Πραγματικές ώρες + Portal-to-portal + Διαγραφή αυτής της εγγραφής; + E-mail επαφής + Όνομα επαφής + Τηλέφωνο επαφής + Αντιγραφή + Σχόλιο ελεγκτή + Άμεσο + Έμμεσο + Μη επιτρεπτό + Με μια ματιά + Καλυπτόμενες εγγραφές + Υποβληθείσες εγγραφές F-42 / δαπανών που πληρώνει το τιμολόγιο· τα αναμενόμενα σύνολά τους συγκρίνονται με το τιμολογημένο ποσό. + Ημερομηνία + Επιλογή de minimis + Απόφαση από (όνομα / τίτλος) + Διαγραφή + Ανάπτυξη + Περιγραφή + Προσδιοριστικό + Λεπτομέρεια + Είδος εγγράφου + Ισοδύναμο + GBR + MOA + MOU + Μόνο τεκμηρίωση + Η εγγραφή τεκμηριώνει την ανταπόκριση χωρίς αίτημα αποζημίωσης (διαδρομή Documentation Only του MARS). + Πιθανή διπλή καταμέτρηση + Πρόχειρο από μονάδες + Επιλέξτε μονάδες και δημιουργήστε γραμμές για όσες δεν έχουν. + Επεξεργασία υπηρεσίας + Επεξεργασία συμφωνίας + Επεξεργασία πόρου + Ισχύει από + Επιλέξιμο + Εξαιρείται + Αβέβαιο + Λήξη + Πακέτο τεκμηρίων + Εξαιρέθηκε (χρεώθηκε σε συμβάντα) + Εξαιρέθηκε (μη επιτρεπτό) + Αναμενόμενο + Αναμενόμενη αποζημίωση + Εκτίμηση από τις τιμές και τη συμφωνία σε ισχύ στην αρχική αποστολή. Το Cal OES ορίζει το επιτρεπόμενο ποσό· κανένα εσωτερικό κόστος δεν μπαίνει στις γραμμές. + Αναμενόμενο σύνολο + Αναμενόμενο έναντι παρατηρούμενου + Λήγει στις + Όνομα εξωτερικού πόρου + Υπηρεσία + Οχήματα + Συνημμένα + Σχόλια + Αποστολή + Συμβάν + Εξουσιοδότηση συμβάντος + Γραμμές δαπανών + Παραγγελία + Προσωπικό + Αίτημα + Πόρος + Υπογραφή υπηρεσίας + Επιστροφή + Εναλλαγή + Υπογραφή + FEIN + Αίτημα / fill + Αναγνωριστικό fill εξωτερικής παραγγελίας RMS (απαιτείται όταν η παραγγελία έχει πάνω από ένα αίτημα) + Προμηθευτής FI$Cal + Διόρθωση + Λειτουργία + Δημιουργήθηκε + Είμαι το εξουσιοδοτημένο άτομο που καταχωρεί την εγγραφή στο MARS και θα αντιγράψω τις εμφανιζόμενες τιμές, όχι το αρχείο της σελίδας. + Προβολή αντιγραφής για χειροκίνητη καταχώρηση στην πύλη MARS. Δεν αποθηκεύεται, δεν αποθηκεύεται προσωρινά και δεν είναι υποβολή. + Εκτελέστε τη λίστα ελέγχου μέχρι να είναι καθαρή· η παράδοση ανοίγει από το Έτοιμο για πύλη. + Προετοιμασμένο για MARS. Το άνοιγμα καταγράφει εγγραφή ελέγχου και δεν αλλάζει τίποτα· η εγγραφή γίνεται Παρατηρήθηκε στο MARS μόνο όταν διαχειριστής καταγράψει τι δείχνει η πύλη. + Η λίστα ελέγχου έχει ακόμη σφάλματα· το MARS μπορεί να επιστρέψει την εγγραφή. + Οι τιμές αντιγράφονται σε F-42 και αιτήματα και αποθηκεύονται ως δεδομένα (εκτός Advanced Data Protection). Η αλλαγή αναγνωριστικού μηδενίζει την τελευταία επαλήθευση. + Αφήστε την ταξινόμηση κενή για συμφωνία σε επίπεδο τμήματος. Συμφωνία που αναφέρεται από υποβληθείσα εγγραφή είναι αμετάβλητη· η επεξεργασία δημιουργεί νέα έκδοση. + Τα μέλη πεδίου βλέπουν τα δικά τους πρόχειρα· οι διαχειριστές MARS την ουρά του τμήματος. Το άνοιγμα παράδοσης ή η λήψη πακέτου δεν σημαίνει ποτέ υποβολή. + Οι τιμές είναι δεδομένα δεσμευμένα σε ελεγμένο προφίλ αρχής· ένα νέο Rate Letter ή έρευνα είναι νέο στιγμιότυπο, και μια προηγούμενη αποστολή κρατά όσο ίσχυε στην έναρξή της. + Τα εμπόδια αποτρέπουν τα νέα F-42 από το να φτάσουν σε Έτοιμο για πύλη· οι προειδοποιήσεις αξίζει να διορθωθούν πριν την επόμενη αποστολή. + Το τιμολόγιο MARS είναι στοιχείο εργασίας, ποτέ τιμολόγιο πελάτη Φάσης Β: χωρίς αριθμό, χωρίς παλαίωση, χωρίς e-mail. Το Πληρώθηκε είναι πάντα μόνο παρατηρούμενο γεγονός πληρωμής. + Οι πρόχειρες γραμμές αντιγράφουν όνομα, πινακίδα και VIN της μονάδας εκείνη τη στιγμή· ελέγξτε τις, προσθέστε τύπο πόρου MARS και καταγράψτε τι δείχνει το MARS. + Ώρες + Αναγνωριστικά + Εξουσιοδοτών συμβάντος / AREP + Χρεώθηκε απευθείας σε συμβάν (εξαιρείται) + Περιλαμβάνει ασφάλιση ανεργίας + Περιλαμβάνει ασφάλιση εργατικών ατυχημάτων + Εισερχόμενος + Ημερομηνία τιμολογίου + Αναμενόμενο έναντι παρατηρούμενου, η τοπική απόφαση και τα γεγονότα πληρωμής για ένα τιμολόγιο MARS. + Τιμολογήθηκε + Τιμολόγια σε αναμονή τοπικής έγκρισης + Στοιχείο + Είδος + Όχημα + Ειδικός εξοπλισμός + Ιδιωτικό όχημα + Όχημα υποστήριξης + Πινακίδα + Είδος γραμμής + Διοικητικό + Διοικητικό ποσοστό + Όχημα + Παράρτημα Α (μη κατάσβεση) + Δαπάνη + Γεύματα, διαμονή, έξοδα + Επίσημο όχημα + Επίσημο όχημα υποστήριξης + Προσωπικό + Χιλιόμετρα POV + Χιλιόμετρα POV + Ενοικίαση + Έρευνα μισθών + Ειδικός εξοπλισμός + Όχημα υποστήριξης + Συνδεδεμένο F-42 + Απόφαση τοπικής υπηρεσίας + Εγκρίνετε ή απορρίψτε το τιμολόγιο ως εξουσιοδοτημένος εκπρόσωπος· η απόρριψη απαιτεί σχόλιο. Η απόφαση καταγράφεται εδώ και καταχωρείται στο MARS από εσάς. + Απώλεια / ζημιά + Προσδιοριστικό MACS + Διαχείριση αποζημίωσης Cal OES MARS + Εγγραφές υπηρεσίας, πόρων F-5, ετήσιων τιμών και συμφωνιών, παράδοση στην πύλη, παρατηρούμενες καταστάσεις MARS και έγκριση τιμολογίων/συμφωνία πληρωμών. Τα μέλη της σύνθεσης ετοιμάζουν τα δικά τους πρόχειρα F-42 και δαπανών χωρίς αυτό. + Σήμανση ως επαληθευμένο σήμερα + Τιμολόγιο MARS + Αναγνωριστικό τιμολογίου MARS + Αυτό είναι στοιχείο εργασίας MARS. Δεν έχει αριθμό τιμολογίου Φάσης Β, δεν εμφανίζεται ποτέ στην παλαίωση πελατών και δεν στέλνεται με e-mail ούτε πληρώνεται online. + Τιμολόγια MARS + Αναγνωριστικό εγγραφής MARS + Αναγνωριστικό πόρου MARS + Επιλεγμένη μέθοδος + Μίλια + Στη σύνθεση αυτής της ανάπτυξης + αναντιστοιχίες + Όνομα + Νέα υποβολή + Όχι + Δεν υπάρχει ακόμη προφίλ υπηρεσίας. + Δεν υπάρχουν ακόμη συμφωνίες. + Χωρίς συνημμένα ανάπτυξης. + Δεν υπάρχουν αναπτύξεις ανάκτησης κόστους για προετοιμασία. + Το Resgrid δεν αποθηκεύει διαπιστευτήρια MARS, διακριτικά MFA ή συνεδρίες προγράμματος περιήγησης και δεν γράφει ποτέ στην πύλη. + Καμία ετήσια υποβολή δεν καλύπτει την ημερομηνία. + Χωρίς ανάπτυξη + Δεν έχουν καταγραφεί τιμολόγια MARS. + Τίποτα στην ουρά. + Δεν υπάρχουν ακόμη ετήσιες υποβολές. + Όλα όσα χρειάζονται για υποβολή MARS είναι έτοιμα. + Δεν υπάρχουν ακόμη γραμμές πόρων F-5. + Το πακέτο τεκμηρίων δεν είναι αποδεκτό αρχείο εισαγωγής MARS. + Δεν έχει υπολογιστεί ακόμη. + Μη τρέχον + Εκτός απογραφής F-5 + Δεν έχει επικυρωθεί ακόμη. + Παρατηρήθηκε στο MARS + Παρατηρήθηκε στις + Παρατηρούμενη κατάσταση + Παρατηρούμενη υποβολή + Οδόμετρο + Επίσημες πηγές + Άνοιγμα + Άνοιγμα ανάπτυξης + Άνοιγμα προβολής παράδοσης + Άνοιγμα πύλης MARS + Ανοιχτά στοιχεία + Εξερχόμενος + Θέση overhead + Επιλέξιμο για υπερωρίες + Μέθοδος υπερωριών + Μετά από 8 ώρες ημερησίως + Μετά από 12 ώρες ημερησίως + Καμία + Κατά συμφωνία (χωρίς μοντέλο) + Υπερωριακή τιμή + Ιδιοκτησία + CAL FIRE + Cal OES + Τοπική υπηρεσία + Άλλο + Ιδιωτική + Ενοικίαση + Πληρώθηκε + Πληρώθηκε στις + Φορέας πληρωμής + Κατάσταση φορέα πληρωμής + Αναφορά πληρωμής + Παράδοση στην πύλη + Αναφορά λογαριασμού πύλης + Ετικέτα για τον λογαριασμό MARS (ποτέ κωδικός ή διακριτικό). + Ρόλος πύλης + Επιλέξιμο portal-to-portal + Προεγκεκριμένο + Προετοιμασία αιτήματος δαπανών + Προετοιμασία F-42 + Προετοιμασία εγγραφής + Ένα F-42 ανά παραγγελθέντα πόρο / αίτημα· μια επαναποστολή αντικαθιστά το προηγούμενο. Τα αιτήματα δαπανών συνδέονται με F-42 ή ακολουθούν τη διαδρομή μόνο ταξιδιού. + Προετοιμασμένο για MARS — τίποτα δεν υποβάλλεται μέχρι ένας διαχειριστής να καταγράψει ό,τι παρατηρήθηκε στην πύλη. + Εκτυπώσιμο + Προέλευση + F-42 και αιτήματα δαπανών για προετοιμασία, επικύρωση και παράδοση, ομαδοποιημένα ανά ανάπτυξη· δίπλα οι παρατηρούμενες καταστάσεις MARS και τα τιμολόγια. + Βαθμός + Επικεφαλίδα, γραμμές και (για το διοικητικό ποσοστό) το φύλλο πραγματικού κόστους προηγούμενου έτους. + Γραμμές τιμών + Οι μισθολογικές γραμμές χρειάζονται ταξινόμηση· οι γραμμές εξοπλισμού κωδικό πόρου ή FEMA. Οι τιμές είναι κανονικές / υπερωριακές ανά βάση. + Η υποβολή είναι υπογεγραμμένη ή υποβληθείσα· οι αλλαγές απαιτούν νέα έκδοση. + Έκδοση προφίλ τιμών + Αποδεκτό + Πρόχειρο + Ελεγμένο + Υπογεγραμμένο τοπικά + Υποβλήθηκε στο MARS + Αντικαταστάθηκε + Salary Survey, Παράρτημα Α, διοικητικό ποσοστό, Rate Letter Cal OES και ειδικός εξοπλισμός ανά έτος υποβολής. + Δεν έχει καταγραφεί διοικητικό ποσοστό για την ημερομηνία· δεν θα εκτιμηθεί διοικητική γραμμή. + Το προφίλ υπηρεσίας (προσδιοριστικό MACS, επαφές, αναγνωριστικά) δεν έχει καταχωρηθεί. + Το προφίλ υπηρεσίας δεν έχει επαληθευτεί με το MARS τον τελευταίο χρόνο. + Μια συμφωνία λήγει εντός του περιθωρίου. + Καμία μέθοδος αποζημίωσης MOU/MOA/GBR δεν καλύπτει την ημερομηνία· το προσωπικό εκτιμάται σε πραγματικές ώρες χωρίς υπερωρίες. + Ετοιμότητα έως + Κανένα ελεγμένο προφίλ αρχής Cal OES δεν καλύπτει την ημερομηνία· οι νέες υποβολές μπλοκάρονται μέχρι να προστεθεί. + Πίνακας ετοιμότητας + Δεν έχει καταγραφεί FEIN για την υπηρεσία. + Δεν έχει καταγραφεί αναγνωριστικό προμηθευτή FI$Cal. + Ετοιμότητα υπηρεσίας, πόρων F-5, ετήσιων τιμών και συμφωνιών για μια ημερομηνία αποστολής, με συνδέσμους στο επίσημο υλικό Cal OES. + Τιμολόγια που δημιούργησε το MARS αναμένουν την τοπική απόφαση. + Λείπει το προσδιοριστικό MACS της υπηρεσίας. + Δεν υπάρχουν γραμμές αντιστοίχισης F-5· τα οχήματα σε F-42 θα επισημανθούν ως εκτός απογραφής. + Μια ετήσια υποβολή λήγει εντός του περιθωρίου. + Καμία γραμμή Rate Letter του Cal OES (οχήματα, υποστήριξη, χιλιόμετρα POV) δεν καλύπτει την ημερομηνία. + Μια ετήσια υποβολή σε ισχύ δεν έχει υπογραφεί τοπικά. + Γραμμές πόρων F-5 δεν ταιριάζουν με όσα δείχνει το MARS. + Εγγραφές που επέστρεψε το Cal OES για έλεγχο υπηρεσίας εκκρεμούν. + Καμία ελεγμένη έρευνα μισθών (ή αποδεκτή βασική τιμή) δεν καλύπτει την ημερομηνία· οι γραμμές προσωπικού δεν εκτιμώνται. + Δεν έχει καταγραφεί SAM UEI για την υπηρεσία. + Έτοιμο + Έτοιμο για την πύλη. + Απόδειξη + Συμφωνία τιμολογίων και πληρωμών + Τιμολόγια που δημιούργησε το MARS όπως παρατηρήθηκαν, η τοπική απόφαση έγκρισης / απόρριψης, η κατάσταση φορέα πληρωμής και τα γεγονότα πληρωμής έναντι των αναμενόμενων γραμμών. + Καταγραφή τιμολογίου MARS + Καταγράψτε το τιμολόγιο που δημιούργησε το Cal OES όπως το βλέπετε στην πύλη και τις υποβληθείσες εγγραφές που καλύπτει. + Καταγραφή παρατήρησης + Καταγραφή πληρωμής + Μόνο μια παρατηρούμενη πληρωμή του φορέα πληρωμής σημειώνει το τιμολόγιο και τις εγγραφές του ως Πληρώθηκε. + Καταγραφή υποβολής που παρατηρήθηκε στο MARS + Τύπος εγγραφής + Διοικητικό ποσοστό + Προφίλ υπηρεσίας + Συμφωνία + Παράρτημα Α + Αίτημα δαπανών + F-42 + Τιμολόγιο MARS + Απογραφή F-5 + Έρευνα μισθών + Ειδικός εξοπλισμός + Καταγεγραμμένο + Επαναποστολή του + Απόρριψη + Σχετικές εγγραφές + Η αποδέσμευση δεν είναι επιστροφή· το F-42 χρειάζεται ώρα επιστροφής ή επαναποστολής. + Αποδεσμεύτηκε + Τόπος αναφοράς + Αίτημα + Κωδικός πόρου + Απογραφή πόρων F-5 + Είδος πόρου + Πόροι F-5 + Τύπος πόρου + Αντιστοίχιση μονάδων και παγίων Resgrid με την ταυτότητά τους στο MARS / F-5. Προετοιμάζει και συμφωνεί την απογραφή· δεν γράφει ποτέ κατάσταση FAST. + Υπογράφων υπηρεσίας + Επιστράφηκαν για έλεγχο + Αιτία + Κατάσταση ελέγχου + Πρόχειρο + Αναντιστοιχία + Παρατηρήθηκε στο MARS + Ελεγμένο + Έλεγχος + Αποδεκτό + Εξαιρέθηκε + Εκκρεμεί + ελέγχθηκε + Εκτέλεση λίστας ελέγχου + Εγγραφή SAM + Αποθήκευση + Η αλλαγή δεν αποθηκεύτηκε. + Αποθήκευση εισόδων + Αποθήκευση γραμμών + Αποθηκεύτηκε. + Σειριακός αριθμός + Παρατηρήθηκε αποδοχή + Πίσω σε πρόχειρο + Σήμανση ως ελεγμένο + Τοπική υπογραφή + Παρατηρήθηκε υποβολή στο MARS + Αντικατάσταση + Σοβαρότητα + Υπογράφηκε από (όνομα) + Υπογράφηκε στις + Υπογράφων + Πηγή + Τεκμήριο πηγής + Ημερομηνία πηγής + Γραμμή πηγής + Σύστημα πηγής + URL πηγής + Έναρξη + Κατάσταση + Εγκρίθηκε + Κλειστό + Μόνο τεκμηρίωση + Πρόχειρο + Απορρίφθηκε από τοπική υπηρεσία + Χρειάζεται έλεγχο + Πληρώθηκε + Εκκρεμεί τοπική έγκριση + Εκκρεμεί ο φορέας πληρωμής + Έτοιμο για πύλη + Επιστράφηκε για έλεγχο υπηρεσίας + Παρατηρήθηκε στο MARS (έλεγχος Cal OES) + Κατάσταση + Κανονική τιμή + Strike team / task force + Αντικείμενο + Τύπος αντικειμένου + Εξωτερικός πόρος + Πάγιο απογραφής + Μονάδα + Υποβολή + Τύπος υποβολής + Διοικητικό ποσοστό + Παράρτημα Α (μη κατάσβεση) + Rate Letter Cal OES + Έρευνα μισθών + Ειδικός εξοπλισμός / κωδικοί FEMA + αντικαθιστά + Αριθμοί εφοδίων + Υποστηρικτικά έγγραφα + Υπηρεσία + Συμφωνίες + Ουρά ενεργειών + Ετήσιες τιμές + Ετοιμότητα + Συμφωνία + Πόροι F-5 + Μόνο ταξίδι (χωρίς F-42) + SAM UEI + Μονάδα + Προσδιοριστικό μονάδας + Επικυρώθηκε + Λίστα ελέγχου: {0} σφάλμα(τα), {1} προειδοποίηση(εις). + Λείπει το προφίλ υπηρεσίας. + Καμία συμφωνία MOU / MOA / GBR δεν καλύπτει την ημερομηνία αποστολής. + Κανένα ελεγμένο προφίλ αρχής δεν καλύπτει την εγγραφή. + Η ταξινόμηση ατόμου δεν υπάρχει στην έρευνα μισθών ούτε στη λίστα αναφοράς. + Λείπει η ώρα αποστολής. + Σημειώθηκε Μόνο τεκμηρίωση: δεν ζητείται αποζημίωση. + Το ίδιο όχημα εμφανίζεται πάνω από μία φορά. + Λείπει ο εγκρίνων του αιτήματος. + Το συνδεδεμένο F-42 δεν παρατηρήθηκε υποβληθέν στο MARS. + Το αίτημα δαπανών δεν έχει γραμμές. + Μια γραμμή δαπάνης δεν έχει απόδειξη. + Λείπει η υπογραφή του αιτήματος. + Λείπει η εξουσιοδότηση συμβάντος / AREP. + Λείπει όνομα ή αριθμός συμβάντος. + Λείπει το προσδιοριστικό MACS. + Λείπει ο αριθμός παραγγελίας συμβάντος. + Δεν έχει επισυναφθεί υπογεγραμμένο ή έντυπο F-42 στην ανάπτυξη. + Το διάστημα δέσμευσης ατόμου είναι εκτός του παραθύρου αποστολής-επιστροφής. + Δεν αναφέρεται προσωπικό. + Καμία γραμμή ετήσιων τιμών δεν ισχύει για την ημερομηνία αποστολής. + Ο πόρος αποδεσμεύτηκε αλλά δεν καταγράφηκε ώρα επιστροφής ή επαναποστολής. + Λείπει ο αριθμός αιτήματος. + Ο αριθμός αιτήματος δεν έχει έγκυρο πρόθεμα (E, O, C, S, A) και αριθμό. + Λείπει τύπος, είδος πόρου ή θέση overhead. + Λείπει η υπογραφή της υπηρεσίας ανταπόκρισης. + Η ώρα επιστροφής προηγείται της αποστολής. + Μια εναλλαγή πληρώματος δεν έχει συνημμένο έγκρισης. + Ένα όχημα δεν έχει γραμμή αντιστοίχισης F-5. + Τιμή + Απόκλιση + Επαλήθευση + Σημειώστε το προφίλ ως επαληθευμένο μετά τη σύγκριση με την εγγραφή υπηρεσίας στο MARS. Ο πίνακας προειδοποιεί μετά από ένα έτος. + Επαληθεύτηκε στις + Έκδοση + Προειδοποίηση + Περίοδος + Επίσημη σειρά πεδίων, σύνδεσμοι στα αμετάβλητα γεγονότα ανάπτυξης, λίστα ελέγχου, αναμενόμενη αποζημίωση και παράδοση στην πύλη. + Έτος + Ναι + Τα ποσά εισόδου δεν μπορούν να είναι αρνητικά. + Μια ταξινόμηση εισόδου δεν είναι έγκυρη. + Ένα οικονομικό έτος εισόδου δεν είναι έγκυρο (μόνο προηγούμενα έτη). + Το όνομα υπηρεσίας απαιτείται. + Το προφίλ υπηρεσίας δεν βρέθηκε. + Η συμφωνία αναφέρεται από στοιχείο εργασίας και δεν διαγράφεται. + Το είδος εγγράφου συμφωνίας δεν είναι έγκυρο. + Η μέθοδος αποζημίωσης δεν είναι έγκυρη. + Η συμφωνία δεν βρέθηκε. + Η μέθοδος υπερωριών δεν είναι έγκυρη. + Επιβεβαιώστε τη βεβαίωση πριν ανοίξετε την προβολή παράδοσης. + Η ημερομηνία λήξης προηγείται της έναρξης. + Η απόφαση απαιτεί όνομα / τίτλο του αποφασίζοντος. + Η ανάπτυξη δεν βρέθηκε. + Το συνδεδεμένο F-42 δεν βρέθηκε σε αυτή την ανάπτυξη. + Αυτό το αίτημα / fill δεν ανήκει στην εξωτερική παραγγελία της ανάπτυξης. + Η παραγγελία έχει πολλά αιτήματα· επιλέξτε αίτημα / fill για αυτό το F-42. + Το τιμολογημένο ποσό δεν μπορεί να είναι αρνητικό. + Τιμολόγιο MARS με αυτό το αναγνωριστικό έχει ήδη καταγραφεί. + Το αναγνωριστικό τιμολογίου MARS απαιτείται. + Καταγράψτε την τοπική έγκριση πριν από πληρωμή. + Το τιμολόγιο δεν είναι σε κατάσταση που επιτρέπει καταγραφή πληρωμής. + Το τιμολόγιο δεν εκκρεμεί για τοπική έγκριση. + Η εγγραφή δεν είναι Έτοιμη για πύλη· εκτελέστε πρώτα τη λίστα ελέγχου. + Το πληρωμένο ποσό δεν μπορεί να είναι αρνητικό. + Η υποβολή δεν έχει γραμμές, διοικητικό ποσοστό ή αποδεκτή βασική τιμή. + Μια βάση γραμμής τιμής δεν είναι έγκυρη. + Οι μισθολογικές γραμμές χρειάζονται κωδικό ταξινόμησης. + Ένα είδος γραμμής τιμής δεν είναι έγκυρο. + Οι τιμές δεν μπορούν να είναι αρνητικές. + Οι γραμμές εξοπλισμού χρειάζονται κωδικό πόρου ή FEMA. + Η υποβολή είναι υπογεγραμμένη ή υποβληθείσα και δεν επεξεργάζεται πλέον. + Η ετήσια υποβολή δεν βρέθηκε. + Καταγράψτε την τοπική υπογραφή πριν παρατηρήσετε εξωτερική κατάσταση. + Το διοικητικό ποσοστό πρέπει να είναι μεταξύ 0 και 100 τοις εκατό. + Η υπογραφή απαιτεί το όνομα του υπογράφοντος. + Αυτή η αλλαγή κατάστασης δεν επιτρέπεται. + Ο τύπος υποβολής δεν είναι έγκυρος. + Το έτος υποβολής δεν είναι έγκυρο. + Η απόρριψη απαιτεί σχόλιο. + Ο εξωτερικός πόρος χρειάζεται όνομα. + Η γραμμή πόρου δεν βρέθηκε. + Ο τύπος αντικειμένου πόρου δεν είναι έγκυρος. + Η μονάδα δεν βρέθηκε σε αυτό το τμήμα. + Απαιτείται μονάδα για πόρο μονάδας. + Αυτή η εξωτερική κατάσταση δεν ανήκει στο λεξιλόγιο του προφίλ αρχής. + Η εγγραφή παρατηρήθηκε στο MARS και δεν επεξεργάζεται πλέον τοπικά. + Μόνο τα F-42 και τα αιτήματα δαπανών έχουν αναμενόμενη αποζημίωση. + Μόνο εγκεκριμένες, μόνο τεκμηρίωσης, απορριφθείσες ή πληρωμένες εγγραφές κλείνουν. + Η εγγραφή δεν παρατηρήθηκε υποβληθείσα στο MARS. + Η εγγραφή δεν βρέθηκε. + Η εγγραφή δεν είναι τιμολόγιο MARS. + Μόνο F-42 και αιτήματα δαπανών υποβάλλονται στο MARS. + Η εγγραφή είναι πληρωμένη ή κλειστή· οι γραμμές της είναι οριστικές. + Η εγγραφή δεν είναι του αναμενόμενου τύπου. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx new file mode 100644 index 00000000..172d91ab --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Incident action queue + Active + Actual amount + Actual hours (DTRs) + Add agreement + Add input + Add line + Add resource + Add rotation + Address + Possible double counts are still pending review. + Some inputs are still pending review. + No allowable direct cost base; the rate cannot be calculated. + No actual-cost inputs; the de-minimis option is the only choice. + Administrative method + Calculated from actuals + De minimis + None + Administrative-rate inputs + Prior-year actuals by function and category, classified direct / indirect / unallowable. Budgets never enter here. + Administrative rate + Administrative-rate worksheet + Allowable indirect ÷ allowable direct from the accepted inputs, compared with the de-minimis option. Unresolved double-count flags block the result. + Age + Agency + Agency category + The department's MARS agency record: MACS designator, contacts and the identifiers printed on every claim. + Agency name + Agency profile + Agreement + Agreements + Approved MOU / MOA / GBR compensation methods by classification. An F-42 selects the one in effect at initial dispatch. + All classifications + All types + All years + Allowable direct + Allowable indirect + Amount + Annual rates + Annual submissions in effect + Approval attachment + Approve + Approver + Deployment attachment id + Optional: the signed agreement uploaded as a deployment attachment. + Authority + Agency special equipment + Agency submitted + Cal OES base rate + Cal OES Rate Letter + FEMA schedule + Authority profile + No reviewed profile covers this date + Back + Cal OES base rate accepted + The agency takes the Cal OES base rate instead of submitting its own survey rows. + Basis + Daily + Flat + Hourly + Per mile + Percent + Blocked + Blocker + Box + Responding agency + Apparatus, support vehicles and equipment + Attachments + Comments, loss / damage, supply numbers + Dispatch / commitment + Incident + Incident order number + Personnel + Request number + Resource + Return / redispatch + Crew rotations + Signatures and authorization + Build administrative rate + Cal OES MARS + Calculate + Calculated rate + Cancel + Category + Category + Lodging + Meal + Miscellaneous + Rental + Check date + Checklist + Checksum + City + Classification + Classification title + Close + Comment + Comments + Committed + Committed hours + Compensation method + Actual hours + Portal to portal + Delete this record? + Contact e-mail + Contact name + Contact phone + Copy + Reviewer comment + Direct + Indirect + Unallowable + At a glance + Covered records + Submitted F-42 / expense records this invoice pays for; their expected totals are compared with the invoiced amount. + Date + De-minimis option + Decision by (name / title) + Delete + Deployment + Description + Designator + Detail + Document kind + Equivalent + GBR + MOA + MOU + Documentation only + The record documents the response without claiming reimbursement (the MARS Documentation Only path). + Possible double count + Draft from units + Select units and create crosswalk rows for the ones that have none. + Edit agency + Edit agreement + Edit resource + Effective on + Eligible + Excluded + Uncertain + End + Evidence packet + Excluded (billed to incidents) + Excluded (unallowable) + Expected + Expected reimbursement + An estimate from the rates and agreement in effect at initial dispatch. Cal OES determines the allowed amount; no internal cost enters these lines. + Expected total + Expected versus observed + Expires on + External resource name + Agency + Apparatus + Attachments + Comments + Dispatch + Incident + Incident authorization + Expense lines + Order + Personnel + Request + Resource + Responding signature + Return + Rotation + Signature + FEIN + Request / fill + RMS external-order fill id (required when the order has more than one request) + FI$Cal supplier + Fix + Function + Generated + I am the authorized person entering this record in MARS and will copy the values shown, not this page's file. + Side-by-side copy view for manual entry in the MARS portal. Not stored, not cached, and not a submission. + Run the checklist until it is clean; the handoff opens from Ready for portal. + Prepared for MARS. Opening this view records an audit entry and changes nothing; the record becomes Observed in MARS only when a manager records what the portal shows. + The checklist still has errors; MARS may return this record. + These values are copied onto F-42s and claims and are stored as data (not under Advanced Data Protection). Changing an identifier clears the last verification. + Leave the classification blank for a department-wide agreement. An agreement referenced by a submitted record is immutable; editing it creates a new version. + Field members see their own incident-bound drafts; MARS managers see the department queue. Opening a handoff or downloading a packet never marks a record submitted. + Rates are data pinned to a reviewed authority profile; a new Rate Letter or survey is a new snapshot, and an earlier dispatch keeps the one in effect when it started. + Blockers stop new F-42s from reaching Ready for portal; warnings are worth fixing before the next dispatch. + A MARS invoice is a work item, never a Phase B customer invoice: no invoice number, no aging, no e-mail. Paid is only ever an observed payment fact. + Draft rows copy the unit's name, plate and VIN at that moment; review them, add the MARS resource type, then record what MARS shows. + Hours + Identifiers + Incident / AREP authorizer + Billed directly to an incident (excluded) + Includes unemployment insurance + Includes workers' comp + Incoming + Invoice date + Expected versus observed, the local decision and the payment facts for one MARS-generated invoice. + Invoiced + Invoices awaiting local approval + Item + Kind + Apparatus + Special equipment + Privately owned vehicle + Support vehicle + License plate + Line kind + Administrative + Administrative rate + Apparatus + Attachment A (non-suppression) + Expense + Meals, lodging, incidentals + Official apparatus + Official support vehicle + Personnel + POV mileage + POV mileage + Rental + Salary Survey + Special equipment + Support vehicle + Linked F-42 + Local agency decision + Approve or reject the invoice as the authorized representative; a rejection needs a comment. The decision is recorded here and entered in MARS by you. + Loss / damage + MACS designator + Manage Cal OES MARS reimbursement + Agency, F-5 resource, annual rate and agreement records, the portal handoff, observed MARS statuses and invoice approval/payment reconciliation. Rostered members prepare their own F-42 and expense drafts without it. + Mark verified today + MARS invoice + MARS invoice id + This is a MARS work item. It has no Phase B invoice number, never appears in customer aging and cannot be e-mailed or paid online. + MARS invoices + MARS record id + MARS resource id + Method chosen + Miles + Rostered on this deployment + mismatches + Name + New submission + No + No agency profile yet. + No agreements yet. + No deployment attachments. + No cost-recovery deployments to prepare from. + Resgrid stores no MARS credentials, MFA tokens or browser sessions and never writes to the portal. + No annual submission covers this date. + No deployment + No MARS invoices recorded. + Nothing in the queue. + No annual submissions yet. + Everything needed for a MARS submission is in place. + No F-5 resource rows yet. + The evidence packet is not an accepted MARS import file. + Not calculated yet. + Not current + Not in the F-5 resource inventory + Not validated yet. + Observed in MARS + Observed on + Observed status + Observed submission + Odometer + Official sources + Open + Open deployment + Open handoff view + Open the MARS portal + Open work items + Outgoing + Overhead position + Overtime eligible + Overtime method + After 8 hours per day + After 12 hours per day + None + Per agreement (not modelled) + Overtime rate + Ownership + CAL FIRE + Cal OES + Local agency + Other + Private + Rental + Paid + Paid on + Paying entity + Paying-entity status + Payment reference + Portal handoff + Portal account reference + A label for the MARS account (never a password or token). + Portal role + Portal-to-portal eligible + Pre-approved + Prepare expense claim + Prepare F-42 + Prepare a record + One F-42 per ordered resource / request; a redispatch supersedes the earlier one. Expense claims link to an F-42 or take the travel-only path. + Prepared for MARS — nothing here is submitted until a manager records what was observed in the portal. + Printable + Provenance + F-42 and expense claims to prepare, validate and hand off, grouped by deployment; observed MARS statuses and invoices alongside. + Rank + Header, lines and (for the Administrative Rate) the prior-year actual-cost worksheet. + Rate lines + Salary lines need a classification; equipment lines a resource or FEMA code. Rates are straight / overtime per basis. + This submission is signed or submitted; edits need a new version. + Rate profile version + Accepted + Draft + Reviewed + Signed locally + Submitted in MARS + Superseded + Salary Survey, Attachment A, Administrative Rate, Cal OES Rate Letter and Special Equipment snapshots by submission year. + No administrative rate is recorded for this date; no administrative line will be estimated. + The agency profile (MACS designator, contacts, identifiers) has not been entered. + The agency profile has not been verified against MARS in the last year. + An agreement ends within the lead window. + No MOU/MOA/GBR compensation method covers this date; personnel are estimated on actual hours without overtime. + Readiness as of + No reviewed Cal OES authority profile covers this dispatch date; new submissions are blocked until one is added. + Readiness dashboard + No FEIN recorded for the agency. + No FI$Cal supplier id recorded for the agency. + Agency, F-5 resource, annual rate and agreement readiness for a dispatch date, with links to the official Cal OES material. + MARS-generated invoices are waiting for the local agency decision. + The MACS agency designator is missing. + No F-5 resource crosswalk rows exist; apparatus on an F-42 will be flagged as not in inventory. + An annual submission expires within the lead window. + No Cal OES Rate Letter lines (apparatus, support vehicle, POV mileage) cover this date. + An annual submission in effect has not been signed locally. + F-5 resource rows do not match what MARS shows. + Records returned by Cal OES for agency review are waiting. + No reviewed Salary Survey (or accepted base rate) covers this date; personnel lines cannot be estimated. + No SAM UEI recorded for the agency. + Ready + Ready for the portal. + Receipt + Invoice and payment reconciliation + MARS-generated invoices as observed, the local approve / reject decision, paying-entity status and payment facts against the expected lines. + Record a MARS invoice + Capture the invoice Cal OES generated as you see it in the portal, and the submitted records it covers. + Record observation + Record payment + Only an observed paying-entity payment marks the invoice and its records Paid. + Record submission observed in MARS + Record type + Administrative Rate + Agency profile + Agreement + Attachment A + Expense claim + F-42 + MARS invoice + F-5 resource inventory + Salary Survey + Special Equipment + Recorded + Redispatch of + Reject + Related records + Release is not return; the F-42 needs the return or redispatch time. + Released + Reporting location + Request + Resource code + F-5 resource inventory + Resource kind + F-5 resources + Resource type + Crosswalk from Resgrid units and assets to their MARS / F-5 identity. It prepares and reconciles inventory; it never writes FAST status. + Responding agency signer + Returned for review + Reason + Review state + Draft + Mismatch + Observed in MARS + Reviewed + Review + Accepted + Excluded + Pending + reviewed + Run checklist + SAM registration + Save + The change could not be saved. + Save inputs + Save lines + Saved. + Serial number + Observed accepted + Back to draft + Mark reviewed + Sign locally + Observed submitted in MARS + Supersede + Severity + Signed by (name) + Signed on + Signer + Source + Source artifact + Source date + Source line + Source system + Source URL + Start + State + Approved + Closed + Documentation only + Draft + Rejected by local agency + Needs review + Paid + Pending local agency approval + Pending paying entity + Ready for portal + Returned for agency review + Observed in MARS (Cal OES review) + Status + Straight rate + Strike team / task force + Subject + Subject type + External resource + Inventory asset + Unit + Submission + Submission type + Administrative Rate + Attachment A (non-suppression) + Cal OES Rate Letter + Salary Survey + Special Equipment / FEMA codes + supersedes + Supply numbers + Supporting documents + Agency + Agreements + Action queue + Annual rates + Readiness + Reconciliation + F-5 resources + Travel only (no F-42) + SAM UEI + Unit + Unit designator + Validated + Checklist: {0} error(s), {1} warning(s). + The agency profile is missing. + No MOU / MOA / GBR agreement covers the dispatch date. + No reviewed authority profile covers this record. + A person's classification is not in the Salary Survey or the pinned list. + The dispatch time is missing. + Marked Documentation Only: no reimbursement is claimed. + The same vehicle appears more than once. + The claim approver is missing. + The linked F-42 has not been observed submitted in MARS. + The expense claim has no lines. + An expense line has no receipt. + The claim signature is missing. + The incident / AREP authorization is missing. + Incident name or number is missing. + The MACS designator is missing. + The incident order number is missing. + No signed or paper F-42 is attached to the deployment. + A person's commitment interval falls outside the resource's dispatch-to-return window. + No personnel are listed. + No annual rate lines are in effect for the dispatch date. + The resource is released but no return or redispatch time is recorded. + The request number is missing. + The request number does not carry a valid prefix (E, O, C, S, A) and number. + Resource type, kind or overhead position is missing. + The responding agency signature is missing. + The return time is before the dispatch time. + A crew rotation has no approval attachment. + A vehicle has no F-5 resource crosswalk row. + Value + Variance + Verification + Mark the profile verified after comparing it with the agency record in MARS. The readiness dashboard warns after a year. + Verified on + Version + Warning + Window + Official box order, source links to the immutable deployment facts, checklist, expected reimbursement and the portal handoff. + Year + Yes + Input amounts cannot be negative. + An input classification is not valid. + An input fiscal year is not valid (prior years only). + The agency name is required. + The agency profile was not found. + The agreement is referenced by a work item and cannot be deleted. + The agreement document kind is not valid. + The compensation method is not valid. + The agreement was not found. + The overtime method is not valid. + Confirm the attestation before opening the handoff view. + The end date is before the start date. + The decision needs the deciding person's name / title. + The deployment was not found. + The linked F-42 was not found on this deployment. + That request / fill is not on the deployment's external order. + The order has several requests; choose the request / fill for this F-42. + The invoiced amount cannot be negative. + A MARS invoice with that id is already recorded. + The MARS invoice id is required. + Record the local approval before a payment. + The invoice is not in a state where a payment can be recorded. + The invoice is not pending local agency approval. + The record is not Ready for portal; run the checklist first. + The paid amount cannot be negative. + The submission has no lines, no administrative rate and no accepted base rate. + A rate line basis is not valid. + Salary lines need a classification code. + A rate line kind is not valid. + Rates cannot be negative. + Equipment lines need a resource or FEMA code. + This submission is signed or submitted and can no longer be edited. + The annual submission was not found. + Record the local signature before observing an external status. + The administrative rate must be between 0 and 100 percent. + Signing needs the signer's name. + That status change is not allowed. + The submission type is not valid. + The submission year is not valid. + A rejection needs a comment. + An external resource needs a name. + The resource row was not found. + The resource subject type is not valid. + The unit was not found in this department. + A unit is required for a unit resource. + That external status is not in the authority profile's vocabulary. + The record has been observed in MARS and is no longer locally editable. + Only F-42 and expense claims have expected reimbursement. + Only approved, documentation-only, rejected or paid records can be closed. + The record has not been observed submitted in MARS. + The record was not found. + The record is not a MARS invoice. + Only F-42 and expense claims are submitted to MARS. + The record is paid or closed; its lines are final. + The record is not of the expected type. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx new file mode 100644 index 00000000..3bb623f2 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Cola de acciones del incidente + Activo + Importe real + Horas reales (DTR) + Añadir acuerdo + Añadir entrada + Añadir línea + Añadir recurso + Añadir rotación + Dirección + Hay posibles dobles cómputos pendientes de revisión. + Algunas entradas siguen pendientes de revisión. + No hay base de coste directo admisible; no se puede calcular la tasa. + Sin entradas de costes reales; la opción de minimis es la única posible. + Método administrativo + Calculado a partir de reales + De minimis + Ninguno + Entradas de tasa administrativa + Costes reales del año anterior por función y categoría, clasificados directo / indirecto / no admisible. Los presupuestos nunca se introducen. + Tasa administrativa + Hoja de tasa administrativa + Indirecto admisible ÷ directo admisible a partir de las entradas aceptadas, comparado con la opción de minimis. Los dobles cómputos sin resolver bloquean el resultado. + Antigüedad + Agencia + Categoría de agencia + El registro de agencia en MARS: designador MACS, contactos e identificadores impresos en cada reclamación. + Nombre de la agencia + Perfil de agencia + Acuerdo + Acuerdos + Métodos de compensación MOU / MOA / GBR aprobados por clasificación. Un F-42 selecciona el vigente en el despacho inicial. + Todas las clasificaciones + Todos los tipos + Todos los años + Directo admisible + Indirecto admisible + Importe + Tarifas anuales + Presentaciones anuales vigentes + Adjunto de aprobación + Aprobar + Aprobador + ID de adjunto del despliegue + Opcional: el acuerdo firmado subido como adjunto de despliegue. + Autoridad + Equipo especial de la agencia + Presentado por la agencia + Tarifa base de Cal OES + Rate Letter de Cal OES + Tabla FEMA + Perfil de autoridad + Ningún perfil revisado cubre esta fecha + Atrás + Tarifa base de Cal OES aceptada + La agencia toma la tarifa base de Cal OES en vez de presentar sus propias filas. + Base + Diario + Fijo + Por hora + Por milla + Porcentaje + Bloqueado + Bloqueante + Casilla + Agencia respondiente + Vehículos, apoyo y equipo + Adjuntos + Comentarios, pérdida / daño, números de suministro + Despacho / compromiso + Incidente + Número de orden del incidente + Personal + Número de solicitud + Recurso + Regreso / redespacho + Rotaciones de personal + Firmas y autorización + Calcular tasa administrativa + Cal OES MARS + Calcular + Tasa calculada + Cancelar + Categoría + Categoría + Alojamiento + Comida + Varios + Alquiler + Comprobar fecha + Lista de comprobación + Suma de verificación + Ciudad + Clasificación + Título de clasificación + Cerrar + Comentario + Comentarios + Comprometido + Horas comprometidas + Método de compensación + Horas reales + Portal a portal + ¿Eliminar este registro? + Correo de contacto + Nombre de contacto + Teléfono de contacto + Copiar + Comentario del revisor + Directo + Indirecto + No admisible + De un vistazo + Registros cubiertos + Registros F-42 / de gastos presentados que paga esta factura; sus totales esperados se comparan con el importe facturado. + Fecha + Opción de minimis + Decisión de (nombre / cargo) + Eliminar + Despliegue + Descripción + Designador + Detalle + Tipo de documento + Equivalente + GBR + MOA + MOU + Solo documentación + El registro documenta la respuesta sin reclamar reembolso (vía Documentation Only de MARS). + Posible doble cómputo + Borrador desde unidades + Seleccione unidades y cree filas para las que no tengan. + Editar agencia + Editar acuerdo + Editar recurso + Vigente desde + Elegible + Excluido + Incierto + Fin + Paquete de evidencia + Excluido (facturado a incidentes) + Excluido (no admisible) + Esperado + Reembolso esperado + Una estimación con las tarifas y el acuerdo vigentes en el despacho inicial. Cal OES determina el importe permitido; ningún coste interno entra en estas líneas. + Total esperado + Esperado frente a observado + Vence el + Nombre del recurso externo + Agencia + Vehículos + Adjuntos + Comentarios + Despacho + Incidente + Autorización del incidente + Líneas de gastos + Orden + Personal + Solicitud + Recurso + Firma de la agencia + Regreso + Rotación + Firma + FEIN + Solicitud / fill + ID de fill de la orden externa RMS (necesario si la orden tiene más de una solicitud) + Proveedor FI$Cal + Corregir + Función + Generado + Soy la persona autorizada para introducir este registro en MARS y copiaré los valores mostrados, no el archivo de esta página. + Vista de copia para la entrada manual en el portal MARS. No se guarda, no se almacena en caché y no es una presentación. + Ejecute la lista hasta que esté limpia; la entrega se abre desde Listo para portal. + Preparado para MARS. Abrir esta vista registra una entrada de auditoría y no cambia nada; el registro pasa a Observado en MARS solo cuando un gestor registra lo que muestra el portal. + La lista aún tiene errores; MARS puede devolver este registro. + Estos valores se copian en los F-42 y reclamaciones y se guardan como datos (fuera de Advanced Data Protection). Cambiar un identificador borra la última verificación. + Deje la clasificación en blanco para un acuerdo de todo el departamento. Un acuerdo referenciado por un registro presentado es inmutable; editarlo crea una nueva versión. + Los miembros de campo ven sus propios borradores; los gestores MARS ven la cola del departamento. Abrir una entrega o descargar un paquete nunca marca un registro como presentado. + Las tarifas son datos vinculados a un perfil de autoridad revisado; una nueva Rate Letter o encuesta es una nueva instantánea, y un despacho anterior conserva la vigente cuando empezó. + Los bloqueantes impiden que los nuevos F-42 alcancen Listo para portal; conviene resolver las advertencias antes del próximo despacho. + Una factura MARS es un elemento de trabajo, nunca una factura de cliente de la Fase B: sin número de factura, sin antigüedad, sin correo. Pagado es siempre un hecho de pago observado. + Las filas borrador copian nombre, matrícula y VIN de la unidad en ese momento; revíselas, añada el tipo de recurso MARS y registre lo que muestra MARS. + Horas + Identificadores + Autorizador del incidente / AREP + Facturado directamente a un incidente (excluido) + Incluye seguro de desempleo + Incluye compensación laboral + Entrante + Fecha de factura + Esperado frente a observado, la decisión local y los hechos de pago de una factura generada por MARS. + Facturado + Facturas pendientes de aprobación local + Elemento + Clase + Vehículo + Equipo especial + Vehículo particular + Vehículo de apoyo + Matrícula + Tipo de línea + Administrativo + Tasa administrativa + Vehículo + Anexo A (no supresión) + Gasto + Comidas, alojamiento, incidentales + Vehículo oficial + Vehículo de apoyo oficial + Personal + Millaje POV + Millaje POV + Alquiler + Encuesta salarial + Equipo especial + Vehículo de apoyo + F-42 vinculado + Decisión de la agencia local + Apruebe o rechace la factura como representante autorizado; un rechazo necesita un comentario. La decisión se registra aquí y usted la introduce en MARS. + Pérdida / daño + Designador MACS + Gestionar reembolso Cal OES MARS + Registros de agencia, recursos F-5, tarifas anuales y acuerdos, entrega al portal, estados MARS observados y aprobación de facturas/conciliación de pagos. Los miembros asignados preparan sus propios borradores F-42 y de gastos sin este permiso. + Marcar como verificado hoy + Factura MARS + ID de factura MARS + Esto es un elemento de trabajo MARS. No tiene número de factura de la Fase B, nunca aparece en la antigüedad de clientes y no se puede enviar por correo ni pagar en línea. + Facturas MARS + ID de registro MARS + ID de recurso MARS + Método elegido + Millas + Asignado a este despliegue + discrepancias + Nombre + Nueva presentación + No + Aún no hay perfil de agencia. + Aún no hay acuerdos. + Sin adjuntos de despliegue. + No hay despliegues de recuperación de costes para preparar. + Resgrid no almacena credenciales de MARS, tokens MFA ni sesiones del navegador y nunca escribe en el portal. + Ninguna presentación anual cubre esta fecha. + Sin despliegue + No hay facturas MARS registradas. + Nada en la cola. + Aún no hay presentaciones anuales. + Todo lo necesario para una presentación a MARS está listo. + Aún no hay filas de recursos F-5. + El paquete de evidencia no es un archivo de importación aceptado por MARS. + Aún no calculado. + No vigente + No está en el inventario F-5 + Aún no validado. + Observado en MARS + Observado el + Estado observado + Presentación observada + Odómetro + Fuentes oficiales + Abrir + Abrir despliegue + Abrir vista de entrega + Abrir el portal MARS + Elementos abiertos + Saliente + Puesto overhead + Elegible para horas extra + Método de horas extra + Tras 8 horas al día + Tras 12 horas al día + Ninguno + Según acuerdo (no modelado) + Tarifa de horas extra + Propiedad + CAL FIRE + Cal OES + Agencia local + Otro + Privado + Alquiler + Pagado + Pagado el + Entidad pagadora + Estado de la entidad pagadora + Referencia de pago + Entrega al portal + Referencia de cuenta del portal + Una etiqueta para la cuenta MARS (nunca una contraseña ni token). + Rol en el portal + Elegible portal a portal + Preaprobado + Preparar reclamación de gastos + Preparar F-42 + Preparar un registro + Un F-42 por recurso / solicitud ordenado; un redespacho sustituye al anterior. Las reclamaciones de gastos se vinculan a un F-42 o siguen la vía solo viaje. + Preparado para MARS: nada se considera presentado hasta que un gestor registre lo observado en el portal. + Imprimible + Procedencia + F-42 y reclamaciones de gastos para preparar, validar y entregar, agrupados por despliegue; estados MARS observados y facturas al lado. + Rango + Cabecera, líneas y (para la tasa administrativa) la hoja de costes reales del año anterior. + Líneas de tarifa + Las líneas salariales necesitan una clasificación; las de equipo un código de recurso o FEMA. Las tarifas son normal / extra por base. + Esta presentación está firmada o presentada; los cambios requieren una nueva versión. + Versión del perfil de tarifas + Aceptado + Borrador + Revisado + Firmado localmente + Presentado en MARS + Sustituido + Encuesta salarial, Anexo A, tasa administrativa, Rate Letter de Cal OES y equipo especial por año de presentación. + No hay tasa administrativa registrada para esta fecha; no se estimará ninguna línea administrativa. + No se ha introducido el perfil de agencia (designador MACS, contactos, identificadores). + El perfil de agencia no se ha verificado contra MARS en el último año. + Un acuerdo termina dentro del plazo de aviso. + Ningún método de compensación MOU/MOA/GBR cubre esta fecha; el personal se estima por horas reales sin horas extra. + Preparación a + Ningún perfil de autoridad Cal OES revisado cubre esta fecha; las nuevas presentaciones quedan bloqueadas hasta que se añada uno. + Panel de preparación + No hay FEIN registrado para la agencia. + No hay ID de proveedor FI$Cal registrado para la agencia. + Preparación de agencia, recursos F-5, tarifas anuales y acuerdos para una fecha de despacho, con enlaces al material oficial de Cal OES. + Facturas generadas por MARS esperan la decisión local de la agencia. + Falta el designador MACS de la agencia. + No hay filas de correspondencia F-5; los vehículos de un F-42 se marcarán como no inventariados. + Una presentación anual vence dentro del plazo de aviso. + Ninguna línea de la Rate Letter de Cal OES (vehículos, apoyo, millaje POV) cubre esta fecha. + Una presentación anual vigente no se ha firmado localmente. + Las filas de recursos F-5 no coinciden con lo que muestra MARS. + Hay registros devueltos por Cal OES para revisión de la agencia. + Ninguna encuesta salarial revisada (o tarifa base aceptada) cubre esta fecha; no se pueden estimar las líneas de personal. + No hay SAM UEI registrado para la agencia. + Listo + Listo para el portal. + Recibo + Conciliación de facturas y pagos + Facturas generadas por MARS tal como se observaron, la decisión local de aprobar / rechazar, el estado de la entidad pagadora y los hechos de pago frente a las líneas esperadas. + Registrar una factura MARS + Capture la factura generada por Cal OES tal como la ve en el portal y los registros presentados que cubre. + Registrar observación + Registrar pago + Solo un pago observado de la entidad pagadora marca la factura y sus registros como pagados. + Registrar presentación observada en MARS + Tipo de registro + Tasa administrativa + Perfil de agencia + Acuerdo + Anexo A + Reclamación de gastos + F-42 + Factura MARS + Inventario de recursos F-5 + Encuesta salarial + Equipo especial + Registrado + Redespacho de + Rechazar + Registros relacionados + Liberar no es regresar; el F-42 necesita la hora de regreso o redespacho. + Liberado + Lugar de presentación + Solicitud + Código de recurso + Inventario de recursos F-5 + Clase de recurso + Recursos F-5 + Tipo de recurso + Correspondencia de unidades y activos de Resgrid con su identidad MARS / F-5. Prepara y concilia inventario; nunca escribe el estado FAST. + Firmante de la agencia + Devueltos para revisión + Motivo + Estado de revisión + Borrador + Discrepancia + Observado en MARS + Revisado + Revisión + Aceptado + Excluido + Pendiente + revisado el + Ejecutar lista + Registro SAM + Guardar + No se pudo guardar el cambio. + Guardar entradas + Guardar líneas + Guardado. + Número de serie + Observado aceptado + Volver a borrador + Marcar como revisado + Firmar localmente + Observado presentado en MARS + Sustituir + Gravedad + Firmado por (nombre) + Firmado el + Firmante + Origen + Artefacto de origen + Fecha de origen + Línea de origen + Sistema de origen + URL de origen + Inicio + Estado + Aprobado + Cerrado + Solo documentación + Borrador + Rechazado por la agencia local + Necesita revisión + Pagado + Pendiente de aprobación local + Pendiente de la entidad pagadora + Listo para portal + Devuelto para revisión de la agencia + Observado en MARS (revisión Cal OES) + Estado + Tarifa normal + Equipo de ataque / fuerza de tarea + Sujeto + Tipo de sujeto + Recurso externo + Activo de inventario + Unidad + Presentación + Tipo de presentación + Tasa administrativa + Anexo A (no supresión) + Rate Letter de Cal OES + Encuesta salarial + Equipo especial / códigos FEMA + sustituye a + Números de suministro + Documentos de apoyo + Agencia + Acuerdos + Cola de acciones + Tarifas anuales + Preparación + Conciliación + Recursos F-5 + Solo viaje (sin F-42) + SAM UEI + Unidad + Designador de unidad + Validado + Lista: {0} error(es), {1} advertencia(s). + Falta el perfil de agencia. + Ningún acuerdo MOU / MOA / GBR cubre la fecha de despacho. + Ningún perfil de autoridad revisado cubre este registro. + La clasificación de una persona no está en la encuesta salarial ni en la lista fijada. + Falta la hora de despacho. + Marcado como Solo documentación: no se reclama reembolso. + El mismo vehículo aparece más de una vez. + Falta el aprobador de la reclamación. + El F-42 vinculado no se ha observado presentado en MARS. + La reclamación de gastos no tiene líneas. + Una línea de gasto no tiene recibo. + Falta la firma de la reclamación. + Falta la autorización del incidente / AREP. + Falta el nombre o número del incidente. + Falta el designador MACS. + Falta el número de orden del incidente. + No hay F-42 firmado ni en papel adjunto al despliegue. + El intervalo de compromiso de una persona queda fuera de la ventana despacho-regreso. + No hay personal listado. + No hay líneas de tarifa anual vigentes para la fecha de despacho. + El recurso está liberado pero no hay hora de regreso ni de redespacho. + Falta el número de solicitud. + El número de solicitud no tiene un prefijo válido (E, O, C, S, A) y número. + Falta el tipo, clase o puesto overhead del recurso. + Falta la firma de la agencia respondiente. + La hora de regreso es anterior al despacho. + Una rotación no tiene adjunto de aprobación. + Un vehículo no tiene fila de correspondencia F-5. + Valor + Variación + Verificación + Marque el perfil como verificado tras compararlo con el registro de agencia en MARS. El panel avisa pasado un año. + Verificado el + Versión + Advertencia + Ventana + Orden oficial de casillas, enlaces a los hechos inmutables del despliegue, lista de comprobación, reembolso esperado y entrega al portal. + Año + + Los importes de entrada no pueden ser negativos. + Una clasificación de entrada no es válida. + Un año fiscal de entrada no es válido (solo años anteriores). + El nombre de la agencia es obligatorio. + No se encontró el perfil de agencia. + El acuerdo está referenciado por un elemento y no se puede eliminar. + El tipo de documento del acuerdo no es válido. + El método de compensación no es válido. + No se encontró el acuerdo. + El método de horas extra no es válido. + Confirme la declaración antes de abrir la vista de entrega. + La fecha de fin es anterior a la de inicio. + La decisión necesita el nombre / cargo de quien decide. + No se encontró el despliegue. + No se encontró el F-42 vinculado en este despliegue. + Esa solicitud / fill no está en la orden externa del despliegue. + La orden tiene varias solicitudes; elija la solicitud / fill para este F-42. + El importe facturado no puede ser negativo. + Ya hay registrada una factura MARS con ese ID. + El ID de factura MARS es obligatorio. + Registre la aprobación local antes de un pago. + La factura no está en un estado en que se pueda registrar un pago. + La factura no está pendiente de aprobación local. + El registro no está Listo para portal; ejecute primero la lista. + El importe pagado no puede ser negativo. + La presentación no tiene líneas, ni tasa administrativa, ni tarifa base aceptada. + Una base de línea de tarifa no es válida. + Las líneas salariales necesitan un código de clasificación. + Un tipo de línea de tarifa no es válido. + Las tarifas no pueden ser negativas. + Las líneas de equipo necesitan un código de recurso o FEMA. + Esta presentación está firmada o presentada y ya no se puede editar. + No se encontró la presentación anual. + Registre la firma local antes de observar un estado externo. + La tasa administrativa debe estar entre 0 y 100 por ciento. + Firmar requiere el nombre del firmante. + Ese cambio de estado no está permitido. + El tipo de presentación no es válido. + El año de presentación no es válido. + Un rechazo necesita un comentario. + Un recurso externo necesita un nombre. + No se encontró la fila del recurso. + El tipo de sujeto del recurso no es válido. + No se encontró la unidad en este departamento. + Se requiere una unidad para un recurso de unidad. + Ese estado externo no está en el vocabulario del perfil de autoridad. + El registro se ha observado en MARS y ya no es editable localmente. + Solo los F-42 y las reclamaciones de gastos tienen reembolso esperado. + Solo se pueden cerrar registros aprobados, solo documentación, rechazados o pagados. + El registro no se ha observado presentado en MARS. + No se encontró el registro. + El registro no es una factura MARS. + Solo los F-42 y las reclamaciones de gastos se presentan a MARS. + El registro está pagado o cerrado; sus líneas son definitivas. + El registro no es del tipo esperado. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx new file mode 100644 index 00000000..6613113e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + File d'actions de l'incident + Actif + Montant réel + Heures réelles (DTR) + Ajouter un accord + Ajouter une entrée + Ajouter une ligne + Ajouter une ressource + Ajouter une rotation + Adresse + Des doubles comptages possibles sont encore en attente de révision. + Certaines entrées sont encore en attente de révision. + Aucune base de coûts directs admissible ; le taux ne peut pas être calculé. + Aucune entrée de coûts réels ; l'option de minimis est le seul choix. + Méthode administrative + Calculé à partir des coûts réels + De minimis + Aucune + Entrées du taux administratif + Coûts réels de l'année précédente par fonction et catégorie, classés direct / indirect / non admissible. Les budgets n'entrent jamais ici. + Taux administratif + Feuille du taux administratif + Indirect admissible ÷ direct admissible à partir des entrées acceptées, comparé à l'option de minimis. Les doubles comptages non résolus bloquent le résultat. + Ancienneté + Agence + Catégorie d'agence + La fiche agence MARS : désignateur MACS, contacts et identifiants imprimés sur chaque demande. + Nom de l'agence + Profil d'agence + Accord + Accords + Méthodes de rémunération MOU / MOA / GBR approuvées par classification. Un F-42 retient celle en vigueur à l'engagement initial. + Toutes les classifications + Tous les types + Toutes les années + Direct admissible + Indirect admissible + Montant + Taux annuels + Soumissions annuelles en vigueur + Pièce jointe d'approbation + Approuver + Approbateur + Identifiant de pièce jointe du déploiement + Facultatif : l'accord signé téléversé en pièce jointe de déploiement. + Autorité + Équipement spécial de l'agence + Soumis par l'agence + Taux de base Cal OES + Rate Letter Cal OES + Barème FEMA + Profil d'autorité + Aucun profil validé ne couvre cette date + Retour + Taux de base Cal OES accepté + L'agence retient le taux de base Cal OES au lieu de ses propres lignes d'enquête. + Base + Journalier + Forfait + Horaire + Par mile + Pourcentage + Bloqué + Bloquant + Case + Agence intervenante + Engins, véhicules de soutien et équipement + Pièces jointes + Commentaires, perte / dommage, numéros d'approvisionnement + Engagement / mobilisation + Incident + Numéro de commande d'incident + Personnel + Numéro de demande + Ressource + Retour / réengagement + Rotations d'équipe + Signatures et autorisation + Calculer le taux administratif + Cal OES MARS + Calculer + Taux calculé + Annuler + Catégorie + Catégorie + Hébergement + Repas + Divers + Location + Vérifier la date + Liste de contrôle + Somme de contrôle + Ville + Classification + Intitulé de classification + Clôturer + Commentaire + Commentaires + Mobilisé + Heures mobilisées + Méthode de rémunération + Heures réelles + Portail à portail + Supprimer cet enregistrement ? + E-mail du contact + Nom du contact + Téléphone du contact + Copier + Commentaire du réviseur + Direct + Indirect + Non admissible + En un coup d'œil + Enregistrements couverts + Enregistrements F-42 / dépenses soumis que cette facture paie ; leurs totaux attendus sont comparés au montant facturé. + Date + Option de minimis + Décision de (nom / titre) + Supprimer + Déploiement + Description + Désignateur + Détail + Type de document + Équivalent + GBR + MOA + MOU + Documentation seule + L'enregistrement documente l'intervention sans demander de remboursement (voie Documentation Only de MARS). + Double comptage possible + Brouillon depuis les unités + Sélectionnez des unités et créez des lignes pour celles qui n'en ont pas. + Modifier l'agence + Modifier l'accord + Modifier la ressource + En vigueur le + Éligible + Exclu + Incertain + Fin + Dossier de preuves + Exclu (facturé aux incidents) + Exclu (non admissible) + Attendu + Remboursement attendu + Une estimation à partir des taux et de l'accord en vigueur à l'engagement initial. Cal OES fixe le montant admis ; aucun coût interne n'entre dans ces lignes. + Total attendu + Attendu contre observé + Expire le + Nom de la ressource externe + Agence + Engins + Pièces jointes + Commentaires + Engagement + Incident + Autorisation d'incident + Lignes de dépenses + Commande + Personnel + Demande + Ressource + Signature de l'agence + Retour + Rotation + Signature + FEIN + Demande / fill + Identifiant fill de la commande externe RMS (requis si la commande a plusieurs demandes) + Fournisseur FI$Cal + Corriger + Fonction + Généré + Je suis la personne autorisée à saisir cet enregistrement dans MARS et je copierai les valeurs affichées, pas le fichier de cette page. + Vue de copie pour la saisie manuelle dans le portail MARS. Ni stockée, ni mise en cache, ni une soumission. + Lancez la liste de contrôle jusqu'à ce qu'elle soit propre ; le transfert s'ouvre à partir de Prêt pour le portail. + Préparé pour MARS. Ouvrir cette vue crée une entrée d'audit et ne change rien ; l'enregistrement ne devient Observé dans MARS que lorsqu'un gestionnaire note ce que le portail affiche. + La liste de contrôle contient encore des erreurs ; MARS peut renvoyer cet enregistrement. + Ces valeurs sont copiées sur les F-42 et demandes et stockées en clair (hors Advanced Data Protection). Modifier un identifiant efface la dernière vérification. + Laissez la classification vide pour un accord à l'échelle du service. Un accord référencé par un enregistrement soumis est immuable ; le modifier crée une nouvelle version. + Les membres de terrain voient leurs propres brouillons ; les gestionnaires MARS voient la file du service. Ouvrir un transfert ou télécharger un dossier ne marque jamais un enregistrement comme soumis. + Les taux sont des données liées à un profil d'autorité validé ; une nouvelle Rate Letter ou enquête est un nouvel instantané, et un engagement antérieur conserve celui en vigueur à son début. + Les bloquants empêchent les nouveaux F-42 d'atteindre Prêt pour le portail ; corrigez les avertissements avant le prochain engagement. + Une facture MARS est un élément de travail, jamais une facture client de la phase B : pas de numéro, pas de balance âgée, pas d'e-mail. Payé n'est jamais qu'un fait de paiement observé. + Les lignes brouillon copient le nom, la plaque et le VIN de l'unité à cet instant ; vérifiez-les, ajoutez le type de ressource MARS puis notez ce que MARS affiche. + Heures + Identifiants + Autorisation incident / AREP + Facturé directement à un incident (exclu) + Inclut l'assurance chômage + Inclut l'assurance accidents du travail + Entrant + Date de facture + Attendu contre observé, décision locale et faits de paiement pour une facture générée par MARS. + Facturé + Factures en attente d'approbation locale + Élément + Nature + Engin + Équipement spécial + Véhicule personnel + Véhicule de soutien + Plaque + Type de ligne + Administratif + Taux administratif + Engin + Annexe A (hors extinction) + Dépense + Repas, hébergement, frais accessoires + Engin officiel + Véhicule de soutien officiel + Personnel + Kilométrage POV + Kilométrage POV + Location + Enquête salariale + Équipement spécial + Véhicule de soutien + F-42 lié + Décision de l'agence locale + Approuvez ou rejetez la facture en tant que représentant autorisé ; un rejet nécessite un commentaire. La décision est enregistrée ici et saisie dans MARS par vous. + Perte / dommage + Désignateur MACS + Gérer le remboursement Cal OES MARS + Fiches agence, ressources F-5, taux annuels et accords, transfert vers le portail, statuts MARS observés et approbation des factures/rapprochement des paiements. Les membres affectés préparent leurs propres brouillons F-42 et de dépenses sans cette permission. + Marquer vérifié aujourd'hui + Facture MARS + Identifiant de facture MARS + Ceci est un élément de travail MARS. Il n'a pas de numéro de facture de phase B, n'apparaît jamais dans la balance âgée clients et ne peut être envoyé par e-mail ni payé en ligne. + Factures MARS + Identifiant d'enregistrement MARS + Identifiant ressource MARS + Méthode retenue + Miles + Affecté à ce déploiement + écarts + Nom + Nouvelle soumission + Non + Pas encore de profil d'agence. + Pas encore d'accords. + Aucune pièce jointe de déploiement. + Aucun déploiement en recouvrement de coûts à préparer. + Resgrid ne stocke aucun identifiant MARS, jeton MFA ou session de navigateur et n'écrit jamais dans le portail. + Aucune soumission annuelle ne couvre cette date. + Sans déploiement + Aucune facture MARS enregistrée. + Rien dans la file. + Pas encore de soumissions annuelles. + Tout ce qu'il faut pour une soumission MARS est en place. + Pas encore de lignes de ressources F-5. + Le dossier de preuves n'est pas un fichier d'import accepté par MARS. + Pas encore calculé. + Non en vigueur + Absent de l'inventaire F-5 + Pas encore validé. + Observé dans MARS + Observé le + Statut observé + Soumission observée + Odomètre + Sources officielles + Ouvrir + Ouvrir le déploiement + Ouvrir la vue de transfert + Ouvrir le portail MARS + Éléments ouverts + Sortant + Poste overhead + Éligible aux heures sup. + Méthode d'heures sup. + Après 8 heures par jour + Après 12 heures par jour + Aucune + Selon l'accord (non modélisé) + Taux heures sup. + Propriété + CAL FIRE + Cal OES + Agence locale + Autre + Privé + Location + Payé + Payé le + Entité payeuse + Statut de l'entité payeuse + Référence de paiement + Transfert vers le portail + Référence du compte portail + Un libellé pour le compte MARS (jamais un mot de passe ou un jeton). + Rôle sur le portail + Éligible portail à portail + Pré-approuvé + Préparer la demande de dépenses + Préparer le F-42 + Préparer un enregistrement + Un F-42 par ressource / demande commandée ; un réengagement remplace le précédent. Les demandes de dépenses se rattachent à un F-42 ou suivent la voie déplacement seul. + Préparé pour MARS — rien n'est soumis tant qu'un gestionnaire n'a pas enregistré ce qui a été observé dans le portail. + Imprimable + Provenance + F-42 et demandes de dépenses à préparer, valider et transférer, groupés par déploiement ; statuts MARS observés et factures à côté. + Grade + En-tête, lignes et (pour le taux administratif) la feuille des coûts réels de l'année précédente. + Lignes de taux + Les lignes salariales nécessitent une classification ; les lignes d'équipement un code ressource ou FEMA. Les taux sont normal / heures sup. par base. + Cette soumission est signée ou soumise ; les modifications nécessitent une nouvelle version. + Version du profil de taux + Accepté + Brouillon + Vérifié + Signé localement + Soumis dans MARS + Remplacé + Enquête salariale, Annexe A, taux administratif, Rate Letter Cal OES et équipement spécial par année de soumission. + Aucun taux administratif enregistré pour cette date ; aucune ligne administrative ne sera estimée. + Le profil d'agence (désignateur MACS, contacts, identifiants) n'a pas été saisi. + Le profil d'agence n'a pas été vérifié dans MARS depuis un an. + Un accord se termine dans le délai d'alerte. + Aucune méthode de rémunération MOU/MOA/GBR ne couvre cette date ; le personnel est estimé aux heures réelles sans heures supplémentaires. + Préparation au + Aucun profil d'autorité Cal OES validé ne couvre cette date ; les nouvelles soumissions sont bloquées jusqu'à l'ajout d'un profil. + Tableau de préparation + Aucun FEIN enregistré pour l'agence. + Aucun identifiant fournisseur FI$Cal enregistré pour l'agence. + Préparation de l'agence, des ressources F-5, des taux annuels et des accords pour une date d'engagement, avec liens vers la documentation officielle Cal OES. + Des factures générées par MARS attendent la décision locale. + Le désignateur MACS de l'agence est manquant. + Aucune correspondance F-5 n'existe ; les engins d'un F-42 seront signalés comme hors inventaire. + Une soumission annuelle expire dans le délai d'alerte. + Aucune ligne de la Rate Letter Cal OES (engins, véhicules de soutien, kilométrage POV) ne couvre cette date. + Une soumission annuelle en vigueur n'a pas été signée localement. + Des ressources F-5 ne correspondent pas à ce que MARS affiche. + Des enregistrements renvoyés par Cal OES pour révision attendent. + Aucune enquête salariale validée (ou taux de base accepté) ne couvre cette date ; les lignes de personnel ne peuvent pas être estimées. + Aucun SAM UEI enregistré pour l'agence. + Prêt + Prêt pour le portail. + Reçu + Rapprochement des factures et paiements + Factures générées par MARS telles qu'observées, décision locale d'approbation / rejet, statut de l'entité payeuse et faits de paiement par rapport aux lignes attendues. + Enregistrer une facture MARS + Saisissez la facture générée par Cal OES telle qu'elle apparaît dans le portail, et les enregistrements soumis qu'elle couvre. + Enregistrer l'observation + Enregistrer le paiement + Seul un paiement observé de l'entité payeuse marque la facture et ses enregistrements comme payés. + Enregistrer la soumission observée dans MARS + Type d'enregistrement + Taux administratif + Profil d'agence + Accord + Annexe A + Demande de dépenses + F-42 + Facture MARS + Inventaire F-5 + Enquête salariale + Équipement spécial + Enregistré + Réengagement de + Rejeter + Enregistrements liés + Libération n'est pas retour ; le F-42 exige l'heure de retour ou de réengagement. + Libéré + Lieu de présentation + Demande + Code ressource + Inventaire des ressources F-5 + Nature de ressource + Ressources F-5 + Type de ressource + Correspondance entre les unités et actifs Resgrid et leur identité MARS / F-5. Prépare et rapproche l'inventaire ; n'écrit jamais le statut FAST. + Signataire de l'agence + Renvoyés pour révision + Motif + État de révision + Brouillon + Écart + Observé dans MARS + Vérifié + Révision + Accepté + Exclu + En attente + vérifié le + Lancer la liste de contrôle + Inscription SAM + Enregistrer + La modification n'a pas pu être enregistrée. + Enregistrer les entrées + Enregistrer les lignes + Enregistré. + Numéro de série + Observé accepté + Retour au brouillon + Marquer vérifié + Signer localement + Observé soumis dans MARS + Remplacer + Gravité + Signé par (nom) + Signé le + Signataire + Source + Artefact source + Date source + Ligne source + Système source + URL source + Début + État + Approuvé + Clôturé + Documentation seule + Brouillon + Rejeté par l'agence locale + À réviser + Payé + En attente d'approbation locale + En attente de l'entité payeuse + Prêt pour le portail + Renvoyé pour révision + Observé dans MARS (revue Cal OES) + Statut + Taux normal + Strike team / task force + Sujet + Type de sujet + Ressource externe + Actif d'inventaire + Unité + Soumission + Type de soumission + Taux administratif + Annexe A (hors extinction) + Rate Letter Cal OES + Enquête salariale + Équipement spécial / codes FEMA + remplace + Numéros d'approvisionnement + Pièces justificatives + Agence + Accords + File d'actions + Taux annuels + Préparation + Rapprochement + Ressources F-5 + Déplacement seul (sans F-42) + SAM UEI + Unité + Désignateur d'unité + Validé + Liste de contrôle : {0} erreur(s), {1} avertissement(s). + Le profil d'agence est manquant. + Aucun accord MOU / MOA / GBR ne couvre la date d'engagement. + Aucun profil d'autorité validé ne couvre cet enregistrement. + La classification d'une personne n'est ni dans l'enquête salariale ni dans la liste de référence. + L'heure d'engagement est manquante. + Marqué Documentation seule : aucun remboursement demandé. + Le même véhicule apparaît plus d'une fois. + L'approbateur de la demande est manquant. + Le F-42 lié n'a pas été observé soumis dans MARS. + La demande de dépenses n'a pas de lignes. + Une ligne de dépense n'a pas de reçu. + La signature de la demande est manquante. + L'autorisation incident / AREP est manquante. + Nom ou numéro d'incident manquant. + Le désignateur MACS est manquant. + Le numéro de commande d'incident est manquant. + Aucun F-42 signé ou papier n'est joint au déploiement. + L'intervalle de mobilisation d'une personne sort de la fenêtre engagement-retour. + Aucun personnel listé. + Aucune ligne de taux annuel n'est en vigueur à la date d'engagement. + La ressource est libérée mais aucune heure de retour ou de réengagement n'est enregistrée. + Le numéro de demande est manquant. + Le numéro de demande n'a pas de préfixe valide (E, O, C, S, A) et de numéro. + Type, nature de ressource ou poste overhead manquant. + La signature de l'agence intervenante est manquante. + L'heure de retour précède l'engagement. + Une rotation d'équipe n'a pas de pièce jointe d'approbation. + Un véhicule n'a pas de ligne de correspondance F-5. + Valeur + Écart + Vérification + Marquez le profil vérifié après l'avoir comparé à la fiche agence dans MARS. Le tableau avertit après un an. + Vérifié le + Version + Avertissement + Période + Ordre officiel des cases, liens vers les faits immuables du déploiement, liste de contrôle, remboursement attendu et transfert vers le portail. + Année + Oui + Les montants d'entrée ne peuvent pas être négatifs. + Une classification d'entrée n'est pas valide. + Un exercice d'entrée n'est pas valide (années antérieures uniquement). + Le nom de l'agence est requis. + Le profil d'agence est introuvable. + L'accord est référencé par un élément et ne peut pas être supprimé. + Le type de document d'accord n'est pas valide. + La méthode de rémunération n'est pas valide. + L'accord est introuvable. + La méthode d'heures supplémentaires n'est pas valide. + Confirmez l'attestation avant d'ouvrir la vue de transfert. + La date de fin précède la date de début. + La décision nécessite le nom / titre du décideur. + Le déploiement est introuvable. + Le F-42 lié est introuvable sur ce déploiement. + Cette demande / fill n'est pas sur la commande externe du déploiement. + La commande comporte plusieurs demandes ; choisissez la demande / fill pour ce F-42. + Le montant facturé ne peut pas être négatif. + Une facture MARS avec cet identifiant est déjà enregistrée. + L'identifiant de facture MARS est requis. + Enregistrez l'approbation locale avant un paiement. + La facture n'est pas dans un état permettant d'enregistrer un paiement. + La facture n'est pas en attente d'approbation locale. + L'enregistrement n'est pas Prêt pour le portail ; lancez d'abord la liste de contrôle. + Le montant payé ne peut pas être négatif. + La soumission n'a ni lignes, ni taux administratif, ni taux de base accepté. + Une base de ligne de taux n'est pas valide. + Les lignes salariales nécessitent un code de classification. + Un type de ligne de taux n'est pas valide. + Les taux ne peuvent pas être négatifs. + Les lignes d'équipement nécessitent un code ressource ou FEMA. + Cette soumission est signée ou soumise et ne peut plus être modifiée. + La soumission annuelle est introuvable. + Enregistrez la signature locale avant d'observer un statut externe. + Le taux administratif doit être compris entre 0 et 100 %. + La signature nécessite le nom du signataire. + Ce changement de statut n'est pas autorisé. + Le type de soumission n'est pas valide. + L'année de soumission n'est pas valide. + Un rejet nécessite un commentaire. + Une ressource externe nécessite un nom. + La ligne de ressource est introuvable. + Le type de sujet de la ressource n'est pas valide. + L'unité est introuvable dans ce service. + Une unité est requise pour une ressource d'unité. + Ce statut externe n'est pas dans le vocabulaire du profil d'autorité. + L'enregistrement a été observé dans MARS et n'est plus modifiable localement. + Seuls les F-42 et demandes de dépenses ont un remboursement attendu. + Seuls les enregistrements approuvés, documentation seule, rejetés ou payés peuvent être clôturés. + L'enregistrement n'a pas été observé soumis dans MARS. + L'enregistrement est introuvable. + L'enregistrement n'est pas une facture MARS. + Seuls les F-42 et demandes de dépenses sont soumis à MARS. + L'enregistrement est payé ou clôturé ; ses lignes sont définitives. + L'enregistrement n'est pas du type attendu. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx new file mode 100644 index 00000000..396798b8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Coda azioni incidente + Attivo + Importo effettivo + Ore effettive (DTR) + Aggiungi accordo + Aggiungi input + Aggiungi riga + Aggiungi risorsa + Aggiungi rotazione + Indirizzo + Possibili doppi conteggi sono ancora in attesa di revisione. + Alcuni input sono ancora in attesa di revisione. + Nessuna base di costo diretto ammissibile; la tariffa non può essere calcolata. + Nessun input di costi effettivi; l'opzione de minimis è l'unica scelta. + Metodo amministrativo + Calcolato dai costi effettivi + De minimis + Nessuno + Input tariffa amministrativa + Costi effettivi dell'anno precedente per funzione e categoria, classificati diretti / indiretti / non ammissibili. I budget non entrano mai qui. + Tariffa amministrativa + Foglio tariffa amministrativa + Indiretto ammissibile ÷ diretto ammissibile dagli input accettati, confrontato con l'opzione de minimis. I doppi conteggi irrisolti bloccano il risultato. + Età + Agenzia + Categoria agenzia + Il record agenzia MARS: designatore MACS, contatti e identificativi stampati su ogni richiesta. + Nome agenzia + Profilo agenzia + Accordo + Accordi + Metodi di compenso MOU / MOA / GBR approvati per classificazione. Un F-42 seleziona quello in vigore all'invio iniziale. + Tutte le classificazioni + Tutti i tipi + Tutti gli anni + Diretto ammissibile + Indiretto ammissibile + Importo + Tariffe annuali + Invii annuali in vigore + Allegato approvazione + Approva + Approvatore + ID allegato dello schieramento + Facoltativo: l'accordo firmato caricato come allegato dello schieramento. + Autorità + Attrezzatura speciale dell'agenzia + Inviato dall'agenzia + Tariffa base Cal OES + Rate Letter Cal OES + Tabella FEMA + Profilo dell'autorità + Nessun profilo verificato copre questa data + Indietro + Tariffa base Cal OES accettata + L'agenzia adotta la tariffa base Cal OES invece di inviare le proprie righe. + Base + Giornaliero + Forfettario + Orario + Per miglio + Percentuale + Bloccato + Bloccante + Casella + Agenzia rispondente + Mezzi, veicoli di supporto e attrezzature + Allegati + Commenti, perdita / danno, numeri forniture + Invio / impegno + Incidente + Numero ordine incidente + Personale + Numero richiesta + Risorsa + Rientro / reinvio + Rotazioni squadra + Firme e autorizzazione + Calcola tariffa amministrativa + Cal OES MARS + Calcola + Tariffa calcolata + Annulla + Categoria + Categoria + Alloggio + Pasto + Varie + Noleggio + Verifica data + Checklist + Checksum + Città + Classificazione + Titolo classificazione + Chiudi + Commento + Commenti + Impegnato + Ore impegnate + Metodo di compenso + Ore effettive + Portal-to-portal + Eliminare questo record? + E-mail contatto + Nome contatto + Telefono contatto + Copia + Commento del revisore + Diretto + Indiretto + Non ammissibile + In sintesi + Record coperti + Record F-42 / spese inviati che questa fattura paga; i loro totali attesi sono confrontati con l'importo fatturato. + Data + Opzione de minimis + Decisione di (nome / titolo) + Elimina + Schieramento + Descrizione + Designatore + Dettaglio + Tipo documento + Equivalente + GBR + MOA + MOU + Solo documentazione + Il record documenta l'intervento senza chiedere rimborso (percorso Documentation Only di MARS). + Possibile doppio conteggio + Bozza dalle unità + Seleziona le unità e crea righe per quelle che non ne hanno. + Modifica agenzia + Modifica accordo + Modifica risorsa + Valido dal + Ammissibile + Escluso + Incerto + Fine + Pacchetto di evidenze + Escluso (fatturato agli incidenti) + Escluso (non ammissibile) + Atteso + Rimborso atteso + Una stima dalle tariffe e dall'accordo in vigore all'invio iniziale. Cal OES determina l'importo ammesso; nessun costo interno entra in queste righe. + Totale atteso + Atteso contro osservato + Scade il + Nome risorsa esterna + Agenzia + Mezzi + Allegati + Commenti + Invio + Incidente + Autorizzazione incidente + Righe spese + Ordine + Personale + Richiesta + Risorsa + Firma dell'agenzia + Rientro + Rotazione + Firma + FEIN + Richiesta / fill + ID fill dell'ordine esterno RMS (richiesto se l'ordine ha più richieste) + Fornitore FI$Cal + Correggi + Funzione + Generato + Sono la persona autorizzata a inserire questo record in MARS e copierò i valori mostrati, non il file di questa pagina. + Vista di copia per l'inserimento manuale nel portale MARS. Non salvata, non in cache e non un invio. + Esegui la checklist finché è pulita; il passaggio si apre da Pronto per il portale. + Preparato per MARS. Aprire questa vista registra una voce di audit e non cambia nulla; il record diventa Osservato in MARS solo quando un responsabile registra ciò che mostra il portale. + La checklist ha ancora errori; MARS potrebbe restituire questo record. + Questi valori vengono copiati su F-42 e richieste e memorizzati come dati (non sotto Advanced Data Protection). Cambiare un identificativo azzera l'ultima verifica. + Lascia vuota la classificazione per un accordo valido per tutto il dipartimento. Un accordo referenziato da un record inviato è immutabile; modificarlo crea una nuova versione. + I membri sul campo vedono le proprie bozze; i responsabili MARS vedono la coda del dipartimento. Aprire un passaggio o scaricare un pacchetto non segna mai un record come inviato. + Le tariffe sono dati legati a un profilo dell'autorità verificato; una nuova Rate Letter o indagine è una nuova istantanea e un invio precedente mantiene quella in vigore all'inizio. + I bloccanti impediscono ai nuovi F-42 di raggiungere Pronto per il portale; gli avvisi vanno risolti prima del prossimo invio. + Una fattura MARS è un elemento di lavoro, mai una fattura cliente di Fase B: nessun numero, nessuno scadenzario, nessuna e-mail. Pagato è sempre e solo un fatto di pagamento osservato. + Le righe bozza copiano nome, targa e VIN dell'unità in quel momento; verificale, aggiungi il tipo risorsa MARS e poi registra ciò che mostra MARS. + Ore + Identificativi + Autorizzatore incidente / AREP + Fatturato direttamente a un incidente (escluso) + Include assicurazione disoccupazione + Include infortuni sul lavoro + Entrante + Data fattura + Atteso contro osservato, la decisione locale e i fatti di pagamento per una fattura generata da MARS. + Fatturato + Fatture in attesa di approvazione locale + Elemento + Genere + Mezzo + Attrezzatura speciale + Veicolo privato + Veicolo di supporto + Targa + Tipo riga + Amministrativo + Tariffa amministrativa + Mezzo + Allegato A (non soppressione) + Spesa + Pasti, alloggio, spese accessorie + Mezzo ufficiale + Veicolo di supporto ufficiale + Personale + Miglia POV + Miglia POV + Noleggio + Salary Survey + Attrezzatura speciale + Veicolo di supporto + F-42 collegato + Decisione dell'agenzia locale + Approva o respingi la fattura come rappresentante autorizzato; un rifiuto richiede un commento. La decisione è registrata qui e inserita in MARS da te. + Perdita / danno + Designatore MACS + Gestire il rimborso Cal OES MARS + Record di agenzia, risorse F-5, tariffe annuali e accordi, passaggio al portale, stati MARS osservati e approvazione fatture/riconciliazione pagamenti. I membri in organico preparano le proprie bozze F-42 e spese senza questo permesso. + Segna come verificato oggi + Fattura MARS + ID fattura MARS + Questo è un elemento di lavoro MARS. Non ha numero di fattura di Fase B, non compare mai nello scadenzario clienti e non può essere inviato via e-mail o pagato online. + Fatture MARS + ID record MARS + ID risorsa MARS + Metodo scelto + Miglia + In organico su questo schieramento + discrepanze + Nome + Nuovo invio + No + Nessun profilo agenzia. + Nessun accordo. + Nessun allegato dello schieramento. + Nessuno schieramento a recupero costi da preparare. + Resgrid non memorizza credenziali MARS, token MFA o sessioni del browser e non scrive mai nel portale. + Nessun invio annuale copre questa data. + Nessuno schieramento + Nessuna fattura MARS registrata. + Niente in coda. + Nessun invio annuale. + Tutto il necessario per un invio MARS è pronto. + Nessuna riga risorsa F-5. + Il pacchetto di evidenze non è un file di importazione accettato da MARS. + Non ancora calcolato. + Non corrente + Non nell'inventario F-5 + Non ancora validato. + Osservato in MARS + Osservato il + Stato osservato + Invio osservato + Contachilometri + Fonti ufficiali + Apri + Apri schieramento + Apri vista passaggio + Apri il portale MARS + Elementi aperti + Uscente + Posizione overhead + Idoneo allo straordinario + Metodo straordinario + Dopo 8 ore al giorno + Dopo 12 ore al giorno + Nessuno + Secondo accordo (non modellato) + Tariffa straordinario + Proprietà + CAL FIRE + Cal OES + Agenzia locale + Altro + Privato + Noleggio + Pagato + Pagato il + Ente pagatore + Stato ente pagatore + Riferimento pagamento + Passaggio al portale + Riferimento account portale + Un'etichetta per l'account MARS (mai una password o un token). + Ruolo nel portale + Idoneo portal-to-portal + Pre-approvato + Prepara richiesta spese + Prepara F-42 + Prepara un record + Un F-42 per risorsa / richiesta ordinata; un reinvio sostituisce il precedente. Le richieste spese si collegano a un F-42 o seguono il percorso solo viaggio. + Preparato per MARS — nulla è inviato finché un responsabile non registra quanto osservato nel portale. + Stampabile + Provenienza + F-42 e richieste spese da preparare, validare e consegnare, raggruppati per schieramento; stati MARS osservati e fatture accanto. + Grado + Intestazione, righe e (per la tariffa amministrativa) il foglio dei costi effettivi dell'anno precedente. + Righe tariffa + Le righe salariali richiedono una classificazione; quelle attrezzature un codice risorsa o FEMA. Le tariffe sono ordinaria / straordinaria per base. + Questo invio è firmato o inviato; le modifiche richiedono una nuova versione. + Versione profilo tariffe + Accettato + Bozza + Verificato + Firmato localmente + Inviato in MARS + Sostituito + Salary Survey, Allegato A, tariffa amministrativa, Rate Letter Cal OES e attrezzature speciali per anno di invio. + Nessuna tariffa amministrativa registrata per questa data; nessuna riga amministrativa sarà stimata. + Il profilo agenzia (designatore MACS, contatti, identificativi) non è stato inserito. + Il profilo agenzia non è stato verificato con MARS nell'ultimo anno. + Un accordo termina entro la finestra di preavviso. + Nessun metodo di compenso MOU/MOA/GBR copre questa data; il personale è stimato a ore effettive senza straordinario. + Prontezza al + Nessun profilo dell'autorità Cal OES verificato copre questa data; i nuovi invii sono bloccati finché non ne viene aggiunto uno. + Cruscotto di prontezza + Nessun FEIN registrato per l'agenzia. + Nessun id fornitore FI$Cal registrato per l'agenzia. + Prontezza di agenzia, risorse F-5, tariffe annuali e accordi per una data di invio, con link al materiale ufficiale Cal OES. + Fatture generate da MARS attendono la decisione locale dell'agenzia. + Manca il designatore MACS dell'agenzia. + Non esistono righe di corrispondenza F-5; i mezzi su un F-42 saranno segnalati come non in inventario. + Un invio annuale scade entro la finestra di preavviso. + Nessuna riga della Rate Letter Cal OES (mezzi, veicoli di supporto, miglia POV) copre questa data. + Un invio annuale in vigore non è stato firmato localmente. + Le righe risorse F-5 non corrispondono a quanto mostra MARS. + Record restituiti da Cal OES per la revisione dell'agenzia sono in attesa. + Nessun Salary Survey verificato (o tariffa base accettata) copre questa data; le righe del personale non possono essere stimate. + Nessun SAM UEI registrato per l'agenzia. + Pronto + Pronto per il portale. + Ricevuta + Riconciliazione fatture e pagamenti + Fatture generate da MARS come osservate, la decisione locale di approvazione / rifiuto, lo stato dell'ente pagatore e i fatti di pagamento rispetto alle righe attese. + Registra una fattura MARS + Registra la fattura generata da Cal OES come la vedi nel portale e i record inviati che copre. + Registra osservazione + Registra pagamento + Solo un pagamento osservato dell'ente pagatore segna la fattura e i suoi record come pagati. + Registra invio osservato in MARS + Tipo record + Tariffa amministrativa + Profilo agenzia + Accordo + Allegato A + Richiesta spese + F-42 + Fattura MARS + Inventario risorse F-5 + Salary Survey + Attrezzatura speciale + Registrato + Reinvio di + Respingi + Record correlati + Il rilascio non è il rientro; l'F-42 richiede l'ora di rientro o reinvio. + Rilasciato + Luogo di presentazione + Richiesta + Codice risorsa + Inventario risorse F-5 + Genere risorsa + Risorse F-5 + Tipo risorsa + Corrispondenza tra unità e beni Resgrid e la loro identità MARS / F-5. Prepara e riconcilia l'inventario; non scrive mai lo stato FAST. + Firmatario dell'agenzia + Restituiti per revisione + Motivo + Stato revisione + Bozza + Discrepanza + Osservato in MARS + Verificato + Revisione + Accettato + Escluso + In attesa + verificato il + Esegui checklist + Registrazione SAM + Salva + Impossibile salvare la modifica. + Salva input + Salva righe + Salvato. + Numero di serie + Osservato accettato + Torna a bozza + Segna come verificato + Firma localmente + Osservato inviato in MARS + Sostituisci + Gravità + Firmato da (nome) + Firmato il + Firmatario + Origine + Artefatto di origine + Data di origine + Riga di origine + Sistema di origine + URL di origine + Inizio + Stato + Approvato + Chiuso + Solo documentazione + Bozza + Respinto dall'agenzia locale + Da rivedere + Pagato + In attesa di approvazione locale + In attesa dell'ente pagatore + Pronto per il portale + Restituito per revisione dell'agenzia + Osservato in MARS (revisione Cal OES) + Stato + Tariffa ordinaria + Strike team / task force + Soggetto + Tipo soggetto + Risorsa esterna + Bene di inventario + Unità + Invio + Tipo di invio + Tariffa amministrativa + Allegato A (non soppressione) + Rate Letter Cal OES + Salary Survey + Attrezzature speciali / codici FEMA + sostituisce + Numeri forniture + Documenti a supporto + Agenzia + Accordi + Coda azioni + Tariffe annuali + Prontezza + Riconciliazione + Risorse F-5 + Solo viaggio (senza F-42) + SAM UEI + Unità + Designatore unità + Validato + Checklist: {0} errore/i, {1} avviso/i. + Manca il profilo agenzia. + Nessun accordo MOU / MOA / GBR copre la data di invio. + Nessun profilo dell'autorità verificato copre questo record. + La classificazione di una persona non è nel Salary Survey né nell'elenco di riferimento. + Manca l'ora di invio. + Segnato Solo documentazione: nessun rimborso richiesto. + Lo stesso veicolo compare più di una volta. + Manca l'approvatore della richiesta. + L'F-42 collegato non è stato osservato inviato in MARS. + La richiesta spese non ha righe. + Una riga spesa non ha ricevuta. + Manca la firma della richiesta. + Manca l'autorizzazione incidente / AREP. + Manca il nome o il numero dell'incidente. + Manca il designatore MACS. + Manca il numero ordine incidente. + Nessun F-42 firmato o cartaceo è allegato allo schieramento. + L'intervallo di impegno di una persona è fuori dalla finestra invio-rientro. + Nessun personale elencato. + Nessuna riga tariffaria annuale è in vigore alla data di invio. + La risorsa è rilasciata ma non è registrata l'ora di rientro o reinvio. + Manca il numero richiesta. + Il numero richiesta non ha un prefisso valido (E, O, C, S, A) e un numero. + Manca tipo, genere risorsa o posizione overhead. + Manca la firma dell'agenzia rispondente. + L'ora di rientro precede l'invio. + Una rotazione non ha allegato di approvazione. + Un veicolo non ha una riga di corrispondenza F-5. + Valore + Scostamento + Verifica + Segna il profilo come verificato dopo il confronto con il record agenzia in MARS. Il cruscotto avvisa dopo un anno. + Verificato il + Versione + Avviso + Periodo + Ordine ufficiale delle caselle, link ai fatti immutabili dello schieramento, checklist, rimborso atteso e passaggio al portale. + Anno + + Gli importi di input non possono essere negativi. + Una classificazione di input non è valida. + Un anno fiscale di input non è valido (solo anni precedenti). + Il nome dell'agenzia è obbligatorio. + Profilo agenzia non trovato. + L'accordo è referenziato da un elemento e non può essere eliminato. + Il tipo documento dell'accordo non è valido. + Il metodo di compenso non è valido. + L'accordo non è stato trovato. + Il metodo straordinario non è valido. + Conferma l'attestazione prima di aprire la vista di passaggio. + La data di fine precede quella di inizio. + La decisione richiede nome / titolo di chi decide. + Lo schieramento non è stato trovato. + L'F-42 collegato non è stato trovato su questo schieramento. + Quella richiesta / fill non è sull'ordine esterno dello schieramento. + L'ordine ha più richieste; scegli la richiesta / fill per questo F-42. + L'importo fatturato non può essere negativo. + Una fattura MARS con quell'ID è già registrata. + L'ID fattura MARS è obbligatorio. + Registra l'approvazione locale prima di un pagamento. + La fattura non è in uno stato in cui si possa registrare un pagamento. + La fattura non è in attesa di approvazione locale. + Il record non è Pronto per il portale; esegui prima la checklist. + L'importo pagato non può essere negativo. + L'invio non ha righe, né tariffa amministrativa, né tariffa base accettata. + Una base di riga tariffa non è valida. + Le righe salariali richiedono un codice di classificazione. + Un tipo di riga tariffa non è valido. + Le tariffe non possono essere negative. + Le righe attrezzature richiedono un codice risorsa o FEMA. + Questo invio è firmato o inviato e non può più essere modificato. + L'invio annuale non è stato trovato. + Registra la firma locale prima di osservare uno stato esterno. + La tariffa amministrativa deve essere tra 0 e 100 percento. + La firma richiede il nome del firmatario. + Questo cambio di stato non è consentito. + Il tipo di invio non è valido. + L'anno di invio non è valido. + Un rifiuto richiede un commento. + Una risorsa esterna richiede un nome. + La riga risorsa non è stata trovata. + Il tipo soggetto della risorsa non è valido. + L'unità non è stata trovata in questo dipartimento. + Per una risorsa unità è richiesta un'unità. + Quello stato esterno non è nel vocabolario del profilo dell'autorità. + Il record è stato osservato in MARS e non è più modificabile localmente. + Solo F-42 e richieste spese hanno un rimborso atteso. + Solo i record approvati, solo documentazione, respinti o pagati possono essere chiusi. + Il record non è stato osservato inviato in MARS. + Il record non è stato trovato. + Il record non è una fattura MARS. + Solo F-42 e richieste spese vengono inviati a MARS. + Il record è pagato o chiuso; le sue righe sono definitive. + Il record non è del tipo atteso. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx new file mode 100644 index 00000000..ff0e123c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Kolejka działań zdarzenia + Aktywny + Kwota rzeczywista + Godziny rzeczywiste (DTR) + Dodaj porozumienie + Dodaj pozycję + Dodaj pozycję + Dodaj zasób + Dodaj rotację + Adres + Możliwe podwójne liczenia nadal czekają na przegląd. + Niektóre pozycje nadal czekają na przegląd. + Brak dozwolonej bazy kosztów bezpośrednich; nie można obliczyć stawki. + Brak danych o kosztach rzeczywistych; opcja de minimis jest jedynym wyborem. + Metoda administracyjna + Obliczona z kosztów rzeczywistych + De minimis + Brak + Dane stawki administracyjnej + Koszty rzeczywiste z poprzedniego roku według funkcji i kategorii, sklasyfikowane jako bezpośrednie / pośrednie / niedozwolone. Budżety nigdy tu nie trafiają. + Stawka administracyjna + Arkusz stawki administracyjnej + Dozwolone pośrednie ÷ dozwolone bezpośrednie z zaakceptowanych pozycji, porównane z opcją de minimis. Nierozwiązane podwójne liczenia blokują wynik. + Wiek + Agencja + Kategoria agencji + Rekord agencji w MARS: oznaczenie MACS, kontakty i identyfikatory drukowane na każdym roszczeniu. + Nazwa agencji + Profil agencji + Porozumienie + Porozumienia + Zatwierdzone metody wynagradzania MOU / MOA / GBR według klasyfikacji. F-42 wybiera obowiązującą przy pierwszym dysponowaniu. + Wszystkie klasyfikacje + Wszystkie typy + Wszystkie lata + Dozwolone bezpośrednie + Dozwolone pośrednie + Kwota + Stawki roczne + Obowiązujące zgłoszenia roczne + Załącznik zatwierdzenia + Zatwierdź + Zatwierdzający + Identyfikator załącznika wdrożenia + Opcjonalnie: podpisane porozumienie przesłane jako załącznik wdrożenia. + Podstawa + Sprzęt specjalny agencji + Zgłoszone przez agencję + Stawka bazowa Cal OES + Cal OES Rate Letter + Cennik FEMA + Profil źródłowy + Żaden zweryfikowany profil nie obejmuje tej daty + Wstecz + Zaakceptowano stawkę bazową Cal OES + Agencja przyjmuje stawkę bazową Cal OES zamiast własnych wierszy ankiety. + Podstawa + Dzienna + Ryczałt + Godzinowa + Za milę + Procent + Zablokowane + Blokada + Pole + Agencja odpowiadająca + Pojazdy, wsparcie i sprzęt + Załączniki + Komentarze, straty / uszkodzenia, numery zaopatrzenia + Dysponowanie / zaangażowanie + Zdarzenie + Numer zamówienia zdarzenia + Personel + Numer wniosku + Zasób + Powrót / ponowne dysponowanie + Rotacje załogi + Podpisy i autoryzacja + Oblicz stawkę administracyjną + Cal OES MARS + Oblicz + Stawka obliczona + Anuluj + Kategoria + Kategoria + Nocleg + Posiłek + Różne + Wynajem + Sprawdź datę + Lista kontrolna + Suma kontrolna + Miasto + Klasyfikacja + Nazwa klasyfikacji + Zamknij + Komentarz + Komentarze + Zaangażowany + Godziny zaangażowania + Metoda wynagradzania + Godziny rzeczywiste + Portal-to-portal + Usunąć ten rekord? + E-mail kontaktowy + Osoba kontaktowa + Telefon kontaktowy + Kopiuj + Komentarz recenzenta + Bezpośredni + Pośredni + Niedozwolony + W skrócie + Objęte rekordy + Złożone rekordy F-42 / wydatków, które opłaca ta faktura; ich oczekiwane sumy porównuje się z kwotą faktury. + Data + Opcja de minimis + Decyzja (imię i nazwisko / stanowisko) + Usuń + Wdrożenie + Opis + Oznaczenie + Szczegół + Rodzaj dokumentu + Równoważny + GBR + MOA + MOU + Tylko dokumentacja + Rekord dokumentuje działanie bez ubiegania się o zwrot (ścieżka Documentation Only w MARS). + Możliwe podwójne liczenie + Szkic z jednostek + Wybierz jednostki i utwórz wiersze dla tych bez odwzorowania. + Edytuj agencję + Edytuj porozumienie + Edytuj zasób + Obowiązuje od + Kwalifikuje się + Wykluczone + Niepewne + Koniec + Pakiet dowodów + Wykluczone (rozliczone na zdarzenia) + Wykluczone (niedozwolone) + Oczekiwane + Oczekiwany zwrot + Szacunek ze stawek i porozumienia obowiązujących przy pierwszym dysponowaniu. Cal OES ustala dozwoloną kwotę; koszty wewnętrzne nie wchodzą do tych pozycji. + Suma oczekiwana + Oczekiwane a zaobserwowane + Wygasa + Nazwa zasobu zewnętrznego + Agencja + Pojazdy + Załączniki + Komentarze + Dysponowanie + Zdarzenie + Autoryzacja zdarzenia + Pozycje wydatków + Zamówienie + Personel + Wniosek + Zasób + Podpis agencji + Powrót + Rotacja + Podpis + FEIN + Wniosek / fill + Identyfikator fill zamówienia zewnętrznego RMS (wymagany, gdy zamówienie ma więcej niż jeden wniosek) + Dostawca FI$Cal + Napraw + Funkcja + Wygenerowano + Jestem osobą upoważnioną do wprowadzenia tego rekordu w MARS i skopiuję wyświetlone wartości, a nie plik z tej strony. + Widok do kopiowania przy ręcznym wprowadzaniu w portalu MARS. Niezapisywany, niebuforowany i niebędący zgłoszeniem. + Uruchamiaj listę kontrolną, aż będzie czysta; przekazanie otwiera się od stanu Gotowe do portalu. + Przygotowane dla MARS. Otwarcie tego widoku tworzy wpis audytu i niczego nie zmienia; rekord staje się Zaobserwowany w MARS dopiero, gdy menedżer zapisze to, co pokazuje portal. + Lista kontrolna nadal zawiera błędy; MARS może zwrócić ten rekord. + Te wartości są kopiowane do F-42 i roszczeń i przechowywane jako dane (poza Advanced Data Protection). Zmiana identyfikatora kasuje ostatnią weryfikację. + Pozostaw klasyfikację pustą dla porozumienia dla całego wydziału. Porozumienie, do którego odwołuje się złożony rekord, jest niezmienne; edycja tworzy nową wersję. + Członkowie w terenie widzą własne szkice; menedżerowie MARS widzą kolejkę wydziału. Otwarcie przekazania lub pobranie pakietu nigdy nie oznacza rekordu jako złożonego. + Stawki to dane przypięte do zweryfikowanego profilu; nowy Rate Letter lub ankieta to nowa migawka, a wcześniejsze dysponowanie zachowuje tę obowiązującą na starcie. + Blokady uniemożliwiają nowym F-42 osiągnięcie stanu Gotowe do portalu; ostrzeżenia warto usunąć przed kolejnym dysponowaniem. + Faktura MARS to element pracy, nigdy faktura klienta z Fazy B: bez numeru, bez wiekowania, bez e-maila. Zapłacone jest zawsze jedynie zaobserwowanym faktem płatności. + Wiersze szkicu kopiują nazwę, tablicę i VIN jednostki w danej chwili; sprawdź je, dodaj typ zasobu MARS, a następnie zapisz to, co pokazuje MARS. + Godziny + Identyfikatory + Autoryzujący zdarzenie / AREP + Rozliczone bezpośrednio na zdarzenie (wykluczone) + Zawiera ubezpieczenie od bezrobocia + Zawiera ubezpieczenie wypadkowe + Przychodzący + Data faktury + Oczekiwane a zaobserwowane, decyzja lokalna i fakty płatności dla jednej faktury wygenerowanej przez MARS. + Zafakturowano + Faktury oczekujące na lokalne zatwierdzenie + Element + Rodzaj + Pojazd + Sprzęt specjalny + Pojazd prywatny + Pojazd wsparcia + Tablica rejestracyjna + Rodzaj pozycji + Administracyjny + Stawka administracyjna + Pojazd + Załącznik A (poza gaszeniem) + Wydatek + Posiłki, nocleg, wydatki dodatkowe + Pojazd urzędowy + Urzędowy pojazd wsparcia + Personel + Przebieg POV + Przebieg POV + Wynajem + Salary Survey + Sprzęt specjalny + Pojazd wsparcia + Powiązany F-42 + Decyzja agencji lokalnej + Zatwierdź lub odrzuć fakturę jako upoważniony przedstawiciel; odrzucenie wymaga komentarza. Decyzja jest zapisywana tutaj i wprowadzana do MARS przez Ciebie. + Straty / uszkodzenia + Oznaczenie MACS + Zarządzaj zwrotem Cal OES MARS + Rekordy agencji, zasobów F-5, stawek rocznych i porozumień, przekazanie do portalu, obserwowane statusy MARS oraz zatwierdzanie faktur/uzgadnianie płatności. Członkowie na liście przygotowują własne szkice F-42 i wydatków bez tego uprawnienia. + Oznacz jako zweryfikowane dziś + Faktura MARS + Identyfikator faktury MARS + To element pracy MARS. Nie ma numeru faktury z Fazy B, nigdy nie pojawia się w wiekowaniu klientów i nie można go wysłać e-mailem ani opłacić online. + Faktury MARS + Identyfikator rekordu MARS + Identyfikator zasobu MARS + Wybrana metoda + Mile + Na liście tego wdrożenia + rozbieżności + Nazwa + Nowe zgłoszenie + Nie + Brak profilu agencji. + Brak porozumień. + Brak załączników wdrożenia. + Brak wdrożeń odzyskiwania kosztów do przygotowania. + Resgrid nie przechowuje danych logowania MARS, tokenów MFA ani sesji przeglądarki i nigdy nie zapisuje w portalu. + Żadne zgłoszenie roczne nie obejmuje tej daty. + Bez wdrożenia + Brak zapisanych faktur MARS. + Kolejka jest pusta. + Brak zgłoszeń rocznych. + Wszystko potrzebne do zgłoszenia w MARS jest gotowe. + Brak wierszy zasobów F-5. + Pakiet dowodów nie jest akceptowanym plikiem importu MARS. + Jeszcze nie obliczono. + Nieaktualne + Nie ma w inwentarzu F-5 + Jeszcze nie zwalidowano. + Zaobserwowano w MARS + Zaobserwowano + Obserwowany status + Zaobserwowane zgłoszenie + Licznik + Źródła oficjalne + Otwórz + Otwórz wdrożenie + Otwórz widok przekazania + Otwórz portal MARS + Otwarte elementy + Wychodzący + Stanowisko overhead + Kwalifikuje się do nadgodzin + Metoda nadgodzin + Po 8 godzinach dziennie + Po 12 godzinach dziennie + Brak + Według porozumienia (niemodelowane) + Stawka nadgodzinowa + Własność + CAL FIRE + Cal OES + Agencja lokalna + Inne + Prywatna + Wynajem + Zapłacono + Zapłacono + Płatnik + Status płatnika + Referencja płatności + Przekazanie do portalu + Odniesienie do konta portalu + Etykieta konta MARS (nigdy hasło ani token). + Rola w portalu + Kwalifikuje się portal-to-portal + Wstępnie zatwierdzone + Przygotuj roszczenie wydatków + Przygotuj F-42 + Przygotuj rekord + Jeden F-42 na zamówiony zasób / wniosek; ponowne dysponowanie zastępuje poprzedni. Roszczenia wydatków łączą się z F-42 albo idą ścieżką tylko-podróż. + Przygotowane dla MARS — nic nie jest złożone, dopóki menedżer nie zapisze tego, co zaobserwował w portalu. + Do druku + Pochodzenie + F-42 i roszczenia wydatków do przygotowania, walidacji i przekazania, pogrupowane według wdrożenia; obok obserwowane statusy MARS i faktury. + Stopień + Nagłówek, pozycje i (dla stawki administracyjnej) arkusz kosztów rzeczywistych z poprzedniego roku. + Pozycje stawek + Pozycje płacowe wymagają klasyfikacji; sprzętowe kodu zasobu lub FEMA. Stawki to zwykła / nadgodzinowa według podstawy. + To zgłoszenie jest podpisane lub złożone; zmiany wymagają nowej wersji. + Wersja profilu stawek + Zaakceptowane + Szkic + Zweryfikowane + Podpisane lokalnie + Zgłoszone w MARS + Zastąpione + Salary Survey, Załącznik A, stawka administracyjna, Cal OES Rate Letter i sprzęt specjalny według roku zgłoszenia. + Brak zapisanej stawki administracyjnej na tę datę; pozycja administracyjna nie zostanie oszacowana. + Nie wprowadzono profilu agencji (oznaczenie MACS, kontakty, identyfikatory). + Profil agencji nie był weryfikowany z MARS w ostatnim roku. + Porozumienie kończy się w oknie wyprzedzenia. + Żadna metoda wynagradzania MOU/MOA/GBR nie obejmuje tej daty; personel szacowany według godzin rzeczywistych bez nadgodzin. + Gotowość na + Żaden zweryfikowany profil Cal OES nie obejmuje tej daty; nowe zgłoszenia są zablokowane do czasu dodania profilu. + Panel gotowości + Brak zapisanego FEIN agencji. + Brak zapisanego identyfikatora dostawcy FI$Cal. + Gotowość agencji, zasobów F-5, stawek rocznych i porozumień na datę dysponowania, z odnośnikami do oficjalnych materiałów Cal OES. + Faktury wygenerowane przez MARS czekają na decyzję lokalną. + Brak oznaczenia MACS agencji. + Brak wierszy odwzorowania F-5; pojazdy na F-42 zostaną oznaczone jako nieobjęte inwentarzem. + Zgłoszenie roczne wygasa w oknie wyprzedzenia. + Żadne pozycje Cal OES Rate Letter (pojazdy, wsparcie, przebieg POV) nie obejmują tej daty. + Obowiązujące zgłoszenie roczne nie zostało podpisane lokalnie. + Wiersze zasobów F-5 nie zgadzają się z tym, co pokazuje MARS. + Rekordy zwrócone przez Cal OES do przeglądu czekają. + Żadne zweryfikowane Salary Survey (ani zaakceptowana stawka bazowa) nie obejmuje tej daty; nie można oszacować pozycji personelu. + Brak zapisanego SAM UEI agencji. + Gotowe + Gotowe do portalu. + Paragon + Uzgadnianie faktur i płatności + Faktury wygenerowane przez MARS według obserwacji, lokalna decyzja zatwierdzenia / odrzucenia, status płatnika i fakty płatności względem oczekiwanych pozycji. + Zapisz fakturę MARS + Zapisz fakturę wygenerowaną przez Cal OES tak, jak widzisz ją w portalu, oraz złożone rekordy, które obejmuje. + Zapisz obserwację + Zapisz płatność + Tylko zaobserwowana płatność płatnika oznacza fakturę i jej rekordy jako zapłacone. + Zapisz zgłoszenie zaobserwowane w MARS + Typ rekordu + Stawka administracyjna + Profil agencji + Porozumienie + Załącznik A + Roszczenie wydatków + F-42 + Faktura MARS + Inwentarz F-5 + Salary Survey + Sprzęt specjalny + Zapisano + Ponowne dysponowanie + Odrzuć + Powiązane rekordy + Zwolnienie to nie powrót; F-42 wymaga czasu powrotu lub ponownego dysponowania. + Zwolniony + Miejsce stawienia się + Wniosek + Kod zasobu + Inwentarz zasobów F-5 + Rodzaj zasobu + Zasoby F-5 + Typ zasobu + Odwzorowanie jednostek i zasobów Resgrid na ich tożsamość MARS / F-5. Przygotowuje i uzgadnia inwentarz; nigdy nie zapisuje statusu FAST. + Podpisujący z agencji + Zwrócone do przeglądu + Powód + Stan przeglądu + Szkic + Rozbieżność + Zaobserwowano w MARS + Zweryfikowany + Przegląd + Zaakceptowane + Wykluczone + Oczekuje + zweryfikowano + Uruchom listę kontrolną + Rejestracja SAM + Zapisz + Nie udało się zapisać zmiany. + Zapisz pozycje + Zapisz pozycje + Zapisano. + Numer seryjny + Zaobserwowano akceptację + Wróć do szkicu + Oznacz jako zweryfikowane + Podpisz lokalnie + Zaobserwowano zgłoszenie w MARS + Zastąp + Waga + Podpisał(a) (imię i nazwisko) + Podpisano + Podpisujący + Źródło + Artefakt źródłowy + Data źródła + Wiersz źródłowy + System źródłowy + Adres URL źródła + Początek + Stan + Zatwierdzone + Zamknięte + Tylko dokumentacja + Szkic + Odrzucone przez agencję lokalną + Wymaga przeglądu + Zapłacone + Oczekuje na zatwierdzenie lokalne + Oczekuje na płatnika + Gotowe do portalu + Zwrócone do przeglądu agencji + Zaobserwowano w MARS (przegląd Cal OES) + Status + Stawka zwykła + Strike team / task force + Obiekt + Typ obiektu + Zasób zewnętrzny + Zasób inwentarza + Jednostka + Zgłoszenie + Typ zgłoszenia + Stawka administracyjna + Załącznik A (poza gaszeniem) + Cal OES Rate Letter + Salary Survey + Sprzęt specjalny / kody FEMA + zastępuje + Numery zaopatrzenia + Dokumenty pomocnicze + Agencja + Porozumienia + Kolejka działań + Stawki roczne + Gotowość + Uzgadnianie + Zasoby F-5 + Tylko podróż (bez F-42) + SAM UEI + Jednostka + Oznaczenie jednostki + Zwalidowano + Lista kontrolna: {0} błąd(ów), {1} ostrzeżeń. + Brak profilu agencji. + Żadne porozumienie MOU / MOA / GBR nie obejmuje daty dysponowania. + Żaden zweryfikowany profil nie obejmuje tego rekordu. + Klasyfikacja osoby nie występuje w Salary Survey ani na liście referencyjnej. + Brak czasu dysponowania. + Oznaczone Tylko dokumentacja: brak roszczenia o zwrot. + Ten sam pojazd występuje więcej niż raz. + Brak zatwierdzającego roszczenie. + Nie zaobserwowano zgłoszenia powiązanego F-42 w MARS. + Roszczenie wydatków nie ma pozycji. + Pozycja wydatku nie ma paragonu. + Brak podpisu roszczenia. + Brak autoryzacji zdarzenia / AREP. + Brak nazwy lub numeru zdarzenia. + Brak oznaczenia MACS. + Brak numeru zamówienia zdarzenia. + Do wdrożenia nie załączono podpisanego ani papierowego F-42. + Przedział zaangażowania osoby wykracza poza okno dysponowanie-powrót. + Nie wymieniono personelu. + Brak obowiązujących pozycji stawek rocznych na datę dysponowania. + Zasób zwolniony, ale brak czasu powrotu lub ponownego dysponowania. + Brak numeru wniosku. + Numer wniosku nie ma prawidłowego przedrostka (E, O, C, S, A) i numeru. + Brak typu, rodzaju zasobu lub stanowiska overhead. + Brak podpisu agencji odpowiadającej. + Czas powrotu jest wcześniejszy niż dysponowanie. + Rotacja załogi nie ma załącznika zatwierdzenia. + Pojazd nie ma wiersza odwzorowania F-5. + Wartość + Odchylenie + Weryfikacja + Oznacz profil jako zweryfikowany po porównaniu z rekordem agencji w MARS. Panel ostrzega po roku. + Zweryfikowano + Wersja + Ostrzeżenie + Okres + Oficjalna kolejność pól, odnośniki do niezmiennych faktów wdrożenia, lista kontrolna, oczekiwany zwrot i przekazanie do portalu. + Rok + Tak + Kwoty pozycji nie mogą być ujemne. + Klasyfikacja pozycji jest nieprawidłowa. + Rok obrotowy pozycji jest nieprawidłowy (tylko lata poprzednie). + Nazwa agencji jest wymagana. + Nie znaleziono profilu agencji. + Porozumienie jest używane przez element pracy i nie można go usunąć. + Rodzaj dokumentu porozumienia jest nieprawidłowy. + Metoda wynagradzania jest nieprawidłowa. + Nie znaleziono porozumienia. + Metoda nadgodzin jest nieprawidłowa. + Potwierdź oświadczenie przed otwarciem widoku przekazania. + Data końcowa jest wcześniejsza niż początkowa. + Decyzja wymaga nazwiska / stanowiska decydenta. + Nie znaleziono wdrożenia. + Nie znaleziono powiązanego F-42 w tym wdrożeniu. + Ten wniosek / fill nie należy do zamówienia zewnętrznego wdrożenia. + Zamówienie ma kilka wniosków; wybierz wniosek / fill dla tego F-42. + Kwota faktury nie może być ujemna. + Faktura MARS o tym identyfikatorze jest już zapisana. + Identyfikator faktury MARS jest wymagany. + Zapisz zatwierdzenie lokalne przed płatnością. + Faktura nie jest w stanie pozwalającym zapisać płatność. + Faktura nie oczekuje na zatwierdzenie lokalne. + Rekord nie jest Gotowy do portalu; najpierw uruchom listę kontrolną. + Kwota zapłacona nie może być ujemna. + Zgłoszenie nie ma pozycji, stawki administracyjnej ani zaakceptowanej stawki bazowej. + Podstawa pozycji stawki jest nieprawidłowa. + Pozycje płacowe wymagają kodu klasyfikacji. + Rodzaj pozycji stawki jest nieprawidłowy. + Stawki nie mogą być ujemne. + Pozycje sprzętowe wymagają kodu zasobu lub FEMA. + To zgłoszenie jest podpisane lub złożone i nie można go już edytować. + Nie znaleziono zgłoszenia rocznego. + Zapisz podpis lokalny przed obserwacją statusu zewnętrznego. + Stawka administracyjna musi mieścić się między 0 a 100 procent. + Podpisanie wymaga nazwiska podpisującego. + Ta zmiana statusu jest niedozwolona. + Typ zgłoszenia jest nieprawidłowy. + Rok zgłoszenia jest nieprawidłowy. + Odrzucenie wymaga komentarza. + Zasób zewnętrzny wymaga nazwy. + Nie znaleziono wiersza zasobu. + Typ obiektu zasobu jest nieprawidłowy. + Nie znaleziono jednostki w tym wydziale. + Zasób jednostki wymaga jednostki. + Ten status zewnętrzny nie należy do słownika profilu źródłowego. + Rekord zaobserwowano w MARS i nie można go już edytować lokalnie. + Tylko F-42 i roszczenia wydatków mają oczekiwany zwrot. + Zamknąć można tylko rekordy zatwierdzone, tylko-dokumentacja, odrzucone lub zapłacone. + Nie zaobserwowano zgłoszenia rekordu w MARS. + Nie znaleziono rekordu. + Rekord nie jest fakturą MARS. + Tylko F-42 i roszczenia wydatków są zgłaszane do MARS. + Rekord jest zapłacony lub zamknięty; jego pozycje są ostateczne. + Rekord nie jest oczekiwanego typu. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx new file mode 100644 index 00000000..172d91ab --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Incident action queue + Active + Actual amount + Actual hours (DTRs) + Add agreement + Add input + Add line + Add resource + Add rotation + Address + Possible double counts are still pending review. + Some inputs are still pending review. + No allowable direct cost base; the rate cannot be calculated. + No actual-cost inputs; the de-minimis option is the only choice. + Administrative method + Calculated from actuals + De minimis + None + Administrative-rate inputs + Prior-year actuals by function and category, classified direct / indirect / unallowable. Budgets never enter here. + Administrative rate + Administrative-rate worksheet + Allowable indirect ÷ allowable direct from the accepted inputs, compared with the de-minimis option. Unresolved double-count flags block the result. + Age + Agency + Agency category + The department's MARS agency record: MACS designator, contacts and the identifiers printed on every claim. + Agency name + Agency profile + Agreement + Agreements + Approved MOU / MOA / GBR compensation methods by classification. An F-42 selects the one in effect at initial dispatch. + All classifications + All types + All years + Allowable direct + Allowable indirect + Amount + Annual rates + Annual submissions in effect + Approval attachment + Approve + Approver + Deployment attachment id + Optional: the signed agreement uploaded as a deployment attachment. + Authority + Agency special equipment + Agency submitted + Cal OES base rate + Cal OES Rate Letter + FEMA schedule + Authority profile + No reviewed profile covers this date + Back + Cal OES base rate accepted + The agency takes the Cal OES base rate instead of submitting its own survey rows. + Basis + Daily + Flat + Hourly + Per mile + Percent + Blocked + Blocker + Box + Responding agency + Apparatus, support vehicles and equipment + Attachments + Comments, loss / damage, supply numbers + Dispatch / commitment + Incident + Incident order number + Personnel + Request number + Resource + Return / redispatch + Crew rotations + Signatures and authorization + Build administrative rate + Cal OES MARS + Calculate + Calculated rate + Cancel + Category + Category + Lodging + Meal + Miscellaneous + Rental + Check date + Checklist + Checksum + City + Classification + Classification title + Close + Comment + Comments + Committed + Committed hours + Compensation method + Actual hours + Portal to portal + Delete this record? + Contact e-mail + Contact name + Contact phone + Copy + Reviewer comment + Direct + Indirect + Unallowable + At a glance + Covered records + Submitted F-42 / expense records this invoice pays for; their expected totals are compared with the invoiced amount. + Date + De-minimis option + Decision by (name / title) + Delete + Deployment + Description + Designator + Detail + Document kind + Equivalent + GBR + MOA + MOU + Documentation only + The record documents the response without claiming reimbursement (the MARS Documentation Only path). + Possible double count + Draft from units + Select units and create crosswalk rows for the ones that have none. + Edit agency + Edit agreement + Edit resource + Effective on + Eligible + Excluded + Uncertain + End + Evidence packet + Excluded (billed to incidents) + Excluded (unallowable) + Expected + Expected reimbursement + An estimate from the rates and agreement in effect at initial dispatch. Cal OES determines the allowed amount; no internal cost enters these lines. + Expected total + Expected versus observed + Expires on + External resource name + Agency + Apparatus + Attachments + Comments + Dispatch + Incident + Incident authorization + Expense lines + Order + Personnel + Request + Resource + Responding signature + Return + Rotation + Signature + FEIN + Request / fill + RMS external-order fill id (required when the order has more than one request) + FI$Cal supplier + Fix + Function + Generated + I am the authorized person entering this record in MARS and will copy the values shown, not this page's file. + Side-by-side copy view for manual entry in the MARS portal. Not stored, not cached, and not a submission. + Run the checklist until it is clean; the handoff opens from Ready for portal. + Prepared for MARS. Opening this view records an audit entry and changes nothing; the record becomes Observed in MARS only when a manager records what the portal shows. + The checklist still has errors; MARS may return this record. + These values are copied onto F-42s and claims and are stored as data (not under Advanced Data Protection). Changing an identifier clears the last verification. + Leave the classification blank for a department-wide agreement. An agreement referenced by a submitted record is immutable; editing it creates a new version. + Field members see their own incident-bound drafts; MARS managers see the department queue. Opening a handoff or downloading a packet never marks a record submitted. + Rates are data pinned to a reviewed authority profile; a new Rate Letter or survey is a new snapshot, and an earlier dispatch keeps the one in effect when it started. + Blockers stop new F-42s from reaching Ready for portal; warnings are worth fixing before the next dispatch. + A MARS invoice is a work item, never a Phase B customer invoice: no invoice number, no aging, no e-mail. Paid is only ever an observed payment fact. + Draft rows copy the unit's name, plate and VIN at that moment; review them, add the MARS resource type, then record what MARS shows. + Hours + Identifiers + Incident / AREP authorizer + Billed directly to an incident (excluded) + Includes unemployment insurance + Includes workers' comp + Incoming + Invoice date + Expected versus observed, the local decision and the payment facts for one MARS-generated invoice. + Invoiced + Invoices awaiting local approval + Item + Kind + Apparatus + Special equipment + Privately owned vehicle + Support vehicle + License plate + Line kind + Administrative + Administrative rate + Apparatus + Attachment A (non-suppression) + Expense + Meals, lodging, incidentals + Official apparatus + Official support vehicle + Personnel + POV mileage + POV mileage + Rental + Salary Survey + Special equipment + Support vehicle + Linked F-42 + Local agency decision + Approve or reject the invoice as the authorized representative; a rejection needs a comment. The decision is recorded here and entered in MARS by you. + Loss / damage + MACS designator + Manage Cal OES MARS reimbursement + Agency, F-5 resource, annual rate and agreement records, the portal handoff, observed MARS statuses and invoice approval/payment reconciliation. Rostered members prepare their own F-42 and expense drafts without it. + Mark verified today + MARS invoice + MARS invoice id + This is a MARS work item. It has no Phase B invoice number, never appears in customer aging and cannot be e-mailed or paid online. + MARS invoices + MARS record id + MARS resource id + Method chosen + Miles + Rostered on this deployment + mismatches + Name + New submission + No + No agency profile yet. + No agreements yet. + No deployment attachments. + No cost-recovery deployments to prepare from. + Resgrid stores no MARS credentials, MFA tokens or browser sessions and never writes to the portal. + No annual submission covers this date. + No deployment + No MARS invoices recorded. + Nothing in the queue. + No annual submissions yet. + Everything needed for a MARS submission is in place. + No F-5 resource rows yet. + The evidence packet is not an accepted MARS import file. + Not calculated yet. + Not current + Not in the F-5 resource inventory + Not validated yet. + Observed in MARS + Observed on + Observed status + Observed submission + Odometer + Official sources + Open + Open deployment + Open handoff view + Open the MARS portal + Open work items + Outgoing + Overhead position + Overtime eligible + Overtime method + After 8 hours per day + After 12 hours per day + None + Per agreement (not modelled) + Overtime rate + Ownership + CAL FIRE + Cal OES + Local agency + Other + Private + Rental + Paid + Paid on + Paying entity + Paying-entity status + Payment reference + Portal handoff + Portal account reference + A label for the MARS account (never a password or token). + Portal role + Portal-to-portal eligible + Pre-approved + Prepare expense claim + Prepare F-42 + Prepare a record + One F-42 per ordered resource / request; a redispatch supersedes the earlier one. Expense claims link to an F-42 or take the travel-only path. + Prepared for MARS — nothing here is submitted until a manager records what was observed in the portal. + Printable + Provenance + F-42 and expense claims to prepare, validate and hand off, grouped by deployment; observed MARS statuses and invoices alongside. + Rank + Header, lines and (for the Administrative Rate) the prior-year actual-cost worksheet. + Rate lines + Salary lines need a classification; equipment lines a resource or FEMA code. Rates are straight / overtime per basis. + This submission is signed or submitted; edits need a new version. + Rate profile version + Accepted + Draft + Reviewed + Signed locally + Submitted in MARS + Superseded + Salary Survey, Attachment A, Administrative Rate, Cal OES Rate Letter and Special Equipment snapshots by submission year. + No administrative rate is recorded for this date; no administrative line will be estimated. + The agency profile (MACS designator, contacts, identifiers) has not been entered. + The agency profile has not been verified against MARS in the last year. + An agreement ends within the lead window. + No MOU/MOA/GBR compensation method covers this date; personnel are estimated on actual hours without overtime. + Readiness as of + No reviewed Cal OES authority profile covers this dispatch date; new submissions are blocked until one is added. + Readiness dashboard + No FEIN recorded for the agency. + No FI$Cal supplier id recorded for the agency. + Agency, F-5 resource, annual rate and agreement readiness for a dispatch date, with links to the official Cal OES material. + MARS-generated invoices are waiting for the local agency decision. + The MACS agency designator is missing. + No F-5 resource crosswalk rows exist; apparatus on an F-42 will be flagged as not in inventory. + An annual submission expires within the lead window. + No Cal OES Rate Letter lines (apparatus, support vehicle, POV mileage) cover this date. + An annual submission in effect has not been signed locally. + F-5 resource rows do not match what MARS shows. + Records returned by Cal OES for agency review are waiting. + No reviewed Salary Survey (or accepted base rate) covers this date; personnel lines cannot be estimated. + No SAM UEI recorded for the agency. + Ready + Ready for the portal. + Receipt + Invoice and payment reconciliation + MARS-generated invoices as observed, the local approve / reject decision, paying-entity status and payment facts against the expected lines. + Record a MARS invoice + Capture the invoice Cal OES generated as you see it in the portal, and the submitted records it covers. + Record observation + Record payment + Only an observed paying-entity payment marks the invoice and its records Paid. + Record submission observed in MARS + Record type + Administrative Rate + Agency profile + Agreement + Attachment A + Expense claim + F-42 + MARS invoice + F-5 resource inventory + Salary Survey + Special Equipment + Recorded + Redispatch of + Reject + Related records + Release is not return; the F-42 needs the return or redispatch time. + Released + Reporting location + Request + Resource code + F-5 resource inventory + Resource kind + F-5 resources + Resource type + Crosswalk from Resgrid units and assets to their MARS / F-5 identity. It prepares and reconciles inventory; it never writes FAST status. + Responding agency signer + Returned for review + Reason + Review state + Draft + Mismatch + Observed in MARS + Reviewed + Review + Accepted + Excluded + Pending + reviewed + Run checklist + SAM registration + Save + The change could not be saved. + Save inputs + Save lines + Saved. + Serial number + Observed accepted + Back to draft + Mark reviewed + Sign locally + Observed submitted in MARS + Supersede + Severity + Signed by (name) + Signed on + Signer + Source + Source artifact + Source date + Source line + Source system + Source URL + Start + State + Approved + Closed + Documentation only + Draft + Rejected by local agency + Needs review + Paid + Pending local agency approval + Pending paying entity + Ready for portal + Returned for agency review + Observed in MARS (Cal OES review) + Status + Straight rate + Strike team / task force + Subject + Subject type + External resource + Inventory asset + Unit + Submission + Submission type + Administrative Rate + Attachment A (non-suppression) + Cal OES Rate Letter + Salary Survey + Special Equipment / FEMA codes + supersedes + Supply numbers + Supporting documents + Agency + Agreements + Action queue + Annual rates + Readiness + Reconciliation + F-5 resources + Travel only (no F-42) + SAM UEI + Unit + Unit designator + Validated + Checklist: {0} error(s), {1} warning(s). + The agency profile is missing. + No MOU / MOA / GBR agreement covers the dispatch date. + No reviewed authority profile covers this record. + A person's classification is not in the Salary Survey or the pinned list. + The dispatch time is missing. + Marked Documentation Only: no reimbursement is claimed. + The same vehicle appears more than once. + The claim approver is missing. + The linked F-42 has not been observed submitted in MARS. + The expense claim has no lines. + An expense line has no receipt. + The claim signature is missing. + The incident / AREP authorization is missing. + Incident name or number is missing. + The MACS designator is missing. + The incident order number is missing. + No signed or paper F-42 is attached to the deployment. + A person's commitment interval falls outside the resource's dispatch-to-return window. + No personnel are listed. + No annual rate lines are in effect for the dispatch date. + The resource is released but no return or redispatch time is recorded. + The request number is missing. + The request number does not carry a valid prefix (E, O, C, S, A) and number. + Resource type, kind or overhead position is missing. + The responding agency signature is missing. + The return time is before the dispatch time. + A crew rotation has no approval attachment. + A vehicle has no F-5 resource crosswalk row. + Value + Variance + Verification + Mark the profile verified after comparing it with the agency record in MARS. The readiness dashboard warns after a year. + Verified on + Version + Warning + Window + Official box order, source links to the immutable deployment facts, checklist, expected reimbursement and the portal handoff. + Year + Yes + Input amounts cannot be negative. + An input classification is not valid. + An input fiscal year is not valid (prior years only). + The agency name is required. + The agency profile was not found. + The agreement is referenced by a work item and cannot be deleted. + The agreement document kind is not valid. + The compensation method is not valid. + The agreement was not found. + The overtime method is not valid. + Confirm the attestation before opening the handoff view. + The end date is before the start date. + The decision needs the deciding person's name / title. + The deployment was not found. + The linked F-42 was not found on this deployment. + That request / fill is not on the deployment's external order. + The order has several requests; choose the request / fill for this F-42. + The invoiced amount cannot be negative. + A MARS invoice with that id is already recorded. + The MARS invoice id is required. + Record the local approval before a payment. + The invoice is not in a state where a payment can be recorded. + The invoice is not pending local agency approval. + The record is not Ready for portal; run the checklist first. + The paid amount cannot be negative. + The submission has no lines, no administrative rate and no accepted base rate. + A rate line basis is not valid. + Salary lines need a classification code. + A rate line kind is not valid. + Rates cannot be negative. + Equipment lines need a resource or FEMA code. + This submission is signed or submitted and can no longer be edited. + The annual submission was not found. + Record the local signature before observing an external status. + The administrative rate must be between 0 and 100 percent. + Signing needs the signer's name. + That status change is not allowed. + The submission type is not valid. + The submission year is not valid. + A rejection needs a comment. + An external resource needs a name. + The resource row was not found. + The resource subject type is not valid. + The unit was not found in this department. + A unit is required for a unit resource. + That external status is not in the authority profile's vocabulary. + The record has been observed in MARS and is no longer locally editable. + Only F-42 and expense claims have expected reimbursement. + Only approved, documentation-only, rejected or paid records can be closed. + The record has not been observed submitted in MARS. + The record was not found. + The record is not a MARS invoice. + Only F-42 and expense claims are submitted to MARS. + The record is paid or closed; its lines are final. + The record is not of the expected type. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx new file mode 100644 index 00000000..9dc65d8d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Insatsåtgärdskö + Aktiv + Faktiskt belopp + Faktiska timmar (DTR) + Lägg till avtal + Lägg till indata + Lägg till rad + Lägg till resurs + Lägg till rotation + Adress + Möjliga dubbelräkningar väntar fortfarande på granskning. + Vissa indata väntar fortfarande på granskning. + Ingen tillåten direkt kostnadsbas; taxan kan inte beräknas. + Inga faktiska kostnadsindata; de minimis-alternativet är enda valet. + Administrativ metod + Beräknad från faktiska kostnader + De minimis + Ingen + Indata administrativ taxa + Föregående års faktiska kostnader per funktion och kategori, klassificerade direkt / indirekt / ej tillåten. Budgetar förs aldrig in här. + Administrativ taxa + Blad administrativ taxa + Tillåten indirekt ÷ tillåten direkt från accepterade indata, jämfört med de minimis-alternativet. Olösta dubbelräkningar blockerar resultatet. + Ålder + Myndighet + Myndighetskategori + Avdelningens MARS-myndighetspost: MACS-beteckning, kontakter och identifierare som skrivs på varje anspråk. + Myndighetens namn + Myndighetsprofil + Avtal + Avtal + Godkända MOU / MOA / GBR-ersättningsmetoder per klassificering. En F-42 väljer den som gällde vid första utlarmningen. + Alla klassificeringar + Alla typer + Alla år + Tillåten direkt + Tillåten indirekt + Belopp + Årstaxor + Gällande årsinlämningar + Godkännandebilaga + Godkänn + Godkännare + Insatsbilage-id + Valfritt: det signerade avtalet uppladdat som insatsbilaga. + Auktoritet + Myndighetens specialutrustning + Inlämnad av myndigheten + Cal OES bastaxa + Cal OES Rate Letter + FEMA-schema + Auktoritetsprofil + Ingen granskad profil täcker detta datum + Tillbaka + Cal OES bastaxa accepterad + Myndigheten tar Cal OES bastaxa istället för egna enkätrader. + Bas + Per dag + Fast + Per timme + Per mile + Procent + Blockerad + Blockerare + Ruta + Insatsmyndighet + Fordon, stödfordon och utrustning + Bilagor + Kommentarer, förlust / skada, förrådsnummer + Utlarmning / åtagande + Insats + Insatsordernummer + Personal + Begäransnummer + Resurs + Återkomst / omutlarmning + Besättningsrotationer + Signaturer och auktorisation + Beräkna administrativ taxa + Cal OES MARS + Beräkna + Beräknad taxa + Avbryt + Kategori + Kategori + Logi + Måltid + Övrigt + Hyra + Kontrollera datum + Checklista + Kontrollsumma + Ort + Klassificering + Klassificeringstitel + Stäng + Kommentar + Kommentarer + Åtagen + Åtagna timmar + Ersättningsmetod + Faktiska timmar + Portal till portal + Ta bort den här posten? + Kontakt-e-post + Kontaktnamn + Kontakttelefon + Kopiera + Granskarens kommentar + Direkt + Indirekt + Ej tillåten + I korthet + Täckta poster + Inlämnade F-42- / utgiftsposter som denna faktura betalar; deras förväntade totaler jämförs med fakturerat belopp. + Datum + De minimis-alternativ + Beslut av (namn / titel) + Ta bort + Insats + Beskrivning + Beteckning + Detalj + Dokumenttyp + Motsvarande + GBR + MOA + MOU + Endast dokumentation + Posten dokumenterar insatsen utan att kräva ersättning (MARS Documentation Only-vägen). + Möjlig dubbelräkning + Utkast från enheter + Välj enheter och skapa rader för dem som saknar. + Redigera myndighet + Redigera avtal + Redigera resurs + Gäller från + Berättigad + Exkluderad + Osäker + Slut + Bevispaket + Exkluderad (fakturerad till insatser) + Exkluderad (ej tillåten) + Förväntat + Förväntad ersättning + En uppskattning från taxor och avtal som gällde vid första utlarmningen. Cal OES avgör tillåtet belopp; ingen intern kostnad ingår i dessa rader. + Förväntat totalt + Förväntat mot observerat + Upphör + Extern resurs namn + Myndighet + Fordon + Bilagor + Kommentarer + Utlarmning + Insats + Insatsauktorisation + Utgiftsrader + Order + Personal + Begäran + Resurs + Myndighetens signatur + Återkomst + Rotation + Signatur + FEIN + Begäran / fill + RMS extern-order fill-id (krävs när ordern har fler än en begäran) + FI$Cal-leverantör + Åtgärda + Funktion + Genererad + Jag är den behöriga personen som anger denna post i MARS och kopierar de visade värdena, inte den här sidans fil. + Kopieringsvy för manuell inmatning i MARS-portalen. Inte lagrad, inte cachad och ingen inlämning. + Kör checklistan tills den är ren; överlämningen öppnas från Klar för portal. + Förberedd för MARS. Att öppna denna vy skapar en revisionspost och ändrar inget; posten blir Observerad i MARS först när en ansvarig registrerar vad portalen visar. + Checklistan har fortfarande fel; MARS kan återsända posten. + Dessa värden kopieras till F-42 och anspråk och lagras som data (inte under Advanced Data Protection). Ändrad identifierare nollställer senaste verifiering. + Lämna klassificeringen tom för ett avdelningsomfattande avtal. Ett avtal som en inlämnad post refererar är oföränderligt; redigering skapar en ny version. + Fältmedlemmar ser sina egna utkast; MARS-ansvariga ser avdelningskön. Att öppna en överlämning eller ladda ner ett paket markerar aldrig en post som inlämnad. + Taxor är data knutna till en granskad auktoritetsprofil; en ny Rate Letter eller enkät är en ny ögonblicksbild, och en tidigare utlarmning behåller den som gällde vid starten. + Blockerare hindrar nya F-42 från att nå Klar för portal; varningar bör åtgärdas före nästa utlarmning. + En MARS-faktura är ett ärende, aldrig en fas B-kundfaktura: inget fakturanummer, ingen åldersanalys, ingen e-post. Betald är alltid endast ett observerat betalningsfaktum. + Utkastrader kopierar enhetens namn, registreringsskylt och VIN i ögonblicket; granska dem, lägg till MARS-resurstyp och notera sedan vad MARS visar. + Timmar + Identifierare + Insats-/AREP-auktoriserare + Fakturerad direkt till en insats (exkluderad) + Inkluderar arbetslöshetsförsäkring + Inkluderar arbetsskadeförsäkring + Ankommande + Fakturadatum + Förväntat mot observerat, det lokala beslutet och betalningsfakta för en MARS-genererad faktura. + Fakturerat + Fakturor som väntar på lokalt godkännande + Post + Slag + Fordon + Specialutrustning + Privatägt fordon + Stödfordon + Registreringsskylt + Radtyp + Administrativ + Administrativ taxa + Fordon + Bilaga A (icke-släckning) + Utgift + Måltider, logi, småutgifter + Officiellt fordon + Officiellt stödfordon + Personal + POV-miltal + POV-miltal + Hyra + Salary Survey + Specialutrustning + Stödfordon + Länkad F-42 + Lokalt myndighetsbeslut + Godkänn eller avvisa fakturan som behörig representant; ett avslag kräver en kommentar. Beslutet registreras här och anges i MARS av dig. + Förlust / skada + MACS-beteckning + Hantera Cal OES MARS-ersättning + Myndighets-, F-5-resurs-, årstaxe- och avtalsposter, portalöverlämning, observerade MARS-statusar och fakturagodkännande/betalningsavstämning. Bemannade medlemmar förbereder sina egna F-42- och utgiftsutkast utan den. + Markera verifierad idag + MARS-faktura + MARS-faktura-id + Detta är ett MARS-ärende. Det har inget fas B-fakturanummer, visas aldrig i kundåldersanalysen och kan inte e-postas eller betalas online. + MARS-fakturor + MARS-post-id + MARS-resurs-id + Vald metod + Miles + Bemannad på denna insats + avvikelser + Namn + Ny inlämning + Nej + Ingen myndighetsprofil ännu. + Inga avtal ännu. + Inga insatsbilagor. + Inga kostnadsåtervinningsinsatser att förbereda från. + Resgrid lagrar inga MARS-inloggningsuppgifter, MFA-tokens eller webbläsarsessioner och skriver aldrig till portalen. + Ingen årsinlämning täcker detta datum. + Ingen insats + Inga MARS-fakturor registrerade. + Inget i kön. + Inga årsinlämningar ännu. + Allt som krävs för en MARS-inlämning finns på plats. + Inga F-5-resursrader ännu. + Bevispaketet är ingen godkänd MARS-importfil. + Inte beräknad ännu. + Ej aktuell + Inte i F-5-inventariet + Inte validerad ännu. + Observerat i MARS + Observerad + Observerad status + Observerad inlämning + Vägmätare + Officiella källor + Öppna + Öppna insats + Öppna överlämningsvy + Öppna MARS-portalen + Öppna ärenden + Avgående + Overhead-befattning + Berättigad övertid + Övertidsmetod + Efter 8 timmar per dag + Efter 12 timmar per dag + Ingen + Enligt avtal (ej modellerad) + Övertidstaxa + Ägande + CAL FIRE + Cal OES + Lokal myndighet + Annat + Privat + Hyrd + Betalt + Betald + Betalande enhet + Betalande enhets status + Betalningsreferens + Portalöverlämning + Portalkontoreferens + En etikett för MARS-kontot (aldrig lösenord eller token). + Portalroll + Berättigad portal-till-portal + Förhandsgodkänd + Förbered utgiftsanspråk + Förbered F-42 + Förbered en post + En F-42 per beställd resurs / begäran; en omutlarmning ersätter den tidigare. Utgiftsanspråk länkas till en F-42 eller tar enbart-resa-vägen. + Förberedd för MARS — inget är inlämnat förrän en ansvarig registrerar vad som observerats i portalen. + Utskrivbar + Ursprung + F-42 och utgiftsanspråk att förbereda, validera och överlämna, grupperade per insats; observerade MARS-statusar och fakturor intill. + Grad + Rubrik, rader och (för administrativ taxa) föregående års faktiska kostnadsblad. + Taxerader + Lönerader behöver en klassificering; utrustningsrader en resurs- eller FEMA-kod. Taxor är ordinarie / övertid per bas. + Denna inlämning är signerad eller inlämnad; ändringar kräver en ny version. + Taxprofilversion + Accepterad + Utkast + Granskad + Signerad lokalt + Inlämnad i MARS + Ersatt + Salary Survey, Bilaga A, administrativ taxa, Cal OES Rate Letter och specialutrustning per inlämningsår. + Ingen administrativ taxa registrerad för detta datum; ingen administrativ rad uppskattas. + Myndighetsprofilen (MACS-beteckning, kontakter, identifierare) har inte angetts. + Myndighetsprofilen har inte verifierats mot MARS det senaste året. + Ett avtal upphör inom framförhållningsfönstret. + Ingen MOU/MOA/GBR-ersättningsmetod täcker detta datum; personal uppskattas på faktiska timmar utan övertid. + Beredskap per + Ingen granskad Cal OES-auktoritetsprofil täcker detta datum; nya inlämningar blockeras tills en läggs till. + Beredskapsöversikt + Inget FEIN registrerat för myndigheten. + Inget FI$Cal-leverantörs-id registrerat för myndigheten. + Beredskap för myndighet, F-5-resurser, årstaxor och avtal för ett utlarmningsdatum, med länkar till officiellt Cal OES-material. + MARS-genererade fakturor väntar på det lokala beslutet. + MACS-beteckningen saknas. + Inga F-5-korsreferensrader finns; fordon på en F-42 flaggas som ej i inventariet. + En årsinlämning löper ut inom framförhållningsfönstret. + Inga Cal OES Rate Letter-rader (fordon, stödfordon, POV-miltal) täcker detta datum. + En gällande årsinlämning har inte signerats lokalt. + F-5-resursrader stämmer inte med vad MARS visar. + Poster återsända av Cal OES för myndighetsgranskning väntar. + Ingen granskad Salary Survey (eller accepterad bastaxa) täcker detta datum; personalrader kan inte uppskattas. + Inget SAM UEI registrerat för myndigheten. + Klar + Klar för portalen. + Kvitto + Faktura- och betalningsavstämning + MARS-genererade fakturor som observerats, det lokala godkänn/avvisa-beslutet, betalande enhets status och betalningsfakta mot de förväntade raderna. + Registrera en MARS-faktura + Registrera fakturan Cal OES genererade som du ser den i portalen, och de inlämnade poster den täcker. + Registrera observation + Registrera betalning + Endast en observerad betalning från betalande enhet markerar fakturan och dess poster som betalda. + Registrera inlämning observerad i MARS + Posttyp + Administrativ taxa + Myndighetsprofil + Avtal + Bilaga A + Utgiftsanspråk + F-42 + MARS-faktura + F-5-inventarium + Salary Survey + Specialutrustning + Registrerad + Omutlarmning av + Avvisa + Relaterade poster + Frisläppning är inte återkomst; F-42 behöver återkomst- eller omutlarmningstiden. + Frisläppt + Anmälningsplats + Begäran + Resurskod + F-5-resursinventarium + Resursslag + F-5-resurser + Resurstyp + Korsreferens från Resgrid-enheter och tillgångar till deras MARS / F-5-identitet. Förbereder och stämmer av inventariet; skriver aldrig FAST-status. + Myndighetens undertecknare + Återsända för granskning + Orsak + Granskningsstatus + Utkast + Avvikelse + Observerad i MARS + Granskad + Granskning + Accepterad + Exkluderad + Väntar + granskad + Kör checklista + SAM-registrering + Spara + Ändringen kunde inte sparas. + Spara indata + Spara rader + Sparat. + Serienummer + Observerad accepterad + Tillbaka till utkast + Markera granskad + Signera lokalt + Observerad inlämnad i MARS + Ersätt + Allvar + Signerad av (namn) + Signerad + Undertecknare + Källa + Källartefakt + Källdatum + Källrad + Källsystem + Käll-URL + Start + Tillstånd + Godkänd + Stängd + Endast dokumentation + Utkast + Avvisad av lokal myndighet + Behöver granskning + Betald + Väntar på lokalt godkännande + Väntar på betalande enhet + Klar för portal + Återsänd för myndighetsgranskning + Observerad i MARS (Cal OES-granskning) + Status + Ordinarie taxa + Strike team / task force + Objekt + Objekttyp + Extern resurs + Inventarietillgång + Enhet + Inlämning + Inlämningstyp + Administrativ taxa + Bilaga A (icke-släckning) + Cal OES Rate Letter + Salary Survey + Specialutrustning / FEMA-koder + ersätter + Förrådsnummer + Stöddokument + Myndighet + Avtal + Åtgärdskö + Årstaxor + Beredskap + Avstämning + F-5-resurser + Enbart resa (ingen F-42) + SAM UEI + Enhet + Enhetsbeteckning + Validerad + Checklista: {0} fel, {1} varning(ar). + Myndighetsprofilen saknas. + Inget MOU / MOA / GBR-avtal täcker utlarmningsdatumet. + Ingen granskad auktoritetsprofil täcker posten. + En persons klassificering finns varken i Salary Survey eller referenslistan. + Utlarmningstiden saknas. + Markerad Endast dokumentation: ingen ersättning begärs. + Samma fordon förekommer mer än en gång. + Anspråkets godkännare saknas. + Den länkade F-42 har inte observerats inlämnad i MARS. + Utgiftsanspråket har inga rader. + En utgiftsrad saknar kvitto. + Anspråkets signatur saknas. + Insats-/AREP-auktorisationen saknas. + Insatsnamn eller -nummer saknas. + MACS-beteckningen saknas. + Insatsordernumret saknas. + Ingen signerad eller pappers-F-42 är bifogad insatsen. + En persons åtagandeintervall ligger utanför resursens utlarmning-till-återkomst-fönster. + Ingen personal listad. + Inga årstaxerader gäller för utlarmningsdatumet. + Resursen är frisläppt men ingen återkomst- eller omutlarmningstid är registrerad. + Begäransnumret saknas. + Begäransnumret saknar giltigt prefix (E, O, C, S, A) och nummer. + Resurstyp, slag eller overhead-befattning saknas. + Insatsmyndighetens signatur saknas. + Återkomsttiden är före utlarmningen. + En besättningsrotation saknar godkännandebilaga. + Ett fordon saknar F-5-korsreferensrad. + Värde + Avvikelse + Verifiering + Markera profilen verifierad efter jämförelse med myndighetsposten i MARS. Översikten varnar efter ett år. + Verifierad + Version + Varning + Period + Officiell rutordning, källänkar till de oföränderliga insatsfakta, checklista, förväntad ersättning och portalöverlämning. + År + Ja + Indatabelopp kan inte vara negativa. + En indataklassificering är ogiltig. + Ett indata-räkenskapsår är ogiltigt (endast tidigare år). + Myndighetens namn krävs. + Myndighetsprofilen hittades inte. + Avtalet refereras av ett ärende och kan inte tas bort. + Avtalets dokumenttyp är ogiltig. + Ersättningsmetoden är ogiltig. + Avtalet hittades inte. + Övertidsmetoden är ogiltig. + Bekräfta intyget innan överlämningsvyn öppnas. + Slutdatumet är före startdatumet. + Beslutet behöver beslutsfattarens namn / titel. + Insatsen hittades inte. + Den länkade F-42 hittades inte på denna insats. + Den begäran / fill finns inte på insatsens externa order. + Ordern har flera begäranden; välj begäran / fill för denna F-42. + Fakturerat belopp kan inte vara negativt. + En MARS-faktura med det id:t är redan registrerad. + MARS-faktura-id krävs. + Registrera det lokala godkännandet före en betalning. + Fakturan är inte i ett tillstånd där en betalning kan registreras. + Fakturan väntar inte på lokalt godkännande. + Posten är inte Klar för portal; kör checklistan först. + Betalt belopp kan inte vara negativt. + Inlämningen har inga rader, ingen administrativ taxa och ingen accepterad bastaxa. + En taxeradbas är ogiltig. + Lönerader behöver en klassificeringskod. + En taxeradtyp är ogiltig. + Taxor kan inte vara negativa. + Utrustningsrader behöver en resurs- eller FEMA-kod. + Denna inlämning är signerad eller inlämnad och kan inte längre redigeras. + Årsinlämningen hittades inte. + Registrera den lokala signaturen innan en extern status observeras. + Den administrativa taxan måste vara mellan 0 och 100 procent. + Signering kräver undertecknarens namn. + Den statusändringen är inte tillåten. + Inlämningstypen är ogiltig. + Inlämningsåret är ogiltigt. + Ett avslag kräver en kommentar. + En extern resurs behöver ett namn. + Resursraden hittades inte. + Resursens objekttyp är ogiltig. + Enheten hittades inte i denna avdelning. + En enhet krävs för en enhetsresurs. + Den externa statusen finns inte i auktoritetsprofilens vokabulär. + Posten har observerats i MARS och kan inte längre redigeras lokalt. + Endast F-42 och utgiftsanspråk har förväntad ersättning. + Endast godkända, endast-dokumentation, avvisade eller betalda poster kan stängas. + Posten har inte observerats inlämnad i MARS. + Posten hittades inte. + Posten är inte en MARS-faktura. + Endast F-42 och utgiftsanspråk lämnas in till MARS. + Posten är betald eller stängd; dess rader är slutgiltiga. + Posten är inte av förväntad typ. + diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx new file mode 100644 index 00000000..f004237d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx @@ -0,0 +1,557 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Черга дій за подією + Активний + Фактична сума + Фактичні години (DTR) + Додати угоду + Додати запис + Додати рядок + Додати ресурс + Додати ротацію + Адреса + Можливі подвійні обліки ще очікують перегляду. + Деякі записи ще очікують перегляду. + Немає допустимої бази прямих витрат; ставку неможливо обчислити. + Немає записів фактичних витрат; опція de minimis — єдиний вибір. + Адміністративний метод + Обчислено з фактичних витрат + De minimis + Немає + Вхідні дані адміністративної ставки + Фактичні витрати попереднього року за функцією та категорією, класифіковані як прямі / непрямі / недопустимі. Бюджети сюди ніколи не вносяться. + Адміністративна ставка + Аркуш адміністративної ставки + Допустимі непрямі ÷ допустимі прямі з прийнятих записів, порівняно з опцією de minimis. Нерозв'язані позначки подвійного обліку блокують результат. + Вік + Агенція + Категорія агенції + Запис агенції в MARS: позначення MACS, контакти та ідентифікатори, що друкуються на кожній вимозі. + Назва агенції + Профіль агенції + Угода + Угоди + Затверджені методи компенсації MOU / MOA / GBR за класифікацією. F-42 обирає чинний на момент першого відправлення. + Усі класифікації + Усі типи + Усі роки + Допустимі прямі + Допустимі непрямі + Сума + Річні ставки + Чинні річні подання + Вкладення затвердження + Затвердити + Затверджувач + Ідентифікатор вкладення розгортання + Необов'язково: підписана угода, завантажена як вкладення розгортання. + Джерело + Спецобладнання агенції + Подано агенцією + Базова ставка Cal OES + Cal OES Rate Letter + Тариф FEMA + Профіль джерела + Жоден перевірений профіль не охоплює цю дату + Назад + Прийнято базову ставку Cal OES + Агенція бере базову ставку Cal OES замість власних рядків опитування. + База + Денна + Фіксована + Погодинна + За милю + Відсоток + Заблоковано + Блокер + Поле + Агенція-відповідач + Техніка, допоміжні авто та обладнання + Вкладення + Коментарі, втрата / пошкодження, номери постачання + Відправлення / залучення + Подія + Номер замовлення події + Персонал + Номер запиту + Ресурс + Повернення / повторне відправлення + Ротації екіпажу + Підписи та авторизація + Обчислити адміністративну ставку + Cal OES MARS + Обчислити + Обчислена ставка + Скасувати + Категорія + Категорія + Проживання + Харчування + Різне + Оренда + Перевірити дату + Чек-лист + Контрольна сума + Місто + Класифікація + Назва класифікації + Закрити + Коментар + Коментарі + Залучено + Залучені години + Метод компенсації + Фактичні години + Portal-to-portal + Видалити цей запис? + Контактна e-mail + Контактна особа + Контактний телефон + Копіювати + Коментар рецензента + Прямий + Непрямий + Недопустимий + Коротко + Охоплені записи + Подані записи F-42 / витрат, які оплачує цей рахунок; їхні очікувані суми порівнюються з виставленою сумою. + Дата + Опція de minimis + Рішення від (ім'я / посада) + Видалити + Розгортання + Опис + Позначення + Деталі + Вид документа + Еквівалент + GBR + MOA + MOU + Лише документація + Запис документує реагування без вимоги відшкодування (шлях Documentation Only у MARS). + Можливий подвійний облік + Чернетка з підрозділів + Виберіть підрозділи та створіть рядки для тих, що їх не мають. + Редагувати агенцію + Редагувати угоду + Редагувати ресурс + Діє з + Придатне + Виключено + Невизначено + Кінець + Пакет доказів + Виключено (виставлено на події) + Виключено (недопустиме) + Очікувано + Очікуване відшкодування + Оцінка за ставками та угодою, чинними на момент першого відправлення. Cal OES визначає дозволену суму; внутрішні витрати в ці рядки не входять. + Очікувана сума + Очікуване проти зафіксованого + Закінчується + Назва зовнішнього ресурсу + Агенція + Техніка + Вкладення + Коментарі + Відправлення + Подія + Авторизація події + Рядки витрат + Замовлення + Персонал + Запит + Ресурс + Підпис агенції + Повернення + Ротація + Підпис + FEIN + Запит / fill + Ідентифікатор fill зовнішнього замовлення RMS (потрібен, коли замовлення має більше одного запиту) + Постачальник FI$Cal + Виправити + Функція + Згенеровано + Я уповноважена особа, яка вносить цей запис у MARS, і скопіюю показані значення, а не файл цієї сторінки. + Вигляд для копіювання при ручному введенні в портал MARS. Не зберігається, не кешується і не є поданням. + Запускайте чек-лист, поки він не стане чистим; передача відкривається зі стану «Готово до порталу». + Підготовлено для MARS. Відкриття цього вигляду створює запис аудиту і нічого не змінює; запис стає «Зафіксовано в MARS» лише коли менеджер зафіксує показане порталом. + Чек-лист досі має помилки; MARS може повернути цей запис. + Ці значення копіюються у F-42 і вимоги та зберігаються як дані (поза Advanced Data Protection). Зміна ідентифікатора скидає останню перевірку. + Залиште класифікацію порожньою для угоди на весь відділ. Угода, на яку посилається поданий запис, незмінна; редагування створює нову версію. + Польові члени бачать власні чернетки; менеджери MARS — чергу відділу. Відкриття передачі чи завантаження пакета ніколи не позначає запис поданим. + Ставки — це дані, прив'язані до перевіреного профілю; новий Rate Letter або опитування — новий знімок, а попереднє відправлення зберігає чинний на момент початку. + Блокери не дають новим F-42 досягти стану «Готово до порталу»; попередження варто усунути до наступного відправлення. + Рахунок MARS — елемент роботи, ніколи не рахунок клієнта Фази B: без номера, без прострочення, без e-mail. «Оплачено» — завжди лише зафіксований факт оплати. + Чернеткові рядки копіюють назву, номер і VIN підрозділу на той момент; перевірте їх, додайте тип ресурсу MARS, потім зафіксуйте, що показує MARS. + Години + Ідентифікатори + Авторизатор події / AREP + Виставлено напряму на подію (виключено) + Включає страхування від безробіття + Включає компенсацію працівникам + Прибуває + Дата рахунку + Очікуване проти зафіксованого, місцеве рішення та факти оплати для одного рахунку, створеного MARS. + Виставлено + Рахунки, що очікують місцевого затвердження + Елемент + Різновид + Техніка + Спецобладнання + Приватне авто + Допоміжне авто + Номерний знак + Вид рядка + Адміністративний + Адміністративна ставка + Техніка + Додаток A (без гасіння) + Витрата + Харчування, проживання, дрібні витрати + Офіційна техніка + Офіційне допоміжне авто + Персонал + Пробіг приватного авто + Пробіг приватного авто + Оренда + Salary Survey + Спецобладнання + Допоміжне авто + Пов'язаний F-42 + Рішення місцевої агенції + Затвердьте або відхиліть рахунок як уповноважений представник; відхилення потребує коментаря. Рішення фіксується тут і вноситься в MARS вами. + Втрата / пошкодження + Позначення MACS + Керування відшкодуванням Cal OES MARS + Записи агенції, ресурсів F-5, річних ставок і угод, передача в портал, зафіксовані статуси MARS та затвердження рахунків/звірка платежів. Члени зі списку готують власні чернетки F-42 і витрат без цього дозволу. + Позначити перевіреним сьогодні + Рахунок MARS + Ідентифікатор рахунку MARS + Це елемент роботи MARS. Він не має номера рахунку Фази B, ніколи не з'являється у простроченні клієнтів і не може бути надісланий e-mail чи оплачений онлайн. + Рахунки MARS + Ідентифікатор запису MARS + Ідентифікатор ресурсу MARS + Обраний метод + Милі + У списку цього розгортання + розбіжності + Назва + Нове подання + Ні + Профілю агенції ще немає. + Угод ще немає. + Немає вкладень розгортання. + Немає розгортань з відшкодування витрат для підготовки. + Resgrid не зберігає облікові дані MARS, токени MFA чи сесії браузера і ніколи не пише в портал. + Жодне річне подання не охоплює цю дату. + Без розгортання + Рахунків MARS не зафіксовано. + У черзі нічого немає. + Річних подань ще немає. + Усе необхідне для подання в MARS готове. + Рядків ресурсів F-5 ще немає. + Пакет доказів не є прийнятним файлом імпорту MARS. + Ще не обчислено. + Не чинний + Немає в інвентарі F-5 + Ще не перевірено. + Зафіксовано в MARS + Зафіксовано + Зафіксований статус + Зафіксоване подання + Одометр + Офіційні джерела + Відкрити + Відкрити розгортання + Відкрити вигляд передачі + Відкрити портал MARS + Відкриті елементи + Вибуває + Посада overhead + Придатне для понаднормових + Метод понаднормових + Після 8 годин на день + Після 12 годин на день + Немає + За угодою (не моделюється) + Понаднормова ставка + Власність + CAL FIRE + Cal OES + Місцева агенція + Інше + Приватна + Оренда + Оплачено + Оплачено + Платник + Статус платника + Посилання на оплату + Передача в портал + Посилання на обліковий запис порталу + Позначка облікового запису MARS (ніколи не пароль чи токен). + Роль у порталі + Придатне portal-to-portal + Попередньо схвалено + Підготувати вимогу витрат + Підготувати F-42 + Підготувати запис + Один F-42 на замовлений ресурс / запит; повторне відправлення замінює попередній. Вимоги витрат прив'язуються до F-42 або йдуть шляхом «лише подорож». + Підготовлено для MARS — нічого не подано, доки менеджер не зафіксує побачене в порталі. + Для друку + Походження + F-42 і вимоги витрат для підготовки, перевірки та передачі, згруповані за розгортанням; поруч зафіксовані статуси MARS і рахунки. + Звання + Заголовок, рядки та (для адміністративної ставки) аркуш фактичних витрат за попередній рік. + Рядки ставок + Зарплатні рядки потребують класифікації; рядки обладнання — коду ресурсу або FEMA. Ставки звичайні / понаднормові за базою. + Це подання підписано або подано; зміни потребують нової версії. + Версія профілю ставок + Прийнято + Чернетка + Перевірено + Підписано локально + Подано в MARS + Замінено + Salary Survey, Додаток A, адміністративна ставка, Cal OES Rate Letter та спецобладнання за роком подання. + Адміністративну ставку на цю дату не зафіксовано; адміністративний рядок не оцінюватиметься. + Профіль агенції (позначення MACS, контакти, ідентифікатори) не внесено. + Профіль агенції не звірявся з MARS протягом останнього року. + Угода закінчується протягом попереджувального періоду. + Жоден метод компенсації MOU/MOA/GBR не охоплює цю дату; персонал оцінюється за фактичними годинами без понаднормових. + Готовність станом на + Жоден перевірений профіль Cal OES не охоплює цю дату; нові подання заблоковані до його додавання. + Панель готовності + FEIN агенції не зафіксовано. + Ідентифікатор постачальника FI$Cal не зафіксовано. + Готовність агенції, ресурсів F-5, річних ставок і угод на дату відправлення з посиланнями на офіційні матеріали Cal OES. + Рахунки, створені MARS, очікують місцевого рішення агенції. + Відсутнє позначення MACS агенції. + Немає рядків зіставлення F-5; техніка у F-42 буде позначена як відсутня в інвентарі. + Річне подання спливає протягом попереджувального періоду. + Жодні рядки Cal OES Rate Letter (техніка, допоміжні авто, пробіг POV) не охоплюють цю дату. + Чинне річне подання не підписано локально. + Рядки ресурсів F-5 не збігаються з тим, що показує MARS. + Записи, повернуті Cal OES на перегляд агенцією, очікують. + Жодне перевірене Salary Survey (або прийнята базова ставка) не охоплює цю дату; рядки персоналу неможливо оцінити. + SAM UEI агенції не зафіксовано. + Готово + Готово до порталу. + Квитанція + Звірка рахунків і платежів + Рахунки, створені MARS, як зафіксовано, місцеве рішення затвердити / відхилити, статус платника та факти оплати проти очікуваних рядків. + Зафіксувати рахунок MARS + Зафіксуйте рахунок, створений Cal OES, як ви бачите його в порталі, і подані записи, які він охоплює. + Зафіксувати спостереження + Зафіксувати оплату + Лише зафіксована оплата платника позначає рахунок і його записи як оплачені. + Зафіксувати подання, побачене в MARS + Тип запису + Адміністративна ставка + Профіль агенції + Угода + Додаток A + Вимога витрат + F-42 + Рахунок MARS + Інвентар F-5 + Salary Survey + Спецобладнання + Зафіксовано + Повторне відправлення + Відхилити + Пов'язані записи + Звільнення — не повернення; F-42 потребує часу повернення або повторного відправлення. + Звільнено + Місце прибуття + Запит + Код ресурсу + Інвентар ресурсів F-5 + Різновид ресурсу + Ресурси F-5 + Тип ресурсу + Зіставлення підрозділів і активів Resgrid з їхньою ідентичністю MARS / F-5. Готує і звіряє інвентар; ніколи не пише статус FAST. + Підписант агенції + Повернуто на перегляд + Причина + Стан перегляду + Чернетка + Розбіжність + Зафіксовано в MARS + Перевірено + Перегляд + Прийнято + Виключено + Очікує + перевірено + Запустити чек-лист + Реєстрація SAM + Зберегти + Не вдалося зберегти зміну. + Зберегти записи + Зберегти рядки + Збережено. + Серійний номер + Зафіксовано прийняття + Повернути до чернетки + Позначити перевіреним + Підписати локально + Зафіксовано подання в MARS + Замінити + Серйозність + Підписав (ім'я) + Підписано + Підписант + Джерело + Джерельний артефакт + Дата джерела + Джерельний рядок + Джерельна система + URL джерела + Початок + Стан + Затверджено + Закрито + Лише документація + Чернетка + Відхилено місцевою агенцією + Потребує перегляду + Оплачено + Очікує місцевого затвердження + Очікує платника + Готово до порталу + Повернуто на перегляд агенцією + Зафіксовано в MARS (перегляд Cal OES) + Статус + Звичайна ставка + Strike team / task force + Об'єкт + Тип об'єкта + Зовнішній ресурс + Актив інвентарю + Підрозділ + Подання + Тип подання + Адміністративна ставка + Додаток A (без гасіння) + Cal OES Rate Letter + Salary Survey + Спецобладнання / коди FEMA + замінює + Номери постачання + Супровідні документи + Агенція + Угоди + Черга дій + Річні ставки + Готовність + Звірка + Ресурси F-5 + Лише подорож (без F-42) + SAM UEI + Підрозділ + Позначення підрозділу + Перевірено + Чек-лист: {0} помилок, {1} попереджень. + Профіль агенції відсутній. + Жодна угода MOU / MOA / GBR не охоплює дату відправлення. + Жоден перевірений профіль не охоплює цей запис. + Класифікація особи відсутня в Salary Survey і в закріпленому списку. + Відсутній час відправлення. + Позначено «Лише документація»: відшкодування не вимагається. + Той самий транспорт з'являється більше одного разу. + Відсутній затверджувач вимоги. + Пов'язаний F-42 не зафіксовано поданим у MARS. + Вимога витрат не має рядків. + Рядок витрати без квитанції. + Відсутній підпис вимоги. + Відсутня авторизація події / AREP. + Відсутня назва або номер події. + Відсутнє позначення MACS. + Відсутній номер замовлення події. + До розгортання не додано підписаний або паперовий F-42. + Інтервал залучення особи виходить за межі вікна відправлення-повернення. + Персонал не вказано. + Немає чинних рядків річних ставок на дату відправлення. + Ресурс звільнено, але час повернення чи повторного відправлення не зафіксовано. + Відсутній номер запиту. + Номер запиту не має дійсного префікса (E, O, C, S, A) та номера. + Відсутній тип, різновид ресурсу або посада overhead. + Відсутній підпис агенції-відповідача. + Час повернення раніший за відправлення. + Ротація екіпажу без вкладення затвердження. + Транспорт не має рядка зіставлення F-5. + Значення + Відхилення + Перевірка + Позначте профіль перевіреним після порівняння із записом агенції в MARS. Панель попереджає через рік. + Перевірено + Версія + Попередження + Період + Офіційний порядок полів, посилання на незмінні факти розгортання, чек-лист, очікуване відшкодування та передача в портал. + Рік + Так + Суми записів не можуть бути від'ємними. + Класифікація запису недійсна. + Фінансовий рік запису недійсний (лише попередні роки). + Назва агенції обов'язкова. + Профіль агенції не знайдено. + На угоду посилається елемент роботи; її не можна видалити. + Вид документа угоди недійсний. + Метод компенсації недійсний. + Угоду не знайдено. + Метод понаднормових недійсний. + Підтвердьте засвідчення перед відкриттям вигляду передачі. + Дата завершення раніша за дату початку. + Рішення потребує імені / посади особи, що вирішує. + Розгортання не знайдено. + Пов'язаний F-42 не знайдено в цьому розгортанні. + Цей запит / fill не належить зовнішньому замовленню розгортання. + Замовлення має кілька запитів; оберіть запит / fill для цього F-42. + Виставлена сума не може бути від'ємною. + Рахунок MARS з таким ідентифікатором уже зафіксовано. + Ідентифікатор рахунку MARS обов'язковий. + Зафіксуйте місцеве затвердження перед оплатою. + Рахунок не в стані, що дозволяє зафіксувати оплату. + Рахунок не очікує місцевого затвердження. + Запис не «Готово до порталу»; спочатку запустіть чек-лист. + Оплачена сума не може бути від'ємною. + Подання не має рядків, адміністративної ставки чи прийнятої базової ставки. + База рядка ставки недійсна. + Зарплатні рядки потребують коду класифікації. + Вид рядка ставки недійсний. + Ставки не можуть бути від'ємними. + Рядки обладнання потребують коду ресурсу або FEMA. + Це подання підписано або подано і більше не редагується. + Річне подання не знайдено. + Зафіксуйте локальний підпис перед фіксацією зовнішнього статусу. + Адміністративна ставка має бути від 0 до 100 відсотків. + Підписання потребує імені підписанта. + Ця зміна статусу не дозволена. + Тип подання недійсний. + Рік подання недійсний. + Відхилення потребує коментаря. + Зовнішній ресурс потребує назви. + Рядок ресурсу не знайдено. + Тип об'єкта ресурсу недійсний. + Підрозділ не знайдено в цьому відділі. + Для ресурсу підрозділу потрібен підрозділ. + Цей зовнішній статус не входить до словника профілю джерела. + Запис зафіксовано в MARS і він більше не редагується локально. + Лише F-42 та вимоги витрат мають очікуване відшкодування. + Закрити можна лише затверджені, «лише документація», відхилені або оплачені записи. + Запис не зафіксовано поданим у MARS. + Запис не знайдено. + Запис не є рахунком MARS. + Лише F-42 та вимоги витрат подаються в MARS. + Запис оплачено або закрито; його рядки остаточні. + Запис не очікуваного типу. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx new file mode 100644 index 00000000..6acf8bd8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + تكلفة الاقتناء + تاريخ الاقتناء + نشط + نشط من + نشط حتى + الفعلي + ساعات العمل الفعلية + إضافة كيان تابع + إضافة تعيين + إضافة مقاول + إضافة افتراضي القسم + إضافة فترة عمل + إضافة منشأة + إضافة بيان + إضافة ملف + إضافة ملف مورد + إضافة افتراضي الدور + إضافة قراءة + إضافة سجل + إضافة عامل + أو أضف عاملًا خارجيًا بمفتاح الرواتب والتسمية (كلاهما محمي). + اختر عضوًا لإنشاء سجل العامل الخاص به. + العنوان + كيان تابع + الكيانات التابعة + فقط للمؤسسة المتكاملة: الكيانات الأخرى التي يغطيها التقرير. + تجميع الصفوف + أساس التخصيص + لكل يوم + لكل ساعة محرك + لكل كيلومتر + لكل ميل + لكل ساعة تشغيل + المبلغ + بيانات الأجور السنوية + دخل W-2 والساعات والأسابيع لكل فترة عمل لسنة الإبلاغ — مدخل كل لقطة CRD. + بيانات الأجور السنوية مفقودة + تطبيق التجاوز + الاعتماد + الملفات غير المعتمدة تسعّر التقديرات لكنها تعلّم كل بند للمراجعة. أي تغيير في المعدل يلغي الاعتماد. + اعتماد الملف + معتمد + تكلفة الرواتب المعتمدة + القيمة الفعلية من نظام الرواتب؛ عند وجودها تحل محل تقدير هذا اليوم. + ملفات التصدير + CSV وXLSX بترتيب أعمدة قالب CRD، بمجموع تحقق ثابت للمدخلات غير المتغيرة، قابلة للتنزيل بدون تخزين مؤقت حتى الحذف بعد فترة الاحتفاظ. + رجوع + المبلغ الأساسي + لكل ساعة أو سنة أو يوم أو وردية حسب أساس الأجر. + الأساس + العرض + إيراد نقطة التعادل + إنشاء اللقطات + معيّن في منشأة بكاليفورنيا + كلاهما + ليس موظفًا في كاليفورنيا + يعمل في كاليفورنيا + أساس كاليفورنيا + الموظفون في كاليفورنيا + رقم النداء + إلغاء + الحد الأقصى + الفئة + مستهلك + نفقة + نفقات عامة + الأفراد + مورد + مرجع مصادقة البوابة + مجموع التحقق + المدينة + الدخل المخصص للعميل + الساعات المخصصة للعميل + الأسابيع المخصصة للعميل + مفتاح تخصيص العميل + الرمز + مصدر الجمع + سجل التوظيف + كيف تم الحصول على هذا السجل؛ إدراك المراقب يُعلَّم في كل عملية تقرير. + إدراك المراقب + سجل موثوق آخر + تعريف ذاتي + تأكيد الاستيراد + التعويض + ملفات التعويض للموظف والدور الافتراضي والقسم مع مكوّنات الأجر وتكلفة صاحب العمل. + ملف التعويض + اكتمال البيانات الديموغرافية + المكوّن + المكوّنات + إنشاء عملية تصحيح؟ تبقى العملية المصدَّرة كما قُدمت وتُعلَّم بأنها مصحَّحة. + هل تريد حذف هذا السجل؟ + تجميد هذه العملية؟ العملية المجمّدة غير قابلة للتغيير؛ وإعادة الحساب لاحقًا تحل محلها. + تأكيد الاستيراد؟ كل صف ينشئ إصدارًا جديدًا من البيانات. + إبطال هذه العملية؟ سيتم حذف ملفات التصدير الخاصة بها. + المستهلكات + الاستهلاك + بيانات الاتصال + السياق + عرض + نداء + انتشار + المقاولون الذين يوفرون موظفين مشمولين بتقرير CRD الثاني. + تصحيح البيان (إصدار جديد) + نفقات عامة مخصصة + ضريبة الرواتب على صاحب العمل + تكلفة عمالة ثابتة + المزايا الصحية + أخرى + مزايا أخرى + التقاعد / المعاش + تعويض العمال + سنوي ثابت + لكل يوم + لكل ساعة + لكل وردية + % من الأجر المؤهل + مكوّنات التكلفة + ضرائب الرواتب والمزايا والمعاش والنفقات العامة المخصصة — نسبة من الأجر المؤهل أو لكل ساعة أو لكل وردية أو سنوية ثابتة، مع حد أقصى اختياري. + عملية حساب التكلفة + عمليات حساب التكلفة + التكلفة الداخلية الكاملة والهامش للعروض (تقدير) والنداءات والانتشارات (فعلي). الإيراد دائمًا رقم مرصود فقط. + مشمول — كلا التقريرين + مشمول — تقرير موظفي مقاول العمالة + مشمول — تقرير موظفي الرواتب + يُعلنه صاحب العمل بعد قراءة إرشادات CRD؛ Resgrid لا تقرر أبدًا ما إذا كان عليك التقديم. + غير مشمول + حالة التغطية + غير معلن + إنشاء تصحيح + إنشاء عملية + تم الإنشاء + العملة + الإجابة الحالية + بيانات أجور محمية وحساب تكاليف ميدانية داخلية وتقارير بيانات الأجور في كاليفورنيا. + التاريخ + الأيام + الأيام المعمولة + DBA + أفضّل عدم الإجابة + رفضوا + المنشأة الافتراضية + الملفات الافتراضية + تسعّر الافتراضات للدور والقسم تقديرات العروض والأعضاء بدون ملف موظف. + حذف + العرق/الإثنية/الجنس + السجل الديموغرافي + سجل مسؤول الامتثال لعامل لم يعرّف نفسه ذاتيًا. + استخدم سجلات التوظيف أو سجلات موثوقة أخرى أولًا؛ إدراك المراقب هو الملاذ الأخير ويُعلَّم في كل لقطة. السبب مطلوب ويُدقق. + إجابات ديموغرافية مفقودة + الإجابة طوعية. تفضيل عدم الإجابة إجابة صالحة ويُبلَّغ عنها كذلك. + القسم + أيام الانتشار + الانتشار + معرّف الانتشار + الإهلاك + الإهلاك / وحدة + تسمية العرض + المسافة + أدخل قراءات العداد أو مسافة؛ تُخزَّن الكيلومترات كأميال. + وحدة المسافة + التنزيلات + تشغيل تجريبي + نتيجة التشغيل التجريبي + تاريخ الاستحقاق + مصدر الدخل + مخصص للعميل + W-2 الخانة 1 (بديل) + W-2 الخانة 5 + الدخل المستخدم + عنوان EDD + تعديل التعيين + تعديل المقاول + تعديل فترة العمل + تعديل المنشأة + تعديل ملف المورد + تعديل القراءة + تعديل السجل + ساري من + رموز الأجر المؤهلة + ملفات الموظف + الموظفون + صاحب العمل + مكوّنات تكلفة صاحب العمل + تكاليف صاحب العمل + هوية صاحب العمل التي يُقدَّم بها تقرير CRD مع الكيانات التابعة. + لا يوجد ملف صاحب عمل بعد. + ملف صاحب العمل + قسم صاحب العمل + فترة العمل + نوع التوظيف + دوام كامل + متقطع + دوام جزئي + غير معروف + فترات العمل + عداد المحرك النهاية + عداد النهاية + النهاية + ساعات المحرك + الأخطاء + المنشأة + المنشآت + كل موقع فعلي يُعيَّن فيه الموظفون؛ يُقدَّم تقرير CRD لكل منشأة. + التقدير + التقدير مقابل الفعلي + مقدّر + الاستثناءات + الفعلي + الإجازة المدفوعة + الأيام × متوسط الساعات + طريقة بديلة للمعفيين + لا شيء + الإعفاء + معفى + غير معفى + غير معروف + الاستخدام السنوي المتوقع + بوحدات التخصيص سنويًا؛ يوزع المكوّنات السنوية الثابتة والإهلاك الزمني. + النفقات + ينتهي + ينتهي + تصدير تقارير بيانات الأجور في كاليفورنيا + تجميد عملية تم التحقق منها وتنزيل ملفات CSV / XLSX وورقة عمل البوابة (بدون تخزين مؤقت، مدقق). + المعرّف الخارجي + مفتاح المورد الخارجي + معرّف بند جدول الأسعار هنا يتيح لتقديرات العروض تسعير بنود المركبات والمعدات حسب الفئة. + المصدر الخارجي + مفتاح العامل الخارجي + البيانات المخزنة غير قابلة للتغيير: الحفظ ينشئ إصدارًا جديدًا يحل محل الحالي. + ملف بديل + FEIN + حساب التكاليف الميدانية + التكلفة الداخلية الكاملة والهامش للعروض والنداءات والانتشارات — فئات مجمعة فقط. + الملف + البريد الإلكتروني لجهة الاتصال + اسم جهة اتصال التقديم + هاتف جهة الاتصال + تصفية + العلامات + التنسيق + تجميد + تجميد وتصدير + مجمّد + الوقود + تكلفة الوقود الفعلية + كمية الوقود + وحدة الوقود + المقر الرئيسي + عنوان المقر الرئيسي + كل مبلغ ومضاعف حقل محمي. تستخدم التقديرات ملف الموظف ثم افتراضي الدور ثم افتراضي القسم؛ والتكلفة المعتمدة من نظام الرواتب تسود دائمًا. + تُعلن التغطية من قبلك ولا تحددها Resgrid أبدًا. المعرّفات والعناوين حقول محمية؛ قيمة REDACTED غير المعدّلة تحتفظ بما هو مخزّن. + من أصل إسباني أو لاتيني + المعدل الساعي + الساعات + ساعات / يوم + ساعات / أسبوع + ساعات / سنة + نوع الساعات + وقت مضاعف + أخرى + إضافية + إجازة مدفوعة + عادية + استعداد + سفر + نوع المعرّف + ساعات الخمول + استيراد بيانات الأجور السنوية (CSV) + الصق تصدير الرواتب بترتيب الأعمدة القياسي. التشغيل التجريبي يتحقق ويسوّي كل صف قبل الحفظ؛ البيانات المستوردة تصل غير معتمدة. + نتيجة الاستيراد + الخانة 5 غائبة — استُخدمت الخانة 1 (ملاحظة مطلوبة) + صف مكرر + الدخل مفقود + لا توجد فترة عمل تغطي السنة + لا شيء للاستيراد + الساعات القابلة للإبلاغ مفقودة أو صفر + العامل غير موجود + سنة غير صالحة + مشمول + أصل مخزون + جزء من مؤسسة متكاملة + فئة الوظيفة CRD + إحدى فئات الوظائف العشر لـ CRD في ملف المخطط المراجَع. + المسمى الوظيفي + مقاول العمالة + مقاولو العمالة + الاسم القانوني + السطر + البنود + بنود الأفراد لا تحمل معدلًا ظاهرًا؛ التفاصيل المسعّرة حقل محمي يُعرض فقط مع تفويض ساري. + تظهر البنود مع صلاحية عرض التعويض؛ الملخص أعلاه هو العرض المجمع. + النشاط الرئيسي + إدارة تقارير بيانات الأجور في كاليفورنيا + معالج تقارير CRD: العمليات ولقطات الموظفين والتجاوزات والتجميع والتحقق والملاحظات ورصد المصادقة والتصحيحات وسجلات مسؤول الامتثال الديموغرافية. كل عضو يجيب دائمًا عن نفسه بدونها. + إدارة القوى العاملة والتعويض + هوية صاحب العمل والمنشآت والعاملون وفترات العمل والتعيينات الوظيفية وملفات ومكوّنات التعويض وسجلات العمل واستيراد بيانات الأجور السنوية. لا تزال القيم تتطلب تفويضًا ساريًا. + مصدر المطابقة + هامش المساهمة + تسجيل المصادقة + فقط ما لاحظته في بوابة CRD بعد المصادقة هناك؛ Resgrid لا تصادق أبدًا. + ملف سلطة MARS + تصنيف Cal OES MARS + متوسط المعدل الساعي + المعدل الساعي الوسيط + عضو + الأميال + المدخلات المفقودة + إجابتي الديموغرافية + أجب أو حدّث تعريفك الذاتي الطوعي لتقارير بيانات الأجور في كاليفورنيا. + NAICS + الاسم + عملية جديدة + لا + لا إجابة + لا توجد ملفات تصدير — جمّد وصدّر عملية تم التحقق منها. + لا توجد تعيينات وظيفية. + لا يوجد مقاولو عمالة. + لا توجد فترات عمل بعد. + لا توجد منشآت بعد. + لا توجد بيانات أجور سنوية لهذه السنة ونوع التقرير. + لا توجد بنود. + لا توجد ملفات تعويض. + لا توجد ملفات تكلفة موارد. + لا توجد صفوف مجمعة بعد — جمّع بعد اكتمال اللقطات. + لا توجد عمليات بعد. + لا توجد لقطات بعد — أنشئها أولًا. + لا توجد قراءات استخدام. + لا توجد سجلات في هذه الفترة. + لا يوجد عاملون بعد. + حضوري + لا يوجد + لا تزال هناك أخطاء مانعة + لم يتم التحقق بعد. + إدراك المراقب + العمليات المفتوحة + ساعات التشغيل + المسافة (كما قُرئت) + أخرى + النفقات العامة + سبب التجاوز + اسم المالك + ساعات الإجازة المدفوعة + أجر + شريحة الأجر + أساس الأجر + يومي + بالساعة + راتب + لكل وردية + مكافأة + فرق + التعليم + الإسعاف + المواد الخطرة + حافز + الأقدمية + أخرى + تخصص + USAR + سنوي ثابت + لكل ساعة + لكل فترة دفع + لكل وردية + % من الأساس + مكوّنات الأجر + الفروقات والحوافز وأجور التخصص فوق الأساس. مكوّنات الفترة الثابتة لا تُدفع على الوقت الإضافي ما لم تُعلَّم لكل ساعة إضافية. + Resgrid تُعدّ وتُصدّر؛ لا تقدّم أبدًا ولا تقرر ما إذا كنت مشمولًا ولا تصادق أبدًا. كل ما في هذه الشاشات حقل محمي يُقدَّم بدون تخزين مؤقت. + أعدّ تقارير CRD في كاليفورنيا لموظفي الرواتب وموظفي مقاول العمالة؛ وتقدّمها بنفسك في بوابة CRD. + تقارير بيانات الأجور + عملية التقرير + لكل ساعة إضافية + الأفراد + دور الأفراد + يُستخدم لاختيار ملف تعويض افتراضي للدور عند عدم وجود ملف للموظف. + المرحلة + الحادث + التعبئة + العودة + استعداد + العنوان + ورقة عمل البوابة + الرمز البريدي + التكلفة الكاملة ليوم عادي من 8 ساعات + لا يوجد ملف مخطط مراجَع + المعرّفات والعناوين ومعدلات الأجور والدخل والإجابات الديموغرافية وملفات التصدير هي حقول حماية بيانات متقدمة (الفهرس 28). تُعرض كـ REDACTED بدون تفويض ساري ولا يتم تخزينها مؤقتًا أو تسجيلها أبدًا. + المصدر + مصدر الهوية (عقد، W-9، سجل البوابة). + متوسط الساعات في اليوم + محذوف + الكمية + العرق / الإثنية + اختر كل الفئات المنطبقة؛ فئتان أو أكثر تُبلَّغ كمتعدد الأعراق. + أبيض + أسود أو أمريكي من أصل أفريقي + من سكان هاواي الأصليين أو جزر المحيط الهادئ + آسيوي + من الهنود الأمريكيين أو سكان ألاسكا الأصليين + من الشرق الأوسط أو شمال أفريقيا + المعدل + مضاعفات المعدل + JSON حسب رمز الأجر؛ الرموز المفقودة تستخدم 1.5 (إضافي) و2 (مضاعف) و1. + الجاهزية + جاهز للتجميد + السبب + تمت التسوية + المعادل الساعي العادي + تجاوز اختياري؛ وإلا يُشتق من المبلغ الأساسي والساعات القياسية. + العلاقة + نهاية العلاقة + بداية العلاقة + عن بُعد في CA + عن بُعد خارج CA + نوع التقرير + تقرير موظفي مقاول العمالة + تقرير موظفي الرواتب + الساعات القابلة للإبلاغ + سنوي ثابت + لكل يوم + لكل انتشار + لكل ساعة محرك + لكل ساعة خمول + لكل كيلومتر + لكل ميل + لكل ساعة تشغيل + المستهلكات + الإهلاك + نفقات عامة ثابتة + الوقود / الطاقة + التأمين / الترخيص + الإيجار + الصيانة + أخرى + التخزين + الإطارات / التآكل + الوقود (معدل أو استهلاك × سعر الوحدة)، الصيانة (يدوية أو فعلية من أوامر العمل مع نافذة عداد)، الإطارات، التأمين، الإيجار، التخزين، النفقات الثابتة والمستهلكات. + تكاليف الموارد + الإهلاك والوقود والصيانة والتكاليف الثابتة لكل وحدة أو أصل أو فئة مورد خارجي. + ملفات الموارد + بيانات رقمية عادية محمية بصلاحية عرض التكاليف الداخلية — لا يُسعَّر أي شخص هنا. + محسوب من الاقتناء + مستورد + يدوي + فعلي متجدد من أوامر العمل + استخدام الموارد + الموارد + الإيراد + إجمالي العرض المقدّر + Cal OES MARS معتمد + Cal OES MARS متوقع + Cal OES MARS مدفوع + فواتير العميل + لا شيء + مصدر الإيراد + المراجعة + استُخدمت تكلفة الرواتب المعتمدة + استُخدم ملف الفئة + المكوّن غير معتمد + عدم تطابق العملة + استُخدم افتراضي القسم + مدخلات الإهلاك مفقودة + المسافة التلقائية واليدوية متعارضتان + مدخلات الوقود مفقودة + لا يوجد ملف تعويض + لا توجد فترة عمل للعضو + لا يوجد معدل للأساس + لا يوجد ملف مورد + مضاعف رمز الأجر مفقود (استُخدم الافتراضي) + الإصلاح محسوب مباشرة بالفعل + استُخدم افتراضي الدور + نافذة الصيانة المتجددة غير كافية + ملف غير معتمد + قراءة الاستخدام تحتاج مراجعة + الاستخدام السنوي مفقود + الصفوف + تقدير تكلفة العرض + بنود العرض × التعويض الافتراضي للقسم وملفات موارد الفئة؛ الإيراد = إجمالي العرض المقدّر. + حساب تكلفة النداء + سجلات العمل وقراءات الاستخدام للنداء؛ بدون إيراد. + حساب تكلفة الانتشار + تقارير الوقت اليومية المعتمدة وقراءات الاستخدام والنفقات حتى التاريخ. + ملاحظات توضيحية + حتى 500 حرف؛ مطلوبة عند استخدام دخل الخانة 1 أو تطبيق طريقة بديلة للمعفيين. + مسودة + مجمّد + يحتاج مراجعة + مستبدل + نوع العملية + فعلي + تقدير + عمليات التقرير + القيمة المتبقية + حفظ + تعذر حفظ التغيير. + حفظ الإجابة + تم الحفظ. + ملف المخطط + النطاق + افتراضي القسم + موظف + افتراضي الدور + SEIN (EDD) + التعريف الذاتي الطوعي + تُخزَّن إجاباتك بشكل منفصل عن ملفك الوظيفي ومشفّرة وتُستخدم فقط لإعداد تقرير CRD المجمع. لا يراها أحد في القسم على أي شاشة أخرى. + تعريف ذاتي + الجنس + أنثى + ذكر + غير ثنائي + الحجم + تم التخطي + اللقطة + الموظفون في اللقطة + نهاية اللقطة + بداية اللقطة + فترة دفع واحدة بين + لقطات الموظفين + صف واحد لكل موظف في كاليفورنيا خلال الفترة؛ الرمز الديموغرافي والدخل والمعدل الساعي حقول محمية. التجاوزات تتطلب سببًا وتُدقق. + رمز SOC + إصدار SOC + رقم وزير خارجية كاليفورنيا + المصدر + عداد المحرك البداية + عداد البداية + البداية + الولاية + الحالة + مصادق عليه خارجيًا + مصحَّح + مسودة + مُصدَّر + مجمّد + تم التحقق + ملغى + الموضوع + نوع الموضوع + فئة خارجية + أصل مخزون + وحدة + الملخص + بيانات الأجور السنوية + التعويض + مقاولو العمالة + عمليات حساب التكلفة + نظرة عامة + صاحب العمل + المنشآت + تقارير بيانات الأجور + تكاليف الموارد + سجلات العمل + العاملون + حتى تاريخ + المنطقة الزمنية + الإجمالي + إجمالي التكلفة الكاملة + غير معتمد + الوحدة + سعر الوحدة + الاستثناءات غير المحلولة + تم التحديث + الموظفون في الولايات المتحدة + سجلات الاستخدام + قراءات العداد وعداد المحرك والساعات والوقود لكل وحدة ويوم؛ تُخزَّن المسافة بالأميال وتُحال القراءات المتعارضة للمراجعة. + تقرير الوقت اليومي + GPS + متتبع الأجهزة + استيراد + يدوي + العمر الإنتاجي (أشهر) + العمر الإنتاجي (وحدات) + الأميال أو الساعات أو الأيام على مدى العمر؛ القسط الثابت = (التكلفة − المتبقي) ÷ العمر. + تحقق + تم التحقق في + التحقق + بيان الأجر السنوي مفقود + بيان الأجر السنوي غير معتمد + تعيينات وظيفية متداخلة + هوية مقاول العمالة مفقودة + حالة التغطية غير معلنة + الإجابة الديموغرافية مفقودة أو مرفوضة + استُخدمت W-2 الخانة 1 بدل الخانة 5 (ملاحظة مطلوبة) + الدخل مفقود + أعداد الموظفين في الصفوف لا تتطابق مع اللقطات + هوية صاحب العمل غير مكتملة (الاسم، FEIN، SEIN، عنوان EDD) + عنوان المنشأة غير مكتمل + المنشأة مفقودة + NAICS المنشأة مفقود + استُخدمت طريقة بديلة للمعفيين (ملاحظة مطلوبة) + الحقل يتجاوز طول القالب + سيتجاوز التصدير حد حجم ملف البوابة + الساعات القابلة للإبلاغ صفر + فئة الوظيفة مفقودة + فئة الوظيفة ليست في ملف المخطط + تم تطبيق تجاوز يدوي + لا يوجد موظفون في اللقطة + استُخدم إدراك المراقب للرمز الديموغرافي + تغيّر ملف المخطط منذ إنشاء العملية + أعداد العمل عن بُعد لا تتطابق مع عدد الموظفين + عمود القالب المطلوب فارغ + الصفوف لم تُجمّع بعد + فترة اللقطة خارج النافذة المسموحة + الأسابيع المعمولة مفقودة + نمط العمل غير محدد (لا يوجد تعيين) + الفرق + الإصدار + عرض التكاليف الداخلية + ملخصات مجمعة لتكاليف الميدان والهوامش للعروض والنداءات والانتشارات — فئات وإجماليات، وليس بندًا أو معدلًا أو شخصًا — بالإضافة إلى ملفات تكلفة الموارد وقراءات الاستخدام. + عرض التعويض + قراءة ملفات التعويض وسجلات العمل وبيانات الأجور السنوية وبنود عمليات التكلفة. لا تزال القيم تتطلب تفويضًا ساريًا. + إبطال + W-2 الخانة 1 + W-2 الخانة 5 + التحذيرات + تم الإبلاغ عنه في العام السابق + الأسابيع المعمولة + لماذا نسأل + تُلزم المادة 12999 من قانون حكومة كاليفورنيا أصحاب العمل المشمولين بالإبلاغ عن بيانات الأجور مجمّعة حسب فئة الوظيفة والعرق/الإثنية والجنس. يحتوي التقرير على أعداد ومعدلات مجمعة فقط؛ ولا يذكر أي شخص بالاسم. + 1. إنشاء لقطات الموظفين · 2. تجميع الصفوف · 3. التحقق · 4. التجميد والتصدير · ثم الإقرار في بوابة CRD وتسجيل المصادقة هنا. + الدولة + سجلات العمل + ساعات لكل عامل ويوم من الرواتب والانتشارات والنداءات؛ التكلفة المعتمدة من الرواتب، إن وُجدت، تحل محل أي تقدير. + موقع العمل + نمط العمل + حضوري + عن بُعد خارج كاليفورنيا (معيّن في منشأة بكاليفورنيا) + عن بُعد داخل كاليفورنيا + الولاية / المقاطعة + عامل + فترات العمل لا تتداخل أبدًا؛ كل فترة تحمل تعييناتها الوظيفية عبر الزمن. + نوع العامل + متعاقد مستقل + موظف مقاول عمالة + موظف على كشف الرواتب + متطوع + العاملون + كل شخص يدفع له القسم أو يبلّغ عنه — الأعضاء والعاملون الخارجيون — مع فترات عملهم. + القوى العاملة + قيم صاحب العمل التي تُدخلها في بوابة CRD إلى جانب الملف المرفوع. + يُقدَّم بدون تخزين مؤقت ويُدقق؛ أغلق التبويب عند الانتهاء. Resgrid لا تسجل الدخول إلى البوابة أبدًا. + السنة + نعم + لم يتم العثور على ملف التصدير. + انتهت صلاحية ملف التصدير أو تم حذفه. + مرجع مصادقة البوابة مطلوب. + مصدر الجمع غير صالح. + توجد عملية تصحيح بالفعل. + إجابة الأصل الإسباني أو اللاتيني غير صالحة. + أنشئ لقطات الموظفين أولًا. + لا يوجد ملف مخطط CRD مراجَع يغطي سنة الإبلاغ تلك. + رمز العرق/الإثنية غير صالح. + أجب عن العرق/الإثنية أو اختر عدم الإجابة. + السبب مطلوب. + الملاحظات محدودة بـ 500 حرف. + لا يمكن إبطال عملية مصادق عليها؛ أنشئ تصحيحًا. + العملية مجمّدة؛ أنشئ تصحيحًا بدلًا من ذلك. + فقط العملية المصدَّرة يمكن تعليمها كمصادق عليها. + لم يتم العثور على عملية التقرير. + فقط العملية المصدَّرة أو المصادق عليها يمكن تصحيحها. + رمز الجنس غير صالح. + أجب عن الجنس أو اختر عدم الإجابة. + لم يتم العثور على لقطة الموظف. + يجب أن تكون فترة اللقطة فترة دفع واحدة ضمن النافذة المسموحة. + لا يزال التحقق يبلغ عن أخطاء مانعة. + تعذر على عملية الإبلاغ فك تشفير القيم المحمية. + الاسم القانوني للكيان التابع مطلوب. + أساس التخصيص غير صالح. + أحد المبالغ سالب. + أصل المخزون مطلوب. + يتداخل التعيين مع تعيين آخر لفترة العمل هذه. + لم يتم العثور على العرض. + فئة أو أساس المكوّن غير صالح. + الاسم القانوني للمقاول مطلوب. + يحتاج موظف مقاول العمالة إلى مقاول عمالة. + حالة التغطية غير صالحة. + تاريخ النهاية قبل تاريخ البداية. + لم يتم العثور على الانتشار. + الاسم القانوني لصاحب العمل مطلوب. + لم يتم العثور على فترة العمل. + تتداخل الفترة مع فترة عمل أخرى لهذا العامل. + منشأة أخرى تستخدم هذا الرمز بالفعل. + تحتاج المنشأة إلى رمز واسم. + لم يتم العثور على المنشأة. + مفتاح المورد الخارجي مطلوب. + يجب أن تكون الساعات بين 0 و24. + نوع الساعات غير صالح. + فئة الوظيفة ليست في ملف المخطط. + يجب أن يتكون NAICS من ستة أرقام. + لا شيء لحسابه: لا توجد سجلات عمل أو قراءات استخدام. + لم يتم العثور على السجل. + أساس الأجر غير صالح. + تتداخل الفترة مع ملف آخر بنفس النطاق. + تم رفض الكتابة المحمية. + نوع التقرير غير صالح. + يحتاج افتراضي الدور إلى دور أفراد. + العملية مجمّدة ولا يمكن تغييرها. + نطاق الملف غير صالح. + نوع الموضوع غير صالح. + لم يتم العثور على الوحدة. + الوحدة مطلوبة. + تحتاج قراءة الاستخدام إلى انتشار أو نداء. + قراءة الاستخدام غير صالحة. + نمط العمل غير صالح. + هذا العضو لديه سجل عامل بالفعل. + يحتاج العامل إلى عضو أو مفتاح خارجي. + نوع العامل غير صالح. + لم يتم العثور على العامل. + العامل مطلوب. + سنة الإبلاغ غير صالحة. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.cs b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.cs new file mode 100644 index 00000000..58716ec5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Localization.Areas.User.Workforce +{ + public class Workforce { } +} diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx new file mode 100644 index 00000000..d3c17600 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Anschaffungskosten + Anschaffungsdatum + Aktiv + Aktiv ab + Aktiv bis + Ist + Tatsächlich gearbeitete Stunden + Verbundenes Unternehmen hinzufügen + Zuordnung hinzufügen + Dienstleister hinzufügen + Organisationsstandard hinzufügen + Beschäftigung hinzufügen + Betriebsstätte hinzufügen + Datensatz hinzufügen + Profil hinzufügen + Ressourcenprofil hinzufügen + Rollenstandard hinzufügen + Ablesung hinzufügen + Arbeitseintrag hinzufügen + Beschäftigten hinzufügen + Oder legen Sie eine externe Person über Lohnschlüssel und Anzeigename an (beide geschützt). + Wählen Sie ein Mitglied, um dessen Beschäftigtendatensatz anzulegen. + Adresse + Verbundenes Unternehmen + Verbundene Unternehmen + Nur bei einem integrierten Unternehmen: die weiteren von der Meldung erfassten Einheiten. + Zeilen aggregieren + Zuordnungsbasis + Pro Tag + Pro Motorstunde + Pro Kilometer + Pro Meile + Pro Betriebsstunde + Betrag + Jahreslohndaten + W-2-Einkünfte, Stunden und Wochen je Beschäftigung für das Meldejahr – die Grundlage jeder CRD-Momentaufnahme. + Fehlende Jahreslohndaten + Überschreibung anwenden + Genehmigung + Nicht genehmigte Profile bewerten Schätzungen weiterhin, kennzeichnen aber jede Position zur Prüfung. Jede Satzänderung hebt die Genehmigung auf. + Profil genehmigen + Genehmigt + Freigegebene Lohnkosten + Der Ist-Wert aus dem Lohnsystem; falls vorhanden, ersetzt er die Schätzung für diesen Tag. + Exportdateien + CSV und XLSX in der Spaltenreihenfolge der CRD-Vorlage, prüfsummenstabil bei unveränderter Eingabe, ohne Zwischenspeicherung herunterladbar bis zur Löschung nach der Aufbewahrungsfrist. + Zurück + Grundbetrag + Pro Stunde, Jahr, Tag oder Schicht je nach Lohnbasis. + Basis + Angebot + Break-even-Umsatz + Momentaufnahmen erstellen + Einer kalifornischen Betriebsstätte zugeordnet + Beides + Kein kalifornischer Beschäftigter + Arbeitet in Kalifornien + Kalifornien-Bezug + Beschäftigte in Kalifornien + Einsatz-Nr. + Abbrechen + Obergrenze + Kategorie + Verbrauchsmaterial + Ausgabe + Gemeinkosten + Personal + Ressource + Portal-Bescheinigungsreferenz + Prüfsumme + Stadt + Kundenzugeordnete Einkünfte + Kundenzugeordnete Stunden + Kundenzugeordnete Wochen + Kundenzuordnungsschlüssel + Code + Erhebungsquelle + Beschäftigungsakte + Wie dieser Eintrag erhoben wurde; Fremdwahrnehmung wird in jedem Meldelauf gekennzeichnet. + Fremdwahrnehmung + Andere verlässliche Aufzeichnung + Selbstauskunft + Import übernehmen + Vergütung + Vergütungsprofile für Beschäftigte, Rollen- und Organisationsstandards mit Lohn- und Arbeitgeberkostenkomponenten. + Vergütungsprofil + Vollständigkeit der Demografie + Komponente + Komponenten + Einen Korrekturlauf anlegen? Der exportierte Lauf bleibt wie gemeldet und wird als korrigiert markiert. + Diesen Datensatz löschen? + Diesen Lauf einfrieren? Ein eingefrorener Lauf ist unveränderlich; eine spätere Neuberechnung ersetzt ihn. + Import übernehmen? Jede Zeile erzeugt eine neue Datenversion. + Diesen Lauf stornieren? Seine Exportdateien werden gelöscht. + Verbrauchsmaterial + Verbrauch + Kontaktdaten + Kontext + Angebot + Einsatz + Entsendung + Dienstleister, die Labor-Contractor-Beschäftigte für die zweite CRD-Meldung stellen. + Datensatz korrigieren (neue Version) + Umgelegte Gemeinkosten + Arbeitgeber-Lohnsteuer + Fixe Personalkosten + Gesundheitsleistungen + Sonstiges + Sonstige Leistungen + Altersvorsorge / Pension + Unfallversicherung + Jährlich fix + Pro Tag + Pro Stunde + Pro Schicht + % des anrechenbaren Lohns + Kostenkomponenten + Lohnsteuern, Leistungen, Pension und umgelegte Gemeinkosten – Prozent des anrechenbaren Lohns, pro Stunde, pro Schicht oder jährlich fix, mit optionaler Obergrenze. + Kostenlauf + Kostenläufe + Interne Vollkosten und Marge für Angebote (Schätzung), Einsätze und Entsendungen (Ist). Umsatz ist immer nur ein beobachteter Wert. + Meldepflichtig – beide Meldungen + Meldepflichtig – Labor-Contractor-Employee-Meldung + Meldepflichtig – Payroll-Employee-Meldung + Vom Arbeitgeber nach Lektüre der CRD-Leitlinien erklärt; Resgrid entscheidet nie, ob Sie melden müssen. + Nicht meldepflichtig + Meldepflichtstatus + Nicht erklärt + Korrektur anlegen + Lauf anlegen + Erstellt + Währung + Aktuelle Angabe + Geschützte Lohndaten, interne Einsatzkostenrechnung und kalifornische Lohndatenmeldung. + Datum + Tage + Gearbeitete Tage + DBA + Keine Angabe + abgelehnt + Standard-Betriebsstätte + Standardprofile + Rollen- und Organisationsstandards bewerten Angebotsschätzungen und Mitglieder ohne Beschäftigtenprofil. + Löschen + Ethnie/Geschlecht + Demografischer Eintrag + Eintrag der Compliance-Verantwortlichen für eine Person ohne Selbstauskunft. + Nutzen Sie zuerst Beschäftigungs- oder andere verlässliche Aufzeichnungen; Fremdwahrnehmung ist das letzte Mittel und wird bei jeder Momentaufnahme gekennzeichnet. Ein Grund ist erforderlich und wird protokolliert. + Fehlende demografische Angaben + Die Beantwortung ist freiwillig. „Keine Angabe“ ist eine gültige Antwort und wird so gemeldet. + Organisation + Einsatztage + Entsendung + Entsendungs-ID + Abschreibung + Abschreibung / Einheit + Anzeigename + Distanz + Kilometerzählerstände oder eine Distanz eingeben; Kilometer werden als Meilen gespeichert. + Distanzeinheit + Downloads + Probelauf + Ergebnis des Probelaufs + Fälligkeit + Einkunftsquelle + Kundenzugeordnet + W-2 Box 1 (Ersatz) + W-2 Box 5 + Verwendete Einkünfte + EDD-Adresse + Zuordnung bearbeiten + Dienstleister bearbeiten + Beschäftigung bearbeiten + Betriebsstätte bearbeiten + Ressourcenprofil bearbeiten + Ablesung bearbeiten + Arbeitseintrag bearbeiten + Gültig ab + Anrechenbare Lohncodes + Beschäftigtenprofile + Beschäftigte + Arbeitgeber + Arbeitgeberkostenkomponenten + Arbeitgeberkosten + Die Arbeitgeberidentität, unter der die CRD-Meldung eingereicht wird, samt verbundener Unternehmen. + Noch kein Arbeitgeberprofil. + Arbeitgeberprofil + Abschnitt Arbeitgeber + Beschäftigung + Beschäftigungstyp + Vollzeit + Unregelmäßig + Teilzeit + Unbekannt + Beschäftigungen + Betriebsstundenzähler Ende + Kilometerzähler Ende + Ende + Motorstunden + Fehler + Betriebsstätte + Betriebsstätten + Jeder physische Standort, dem Beschäftigte zugeordnet sind; die CRD-Meldung erfolgt je Betriebsstätte. + Schätzung + Schätzung vs. Ist + geschätzt + Ausnahmen + Tatsächlich + bezahlter Urlaub + Tage × Durchschnittsstunden + Ersatzmethode für befreite Stunden + Keine + Befreiungsstatus + Befreit + Nicht befreit + Unbekannt + Erwartete Jahresnutzung + In Zuordnungseinheiten pro Jahr; verteilt jährlich fixe Komponenten und zeitbasierte Abschreibung. + Ausgaben + Läuft ab + Gültig bis + Kalifornische Lohndatenmeldungen exportieren + Einen validierten Lauf einfrieren und seine CSV-/XLSX-Dateien sowie das Portal-Arbeitsblatt herunterladen (ohne Zwischenspeicherung, protokolliert). + Externe ID + Externer Ressourcenschlüssel + Eine Ratenplan-Eintrags-ID hier ermöglicht Angebotsschätzungen die Bewertung von Fahrzeug- und Ausrüstungspositionen nach Klasse. + Externe Quelle + Externer Beschäftigtenschlüssel + Gespeicherte Daten sind unveränderlich: Speichern erzeugt eine neue Version, die die aktuelle ersetzt. + Ersatzprofil + FEIN + Einsatzkostenrechnung + Interne Vollkosten und Marge für Angebote, Einsätze und Entsendungen – nur aggregierte Kategorien. + Datei + E-Mail der Kontaktperson + Name der meldenden Kontaktperson + Telefon der Kontaktperson + Filtern + Kennzeichen + Format + Einfrieren + Einfrieren und exportieren + Eingefroren + Kraftstoff + Tatsächliche Kraftstoffkosten + Kraftstoffmenge + Kraftstoffeinheit + Hauptsitz + Adresse des Hauptsitzes + Jeder Betrag und Multiplikator ist ein geschütztes Feld. Schätzungen nutzen das Beschäftigtenprofil, dann den Rollen-, dann den Organisationsstandard; die vom Lohnsystem freigegebenen Kosten haben stets Vorrang. + Die Meldepflicht erklären Sie selbst; Resgrid bestimmt sie nie. Kennungen und Adressen sind geschützte Felder; ein unverändert belassener REDACTED-Wert behält den gespeicherten Wert. + Hispanisch oder Latino + Stundensatz + Stunden + Stunden / Tag + Stunden / Woche + Stunden / Jahr + Stundenart + Doppelzeit + Sonstiges + Überstunden + Bezahlter Urlaub + Regulär + Bereitschaft + Reise + Kennungstyp + Leerlaufstunden + Jahreslohndaten importieren (CSV) + Fügen Sie den Lohnexport in der kanonischen Spaltenreihenfolge ein. Ein Probelauf prüft und stimmt jede Zeile ab, bevor etwas übernommen wird; importierte Daten sind zunächst nicht genehmigt. + Importergebnis + Box 5 fehlt – Box 1 verwendet (Anmerkung erforderlich) + doppelte Zeile + Einkünfte fehlen + keine Beschäftigung deckt das Jahr ab + nichts zu importieren + meldbare Stunden fehlen oder null + Person nicht gefunden + Meldejahr ungültig + Enthalten + Inventaranlage + Teil eines integrierten Unternehmens + CRD-Tätigkeitskategorie + Eine der zehn CRD-Tätigkeitskategorien des geprüften Schemaprofils. + Tätigkeitsbezeichnung + Personaldienstleister + Personaldienstleister + Rechtlicher Name + Zeile + Positionen + Personalpositionen enthalten keinen Satz im Klartext; das bewertete Detail ist ein geschütztes Feld, das nur mit gültiger Freigabe erscheint. + Positionen sind mit „Vergütung anzeigen“ sichtbar; die Zusammenfassung oben ist die aggregierte Ansicht. + Haupttätigkeit + Kalifornische Lohndatenmeldung verwalten + Der CRD-Meldeassistent: Läufe, Beschäftigten-Momentaufnahmen, Überschreibungen, Aggregation, Validierung, Anmerkungen, Bescheinigungsbeobachtung, Korrekturen und die demografischen Einträge der Compliance-Verantwortlichen. Jedes Mitglied beantwortet seine eigene Angabe auch ohne diese Berechtigung. + Belegschaft und Vergütung verwalten + Arbeitgeberidentität, Betriebsstätten, Beschäftigte, Beschäftigungszeiträume, Tätigkeitszuordnungen, Vergütungsprofile und -komponenten, Arbeitseinträge und Jahreslohndaten-Importe. Werte benötigen weiterhin eine gültige Freigabe. + Herkunft der Zuordnung + Deckungsbeitrag + Bescheinigung erfassen + Nur das, was Sie nach der Bescheinigung im CRD-Portal beobachtet haben; Resgrid bescheinigt nie. + MARS-Autoritätsprofil + Cal OES MARS-Klassifikation + Mittlerer Stundensatz + Median-Stundensatz + Mitglied + Meilen + Fehlende Eingaben + Meine demografischen Angaben + Beantworten oder aktualisieren Sie Ihre freiwillige Selbstauskunft für die kalifornische Lohndatenmeldung. + NAICS + Name + Neuer Lauf + Nein + Keine Angabe + Keine Exportdateien – einen validierten Lauf einfrieren und exportieren. + Keine Tätigkeitszuordnungen. + Keine Personaldienstleister. + Noch keine Beschäftigungszeiträume. + Noch keine Betriebsstätten. + Keine Jahreslohndaten für dieses Jahr und diesen Meldetyp. + Keine Positionen. + Keine Vergütungsprofile. + Keine Ressourcenkostenprofile. + Noch keine aggregierten Zeilen – nach Abschluss der Momentaufnahmen aggregieren. + Noch keine Läufe. + Noch keine Momentaufnahmen – zuerst erstellen. + Keine Nutzungsablesungen. + Keine Arbeitseinträge in diesem Zeitraum. + Noch keine Beschäftigten. + Vor Ort + Keine + blockierende Fehler bestehen + Noch nicht validiert. + Fremdwahrnehmung + Offene Läufe + Betriebsstunden + Distanz (abgelesen) + Sonstiges + Gemeinkosten + Grund der Überschreibung + Name des Eigentümers + Bezahlte Urlaubsstunden + Lohn + Lohnband + Lohnbasis + Täglich + Stündlich + Gehalt + Pro Schicht + Aufwandsentschädigung + Zulage + Ausbildung + Rettungsdienst + Gefahrgut + Anreiz + Dienstalter + Sonstiges + Fachzulage + USAR + Jährlich fix + Pro Stunde + Pro Abrechnungszeitraum + Pro Schicht + % der Basis + Lohnkomponenten + Zulagen, Anreize und Fachzulagen zusätzlich zur Basis. Komponenten mit festem Zeitraum werden bei Überstunden nur gezahlt, wenn sie pro Überstunde markiert sind. + Resgrid bereitet vor und exportiert; es reicht nie ein, entscheidet nie über die Meldepflicht und bescheinigt nie. Alles auf diesen Seiten ist ein geschütztes Feld und wird ohne Zwischenspeicherung ausgeliefert. + Bereiten Sie die kalifornischen CRD-Meldungen für Payroll Employee und Labor Contractor Employee vor; eingereicht werden sie von Ihnen selbst im CRD-Portal. + Lohndatenmeldung + Meldelauf + Pro Überstunde + Personal + Personalrolle + Dient zur Auswahl eines Rollen-Standardvergütungsprofils, wenn kein Beschäftigtenprofil existiert. + Phase + Einsatz + Mobilisierung + Rückkehr + Bereitschaft + Straßenadresse + Portal-Arbeitsblatt + PLZ + Vollkosten eines regulären 8-Stunden-Tags + kein geprüftes Schemaprofil + Kennungen, Adressen, Lohnsätze, Einkünfte, demografische Angaben und Exportdateien sind Felder der erweiterten Datensicherung (Katalog 28). Ohne gültige Freigabe erscheinen sie als REDACTED und werden nie zwischengespeichert oder protokolliert. + Herkunft + Woher die Identität stammt (Vertrag, W-9, Portaleintrag). + Durchschnittsstunden pro Tag + gelöscht + Menge + Ethnische Zugehörigkeit + Wählen Sie alle zutreffenden Kategorien; zwei oder mehr werden als multiethnisch gemeldet. + Weiß + Schwarz oder Afroamerikanisch + Hawaiianisch oder pazifische Inselbevölkerung + Asiatisch + Indigen (American Indian / Alaska Native) + Nahöstlich oder Nordafrikanisch + Satz + Satzmultiplikatoren + JSON je Lohncode; fehlende Codes verwenden 1,5 (Überstunden), 2 (Doppelzeit) und 1. + Bereitschaft + bereit zum Einfrieren + Grund + Abgestimmt + Regulärer Stundenäquivalent + Optionale Überschreibung; sonst aus Grundbetrag und Standardstunden abgeleitet. + Beziehung + Ende der Beziehung + Beginn der Beziehung + Remote in CA + Remote außerhalb CA + Meldetyp + Labor Contractor Employee Report + Payroll Employee Report + Meldbare Stunden + Jährlich fix + Pro Tag + Pro Entsendung + Pro Motorstunde + Pro Leerlaufstunde + Pro Kilometer + Pro Meile + Pro Betriebsstunde + Verbrauchsmaterial + Abschreibung + Fixe Gemeinkosten + Kraftstoff / Energie + Versicherung / Zulassung + Leasing / Miete + Wartung + Sonstiges + Lagerung + Reifen / Verschleiß + Kraftstoff (Satz oder Verbrauch × Stückpreis), Wartung (manuell oder rollierender Auftrags-Ist mit Zählerfenster), Reifen, Versicherung, Leasing, Lagerung, fixe Gemeinkosten und Verbrauchsmaterial. + Ressourcenkosten + Abschreibung, Kraftstoff, Wartung und Fixkosten je Einheit, Anlage oder externer Ressourcenklasse. + Ressourcenprofile + Reine Zahlenwerte, geschützt durch „Interne Kosten anzeigen“ – hier wird keine Person bewertet. + Aus Anschaffung berechnet + Importiert + Manuell + Rollierender Auftrags-Ist + Ressourcennutzung + Ressourcen + Umsatz + Angebotssumme + Cal OES MARS genehmigt + Cal OES MARS erwartet + Cal OES MARS bezahlt + Kundenrechnungen + Keiner + Umsatzquelle + Prüfung + freigegebene Lohnkosten verwendet + Klassenprofil verwendet + Komponente nicht genehmigt + Währungsabweichung + Organisationsstandard verwendet + Abschreibungsangaben fehlen + automatische und manuelle Distanz weichen ab + Kraftstoffangaben fehlen + kein Vergütungsprofil + kein Beschäftigungszeitraum für das Mitglied + kein Satz für die Basis + kein Ressourcenprofil + Lohncode-Multiplikator fehlt (Standard verwendet) + Reparatur bereits direkt berechnet + Rollenstandard verwendet + rollierendes Wartungsfenster unzureichend + nicht genehmigtes Profil + Nutzungsablesung prüfen + Jahresnutzung fehlt + Zeilen + Angebotskosten schätzen + Angebotspositionen × Organisationsstandard-Vergütung und Klassen-Ressourcenprofile; Umsatz = Angebotssumme. + Einsatzkosten berechnen + Arbeitseinträge und Nutzungsablesungen zum Einsatz; kein Umsatz. + Entsendungskosten berechnen + Freigegebene Tageszeitberichte, Nutzungsablesungen und Ausgaben bis zum Datum. + Erläuternde Anmerkungen + Bis zu 500 Zeichen; erforderlich, wenn Box-1-Einkünfte verwendet wurden oder eine Ersatzmethode für Befreite gilt. + Entwurf + Eingefroren + Prüfung nötig + Ersetzt + Lauftyp + Ist + Schätzung + Meldeläufe + Restwert + Speichern + Die Änderung konnte nicht gespeichert werden. + Angabe speichern + Gespeichert. + Schemaprofil + Geltungsbereich + Organisationsstandard + Beschäftigte(r) + Rollenstandard + SEIN (EDD) + Freiwillige Selbstauskunft + Ihre Angaben werden getrennt von Ihrer Personalakte verschlüsselt gespeichert und nur zur Erstellung der aggregierten CRD-Meldung verwendet. Niemand in der Organisation sieht sie auf einer anderen Seite. + selbst angegeben + Geschlecht + Weiblich + Männlich + Nicht-binär + Größe + Übersprungen + Momentaufnahme + Beschäftigte in der Momentaufnahme + Ende der Momentaufnahme + Beginn der Momentaufnahme + Ein einzelner Abrechnungszeitraum zwischen + Beschäftigten-Momentaufnahmen + Eine Zeile je kalifornischem Beschäftigten im Zeitraum; demografischer Code, Einkünfte und Stundensatz sind geschützte Felder. Überschreibungen benötigen einen Grund und werden protokolliert. + SOC-Code + SOC-Version + Nummer beim CA Secretary of State + Quelle + Betriebsstundenzähler Start + Kilometerzähler Start + Beginn + Bundesstaat + Status + Extern bescheinigt + Korrigiert + Entwurf + Exportiert + Eingefroren + Validiert + Storniert + Gegenstand + Gegenstandstyp + Externe Klasse + Inventaranlage + Einheit + Zusammenfassung + Jahreslohndaten + Vergütung + Personaldienstleister + Kostenläufe + Übersicht + Arbeitgeber + Betriebsstätten + Lohndatenmeldung + Ressourcenkosten + Arbeitseinträge + Beschäftigte + Bis Datum + Zeitzone + Gesamt + Gesamte Vollkosten + nicht genehmigt + Einheit + Stückpreis + Ungelöste Ausnahmen + Aktualisiert + US-Beschäftigte + Nutzungseinträge + Kilometerzähler, Betriebsstundenzähler, Stunden und Kraftstoff je Einheit und Tag; Distanz wird in Meilen gespeichert, widersprüchliche Ablesungen kommen in die Prüfung. + Tageszeitbericht + GPS + Hardware-Tracker + Import + Manuell + Nutzungsdauer (Monate) + Nutzungsdauer (Einheiten) + Meilen, Stunden oder Tage über die Nutzungsdauer; linear = (Kosten − Restwert) ÷ Nutzungsdauer. + Validieren + Validiert am + Validierung + Jahreslohndaten fehlen + Jahreslohndaten nicht genehmigt + überlappende Tätigkeitszuordnungen + Identität des Personaldienstleisters fehlt + Meldepflichtstatus nicht erklärt + demografische Angabe fehlt oder abgelehnt + W-2 Box 1 statt Box 5 verwendet (Anmerkung erforderlich) + Einkünfte fehlen + Zeilen-Beschäftigtenzahlen stimmen nicht mit den Momentaufnahmen überein + Arbeitgeberidentität unvollständig (Name, FEIN, SEIN, EDD-Adresse) + Adresse der Betriebsstätte unvollständig + Betriebsstätte fehlt + NAICS der Betriebsstätte fehlt + Ersatzmethode für befreite Stunden verwendet (Anmerkung erforderlich) + Feld überschreitet die Vorlagenlänge + Export würde die Dateigrößenbeschränkung des Portals überschreiten + meldbare Stunden sind null + Tätigkeitskategorie fehlt + Tätigkeitskategorie nicht im Schemaprofil + manuelle Überschreibung angewendet + keine Beschäftigten in der Momentaufnahme + Fremdwahrnehmung für den demografischen Code verwendet + Schemaprofil hat sich seit Anlage des Laufs geändert + Remote-Zahlen stimmen nicht mit der Beschäftigtenzahl überein + erforderliche Vorlagenspalte ist leer + Zeilen noch nicht aggregiert + Momentaufnahme außerhalb des zulässigen Fensters + gearbeitete Wochen fehlen + Arbeitsmodus ungeklärt (keine Zuordnung) + Abweichung + Version + Interne Kosten anzeigen + Aggregierte Einsatzkosten und Margen für Angebote, Einsätze und Entsendungen – Kategorien und Summen, nie eine Position, ein Satz oder eine Person – sowie Ressourcenkostenprofile und Nutzungsablesungen. + Vergütung anzeigen + Vergütungsprofile, Arbeitseinträge, Jahreslohndaten und Kostenlauf-Positionen lesen. Werte benötigen weiterhin eine gültige Freigabe. + Stornieren + W-2 Box 1 + W-2 Box 5 + Warnungen + Im Vorjahr gemeldet + Gearbeitete Wochen + Warum wir fragen + Der California Government Code § 12999 verpflichtet meldepflichtige Arbeitgeber, Lohndaten nach Tätigkeitskategorie, Ethnie und Geschlecht gruppiert zu melden. Die Meldung enthält nur aggregierte Zahlen und Sätze; sie nennt niemanden. + 1. Beschäftigten-Momentaufnahmen erstellen · 2. Zeilen aggregieren · 3. Validieren · 4. Einfrieren und exportieren · dann im CRD-Portal bestätigen und die Bescheinigung hier erfassen. + Land + Arbeitseinträge + Stunden je Person und Tag aus Lohnsystem, Entsendungen und Einsätzen; die vom Lohnsystem freigegebenen Kosten ersetzen jede Schätzung. + Arbeitsort + Arbeitsmodus + Vor Ort + Remote außerhalb Kaliforniens (kalifornischer Betriebsstätte zugeordnet) + Remote innerhalb Kaliforniens + Bundesstaat / Provinz + Beschäftigte(r) + Beschäftigungszeiträume überschneiden sich nie; jeder Zeitraum trägt seine Tätigkeitszuordnungen im Zeitverlauf. + Beschäftigungsart + Selbstständige(r) + Labor-Contractor-Beschäftigte(r) + Payroll-Beschäftigte(r) + Ehrenamtliche(r) + Beschäftigte + Jede Person, die die Organisation bezahlt oder meldet – Mitglieder und externe Beschäftigte – mit ihren Beschäftigungszeiträumen. + Belegschaft + Die Arbeitgeberwerte, die Sie neben der hochgeladenen Datei in das CRD-Portal eingeben. + Ohne Zwischenspeicherung ausgeliefert und protokolliert; Tab nach Gebrauch schließen. Resgrid meldet sich nie am Portal an. + Jahr + Ja + Die Exportdatei wurde nicht gefunden. + Die Exportdatei ist abgelaufen oder wurde gelöscht. + Die Portal-Bescheinigungsreferenz ist erforderlich. + Die Erhebungsquelle ist ungültig. + Ein Korrekturlauf existiert bereits. + Die Angabe „Hispanisch oder Latino“ ist ungültig. + Erstellen Sie zuerst die Beschäftigten-Momentaufnahmen. + Kein geprüftes CRD-Schemaprofil deckt dieses Meldejahr ab. + Ein Ethnie-Code ist ungültig. + Geben Sie die Ethnie an oder wählen Sie „Keine Angabe“. + Ein Grund ist erforderlich. + Anmerkungen sind auf 500 Zeichen begrenzt. + Ein bescheinigter Lauf kann nicht storniert werden; legen Sie eine Korrektur an. + Der Lauf ist eingefroren; legen Sie stattdessen eine Korrektur an. + Nur ein exportierter Lauf kann als bescheinigt markiert werden. + Der Meldelauf wurde nicht gefunden. + Nur ein exportierter oder bescheinigter Lauf kann korrigiert werden. + Der Geschlechtscode ist ungültig. + Geben Sie das Geschlecht an oder wählen Sie „Keine Angabe“. + Die Beschäftigten-Momentaufnahme wurde nicht gefunden. + Der Momentaufnahmezeitraum muss ein Abrechnungszeitraum innerhalb des zulässigen Fensters sein. + Die Validierung meldet weiterhin blockierende Fehler. + Der Meldeprozess konnte die geschützten Werte nicht entschlüsseln. + Der Name des verbundenen Unternehmens ist erforderlich. + Die Zuordnungsbasis ist ungültig. + Ein Betrag ist negativ. + Eine Inventaranlage ist erforderlich. + Die Zuordnung überschneidet sich mit einer anderen Zuordnung dieser Beschäftigung. + Das Angebot wurde nicht gefunden. + Eine Komponentenkategorie oder -basis ist ungültig. + Der rechtliche Name des Dienstleisters ist erforderlich. + Ein Labor-Contractor-Beschäftigter benötigt einen Personaldienstleister. + Der Meldepflichtstatus ist ungültig. + Das Enddatum liegt vor dem Startdatum. + Die Entsendung wurde nicht gefunden. + Der rechtliche Name des Arbeitgebers ist erforderlich. + Die Beschäftigung wurde nicht gefunden. + Der Zeitraum überschneidet sich mit einer anderen Beschäftigung dieser Person. + Eine andere Betriebsstätte verwendet diesen Code bereits. + Eine Betriebsstätte benötigt Code und Name. + Die Betriebsstätte wurde nicht gefunden. + Ein externer Ressourcenschlüssel ist erforderlich. + Stunden müssen zwischen 0 und 24 liegen. + Die Stundenart ist ungültig. + Die Tätigkeitskategorie ist nicht im Schemaprofil. + NAICS muss sechs Ziffern haben. + Nichts zu berechnen: keine Arbeitseinträge oder Nutzungsablesungen. + Der Datensatz wurde nicht gefunden. + Die Lohnbasis ist ungültig. + Der Zeitraum überschneidet sich mit einem anderen Profil desselben Geltungsbereichs. + Der geschützte Schreibvorgang wurde abgelehnt. + Der Meldetyp ist ungültig. + Ein Rollenstandard benötigt eine Personalrolle. + Der Lauf ist eingefroren und kann nicht geändert werden. + Der Geltungsbereich des Profils ist ungültig. + Der Gegenstandstyp ist ungültig. + Die Einheit wurde nicht gefunden. + Eine Einheit ist erforderlich. + Eine Nutzungsablesung benötigt eine Entsendung oder einen Einsatz. + Die Nutzungsablesung ist ungültig. + Der Arbeitsmodus ist ungültig. + Dieses Mitglied hat bereits einen Beschäftigtendatensatz. + Eine Person benötigt ein Mitglied oder einen externen Schlüssel. + Die Beschäftigungsart ist ungültig. + Die Person wurde nicht gefunden. + Eine Person ist erforderlich. + Das Meldejahr ist ungültig. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx new file mode 100644 index 00000000..4949390c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Κόστος απόκτησης + Ημερομηνία απόκτησης + Ενεργό + Ενεργό από + Ενεργό έως + Πραγματικό + Πραγματικές ώρες εργασίας + Προσθήκη συνδεδεμένης οντότητας + Προσθήκη ανάθεσης + Προσθήκη εργολάβου + Προσθήκη προεπιλογής τμήματος + Προσθήκη απασχόλησης + Προσθήκη εγκατάστασης + Προσθήκη στοιχείου + Προσθήκη προφίλ + Προσθήκη προφίλ πόρου + Προσθήκη προεπιλογής ρόλου + Προσθήκη ένδειξης + Προσθήκη καταχώρισης + Προσθήκη εργαζομένου + Ή προσθέστε εξωτερικό εργαζόμενο με κλειδί μισθοδοσίας και ετικέτα (και τα δύο προστατευμένα). + Επιλέξτε μέλος για να δημιουργήσετε την εγγραφή εργαζομένου. + Διεύθυνση + Συνδεδεμένη οντότητα + Συνδεδεμένες οντότητες + Μόνο για ενοποιημένη επιχείρηση: οι λοιπές οντότητες που καλύπτει η αναφορά. + Συγκέντρωση γραμμών + Βάση κατανομής + Ανά ημέρα + Ανά ώρα κινητήρα + Ανά χιλιόμετρο + Ανά μίλι + Ανά ώρα λειτουργίας + Ποσό + Ετήσια στοιχεία αμοιβών + Εισοδήματα W-2, ώρες και εβδομάδες ανά απασχόληση για το έτος αναφοράς — η είσοδος κάθε στιγμιότυπου CRD. + Ελλείποντα ετήσια στοιχεία αμοιβών + Εφαρμογή παράκαμψης + Έγκριση + Τα μη εγκεκριμένα προφίλ εξακολουθούν να τιμολογούν εκτιμήσεις αλλά σημειώνουν κάθε γραμμή για έλεγχο. Κάθε αλλαγή αμοιβής αφαιρεί την έγκριση. + Έγκριση προφίλ + Εγκεκριμένο + Εγκεκριμένο κόστος μισθοδοσίας + Το πραγματικό από το σύστημα μισθοδοσίας· όταν υπάρχει αντικαθιστά την εκτίμηση της ημέρας. + Αρχεία εξαγωγής + CSV και XLSX με τη σειρά στηλών του προτύπου CRD, με σταθερό άθροισμα ελέγχου για αμετάβλητη είσοδο, με δυνατότητα λήψης χωρίς αποθήκευση έως τη διαγραφή μετά το διάστημα διατήρησης. + Πίσω + Βασικό ποσό + Ανά ώρα, έτος, ημέρα ή βάρδια ανάλογα με τη βάση αμοιβής. + Βάση + Προσφορά + Έσοδα νεκρού σημείου + Δημιουργία στιγμιότυπων + Τοποθετημένος σε εγκατάσταση Καλιφόρνιας + Και τα δύο + Όχι εργαζόμενος Καλιφόρνιας + Εργάζεται στην Καλιφόρνια + Βάση Καλιφόρνιας + Εργαζόμενοι στην Καλιφόρνια + Αρ. κλήσης + Ακύρωση + Όριο + Κατηγορία + Αναλώσιμο + Έξοδο + Γενικά έξοδα + Προσωπικό + Πόρος + Αναφορά πιστοποίησης πύλης + Άθροισμα ελέγχου + Πόλη + Εισοδήματα κατανεμημένα σε πελάτη + Ώρες κατανεμημένες σε πελάτη + Εβδομάδες κατανεμημένες σε πελάτη + Κλειδί κατανομής πελάτη + Κωδικός + Πηγή συλλογής + Αρχείο απασχόλησης + Πώς αποκτήθηκε αυτή η εγγραφή· η αντίληψη παρατηρητή σημειώνεται σε κάθε εκτέλεση. + Αντίληψη παρατηρητή + Άλλο αξιόπιστο αρχείο + Αυτοπροσδιορισμός + Επιβεβαίωση εισαγωγής + Αμοιβές + Προφίλ αμοιβών εργαζομένου, ρόλου και τμήματος με στοιχεία αμοιβής και εργοδοτικού κόστους. + Προφίλ αμοιβής + Πληρότητα δημογραφικών + Στοιχείο + Στοιχεία + Δημιουργία εκτέλεσης διόρθωσης; Η εξαγόμενη εκτέλεση παραμένει όπως κατατέθηκε και σημειώνεται ως διορθωμένη. + Διαγραφή αυτής της εγγραφής; + Πάγωμα αυτής της εκτέλεσης; Μια παγωμένη εκτέλεση είναι αμετάβλητη· ένας μεταγενέστερος επανυπολογισμός την αντικαθιστά. + Επιβεβαίωση εισαγωγής; Κάθε γραμμή δημιουργεί νέα έκδοση στοιχείου. + Ακύρωση αυτής της εκτέλεσης; Τα αρχεία εξαγωγής της διαγράφονται. + Αναλώσιμα + Κατανάλωση + Στοιχεία επικοινωνίας + Πλαίσιο + Προσφορά + Κλήση + Αποστολή + Εργολάβοι που παρέχουν εργαζόμενους που καλύπτονται από τη δεύτερη αναφορά CRD. + Διόρθωση στοιχείου (νέα έκδοση) + Κατανεμημένα γενικά έξοδα + Εργοδοτικές εισφορές + Σταθερό κόστος εργασίας + Παροχές υγείας + Άλλο + Λοιπές παροχές + Συνταξιοδότηση + Αποζημίωση εργαζομένων + Σταθερό ετήσιο + Ανά ημέρα + Ανά ώρα + Ανά βάρδια + % επιλέξιμης αμοιβής + Στοιχεία κόστους + Φόροι μισθοδοσίας, παροχές, σύνταξη και κατανεμημένα γενικά έξοδα — ποσοστό επιλέξιμης αμοιβής, ανά ώρα, ανά βάρδια ή σταθερό ετήσιο, με προαιρετικό όριο. + Υπολογισμός κόστους + Υπολογισμοί κόστους + Εσωτερικό πλήρες κόστος και περιθώριο για προσφορές (εκτίμηση), κλήσεις και αποστολές (πραγματικό). Τα έσοδα είναι πάντα μόνο παρατηρούμενος αριθμός. + Καλύπτεται — και οι δύο αναφορές + Καλύπτεται — αναφορά Labor Contractor Employee + Καλύπτεται — αναφορά Payroll Employee + Δηλώνεται από τον εργοδότη αφού διαβάσει τις οδηγίες CRD· το Resgrid δεν αποφασίζει ποτέ αν πρέπει να καταθέσετε. + Δεν καλύπτεται + Κατάσταση κάλυψης + Μη δηλωμένο + Δημιουργία διόρθωσης + Δημιουργία εκτέλεσης + Δημιουργήθηκε + Νόμισμα + Τρέχουσα απάντηση + Προστατευμένα δεδομένα αμοιβών, εσωτερικός υπολογισμός κόστους πεδίου και αναφορά δεδομένων αμοιβών Καλιφόρνιας. + Ημερομηνία + Ημέρες + Ημέρες εργασίας + DBA + Δεν επιθυμώ να δηλώσω + αρνήθηκαν + Προεπιλεγμένη εγκατάσταση + Προεπιλεγμένα προφίλ + Οι προεπιλογές ρόλου και τμήματος τιμολογούν εκτιμήσεις προσφορών και μέλη χωρίς προφίλ εργαζομένου. + Διαγραφή + Φυλή/εθνότητα/φύλο + Δημογραφική εγγραφή + Εγγραφή υπευθύνου συμμόρφωσης για εργαζόμενο που δεν αυτοπροσδιορίστηκε. + Χρησιμοποιήστε πρώτα αρχεία απασχόλησης ή άλλα αξιόπιστα· η αντίληψη παρατηρητή είναι έσχατη λύση και σημειώνεται σε κάθε στιγμιότυπο. Απαιτείται αιτία που ελέγχεται. + Ελλείπουσες δημογραφικές απαντήσεις + Η απάντηση είναι εθελοντική. Η άρνηση δήλωσης είναι έγκυρη απάντηση και δηλώνεται ως τέτοια. + Τμήμα + Ημέρες αποστολής + Αποστολή + Αναγν. αποστολής + Απόσβεση + Απόσβεση / μονάδα + Ετικέτα εμφάνισης + Απόσταση + Εισαγάγετε ενδείξεις οδομέτρου ή απόσταση· τα χιλιόμετρα αποθηκεύονται ως μίλια. + Μονάδα απόστασης + Λήψεις + Δοκιμαστική εκτέλεση + Αποτέλεσμα δοκιμής + Προθεσμία + Πηγή εισοδήματος + Κατανεμημένο σε πελάτη + W-2 πλαίσιο 1 (εφεδρικό) + W-2 πλαίσιο 5 + Χρησιμοποιούμενα εισοδήματα + Διεύθυνση EDD + Επεξεργασία ανάθεσης + Επεξεργασία εργολάβου + Επεξεργασία απασχόλησης + Επεξεργασία εγκατάστασης + Επεξεργασία προφίλ πόρου + Επεξεργασία ένδειξης + Επεξεργασία καταχώρισης + Ισχύει από + Επιλέξιμοι κωδικοί αμοιβής + Προφίλ εργαζομένου + Εργαζόμενοι + Εργοδότης + Στοιχεία εργοδοτικού κόστους + εργοδοτικό κόστος + Η ταυτότητα εργοδότη με την οποία κατατίθεται η αναφορά CRD, με τις συνδεδεμένες οντότητες. + Δεν υπάρχει ακόμη προφίλ εργοδότη. + Προφίλ εργοδότη + Ενότητα εργοδότη + Απασχόληση + Τύπος απασχόλησης + Πλήρης απασχόληση + Διαλείπουσα + Μερική απασχόληση + Άγνωστο + Απασχολήσεις + Ωρόμετρο λήξης + Οδόμετρο λήξης + Λήξη + Ώρες κινητήρα + Σφάλματα + Εγκατάσταση + Εγκαταστάσεις + Κάθε φυσική τοποθεσία στην οποία τοποθετούνται εργαζόμενοι· η αναφορά CRD κατατίθεται ανά εγκατάσταση. + Εκτίμηση + Εκτίμηση έναντι πραγματικού + εκτιμώμενο + Εξαιρέσεις + Πραγματικές + άδεια μετ' αποδοχών + Ημέρες × μέσες ώρες + Μέθοδος αντικατάστασης εξαιρουμένων + Καμία + Εξαίρεση + Εξαιρούμενος + Μη εξαιρούμενος + Άγνωστο + Αναμενόμενη ετήσια χρήση + Σε μονάδες κατανομής ανά έτος· κατανέμει σταθερά ετήσια στοιχεία και χρονική απόσβεση. + Έξοδα + Λήγει + Λήγει + Εξαγωγή αναφορών δεδομένων αμοιβών Καλιφόρνιας + Πάγωμα επικυρωμένης εκτέλεσης και λήψη των αρχείων CSV / XLSX και του φύλλου εργασίας πύλης (χωρίς αποθήκευση, ελεγμένο). + Εξωτερικό αναγν. + Κλειδί εξωτερικού πόρου + Ένα αναγν. εγγραφής τιμοκαταλόγου εδώ επιτρέπει στις εκτιμήσεις προσφορών να τιμολογούν γραμμές οχημάτων και εξοπλισμού ανά κατηγορία. + Εξωτερική πηγή + Κλειδί εξωτερικού εργαζομένου + Τα αποθηκευμένα στοιχεία είναι αμετάβλητα: η αποθήκευση δημιουργεί νέα έκδοση που αντικαθιστά την τρέχουσα. + εφεδρικό προφίλ + FEIN + Κόστος πεδίου + Εσωτερικό πλήρες κόστος και περιθώριο για προσφορές, κλήσεις και αποστολές — μόνο συγκεντρωτικές κατηγορίες. + Αρχείο + E-mail υπευθύνου + Όνομα υπευθύνου κατάθεσης + Τηλέφωνο υπευθύνου + Φίλτρο + Σημάνσεις + Μορφή + Πάγωμα + Πάγωμα και εξαγωγή + Παγωμένο + Καύσιμα + Πραγματικό κόστος καυσίμου + Ποσότητα καυσίμου + Μονάδα καυσίμου + Έδρα + Διεύθυνση έδρας + Κάθε ποσό και πολλαπλασιαστής είναι προστατευμένο πεδίο. Οι εκτιμήσεις χρησιμοποιούν το προφίλ εργαζομένου, μετά την προεπιλογή ρόλου, μετά του τμήματος· το εγκεκριμένο κόστος μισθοδοσίας υπερισχύει πάντα. + Η κάλυψη δηλώνεται από εσάς, ποτέ από το Resgrid. Αναγνωριστικά και διευθύνσεις είναι προστατευμένα πεδία· μια τιμή REDACTED που μένει αμετάβλητη διατηρεί ό,τι είναι αποθηκευμένο. + Ισπανόφωνος ή Λατίνος + Ωριαία αμοιβή + Ώρες + Ώρες / ημέρα + Ώρες / εβδομάδα + Ώρες / έτος + Τύπος ωρών + Διπλός χρόνος + Άλλο + Υπερωρίες + Άδεια μετ' αποδοχών + Κανονικές + Επιφυλακή + Μετακίνηση + Τύπος αναγνωριστικού + Ώρες αδράνειας + Εισαγωγή ετήσιων στοιχείων (CSV) + Επικολλήστε την εξαγωγή μισθοδοσίας με την κανονική σειρά στηλών. Μια δοκιμαστική εκτέλεση επικυρώνει και συμφωνεί κάθε γραμμή πριν από οποιαδήποτε δέσμευση· τα εισαγόμενα στοιχεία έρχονται μη εγκεκριμένα. + Αποτέλεσμα εισαγωγής + Απουσία πλαισίου 5 — χρήση πλαισίου 1 (απαιτείται παρατήρηση) + διπλή γραμμή + λείπουν εισοδήματα + καμία απασχόληση δεν καλύπτει το έτος + τίποτα προς εισαγωγή + δηλωτέες ώρες λείπουν ή μηδέν + ο εργαζόμενος δεν βρέθηκε + μη έγκυρο έτος + Περιλαμβάνεται + Πάγιο αποθέματος + Μέρος ενοποιημένης επιχείρησης + Κατηγορία θέσης CRD + Μία από τις δέκα κατηγορίες θέσεων CRD του ελεγμένου προφίλ σχήματος. + Τίτλος θέσης + Εργολάβος εργασίας + Εργολάβοι εργασίας + Νομική επωνυμία + Γραμμή + Γραμμές + Οι γραμμές προσωπικού δεν φέρουν αμοιβή σε καθαρή μορφή· η τιμολογημένη λεπτομέρεια είναι προστατευμένο πεδίο που εμφανίζεται μόνο με έγκυρη άδεια. + Οι γραμμές είναι ορατές με Προβολή αμοιβών· η σύνοψη πάνω είναι η συγκεντρωτική προβολή. + Κύρια δραστηριότητα + Διαχείριση αναφοράς δεδομένων αμοιβών Καλιφόρνιας + Ο οδηγός αναφοράς CRD: εκτελέσεις, στιγμιότυπα, παρακάμψεις, συγκέντρωση, επικύρωση, παρατηρήσεις, καταγραφή πιστοποίησης, διορθώσεις και δημογραφικές εγγραφές του υπευθύνου συμμόρφωσης. Κάθε μέλος απαντά πάντα για τον εαυτό του χωρίς αυτό. + Διαχείριση προσωπικού και αμοιβών + Ταυτότητα εργοδότη, εγκαταστάσεις, εργαζόμενοι, περίοδοι απασχόλησης, αναθέσεις θέσεων, προφίλ και στοιχεία αμοιβών, καταχωρίσεις εργασίας και εισαγωγές ετήσιων στοιχείων. Οι τιμές εξακολουθούν να απαιτούν έγκυρη άδεια. + Προέλευση αντιστοίχισης + Περιθώριο συνεισφοράς + Καταγραφή πιστοποίησης + Μόνο ό,τι παρατηρήσατε στην πύλη CRD αφού πιστοποιήσατε εκεί· το Resgrid δεν πιστοποιεί ποτέ. + Προφίλ αρχής MARS + Ταξινόμηση Cal OES MARS + Μέση ωριαία αμοιβή + Διάμεση ωριαία αμοιβή + Μέλος + Μίλια + Ελλείπουσες είσοδοι + Η δημογραφική μου απάντηση + Απαντήστε ή ενημερώστε τον εθελοντικό αυτοπροσδιορισμό σας για την αναφορά δεδομένων αμοιβών Καλιφόρνιας. + NAICS + Όνομα + Νέα εκτέλεση + Όχι + Καμία απάντηση + Δεν υπάρχουν αρχεία εξαγωγής — παγώστε και εξαγάγετε μια επικυρωμένη εκτέλεση. + Δεν υπάρχουν αναθέσεις θέσεων. + Δεν υπάρχουν εργολάβοι εργασίας. + Δεν υπάρχουν περίοδοι απασχόλησης ακόμη. + Δεν υπάρχουν εγκαταστάσεις ακόμη. + Δεν υπάρχουν ετήσια στοιχεία για αυτό το έτος και τύπο αναφοράς. + Δεν υπάρχουν γραμμές. + Δεν υπάρχουν προφίλ αμοιβών. + Δεν υπάρχουν προφίλ κόστους πόρων. + Δεν υπάρχουν συγκεντρωτικές γραμμές ακόμη — συγκεντρώστε αφού ολοκληρωθούν τα στιγμιότυπα. + Δεν υπάρχουν εκτελέσεις ακόμη. + Δεν υπάρχουν στιγμιότυπα ακόμη — δημιουργήστε τα πρώτα. + Δεν υπάρχουν ενδείξεις χρήσης. + Δεν υπάρχουν καταχωρίσεις σε αυτό το διάστημα. + Δεν υπάρχουν εργαζόμενοι ακόμη. + Μη εξ αποστάσεως + Κανένα + παραμένουν σφάλματα φραγής + Δεν έχει επικυρωθεί ακόμη. + αντίληψη παρατηρητή + Ανοικτές εκτελέσεις + Ώρες λειτουργίας + Απόσταση (όπως αναγνώστηκε) + Άλλο + Γενικά έξοδα + Αιτία παράκαμψης + Όνομα ιδιοκτήτη + Ώρες άδειας μετ' αποδοχών + αμοιβή + Μισθολογική ζώνη + Βάση αμοιβής + Ημερήσια + Ωριαία + Μισθός + Ανά βάρδια + Επίδομα + Διαφορικό + Εκπαίδευση + EMS + HazMat + Κίνητρο + Προϋπηρεσία + Άλλο + Ειδικότητα + USAR + Σταθερό ετήσιο + Ανά ώρα + Ανά περίοδο μισθοδοσίας + Ανά βάρδια + % της βάσης + Στοιχεία αμοιβής + Διαφορικά, κίνητρα και ειδικές αμοιβές πέραν της βάσης. Τα στοιχεία σταθερής περιόδου δεν πληρώνονται σε υπερωρίες εκτός αν σημειωθούν ανά ώρα υπερωρίας. + Το Resgrid προετοιμάζει και εξάγει· δεν καταθέτει ποτέ, δεν αποφασίζει ποτέ αν καλύπτεστε και δεν πιστοποιεί ποτέ. Όλα σε αυτές τις οθόνες είναι προστατευμένα πεδία χωρίς αποθήκευση. + Προετοιμάστε τις αναφορές CRD Καλιφόρνιας Payroll Employee και Labor Contractor Employee· τις καταθέτετε εσείς στην πύλη CRD. + Αναφορά δεδομένων αμοιβών + Εκτέλεση αναφοράς + Ανά ώρα υπερωρίας + Προσωπικό + Ρόλος προσωπικού + Χρησιμοποιείται για την επιλογή προεπιλεγμένου προφίλ αμοιβής ρόλου όταν ο εργαζόμενος δεν έχει. + Φάση + Συμβάν + Κινητοποίηση + Επιστροφή + Αναμονή + Διεύθυνση + Φύλλο εργασίας πύλης + Ταχ. κώδικας + Πλήρες κόστος κανονικής 8ωρης ημέρας + κανένα ελεγμένο προφίλ σχήματος + Αναγνωριστικά, διευθύνσεις, αμοιβές, εισοδήματα, δημογραφικές απαντήσεις και αρχεία εξαγωγής είναι πεδία προηγμένης προστασίας δεδομένων (κατάλογος 28). Εμφανίζονται ως REDACTED χωρίς έγκυρη άδεια και δεν αποθηκεύονται ποτέ στην κρυφή μνήμη ούτε καταγράφονται. + Προέλευση + Από πού προήλθε η ταυτότητα (σύμβαση, W-9, εγγραφή πύλης). + Μέσες ώρες ανά ημέρα + διαγράφηκε + Ποσότητα + Φυλή / εθνότητα + Επιλέξτε κάθε κατηγορία που ισχύει· δύο ή περισσότερες δηλώνονται ως πολυφυλετικές. + Λευκός + Μαύρος ή Αφροαμερικανός + Ιθαγενής Χαβάης ή άλλων νήσων Ειρηνικού + Ασιάτης + Ιθαγενής Αμερικής ή Αλάσκας + Μέσης Ανατολής ή Βόρειας Αφρικής + Τιμή + Πολλαπλασιαστές αμοιβής + JSON ανά κωδικό αμοιβής· οι απόντες κωδικοί χρησιμοποιούν 1,5 (υπερωρία), 2 (διπλή) και 1. + Ετοιμότητα + έτοιμο για πάγωμα + Αιτία + Συμφωνημένο + Ισοδύναμη ωριαία αμοιβή + Προαιρετική παράκαμψη· αλλιώς προκύπτει από το βασικό ποσό και τις τυπικές ώρες. + Σχέση + Λήξη σχέσης + Έναρξη σχέσης + Εξ αποστάσεως στην CA + Εξ αποστάσεως εκτός CA + Τύπος αναφοράς + Αναφορά Labor Contractor Employee + Αναφορά Payroll Employee + Δηλωτέες ώρες + Σταθερό ετήσιο + Ανά ημέρα + Ανά αποστολή + Ανά ώρα κινητήρα + Ανά ώρα αδράνειας + Ανά χιλιόμετρο + Ανά μίλι + Ανά ώρα λειτουργίας + Αναλώσιμα + Απόσβεση + Σταθερά γενικά έξοδα + Καύσιμα / ενέργεια + Ασφάλιση / άδειες + Μίσθωση / ενοικίαση + Συντήρηση + Άλλο + Αποθήκευση + Ελαστικά / φθορά + Καύσιμα (τιμή ή κατανάλωση × τιμή μονάδας), συντήρηση (χειροκίνητη ή κυλιόμενο πραγματικό εντολών εργασίας με παράθυρο μετρητή), ελαστικά, ασφάλιση, μίσθωση, αποθήκευση, σταθερά γενικά και αναλώσιμα. + Κόστη πόρων + Απόσβεση, καύσιμα, συντήρηση και σταθερά κόστη ανά μονάδα, πάγιο ή κατηγορία εξωτερικού πόρου. + Προφίλ πόρων + Απλά αριθμητικά δεδομένα με φραγή Προβολή εσωτερικών κοστών — κανένα πρόσωπο δεν τιμολογείται εδώ. + Υπολογισμένο από την απόκτηση + Εισαγόμενο + Χειροκίνητο + Κυλιόμενο πραγματικό εντολών εργασίας + Χρήση πόρων + Πόροι + Έσοδα + Εκτιμώμενο σύνολο προσφοράς + Cal OES MARS εγκεκριμένο + Cal OES MARS αναμενόμενο + Cal OES MARS πληρωμένο + Τιμολόγια πελάτη + Κανένα + Πηγή εσόδων + Έλεγχος + χρήση εγκεκριμένου κόστους μισθοδοσίας + χρήση προφίλ κατηγορίας + στοιχείο μη εγκεκριμένο + αναντιστοιχία νομίσματος + χρήση προεπιλογής τμήματος + λείπουν είσοδοι απόσβεσης + αυτόματη και χειροκίνητη απόσταση διαφωνούν + λείπουν είσοδοι καυσίμου + κανένα προφίλ αμοιβής + καμία περίοδος απασχόλησης για το μέλος + καμία τιμή για τη βάση + κανένα προφίλ πόρου + λείπει πολλαπλασιαστής κωδικού αμοιβής (χρήση προεπιλογής) + η επισκευή έχει ήδη χρεωθεί άμεσα + χρήση προεπιλογής ρόλου + ανεπαρκές κυλιόμενο παράθυρο συντήρησης + μη εγκεκριμένο προφίλ + η ένδειξη χρήσης χρειάζεται έλεγχο + λείπει ετήσια χρήση + Γραμμές + Εκτίμηση κόστους προσφοράς + Γραμμές προσφοράς × προεπιλεγμένη αμοιβή τμήματος και προφίλ πόρων κατηγορίας· έσοδα = εκτιμώμενο σύνολο προσφοράς. + Υπολογισμός κόστους κλήσης + Καταχωρίσεις εργασίας και ενδείξεις χρήσης της κλήσης· χωρίς έσοδα. + Υπολογισμός κόστους αποστολής + Εγκεκριμένες ημερήσιες αναφορές χρόνου, ενδείξεις χρήσης και έξοδα έως την ημερομηνία. + Διευκρινιστικές παρατηρήσεις + Έως 500 χαρακτήρες· απαιτούνται όταν χρησιμοποιήθηκαν εισοδήματα πλαισίου 1 ή ισχύει μέθοδος εξαιρουμένων. + Πρόχειρο + Παγωμένο + Χρειάζεται έλεγχο + Αντικατεστημένο + Τύπος εκτέλεσης + Πραγματικό + Εκτίμηση + Εκτελέσεις αναφοράς + Υπολειμματική αξία + Αποθήκευση + Η αλλαγή δεν ήταν δυνατό να αποθηκευτεί. + Αποθήκευση απάντησης + Αποθηκεύτηκε. + Προφίλ σχήματος + Εύρος + Προεπιλογή τμήματος + Εργαζόμενος + Προεπιλογή ρόλου + SEIN (EDD) + Εθελοντικός αυτοπροσδιορισμός + Οι απαντήσεις σας αποθηκεύονται χωριστά από τον φάκελό σας, κρυπτογραφημένες, και χρησιμοποιούνται μόνο για τη συγκεντρωτική αναφορά CRD. Κανείς στο τμήμα δεν τις βλέπει σε άλλη οθόνη. + αυτοπροσδιορισμένοι + Φύλο + Γυναίκα + Άνδρας + Μη δυαδικό + Μέγεθος + Παραλείφθηκε + Στιγμιότυπο + Εργαζόμενοι στο στιγμιότυπο + Λήξη στιγμιότυπου + Έναρξη στιγμιότυπου + Μία περίοδος μισθοδοσίας μεταξύ + Στιγμιότυπα εργαζομένων + Μία γραμμή ανά εργαζόμενο Καλιφόρνιας στην περίοδο· δημογραφικός κωδικός, εισοδήματα και ωριαία αμοιβή είναι προστατευμένα πεδία. Οι παρακάμψεις απαιτούν αιτία και ελέγχονται. + Κωδικός SOC + Έκδοση SOC + Αριθμός CA Secretary of State + Πηγή + Ωρόμετρο έναρξης + Οδόμετρο έναρξης + Έναρξη + Πολιτεία + Κατάσταση + Πιστοποιημένο εξωτερικά + Διορθωμένο + Πρόχειρο + Εξαχθέν + Παγωμένο + Επικυρωμένο + Άκυρο + Αντικείμενο + Τύπος αντικειμένου + Εξωτερική κατηγορία + Πάγιο αποθέματος + Μονάδα + Σύνοψη + Ετήσια στοιχεία αμοιβών + Αμοιβές + Εργολάβοι εργασίας + Υπολογισμοί κόστους + Επισκόπηση + Εργοδότης + Εγκαταστάσεις + Αναφορά δεδομένων αμοιβών + Κόστη πόρων + Καταχωρίσεις εργασίας + Εργαζόμενοι + Έως ημερομηνία + Ζώνη ώρας + Σύνολο + Συνολικό πλήρες κόστος + μη εγκεκριμένο + Μονάδα + Τιμή μονάδας + Ανεπίλυτες εξαιρέσεις + Ενημερώθηκε + Εργαζόμενοι στις ΗΠΑ + Καταχωρίσεις χρήσης + Ενδείξεις οδομέτρου, ωρομέτρου, ωρών και καυσίμου ανά μονάδα και ημέρα· η απόσταση αποθηκεύεται σε μίλια και οι αντικρουόμενες ενδείξεις μπαίνουν σε έλεγχο. + Ημερήσια αναφορά χρόνου + GPS + Ιχνηλάτης υλικού + Εισαγωγή + Χειροκίνητο + Ωφέλιμη ζωή (μήνες) + Ωφέλιμη ζωή (μονάδες) + Μίλια, ώρες ή ημέρες κατά τη ζωή· σταθερή = (κόστος − υπόλειμμα) ÷ ζωή. + Επικύρωση + Επικυρώθηκε στις + Επικύρωση + λείπει ετήσιο στοιχείο αμοιβής + ετήσιο στοιχείο αμοιβής μη εγκεκριμένο + επικαλυπτόμενες αναθέσεις θέσεων + λείπει ταυτότητα εργολάβου εργασίας + κατάσταση κάλυψης μη δηλωμένη + δημογραφική απάντηση λείπει ή αρνήθηκε + χρήση W-2 πλαισίου 1 αντί 5 (απαιτείται παρατήρηση) + λείπουν εισοδήματα + τα πλήθη γραμμών δεν συμφωνούν με τα στιγμιότυπα + ελλιπής ταυτότητα εργοδότη (όνομα, FEIN, SEIN, διεύθυνση EDD) + ελλιπής διεύθυνση εγκατάστασης + λείπει εγκατάσταση + λείπει NAICS εγκατάστασης + χρήση μεθόδου εξαιρουμένων (απαιτείται παρατήρηση) + το πεδίο υπερβαίνει το μήκος προτύπου + η εξαγωγή θα υπερέβαινε το όριο μεγέθους της πύλης + οι δηλωτέες ώρες είναι μηδέν + λείπει κατηγορία θέσης + κατηγορία θέσης εκτός προφίλ σχήματος + εφαρμόστηκε χειροκίνητη παράκαμψη + κανένας εργαζόμενος στο στιγμιότυπο + χρήση αντίληψης παρατηρητή για δημογραφικό κωδικό + το προφίλ σχήματος άλλαξε από τη δημιουργία + τα πλήθη εξ αποστάσεως δεν συμφωνούν με το πλήθος εργαζομένων + η απαιτούμενη στήλη προτύπου είναι κενή + οι γραμμές δεν έχουν συγκεντρωθεί ακόμη + περίοδος στιγμιότυπου εκτός επιτρεπόμενου παραθύρου + λείπουν εβδομάδες εργασίας + τρόπος εργασίας ανεπίλυτος (καμία ανάθεση) + Απόκλιση + Έκδοση + Προβολή εσωτερικών κοστών + Συγκεντρωτικές συνόψεις κόστους πεδίου και περιθώρια για προσφορές, κλήσεις και αποστολές — κατηγορίες και σύνολα, ποτέ γραμμή, τιμή ή πρόσωπο — συν προφίλ κόστους πόρων και ενδείξεις χρήσης. + Προβολή αμοιβών + Ανάγνωση προφίλ αμοιβών, καταχωρίσεων εργασίας, ετήσιων στοιχείων και γραμμών υπολογισμών κόστους. Οι τιμές εξακολουθούν να απαιτούν έγκυρη άδεια. + Ακύρωση + W-2 πλαίσιο 1 + W-2 πλαίσιο 5 + Προειδοποιήσεις + Δηλώθηκε το προηγούμενο έτος + Εβδομάδες εργασίας + Γιατί ρωτάμε + Το άρθρο 12999 του Κώδικα Κυβέρνησης Καλιφόρνιας απαιτεί από τους καλυπτόμενους εργοδότες να δηλώνουν δεδομένα αμοιβών ομαδοποιημένα ανά κατηγορία θέσης, φυλή/εθνότητα και φύλο. Η αναφορά περιέχει μόνο συγκεντρωτικά πλήθη και τιμές· δεν κατονομάζει ποτέ κανέναν. + 1. Δημιουργία στιγμιότυπων εργαζομένων · 2. Συγκέντρωση γραμμών · 3. Επικύρωση · 4. Πάγωμα και εξαγωγή · μετά βεβαίωση στην πύλη CRD και καταγραφή της πιστοποίησης εδώ. + Χώρα + Καταχωρίσεις εργασίας + Ώρες ανά εργαζόμενο και ημέρα από μισθοδοσία, αποστολές και κλήσεις· το εγκεκριμένο κόστος μισθοδοσίας, όταν υπάρχει, αντικαθιστά κάθε εκτίμηση. + Τοποθεσία εργασίας + Τρόπος εργασίας + Μη εξ αποστάσεως + Εξ αποστάσεως εκτός Καλιφόρνιας (τοποθετημένος σε εγκατάσταση CA) + Εξ αποστάσεως εντός Καλιφόρνιας + Πολιτεία / επαρχία + Εργαζόμενος + Οι περίοδοι απασχόλησης δεν επικαλύπτονται ποτέ· κάθε περίοδος φέρει τις αναθέσεις θέσεων στον χρόνο. + Είδος εργαζομένου + Ανεξάρτητος συνεργάτης + Εργαζόμενος εργολάβου εργασίας + Μισθωτός εργαζόμενος + Εθελοντής + Εργαζόμενοι + Κάθε πρόσωπο που το τμήμα πληρώνει ή δηλώνει — μέλη και εξωτερικοί εργαζόμενοι — με τις περιόδους απασχόλησης. + Εργατικό δυναμικό + Οι τιμές επιπέδου εργοδότη που πληκτρολογείτε στην πύλη CRD μαζί με το ανεβασμένο αρχείο. + Παρέχεται χωρίς αποθήκευση και ελέγχεται· κλείστε την καρτέλα όταν τελειώσετε. Το Resgrid δεν συνδέεται ποτέ στην πύλη. + Έτος + Ναι + Το αρχείο εξαγωγής δεν βρέθηκε. + Το αρχείο εξαγωγής έληξε ή διαγράφηκε. + Απαιτείται η αναφορά πιστοποίησης πύλης. + Η πηγή συλλογής δεν είναι έγκυρη. + Υπάρχει ήδη εκτέλεση διόρθωσης. + Η απάντηση Ισπανόφωνος ή Λατίνος δεν είναι έγκυρη. + Δημιουργήστε πρώτα τα στιγμιότυπα εργαζομένων. + Κανένα ελεγμένο προφίλ σχήματος CRD δεν καλύπτει αυτό το έτος. + Ένας κωδικός φυλής/εθνότητας δεν είναι έγκυρος. + Απαντήστε φυλή/εθνότητα ή αρνηθείτε να δηλώσετε. + Απαιτείται αιτία. + Οι παρατηρήσεις περιορίζονται σε 500 χαρακτήρες. + Πιστοποιημένη εκτέλεση δεν μπορεί να ακυρωθεί· δημιουργήστε διόρθωση. + Η εκτέλεση είναι παγωμένη· δημιουργήστε διόρθωση. + Μόνο εξαγόμενη εκτέλεση μπορεί να σημειωθεί ως πιστοποιημένη. + Η εκτέλεση αναφοράς δεν βρέθηκε. + Μόνο εξαγόμενη ή πιστοποιημένη εκτέλεση μπορεί να διορθωθεί. + Ο κωδικός φύλου δεν είναι έγκυρος. + Απαντήστε φύλο ή αρνηθείτε να δηλώσετε. + Το στιγμιότυπο εργαζομένου δεν βρέθηκε. + Η περίοδος στιγμιότυπου πρέπει να είναι μία περίοδος μισθοδοσίας εντός του επιτρεπόμενου παραθύρου. + Η επικύρωση εξακολουθεί να αναφέρει σφάλματα φραγής. + Η διεργασία αναφοράς δεν μπόρεσε να αποκρυπτογραφήσει τις προστατευμένες τιμές. + Απαιτείται η νομική επωνυμία της συνδεδεμένης οντότητας. + Η βάση κατανομής δεν είναι έγκυρη. + Ένα ποσό είναι αρνητικό. + Απαιτείται πάγιο αποθέματος. + Η ανάθεση επικαλύπτεται με άλλη ανάθεση αυτής της απασχόλησης. + Η προσφορά δεν βρέθηκε. + Μια κατηγορία ή βάση στοιχείου δεν είναι έγκυρη. + Απαιτείται η νομική επωνυμία του εργολάβου. + Ένας εργαζόμενος εργολάβου χρειάζεται εργολάβο εργασίας. + Η κατάσταση κάλυψης δεν είναι έγκυρη. + Η ημερομηνία λήξης προηγείται της έναρξης. + Η αποστολή δεν βρέθηκε. + Απαιτείται η νομική επωνυμία του εργοδότη. + Η απασχόληση δεν βρέθηκε. + Η περίοδος επικαλύπτεται με άλλη απασχόληση αυτού του εργαζομένου. + Άλλη εγκατάσταση χρησιμοποιεί ήδη αυτόν τον κωδικό. + Μια εγκατάσταση χρειάζεται κωδικό και όνομα. + Η εγκατάσταση δεν βρέθηκε. + Απαιτείται κλειδί εξωτερικού πόρου. + Οι ώρες πρέπει να είναι μεταξύ 0 και 24. + Ο τύπος ωρών δεν είναι έγκυρος. + Η κατηγορία θέσης δεν υπάρχει στο προφίλ σχήματος. + Το NAICS πρέπει να έχει έξι ψηφία. + Τίποτα προς κοστολόγηση: καμία καταχώριση εργασίας ή ένδειξη χρήσης. + Η εγγραφή δεν βρέθηκε. + Η βάση αμοιβής δεν είναι έγκυρη. + Η περίοδος επικαλύπτεται με άλλο προφίλ ίδιου εύρους. + Η προστατευμένη εγγραφή απορρίφθηκε. + Ο τύπος αναφοράς δεν είναι έγκυρος. + Μια προεπιλογή ρόλου χρειάζεται ρόλο προσωπικού. + Η εκτέλεση είναι παγωμένη και δεν μπορεί να αλλάξει. + Το εύρος του προφίλ δεν είναι έγκυρο. + Ο τύπος αντικειμένου δεν είναι έγκυρος. + Η μονάδα δεν βρέθηκε. + Απαιτείται μονάδα. + Μια ένδειξη χρήσης χρειάζεται αποστολή ή κλήση. + Η ένδειξη χρήσης δεν είναι έγκυρη. + Ο τρόπος εργασίας δεν είναι έγκυρος. + Αυτό το μέλος έχει ήδη εγγραφή εργαζομένου. + Ένας εργαζόμενος χρειάζεται μέλος ή εξωτερικό κλειδί. + Το είδος εργαζομένου δεν είναι έγκυρο. + Ο εργαζόμενος δεν βρέθηκε. + Απαιτείται εργαζόμενος. + Το έτος αναφοράς δεν είναι έγκυρο. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx new file mode 100644 index 00000000..6d5a27c6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Acquisition cost + Acquisition date + Active + Active from + Active to + Actual + Actual worked hours + Add affiliate + Add assignment + Add contractor + Add department default + Add employment + Add establishment + Add fact + Add profile + Add resource profile + Add role default + Add reading + Add work entry + Add worker + Or add an external worker by payroll key and label (both protected). + Pick a department member to create their worker row. + Address + Affiliate + Affiliated entities + Only for an integrated enterprise: the other entities the report covers. + Aggregate rows + Allocation basis + Per day + Per engine hour + Per kilometer + Per mile + Per operating hour + Amount + Annual pay facts + W-2 earnings, hours and weeks per employment for the reporting year — the input to every CRD snapshot. + Annual pay facts missing + Apply override + Approval + Unapproved profiles still price estimates but flag every line for review. Any rate change removes the approval. + Approve profile + Approved + Approved payroll cost + The actual from the payroll system; when present it replaces the estimate for this day. + Export files + CSV and XLSX in the CRD template column order, checksum-stable for unchanged input, downloadable no-store until purged after the retention window. + Back + Base amount + Per hour, year, day or shift according to the pay basis. + Basis + Bid + Break-even revenue + Build snapshots + Assigned to a California establishment + Both + Not a California employee + Works in California + California basis + California employees + Call # + Cancel + Cap + Category + Consumable + Expense + Overhead + Personnel + Resource + Portal certification reference + Checksum + City + Client-allocated earnings + Client-allocated hours + Client-allocated weeks + Client allocation key + Code + Collection source + Employment record + How this record was obtained; observer perception is flagged in every report run. + Observer perception + Other reliable record + Self-identified + Commit import + Compensation + Employee, role-default and department-default compensation profiles with pay and employer-cost components. + Compensation profile + Demographic completeness + Component + Components + Create a correction run? The exported run stays as filed and is marked corrected. + Delete this record? + Freeze this run? A frozen run is immutable; a later recalculation supersedes it. + Commit the import? Every row creates a new fact version. + Void this run? Its export files are purged. + Consumables + Consumption + Contact details + Context + Bid + Call + Deployment + Contractors that supply labor contractor employees covered by the second CRD report. + Correct fact (new version) + Allocated overhead + Employer payroll tax + Fixed labor cost + Health benefits + Other + Other benefits + Retirement / pension + Workers' compensation + Fixed annual + Per day + Per hour + Per shift + % of eligible pay + Cost components + Payroll taxes, benefits, pension and allocated overhead — percent of eligible pay, per hour, per shift or fixed annual, with an optional cap. + Cost run + Cost runs + Internal loaded cost and margin for bids (estimate), calls and deployments (actual). Revenue is only ever an observed number. + Covered — both reports + Covered — labor contractor employee report + Covered — payroll employee report + Declared by the employer after reading the CRD guidance; Resgrid never decides whether you must file. + Not covered + Coverage status + Not declared + Create correction + Create run + Created + Currency + Current response + Protected workforce pay data, internal field costing and California pay data reporting. + Date + Days + Days worked + DBA + Decline to state + declined + Default establishment + Default profiles + Role and department defaults price bid estimates and members without an employee profile. + Delete + Race/ethnicity/sex + Demographic record + A compliance officer's record for a worker who has not self-identified. + Use employment or other reliable records first; observer perception is a last resort and is flagged on every snapshot. A reason is required and audited. + Demographic responses missing + Answering is voluntary. Declining to state is a valid answer and is reported as such. + Department + Deployed days + Deployment + Deployment id + Depreciation + Depreciation / unit + Display label + Distance + Enter odometer readings or a distance; kilometres are stored as miles. + Distance unit + Downloads + Dry run + Dry run result + Due date + Earnings source + Client-allocated + W-2 Box 1 (fallback) + W-2 Box 5 + Earnings used + EDD address + Edit assignment + Edit contractor + Edit employment + Edit establishment + Edit resource profile + Edit reading + Edit work entry + Effective + Eligible pay codes + Employee profiles + Employees + Employer + Employer cost components + employer costs + The employer identity the CRD report is filed under, with its affiliated entities. + No employer profile yet. + Employer profile + Employer section + Employment + Employment type + Full-time + Intermittent + Part-time + Unknown + Employments + Engine meter end + End odometer + End + Engine hours + Errors + Establishment + Establishments + Every physical location employees are assigned to; the CRD report is filed per establishment. + Estimate + Estimate vs. actual + estimated + Exceptions + Actual + paid leave + Days × average hours + Exempt-hours proxy + None + Exemption + Exempt + Non-exempt + Unknown + Expected annual utilization + In allocation units per year; spreads fixed annual components and time-based depreciation. + Expenses + Expires + Expires + Export California pay data reports + Freeze a validated run and download its CSV / XLSX files and the portal worksheet (no-store, audited). + External id + External resource key + A rate schedule entry id here lets bid estimates price vehicle and equipment lines by class. + External source + External worker key + Stored facts are immutable: saving creates a new version that supersedes the current one. + fallback profile + FEIN + Field costing + Internal loaded cost and margin for bids, calls and deployments — aggregate categories only. + File + Filing contact e-mail + Filing contact name + Filing contact phone + Filter + Flags + Format + Freeze + Freeze and export + Frozen + Fuel + Actual fuel cost + Fuel quantity + Fuel unit + Headquarters + Headquarters address + Every amount and multiplier is a protected field. Estimates use the employee profile, then the role default, then the department default; the payroll system's approved cost always wins. + Coverage is declared by you, never determined by Resgrid. Identifiers and addresses are protected fields; a REDACTED value left unchanged keeps what is stored. + Hispanic or Latino + Hourly rate + Hours + Hours / day + Hours / week + Hours / year + Hours type + Double time + Other + Overtime + Paid leave + Regular + Standby + Travel + Identifier type + Idle hours + Import annual pay facts (CSV) + Paste the payroll export in the canonical column order. A dry run validates and reconciles every row before anything commits; imported facts arrive unapproved. + Import result + Box 5 absent — Box 1 used (remark required) + duplicate row + earnings missing + no employment covers the year + nothing to import + reportable hours missing or zero + worker not found + reporting year invalid + Included + Inventory asset + Part of an integrated enterprise + CRD job category + One of the ten CRD job categories of the reviewed schema profile. + Job title + Labor contractor + Labor contractors + Legal name + Line + Lines + Personnel lines carry no rate in the clear; the priced detail is a protected field shown only with a current grant. + Lines are visible with View workforce compensation; the summary above is the aggregate view. + Major activity + Manage California pay data reporting + The CRD report wizard: runs, employee snapshots, overrides, aggregation, validation, remarks, certification observation, corrections and the compliance officer's demographic records. Every member always answers their own response without it. + Manage workforce and compensation + Employer identity, establishments, workers, employment periods, job assignments, compensation profiles and components, work entries and annual pay fact imports. Values still need a current Protected Data Grant. + Mapping provenance + Contribution margin + Record certification + Only what you observed in the CRD portal after certifying there; Resgrid never certifies. + MARS authority profile + Cal OES MARS classification + Mean hourly rate + Median hourly rate + Member + Miles + Missing inputs + My demographic response + Answer or update your voluntary self-identification for California pay data reporting. + NAICS + Name + New run + No + No answer + No export files — freeze and export a validated run. + No job assignments. + No labor contractors. + No employment periods yet. + No establishments yet. + No annual pay facts for this year and report type. + No lines. + No compensation profiles. + No resource cost profiles. + No aggregate rows yet — aggregate after the snapshots are complete. + No runs yet. + No snapshots yet — build them first. + No usage readings. + No work entries in this window. + No workers yet. + Non-remote + None + blocking errors remain + Not validated yet. + observer perception + Open runs + Operating hours + Distance (as read) + Other + Overhead + Override reason + Ownership name + Paid leave hours + pay + Pay band + Pay basis + Daily + Hourly + Salary + Per shift + Stipend + Differential + Education + EMS + HazMat + Incentive + Longevity + Other + Specialty + USAR + Fixed annual + Per hour + Per pay period + Per shift + % of base + Pay components + Differentials, incentives and specialty pay on top of the base. Fixed-period components are not paid on overtime unless flagged per overtime hour. + Resgrid prepares and exports; it never files, never decides whether you are covered and never certifies. Everything on these screens is a protected field and is served no-store. + Prepare the California CRD Payroll Employee and Labor Contractor Employee reports; you file them yourself in the CRD portal. + Pay data reporting + Report run + Per OT hour + Personnel + Personnel role + Used to pick a role-default compensation profile when the employee has none. + Phase + Incident + Mobilization + Return + Standby + Street address + Portal worksheet + ZIP + Loaded cost of a regular 8-hour day + no reviewed schema profile + Identifiers, addresses, pay rates, earnings, demographic responses and export files are Advanced Data Protection fields (catalog 28). They render REDACTED without a current Protected Data Grant and are never cached or logged. + Provenance + Where the identity came from (contract, W-9, portal record). + Average hours per day + purged + Quantity + Race / ethnicity + Select every category that applies; two or more are reported as multiracial. + White + Black or African American + Native Hawaiian or Other Pacific Islander + Asian + American Indian or Alaska Native + Middle Eastern or North African + Rate + Rate multipliers + JSON by pay code; missing codes use 1.5 (overtime), 2 (double time) and 1. + Readiness + ready to freeze + Reason + Reconciled + Regular hourly equivalent + Optional override; otherwise derived from the base amount and standard hours. + Relationship + Relationship end + Relationship start + Remote in CA + Remote outside CA + Report type + Labor Contractor Employee Report + Payroll Employee Report + Reportable hours + Fixed annual + Per day + Per deployment + Per engine hour + Per idle hour + Per kilometer + Per mile + Per operating hour + Consumables + Depreciation + Fixed overhead + Fuel / energy + Insurance / licensing + Lease / rental + Maintenance + Other + Storage + Tires / wear + Fuel (rate, or consumption × unit price), maintenance (manual or rolling work-order actual with a meter window), tires, insurance, lease, storage, fixed overhead and consumables. + Resource costs + Depreciation, fuel, maintenance and fixed costs per unit, asset or external resource class. + Resource profiles + Plain numeric data gated by View internal costs — no person is priced here. + Calculated from acquisition + Imported + Manual + Work-order rolling actual + Resource usage + Resources + Revenue + Bid estimated total + Cal OES MARS approved + Cal OES MARS expected + Cal OES MARS paid + Customer invoices + None + Revenue source + Review + approved payroll cost used + class profile used + component unapproved + currency mismatch + department default used + depreciation inputs missing + automatic and manual distance disagree + fuel inputs missing + no compensation profile + no employment period for the member + no rate for the basis + no resource profile + pay code multiplier missing (default used) + repair already charged directly + role default used + rolling maintenance window insufficient + unapproved profile + usage reading needs review + annual utilization missing + Rows + Estimate bid cost + Bid lines × department-default compensation and class resource profiles; revenue = the bid's estimated total. + Calculate call cost + Work entries and usage readings recorded against the call; no revenue. + Calculate deployment cost + Approved daily time reports, usage readings and expenses through the date. + Clarifying remarks + Up to 500 characters; required when Box 1 earnings were used or an exempt proxy applies. + Draft + Frozen + Needs review + Superseded + Run type + Actual + Estimate + Report runs + Salvage value + Save + The change could not be saved. + Save response + Saved. + Schema profile + Scope + Department default + Employee + Role default + SEIN (EDD) + Voluntary self-identification + Your answers are stored separately from your personnel record, encrypted, and used only to prepare the aggregate CRD report. Nobody in the department sees them on any other screen. + self-identified + Sex + Female + Male + Non-binary + Size + Skipped + Snapshot + Employees in snapshot + Snapshot end + Snapshot start + A single pay period between + Employee snapshots + One row per California employee in the snapshot period; demographic code, earnings and hourly rate are protected fields. Overrides need a reason and are audited. + SOC code + SOC version + CA Secretary of State number + Source + Engine meter start + Start odometer + Start + State + Status + Certified externally + Corrected + Draft + Exported + Frozen + Validated + Void + Subject + Subject type + External class + Inventory asset + Unit + Summary + Annual pay facts + Compensation + Labor contractors + Cost runs + Overview + Employer + Establishments + Pay data reporting + Resource costs + Work entries + Workers + Through date + Time zone + Total + Total loaded cost + not approved + Unit + Unit price + Unresolved exceptions + Updated + US employees + Usage entries + Odometer, engine meter, hours and fuel readings per unit and day; distance is stored in miles and conflicting readings are queued for review. + Daily time report + GPS + Hardware tracker + Import + Manual + Useful life (months) + Useful life (units) + Miles, hours or days over the life; straight-line = (cost − salvage) ÷ life. + Validate + Validated on + Validation + annual pay fact missing + annual pay fact not approved + overlapping job assignments + labor contractor identity missing + coverage status not declared + demographic response missing or declined + W-2 Box 1 used instead of Box 5 (remark required) + earnings missing + row employee counts do not reconcile to the snapshots + employer identity incomplete (name, FEIN, SEIN, EDD address) + establishment address incomplete + establishment missing + establishment NAICS missing + exempt-hours proxy used (remark required) + field exceeds the template length + export would exceed the portal file size limit + reportable hours are zero + job category missing + job category not in the schema profile + manual override applied + no employees in the snapshot + observer perception used for the demographic code + schema profile changed since the run was created + remote counts do not reconcile to the employee count + required template column is empty + rows not aggregated yet + snapshot period outside the allowed window + weeks worked missing + work mode unresolved (no assignment) + Variance + Version + View internal costs + Aggregate field-cost summaries and margins for bids, calls and deployments — categories and totals, never a line, rate or person — plus resource cost profiles and usage readings. + View workforce compensation + Read compensation profiles, work entries, annual pay facts and cost-run lines. Values still need a current Protected Data Grant. + Void + W-2 Box 1 + W-2 Box 5 + Warnings + Reported in the prior year + Weeks worked + Why we ask + California Government Code section 12999 requires covered employers to report pay data grouped by job category, race/ethnicity and sex. The report contains only aggregate counts and rates; it never names anyone. + 1. Build the employee snapshots · 2. Aggregate the rows · 3. Validate · 4. Freeze and export · then attest in the CRD portal and record the certification here. + Country + Work entries + Hours per worker and day from payroll, deployments and calls; the payroll-approved cost, when present, replaces any estimate. + Work location + Work mode + Non-remote + Remote outside California (assigned to a CA establishment) + Remote within California + State / province + Worker + Employment periods never overlap; each period carries its job assignments over time. + Worker kind + Independent contractor + Labor contractor employee + Payroll employee + Volunteer + Workers + Every person the department pays or reports on — members and external workers — with their employment periods. + Workforce + The employer-level values you type into the CRD portal alongside the uploaded file. + Served no-store and audited; close the tab when done. Resgrid never signs in to the portal. + Year + Yes + The export file was not found. + The export file has expired or was purged. + The portal certification reference is required. + The collection source is not valid. + A correction run already exists. + The Hispanic or Latino answer is not valid. + Build the employee snapshots first. + No reviewed CRD schema profile covers that reporting year. + A race/ethnicity code is not valid. + Answer race/ethnicity or decline to state. + A reason is required. + Remarks are limited to 500 characters. + A certified run cannot be voided; create a correction. + The run is frozen; create a correction instead. + Only an exported run can be marked certified. + The report run was not found. + Only an exported or certified run can be corrected. + The sex code is not valid. + Answer sex or decline to state. + The employee snapshot was not found. + The snapshot period must be one pay period inside the allowed window. + Validation still reports blocking errors. + The reporting workload could not decrypt the protected values. + The affiliate legal name is required. + The allocation basis is not valid. + An amount is negative. + An inventory asset is required. + The assignment overlaps another assignment of this employment. + The bid was not found. + A component category or basis is not valid. + The contractor legal name is required. + A labor contractor employee needs a labor contractor. + The coverage status is not valid. + The end date is before the start date. + The deployment was not found. + The employer legal name is required. + The employment was not found. + The period overlaps another employment of this worker. + Another establishment already uses that code. + An establishment needs a code and a name. + The establishment was not found. + An external resource key is required. + Hours must be between 0 and 24. + The hours type is not valid. + The job category is not in the schema profile. + NAICS must be six digits. + Nothing to cost: no work entries or usage readings. + The record was not found. + The pay basis is not valid. + The period overlaps another profile of the same scope. + The protected write was refused. + The report type is not valid. + A role default needs a personnel role. + The run is frozen and cannot be changed. + The profile scope is not valid. + The subject type is not valid. + The unit was not found. + A unit is required. + A usage reading needs a deployment or a call. + The usage reading is not valid. + The work mode is not valid. + That member already has a worker row. + A worker needs a member or an external key. + The worker kind is not valid. + The worker was not found. + A worker is required. + The reporting year is not valid. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx new file mode 100644 index 00000000..c6697853 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Costo de adquisición + Fecha de adquisición + Activo + Activo desde + Activo hasta + Real + Horas trabajadas reales + Añadir afiliada + Añadir asignación + Añadir contratista + Añadir predeterminado del departamento + Añadir empleo + Añadir establecimiento + Añadir dato + Añadir perfil + Añadir perfil de recurso + Añadir predeterminado de rol + Añadir lectura + Añadir registro + Añadir trabajador + O añada un trabajador externo por clave de nómina y etiqueta (ambas protegidas). + Elija un miembro para crear su registro de trabajador. + Dirección + Afiliada + Entidades afiliadas + Solo para una empresa integrada: las demás entidades que cubre el informe. + Agregar filas + Base de asignación + Por día + Por hora de motor + Por kilómetro + Por milla + Por hora de operación + Importe + Datos anuales de pago + Ingresos W-2, horas y semanas por empleo para el año del informe — la entrada de cada instantánea CRD. + Datos anuales de pago faltantes + Aplicar anulación + Aprobación + Los perfiles no aprobados siguen valorando estimaciones pero marcan cada línea para revisión. Cualquier cambio de tarifa elimina la aprobación. + Aprobar perfil + Aprobado + Costo de nómina aprobado + El real del sistema de nómina; si existe, reemplaza la estimación de este día. + Archivos de exportación + CSV y XLSX en el orden de columnas de la plantilla CRD, con suma de verificación estable para entradas sin cambios, descargables sin caché hasta su purga tras el periodo de retención. + Volver + Importe base + Por hora, año, día o turno según la base de pago. + Base + Oferta + Ingresos de equilibrio + Crear instantáneas + Asignado a un establecimiento de California + Ambos + No es empleado de California + Trabaja en California + Base en California + Empleados en California + Llamada n.º + Cancelar + Tope + Categoría + Consumible + Gasto + Gastos generales + Personal + Recurso + Referencia de certificación del portal + Suma de verificación + Ciudad + Ingresos asignados al cliente + Horas asignadas al cliente + Semanas asignadas al cliente + Clave de asignación de cliente + Código + Origen de la recolección + Registro de empleo + Cómo se obtuvo este registro; la percepción del observador se marca en cada proceso. + Percepción del observador + Otro registro fiable + Autoidentificado + Confirmar importación + Compensación + Perfiles de compensación por empleado, por rol y por departamento con componentes de pago y de costo del empleador. + Perfil de compensación + Completitud demográfica + Componente + Componentes + ¿Crear un proceso de corrección? El proceso exportado se conserva tal como se presentó y se marca como corregido. + ¿Eliminar este registro? + ¿Congelar este cálculo? Un cálculo congelado es inmutable; un recálculo posterior lo reemplaza. + ¿Confirmar la importación? Cada fila crea una nueva versión del dato. + ¿Anular este proceso? Sus archivos de exportación se eliminan. + Consumibles + Consumo + Datos de contacto + Contexto + Oferta + Llamada + Despliegue + Contratistas que suministran empleados cubiertos por el segundo informe CRD. + Corregir dato (nueva versión) + Gastos generales asignados + Impuesto sobre nómina del empleador + Costo laboral fijo + Beneficios de salud + Otro + Otros beneficios + Jubilación / pensión + Compensación laboral + Anual fijo + Por día + Por hora + Por turno + % del pago elegible + Componentes de costo + Impuestos sobre nómina, beneficios, pensión y gastos generales asignados — porcentaje del pago elegible, por hora, por turno o anual fijo, con tope opcional. + Cálculo de costos + Cálculos de costos + Costo cargado interno y margen para ofertas (estimación), llamadas y despliegues (real). Los ingresos son siempre un valor observado. + Cubierto — ambos informes + Cubierto — informe de empleados de contratista laboral + Cubierto — informe de empleados de nómina + Declarado por el empleador tras leer la guía del CRD; Resgrid nunca decide si debe presentar. + No cubierto + Estado de cobertura + No declarado + Crear corrección + Crear proceso + Creado + Moneda + Respuesta actual + Datos salariales protegidos, costeo interno de campo e informe de datos salariales de California. + Fecha + Días + Días trabajados + DBA + Prefiero no decirlo + declinaron + Establecimiento predeterminado + Perfiles predeterminados + Los predeterminados de rol y departamento valoran las estimaciones de ofertas y los miembros sin perfil propio. + Eliminar + Raza/etnia/sexo + Registro demográfico + Registro del responsable de cumplimiento para un trabajador que no se ha autoidentificado. + Use primero registros laborales u otros fiables; la percepción del observador es el último recurso y se marca en cada instantánea. Se requiere un motivo y se audita. + Respuestas demográficas faltantes + Responder es voluntario. Preferir no decirlo es una respuesta válida y se reporta como tal. + Departamento + Días desplegado + Despliegue + Id de despliegue + Depreciación + Depreciación / unidad + Etiqueta + Distancia + Introduzca lecturas de odómetro o una distancia; los kilómetros se guardan como millas. + Unidad de distancia + Descargas + Simulación + Resultado de la simulación + Fecha límite + Origen de ingresos + Asignado al cliente + W-2 casilla 1 (alternativa) + W-2 casilla 5 + Ingresos usados + Dirección EDD + Editar asignación + Editar contratista + Editar empleo + Editar establecimiento + Editar perfil de recurso + Editar lectura + Editar registro + Vigente desde + Códigos de pago elegibles + Perfiles del empleado + Empleados + Empleador + Componentes de costo del empleador + costos del empleador + La identidad del empleador bajo la que se presenta el informe CRD, con sus entidades afiliadas. + Aún no hay perfil de empleador. + Perfil del empleador + Sección del empleador + Empleo + Tipo de empleo + Tiempo completo + Intermitente + Tiempo parcial + Desconocido + Empleos + Horómetro final + Odómetro final + Fin + Horas de motor + Errores + Establecimiento + Establecimientos + Cada ubicación física a la que se asignan empleados; el informe CRD se presenta por establecimiento. + Estimación + Estimación vs. real + estimado + Excepciones + Real + permiso pagado + Días × horas promedio + Método sustituto para exentos + Ninguno + Exención + Exento + No exento + Desconocido + Utilización anual esperada + En unidades de asignación por año; reparte componentes anuales fijos y depreciación por tiempo. + Gastos + Vence + Vence + Exportar informes de datos salariales de California + Congelar un proceso validado y descargar sus archivos CSV / XLSX y la hoja de trabajo del portal (sin caché, auditado). + Id externo + Clave de recurso externo + Un id de entrada de tarifario aquí permite que las estimaciones de ofertas valoren líneas de vehículos y equipos por clase. + Origen externo + Clave de trabajador externo + Los datos guardados son inmutables: guardar crea una nueva versión que reemplaza la actual. + perfil alternativo + FEIN + Costeo de campo + Costo cargado interno y margen para ofertas, llamadas y despliegues — solo categorías agregadas. + Archivo + Correo del contacto + Nombre del contacto de presentación + Teléfono del contacto + Filtrar + Marcas + Formato + Congelar + Congelar y exportar + Congelado + Combustible + Costo real de combustible + Cantidad de combustible + Unidad de combustible + Sede central + Dirección de la sede + Cada importe y multiplicador es un campo protegido. Las estimaciones usan el perfil del empleado, luego el predeterminado del rol y luego el del departamento; el costo aprobado por nómina siempre prevalece. + La cobertura la declara usted; Resgrid nunca la determina. Los identificadores y direcciones son campos protegidos; un valor REDACTED sin cambios conserva lo almacenado. + Hispano o latino + Tarifa horaria + Horas + Horas / día + Horas / semana + Horas / año + Tipo de horas + Tiempo doble + Otro + Horas extra + Permiso pagado + Regulares + Guardia + Viaje + Tipo de identificador + Horas inactivas + Importar datos anuales (CSV) + Pegue la exportación de nómina en el orden canónico de columnas. Una simulación valida y concilia cada fila antes de confirmar; los datos importados llegan sin aprobar. + Resultado de la importación + Falta casilla 5 — se usa casilla 1 (requiere observación) + fila duplicada + faltan ingresos + ningún empleo cubre el año + nada que importar + faltan horas reportables o son cero + trabajador no encontrado + año inválido + Incluido + Activo de inventario + Parte de una empresa integrada + Categoría de puesto CRD + Una de las diez categorías de puesto CRD del perfil de esquema revisado. + Puesto + Contratista laboral + Contratistas laborales + Razón social + Línea + Líneas + Las líneas de personal no llevan tarifa en claro; el detalle valorado es un campo protegido que solo se muestra con una concesión vigente. + Las líneas se ven con Ver compensación; el resumen superior es la vista agregada. + Actividad principal + Gestionar informe de datos salariales de California + El asistente de informes CRD: procesos, instantáneas, anulaciones, agregación, validación, observaciones, registro de certificación, correcciones y los registros demográficos del responsable de cumplimiento. Cada miembro siempre responde su propia respuesta sin él. + Gestionar plantilla y compensación + Identidad del empleador, establecimientos, trabajadores, periodos de empleo, asignaciones de puesto, perfiles y componentes de compensación, registros de trabajo e importaciones de datos anuales. Los valores siguen requiriendo una concesión vigente. + Procedencia del mapeo + Margen de contribución + Registrar certificación + Solo lo que observó en el portal del CRD tras certificar allí; Resgrid nunca certifica. + Perfil de autoridad MARS + Clasificación Cal OES MARS + Tarifa horaria media + Tarifa horaria mediana + Miembro + Millas + Entradas faltantes + Mi respuesta demográfica + Responda o actualice su autoidentificación voluntaria para el informe de datos salariales de California. + NAICS + Nombre + Nuevo proceso + No + Sin respuesta + Sin archivos de exportación — congele y exporte un proceso validado. + Sin asignaciones de puesto. + Sin contratistas laborales. + Aún no hay periodos de empleo. + Aún no hay establecimientos. + Sin datos anuales para este año y tipo de informe. + Sin líneas. + Sin perfiles de compensación. + Sin perfiles de costo de recursos. + Aún no hay filas agregadas — agregue cuando las instantáneas estén completas. + Aún no hay procesos. + Aún no hay instantáneas — créelas primero. + Sin lecturas de uso. + Sin registros en este periodo. + Aún no hay trabajadores. + Presencial + Ninguno + quedan errores bloqueantes + Aún no validado. + percepción del observador + Procesos abiertos + Horas de operación + Distancia (leída) + Otro + Gastos generales + Motivo de la anulación + Nombre del propietario + Horas de permiso pagado + pago + Banda salarial + Base de pago + Diario + Por hora + Salario + Por turno + Estipendio + Diferencial + Educación + EMS + HazMat + Incentivo + Antigüedad + Otro + Especialidad + USAR + Anual fijo + Por hora + Por periodo de pago + Por turno + % de la base + Componentes de pago + Diferenciales, incentivos y pagos por especialidad sobre la base. Los componentes de periodo fijo no se pagan en horas extra salvo que se marquen por hora extra. + Resgrid prepara y exporta; nunca presenta, nunca decide si está cubierto y nunca certifica. Todo en estas pantallas es un campo protegido y se sirve sin caché. + Prepare los informes CRD de California de empleados de nómina y de contratista laboral; usted los presenta en el portal del CRD. + Informe de datos salariales + Proceso de informe + Por hora extra + Personal + Rol de personal + Se usa para elegir un perfil de compensación por rol cuando el empleado no tiene uno. + Fase + Incidente + Movilización + Regreso + En espera + Dirección + Hoja de trabajo del portal + Código postal + Costo cargado de una jornada regular de 8 horas + sin perfil de esquema revisado + Los identificadores, direcciones, tarifas salariales, ingresos, respuestas demográficas y archivos de exportación son campos de Protección Avanzada de Datos (catálogo 28). Se muestran como REDACTED sin una concesión vigente y nunca se almacenan en caché ni se registran. + Procedencia + De dónde proviene la identidad (contrato, W-9, registro del portal). + Horas promedio por día + purgado + Cantidad + Raza / etnia + Seleccione todas las categorías aplicables; dos o más se reportan como multirracial. + Blanco + Negro o afroamericano + Nativo de Hawái u otra isla del Pacífico + Asiático + Indígena americano o nativo de Alaska + De Medio Oriente o del norte de África + Tarifa + Multiplicadores de tarifa + JSON por código de pago; los códigos ausentes usan 1,5 (horas extra), 2 (doble) y 1. + Preparación + listo para congelar + Motivo + Conciliado + Equivalente horario regular + Anulación opcional; si no, se deriva del importe base y las horas estándar. + Relación + Fin de la relación + Inicio de la relación + Remoto en CA + Remoto fuera de CA + Tipo de informe + Informe de empleados de contratista laboral + Informe de empleados de nómina + Horas reportables + Anual fijo + Por día + Por despliegue + Por hora de motor + Por hora inactiva + Por kilómetro + Por milla + Por hora de operación + Consumibles + Depreciación + Gastos generales fijos + Combustible / energía + Seguro / licencias + Arrendamiento / alquiler + Mantenimiento + Otro + Almacenamiento + Neumáticos / desgaste + Combustible (tarifa o consumo × precio unitario), mantenimiento (manual o real de órdenes de trabajo con ventana de medidor), neumáticos, seguro, arrendamiento, almacenamiento, gastos fijos y consumibles. + Costos de recursos + Depreciación, combustible, mantenimiento y costos fijos por unidad, activo o clase de recurso externo. + Perfiles de recursos + Datos numéricos simples protegidos por Ver costos internos — aquí no se valora a ninguna persona. + Calculado de la adquisición + Importado + Manual + Real acumulado de órdenes de trabajo + Uso de recursos + Recursos + Ingresos + Total estimado de la oferta + Cal OES MARS aprobado + Cal OES MARS esperado + Cal OES MARS pagado + Facturas al cliente + Ninguno + Origen de ingresos + Revisión + se usó costo de nómina aprobado + se usó perfil de clase + componente no aprobado + moneda no coincide + se usó predeterminado del departamento + faltan datos de depreciación + distancia automática y manual no coinciden + faltan datos de combustible + sin perfil de compensación + sin periodo de empleo para el miembro + sin tarifa para la base + sin perfil de recurso + falta multiplicador del código de pago (se usó el predeterminado) + reparación ya cargada directamente + se usó predeterminado de rol + ventana de mantenimiento insuficiente + perfil no aprobado + lectura de uso requiere revisión + falta utilización anual + Filas + Estimar costo de la oferta + Líneas de oferta × compensación predeterminada del departamento y perfiles de recursos por clase; ingresos = total estimado de la oferta. + Calcular costo de la llamada + Registros de trabajo y lecturas de uso de la llamada; sin ingresos. + Calcular costo del despliegue + Informes diarios aprobados, lecturas de uso y gastos hasta la fecha. + Observaciones aclaratorias + Hasta 500 caracteres; requerido cuando se usaron ingresos de casilla 1 o aplica un sustituto para exentos. + Borrador + Congelado + Requiere revisión + Reemplazado + Tipo de proceso + Real + Estimación + Procesos de informe + Valor residual + Guardar + No se pudo guardar el cambio. + Guardar respuesta + Guardado. + Perfil de esquema + Ámbito + Predeterminado del departamento + Empleado + Predeterminado de rol + SEIN (EDD) + Autoidentificación voluntaria + Sus respuestas se guardan cifradas, separadas de su expediente, y solo se usan para preparar el informe CRD agregado. Nadie en el departamento las ve en otra pantalla. + autoidentificados + Sexo + Femenino + Masculino + No binario + Tamaño + Omitido + Instantánea + Empleados en la instantánea + Fin de la instantánea + Inicio de la instantánea + Un solo periodo de pago entre + Instantáneas de empleados + Una fila por empleado de California en el periodo; el código demográfico, los ingresos y la tarifa horaria son campos protegidos. Las anulaciones requieren motivo y se auditan. + Código SOC + Versión SOC + Número del Secretario de Estado de CA + Origen + Horómetro inicial + Odómetro inicial + Inicio + Estado + Estado + Certificado externamente + Corregido + Borrador + Exportado + Congelado + Validado + Anulado + Sujeto + Tipo de sujeto + Clase externa + Activo de inventario + Unidad + Resumen + Datos anuales de pago + Compensación + Contratistas laborales + Cálculos de costos + Resumen + Empleador + Establecimientos + Informe de datos salariales + Costos de recursos + Registros de trabajo + Trabajadores + Hasta la fecha + Zona horaria + Total + Costo cargado total + no aprobado + Unidad + Precio unitario + Excepciones sin resolver + Actualizado + Empleados en EE. UU. + Registros de uso + Lecturas de odómetro, horómetro, horas y combustible por unidad y día; la distancia se guarda en millas y las lecturas contradictorias se envían a revisión. + Informe diario de tiempo + GPS + Rastreador de hardware + Importación + Manual + Vida útil (meses) + Vida útil (unidades) + Millas, horas o días de vida; lineal = (costo − residual) ÷ vida. + Validar + Validado el + Validación + falta dato anual de pago + dato anual de pago no aprobado + asignaciones de puesto superpuestas + falta identidad del contratista laboral + estado de cobertura no declarado + respuesta demográfica faltante o declinada + se usó W-2 casilla 1 en lugar de casilla 5 (requiere observación) + faltan ingresos + los recuentos de empleados de las filas no cuadran con las instantáneas + identidad del empleador incompleta (nombre, FEIN, SEIN, dirección EDD) + dirección del establecimiento incompleta + falta establecimiento + falta NAICS del establecimiento + se usó método sustituto para exentos (requiere observación) + el campo supera la longitud de la plantilla + la exportación superaría el límite de tamaño del portal + las horas reportables son cero + falta categoría de puesto + categoría de puesto no está en el perfil de esquema + anulación manual aplicada + sin empleados en la instantánea + se usó percepción del observador para el código demográfico + el perfil de esquema cambió desde que se creó el proceso + los recuentos remotos no cuadran con el total de empleados + columna obligatoria de la plantilla vacía + filas aún no agregadas + periodo de instantánea fuera de la ventana permitida + faltan semanas trabajadas + modalidad de trabajo sin resolver (sin asignación) + Variación + Versión + Ver costos internos + Resúmenes agregados de costos de campo y márgenes para ofertas, llamadas y despliegues — categorías y totales, nunca una línea, tarifa o persona — más perfiles de costo de recursos y lecturas de uso. + Ver compensación + Leer perfiles de compensación, registros de trabajo, datos anuales y líneas de cálculos de costos. Los valores siguen requiriendo una concesión vigente. + Anular + W-2 casilla 1 + W-2 casilla 5 + Advertencias + Reportado el año anterior + Semanas trabajadas + Por qué preguntamos + La sección 12999 del Código de Gobierno de California exige a los empleadores cubiertos reportar datos salariales agrupados por categoría de puesto, raza/etnia y sexo. El informe contiene solo recuentos y tarifas agregados; nunca nombra a nadie. + 1. Crear las instantáneas de empleados · 2. Agregar las filas · 3. Validar · 4. Congelar y exportar · luego atestar en el portal del CRD y registrar la certificación aquí. + País + Registros de trabajo + Horas por trabajador y día de nómina, despliegues y llamadas; el costo aprobado por nómina, si existe, reemplaza cualquier estimación. + Ubicación de trabajo + Modalidad de trabajo + Presencial + Remoto fuera de California (asignado a establecimiento de CA) + Remoto dentro de California + Estado / provincia + Trabajador + Los periodos de empleo nunca se superponen; cada periodo lleva sus asignaciones de puesto a lo largo del tiempo. + Tipo de trabajador + Contratista independiente + Empleado de contratista laboral + Empleado de nómina + Voluntario + Trabajadores + Cada persona a la que el departamento paga o reporta — miembros y trabajadores externos — con sus periodos de empleo. + Plantilla + Los valores del empleador que introduce en el portal del CRD junto con el archivo cargado. + Servido sin caché y auditado; cierre la pestaña al terminar. Resgrid nunca inicia sesión en el portal. + Año + + No se encontró el archivo de exportación. + El archivo de exportación venció o fue purgado. + Se requiere la referencia de certificación del portal. + El origen de la recolección no es válido. + Ya existe un proceso de corrección. + La respuesta hispano o latino no es válida. + Cree primero las instantáneas de empleados. + Ningún perfil de esquema CRD revisado cubre ese año. + Un código de raza/etnia no es válido. + Responda raza/etnia o prefiera no decirlo. + Se requiere un motivo. + Las observaciones se limitan a 500 caracteres. + Un proceso certificado no puede anularse; cree una corrección. + El proceso está congelado; cree una corrección. + Solo un proceso exportado puede marcarse como certificado. + No se encontró el proceso de informe. + Solo un proceso exportado o certificado puede corregirse. + El código de sexo no es válido. + Responda sexo o prefiera no decirlo. + No se encontró la instantánea del empleado. + El periodo de instantánea debe ser un periodo de pago dentro de la ventana permitida. + La validación aún reporta errores bloqueantes. + El proceso de informe no pudo descifrar los valores protegidos. + Se requiere la razón social de la afiliada. + La base de asignación no es válida. + Un importe es negativo. + Se requiere un activo de inventario. + La asignación se superpone con otra de este empleo. + No se encontró la oferta. + Una categoría o base de componente no es válida. + Se requiere la razón social del contratista. + Un empleado de contratista laboral necesita un contratista. + El estado de cobertura no es válido. + La fecha de fin es anterior a la de inicio. + No se encontró el despliegue. + Se requiere la razón social del empleador. + No se encontró el empleo. + El periodo se superpone con otro empleo de este trabajador. + Otro establecimiento ya usa ese código. + Un establecimiento necesita código y nombre. + No se encontró el establecimiento. + Se requiere una clave de recurso externo. + Las horas deben estar entre 0 y 24. + El tipo de horas no es válido. + La categoría de puesto no está en el perfil de esquema. + NAICS debe tener seis dígitos. + Nada que costear: sin registros de trabajo ni lecturas de uso. + No se encontró el registro. + La base de pago no es válida. + El periodo se superpone con otro perfil del mismo ámbito. + Se rechazó la escritura protegida. + El tipo de informe no es válido. + Un predeterminado de rol necesita un rol de personal. + El proceso está congelado y no se puede cambiar. + El ámbito del perfil no es válido. + El tipo de sujeto no es válido. + No se encontró la unidad. + Se requiere una unidad. + Una lectura de uso necesita un despliegue o una llamada. + La lectura de uso no es válida. + La modalidad de trabajo no es válida. + Ese miembro ya tiene un registro de trabajador. + Un trabajador necesita un miembro o una clave externa. + El tipo de trabajador no es válido. + No se encontró el trabajador. + Se requiere un trabajador. + El año del informe no es válido. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx new file mode 100644 index 00000000..34caa85c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Coût d'acquisition + Date d'acquisition + Actif + Actif à partir du + Actif jusqu'au + Réel + Heures réellement travaillées + Ajouter une entité affiliée + Ajouter une affectation + Ajouter un prestataire + Ajouter un défaut de service + Ajouter un emploi + Ajouter un établissement + Ajouter une donnée + Ajouter un profil + Ajouter un profil de ressource + Ajouter un défaut de rôle + Ajouter un relevé + Ajouter une saisie + Ajouter un travailleur + Ou ajoutez un travailleur externe par clé de paie et libellé (tous deux protégés). + Choisissez un membre pour créer sa fiche travailleur. + Adresse + Entité affiliée + Entités affiliées + Uniquement pour une entreprise intégrée : les autres entités couvertes par le rapport. + Agréger les lignes + Base d'allocation + Par jour + Par heure moteur + Par kilomètre + Par mile + Par heure d'exploitation + Montant + Données annuelles de paie + Revenus W-2, heures et semaines par emploi pour l'année déclarée — l'entrée de chaque instantané CRD. + Données annuelles de paie manquantes + Appliquer le remplacement + Approbation + Les profils non approuvés valorisent toujours les estimations mais signalent chaque ligne pour vérification. Tout changement de taux retire l'approbation. + Approuver le profil + Approuvé + Coût de paie approuvé + Le réel du système de paie ; s'il existe, il remplace l'estimation de ce jour. + Fichiers d'export + CSV et XLSX dans l'ordre des colonnes du modèle CRD, à somme de contrôle stable pour une entrée inchangée, téléchargeables sans cache jusqu'à purge après la période de rétention. + Retour + Montant de base + Par heure, année, jour ou poste selon la base de paie. + Base + Offre + Chiffre d'affaires d'équilibre + Construire les instantanés + Affecté à un établissement de Californie + Les deux + Pas un employé de Californie + Travaille en Californie + Rattachement à la Californie + Employés en Californie + Intervention n° + Annuler + Plafond + Catégorie + Consommable + Dépense + Frais généraux + Personnel + Ressource + Référence de certification du portail + Somme de contrôle + Ville + Revenus alloués au client + Heures allouées au client + Semaines allouées au client + Clé d'allocation client + Code + Source de collecte + Dossier d'emploi + Comment cette fiche a été obtenue ; la perception de l'observateur est signalée dans chaque traitement. + Perception de l'observateur + Autre source fiable + Auto-identifié + Valider l'import + Rémunération + Profils de rémunération par employé, par rôle et par service avec composantes de paie et de coût employeur. + Profil de rémunération + Complétude démographique + Composante + Composantes + Créer un traitement de correction ? Le traitement exporté reste tel que déposé et est marqué corrigé. + Supprimer cet enregistrement ? + Figer ce calcul ? Un calcul figé est immuable ; un recalcul ultérieur le remplace. + Valider l'import ? Chaque ligne crée une nouvelle version de la donnée. + Annuler ce traitement ? Ses fichiers d'export sont purgés. + Consommables + Consommation + Coordonnées + Contexte + Offre + Intervention + Déploiement + Prestataires fournissant les employés couverts par le second rapport CRD. + Corriger la donnée (nouvelle version) + Frais généraux affectés + Charges patronales + Coût de main-d'œuvre fixe + Prestations santé + Autre + Autres avantages + Retraite / pension + Accidents du travail + Annuel fixe + Par jour + Par heure + Par poste + % de la paie éligible + Composantes de coût + Charges sociales, avantages, retraite et frais généraux affectés — pourcentage de la paie éligible, par heure, par poste ou annuel fixe, avec plafond facultatif. + Calcul de coûts + Calculs de coûts + Coût chargé interne et marge pour les offres (estimation), interventions et déploiements (réel). Le chiffre d'affaires n'est jamais qu'une valeur observée. + Assujetti — les deux rapports + Assujetti — rapport Labor Contractor Employee + Assujetti — rapport Payroll Employee + Déclaré par l'employeur après lecture des directives du CRD ; Resgrid ne décide jamais si vous devez déposer. + Non assujetti + Statut d'assujettissement + Non déclaré + Créer une correction + Créer le traitement + Créé + Devise + Réponse actuelle + Données de paie protégées, calcul interne des coûts d'intervention et déclaration des données de paie de Californie. + Date + Jours + Jours travaillés + DBA + Ne souhaite pas répondre + refusés + Établissement par défaut + Profils par défaut + Les défauts de rôle et de service valorisent les estimations d'offres et les membres sans profil employé. + Supprimer + Race/ethnie/sexe + Fiche démographique + Fiche du responsable conformité pour un travailleur ne s'étant pas auto-identifié. + Utilisez d'abord les dossiers d'emploi ou d'autres sources fiables ; la perception de l'observateur est un dernier recours et est signalée sur chaque instantané. Un motif est requis et audité. + Réponses démographiques manquantes + Répondre est volontaire. Ne pas souhaiter répondre est une réponse valide et est déclaré comme tel. + Service + Jours déployés + Déploiement + Id de déploiement + Amortissement + Amortissement / unité + Libellé + Distance + Saisissez des relevés d'odomètre ou une distance ; les kilomètres sont stockés en miles. + Unité de distance + Téléchargements + Essai + Résultat de l'essai + Échéance + Source des revenus + Alloué au client + W-2 case 1 (repli) + W-2 case 5 + Revenus utilisés + Adresse EDD + Modifier l'affectation + Modifier le prestataire + Modifier l'emploi + Modifier l'établissement + Modifier le profil de ressource + Modifier le relevé + Modifier la saisie + Effectif le + Codes de paie éligibles + Profils de l'employé + Employés + Employeur + Composantes de coût employeur + coûts employeur + L'identité de l'employeur sous laquelle la déclaration CRD est déposée, avec ses entités affiliées. + Pas encore de profil employeur. + Profil employeur + Section employeur + Emploi + Type d'emploi + Temps plein + Intermittent + Temps partiel + Inconnu + Emplois + Horamètre fin + Odomètre fin + Fin + Heures moteur + Erreurs + Établissement + Établissements + Chaque lieu physique auquel des employés sont affectés ; le rapport CRD est déposé par établissement. + Estimation + Estimation vs réel + estimé + Exceptions + Réel + congé payé + Jours × heures moyennes + Méthode substitutive (exemptés) + Aucune + Exemption + Exempté + Non exempté + Inconnu + Utilisation annuelle prévue + En unités d'allocation par an ; répartit les composantes annuelles fixes et l'amortissement temporel. + Dépenses + Expire + Expire le + Exporter les rapports de données de paie de Californie + Figer un traitement validé et télécharger ses fichiers CSV / XLSX et la feuille de travail du portail (sans cache, audité). + Id externe + Clé de ressource externe + Un id d'entrée de grille tarifaire ici permet aux estimations d'offres de valoriser les lignes véhicules et équipements par classe. + Source externe + Clé de travailleur externe + Les données enregistrées sont immuables : l'enregistrement crée une nouvelle version qui remplace l'actuelle. + profil de repli + FEIN + Coûts d'intervention + Coût chargé interne et marge pour les offres, interventions et déploiements — catégories agrégées uniquement. + Fichier + Courriel du contact + Nom du contact déclarant + Téléphone du contact + Filtrer + Indicateurs + Format + Figer + Figer et exporter + Figé + Carburant + Coût réel du carburant + Quantité de carburant + Unité de carburant + Siège + Adresse du siège + Chaque montant et multiplicateur est un champ protégé. Les estimations utilisent le profil de l'employé, puis le défaut du rôle, puis celui du service ; le coût approuvé par la paie l'emporte toujours. + La couverture est déclarée par vous, jamais déterminée par Resgrid. Les identifiants et adresses sont des champs protégés ; une valeur REDACTED laissée telle quelle conserve ce qui est stocké. + Hispanique ou latino + Taux horaire + Heures + Heures / jour + Heures / semaine + Heures / an + Type d'heures + Temps double + Autre + Heures supplémentaires + Congé payé + Régulières + Astreinte + Déplacement + Type d'identifiant + Heures d'attente + Importer les données annuelles (CSV) + Collez l'export de paie dans l'ordre canonique des colonnes. Un essai valide et rapproche chaque ligne avant toute validation ; les données importées arrivent non approuvées. + Résultat de l'import + Case 5 absente — case 1 utilisée (remarque requise) + ligne en double + revenus manquants + aucun emploi ne couvre l'année + rien à importer + heures déclarables manquantes ou nulles + travailleur introuvable + année invalide + Inclus + Actif d'inventaire + Fait partie d'une entreprise intégrée + Catégorie d'emploi CRD + L'une des dix catégories d'emploi CRD du profil de schéma vérifié. + Intitulé du poste + Prestataire de main-d'œuvre + Prestataires de main-d'œuvre + Raison sociale + Ligne + Lignes + Les lignes de personnel ne portent aucun taux en clair ; le détail valorisé est un champ protégé affiché seulement avec une autorisation valide. + Les lignes sont visibles avec Voir la rémunération ; le résumé ci-dessus est la vue agrégée. + Activité principale + Gérer la déclaration des données de paie de Californie + L'assistant de rapport CRD : traitements, instantanés, remplacements, agrégation, validation, remarques, observation de certification, corrections et fiches démographiques du responsable conformité. Chaque membre répond toujours à sa propre réponse sans ce droit. + Gérer les effectifs et la rémunération + Identité employeur, établissements, travailleurs, périodes d'emploi, affectations de poste, profils et composantes de rémunération, saisies de travail et imports de données annuelles. Les valeurs exigent toujours une autorisation valide. + Provenance de la correspondance + Marge sur coûts + Enregistrer la certification + Seulement ce que vous avez observé sur le portail du CRD après y avoir certifié ; Resgrid ne certifie jamais. + Profil d'autorité MARS + Classification Cal OES MARS + Taux horaire moyen + Taux horaire médian + Membre + Miles + Données manquantes + Ma réponse démographique + Répondez ou mettez à jour votre auto-identification volontaire pour la déclaration des données de paie de Californie. + NAICS + Nom + Nouveau traitement + Non + Sans réponse + Aucun fichier d'export — figez et exportez un traitement validé. + Aucune affectation de poste. + Aucun prestataire. + Aucune période d'emploi pour l'instant. + Aucun établissement pour l'instant. + Aucune donnée annuelle pour cette année et ce type de rapport. + Aucune ligne. + Aucun profil de rémunération. + Aucun profil de coût de ressource. + Pas encore de lignes agrégées — agrégez une fois les instantanés complets. + Aucun traitement pour l'instant. + Pas encore d'instantanés — construisez-les d'abord. + Aucun relevé d'utilisation. + Aucune saisie sur cette période. + Aucun travailleur pour l'instant. + Sur site + Aucun + des erreurs bloquantes subsistent + Pas encore validé. + perception de l'observateur + Traitements ouverts + Heures d'exploitation + Distance (relevée) + Autre + Frais généraux + Motif du remplacement + Nom du propriétaire + Heures de congé payé + paie + Tranche de rémunération + Base de paie + Journalier + Horaire + Salaire + Par poste + Indemnité + Différentiel + Formation + EMS + HazMat + Prime + Ancienneté + Autre + Spécialité + USAR + Annuel fixe + Par heure + Par période de paie + Par poste + % de la base + Composantes de paie + Différentiels, primes et indemnités de spécialité en plus de la base. Les composantes à période fixe ne sont pas payées sur les heures sup. sauf si marquées par heure sup. + Resgrid prépare et exporte ; il ne dépose jamais, ne décide jamais de votre assujettissement et ne certifie jamais. Tout sur ces écrans est un champ protégé servi sans cache. + Préparez les rapports CRD de Californie Payroll Employee et Labor Contractor Employee ; vous les déposez vous-même sur le portail du CRD. + Déclaration des données de paie + Traitement + Par heure sup. + Personnel + Rôle du personnel + Sert à choisir un profil de rémunération par défaut du rôle quand l'employé n'en a pas. + Phase + Incident + Mobilisation + Retour + Attente + Adresse + Feuille de travail du portail + Code postal + Coût chargé d'une journée régulière de 8 heures + aucun profil de schéma vérifié + Les identifiants, adresses, taux de rémunération, revenus, réponses démographiques et fichiers d'export sont des champs de protection avancée des données (catalogue 28). Ils s'affichent REDACTED sans autorisation valide et ne sont jamais mis en cache ni journalisés. + Provenance + D'où provient l'identité (contrat, W-9, fiche du portail). + Heures moyennes par jour + purgé + Quantité + Race / ethnie + Sélectionnez toutes les catégories applicables ; deux ou plus sont déclarées multiraciales. + Blanc + Noir ou afro-américain + Hawaïen ou autre insulaire du Pacifique + Asiatique + Amérindien ou autochtone d'Alaska + Moyen-oriental ou nord-africain + Taux + Multiplicateurs de taux + JSON par code de paie ; les codes absents utilisent 1,5 (heures sup.), 2 (double) et 1. + Préparation + prêt à figer + Motif + Rapproché + Équivalent horaire régulier + Remplacement facultatif ; sinon dérivé du montant de base et des heures standard. + Relation + Fin de la relation + Début de la relation + À distance en CA + À distance hors CA + Type de rapport + Rapport Labor Contractor Employee + Rapport Payroll Employee + Heures déclarables + Annuel fixe + Par jour + Par déploiement + Par heure moteur + Par heure d'attente + Par kilomètre + Par mile + Par heure d'exploitation + Consommables + Amortissement + Frais généraux fixes + Carburant / énergie + Assurance / immatriculation + Location + Maintenance + Autre + Stockage + Pneus / usure + Carburant (taux ou consommation × prix unitaire), maintenance (manuelle ou réel glissant des ordres de travail avec fenêtre de compteur), pneus, assurance, location, stockage, frais fixes et consommables. + Coûts des ressources + Amortissement, carburant, maintenance et coûts fixes par unité, actif ou classe de ressource externe. + Profils de ressources + Données numériques simples protégées par Voir les coûts internes — aucune personne n'est valorisée ici. + Calculé à partir de l'acquisition + Importé + Manuel + Réel glissant des ordres de travail + Utilisation des ressources + Ressources + Chiffre d'affaires + Total estimé de l'offre + Cal OES MARS approuvé + Cal OES MARS attendu + Cal OES MARS payé + Factures client + Aucun + Source de revenus + Vérification + coût de paie approuvé utilisé + profil de classe utilisé + composante non approuvée + devise différente + défaut de service utilisé + données d'amortissement manquantes + distances automatique et manuelle divergent + données carburant manquantes + aucun profil de rémunération + aucune période d'emploi pour le membre + aucun taux pour la base + aucun profil de ressource + multiplicateur du code de paie manquant (défaut utilisé) + réparation déjà facturée directement + défaut de rôle utilisé + fenêtre de maintenance glissante insuffisante + profil non approuvé + relevé d'utilisation à vérifier + utilisation annuelle manquante + Lignes + Estimer le coût de l'offre + Lignes d'offre × rémunération par défaut du service et profils de ressources par classe ; revenu = total estimé de l'offre. + Calculer le coût de l'intervention + Saisies de travail et relevés d'utilisation de l'intervention ; pas de revenu. + Calculer le coût du déploiement + Rapports de temps journaliers approuvés, relevés d'utilisation et dépenses jusqu'à la date. + Remarques explicatives + Jusqu'à 500 caractères ; requis quand les revenus case 1 ont été utilisés ou qu'une méthode substitutive s'applique. + Brouillon + Figé + À vérifier + Remplacé + Type de traitement + Réel + Estimation + Traitements + Valeur résiduelle + Enregistrer + La modification n'a pas pu être enregistrée. + Enregistrer la réponse + Enregistré. + Profil de schéma + Portée + Défaut de service + Employé + Défaut de rôle + SEIN (EDD) + Auto-identification volontaire + Vos réponses sont stockées séparément de votre dossier, chiffrées, et servent uniquement à préparer le rapport CRD agrégé. Personne dans le service ne les voit sur un autre écran. + auto-identifiés + Sexe + Féminin + Masculin + Non binaire + Taille + Ignoré + Instantané + Employés dans l'instantané + Fin de l'instantané + Début de l'instantané + Une seule période de paie entre + Instantanés employés + Une ligne par employé de Californie sur la période ; code démographique, revenus et taux horaire sont des champs protégés. Les remplacements exigent un motif et sont audités. + Code SOC + Version SOC + Numéro du Secretary of State de Californie + Source + Horamètre début + Odomètre début + Début + État + Statut + Certifié à l'externe + Corrigé + Brouillon + Exporté + Figé + Validé + Annulé + Sujet + Type de sujet + Classe externe + Actif d'inventaire + Unité + Résumé + Données annuelles de paie + Rémunération + Prestataires de main-d'œuvre + Calculs de coûts + Aperçu + Employeur + Établissements + Déclaration des données de paie + Coûts des ressources + Saisies de travail + Travailleurs + Jusqu'au + Fuseau horaire + Total + Coût chargé total + non approuvé + Unité + Prix unitaire + Exceptions non résolues + Mis à jour + Employés aux États-Unis + Relevés d'utilisation + Relevés d'odomètre, d'horamètre, d'heures et de carburant par unité et par jour ; la distance est stockée en miles et les relevés contradictoires sont mis en file de vérification. + Rapport de temps journalier + GPS + Traceur matériel + Import + Manuel + Durée de vie (mois) + Durée de vie (unités) + Miles, heures ou jours sur la durée de vie ; linéaire = (coût − résiduel) ÷ durée. + Valider + Validé le + Validation + donnée annuelle de paie manquante + donnée annuelle de paie non approuvée + affectations de poste qui se chevauchent + identité du prestataire manquante + statut d'assujettissement non déclaré + réponse démographique manquante ou refusée + W-2 case 1 utilisée à la place de la case 5 (remarque requise) + revenus manquants + les effectifs des lignes ne se rapprochent pas des instantanés + identité employeur incomplète (nom, FEIN, SEIN, adresse EDD) + adresse de l'établissement incomplète + établissement manquant + NAICS de l'établissement manquant + méthode substitutive utilisée (remarque requise) + le champ dépasse la longueur du modèle + l'export dépasserait la taille limite du portail + heures déclarables nulles + catégorie d'emploi manquante + catégorie d'emploi absente du profil de schéma + remplacement manuel appliqué + aucun employé dans l'instantané + perception de l'observateur utilisée pour le code démographique + le profil de schéma a changé depuis la création + les effectifs à distance ne se rapprochent pas du total + colonne obligatoire du modèle vide + lignes pas encore agrégées + période d'instantané hors de la fenêtre autorisée + semaines travaillées manquantes + mode de travail non résolu (aucune affectation) + Écart + Version + Voir les coûts internes + Résumés agrégés des coûts d'intervention et marges pour offres, interventions et déploiements — catégories et totaux, jamais une ligne, un taux ou une personne — plus profils de coût de ressources et relevés d'utilisation. + Voir la rémunération + Lire les profils de rémunération, saisies de travail, données annuelles et lignes de calculs de coûts. Les valeurs exigent toujours une autorisation valide. + Annuler + W-2 case 1 + W-2 case 5 + Avertissements + Déclaré l'année précédente + Semaines travaillées + Pourquoi nous demandons + L'article 12999 du Code du gouvernement de Californie impose aux employeurs assujettis de déclarer les données de paie regroupées par catégorie d'emploi, race/ethnie et sexe. Le rapport ne contient que des effectifs et taux agrégés ; il ne nomme personne. + 1. Construire les instantanés employés · 2. Agréger les lignes · 3. Valider · 4. Figer et exporter · puis attester sur le portail du CRD et enregistrer la certification ici. + Pays + Saisies de travail + Heures par travailleur et par jour issues de la paie, des déploiements et des interventions ; le coût approuvé par la paie, s'il existe, remplace toute estimation. + Lieu de travail + Mode de travail + Sur site + À distance hors Californie (affecté à un établissement de CA) + À distance en Californie + État / province + Travailleur + Les périodes d'emploi ne se chevauchent jamais ; chaque période porte ses affectations de poste dans le temps. + Type de travailleur + Travailleur indépendant + Employé d'un prestataire de main-d'œuvre + Employé salarié + Bénévole + Travailleurs + Chaque personne que le service rémunère ou déclare — membres et travailleurs externes — avec ses périodes d'emploi. + Effectifs + Les valeurs de niveau employeur que vous saisissez sur le portail du CRD avec le fichier téléversé. + Servi sans cache et audité ; fermez l'onglet une fois terminé. Resgrid ne se connecte jamais au portail. + Année + Oui + Le fichier d'export est introuvable. + Le fichier d'export a expiré ou a été purgé. + La référence de certification du portail est requise. + La source de collecte n'est pas valide. + Un traitement de correction existe déjà. + La réponse hispanique ou latino n'est pas valide. + Construisez d'abord les instantanés employés. + Aucun profil de schéma CRD vérifié ne couvre cette année. + Un code race/ethnie n'est pas valide. + Répondez race/ethnie ou refusez de répondre. + Un motif est requis. + Les remarques sont limitées à 500 caractères. + Un traitement certifié ne peut pas être annulé ; créez une correction. + Le traitement est figé ; créez plutôt une correction. + Seul un traitement exporté peut être marqué certifié. + Le traitement est introuvable. + Seul un traitement exporté ou certifié peut être corrigé. + Le code de sexe n'est pas valide. + Répondez sexe ou refusez de répondre. + L'instantané employé est introuvable. + La période d'instantané doit être une période de paie dans la fenêtre autorisée. + La validation signale encore des erreurs bloquantes. + Le traitement de déclaration n'a pas pu déchiffrer les valeurs protégées. + La raison sociale de l'entité affiliée est requise. + La base d'allocation n'est pas valide. + Un montant est négatif. + Un actif d'inventaire est requis. + L'affectation chevauche une autre affectation de cet emploi. + L'offre est introuvable. + Une catégorie ou base de composante n'est pas valide. + La raison sociale du prestataire est requise. + Un employé de prestataire a besoin d'un prestataire. + Le statut d'assujettissement n'est pas valide. + La date de fin précède la date de début. + Le déploiement est introuvable. + La raison sociale de l'employeur est requise. + L'emploi est introuvable. + La période chevauche un autre emploi de ce travailleur. + Un autre établissement utilise déjà ce code. + Un établissement a besoin d'un code et d'un nom. + L'établissement est introuvable. + Une clé de ressource externe est requise. + Les heures doivent être comprises entre 0 et 24. + Le type d'heures n'est pas valide. + La catégorie d'emploi n'est pas dans le profil de schéma. + Le NAICS doit comporter six chiffres. + Rien à chiffrer : aucune saisie de travail ni relevé d'utilisation. + L'enregistrement est introuvable. + La base de paie n'est pas valide. + La période chevauche un autre profil de même portée. + L'écriture protégée a été refusée. + Le type de rapport n'est pas valide. + Un défaut de rôle a besoin d'un rôle du personnel. + Le calcul est figé et ne peut pas être modifié. + La portée du profil n'est pas valide. + Le type de sujet n'est pas valide. + L'unité est introuvable. + Une unité est requise. + Un relevé d'utilisation a besoin d'un déploiement ou d'une intervention. + Le relevé d'utilisation n'est pas valide. + Le mode de travail n'est pas valide. + Ce membre a déjà une fiche travailleur. + Un travailleur a besoin d'un membre ou d'une clé externe. + Le type de travailleur n'est pas valide. + Le travailleur est introuvable. + Un travailleur est requis. + L'année déclarée n'est pas valide. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx new file mode 100644 index 00000000..030f0ed8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Costo di acquisizione + Data di acquisizione + Attivo + Attivo dal + Attivo fino al + Consuntivo + Ore effettivamente lavorate + Aggiungi affiliata + Aggiungi assegnazione + Aggiungi fornitore + Aggiungi predefinito dipartimento + Aggiungi rapporto di lavoro + Aggiungi sede + Aggiungi dato + Aggiungi profilo + Aggiungi profilo risorsa + Aggiungi predefinito ruolo + Aggiungi lettura + Aggiungi registrazione + Aggiungi lavoratore + Oppure aggiungi un lavoratore esterno con chiave paghe ed etichetta (entrambe protette). + Scegli un membro per creare la sua scheda lavoratore. + Indirizzo + Affiliata + Entità affiliate + Solo per un'impresa integrata: le altre entità coperte dal rapporto. + Aggrega righe + Base di allocazione + Per giorno + Per ora motore + Per chilometro + Per miglio + Per ora operativa + Importo + Dati annuali di paga + Redditi W-2, ore e settimane per rapporto di lavoro dell'anno di riferimento — l'input di ogni istantanea CRD. + Dati annuali di paga mancanti + Applica sostituzione + Approvazione + I profili non approvati valorizzano ancora le stime ma segnalano ogni riga per revisione. Ogni modifica di tariffa rimuove l'approvazione. + Approva profilo + Approvato + Costo paghe approvato + Il consuntivo dal sistema paghe; se presente sostituisce la stima di questo giorno. + File di esportazione + CSV e XLSX nell'ordine delle colonne del modello CRD, con checksum stabile per input invariato, scaricabili senza cache fino alla cancellazione dopo il periodo di conservazione. + Indietro + Importo base + Per ora, anno, giorno o turno secondo la base retributiva. + Base + Offerta + Ricavo di pareggio + Costruisci istantanee + Assegnato a una sede California + Entrambi + Non dipendente California + Lavora in California + Base California + Dipendenti in California + Intervento n. + Annulla + Tetto + Categoria + Consumabile + Spesa + Costi generali + Personale + Risorsa + Riferimento certificazione portale + Checksum + Città + Reddito allocato al cliente + Ore allocate al cliente + Settimane allocate al cliente + Chiave allocazione cliente + Codice + Fonte di raccolta + Registro del rapporto di lavoro + Come è stata ottenuta questa scheda; la percezione dell'osservatore è segnalata in ogni elaborazione. + Percezione dell'osservatore + Altro registro affidabile + Autoidentificato + Conferma importazione + Retribuzione + Profili retributivi per dipendente, ruolo e dipartimento con componenti di paga e costo datoriale. + Profilo retributivo + Completezza demografica + Componente + Componenti + Creare un'elaborazione di correzione? L'elaborazione esportata resta come depositata e viene contrassegnata come corretta. + Eliminare questo record? + Congelare questo calcolo? Un calcolo congelato è immutabile; un ricalcolo successivo lo sostituisce. + Confermare l'importazione? Ogni riga crea una nuova versione del dato. + Annullare questa elaborazione? I suoi file di esportazione vengono eliminati. + Consumabili + Consumo + Recapiti + Contesto + Offerta + Intervento + Dispiegamento + Fornitori di manodopera i cui dipendenti sono coperti dal secondo rapporto CRD. + Correggi dato (nuova versione) + Costi generali allocati + Contributi datoriali + Costo del lavoro fisso + Benefit sanitari + Altro + Altri benefit + Pensione + Assicurazione infortuni + Annuale fisso + Per giorno + Per ora + Per turno + % della paga ammissibile + Componenti di costo + Imposte sul lavoro, benefit, pensione e costi generali allocati — percentuale della paga ammissibile, per ora, per turno o annuale fisso, con tetto facoltativo. + Calcolo dei costi + Calcoli dei costi + Costo pieno interno e margine per offerte (stima), interventi e dispiegamenti (consuntivo). Il ricavo è sempre e solo un valore osservato. + Coperto — entrambi i rapporti + Coperto — rapporto Labor Contractor Employee + Coperto — rapporto Payroll Employee + Dichiarato dal datore di lavoro dopo aver letto le linee guida CRD; Resgrid non decide mai se dovete depositare. + Non coperto + Stato di copertura + Non dichiarato + Crea correzione + Crea elaborazione + Creato + Valuta + Risposta corrente + Dati retributivi protetti, costi interni di campo e rendicontazione dati retributivi della California. + Data + Giorni + Giorni lavorati + DBA + Preferisco non dichiarare + rifiutati + Sede predefinita + Profili predefiniti + I predefiniti di ruolo e dipartimento valorizzano le stime delle offerte e i membri senza profilo. + Elimina + Razza/etnia/sesso + Scheda demografica + Scheda del responsabile conformità per un lavoratore che non si è autoidentificato. + Usa prima i registri del rapporto di lavoro o altri affidabili; la percezione dell'osservatore è l'ultima risorsa ed è segnalata in ogni istantanea. È richiesto un motivo, verificato. + Risposte demografiche mancanti + Rispondere è volontario. Preferire non dichiarare è una risposta valida ed è dichiarata come tale. + Dipartimento + Giorni dispiegati + Dispiegamento + Id dispiegamento + Ammortamento + Ammortamento / unità + Etichetta + Distanza + Inserisci letture del contachilometri o una distanza; i chilometri sono memorizzati come miglia. + Unità di distanza + Download + Prova + Risultato della prova + Scadenza + Origine reddito + Allocato al cliente + W-2 casella 1 (alternativa) + W-2 casella 5 + Reddito utilizzato + Indirizzo EDD + Modifica assegnazione + Modifica fornitore + Modifica rapporto di lavoro + Modifica sede + Modifica profilo risorsa + Modifica lettura + Modifica registrazione + Efficace dal + Codici paga ammissibili + Profili del dipendente + Dipendenti + Datore di lavoro + Componenti di costo datoriale + costi datoriali + L'identità del datore di lavoro con cui viene depositata la dichiarazione CRD, con le entità affiliate. + Nessun profilo del datore di lavoro. + Profilo del datore di lavoro + Sezione datore di lavoro + Rapporto di lavoro + Tipo di impiego + Tempo pieno + Intermittente + Part-time + Sconosciuto + Rapporti di lavoro + Contaore finale + Contachilometri finale + Fine + Ore motore + Errori + Sede + Sedi + Ogni sede fisica a cui sono assegnati dipendenti; il rapporto CRD è depositato per sede. + Stima + Stima vs consuntivo + stimato + Eccezioni + Effettive + ferie retribuite + Giorni × ore medie + Metodo proxy esenti + Nessuno + Esenzione + Esente + Non esente + Sconosciuto + Utilizzo annuo previsto + In unità di allocazione all'anno; ripartisce componenti annuali fissi e ammortamento temporale. + Spese + Scade + Scade il + Esporta rapporti dati retributivi California + Congelare un'elaborazione validata e scaricare i file CSV / XLSX e il foglio di lavoro del portale (senza cache, verificato). + Id esterno + Chiave risorsa esterna + Un id di voce del listino qui consente alle stime delle offerte di valorizzare righe veicoli e attrezzature per classe. + Origine esterna + Chiave lavoratore esterno + I dati salvati sono immutabili: il salvataggio crea una nuova versione che sostituisce quella corrente. + profilo alternativo + FEIN + Costi di campo + Costo pieno interno e margine per offerte, interventi e dispiegamenti — solo categorie aggregate. + File + E-mail referente + Nome referente per il deposito + Telefono referente + Filtra + Contrassegni + Formato + Congela + Congela ed esporta + Congelato + Carburante + Costo reale carburante + Quantità carburante + Unità carburante + Sede centrale + Indirizzo sede centrale + Ogni importo e moltiplicatore è un campo protetto. Le stime usano il profilo del dipendente, poi il predefinito del ruolo, poi quello del dipartimento; il costo approvato dalle paghe prevale sempre. + La copertura è dichiarata da voi, mai determinata da Resgrid. Identificativi e indirizzi sono campi protetti; un valore REDACTED lasciato invariato conserva quanto memorizzato. + Ispanico o latino + Tariffa oraria + Ore + Ore / giorno + Ore / settimana + Ore / anno + Tipo di ore + Doppio + Altro + Straordinario + Ferie retribuite + Ordinarie + Reperibilità + Viaggio + Tipo identificativo + Ore di inattività + Importa dati annuali (CSV) + Incolla l'esportazione paghe nell'ordine canonico delle colonne. Una prova valida e riconcilia ogni riga prima di confermare; i dati importati arrivano non approvati. + Risultato importazione + Casella 5 assente — usata casella 1 (nota richiesta) + riga duplicata + reddito mancante + nessun rapporto copre l'anno + niente da importare + ore dichiarabili mancanti o zero + lavoratore non trovato + anno non valido + Incluso + Bene di inventario + Parte di un'impresa integrata + Categoria professionale CRD + Una delle dieci categorie professionali CRD del profilo di schema revisionato. + Titolo mansione + Fornitore di manodopera + Fornitori di manodopera + Ragione sociale + Riga + Righe + Le righe del personale non riportano la tariffa in chiaro; il dettaglio valorizzato è un campo protetto mostrato solo con un'autorizzazione valida. + Le righe sono visibili con Visualizza retribuzione; il riepilogo sopra è la vista aggregata. + Attività principale + Gestisci rendicontazione dati retributivi California + La procedura guidata CRD: elaborazioni, istantanee, sostituzioni, aggregazione, validazione, note, osservazione della certificazione, correzioni e le schede demografiche del responsabile conformità. Ogni membro risponde sempre per sé senza questo diritto. + Gestisci personale e retribuzione + Identità datore di lavoro, sedi, lavoratori, periodi di lavoro, assegnazioni di mansione, profili e componenti retributivi, registrazioni di lavoro e importazioni di dati annuali. I valori richiedono comunque un'autorizzazione valida. + Provenienza della mappatura + Margine di contribuzione + Registra certificazione + Solo ciò che hai osservato nel portale CRD dopo aver certificato lì; Resgrid non certifica mai. + Profilo di autorità MARS + Classificazione Cal OES MARS + Tariffa oraria media + Tariffa oraria mediana + Membro + Miglia + Input mancanti + La mia risposta demografica + Rispondi o aggiorna la tua autoidentificazione volontaria per la rendicontazione dei dati retributivi della California. + NAICS + Nome + Nuova elaborazione + No + Nessuna risposta + Nessun file di esportazione — congela ed esporta un'elaborazione validata. + Nessuna assegnazione di mansione. + Nessun fornitore di manodopera. + Nessun periodo di lavoro. + Nessuna sede. + Nessun dato annuale per questo anno e tipo di rapporto. + Nessuna riga. + Nessun profilo retributivo. + Nessun profilo di costo risorsa. + Nessuna riga aggregata — aggrega quando le istantanee sono complete. + Nessuna elaborazione. + Nessuna istantanea — costruiscile prima. + Nessuna lettura di utilizzo. + Nessuna registrazione in questo periodo. + Nessun lavoratore. + In presenza + Nessuno + restano errori bloccanti + Non ancora validato. + percezione dell'osservatore + Elaborazioni aperte + Ore operative + Distanza (rilevata) + Altro + Costi generali + Motivo della sostituzione + Nome del proprietario + Ore di ferie retribuite + paga + Fascia retributiva + Base retributiva + Giornaliera + Oraria + Stipendio + Per turno + Indennità + Differenziale + Istruzione + EMS + HazMat + Incentivo + Anzianità + Altro + Specialità + USAR + Annuale fisso + Per ora + Per periodo di paga + Per turno + % della base + Componenti di paga + Differenziali, incentivi e indennità di specialità oltre la base. I componenti a periodo fisso non sono pagati sullo straordinario se non contrassegnati per ora di straordinario. + Resgrid prepara ed esporta; non deposita mai, non decide mai se siete coperti e non certifica mai. Tutto in queste schermate è un campo protetto servito senza cache. + Prepara i rapporti CRD della California Payroll Employee e Labor Contractor Employee; li depositi tu stesso nel portale CRD. + Rendicontazione dati retributivi + Elaborazione + Per ora straord. + Personale + Ruolo del personale + Usato per scegliere un profilo retributivo predefinito del ruolo quando il dipendente non ne ha uno. + Fase + Intervento + Mobilitazione + Rientro + Attesa + Indirizzo + Foglio di lavoro portale + CAP + Costo pieno di una giornata regolare di 8 ore + nessun profilo di schema revisionato + Identificativi, indirizzi, tariffe retributive, redditi, risposte demografiche e file di esportazione sono campi di protezione avanzata dei dati (catalogo 28). Senza un'autorizzazione valida appaiono come REDACTED e non vengono mai memorizzati in cache né registrati. + Provenienza + Da dove proviene l'identità (contratto, W-9, record del portale). + Ore medie al giorno + eliminato + Quantità + Razza / etnia + Seleziona tutte le categorie applicabili; due o più sono dichiarate multirazziali. + Bianco + Nero o afroamericano + Nativo hawaiano o altro isolano del Pacifico + Asiatico + Nativo americano o dell'Alaska + Mediorientale o nordafricano + Tariffa + Moltiplicatori di tariffa + JSON per codice paga; i codici mancanti usano 1,5 (straordinario), 2 (doppio) e 1. + Prontezza + pronto per il congelamento + Motivo + Riconciliato + Equivalente orario regolare + Sostituzione facoltativa; altrimenti derivato da importo base e ore standard. + Rapporto + Fine rapporto + Inizio rapporto + Da remoto in CA + Da remoto fuori CA + Tipo di rapporto + Rapporto Labor Contractor Employee + Rapporto Payroll Employee + Ore dichiarabili + Annuale fisso + Per giorno + Per dispiegamento + Per ora motore + Per ora di inattività + Per chilometro + Per miglio + Per ora operativa + Consumabili + Ammortamento + Costi generali fissi + Carburante / energia + Assicurazione / licenze + Leasing / noleggio + Manutenzione + Altro + Deposito + Pneumatici / usura + Carburante (tariffa o consumo × prezzo unitario), manutenzione (manuale o consuntivo rolling degli ordini di lavoro con finestra contatore), pneumatici, assicurazione, leasing, deposito, costi fissi e consumabili. + Costi delle risorse + Ammortamento, carburante, manutenzione e costi fissi per unità, bene o classe di risorsa esterna. + Profili delle risorse + Dati numerici semplici protetti da Visualizza costi interni — nessuna persona è valorizzata qui. + Calcolato dall'acquisizione + Importato + Manuale + Consuntivo rolling ordini di lavoro + Utilizzo risorse + Risorse + Ricavo + Totale stimato offerta + Cal OES MARS approvato + Cal OES MARS atteso + Cal OES MARS pagato + Fatture cliente + Nessuno + Fonte del ricavo + Revisione + usato costo paghe approvato + usato profilo di classe + componente non approvato + valuta non corrispondente + usato predefinito dipartimento + dati ammortamento mancanti + distanza automatica e manuale discordanti + dati carburante mancanti + nessun profilo retributivo + nessun periodo di lavoro per il membro + nessuna tariffa per la base + nessun profilo risorsa + moltiplicatore codice paga mancante (usato predefinito) + riparazione già addebitata direttamente + usato predefinito ruolo + finestra manutenzione rolling insufficiente + profilo non approvato + lettura di utilizzo da rivedere + utilizzo annuo mancante + Righe + Stima costo offerta + Righe offerta × retribuzione predefinita del dipartimento e profili risorsa per classe; ricavo = totale stimato dell'offerta. + Calcola costo intervento + Registrazioni di lavoro e letture di utilizzo dell'intervento; nessun ricavo. + Calcola costo dispiegamento + Rapporti giornalieri approvati, letture di utilizzo e spese fino alla data. + Note esplicative + Fino a 500 caratteri; richieste quando è stato usato il reddito casella 1 o si applica un proxy esenti. + Bozza + Congelato + Da rivedere + Sostituito + Tipo elaborazione + Consuntivo + Stima + Elaborazioni + Valore residuo + Salva + Impossibile salvare la modifica. + Salva risposta + Salvato. + Profilo di schema + Ambito + Predefinito dipartimento + Dipendente + Predefinito ruolo + SEIN (EDD) + Autoidentificazione volontaria + Le tue risposte sono conservate separatamente dal tuo fascicolo, cifrate, e usate solo per preparare il rapporto CRD aggregato. Nessuno nel dipartimento le vede su altre schermate. + autoidentificati + Sesso + Femminile + Maschile + Non binario + Dimensione + Saltato + Istantanea + Dipendenti nell'istantanea + Fine istantanea + Inizio istantanea + Un singolo periodo di paga tra + Istantanee dipendenti + Una riga per dipendente della California nel periodo; codice demografico, reddito e tariffa oraria sono campi protetti. Le sostituzioni richiedono un motivo e sono verificate. + Codice SOC + Versione SOC + Numero Secretary of State CA + Origine + Contaore iniziale + Contachilometri iniziale + Inizio + Stato + Stato + Certificato esternamente + Corretto + Bozza + Esportato + Congelato + Validato + Annullato + Soggetto + Tipo di soggetto + Classe esterna + Bene di inventario + Unità + Riepilogo + Dati annuali di paga + Retribuzione + Fornitori di manodopera + Calcoli dei costi + Panoramica + Datore di lavoro + Sedi + Rendicontazione dati retributivi + Costi delle risorse + Registrazioni di lavoro + Lavoratori + Fino al + Fuso orario + Totale + Costo pieno totale + non approvato + Unità + Prezzo unitario + Eccezioni irrisolte + Aggiornato + Dipendenti negli USA + Registrazioni di utilizzo + Letture di contachilometri, contaore, ore e carburante per unità e giorno; la distanza è memorizzata in miglia e le letture in conflitto vanno in revisione. + Rapporto giornaliero + GPS + Tracker hardware + Importazione + Manuale + Vita utile (mesi) + Vita utile (unità) + Miglia, ore o giorni sulla vita; lineare = (costo − residuo) ÷ vita. + Valida + Validato il + Validazione + dato annuale di paga mancante + dato annuale di paga non approvato + assegnazioni di mansione sovrapposte + identità fornitore di manodopera mancante + stato di copertura non dichiarato + risposta demografica mancante o rifiutata + usata W-2 casella 1 invece della 5 (nota richiesta) + reddito mancante + i conteggi delle righe non quadrano con le istantanee + identità datore di lavoro incompleta (nome, FEIN, SEIN, indirizzo EDD) + indirizzo sede incompleto + sede mancante + NAICS sede mancante + usato proxy ore esenti (nota richiesta) + il campo supera la lunghezza del modello + l'esportazione supererebbe il limite di dimensione del portale + ore dichiarabili pari a zero + categoria professionale mancante + categoria professionale non nel profilo di schema + sostituzione manuale applicata + nessun dipendente nell'istantanea + percezione dell'osservatore usata per il codice demografico + il profilo di schema è cambiato dalla creazione + i conteggi remoti non quadrano con il totale dipendenti + colonna obbligatoria del modello vuota + righe non ancora aggregate + periodo istantanea fuori dalla finestra consentita + settimane lavorate mancanti + modalità di lavoro non risolta (nessuna assegnazione) + Scostamento + Versione + Visualizza costi interni + Riepiloghi aggregati dei costi di campo e margini per offerte, interventi e dispiegamenti — categorie e totali, mai una riga, una tariffa o una persona — più profili di costo risorse e letture di utilizzo. + Visualizza retribuzione + Leggere profili retributivi, registrazioni di lavoro, dati annuali e righe dei calcoli dei costi. I valori richiedono comunque un'autorizzazione valida. + Annulla + W-2 casella 1 + W-2 casella 5 + Avvisi + Dichiarato l'anno precedente + Settimane lavorate + Perché lo chiediamo + La sezione 12999 del Codice del governo della California impone ai datori di lavoro coperti di dichiarare i dati retributivi raggruppati per categoria professionale, razza/etnia e sesso. Il rapporto contiene solo conteggi e tariffe aggregati; non nomina mai nessuno. + 1. Costruisci le istantanee dei dipendenti · 2. Aggrega le righe · 3. Valida · 4. Congela ed esporta · poi attesta nel portale CRD e registra qui la certificazione. + Paese + Registrazioni di lavoro + Ore per lavoratore e giorno da paghe, dispiegamenti e interventi; il costo approvato dalle paghe, se presente, sostituisce ogni stima. + Luogo di lavoro + Modalità di lavoro + In presenza + Da remoto fuori California (assegnato a sede CA) + Da remoto in California + Stato / provincia + Lavoratore + I periodi di lavoro non si sovrappongono mai; ogni periodo porta le sue assegnazioni di mansione nel tempo. + Tipo di lavoratore + Collaboratore indipendente + Dipendente di fornitore di manodopera + Dipendente a libro paga + Volontario + Lavoratori + Ogni persona che il dipartimento paga o dichiara — membri e lavoratori esterni — con i periodi di lavoro. + Personale + I valori a livello di datore di lavoro che inserisci nel portale CRD insieme al file caricato. + Servito senza cache e verificato; chiudi la scheda al termine. Resgrid non accede mai al portale. + Anno + + Il file di esportazione non è stato trovato. + Il file di esportazione è scaduto o è stato eliminato. + Il riferimento di certificazione del portale è obbligatorio. + La fonte di raccolta non è valida. + Esiste già un'elaborazione di correzione. + La risposta ispanico o latino non è valida. + Costruisci prima le istantanee dei dipendenti. + Nessun profilo di schema CRD revisionato copre quell'anno. + Un codice razza/etnia non è valido. + Rispondi razza/etnia o dichiara di non voler rispondere. + È richiesto un motivo. + Le note sono limitate a 500 caratteri. + Un'elaborazione certificata non può essere annullata; crea una correzione. + L'elaborazione è congelata; crea invece una correzione. + Solo un'elaborazione esportata può essere contrassegnata come certificata. + L'elaborazione non è stata trovata. + Solo un'elaborazione esportata o certificata può essere corretta. + Il codice sesso non è valido. + Rispondi sesso o dichiara di non voler rispondere. + L'istantanea del dipendente non è stata trovata. + Il periodo di istantanea deve essere un periodo di paga nella finestra consentita. + La validazione segnala ancora errori bloccanti. + L'elaborazione non ha potuto decifrare i valori protetti. + La ragione sociale dell'affiliata è obbligatoria. + La base di allocazione non è valida. + Un importo è negativo. + È richiesto un bene di inventario. + L'assegnazione si sovrappone a un'altra di questo rapporto. + L'offerta non è stata trovata. + Una categoria o base di componente non è valida. + La ragione sociale del fornitore è obbligatoria. + Un dipendente di fornitore richiede un fornitore di manodopera. + Lo stato di copertura non è valido. + La data di fine precede quella di inizio. + Il dispiegamento non è stato trovato. + La ragione sociale del datore di lavoro è obbligatoria. + Il rapporto di lavoro non è stato trovato. + Il periodo si sovrappone a un altro rapporto di questo lavoratore. + Un'altra sede usa già quel codice. + Una sede richiede codice e nome. + La sede non è stata trovata. + È richiesta una chiave di risorsa esterna. + Le ore devono essere comprese tra 0 e 24. + Il tipo di ore non è valido. + La categoria professionale non è nel profilo di schema. + Il NAICS deve avere sei cifre. + Niente da valorizzare: nessuna registrazione di lavoro o lettura di utilizzo. + Il record non è stato trovato. + La base retributiva non è valida. + Il periodo si sovrappone a un altro profilo dello stesso ambito. + La scrittura protetta è stata rifiutata. + Il tipo di rapporto non è valido. + Un predefinito di ruolo richiede un ruolo del personale. + L'elaborazione è congelata e non può essere modificata. + L'ambito del profilo non è valido. + Il tipo di soggetto non è valido. + L'unità non è stata trovata. + È richiesta un'unità. + Una lettura di utilizzo richiede un dispiegamento o un intervento. + La lettura di utilizzo non è valida. + La modalità di lavoro non è valida. + Quel membro ha già una scheda lavoratore. + Un lavoratore richiede un membro o una chiave esterna. + Il tipo di lavoratore non è valido. + Il lavoratore non è stato trovato. + È richiesto un lavoratore. + L'anno di riferimento non è valido. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx new file mode 100644 index 00000000..580dc290 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Koszt nabycia + Data nabycia + Aktywny + Aktywny od + Aktywny do + Rzeczywiste + Rzeczywiste godziny pracy + Dodaj podmiot powiązany + Dodaj przypisanie + Dodaj agencję + Dodaj domyślny jednostki + Dodaj zatrudnienie + Dodaj zakład + Dodaj dane + Dodaj profil + Dodaj profil zasobu + Dodaj domyślny roli + Dodaj odczyt + Dodaj wpis + Dodaj pracownika + Lub dodaj pracownika zewnętrznego po kluczu płacowym i etykiecie (oba chronione). + Wybierz członka, aby utworzyć jego rekord pracownika. + Adres + Podmiot powiązany + Podmioty powiązane + Tylko dla przedsiębiorstwa zintegrowanego: pozostałe podmioty objęte raportem. + Zagreguj wiersze + Podstawa alokacji + Za dzień + Za motogodzinę + Za kilometr + Za milę + Za godzinę pracy + Kwota + Roczne dane płacowe + Zarobki W-2, godziny i tygodnie na zatrudnienie za rok sprawozdawczy — wejście każdej migawki CRD. + Brakujące roczne dane płacowe + Zastosuj nadpisanie + Zatwierdzenie + Niezatwierdzone profile nadal wyceniają szacunki, ale oznaczają każdą pozycję do przeglądu. Każda zmiana stawki cofa zatwierdzenie. + Zatwierdź profil + Zatwierdzony + Zatwierdzony koszt płacowy + Rzeczywista wartość z systemu płac; jeśli istnieje, zastępuje szacunek dla tego dnia. + Pliki eksportu + CSV i XLSX w kolejności kolumn szablonu CRD, ze stałą sumą kontrolną dla niezmienionych danych, pobierane bez buforowania do usunięcia po okresie przechowywania. + Wstecz + Kwota bazowa + Za godzinę, rok, dzień lub zmianę zgodnie z podstawą płacy. + Podstawa + Oferta + Przychód progu rentowności + Zbuduj migawki + Przypisany do zakładu w Kalifornii + Oba + Nie pracownik kalifornijski + Pracuje w Kalifornii + Podstawa kalifornijska + Pracownicy w Kalifornii + Nr wezwania + Anuluj + Limit + Kategoria + Materiał + Wydatek + Koszty ogólne + Personel + Zasób + Numer certyfikacji z portalu + Suma kontrolna + Miasto + Zarobki przypisane klientowi + Godziny przypisane klientowi + Tygodnie przypisane klientowi + Klucz alokacji klienta + Kod + Źródło zebrania + Akta zatrudnienia + Jak uzyskano ten rekord; percepcja obserwatora jest oznaczana w każdym przebiegu. + Percepcja obserwatora + Inne wiarygodne źródło + Samoidentyfikacja + Zatwierdź import + Wynagrodzenie + Profile wynagrodzeń pracownika, roli i jednostki ze składnikami płacowymi i kosztów pracodawcy. + Profil wynagrodzenia + Kompletność demograficzna + Składnik + Składniki + Utworzyć przebieg korekty? Wyeksportowany przebieg pozostaje bez zmian i zostaje oznaczony jako skorygowany. + Usunąć ten rekord? + Zamrozić ten przebieg? Zamrożony przebieg jest niezmienny; późniejsze przeliczenie go zastępuje. + Zatwierdzić import? Każdy wiersz tworzy nową wersję danych. + Unieważnić ten przebieg? Jego pliki eksportu zostaną usunięte. + Materiały + Zużycie + Dane kontaktowe + Kontekst + Oferta + Wezwanie + Rozmieszczenie + Agencje dostarczające pracowników objętych drugim raportem CRD. + Popraw dane (nowa wersja) + Alokowane koszty ogólne + Podatek płacowy pracodawcy + Stały koszt pracy + Świadczenia zdrowotne + Inne + Inne świadczenia + Emerytura + Ubezpieczenie wypadkowe + Stała roczna + Za dzień + Za godzinę + Za zmianę + % kwalifikowanej płacy + Składniki kosztów + Podatki od płac, świadczenia, emerytura i alokowane koszty ogólne — procent kwalifikowanej płacy, za godzinę, za zmianę lub stała roczna, z opcjonalnym limitem. + Przebieg kosztów + Przebiegi kosztów + Wewnętrzny koszt pełny i marża dla ofert (szacunek), wezwań i rozmieszczeń (rzeczywiste). Przychód jest zawsze tylko wartością zaobserwowaną. + Objęty — oba raporty + Objęty — raport Labor Contractor Employee + Objęty — raport Payroll Employee + Deklarowane przez pracodawcę po zapoznaniu się z wytycznymi CRD; Resgrid nigdy nie decyduje, czy musisz składać raport. + Nieobjęty + Status objęcia obowiązkiem + Nie zadeklarowano + Utwórz korektę + Utwórz przebieg + Utworzono + Waluta + Bieżąca odpowiedź + Chronione dane płacowe, wewnętrzne kosztorysowanie działań i raportowanie danych płacowych Kalifornii. + Data + Dni + Przepracowane dni + DBA + Odmawiam odpowiedzi + odmówili + Domyślny zakład + Profile domyślne + Domyślne profile roli i jednostki wyceniają szacunki ofert i członków bez własnego profilu. + Usuń + Rasa/pochodzenie/płeć + Rekord demograficzny + Rekord specjalisty ds. zgodności dla pracownika, który nie dokonał samoidentyfikacji. + Najpierw korzystaj z akt zatrudnienia lub innych wiarygodnych źródeł; percepcja obserwatora jest ostatecznością i jest oznaczana w każdej migawce. Powód jest wymagany i audytowany. + Brakujące odpowiedzi demograficzne + Odpowiedź jest dobrowolna. Odmowa odpowiedzi jest ważną odpowiedzią i tak jest raportowana. + Jednostka + Dni rozmieszczenia + Rozmieszczenie + Id rozmieszczenia + Amortyzacja + Amortyzacja / jednostka + Etykieta + Odległość + Wprowadź odczyty licznika lub odległość; kilometry zapisywane są jako mile. + Jednostka odległości + Pobrania + Próba + Wynik próby + Termin + Źródło zarobków + Przypisane klientowi + W-2 pole 1 (zastępczo) + W-2 pole 5 + Użyte zarobki + Adres EDD + Edytuj przypisanie + Edytuj agencję + Edytuj zatrudnienie + Edytuj zakład + Edytuj profil zasobu + Edytuj odczyt + Edytuj wpis + Obowiązuje od + Kwalifikowane kody płacy + Profile pracownika + Pracownicy + Pracodawca + Składniki kosztów pracodawcy + koszty pracodawcy + Tożsamość pracodawcy, pod którą składany jest raport CRD, wraz z podmiotami powiązanymi. + Brak profilu pracodawcy. + Profil pracodawcy + Sekcja pracodawcy + Zatrudnienie + Rodzaj zatrudnienia + Pełny etat + Dorywczy + Niepełny etat + Nieznany + Zatrudnienia + Motogodziny koniec + Licznik końcowy + Koniec + Motogodziny + Błędy + Zakład + Zakłady + Każda fizyczna lokalizacja, do której przypisani są pracownicy; raport CRD składany jest per zakład. + Szacunek + Szacunek a rzeczywiste + szacowane + Wyjątki + Rzeczywiste + płatny urlop + Dni × średnie godziny + Metoda zastępcza (zwolnieni) + Brak + Zwolnienie + Zwolniony + Niezwolniony + Nieznany + Oczekiwane roczne wykorzystanie + W jednostkach alokacji rocznie; rozkłada stałe składniki roczne i amortyzację czasową. + Wydatki + Wygasa + Wygasa + Eksport raportów danych płacowych Kalifornii + Zamrożenie zwalidowanego przebiegu i pobranie plików CSV / XLSX oraz arkusza portalu (bez buforowania, audytowane). + Id zewnętrzne + Klucz zasobu zewnętrznego + Id pozycji cennika pozwala szacunkom ofert wyceniać pozycje pojazdów i sprzętu według klasy. + Źródło zewnętrzne + Klucz pracownika zewnętrznego + Zapisane dane są niezmienne: zapis tworzy nową wersję zastępującą bieżącą. + profil zastępczy + FEIN + Kosztorysowanie działań + Wewnętrzny koszt pełny i marża dla ofert, wezwań i rozmieszczeń — tylko kategorie zbiorcze. + Plik + E-mail osoby składającej + Nazwisko osoby składającej + Telefon osoby składającej + Filtruj + Znaczniki + Format + Zamroź + Zamroź i wyeksportuj + Zamrożony + Paliwo + Rzeczywisty koszt paliwa + Ilość paliwa + Jednostka paliwa + Siedziba główna + Adres siedziby głównej + Każda kwota i mnożnik to pole chronione. Szacunki korzystają z profilu pracownika, potem domyślnego roli, potem jednostki; zatwierdzony koszt z systemu płac zawsze ma pierwszeństwo. + Objęcie obowiązkiem deklarujesz samodzielnie; Resgrid nigdy go nie ustala. Identyfikatory i adresy to pola chronione; niezmieniona wartość REDACTED zachowuje zapisane dane. + Latynoskie pochodzenie + Stawka godzinowa + Godziny + Godziny / dzień + Godziny / tydzień + Godziny / rok + Rodzaj godzin + Podwójne + Inne + Nadgodziny + Płatny urlop + Zwykłe + Dyżur + Podróż + Typ identyfikatora + Godziny postoju + Importuj roczne dane (CSV) + Wklej eksport z płac w kanonicznej kolejności kolumn. Próba sprawdza i uzgadnia każdy wiersz przed zapisem; zaimportowane dane są niezatwierdzone. + Wynik importu + Brak pola 5 — użyto pola 1 (wymagana uwaga) + zduplikowany wiersz + brak zarobków + żadne zatrudnienie nie obejmuje roku + nic do zaimportowania + brak godzin raportowanych lub zero + nie znaleziono pracownika + nieprawidłowy rok + Uwzględniony + Zasób inwentarzowy + Część przedsiębiorstwa zintegrowanego + Kategoria zawodowa CRD + Jedna z dziesięciu kategorii zawodowych CRD zweryfikowanego profilu schematu. + Stanowisko + Agencja pracy + Agencje pracy + Nazwa prawna + Wiersz + Pozycje + Pozycje personelu nie zawierają stawki jawnie; wyceniony szczegół to pole chronione widoczne tylko z ważnym uprawnieniem. + Pozycje widoczne z uprawnieniem Podgląd wynagrodzeń; podsumowanie powyżej to widok zbiorczy. + Główna działalność + Zarządzanie raportowaniem danych płacowych Kalifornii + Kreator raportu CRD: przebiegi, migawki, nadpisania, agregacja, walidacja, uwagi, odnotowanie certyfikacji, korekty i rekordy demograficzne specjalisty ds. zgodności. Każdy członek zawsze odpowiada za siebie bez tego uprawnienia. + Zarządzanie kadrami i wynagrodzeniem + Tożsamość pracodawcy, zakłady, pracownicy, okresy zatrudnienia, przypisania stanowisk, profile i składniki wynagrodzeń, wpisy pracy i importy rocznych danych. Wartości nadal wymagają ważnego uprawnienia. + Pochodzenie mapowania + Marża pokrycia + Zapisz certyfikację + Tylko to, co zaobserwowano w portalu CRD po certyfikacji; Resgrid nigdy nie certyfikuje. + Profil autorytetu MARS + Klasyfikacja Cal OES MARS + Średnia stawka godzinowa + Mediana stawki godzinowej + Członek + Mile + Brakujące dane + Moje dane demograficzne + Odpowiedz lub zaktualizuj dobrowolną samoidentyfikację na potrzeby raportowania danych płacowych Kalifornii. + NAICS + Nazwa + Nowy przebieg + Nie + Brak odpowiedzi + Brak plików eksportu — zamroź i wyeksportuj zwalidowany przebieg. + Brak przypisań stanowisk. + Brak agencji pracy. + Brak okresów zatrudnienia. + Brak zakładów. + Brak rocznych danych dla tego roku i typu raportu. + Brak pozycji. + Brak profili wynagrodzeń. + Brak profili kosztów zasobów. + Brak wierszy zbiorczych — zagreguj po ukończeniu migawek. + Brak przebiegów. + Brak migawek — najpierw je zbuduj. + Brak odczytów. + Brak wpisów w tym okresie. + Brak pracowników. + Stacjonarni + Brak + pozostają błędy blokujące + Jeszcze nie zwalidowano. + percepcja obserwatora + Otwarte przebiegi + Godziny pracy + Odległość (odczytana) + Inne + Koszty ogólne + Powód nadpisania + Nazwa właściciela + Godziny płatnego urlopu + płaca + Przedział płacowy + Podstawa płacy + Dzienna + Godzinowa + Pensja + Za zmianę + Stypendium + Dodatek + Wykształcenie + EMS + HazMat + Zachęta + Staż + Inne + Specjalizacja + USAR + Stała roczna + Za godzinę + Za okres rozliczeniowy + Za zmianę + % podstawy + Składniki płacy + Dodatki, zachęty i płace specjalistyczne ponad podstawę. Składniki stałookresowe nie są płacone za nadgodziny, chyba że oznaczono je na godzinę nadliczbową. + Resgrid przygotowuje i eksportuje; nigdy nie składa, nie decyduje o objęciu obowiązkiem i nie certyfikuje. Wszystko na tych ekranach to pole chronione serwowane bez buforowania. + Przygotuj kalifornijskie raporty CRD Payroll Employee i Labor Contractor Employee; składasz je samodzielnie w portalu CRD. + Raportowanie danych płacowych + Przebieg raportu + Za godz. nadl. + Personel + Rola personelu + Używane do wyboru domyślnego profilu wynagrodzenia roli, gdy pracownik nie ma własnego. + Faza + Zdarzenie + Mobilizacja + Powrót + Oczekiwanie + Adres + Arkusz portalu + Kod pocztowy + Koszt pełny zwykłego 8-godzinnego dnia + brak zweryfikowanego profilu schematu + Identyfikatory, adresy, stawki płac, zarobki, odpowiedzi demograficzne i pliki eksportu to pola zaawansowanej ochrony danych (katalog 28). Bez ważnego uprawnienia wyświetlają się jako REDACTED i nigdy nie są buforowane ani logowane. + Pochodzenie + Skąd pochodzi tożsamość (umowa, W-9, wpis w portalu). + Średnie godziny dziennie + usunięto + Ilość + Rasa / pochodzenie etniczne + Zaznacz wszystkie pasujące kategorie; dwie lub więcej raportowane są jako wielorasowe. + Biały + Czarny lub Afroamerykanin + Rdzenny Hawajczyk lub mieszkaniec wysp Pacyfiku + Azjata + Rdzenny Amerykanin lub rdzenny mieszkaniec Alaski + Bliskowschodni lub północnoafrykański + Stawka + Mnożniki stawek + JSON według kodu płacy; brakujące kody używają 1,5 (nadgodziny), 2 (podwójny) i 1. + Gotowość + gotowy do zamrożenia + Powód + Uzgodniony + Ekwiwalent godzinowy + Opcjonalne nadpisanie; w przeciwnym razie wyliczane z kwoty bazowej i godzin standardowych. + Relacja + Koniec relacji + Początek relacji + Zdalnie w CA + Zdalnie poza CA + Typ raportu + Raport Labor Contractor Employee + Raport Payroll Employee + Godziny raportowane + Stała roczna + Za dzień + Za rozmieszczenie + Za motogodzinę + Za godzinę postoju + Za kilometr + Za milę + Za godzinę pracy + Materiały eksploatacyjne + Amortyzacja + Stałe koszty ogólne + Paliwo / energia + Ubezpieczenie / rejestracja + Leasing / wynajem + Konserwacja + Inne + Magazynowanie + Opony / zużycie + Paliwo (stawka lub zużycie × cena jednostkowa), konserwacja (ręczna lub rzeczywista ze zleceń z oknem licznika), opony, ubezpieczenie, leasing, magazynowanie, koszty stałe i materiały. + Koszty zasobów + Amortyzacja, paliwo, konserwacja i koszty stałe na jednostkę, zasób lub klasę zasobu zewnętrznego. + Profile zasobów + Zwykłe dane liczbowe chronione uprawnieniem Podgląd kosztów wewnętrznych — nikt nie jest tu wyceniany. + Obliczone z nabycia + Zaimportowane + Ręczne + Rzeczywiste ze zleceń (kroczące) + Wykorzystanie zasobów + Zasoby + Przychód + Szacowana suma oferty + Cal OES MARS zatwierdzone + Cal OES MARS oczekiwane + Cal OES MARS zapłacone + Faktury klienta + Brak + Źródło przychodu + Przegląd + użyto zatwierdzonego kosztu płac + użyto profilu klasy + składnik niezatwierdzony + niezgodność waluty + użyto domyślnego jednostki + brak danych amortyzacji + odległość automatyczna i ręczna różnią się + brak danych paliwa + brak profilu wynagrodzenia + brak okresu zatrudnienia członka + brak stawki dla podstawy + brak profilu zasobu + brak mnożnika kodu płacy (użyto domyślnego) + naprawa już naliczona bezpośrednio + użyto domyślnego roli + niewystarczające okno konserwacji + niezatwierdzony profil + odczyt wymaga przeglądu + brak rocznego wykorzystania + Wiersze + Oszacuj koszt oferty + Pozycje oferty × domyślne wynagrodzenie jednostki i profile zasobów klasy; przychód = szacowana suma oferty. + Oblicz koszt wezwania + Wpisy pracy i odczyty wykorzystania dla wezwania; bez przychodu. + Oblicz koszt rozmieszczenia + Zatwierdzone dzienne raporty czasu, odczyty wykorzystania i wydatki do daty. + Uwagi wyjaśniające + Do 500 znaków; wymagane, gdy użyto zarobków z pola 1 lub zastosowano metodę zastępczą. + Szkic + Zamrożony + Wymaga przeglądu + Zastąpiony + Typ przebiegu + Rzeczywiste + Szacunek + Przebiegi raportu + Wartość końcowa + Zapisz + Nie udało się zapisać zmiany. + Zapisz odpowiedź + Zapisano. + Profil schematu + Zakres + Domyślny jednostki + Pracownik + Domyślny roli + SEIN (EDD) + Dobrowolna samoidentyfikacja + Twoje odpowiedzi są przechowywane oddzielnie od akt, zaszyfrowane i używane wyłącznie do przygotowania zbiorczego raportu CRD. Nikt w jednostce nie widzi ich na innym ekranie. + samoidentyfikacja + Płeć + Kobieta + Mężczyzna + Niebinarna + Rozmiar + Pominięto + Migawka + Pracownicy w migawce + Koniec migawki + Początek migawki + Jeden okres rozliczeniowy między + Migawki pracowników + Jeden wiersz na pracownika z Kalifornii w okresie; kod demograficzny, zarobki i stawka godzinowa to pola chronione. Nadpisania wymagają powodu i są audytowane. + Kod SOC + Wersja SOC + Numer CA Secretary of State + Źródło + Motogodziny start + Licznik początkowy + Początek + Stan + Status + Certyfikowany zewnętrznie + Skorygowany + Szkic + Wyeksportowany + Zamrożony + Zwalidowany + Unieważniony + Podmiot + Typ podmiotu + Klasa zewnętrzna + Zasób inwentarzowy + Jednostka + Podsumowanie + Roczne dane płacowe + Wynagrodzenie + Agencje pracy + Przebiegi kosztów + Przegląd + Pracodawca + Zakłady + Raportowanie danych płacowych + Koszty zasobów + Wpisy pracy + Pracownicy + Do dnia + Strefa czasowa + Razem + Łączny koszt pełny + niezatwierdzony + Jednostka + Cena jednostkowa + Nierozwiązane wyjątki + Zaktualizowano + Pracownicy w USA + Wpisy wykorzystania + Odczyty licznika, motogodzin, godzin i paliwa na jednostkę i dzień; odległość zapisywana w milach, sprzeczne odczyty trafiają do przeglądu. + Dzienny raport czasu + GPS + Tracker sprzętowy + Import + Ręczny + Okres użytkowania (miesiące) + Okres użytkowania (jednostki) + Mile, godziny lub dni w okresie; liniowo = (koszt − wartość końcowa) ÷ okres. + Zwaliduj + Zwalidowano + Walidacja + brak rocznych danych płacowych + roczne dane płacowe niezatwierdzone + nakładające się przypisania stanowisk + brak tożsamości agencji pracy + status objęcia nie zadeklarowany + brak odpowiedzi demograficznej lub odmowa + użyto W-2 pole 1 zamiast pola 5 (wymagana uwaga) + brak zarobków + liczby pracowników w wierszach nie zgadzają się z migawkami + niekompletna tożsamość pracodawcy (nazwa, FEIN, SEIN, adres EDD) + niekompletny adres zakładu + brak zakładu + brak NAICS zakładu + użyto metody zastępczej (wymagana uwaga) + pole przekracza długość szablonu + eksport przekroczyłby limit rozmiaru pliku portalu + godziny raportowane wynoszą zero + brak kategorii zawodowej + kategoria zawodowa spoza profilu schematu + zastosowano ręczne nadpisanie + brak pracowników w migawce + użyto percepcji obserwatora dla kodu demograficznego + profil schematu zmienił się od utworzenia + liczby zdalne nie zgadzają się z liczbą pracowników + wymagana kolumna szablonu jest pusta + wiersze jeszcze niezagregowane + okres migawki poza dozwolonym oknem + brak przepracowanych tygodni + nierozstrzygnięty tryb pracy (brak przypisania) + Odchylenie + Wersja + Podgląd kosztów wewnętrznych + Zbiorcze podsumowania kosztów działań i marż dla ofert, wezwań i rozmieszczeń — kategorie i sumy, nigdy pozycja, stawka ani osoba — oraz profile kosztów zasobów i odczyty. + Podgląd wynagrodzeń + Odczyt profili wynagrodzeń, wpisów pracy, rocznych danych i pozycji przebiegów kosztów. Wartości nadal wymagają ważnego uprawnienia. + Unieważnij + W-2 pole 1 + W-2 pole 5 + Ostrzeżenia + Zgłoszono w poprzednim roku + Przepracowane tygodnie + Dlaczego pytamy + Sekcja 12999 kalifornijskiego Government Code wymaga od objętych pracodawców raportowania danych płacowych pogrupowanych według kategorii zawodowej, rasy/pochodzenia i płci. Raport zawiera wyłącznie zbiorcze liczby i stawki; nigdy nikogo nie wymienia. + 1. Zbuduj migawki pracowników · 2. Zagreguj wiersze · 3. Zwaliduj · 4. Zamroź i wyeksportuj · następnie potwierdź w portalu CRD i zapisz tu certyfikację. + Kraj + Wpisy pracy + Godziny na pracownika i dzień z płac, rozmieszczeń i wezwań; zatwierdzony koszt z płac, jeśli istnieje, zastępuje szacunek. + Miejsce pracy + Tryb pracy + Stacjonarny + Zdalnie poza Kalifornią (przypisany do zakładu CA) + Zdalnie w Kalifornii + Stan / prowincja + Pracownik + Okresy zatrudnienia nigdy się nie nakładają; każdy okres zawiera przypisania stanowisk w czasie. + Rodzaj pracownika + Wykonawca niezależny + Pracownik agencji pracy + Pracownik etatowy + Wolontariusz + Pracownicy + Każda osoba opłacana lub raportowana przez jednostkę — członkowie i pracownicy zewnętrzni — z okresami zatrudnienia. + Kadry + Wartości na poziomie pracodawcy wpisywane w portalu CRD obok przesłanego pliku. + Serwowane bez buforowania i audytowane; zamknij kartę po zakończeniu. Resgrid nigdy nie loguje się do portalu. + Rok + Tak + Nie znaleziono pliku eksportu. + Plik eksportu wygasł lub został usunięty. + Numer certyfikacji z portalu jest wymagany. + Źródło zebrania jest nieprawidłowe. + Przebieg korekty już istnieje. + Odpowiedź dotycząca pochodzenia latynoskiego jest nieprawidłowa. + Najpierw zbuduj migawki pracowników. + Żaden zweryfikowany profil schematu CRD nie obejmuje tego roku. + Kod rasy/pochodzenia jest nieprawidłowy. + Odpowiedz na rasę/pochodzenie lub odmów odpowiedzi. + Wymagany jest powód. + Uwagi są ograniczone do 500 znaków. + Certyfikowanego przebiegu nie można unieważnić; utwórz korektę. + Przebieg jest zamrożony; utwórz korektę. + Tylko wyeksportowany przebieg można oznaczyć jako certyfikowany. + Nie znaleziono przebiegu raportu. + Tylko wyeksportowany lub certyfikowany przebieg można skorygować. + Kod płci jest nieprawidłowy. + Odpowiedz na płeć lub odmów odpowiedzi. + Nie znaleziono migawki pracownika. + Okres migawki musi być jednym okresem rozliczeniowym w dozwolonym oknie. + Walidacja nadal zgłasza błędy blokujące. + Proces raportowania nie mógł odszyfrować chronionych wartości. + Nazwa prawna podmiotu powiązanego jest wymagana. + Podstawa alokacji jest nieprawidłowa. + Kwota jest ujemna. + Wymagany jest zasób inwentarzowy. + Przypisanie nakłada się na inne przypisanie tego zatrudnienia. + Nie znaleziono oferty. + Kategoria lub podstawa składnika jest nieprawidłowa. + Nazwa prawna agencji jest wymagana. + Pracownik agencji wymaga agencji pracy. + Status objęcia jest nieprawidłowy. + Data końcowa jest wcześniejsza niż początkowa. + Nie znaleziono rozmieszczenia. + Nazwa prawna pracodawcy jest wymagana. + Nie znaleziono zatrudnienia. + Okres nakłada się na inne zatrudnienie tego pracownika. + Inny zakład używa już tego kodu. + Zakład wymaga kodu i nazwy. + Nie znaleziono zakładu. + Wymagany jest klucz zasobu zewnętrznego. + Godziny muszą mieścić się w zakresie 0–24. + Rodzaj godzin jest nieprawidłowy. + Kategoria zawodowa nie występuje w profilu schematu. + NAICS musi mieć sześć cyfr. + Nic do wyceny: brak wpisów pracy i odczytów. + Nie znaleziono rekordu. + Podstawa płacy jest nieprawidłowa. + Okres nakłada się na inny profil tego samego zakresu. + Zapis chroniony został odrzucony. + Typ raportu jest nieprawidłowy. + Domyślny roli wymaga roli personelu. + Przebieg jest zamrożony i nie można go zmienić. + Zakres profilu jest nieprawidłowy. + Typ podmiotu jest nieprawidłowy. + Nie znaleziono jednostki. + Wymagana jest jednostka. + Odczyt wykorzystania wymaga rozmieszczenia lub wezwania. + Odczyt wykorzystania jest nieprawidłowy. + Tryb pracy jest nieprawidłowy. + Ten członek ma już rekord pracownika. + Pracownik wymaga członka lub klucza zewnętrznego. + Rodzaj pracownika jest nieprawidłowy. + Nie znaleziono pracownika. + Wymagany jest pracownik. + Rok sprawozdawczy jest nieprawidłowy. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx new file mode 100644 index 00000000..6d5a27c6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Acquisition cost + Acquisition date + Active + Active from + Active to + Actual + Actual worked hours + Add affiliate + Add assignment + Add contractor + Add department default + Add employment + Add establishment + Add fact + Add profile + Add resource profile + Add role default + Add reading + Add work entry + Add worker + Or add an external worker by payroll key and label (both protected). + Pick a department member to create their worker row. + Address + Affiliate + Affiliated entities + Only for an integrated enterprise: the other entities the report covers. + Aggregate rows + Allocation basis + Per day + Per engine hour + Per kilometer + Per mile + Per operating hour + Amount + Annual pay facts + W-2 earnings, hours and weeks per employment for the reporting year — the input to every CRD snapshot. + Annual pay facts missing + Apply override + Approval + Unapproved profiles still price estimates but flag every line for review. Any rate change removes the approval. + Approve profile + Approved + Approved payroll cost + The actual from the payroll system; when present it replaces the estimate for this day. + Export files + CSV and XLSX in the CRD template column order, checksum-stable for unchanged input, downloadable no-store until purged after the retention window. + Back + Base amount + Per hour, year, day or shift according to the pay basis. + Basis + Bid + Break-even revenue + Build snapshots + Assigned to a California establishment + Both + Not a California employee + Works in California + California basis + California employees + Call # + Cancel + Cap + Category + Consumable + Expense + Overhead + Personnel + Resource + Portal certification reference + Checksum + City + Client-allocated earnings + Client-allocated hours + Client-allocated weeks + Client allocation key + Code + Collection source + Employment record + How this record was obtained; observer perception is flagged in every report run. + Observer perception + Other reliable record + Self-identified + Commit import + Compensation + Employee, role-default and department-default compensation profiles with pay and employer-cost components. + Compensation profile + Demographic completeness + Component + Components + Create a correction run? The exported run stays as filed and is marked corrected. + Delete this record? + Freeze this run? A frozen run is immutable; a later recalculation supersedes it. + Commit the import? Every row creates a new fact version. + Void this run? Its export files are purged. + Consumables + Consumption + Contact details + Context + Bid + Call + Deployment + Contractors that supply labor contractor employees covered by the second CRD report. + Correct fact (new version) + Allocated overhead + Employer payroll tax + Fixed labor cost + Health benefits + Other + Other benefits + Retirement / pension + Workers' compensation + Fixed annual + Per day + Per hour + Per shift + % of eligible pay + Cost components + Payroll taxes, benefits, pension and allocated overhead — percent of eligible pay, per hour, per shift or fixed annual, with an optional cap. + Cost run + Cost runs + Internal loaded cost and margin for bids (estimate), calls and deployments (actual). Revenue is only ever an observed number. + Covered — both reports + Covered — labor contractor employee report + Covered — payroll employee report + Declared by the employer after reading the CRD guidance; Resgrid never decides whether you must file. + Not covered + Coverage status + Not declared + Create correction + Create run + Created + Currency + Current response + Protected workforce pay data, internal field costing and California pay data reporting. + Date + Days + Days worked + DBA + Decline to state + declined + Default establishment + Default profiles + Role and department defaults price bid estimates and members without an employee profile. + Delete + Race/ethnicity/sex + Demographic record + A compliance officer's record for a worker who has not self-identified. + Use employment or other reliable records first; observer perception is a last resort and is flagged on every snapshot. A reason is required and audited. + Demographic responses missing + Answering is voluntary. Declining to state is a valid answer and is reported as such. + Department + Deployed days + Deployment + Deployment id + Depreciation + Depreciation / unit + Display label + Distance + Enter odometer readings or a distance; kilometres are stored as miles. + Distance unit + Downloads + Dry run + Dry run result + Due date + Earnings source + Client-allocated + W-2 Box 1 (fallback) + W-2 Box 5 + Earnings used + EDD address + Edit assignment + Edit contractor + Edit employment + Edit establishment + Edit resource profile + Edit reading + Edit work entry + Effective + Eligible pay codes + Employee profiles + Employees + Employer + Employer cost components + employer costs + The employer identity the CRD report is filed under, with its affiliated entities. + No employer profile yet. + Employer profile + Employer section + Employment + Employment type + Full-time + Intermittent + Part-time + Unknown + Employments + Engine meter end + End odometer + End + Engine hours + Errors + Establishment + Establishments + Every physical location employees are assigned to; the CRD report is filed per establishment. + Estimate + Estimate vs. actual + estimated + Exceptions + Actual + paid leave + Days × average hours + Exempt-hours proxy + None + Exemption + Exempt + Non-exempt + Unknown + Expected annual utilization + In allocation units per year; spreads fixed annual components and time-based depreciation. + Expenses + Expires + Expires + Export California pay data reports + Freeze a validated run and download its CSV / XLSX files and the portal worksheet (no-store, audited). + External id + External resource key + A rate schedule entry id here lets bid estimates price vehicle and equipment lines by class. + External source + External worker key + Stored facts are immutable: saving creates a new version that supersedes the current one. + fallback profile + FEIN + Field costing + Internal loaded cost and margin for bids, calls and deployments — aggregate categories only. + File + Filing contact e-mail + Filing contact name + Filing contact phone + Filter + Flags + Format + Freeze + Freeze and export + Frozen + Fuel + Actual fuel cost + Fuel quantity + Fuel unit + Headquarters + Headquarters address + Every amount and multiplier is a protected field. Estimates use the employee profile, then the role default, then the department default; the payroll system's approved cost always wins. + Coverage is declared by you, never determined by Resgrid. Identifiers and addresses are protected fields; a REDACTED value left unchanged keeps what is stored. + Hispanic or Latino + Hourly rate + Hours + Hours / day + Hours / week + Hours / year + Hours type + Double time + Other + Overtime + Paid leave + Regular + Standby + Travel + Identifier type + Idle hours + Import annual pay facts (CSV) + Paste the payroll export in the canonical column order. A dry run validates and reconciles every row before anything commits; imported facts arrive unapproved. + Import result + Box 5 absent — Box 1 used (remark required) + duplicate row + earnings missing + no employment covers the year + nothing to import + reportable hours missing or zero + worker not found + reporting year invalid + Included + Inventory asset + Part of an integrated enterprise + CRD job category + One of the ten CRD job categories of the reviewed schema profile. + Job title + Labor contractor + Labor contractors + Legal name + Line + Lines + Personnel lines carry no rate in the clear; the priced detail is a protected field shown only with a current grant. + Lines are visible with View workforce compensation; the summary above is the aggregate view. + Major activity + Manage California pay data reporting + The CRD report wizard: runs, employee snapshots, overrides, aggregation, validation, remarks, certification observation, corrections and the compliance officer's demographic records. Every member always answers their own response without it. + Manage workforce and compensation + Employer identity, establishments, workers, employment periods, job assignments, compensation profiles and components, work entries and annual pay fact imports. Values still need a current Protected Data Grant. + Mapping provenance + Contribution margin + Record certification + Only what you observed in the CRD portal after certifying there; Resgrid never certifies. + MARS authority profile + Cal OES MARS classification + Mean hourly rate + Median hourly rate + Member + Miles + Missing inputs + My demographic response + Answer or update your voluntary self-identification for California pay data reporting. + NAICS + Name + New run + No + No answer + No export files — freeze and export a validated run. + No job assignments. + No labor contractors. + No employment periods yet. + No establishments yet. + No annual pay facts for this year and report type. + No lines. + No compensation profiles. + No resource cost profiles. + No aggregate rows yet — aggregate after the snapshots are complete. + No runs yet. + No snapshots yet — build them first. + No usage readings. + No work entries in this window. + No workers yet. + Non-remote + None + blocking errors remain + Not validated yet. + observer perception + Open runs + Operating hours + Distance (as read) + Other + Overhead + Override reason + Ownership name + Paid leave hours + pay + Pay band + Pay basis + Daily + Hourly + Salary + Per shift + Stipend + Differential + Education + EMS + HazMat + Incentive + Longevity + Other + Specialty + USAR + Fixed annual + Per hour + Per pay period + Per shift + % of base + Pay components + Differentials, incentives and specialty pay on top of the base. Fixed-period components are not paid on overtime unless flagged per overtime hour. + Resgrid prepares and exports; it never files, never decides whether you are covered and never certifies. Everything on these screens is a protected field and is served no-store. + Prepare the California CRD Payroll Employee and Labor Contractor Employee reports; you file them yourself in the CRD portal. + Pay data reporting + Report run + Per OT hour + Personnel + Personnel role + Used to pick a role-default compensation profile when the employee has none. + Phase + Incident + Mobilization + Return + Standby + Street address + Portal worksheet + ZIP + Loaded cost of a regular 8-hour day + no reviewed schema profile + Identifiers, addresses, pay rates, earnings, demographic responses and export files are Advanced Data Protection fields (catalog 28). They render REDACTED without a current Protected Data Grant and are never cached or logged. + Provenance + Where the identity came from (contract, W-9, portal record). + Average hours per day + purged + Quantity + Race / ethnicity + Select every category that applies; two or more are reported as multiracial. + White + Black or African American + Native Hawaiian or Other Pacific Islander + Asian + American Indian or Alaska Native + Middle Eastern or North African + Rate + Rate multipliers + JSON by pay code; missing codes use 1.5 (overtime), 2 (double time) and 1. + Readiness + ready to freeze + Reason + Reconciled + Regular hourly equivalent + Optional override; otherwise derived from the base amount and standard hours. + Relationship + Relationship end + Relationship start + Remote in CA + Remote outside CA + Report type + Labor Contractor Employee Report + Payroll Employee Report + Reportable hours + Fixed annual + Per day + Per deployment + Per engine hour + Per idle hour + Per kilometer + Per mile + Per operating hour + Consumables + Depreciation + Fixed overhead + Fuel / energy + Insurance / licensing + Lease / rental + Maintenance + Other + Storage + Tires / wear + Fuel (rate, or consumption × unit price), maintenance (manual or rolling work-order actual with a meter window), tires, insurance, lease, storage, fixed overhead and consumables. + Resource costs + Depreciation, fuel, maintenance and fixed costs per unit, asset or external resource class. + Resource profiles + Plain numeric data gated by View internal costs — no person is priced here. + Calculated from acquisition + Imported + Manual + Work-order rolling actual + Resource usage + Resources + Revenue + Bid estimated total + Cal OES MARS approved + Cal OES MARS expected + Cal OES MARS paid + Customer invoices + None + Revenue source + Review + approved payroll cost used + class profile used + component unapproved + currency mismatch + department default used + depreciation inputs missing + automatic and manual distance disagree + fuel inputs missing + no compensation profile + no employment period for the member + no rate for the basis + no resource profile + pay code multiplier missing (default used) + repair already charged directly + role default used + rolling maintenance window insufficient + unapproved profile + usage reading needs review + annual utilization missing + Rows + Estimate bid cost + Bid lines × department-default compensation and class resource profiles; revenue = the bid's estimated total. + Calculate call cost + Work entries and usage readings recorded against the call; no revenue. + Calculate deployment cost + Approved daily time reports, usage readings and expenses through the date. + Clarifying remarks + Up to 500 characters; required when Box 1 earnings were used or an exempt proxy applies. + Draft + Frozen + Needs review + Superseded + Run type + Actual + Estimate + Report runs + Salvage value + Save + The change could not be saved. + Save response + Saved. + Schema profile + Scope + Department default + Employee + Role default + SEIN (EDD) + Voluntary self-identification + Your answers are stored separately from your personnel record, encrypted, and used only to prepare the aggregate CRD report. Nobody in the department sees them on any other screen. + self-identified + Sex + Female + Male + Non-binary + Size + Skipped + Snapshot + Employees in snapshot + Snapshot end + Snapshot start + A single pay period between + Employee snapshots + One row per California employee in the snapshot period; demographic code, earnings and hourly rate are protected fields. Overrides need a reason and are audited. + SOC code + SOC version + CA Secretary of State number + Source + Engine meter start + Start odometer + Start + State + Status + Certified externally + Corrected + Draft + Exported + Frozen + Validated + Void + Subject + Subject type + External class + Inventory asset + Unit + Summary + Annual pay facts + Compensation + Labor contractors + Cost runs + Overview + Employer + Establishments + Pay data reporting + Resource costs + Work entries + Workers + Through date + Time zone + Total + Total loaded cost + not approved + Unit + Unit price + Unresolved exceptions + Updated + US employees + Usage entries + Odometer, engine meter, hours and fuel readings per unit and day; distance is stored in miles and conflicting readings are queued for review. + Daily time report + GPS + Hardware tracker + Import + Manual + Useful life (months) + Useful life (units) + Miles, hours or days over the life; straight-line = (cost − salvage) ÷ life. + Validate + Validated on + Validation + annual pay fact missing + annual pay fact not approved + overlapping job assignments + labor contractor identity missing + coverage status not declared + demographic response missing or declined + W-2 Box 1 used instead of Box 5 (remark required) + earnings missing + row employee counts do not reconcile to the snapshots + employer identity incomplete (name, FEIN, SEIN, EDD address) + establishment address incomplete + establishment missing + establishment NAICS missing + exempt-hours proxy used (remark required) + field exceeds the template length + export would exceed the portal file size limit + reportable hours are zero + job category missing + job category not in the schema profile + manual override applied + no employees in the snapshot + observer perception used for the demographic code + schema profile changed since the run was created + remote counts do not reconcile to the employee count + required template column is empty + rows not aggregated yet + snapshot period outside the allowed window + weeks worked missing + work mode unresolved (no assignment) + Variance + Version + View internal costs + Aggregate field-cost summaries and margins for bids, calls and deployments — categories and totals, never a line, rate or person — plus resource cost profiles and usage readings. + View workforce compensation + Read compensation profiles, work entries, annual pay facts and cost-run lines. Values still need a current Protected Data Grant. + Void + W-2 Box 1 + W-2 Box 5 + Warnings + Reported in the prior year + Weeks worked + Why we ask + California Government Code section 12999 requires covered employers to report pay data grouped by job category, race/ethnicity and sex. The report contains only aggregate counts and rates; it never names anyone. + 1. Build the employee snapshots · 2. Aggregate the rows · 3. Validate · 4. Freeze and export · then attest in the CRD portal and record the certification here. + Country + Work entries + Hours per worker and day from payroll, deployments and calls; the payroll-approved cost, when present, replaces any estimate. + Work location + Work mode + Non-remote + Remote outside California (assigned to a CA establishment) + Remote within California + State / province + Worker + Employment periods never overlap; each period carries its job assignments over time. + Worker kind + Independent contractor + Labor contractor employee + Payroll employee + Volunteer + Workers + Every person the department pays or reports on — members and external workers — with their employment periods. + Workforce + The employer-level values you type into the CRD portal alongside the uploaded file. + Served no-store and audited; close the tab when done. Resgrid never signs in to the portal. + Year + Yes + The export file was not found. + The export file has expired or was purged. + The portal certification reference is required. + The collection source is not valid. + A correction run already exists. + The Hispanic or Latino answer is not valid. + Build the employee snapshots first. + No reviewed CRD schema profile covers that reporting year. + A race/ethnicity code is not valid. + Answer race/ethnicity or decline to state. + A reason is required. + Remarks are limited to 500 characters. + A certified run cannot be voided; create a correction. + The run is frozen; create a correction instead. + Only an exported run can be marked certified. + The report run was not found. + Only an exported or certified run can be corrected. + The sex code is not valid. + Answer sex or decline to state. + The employee snapshot was not found. + The snapshot period must be one pay period inside the allowed window. + Validation still reports blocking errors. + The reporting workload could not decrypt the protected values. + The affiliate legal name is required. + The allocation basis is not valid. + An amount is negative. + An inventory asset is required. + The assignment overlaps another assignment of this employment. + The bid was not found. + A component category or basis is not valid. + The contractor legal name is required. + A labor contractor employee needs a labor contractor. + The coverage status is not valid. + The end date is before the start date. + The deployment was not found. + The employer legal name is required. + The employment was not found. + The period overlaps another employment of this worker. + Another establishment already uses that code. + An establishment needs a code and a name. + The establishment was not found. + An external resource key is required. + Hours must be between 0 and 24. + The hours type is not valid. + The job category is not in the schema profile. + NAICS must be six digits. + Nothing to cost: no work entries or usage readings. + The record was not found. + The pay basis is not valid. + The period overlaps another profile of the same scope. + The protected write was refused. + The report type is not valid. + A role default needs a personnel role. + The run is frozen and cannot be changed. + The profile scope is not valid. + The subject type is not valid. + The unit was not found. + A unit is required. + A usage reading needs a deployment or a call. + The usage reading is not valid. + The work mode is not valid. + That member already has a worker row. + A worker needs a member or an external key. + The worker kind is not valid. + The worker was not found. + A worker is required. + The reporting year is not valid. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx new file mode 100644 index 00000000..3a947c0a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Anskaffningskostnad + Anskaffningsdatum + Aktiv + Aktiv från + Aktiv till + Faktisk + Faktiskt arbetade timmar + Lägg till anknuten enhet + Lägg till tilldelning + Lägg till leverantör + Lägg till avdelningsstandard + Lägg till anställning + Lägg till arbetsställe + Lägg till faktum + Lägg till profil + Lägg till resursprofil + Lägg till rollstandard + Lägg till avläsning + Lägg till post + Lägg till arbetstagare + Eller lägg till en extern arbetstagare via lönenyckel och etikett (båda skyddade). + Välj en medlem för att skapa dennes arbetstagarpost. + Adress + Anknuten enhet + Anknutna enheter + Endast för ett integrerat företag: de andra enheter rapporten omfattar. + Aggregera rader + Fördelningsgrund + Per dag + Per motortimme + Per kilometer + Per mile + Per drifttimme + Belopp + Årliga lönefakta + W-2-inkomst, timmar och veckor per anställning för rapportåret — indata till varje CRD-ögonblicksbild. + Saknade årliga lönefakta + Tillämpa överstyrning + Godkännande + Ej godkända profiler prissätter fortfarande uppskattningar men flaggar varje rad för granskning. Varje satsändring tar bort godkännandet. + Godkänn profil + Godkänd + Godkänd lönekostnad + Det faktiska från lönesystemet; när det finns ersätter det uppskattningen för dagen. + Exportfiler + CSV och XLSX i CRD-mallens kolumnordning, kontrollsummestabila för oförändrad indata, nedladdningsbara utan cache tills de rensas efter lagringsperioden. + Tillbaka + Grundbelopp + Per timme, år, dag eller skift enligt lönegrunden. + Grund + Anbud + Nollpunktsintäkt + Bygg ögonblicksbilder + Placerad vid ett arbetsställe i Kalifornien + Båda + Ej Kalifornienanställd + Arbetar i Kalifornien + Kaliforniengrund + Anställda i Kalifornien + Larm nr + Avbryt + Tak + Kategori + Förbrukningsvara + Utgift + Omkostnad + Personal + Resurs + Portalens certifieringsreferens + Kontrollsumma + Ort + Klientallokerad inkomst + Klientallokerade timmar + Klientallokerade veckor + Klientallokeringsnyckel + Kod + Insamlingskälla + Anställningsuppgift + Hur denna post erhölls; observatörsuppfattning flaggas i varje rapportkörning. + Observatörsuppfattning + Annan tillförlitlig uppgift + Självidentifierad + Genomför import + Ersättning + Ersättningsprofiler för anställd, roll och avdelning med löne- och arbetsgivarkostnadskomponenter. + Ersättningsprofil + Demografisk fullständighet + Komponent + Komponenter + Skapa en korrigeringskörning? Den exporterade körningen förblir som inlämnad och markeras som korrigerad. + Ta bort denna post? + Frys denna körning? En fryst körning är oföränderlig; en senare omräkning ersätter den. + Genomför importen? Varje rad skapar en ny faktaversion. + Ogiltigförklara denna körning? Dess exportfiler rensas. + Förbrukningsvaror + Förbrukning + Kontaktuppgifter + Kontext + Anbud + Larm + Insats + Leverantörer av bemanningsanställda som omfattas av den andra CRD-rapporten. + Korrigera faktum (ny version) + Fördelade omkostnader + Arbetsgivaravgift + Fast arbetskostnad + Hälsoförmåner + Övrigt + Övriga förmåner + Pension + Arbetsskadeförsäkring + Fast årlig + Per dag + Per timme + Per skift + % av berättigad lön + Kostnadskomponenter + Arbetsgivaravgifter, förmåner, pension och fördelade omkostnader — procent av berättigad lön, per timme, per skift eller fast årlig, med valfritt tak. + Kostnadskörning + Kostnadskörningar + Intern totalkostnad och marginal för anbud (uppskattning), larm och insatser (faktisk). Intäkt är alltid bara ett observerat tal. + Omfattad — båda rapporterna + Omfattad — Labor Contractor Employee-rapport + Omfattad — Payroll Employee-rapport + Deklareras av arbetsgivaren efter att ha läst CRD:s vägledning; Resgrid avgör aldrig om du måste lämna in. + Ej omfattad + Omfattningsstatus + Ej deklarerad + Skapa korrigering + Skapa körning + Skapad + Valuta + Aktuellt svar + Skyddade lönedata, intern fältkostnadsberäkning och Kaliforniens lönedatarapportering. + Datum + Dagar + Arbetade dagar + DBA + Vill inte uppge + avböjda + Standardarbetsställe + Standardprofiler + Roll- och avdelningsstandarder prissätter anbudsuppskattningar och medlemmar utan egen profil. + Ta bort + Ras/etnicitet/kön + Demografisk post + Efterlevnadsansvarigs post för en arbetstagare som inte självidentifierat sig. + Använd anställnings- eller andra tillförlitliga uppgifter först; observatörsuppfattning är en sista utväg och flaggas på varje ögonblicksbild. Orsak krävs och granskas. + Saknade demografiska svar + Att svara är frivilligt. Att avstå är ett giltigt svar och rapporteras som sådant. + Avdelning + Insatsdagar + Insats + Insats-id + Avskrivning + Avskrivning / enhet + Visningsnamn + Avstånd + Ange vägmätaravläsningar eller ett avstånd; kilometer lagras som miles. + Avståndsenhet + Nedladdningar + Provkörning + Provkörningsresultat + Förfallodatum + Inkomstkälla + Klientallokerad + W-2 ruta 1 (reserv) + W-2 ruta 5 + Använd inkomst + EDD-adress + Redigera tilldelning + Redigera leverantör + Redigera anställning + Redigera arbetsställe + Redigera resursprofil + Redigera avläsning + Redigera post + Gäller från + Berättigade lönekoder + Anställdprofiler + Anställda + Arbetsgivare + Arbetsgivarkostnadskomponenter + arbetsgivarkostnader + Arbetsgivaridentiteten som CRD-rapporten lämnas under, med dess anknutna enheter. + Ingen arbetsgivarprofil ännu. + Arbetsgivarprofil + Arbetsgivaravsnitt + Anställning + Anställningstyp + Heltid + Intermittent + Deltid + Okänd + Anställningar + Motortimmätare slut + Vägmätare slut + Slut + Motortimmar + Fel + Arbetsställe + Arbetsställen + Varje fysisk plats där anställda placeras; CRD-rapporten lämnas per arbetsställe. + Uppskattning + Uppskattning mot faktisk + uppskattad + Undantag + Faktisk + betald ledighet + Dagar × genomsnittstimmar + Proxymetod för undantagna + Ingen + Undantag + Undantagen + Ej undantagen + Okänd + Förväntad årlig användning + I fördelningsenheter per år; fördelar fasta årliga komponenter och tidsbaserad avskrivning. + Utgifter + Upphör + Upphör + Exportera Kaliforniens lönedatarapporter + Frysa en validerad körning och ladda ner dess CSV/XLSX-filer och portalarbetsbladet (utan cache, granskat). + Externt id + Extern resursnyckel + Ett prislisteposts-id här låter anbudsuppskattningar prissätta fordons- och utrustningsrader per klass. + Extern källa + Extern arbetstagarnyckel + Lagrade fakta är oföränderliga: att spara skapar en ny version som ersätter den nuvarande. + reservprofil + FEIN + Fältkostnader + Intern totalkostnad och marginal för anbud, larm och insatser — endast aggregerade kategorier. + Fil + Kontakt-e-post + Kontaktnamn för inlämning + Kontakttelefon + Filtrera + Flaggor + Format + Frys + Frys och exportera + Fryst + Bränsle + Faktisk bränslekostnad + Bränslemängd + Bränsleenhet + Huvudkontor + Huvudkontorsadress + Varje belopp och multiplikator är ett skyddat fält. Uppskattningar använder den anställdes profil, sedan rollens standard, sedan avdelningens; lönesystemets godkända kostnad vinner alltid. + Omfattningen deklareras av dig, aldrig av Resgrid. Identifierare och adresser är skyddade fält; ett oförändrat REDACTED-värde behåller det lagrade. + Hispanisk eller latino + Timlön + Timmar + Timmar / dag + Timmar / vecka + Timmar / år + Timtyp + Dubbel tid + Övrigt + Övertid + Betald ledighet + Ordinarie + Beredskap + Resa + Identifierartyp + Tomgångstimmar + Importera årliga lönefakta (CSV) + Klistra in löneexporten i kanonisk kolumnordning. En provkörning validerar och stämmer av varje rad innan något sparas; importerade fakta är ej godkända. + Importresultat + Ruta 5 saknas — ruta 1 används (anmärkning krävs) + dubblettrad + inkomst saknas + ingen anställning täcker året + inget att importera + rapporterbara timmar saknas eller noll + arbetstagare hittades inte + ogiltigt år + Inkluderad + Inventarietillgång + Del av ett integrerat företag + CRD-jobbkategori + En av de tio CRD-jobbkategorierna i den granskade schemaprofilen. + Befattning + Bemanningsföretag + Bemanningsföretag + Juridiskt namn + Rad + Rader + Personalrader bär ingen sats i klartext; den prissatta detaljen är ett skyddat fält som bara visas med giltig behörighet. + Rader syns med Visa ersättning; sammanfattningen ovan är den aggregerade vyn. + Huvudsaklig verksamhet + Hantera Kaliforniens lönedatarapportering + CRD-rapportguiden: körningar, ögonblicksbilder, överstyrningar, aggregering, validering, anmärkningar, certifieringsobservation, korrigeringar och efterlevnadsansvarigs demografiska poster. Varje medlem besvarar alltid sitt eget svar utan den. + Hantera personal och ersättning + Arbetsgivaridentitet, arbetsställen, arbetstagare, anställningsperioder, tjänstetilldelningar, ersättningsprofiler och komponenter, arbetsposter och import av årliga lönefakta. Värden kräver fortfarande giltig behörighet. + Mappningens ursprung + Täckningsbidrag + Registrera certifiering + Endast det du observerade i CRD-portalen efter att ha certifierat där; Resgrid certifierar aldrig. + MARS-auktoritetsprofil + Cal OES MARS-klassificering + Medeltimlön + Median timlön + Medlem + Miles + Saknade indata + Mitt demografiska svar + Besvara eller uppdatera din frivilliga självidentifiering för Kaliforniens lönedatarapportering. + NAICS + Namn + Ny körning + Nej + Inget svar + Inga exportfiler — frys och exportera en validerad körning. + Inga tjänstetilldelningar. + Inga bemanningsföretag. + Inga anställningsperioder ännu. + Inga arbetsställen ännu. + Inga årliga lönefakta för detta år och rapporttyp. + Inga rader. + Inga ersättningsprofiler. + Inga resurskostnadsprofiler. + Inga aggregerade rader ännu — aggregera när ögonblicksbilderna är klara. + Inga körningar ännu. + Inga ögonblicksbilder ännu — bygg dem först. + Inga användningsavläsningar. + Inga poster i detta intervall. + Inga arbetstagare ännu. + På plats + Inga + blockerande fel kvarstår + Inte validerad ännu. + observatörsuppfattning + Öppna körningar + Drifttimmar + Avstånd (avläst) + Övrigt + Omkostnader + Orsak till överstyrning + Ägarens namn + Betalda ledighetstimmar + lön + Löneband + Lönegrund + Per dag + Per timme + Månadslön + Per skift + Arvode + Tillägg + Utbildning + EMS + HazMat + Incitament + Tjänsteålder + Övrigt + Specialitet + USAR + Fast årlig + Per timme + Per löneperiod + Per skift + % av grund + Lönekomponenter + Tillägg, incitament och specialistlön utöver grunden. Fastperiodskomponenter betalas inte på övertid om de inte är flaggade per övertidstimme. + Resgrid förbereder och exporterar; det lämnar aldrig in, avgör aldrig om ni omfattas och certifierar aldrig. Allt på dessa skärmar är skyddade fält som levereras utan cache. + Förbered Kaliforniens CRD-rapporter Payroll Employee och Labor Contractor Employee; du lämnar in dem själv i CRD-portalen. + Lönedatarapportering + Rapportkörning + Per ÖT-timme + Personal + Personalroll + Används för att välja rollens standardersättningsprofil när den anställde saknar egen. + Fas + Insats + Mobilisering + Återresa + Beredskap + Gatuadress + Portalarbetsblad + Postnummer + Totalkostnad för en ordinarie 8-timmarsdag + ingen granskad schemaprofil + Identifierare, adresser, lönesatser, inkomster, demografiska svar och exportfiler är fält för avancerat dataskydd (katalog 28). De visas som REDACTED utan giltig behörighet och cachas eller loggas aldrig. + Ursprung + Var identiteten kommer ifrån (avtal, W-9, portalpost). + Genomsnittstimmar per dag + rensad + Antal + Ras / etnicitet + Välj alla kategorier som gäller; två eller fler rapporteras som flerrasig. + Vit + Svart eller afroamerikan + Hawaiian eller annan stillahavsöbo + Asiat + Amerikansk urinvånare eller Alaska Native + Mellanöstern eller Nordafrika + Sats + Satsmultiplikatorer + JSON per lönekod; saknade koder använder 1,5 (övertid), 2 (dubbel) och 1. + Beredskap + redo att frysas + Orsak + Avstämd + Ordinarie timekvivalent + Valfri överstyrning; annars härledd från grundbelopp och standardtimmar. + Relation + Relationens slut + Relationens start + Distans i CA + Distans utanför CA + Rapporttyp + Labor Contractor Employee-rapport + Payroll Employee-rapport + Rapporterbara timmar + Fast årlig + Per dag + Per insats + Per motortimme + Per tomgångstimme + Per kilometer + Per mile + Per drifttimme + Förbrukningsvaror + Avskrivning + Fasta omkostnader + Bränsle / energi + Försäkring / registrering + Leasing / hyra + Underhåll + Övrigt + Förvaring + Däck / slitage + Bränsle (sats eller förbrukning × enhetspris), underhåll (manuellt eller rullande arbetsorderfaktisk med mätarfönster), däck, försäkring, leasing, förvaring, fasta omkostnader och förbrukningsvaror. + Resurskostnader + Avskrivning, bränsle, underhåll och fasta kostnader per enhet, tillgång eller extern resursklass. + Resursprofiler + Rena numeriska data skyddade av Visa interna kostnader — ingen person prissätts här. + Beräknad från anskaffning + Importerad + Manuell + Rullande arbetsorderfaktisk + Resursanvändning + Resurser + Intäkt + Anbudets uppskattade total + Cal OES MARS godkänd + Cal OES MARS förväntad + Cal OES MARS betald + Kundfakturor + Ingen + Intäktskälla + Granskning + godkänd lönekostnad använd + klassprofil använd + komponent ej godkänd + valutaavvikelse + avdelningsstandard använd + avskrivningsindata saknas + automatiskt och manuellt avstånd skiljer sig + bränsleindata saknas + ingen ersättningsprofil + ingen anställningsperiod för medlemmen + ingen sats för grunden + ingen resursprofil + lönekodsmultiplikator saknas (standard använd) + reparation redan debiterad direkt + rollstandard använd + rullande underhållsfönster otillräckligt + ej godkänd profil + användningsavläsning behöver granskas + årlig användning saknas + Rader + Uppskatta anbudskostnad + Anbudsrader × avdelningens standardersättning och klassresursprofiler; intäkt = anbudets uppskattade total. + Beräkna larmkostnad + Arbetsposter och användningsavläsningar för larmet; ingen intäkt. + Beräkna insatskostnad + Godkända dagliga tidrapporter, användningsavläsningar och utgifter till datumet. + Förtydligande anmärkningar + Upp till 500 tecken; krävs när ruta 1-inkomst använts eller en undantagsproxy gäller. + Utkast + Fryst + Behöver granskas + Ersatt + Körningstyp + Faktisk + Uppskattning + Rapportkörningar + Restvärde + Spara + Ändringen kunde inte sparas. + Spara svar + Sparat. + Schemaprofil + Omfattning + Avdelningsstandard + Anställd + Rollstandard + SEIN (EDD) + Frivillig självidentifiering + Dina svar lagras separat från din personalakt, krypterade, och används endast för att förbereda den aggregerade CRD-rapporten. Ingen i avdelningen ser dem på någon annan skärm. + självidentifierade + Kön + Kvinna + Man + Icke-binär + Storlek + Överhoppad + Ögonblicksbild + Anställda i ögonblicksbilden + Ögonblicksbild slut + Ögonblicksbild start + En enda löneperiod mellan + Anställdas ögonblicksbilder + En rad per Kalifornienanställd i perioden; demografisk kod, inkomst och timlön är skyddade fält. Överstyrningar kräver orsak och granskas. + SOC-kod + SOC-version + CA Secretary of State-nummer + Källa + Motortimmätare start + Vägmätare start + Start + Delstat + Status + Certifierad externt + Korrigerad + Utkast + Exporterad + Fryst + Validerad + Ogiltig + Objekt + Objekttyp + Extern klass + Inventarietillgång + Enhet + Sammanfattning + Årliga lönefakta + Ersättning + Bemanningsföretag + Kostnadskörningar + Översikt + Arbetsgivare + Arbetsställen + Lönedatarapportering + Resurskostnader + Arbetsposter + Arbetstagare + Till datum + Tidszon + Totalt + Total kostnad + ej godkänd + Enhet + Enhetspris + Olösta undantag + Uppdaterad + Anställda i USA + Användningsposter + Vägmätar-, motortimmar-, tim- och bränsleavläsningar per enhet och dag; avstånd lagras i miles och motstridiga avläsningar köas för granskning. + Daglig tidrapport + GPS + Hårdvaruspårare + Import + Manuell + Livslängd (månader) + Livslängd (enheter) + Miles, timmar eller dagar över livslängden; linjär = (kostnad − restvärde) ÷ livslängd. + Validera + Validerad + Validering + årligt lönefaktum saknas + årligt lönefaktum ej godkänt + överlappande tjänstetilldelningar + bemanningsföretagets identitet saknas + omfattningsstatus ej deklarerad + demografiskt svar saknas eller avböjt + W-2 ruta 1 använd i stället för ruta 5 (anmärkning krävs) + inkomst saknas + radernas antal anställda stämmer inte med ögonblicksbilderna + arbetsgivaridentitet ofullständig (namn, FEIN, SEIN, EDD-adress) + arbetsställets adress ofullständig + arbetsställe saknas + arbetsställets NAICS saknas + proxy för undantagna timmar använd (anmärkning krävs) + fältet överskrider mallens längd + exporten skulle överskrida portalens filstorleksgräns + rapporterbara timmar är noll + jobbkategori saknas + jobbkategori finns inte i schemaprofilen + manuell överstyrning tillämpad + inga anställda i ögonblicksbilden + observatörsuppfattning använd för demografisk kod + schemaprofilen ändrades sedan körningen skapades + distansantalen stämmer inte med antalet anställda + obligatorisk mallkolumn är tom + rader inte aggregerade ännu + ögonblicksperiod utanför tillåtet fönster + arbetade veckor saknas + arbetsform olöst (ingen tilldelning) + Avvikelse + Version + Visa interna kostnader + Aggregerade fältkostnadssammanfattningar och marginaler för anbud, larm och insatser — kategorier och totaler, aldrig en rad, sats eller person — plus resurskostnadsprofiler och användningsavläsningar. + Visa ersättning + Läsa ersättningsprofiler, arbetsposter, årliga lönefakta och kostnadskörningsrader. Värden kräver fortfarande giltig behörighet. + Ogiltigförklara + W-2 ruta 1 + W-2 ruta 5 + Varningar + Rapporterat föregående år + Arbetade veckor + Varför vi frågar + Kaliforniens Government Code § 12999 kräver att omfattade arbetsgivare rapporterar lönedata grupperade efter jobbkategori, ras/etnicitet och kön. Rapporten innehåller endast aggregerade antal och satser; den namnger aldrig någon. + 1. Bygg de anställdas ögonblicksbilder · 2. Aggregera raderna · 3. Validera · 4. Frys och exportera · intyga sedan i CRD-portalen och registrera certifieringen här. + Land + Arbetsposter + Timmar per arbetstagare och dag från lön, insatser och larm; den lönegodkända kostnaden ersätter varje uppskattning när den finns. + Arbetsplats + Arbetsform + På plats + Distans utanför Kalifornien (placerad vid CA-arbetsställe) + Distans inom Kalifornien + Delstat / provins + Arbetstagare + Anställningsperioder överlappar aldrig; varje period bär sina tjänstetilldelningar över tid. + Typ av arbetstagare + Egenföretagare + Bemanningsanställd + Löneanställd + Frivillig + Arbetstagare + Varje person avdelningen betalar eller rapporterar — medlemmar och externa arbetstagare — med anställningsperioder. + Personal + Arbetsgivarvärdena du anger i CRD-portalen vid sidan av den uppladdade filen. + Levereras utan cache och granskas; stäng fliken när du är klar. Resgrid loggar aldrig in på portalen. + År + Ja + Exportfilen hittades inte. + Exportfilen har upphört eller rensats. + Portalens certifieringsreferens krävs. + Insamlingskällan är ogiltig. + En korrigeringskörning finns redan. + Svaret hispanisk eller latino är ogiltigt. + Bygg de anställdas ögonblicksbilder först. + Ingen granskad CRD-schemaprofil täcker det rapportåret. + En ras/etnicitetskod är ogiltig. + Ange ras/etnicitet eller avstå. + En orsak krävs. + Anmärkningar är begränsade till 500 tecken. + En certifierad körning kan inte ogiltigförklaras; skapa en korrigering. + Körningen är fryst; skapa en korrigering i stället. + Endast en exporterad körning kan markeras certifierad. + Rapportkörningen hittades inte. + Endast en exporterad eller certifierad körning kan korrigeras. + Könskoden är ogiltig. + Ange kön eller avstå. + Den anställdes ögonblicksbild hittades inte. + Ögonblicksperioden måste vara en löneperiod inom det tillåtna fönstret. + Valideringen rapporterar fortfarande blockerande fel. + Rapporteringsprocessen kunde inte dekryptera de skyddade värdena. + Den anknutna enhetens juridiska namn krävs. + Fördelningsgrunden är ogiltig. + Ett belopp är negativt. + En inventarietillgång krävs. + Tilldelningen överlappar en annan tilldelning i denna anställning. + Anbudet hittades inte. + En komponentkategori eller grund är ogiltig. + Leverantörens juridiska namn krävs. + En bemanningsanställd behöver ett bemanningsföretag. + Omfattningsstatusen är ogiltig. + Slutdatumet ligger före startdatumet. + Insatsen hittades inte. + Arbetsgivarens juridiska namn krävs. + Anställningen hittades inte. + Perioden överlappar en annan anställning för denna arbetstagare. + Ett annat arbetsställe använder redan den koden. + Ett arbetsställe behöver kod och namn. + Arbetsstället hittades inte. + En extern resursnyckel krävs. + Timmar måste vara mellan 0 och 24. + Timtypen är ogiltig. + Jobbkategorin finns inte i schemaprofilen. + NAICS måste vara sex siffror. + Inget att kostnadsberäkna: inga arbetsposter eller användningsavläsningar. + Posten hittades inte. + Lönegrunden är ogiltig. + Perioden överlappar en annan profil med samma omfattning. + Den skyddade skrivningen nekades. + Rapporttypen är ogiltig. + En rollstandard behöver en personalroll. + Körningen är fryst och kan inte ändras. + Profilens omfattning är ogiltig. + Objekttypen är ogiltig. + Enheten hittades inte. + En enhet krävs. + En användningsavläsning behöver en insats eller ett larm. + Användningsavläsningen är ogiltig. + Arbetsformen är ogiltig. + Den medlemmen har redan en arbetstagarpost. + En arbetstagare behöver en medlem eller en extern nyckel. + Typen av arbetstagare är ogiltig. + Arbetstagaren hittades inte. + En arbetstagare krävs. + Rapportåret är ogiltigt. + diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx new file mode 100644 index 00000000..e47ea2a7 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx @@ -0,0 +1,713 @@ + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms + System.Resources.ResXResourceWriter, System.Windows.Forms + Вартість придбання + Дата придбання + Активний + Активний з + Активний до + Фактичне + Фактично відпрацьовані години + Додати афілійовану організацію + Додати призначення + Додати підрядника + Додати профіль підрозділу + Додати працевлаштування + Додати підрозділ + Додати дані + Додати профіль + Додати профіль ресурсу + Додати профіль ролі + Додати показник + Додати запис + Додати працівника + Або додайте зовнішнього працівника за ключем зарплати та назвою (обидва захищені). + Виберіть члена, щоб створити запис працівника. + Адреса + Афілійована організація + Афілійовані організації + Лише для інтегрованого підприємства: інші організації, які охоплює звіт. + Агрегувати рядки + Основа розподілу + За день + За мотогодину + За кілометр + За милю + За годину роботи + Сума + Річні дані оплати + Доходи W-2, години й тижні на працевлаштування за звітний рік — вхідні дані кожного знімка CRD. + Відсутні річні дані оплати + Застосувати перевизначення + Затвердження + Незатверджені профілі все ще оцінюють, але позначають кожен рядок для перевірки. Будь-яка зміна ставки скасовує затвердження. + Затвердити профіль + Затверджено + Затверджена вартість зарплати + Фактичне значення із системи зарплати; якщо є, замінює оцінку за цей день. + Файли експорту + CSV і XLSX у порядку стовпців шаблону CRD, зі стабільною контрольною сумою для незмінних даних, доступні без кешування до видалення після періоду зберігання. + Назад + Базова сума + За годину, рік, день або зміну відповідно до основи оплати. + Основа + Тендер + Дохід беззбитковості + Побудувати знімки + Приписаний до підрозділу в Каліфорнії + Обидва + Не працівник Каліфорнії + Працює в Каліфорнії + Підстава Каліфорнії + Працівники в Каліфорнії + № виклику + Скасувати + Ліміт + Категорія + Витратний матеріал + Витрата + Накладні + Персонал + Ресурс + Посилання на сертифікацію порталу + Контрольна сума + Місто + Дохід, розподілений на клієнта + Години, розподілені на клієнта + Тижні, розподілені на клієнта + Ключ розподілу клієнта + Код + Джерело збору + Кадровий запис + Як отримано цей запис; сприйняття спостерігача позначається в кожному звіті. + Сприйняття спостерігача + Інший надійний запис + Самоідентифікація + Підтвердити імпорт + Оплата + Профілі оплати працівника, ролі та підрозділу з компонентами оплати й витрат роботодавця. + Профіль оплати + Повнота демографії + Компонент + Компоненти + Створити коригувальний звіт? Експортований звіт залишається як поданий і позначається виправленим. + Видалити цей запис? + Заморозити цей розрахунок? Заморожений розрахунок незмінний; пізніший перерахунок його замінює. + Підтвердити імпорт? Кожен рядок створює нову версію даних. + Анулювати цей звіт? Його файли експорту буде видалено. + Витратні матеріали + Споживання + Контактні дані + Контекст + Тендер + Виклик + Залучення + Підрядники, що надають працівників, охоплених другим звітом CRD. + Виправити дані (нова версія) + Розподілені накладні + Податок роботодавця на зарплату + Фіксована вартість праці + Медичні пільги + Інше + Інші пільги + Пенсія + Компенсація працівникам + Фіксована річна + За день + За годину + За зміну + % від прийнятної оплати + Компоненти витрат + Податки на зарплату, пільги, пенсія та розподілені накладні — відсоток від прийнятної оплати, за годину, за зміну або фіксована річна, з необов'язковим лімітом. + Розрахунок витрат + Розрахунки витрат + Внутрішня повна вартість і маржа для тендерів (оцінка), викликів і залучень (фактичне). Дохід — завжди лише спостережуване число. + Охоплено — обидва звіти + Охоплено — звіт Labor Contractor Employee + Охоплено — звіт Payroll Employee + Декларується роботодавцем після ознайомлення з настановами CRD; Resgrid ніколи не вирішує, чи мусите ви подавати. + Не охоплено + Статус охоплення + Не задекларовано + Створити виправлення + Створити звіт + Створено + Валюта + Поточна відповідь + Захищені дані оплати, внутрішній розрахунок польових витрат і звітність про оплату в Каліфорнії. + Дата + Дні + Відпрацьовані дні + DBA + Відмовляюся відповідати + відмовилися + Підрозділ за замовчуванням + Профілі за замовчуванням + Профілі за замовчуванням ролі та підрозділу оцінюють тендери й членів без власного профілю. + Видалити + Раса/етнічність/стать + Демографічний запис + Запис відповідального за відповідність для працівника, який не самоідентифікувався. + Спершу використовуйте кадрові чи інші надійні записи; сприйняття спостерігача — крайній захід, позначений у кожному знімку. Причина обов'язкова й аудитується. + Відсутні демографічні відповіді + Відповідь добровільна. Відмова відповідати — дійсна відповідь і звітується як така. + Підрозділ + Дні залучення + Залучення + Ід. залучення + Амортизація + Амортизація / одиниця + Відображувана назва + Відстань + Введіть показники одометра або відстань; кілометри зберігаються як милі. + Одиниця відстані + Завантаження + Пробний запуск + Результат пробного запуску + Кінцевий термін + Джерело доходу + Розподілено на клієнта + W-2 поле 1 (резерв) + W-2 поле 5 + Використаний дохід + Адреса EDD + Редагувати призначення + Редагувати підрядника + Редагувати працевлаштування + Редагувати підрозділ + Редагувати профіль ресурсу + Редагувати показник + Редагувати запис + Діє з + Прийнятні коди оплати + Профілі працівника + Працівники + Роботодавець + Компоненти витрат роботодавця + витрати роботодавця + Ідентичність роботодавця, під якою подається звіт CRD, з афілійованими організаціями. + Профіль роботодавця ще не створено. + Профіль роботодавця + Розділ роботодавця + Працевлаштування + Тип зайнятості + Повна зайнятість + Періодична + Часткова зайнятість + Невідомо + Працевлаштування + Мотолічильник кінець + Одометр кінець + Кінець + Мотогодини + Помилки + Підрозділ + Підрозділи + Кожне фізичне місце, до якого приписані працівники; звіт CRD подається за кожним підрозділом. + Оцінка + Оцінка та фактичне + оцінено + Винятки + Фактичні + оплачувана відпустка + Дні × середні години + Замінний метод для звільнених + Немає + Звільнення + Звільнений + Не звільнений + Невідомо + Очікуване річне використання + В одиницях розподілу на рік; розподіляє фіксовані річні компоненти та часову амортизацію. + Витрати + Спливає + Діє до + Експорт звітів про оплату в Каліфорнії + Заморозити перевірений звіт і завантажити його файли CSV / XLSX та робочий аркуш порталу (без кешування, аудитовано). + Зовнішній ід. + Ключ зовнішнього ресурсу + Ід. запису тарифної сітки тут дає змогу оцінкам тендерів оцінювати рядки транспорту й обладнання за класом. + Зовнішнє джерело + Ключ зовнішнього працівника + Збережені дані незмінні: збереження створює нову версію, що замінює поточну. + резервний профіль + FEIN + Польові витрати + Внутрішня повна вартість і маржа для тендерів, викликів і залучень — лише агреговані категорії. + Файл + Електронна пошта контакту + Контактна особа для подання + Телефон контакту + Фільтр + Позначки + Формат + Заморозити + Заморозити й експортувати + Заморожено + Паливо + Фактична вартість палива + Кількість палива + Одиниця палива + Головний офіс + Адреса головного офісу + Кожна сума й множник — захищене поле. Оцінки використовують профіль працівника, потім ролі, потім підрозділу; затверджена вартість із системи зарплати завжди має пріоритет. + Охоплення декларуєте ви; Resgrid ніколи його не визначає. Ідентифікатори та адреси — захищені поля; незмінене значення REDACTED зберігає збережене. + Іспаномовний або латиноамериканець + Погодинна ставка + Години + Годин / день + Годин / тиждень + Годин / рік + Тип годин + Подвійні + Інше + Понаднормові + Оплачувана відпустка + Звичайні + Чергування + Відрядження + Тип ідентифікатора + Години простою + Імпорт річних даних (CSV) + Вставте експорт зарплати в канонічному порядку стовпців. Пробний запуск перевіряє й узгоджує кожен рядок перед збереженням; імпортовані дані незатверджені. + Результат імпорту + Поле 5 відсутнє — використано поле 1 (потрібна примітка) + дубльований рядок + дохід відсутній + жодне працевлаштування не охоплює рік + нічого імпортувати + звітні години відсутні або нуль + працівника не знайдено + некоректний рік + Включено + Інвентарний актив + Частина інтегрованого підприємства + Категорія посади CRD + Одна з десяти категорій посад CRD перевіреного профілю схеми. + Посада + Підрядник праці + Підрядники праці + Юридична назва + Рядок + Рядки + Рядки персоналу не містять ставки відкрито; оцінені деталі — захищене поле, видиме лише з чинним дозволом. + Рядки видно з правом «Перегляд оплати»; підсумок вище — агрегований вигляд. + Основна діяльність + Керування звітністю про оплату в Каліфорнії + Майстер звіту CRD: звіти, знімки, перевизначення, агрегація, перевірка, примітки, фіксація сертифікації, виправлення й демографічні записи відповідального за відповідність. Кожен член завжди відповідає за себе без цього права. + Керування персоналом і оплатою + Ідентичність роботодавця, підрозділи, працівники, періоди працевлаштування, призначення посад, профілі й компоненти оплати, записи роботи та імпорт річних даних. Значення все одно потребують чинного дозволу. + Походження зіставлення + Маржинальний дохід + Зафіксувати сертифікацію + Лише те, що ви спостерігали на порталі CRD після засвідчення там; Resgrid ніколи не засвідчує. + Профіль авторитету MARS + Класифікація Cal OES MARS + Середня погодинна ставка + Медіанна погодинна ставка + Член + Милі + Відсутні вхідні дані + Моя демографічна відповідь + Дайте або оновіть добровільну самоідентифікацію для звітності про оплату в Каліфорнії. + NAICS + Назва + Новий розрахунок + Ні + Без відповіді + Файлів експорту немає — заморозьте й експортуйте перевірений звіт. + Призначень посад немає. + Підрядників праці немає. + Періодів працевлаштування ще немає. + Підрозділів ще немає. + Немає річних даних за цей рік і тип звіту. + Рядків немає. + Профілів оплати немає. + Профілів витрат ресурсів немає. + Агрегованих рядків ще немає — агрегуйте після завершення знімків. + Розрахунків ще немає. + Знімків ще немає — спершу побудуйте їх. + Показників використання немає. + Записів у цьому періоді немає. + Працівників ще немає. + Не дистанційно + Немає + залишаються блокуючі помилки + Ще не перевірено. + сприйняття спостерігача + Відкриті звіти + Години роботи + Відстань (як зчитано) + Інше + Накладні + Причина перевизначення + Назва власника + Години оплачуваної відпустки + оплата + Діапазон оплати + Основа оплати + Денна + Погодинна + Оклад + За зміну + Стипендія + Надбавка + Освіта + EMS + HazMat + Стимул + Вислуга + Інше + Спеціальність + USAR + Фіксована річна + За годину + За розрахунковий період + За зміну + % від бази + Компоненти оплати + Надбавки, стимули та спеціальні виплати понад базу. Компоненти фіксованого періоду не виплачуються за понаднормові, якщо не позначені за годину понаднормових. + Resgrid готує й експортує; ніколи не подає, не вирішує, чи ви охоплені, і не засвідчує. Усе на цих екранах — захищені поля, що подаються без кешування. + Підготуйте звіти CRD Каліфорнії Payroll Employee і Labor Contractor Employee; ви подаєте їх самі на порталі CRD. + Звітність про оплату + Звіт + За год. понаднорм. + Персонал + Роль персоналу + Використовується для вибору профілю оплати за роллю, якщо у працівника його немає. + Фаза + Подія + Мобілізація + Повернення + Очікування + Адреса + Робочий аркуш порталу + Поштовий індекс + Повна вартість звичайного 8-годинного дня + немає перевіреного профілю схеми + Ідентифікатори, адреси, ставки оплати, доходи, демографічні відповіді та файли експорту — поля розширеного захисту даних (каталог 28). Без чинного дозволу вони показуються як REDACTED і ніколи не кешуються й не логуються. + Походження + Звідки походить ідентичність (договір, W-9, запис порталу). + Середні години на день + видалено + Кількість + Раса / етнічність + Виберіть усі відповідні категорії; дві або більше звітуються як багаторасові. + Білий + Чорношкірий або афроамериканець + Корінний гаваєць або мешканець островів Тихого океану + Азіат + Корінний американець або корінний мешканець Аляски + Близькосхідний або північноафриканський + Ставка + Множники ставок + JSON за кодом оплати; відсутні коди використовують 1,5 (понаднормові), 2 (подвійні) та 1. + Готовність + готово до заморозки + Причина + Узгоджено + Погодинний еквівалент + Необов'язкове перевизначення; інакше виводиться з базової суми та стандартних годин. + Відносини + Кінець відносин + Початок відносин + Дистанційно в CA + Дистанційно поза CA + Тип звіту + Звіт Labor Contractor Employee + Звіт Payroll Employee + Звітні години + Фіксована річна + За день + За залучення + За мотогодину + За годину простою + За кілометр + За милю + За годину роботи + Витратні матеріали + Амортизація + Фіксовані накладні + Паливо / енергія + Страхування / реєстрація + Лізинг / оренда + Обслуговування + Інше + Зберігання + Шини / знос + Паливо (ставка або споживання × ціна), обслуговування (вручну або ковзне фактичне з нарядів із вікном лічильника), шини, страхування, оренда, зберігання, фіксовані накладні та витратні матеріали. + Витрати ресурсів + Амортизація, паливо, обслуговування та фіксовані витрати на одиницю, актив або клас зовнішнього ресурсу. + Профілі ресурсів + Прості числові дані під правом «Перегляд внутрішніх витрат» — жодну особу тут не оцінюють. + Розраховано з придбання + Імпортовано + Вручну + Ковзне фактичне з нарядів + Використання ресурсів + Ресурси + Дохід + Оцінена сума тендеру + Cal OES MARS затверджене + Cal OES MARS очікуване + Cal OES MARS сплачене + Рахунки клієнта + Немає + Джерело доходу + Перевірка + використано затверджену вартість зарплати + використано профіль класу + компонент не затверджено + невідповідність валюти + використано профіль підрозділу + відсутні дані амортизації + автоматична й ручна відстань не збігаються + відсутні дані палива + немає профілю оплати + немає періоду працевлаштування для члена + немає ставки для основи + немає профілю ресурсу + відсутній множник коду оплати (використано типовий) + ремонт уже нараховано напряму + використано профіль ролі + недостатнє ковзне вікно обслуговування + незатверджений профіль + показник використання потребує перевірки + відсутнє річне використання + Рядки + Оцінити вартість тендеру + Рядки тендеру × оплата за замовчуванням підрозділу та профілі ресурсів класу; дохід = оцінена сума тендеру. + Розрахувати вартість виклику + Записи роботи та показники використання за викликом; без доходу. + Розрахувати вартість залучення + Затверджені денні звіти часу, показники використання та витрати до дати. + Пояснювальні примітки + До 500 символів; обов'язково, якщо використано дохід поля 1 або застосовано замінний метод. + Чернетка + Заморожено + Потребує перевірки + Замінено + Тип розрахунку + Фактичне + Оцінка + Звіти + Ліквідаційна вартість + Зберегти + Не вдалося зберегти зміну. + Зберегти відповідь + Збережено. + Профіль схеми + Область + За підрозділом + Працівник + За роллю + SEIN (EDD) + Добровільна самоідентифікація + Ваші відповіді зберігаються окремо від особової справи, зашифровані, і використовуються лише для підготовки агрегованого звіту CRD. Ніхто в підрозділі не бачить їх на іншому екрані. + самоідентифіковані + Стать + Жінка + Чоловік + Небінарна + Розмір + Пропущено + Знімок + Працівників у знімку + Кінець знімка + Початок знімка + Один розрахунковий період між + Знімки працівників + Один рядок на працівника Каліфорнії в періоді; демографічний код, дохід і погодинна ставка — захищені поля. Перевизначення потребують причини й аудитуються. + Код SOC + Версія SOC + Номер CA Secretary of State + Джерело + Мотолічильник початок + Одометр початок + Початок + Штат + Статус + Засвідчено зовнішньо + Виправлено + Чернетка + Експортовано + Заморожено + Перевірено + Анульовано + Об'єкт + Тип об'єкта + Зовнішній клас + Інвентарний актив + Одиниця + Підсумок + Річні дані оплати + Оплата + Підрядники праці + Розрахунки витрат + Огляд + Роботодавець + Підрозділи + Звітність про оплату + Витрати ресурсів + Записи роботи + Працівники + До дати + Часовий пояс + Усього + Загальна повна вартість + не затверджено + Одиниця + Ціна за одиницю + Невирішені винятки + Оновлено + Працівники в США + Записи використання + Показники одометра, мотолічильника, годин і палива на одиницю та день; відстань зберігається в милях, суперечливі показники ставляться на перевірку. + Денний звіт часу + GPS + Апаратний трекер + Імпорт + Вручну + Строк служби (місяців) + Строк служби (одиниць) + Милі, години або дні протягом строку; лінійна = (вартість − ліквідаційна) ÷ строк. + Перевірити + Перевірено + Перевірка + річні дані оплати відсутні + річні дані оплати не затверджено + перекриття призначень посад + ідентичність підрядника праці відсутня + статус охоплення не задекларовано + демографічна відповідь відсутня або відхилена + використано W-2 поле 1 замість поля 5 (потрібна примітка) + дохід відсутній + кількості в рядках не збігаються зі знімками + неповна ідентичність роботодавця (назва, FEIN, SEIN, адреса EDD) + неповна адреса підрозділу + підрозділ відсутній + NAICS підрозділу відсутній + використано замінний метод (потрібна примітка) + поле перевищує довжину шаблону + експорт перевищить ліміт розміру файлу порталу + звітні години дорівнюють нулю + категорія посади відсутня + категорія посади відсутня в профілі схеми + застосовано ручне перевизначення + немає працівників у знімку + використано сприйняття спостерігача для демографічного коду + профіль схеми змінився з моменту створення + дистанційні кількості не збігаються з кількістю працівників + обов'язковий стовпець шаблону порожній + рядки ще не агреговано + період знімка поза дозволеним вікном + відпрацьовані тижні відсутні + режим роботи не визначено (немає призначення) + Відхилення + Версія + Перегляд внутрішніх витрат + Агреговані підсумки польових витрат і маржі для тендерів, викликів і залучень — категорії й суми, ніколи рядок, ставка чи особа — плюс профілі витрат ресурсів і показники використання. + Перегляд оплати + Читання профілів оплати, записів роботи, річних даних і рядків розрахунків витрат. Значення все одно потребують чинного дозволу. + Анулювати + W-2 поле 1 + W-2 поле 5 + Попередження + Звітовано за попередній рік + Відпрацьовані тижні + Чому ми запитуємо + Розділ 12999 Урядового кодексу Каліфорнії вимагає від охоплених роботодавців звітувати про дані оплати, згруповані за категорією посади, расою/етнічністю та статтю. Звіт містить лише агреговані кількості й ставки; він нікого не називає. + 1. Побудувати знімки працівників · 2. Агрегувати рядки · 3. Перевірити · 4. Заморозити й експортувати · потім засвідчити на порталі CRD і зафіксувати сертифікацію тут. + Країна + Записи роботи + Години на працівника та день із зарплати, залучень і викликів; затверджена зарплатою вартість, якщо є, замінює будь-яку оцінку. + Місце роботи + Режим роботи + Не дистанційно + Дистанційно поза Каліфорнією (приписаний до підрозділу CA) + Дистанційно в Каліфорнії + Штат / область + Працівник + Періоди працевлаштування ніколи не перетинаються; кожен період містить призначення посад у часі. + Тип працівника + Незалежний підрядник + Працівник підрядника праці + Штатний працівник + Волонтер + Працівники + Кожна особа, якій підрозділ платить або про яку звітує, — члени та зовнішні працівники — з періодами працевлаштування. + Персонал + Значення рівня роботодавця, які ви вводите на порталі CRD разом із завантаженим файлом. + Подається без кешування й аудитується; закрийте вкладку після завершення. Resgrid ніколи не входить на портал. + Рік + Так + Файл експорту не знайдено. + Файл експорту сплив або видалений. + Посилання на сертифікацію порталу обов'язкове. + Джерело збору недійсне. + Коригувальний звіт уже існує. + Відповідь «іспаномовний або латиноамериканець» недійсна. + Спершу побудуйте знімки працівників. + Жоден перевірений профіль схеми CRD не охоплює цей звітний рік. + Код раси/етнічності недійсний. + Дайте відповідь про расу/етнічність або відмовтеся відповідати. + Причина обов'язкова. + Примітки обмежені 500 символами. + Засвідчений звіт не можна анулювати; створіть виправлення. + Звіт заморожено; натомість створіть виправлення. + Лише експортований звіт можна позначити засвідченим. + Звіт не знайдено. + Лише експортований або засвідчений звіт можна виправити. + Код статі недійсний. + Дайте відповідь про стать або відмовтеся відповідати. + Знімок працівника не знайдено. + Період знімка має бути одним розрахунковим періодом у дозволеному вікні. + Перевірка все ще повідомляє про блокуючі помилки. + Процес звітності не зміг розшифрувати захищені значення. + Юридична назва афілійованої організації обов'язкова. + Основа розподілу недійсна. + Сума від'ємна. + Інвентарний актив обов'язковий. + Призначення перетинається з іншим призначенням цього працевлаштування. + Тендер не знайдено. + Категорія або основа компонента недійсна. + Юридична назва підрядника обов'язкова. + Працівник підрядника потребує підрядника праці. + Статус охоплення недійсний. + Дата закінчення раніша за дату початку. + Залучення не знайдено. + Юридична назва роботодавця обов'язкова. + Працевлаштування не знайдено. + Період перетинається з іншим працевлаштуванням цього працівника. + Інший підрозділ уже використовує цей код. + Підрозділ потребує коду та назви. + Підрозділ не знайдено. + Ключ зовнішнього ресурсу обов'язковий. + Години мають бути від 0 до 24. + Тип годин недійсний. + Категорії посади немає в профілі схеми. + NAICS має складатися з шести цифр. + Нічого розраховувати: немає записів роботи чи показників використання. + Запис не знайдено. + Основа оплати недійсна. + Період перетинається з іншим профілем тієї ж області. + Захищений запис відхилено. + Тип звіту недійсний. + Профіль ролі потребує ролі персоналу. + Розрахунок заморожено; його не можна змінити. + Область профілю недійсна. + Тип об'єкта недійсний. + Одиницю не знайдено. + Одиниця обов'язкова. + Показник використання потребує залучення або виклику. + Показник використання недійсний. + Режим роботи недійсний. + Цей член уже має запис працівника. + Працівник потребує члена або зовнішнього ключа. + Тип працівника недійсний. + Працівника не знайдено. + Працівник обов'язковий. + Звітний рік недійсний. + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 7349ff29..265f2692 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -304,6 +304,42 @@ public enum AuditLogTypes BidConverted, BidDeleted, TimeReportBilled, - DeploymentInvoiceGenerated + DeploymentInvoiceGenerated, + + // Workforce & Business Operations plan Phase C-M3 (Cal OES MARS cost recovery). Append-only. + CalOesMarsAgencyProfileChanged, + CalOesMarsResourceProfileChanged, + CalOesMarsRateProfileChanged, + CalOesMarsRateDraftBuilt, + CalOesMarsRateReviewed, + CalOesMarsAgreementChanged, + CalOesMarsAgreementObserved, + CalOesMarsWorkItemPrepared, + CalOesMarsWorkItemValidated, + CalOesMarsReimbursementCalculated, + CalOesMarsWorkItemOpenedForHandoff, + CalOesMarsExternalStatusObserved, + CalOesMarsInvoiceApproved, + CalOesMarsInvoiceRejected, + CalOesMarsPaymentReconciled, + CalOesMarsWorkItemDeleted, + + // Workforce & Business Operations plan Phase E (protected workforce pay data, field costing, California pay data reporting). Append-only. + WorkforceEmployerProfileChanged, + WorkforceEstablishmentChanged, + WorkforceEmploymentChanged, + WorkforceCompensationChanged, + WorkforceAnnualPayFactImported, + PayDataDemographicChanged, + PayDataReportCreated, + PayDataReportValidated, + PayDataReportFrozen, + PayDataReportExported, + PayDataReportMarkedCertified, + PayDataReportCorrected, + ResourceCostProfileChanged, + ResourceUsageChanged, + FieldCostRunCreated, + FieldCostRunFrozen } } diff --git a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsAuthorityProfile.cs b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsAuthorityProfile.cs new file mode 100644 index 00000000..3b9058ec --- /dev/null +++ b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsAuthorityProfile.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.CostRecovery.CalOesMars +{ + /// + /// A reviewed, versioned description of the official Cal OES MARS / CFAA material (plan decision 38): sources by + /// URL, publication date and checksum, the F-42 field rules, request-number prefixes, external status vocabulary + /// and the administrative de-minimis option. It is a code/data contract, not a table: a changed form, rate letter, + /// CFAA term or portal status creates a new profile with a new code, and an earlier dispatch keeps the profile it + /// was prepared against. Rates are never here — they are department data on . + /// + public sealed class CalOesMarsAuthorityProfile + { + public string Code { get; } + public DateTime ReviewedOn { get; } + public DateTime EffectiveOn { get; } + public DateTime? SupersededOn { get; } + /// Official material this profile pins. Checksums are recorded by the reviewer at review time. + public IReadOnlyList Sources { get; } + /// F-42 boxes in official order with the local validation rule each carries. + public IReadOnlyList F42Boxes { get; } + /// Request-number prefixes the ordering system issues (E equipment, O overhead, C crew, S supply, A aircraft). + public IReadOnlyList RequestPrefixes { get; } + /// External status vocabulary → local mirror state, for F-42 / expense records. + public IReadOnlyDictionary RecordStatusMap { get; } + /// External status vocabulary → local mirror state, for generated invoices. + public IReadOnlyDictionary InvoiceStatusMap { get; } + /// The administrative-rate de-minimis option the CFAA instructions offered on the review date (percent). + public decimal DeMinimisAdministrativePercent { get; } + /// Pinned MARS / F-5 resource type vocabulary. + public IReadOnlyList ResourceTypes { get; } + /// Personnel classification codes the Salary Survey expects. + public IReadOnlyList SalaryClassifications { get; } + + public bool IsReviewed => Sources.Count > 0 && F42Boxes.Count > 0; + public bool IsCurrent(DateTime asOf) => asOf.Date >= EffectiveOn.Date && (!SupersededOn.HasValue || asOf.Date < SupersededOn.Value.Date); + + private CalOesMarsAuthorityProfile(string code, DateTime reviewedOn, DateTime effectiveOn, DateTime? supersededOn, IReadOnlyList sources, + IReadOnlyList boxes, IReadOnlyList prefixes, IReadOnlyDictionary recordStatuses, + IReadOnlyDictionary invoiceStatuses, decimal deMinimis, IReadOnlyList resourceTypes, IReadOnlyList classifications) + { + Code = code; ReviewedOn = reviewedOn; EffectiveOn = effectiveOn; SupersededOn = supersededOn; Sources = sources; F42Boxes = boxes; RequestPrefixes = prefixes; + RecordStatusMap = recordStatuses; InvoiceStatusMap = invoiceStatuses; DeMinimisAdministrativePercent = deMinimis; ResourceTypes = resourceTypes; SalaryClassifications = classifications; + } + + /// The first implementation profile: the official material current on 2026-08-21 (plan decision 38). + public const string CurrentCode = "CFAA-2026-08-21"; + + public static readonly IReadOnlyList All = new[] { Build20260821() }; + + public static CalOesMarsAuthorityProfile Current => All.Last(); + + public static CalOesMarsAuthorityProfile Get(string code) => All.FirstOrDefault(p => string.Equals(p.Code, code, StringComparison.OrdinalIgnoreCase)); + + /// The profile effective on a dispatch date, or null when none covers it (fail closed for new submissions). + public static CalOesMarsAuthorityProfile ForDispatch(DateTime dispatchOn) => All.Where(p => p.IsCurrent(dispatchOn)).OrderByDescending(p => p.EffectiveOn).FirstOrDefault(); + + public CalOesMarsLocalStates? MapRecordStatus(string externalStatus) => Map(RecordStatusMap, externalStatus); + public CalOesMarsLocalStates? MapInvoiceStatus(string externalStatus) => Map(InvoiceStatusMap, externalStatus); + + private static CalOesMarsLocalStates? Map(IReadOnlyDictionary map, string externalStatus) + { + if (string.IsNullOrWhiteSpace(externalStatus)) return null; + var key = map.Keys.FirstOrDefault(k => string.Equals(k, externalStatus.Trim(), StringComparison.OrdinalIgnoreCase)); + return key == null ? null : map[key]; + } + + private static CalOesMarsAuthorityProfile Build20260821() + { + var sources = new[] + { + new CalOesMarsAuthoritySource("cfaa", "California Fire Assistance Agreement (CFAA) 2020-2025 and addenda", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/", new DateTime(2020, 1, 1)), + new CalOesMarsAuthoritySource("mars-help", "Cal OES MARS user help, F-42 checklist and FAQ", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/", new DateTime(2026, 8, 21)), + new CalOesMarsAuthoritySource("salary-survey", "CFAA Salary Survey and Administrative Rate instructions", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/", new DateTime(2026, 8, 21)), + new CalOesMarsAuthoritySource("rate-letter", "Cal OES Rate Letter (apparatus, support vehicle, POV mileage, per diem)", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/", new DateTime(2026, 8, 21)), + new CalOesMarsAuthoritySource("f5", "Cal OES Form F-5 resource inventory instructions", "https://www.caloes.ca.gov/office-of-the-director/operations/response-operations/fire-rescue/", new DateTime(2026, 8, 21)) + }; + var boxes = new[] + { + new CalOesMarsF42Box("agency", "Responding agency / MACS designator", true, "AgencyProfile"), + new CalOesMarsF42Box("incident", "Incident name and number", true, "Deployment"), + new CalOesMarsF42Box("order", "Incident order number", true, "ExternalOrder"), + new CalOesMarsF42Box("request", "Request number (prefixed)", true, "ExternalOrderFill"), + new CalOesMarsF42Box("resource", "Resource type / strike team / task force", true, "ExternalOrderFill"), + new CalOesMarsF42Box("dispatch", "Dispatch (commitment) date and time", true, "ExternalOrderFill"), + new CalOesMarsF42Box("return", "Return or redispatch date and time", true, "ExternalOrderFill"), + new CalOesMarsF42Box("apparatus", "Apparatus / support vehicle identifiers", false, "Roster"), + new CalOesMarsF42Box("personnel", "Personnel, rank and commitment or actual hours", true, "Roster"), + new CalOesMarsF42Box("rotation", "Crew rotations (approval evidence)", false, "Attachments"), + new CalOesMarsF42Box("comments", "Comments / loss / damage / supply numbers", false, "Snapshot"), + new CalOesMarsF42Box("responding-signature", "Responding agency representative signature", true, "Snapshot"), + new CalOesMarsF42Box("incident-signature", "Incident / AREP authorization", true, "Snapshot"), + new CalOesMarsF42Box("attachments", "Supporting attachments (paper F-42 or signed copy)", true, "Attachments") + }; + var recordStatuses = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Cal OES Review"] = CalOesMarsLocalStates.SubmittedExternal, + ["Agency Review"] = CalOesMarsLocalStates.ReturnedForAgencyReview, + ["Approved"] = CalOesMarsLocalStates.Approved, + ["Documentation Only"] = CalOesMarsLocalStates.DocumentationOnly + }; + var invoiceStatuses = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Pending Local Agency Approval"] = CalOesMarsLocalStates.PendingLocalAgencyApproval, + ["Local Agency Rejected"] = CalOesMarsLocalStates.LocalAgencyRejected, + ["Pending Paying Entity Approval"] = CalOesMarsLocalStates.PendingPayingEntityApproval, + ["Paid"] = CalOesMarsLocalStates.Paid, + ["Documentation Only"] = CalOesMarsLocalStates.DocumentationOnly + }; + var resourceTypes = new[] + { + "Type 1 Engine", "Type 2 Engine", "Type 3 Engine", "Type 4 Engine", "Type 5 Engine", "Type 6 Engine", "Type 7 Engine", + "Water Tender", "Type 1 Dozer", "Type 2 Dozer", "Hand Crew", "Overhead", "Support Vehicle", "Command Vehicle", "Ambulance", "Rescue", "Other" + }; + var classifications = new[] { "Fire Chief", "Deputy Chief", "Division Chief", "Battalion Chief", "Captain", "Lieutenant", "Engineer", "Firefighter", "Firefighter/Paramedic", "Non-suppression" }; + return new CalOesMarsAuthorityProfile(CurrentCode, new DateTime(2026, 8, 21), new DateTime(2020, 1, 1), null, sources, boxes, new[] { "E", "O", "C", "S", "A" }, + recordStatuses, invoiceStatuses, 10m, resourceTypes, classifications); + } + } + + public sealed class CalOesMarsAuthoritySource + { + public string Key { get; } + public string Title { get; } + public string Url { get; } + public DateTime PublishedOn { get; } + /// Recorded by the reviewer; null until the artifact is checksummed. + public string Checksum { get; } + + public CalOesMarsAuthoritySource(string key, string title, string url, DateTime publishedOn, string checksum = null) + { + Key = key; Title = title; Url = url; PublishedOn = publishedOn; Checksum = checksum; + } + } + + public sealed class CalOesMarsF42Box + { + public string Id { get; } + public string Label { get; } + public bool Required { get; } + /// Where the value is sourced from (AgencyProfile, Deployment, ExternalOrder, ExternalOrderFill, Roster, Attachments, Snapshot). + public string Source { get; } + + public CalOesMarsF42Box(string id, string label, bool required, string source) + { + Id = id; Label = label; Required = required; Source = source; + } + } +} diff --git a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsContracts.cs b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsContracts.cs new file mode 100644 index 00000000..817c01fc --- /dev/null +++ b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsContracts.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.CostRecovery.CalOesMars +{ + // Workforce & Business Operations plan, Phase C-M3: the typed snapshots a work item carries, the readiness / + // validation / handoff results the screens show, and the pure calculator's input and output. + + #region Readiness + + public enum CalOesMarsReadinessSeverities + { + Ok = 0, + Warning = 1, + Blocker = 2 + } + + public sealed class CalOesMarsReadinessItem + { + public string Key { get; set; } + /// . + public int Severity { get; set; } + /// Localization key on the CalOesMars family. + public string MessageKey { get; set; } + public string Detail { get; set; } + /// The screen that fixes it ("Agency", "Resources", "Rates", "Agreements"). + public string Area { get; set; } + } + + public sealed class CalOesMarsReadiness + { + public DateTime AsOf { get; set; } + public string AuthorityProfileCode { get; set; } + public bool AuthorityProfileCurrent { get; set; } + public CalOesMarsAgencyProfile Agency { get; set; } + public List Items { get; set; } = new List(); + public int ResourceProfiles { get; set; } + public int ResourceMismatches { get; set; } + public List CurrentRateProfiles { get; set; } = new List(); + public List CurrentAgreements { get; set; } = new List(); + public int OpenWorkItems { get; set; } + public int ReturnedWorkItems { get; set; } + public int InvoicesAwaitingLocalApproval { get; set; } + public bool IsReady => Items.All(i => i.Severity != (int)CalOesMarsReadinessSeverities.Blocker); + } + + #endregion + + #region Snapshots (SnapshotJson) + + /// The prepared F-42: one per ordered resource / request (a redispatch is a new work item). + public sealed class CalOesMarsF42Snapshot + { + public string AuthorityProfileCode { get; set; } + public string MacsDesignator { get; set; } + public string AgencyName { get; set; } + public string IncidentName { get; set; } + public string IncidentNumber { get; set; } + public string OrderNumber { get; set; } + public string RequestNumber { get; set; } + public string ParentRequestNumber { get; set; } + public string ResourceKind { get; set; } + public string ResourceType { get; set; } + public string StrikeTeamOrTaskForce { get; set; } + public string ReportingLocation { get; set; } + public string PointOfHire { get; set; } + public DateTime? DispatchedOn { get; set; } + public DateTime? CommittedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public DateTime? ReturnedOn { get; set; } + public bool IsRedispatch { get; set; } + public string PreviousOrderNumber { get; set; } + public string PreviousRequestNumber { get; set; } + public string OverheadPosition { get; set; } + public List Vehicles { get; set; } = new List(); + public List Personnel { get; set; } = new List(); + public List Rotations { get; set; } = new List(); + public string Comments { get; set; } + public string LossDamage { get; set; } + public string SupplyNumbers { get; set; } + public string RespondingSignerName { get; set; } + public DateTime? RespondingSignedOn { get; set; } + public string IncidentAuthorizerName { get; set; } + public DateTime? IncidentAuthorizedOn { get; set; } + public bool DocumentationOnly { get; set; } + public List AttachmentIds { get; set; } = new List(); + /// The DTR ids that pre-filled the time facts (provenance; a DTR is not an F-42). + public List SourceTimeReportIds { get; set; } = new List(); + } + + public sealed class CalOesMarsF42Vehicle + { + /// "Apparatus", "Support", "POV", "Equipment". + public string Kind { get; set; } + public string DeploymentUnitId { get; set; } + public string DeploymentEquipmentId { get; set; } + public string ResourceProfileId { get; set; } + public string Designator { get; set; } + public string ResourceCode { get; set; } + public string LicensePlate { get; set; } + public string Vin { get; set; } + public string SerialNumber { get; set; } + public decimal? StartOdometer { get; set; } + public decimal? EndOdometer { get; set; } + public decimal? Miles { get; set; } + public decimal CommittedHours { get; set; } + public decimal CommittedDays { get; set; } + public string FemaCode { get; set; } + } + + public sealed class CalOesMarsF42Person + { + public string DeploymentPersonnelId { get; set; } + public string UserId { get; set; } + public string Name { get; set; } + public string Rank { get; set; } + /// Salary Survey classification code. + public string ClassificationCode { get; set; } + public DateTime? CommittedOn { get; set; } + public DateTime? ReleasedOn { get; set; } + public decimal CommittedHours { get; set; } + /// Per-day actual hours from the DTRs when the agreement is actual-hours. + public List ActualHours { get; set; } = new List(); + } + + public sealed class CalOesMarsDailyHours + { + public DateTime Date { get; set; } + public decimal Hours { get; set; } + public string TimeReportId { get; set; } + } + + public sealed class CalOesMarsF42Rotation + { + public DateTime On { get; set; } + public string OutgoingUserId { get; set; } + public string IncomingUserId { get; set; } + public int? ApprovalAttachmentId { get; set; } + } + + /// The prepared expense claim linked to a resource / F-42. + public sealed class CalOesMarsExpenseClaimSnapshot + { + public string AuthorityProfileCode { get; set; } + public string F42WorkItemId { get; set; } + public string RequestNumber { get; set; } + public string IncidentNumber { get; set; } + /// The portal's unmatched / travel-only path when no F-42 exists for the resource. + public bool TravelOnly { get; set; } + public List Lines { get; set; } = new List(); + public string SignerName { get; set; } + public DateTime? SignedOn { get; set; } + public string ApproverName { get; set; } + public DateTime? ApprovedOn { get; set; } + } + + public sealed class CalOesMarsExpenseLine + { + public string DeploymentExpenseId { get; set; } + public DateTime Date { get; set; } + public string City { get; set; } + /// "Meal", "Lodging", "Miscellaneous". + public string Category { get; set; } + public decimal Amount { get; set; } + public string Description { get; set; } + public int? ReceiptAttachmentId { get; set; } + public bool PreApproved { get; set; } + } + + /// The observed MARS-generated invoice. + public sealed class CalOesMarsInvoiceSnapshot + { + public string MarsInvoiceId { get; set; } + public DateTime? InvoiceDate { get; set; } + public decimal? InvoicedTotal { get; set; } + public string PayingEntity { get; set; } + public List CoveredWorkItemIds { get; set; } = new List(); + public int? InvoiceAttachmentId { get; set; } + public string LocalDecisionComment { get; set; } + public string LocalDecisionTitle { get; set; } + } + + #endregion + + #region Validation and handoff + + public sealed class CalOesMarsValidationIssue + { + public string Box { get; set; } + public string Code { get; set; } + public string Detail { get; set; } + } + + public sealed class CalOesMarsValidationResult + { + public string WorkItemId { get; set; } + public string AuthorityProfileCode { get; set; } + public DateTime ValidatedOn { get; set; } + public List Errors { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + public bool IsReadyForPortal => Errors.Count == 0; + } + + public sealed class CalOesMarsHandoffField + { + public string Box { get; set; } + public string Label { get; set; } + public string Value { get; set; } + public string Source { get; set; } + } + + /// A no-store, side-by-side copy view for the portal. Opening it never marks anything submitted. + public sealed class CalOesMarsHandoffManifest + { + public string WorkItemId { get; set; } + public int RecordType { get; set; } + public string AuthorityProfileCode { get; set; } + public string RateProfileVersion { get; set; } + public string AgreementSnapshotId { get; set; } + public DateTime GeneratedOn { get; set; } + public string GeneratedByUserId { get; set; } + public string Checksum { get; set; } + public string PortalUrl { get; set; } + public List Fields { get; set; } = new List(); + public List SupportingAttachmentNames { get; set; } = new List(); + public CalOesMarsValidationResult Validation { get; set; } + /// Always true in P0: the packet is evidence, not an accepted MARS import file. + public bool NotAnImportFile => true; + } + + /// An observed external fact recorded by a MARS manager (P0: manual observation only). + public sealed class CalOesMarsExternalObservation + { + public string ExternalId { get; set; } + public string ExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public string Source { get; set; } = CalOesMarsObservationSources.Manual; + public string Comment { get; set; } + public string ArtifactChecksum { get; set; } + public int? ArtifactAttachmentId { get; set; } + } + + public sealed class CalOesMarsInvoiceObservation + { + public string MarsInvoiceId { get; set; } + public DateTime? InvoiceDate { get; set; } + public decimal InvoicedTotal { get; set; } + public string PayingEntity { get; set; } + public string ExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public List CoveredWorkItemIds { get; set; } = new List(); + public int? InvoiceAttachmentId { get; set; } + public string Comment { get; set; } + } + + public sealed class CalOesMarsPaymentObservation + { + public decimal PaidTotal { get; set; } + public DateTime PaidOn { get; set; } + public string PaymentReference { get; set; } + public string PayingEntityStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public string Comment { get; set; } + } + + #endregion + + #region Calculator + + /// Everything the pure calculator needs; the service assembles it from the snapshot, the effective rate profiles and the agreement. + public sealed class CalOesMarsReimbursementInput + { + public CalOesMarsF42Snapshot F42 { get; set; } + public CalOesMarsExpenseClaimSnapshot Expenses { get; set; } + public List RateLines { get; set; } = new List(); + public CalOesMarsAgreementSnapshot Agreement { get; set; } + /// Administrative rate percent applied to eligible personnel reimbursement; null = no administrative line. + public decimal? AdministrativeRatePercent { get; set; } + public string RateProfileVersion { get; set; } + } + + public sealed class CalOesMarsReimbursementResult + { + public List Lines { get; set; } = new List(); + public List Exceptions { get; set; } = new List(); + public decimal ExpectedTotal => Lines.Where(l => l.EligibilityState == (int)CalOesMarsEligibilityStates.Eligible).Sum(l => l.ExpectedAmount); + public decimal UncertainTotal => Lines.Where(l => l.EligibilityState == (int)CalOesMarsEligibilityStates.Uncertain).Sum(l => l.ExpectedAmount); + } + + public static class CalOesMarsExceptionCodes + { + public const string NoAgreement = "no_agreement"; + public const string NoSalaryRate = "no_salary_rate"; + public const string NoApparatusRate = "no_apparatus_rate"; + public const string NoSupportRate = "no_support_rate"; + public const string NoPovRate = "no_pov_rate"; + public const string NoSpecialEquipmentRate = "no_special_equipment_rate"; + public const string NoAdministrativeRate = "no_administrative_rate"; + public const string OvertimePerAgreement = "overtime_per_agreement"; + public const string NoActualHours = "no_actual_hours"; + public const string ExpenseWithoutReceipt = "expense_without_receipt"; + public const string ExpenseNotPreApproved = "expense_not_pre_approved"; + public const string MileageWithoutOdometer = "mileage_without_odometer"; + } + + public static class CalOesMarsValidationCodes + { + public const string AuthorityProfileMissing = "authority_profile_missing"; + public const string AgencyProfileMissing = "agency_profile_missing"; + public const string MacsMissing = "macs_missing"; + public const string IncidentMissing = "incident_missing"; + public const string OrderMissing = "order_missing"; + public const string RequestMissing = "request_missing"; + public const string RequestPrefixInvalid = "request_prefix_invalid"; + public const string ResourceMissing = "resource_missing"; + public const string DispatchMissing = "dispatch_missing"; + public const string ReturnBeforeDispatch = "return_before_dispatch"; + public const string ReleaseIsNotReturn = "release_is_not_return"; + public const string PersonnelMissing = "personnel_missing"; + public const string PersonnelIntervalOutside = "personnel_interval_outside"; + public const string DuplicateVehicle = "duplicate_vehicle"; + public const string VehicleNotInInventory = "vehicle_not_in_inventory"; + public const string RotationUndocumented = "rotation_undocumented"; + public const string RespondingSignatureMissing = "responding_signature_missing"; + public const string IncidentAuthorizationMissing = "incident_authorization_missing"; + public const string PaperFallbackMissing = "paper_fallback_missing"; + public const string AgreementMissing = "agreement_missing"; + public const string RateProfileMissing = "rate_profile_missing"; + public const string ClassificationUnmapped = "classification_unmapped"; + public const string ExpenseNoLines = "expense_no_lines"; + public const string ExpenseReceiptMissing = "expense_receipt_missing"; + public const string ExpenseF42NotSubmitted = "expense_f42_not_submitted"; + public const string ExpenseSignatureMissing = "expense_signature_missing"; + public const string ExpenseApprovalMissing = "expense_approval_missing"; + public const string DocumentationOnly = "documentation_only"; + } + + #endregion + + #region Queue + + public sealed class CalOesMarsQueueItem + { + public CalOesMarsWorkItem WorkItem { get; set; } + public string DeploymentName { get; set; } + public string IncidentNumber { get; set; } + public string RequestNumber { get; set; } + public int ErrorCount { get; set; } + public int WarningCount { get; set; } + public int AgeDays { get; set; } + public bool IsMine { get; set; } + } + + #endregion +} diff --git a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs new file mode 100644 index 00000000..4bc2dc1d --- /dev/null +++ b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.CostRecovery.CalOesMars +{ + // Workforce & Business Operations plan, Phase C-M3 (C1/C11; registry M0219): the Cal OES MARS shadow tables. They + // are local mirrors of an external system of record — Resgrid prepares, validates, estimates and reconciles; Cal OES + // MARS accepts, invoices and pays. No portal credential, MFA token or browser session material is stored anywhere here. + // Nothing in these tables is under Advanced Data Protection (decision 44): every value is either printed on a claim + // the paying entity reads or is the department's own public agency information. The reserved IsProtected / + // ProtectedCatalogVersion columns are never set. + + /// The department's MARS agency record (one per department). + public class CalOesMarsAgencyProfile : IEntity + { + [Required] + public string CalOesMarsAgencyProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// The reviewed this row was prepared against. + public string AuthorityProfileCode { get; set; } + /// The Cal OES MACS agency designator (e.g. "XLA" style three-letter identifier). + public string MacsDesignator { get; set; } + public string AgencyName { get; set; } + /// Cal OES agency category (city, county, fire district, …). + public string AgencyCategory { get; set; } + public string ContactName { get; set; } + public string ContactPhone { get; set; } + public string ContactEmail { get; set; } + public string Address { get; set; } + /// Federal employer identification number as entered for MARS; printed on the claim, so stored as data. + public string FeinReference { get; set; } + /// SAM.gov unique entity identifier. + public string UeiReference { get; set; } + /// SAM registration reference / expiry note. + public string SamReference { get; set; } + /// FI$Cal supplier id. + public string FiscalSupplierReference { get; set; } + /// The department's MARS portal role (Primary / Secondary) — a reference, never a credential. + public string PortalAccountRole { get; set; } + public string PortalAccountReference { get; set; } + public DateTime? VerifiedOn { get; set; } + public string VerifiedByUserId { get; set; } + public bool IsActive { get; set; } = true; + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "CalOesMarsAgencyProfiles"; + [NotMapped] public string IdName => "CalOesMarsAgencyProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsAgencyProfileId; set => CalOesMarsAgencyProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// F-5 resource-inventory crosswalk: one Resgrid Unit / asset / external resource to its MARS identity. Never a live status master. + public class CalOesMarsResourceProfile : IEntity + { + [Required] + public string CalOesMarsResourceProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int SubjectType { get; set; } + public int? UnitId { get; set; } + public string InventoryAssetId { get; set; } + public string ExternalResourceName { get; set; } + /// The identifier MARS / F-5 shows for this resource once accepted. + public string MarsResourceId { get; set; } + /// Pinned resource type code (e.g. "Type 1 Engine"). + public string ResourceType { get; set; } + public string ResourceKind { get; set; } + public string CodeScheme { get; set; } + public string UnitDesignator { get; set; } + public string LicensePlate { get; set; } + public string Vin { get; set; } + public string SerialNumber { get; set; } + /// . + public int Ownership { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string ObservedExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + /// . + public int ReviewState { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string SubjectName { get; set; } + public bool IsCurrent(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "CalOesMarsResourceProfiles"; + [NotMapped] public string IdName => "CalOesMarsResourceProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsResourceProfileId; set => CalOesMarsResourceProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "SubjectName" }; + } + + /// An annual rate submission snapshot (Salary Survey, Attachment A, Administrative Rate, Rate Letter, Special Equipment) with its lines. + public class CalOesMarsRateProfile : IEntity + { + [Required] + public string CalOesMarsRateProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + public int SubmissionYear { get; set; } + /// . + public int SubmissionType { get; set; } + /// . + public int Status { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// The agency accepted the Cal OES base rate instead of submitting its own survey. + public bool BaseRateAccepted { get; set; } + /// . + public int AdministrativeRateMethod { get; set; } + /// Administrative rate as a percentage (e.g. 10.0000). + public decimal? AdministrativeRateValue { get; set; } + public string AuthorityProfileCode { get; set; } + public string SourceUrl { get; set; } + public DateTime? SourceDate { get; set; } + public DateTime? SignedOn { get; set; } + public string SignedByName { get; set; } + public string ObservedExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { 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 Lines { get; set; } = new List(); + [NotMapped] public List AdministrativeInputs { get; set; } = new List(); + [NotMapped] public bool IsEditable => Status is (int)CalOesMarsRateProfileStatuses.Draft or (int)CalOesMarsRateProfileStatuses.Reviewed; + public bool IsCurrent(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date) && Status != (int)CalOesMarsRateProfileStatuses.Superseded; + + [NotMapped] public string TableName => "CalOesMarsRateProfiles"; + [NotMapped] public string IdName => "CalOesMarsRateProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsRateProfileId; set => CalOesMarsRateProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Lines", "AdministrativeInputs", "IsEditable" }; + } + + public class CalOesMarsRateLine : IEntity + { + [Required] + public string CalOesMarsRateLineId { get; set; } + [Required] + public string CalOesMarsRateProfileId { get; set; } + public int DepartmentId { get; set; } + /// . + public int LineKind { get; set; } + /// Personnel classification (rank) code for salary lines. + public string ClassificationCode { get; set; } + /// Apparatus / support / equipment resource code for equipment lines. + public string ResourceCode { get; set; } + public string FemaCode { get; set; } + public string Description { get; set; } + /// . + public int Basis { get; set; } + public decimal? StraightRate { get; set; } + public decimal? OvertimeRate { get; set; } + public bool IncludesWorkersComp { get; set; } + public bool IncludesUnemploymentInsurance { get; set; } + public bool PortalToPortalEligible { get; set; } + public bool OvertimeEligible { get; set; } + /// . + public int Authority { get; set; } + /// Versions of the inputs (pay data, source documents) this line was derived from. + public string SourceInputVersions { get; set; } + public string ObservedExternalStatus { get; set; } + public int SortOrder { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { get; set; } + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "CalOesMarsRateLines"; + [NotMapped] public string IdName => "CalOesMarsRateLineId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsRateLineId; set => CalOesMarsRateLineId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A prior-year actual-cost input to the administrative (indirect) rate worksheet. Actuals, never budgets. + public class CalOesMarsAdministrativeRateInput : IEntity + { + [Required] + public string CalOesMarsAdministrativeRateInputId { get; set; } + [Required] + public string CalOesMarsRateProfileId { get; set; } + public int DepartmentId { get; set; } + public int FiscalYear { get; set; } + public string FunctionCode { get; set; } + public string CategoryCode { get; set; } + public string CategoryProfileVersion { get; set; } + /// . + public int Classification { get; set; } + /// Stored as text (the column is nvarchar(max)); parsed with . + public string ActualAmount { get; set; } + public string SourceSystem { get; set; } + public string SourceLine { get; set; } + public int InputVersion { get; set; } = 1; + /// Cost already billed directly to an incident — excluded from the indirect pool. + public bool IncidentDirectExclusion { get; set; } + /// Flagged as possibly counted twice (a resource billed directly and included in a pool). + public bool DoubleCountMarker { get; set; } + /// . + public int ReviewStatus { get; set; } + public string ReviewReason { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { 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 decimal Amount => decimal.TryParse(ActualAmount, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : 0m; + + [NotMapped] public string TableName => "CalOesMarsAdministrativeRateInputs"; + [NotMapped] public string IdName => "CalOesMarsAdministrativeRateInputId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsAdministrativeRateInputId; set => CalOesMarsAdministrativeRateInputId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Amount" }; + } + + /// An approved MOU / MOA / GBR compensation method for a classification, selected as of initial dispatch. + public class CalOesMarsAgreementSnapshot : IEntity + { + [Required] + public string CalOesMarsAgreementSnapshotId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// Null = applies to every classification without a more specific agreement. + public string ClassificationCode { get; set; } + public string ClassificationTitle { get; set; } + /// . + public int DocumentKind { get; set; } + /// . + public int CompensationMethod { get; set; } + /// . + public int OvertimeMethod { get; set; } + public DateTime? StartOn { get; set; } + public DateTime? EndOn { get; set; } + public string ExternalApprovalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public int? AttachmentId { get; set; } + public string AttachmentChecksum { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { 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 CoversDate(DateTime asOf) => (!StartOn.HasValue || StartOn.Value.Date <= asOf.Date) && (!EndOn.HasValue || EndOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "CalOesMarsAgreementSnapshots"; + [NotMapped] public string IdName => "CalOesMarsAgreementSnapshotId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsAgreementSnapshotId; set => CalOesMarsAgreementSnapshotId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// A local mirror of one MARS record (F-42, expense claim, generated invoice, annual submission…). The snapshot JSON + /// carries the prepared document; the external ids / statuses are what a MARS manager observed in the portal. + /// + public class CalOesMarsWorkItem : IEntity + { + [Required] + public string CalOesMarsWorkItemId { get; set; } + [Required] + public int DepartmentId { get; set; } + public string DeploymentId { get; set; } + public string RmsExternalOrderId { get; set; } + public string RmsExternalOrderFillId { get; set; } + /// . + public int RecordType { get; set; } + /// . + public int LocalState { get; set; } + public string MarsRecordId { get; set; } + public string MarsInvoiceId { get; set; } + public string ObservedExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + /// . + public string ObservedSource { get; set; } + /// The reviewer's comment when MARS returned the record for agency review. + public string CorrectionComment { get; set; } + public string AuthorityProfileCode { get; set; } + public string RateProfileVersion { get; set; } + public string AgreementSnapshotId { get; set; } + /// Typed snapshot ( / / ). + public string SnapshotJson { get; set; } + public string ValidationSummaryJson { get; set; } + public string SubmittedByUserId { get; set; } + public DateTime? SubmittedOn { get; set; } + public string ApprovedByUserId { get; set; } + public DateTime? ApprovedOn { get; set; } + public string RejectedByUserId { get; set; } + public DateTime? RejectedOn { get; set; } + public DateTime? PaidOn { get; set; } + public decimal? ExpectedTotal { get; set; } + public decimal? ApprovedTotal { get; set; } + public decimal? PaidTotal { get; set; } + public string PaymentReference { get; set; } + /// The earlier revision this row replaced (returned-for-review corrections, redispatch). + public string SupersedesWorkItemId { get; set; } + public int RowVersion { get; set; } = 1; + public string SourceArtifact { get; set; } + public string SourceChecksum { 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 Lines { get; set; } = new List(); + [NotMapped] public string DeploymentName { get; set; } + [NotMapped] public bool IsLocallyEditable => LocalState is (int)CalOesMarsLocalStates.Draft or (int)CalOesMarsLocalStates.NeedsReview or (int)CalOesMarsLocalStates.ReadyForPortal or (int)CalOesMarsLocalStates.ReturnedForAgencyReview; + [NotMapped] public bool IsExternal => LocalState >= (int)CalOesMarsLocalStates.SubmittedExternal; + + [NotMapped] public string TableName => "CalOesMarsWorkItems"; + [NotMapped] public string IdName => "CalOesMarsWorkItemId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsWorkItemId; set => CalOesMarsWorkItemId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Lines", "DeploymentName", "IsLocallyEditable", "IsExternal" }; + } + + /// An immutable expected-reimbursement line (decision 37: reimbursement, not revenue, not cost). + public class CalOesMarsReimbursementLine : IEntity + { + [Required] + public string CalOesMarsReimbursementLineId { get; set; } + [Required] + public string CalOesMarsWorkItemId { get; set; } + public int DepartmentId { get; set; } + public string DeploymentId { get; set; } + public DateTime? LineDate { get; set; } + /// . + public int LineKind { get; set; } + /// for roster subjects. + public int? SubjectType { get; set; } + public string SubjectId { get; set; } + public string SourceWorkId { get; set; } + public string SourceExpenseId { get; set; } + public decimal Quantity { get; set; } + public string Unit { get; set; } + public decimal Rate { get; set; } + public string RateLineId { get; set; } + public int? RateLineVersion { get; set; } + public decimal ExpectedAmount { get; set; } + public decimal? ApprovedAmount { get; set; } + public decimal? PaidAmount { get; set; } + /// . + public int EligibilityState { get; set; } + public string EligibilityReason { get; set; } + public string SourceVersions { get; set; } + public int SortOrder { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + + [NotMapped] public string SubjectName { get; set; } + + [NotMapped] public string TableName => "CalOesMarsReimbursementLines"; + [NotMapped] public string IdName => "CalOesMarsReimbursementLineId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => CalOesMarsReimbursementLineId; set => CalOesMarsReimbursementLineId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "SubjectName" }; + } +} diff --git a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEnums.cs b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEnums.cs new file mode 100644 index 00000000..b2550448 --- /dev/null +++ b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEnums.cs @@ -0,0 +1,189 @@ +namespace Resgrid.Model.CostRecovery.CalOesMars +{ + // Workforce & Business Operations plan, Phase C-M3 (C11): Cal OES Mutual Aid Reimbursement System (CFAA cost + // recovery). Every value below is a local mirror of an external authority's vocabulary; the authority profile + // (CalOesMarsAuthorityProfile) pins the mapping to the official material and a changed official list needs a new profile. + + /// What a resource-inventory (F-5) crosswalk row points at. + public enum CalOesMarsSubjectTypes + { + Unit = 0, + InventoryAsset = 1, + External = 2 + } + + public enum CalOesMarsOwnerships + { + LocalAgency = 0, + CalOes = 1, + CalFire = 2, + Private = 3, + Rental = 4, + Other = 5 + } + + /// Review state of a locally prepared profile row against what MARS shows. + public enum CalOesMarsReviewStates + { + Draft = 0, + Reviewed = 1, + Observed = 2, + Mismatch = 3 + } + + /// Annual submission a rate profile represents. + public enum CalOesMarsSubmissionTypes + { + SalarySurvey = 0, + AttachmentA = 1, + AdministrativeRate = 2, + RateLetter = 3, + SpecialEquipment = 4 + } + + public enum CalOesMarsRateProfileStatuses + { + Draft = 0, + Reviewed = 1, + SignedLocally = 2, + SubmittedExternal = 3, + Accepted = 4, + Superseded = 5 + } + + /// How the administrative (indirect) rate was chosen (CFAA administrative-rate instructions). + public enum CalOesMarsAdministrativeRateMethods + { + None = 0, + DeMinimis = 1, + Calculated = 2 + } + + public enum CalOesMarsRateLineKinds + { + SalarySurvey = 0, + AttachmentANonSuppression = 1, + AdministrativeRate = 2, + OfficialApparatus = 3, + OfficialSupportVehicle = 4, + PrivatelyOwnedVehicle = 5, + Rental = 6, + MealLodgingIncidentals = 7, + SpecialEquipment = 8 + } + + public enum CalOesMarsRateBases + { + Hourly = 0, + Daily = 1, + PerMile = 2, + Percent = 3, + Flat = 4 + } + + public enum CalOesMarsRateAuthorities + { + AgencySubmitted = 0, + CalOesBase = 1, + CalOesRateLetter = 2, + Fema = 3, + AgencySpecialEquipment = 4 + } + + /// Indirect-cost rate proposal classification of an actual-cost input. + public enum CalOesMarsCostClassifications + { + Direct = 0, + Indirect = 1, + Unallowable = 2 + } + + public enum CalOesMarsInputReviewStatuses + { + Pending = 0, + Accepted = 1, + Excluded = 2 + } + + public enum CalOesMarsDocumentKinds + { + Mou = 0, + Moa = 1, + Gbr = 2, + Equivalent = 3 + } + + public enum CalOesMarsCompensationMethods + { + ActualHours = 0, + PortalToPortal = 1 + } + + public enum CalOesMarsOvertimeMethods + { + None = 0, + AfterEightHoursPerDay = 1, + AfterTwelveHoursPerDay = 2, + PerAgreement = 3 + } + + public enum CalOesMarsRecordTypes + { + AgencyProfile = 0, + ResourceInventoryF5 = 1, + SalarySurvey = 2, + AdministrativeRate = 3, + AttachmentA = 4, + SpecialEquipment = 5, + Agreement = 6, + F42 = 7, + ExpenseClaim = 8, + GeneratedInvoice = 9 + } + + /// + /// Local mirror state of a work item. Only an observed external action (recorded by a MARS manager) moves a + /// record past ReadyForPortal; opening a handoff view or downloading a packet never does. + /// + public enum CalOesMarsLocalStates + { + Draft = 0, + NeedsReview = 1, + ReadyForPortal = 2, + SubmittedExternal = 3, + ReturnedForAgencyReview = 4, + Approved = 5, + DocumentationOnly = 6, + PendingLocalAgencyApproval = 7, + LocalAgencyRejected = 8, + PendingPayingEntityApproval = 9, + Paid = 10, + Closed = 11 + } + + public enum CalOesMarsLineKinds + { + Personnel = 0, + Apparatus = 1, + SupportVehicle = 2, + PovMileage = 3, + Rental = 4, + Expense = 5, + SpecialEquipment = 6, + Administrative = 7 + } + + public enum CalOesMarsEligibilityStates + { + Eligible = 0, + Excluded = 1, + Uncertain = 2 + } + + /// Where an external fact came from. P0 has only manual observation. + public static class CalOesMarsObservationSources + { + public const string Manual = "manual"; + public const string Connector = "connector"; + } +} diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index c489506a..7e06015b 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -119,5 +119,11 @@ public static class FeatureFlagKeys /// Phase C Cal OES MARS cost recovery. Child of Business.Operations. Seeded off by M0219. public const string CalOesMars = "CostRecovery.CalOesMars"; + + /// Workforce & Business Operations plan Phase E: protected compensation, work entries, resource costing and field-cost runs. Child of Business.Operations. Seeded off by M0224. + public const string WorkforceInternalCosting = "Workforce.InternalCosting"; + + /// Workforce & Business Operations plan Phase E: the California CRD pay data report wizard and demographic self-identification. Child of Business.Operations; also requires the department's Advanced Data Protection state to be Enabled. Seeded off by M0224. + public const string CaliforniaPayDataReporting = "Compliance.CaliforniaPayDataReporting"; } } diff --git a/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs b/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs index eb9f7bf9..d9ba25c3 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentPermissionCatalog.cs @@ -5,8 +5,8 @@ namespace Resgrid.Model /// /// 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. + /// deployments and file time entries without any of them, and file their own incident-bound F-42 / expense drafts + /// without ManageMutualAidReimbursement (79). /// public static class DeploymentPermissionCatalog { @@ -16,7 +16,9 @@ public static class DeploymentPermissionCatalog 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) + new RecordPermissionDescriptor(PermissionTypes.ManageContracts, PermissionActions.DepartmentAdminsOnly, false, "ManageContractsNote", false), + // Cal OES MARS (C-M3, 2026-09-19): agency / rate / agreement management, portal handoff, external observation and invoice reconciliation. + new RecordPermissionDescriptor(PermissionTypes.ManageMutualAidReimbursement, PermissionActions.DepartmentAdminsOnly, false, "ManageMutualAidReimbursementNote", false) }; } } diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs index 0008adcc..b97d7958 100644 --- a/Core/Resgrid.Model/PermissionTypes.cs +++ b/Core/Resgrid.Model/PermissionTypes.cs @@ -187,8 +187,24 @@ public enum PermissionTypes ManageDeployments = 118, /// Phase C deployment core (registry 119): approve and void submitted daily time reports (TimeReports_Approve). Defaults to department administrators. ApproveTimeReports = 119, - /// Phase C Cal OES MARS (registry 79): agency/rate/agreement management, protected portal handoff, external status observation and MARS invoice reconciliation. Defaults to department administrators. Chain wired by the MARS milestone. - ManageMutualAidReimbursement = 79 + /// Phase C Cal OES MARS (registry 79): agency/rate/agreement management, portal handoff, external status observation and MARS invoice reconciliation. Defaults to department administrators. Chain wired by C-M3 (2026-09-19). + ManageMutualAidReimbursement = 79, + + // -- Workforce & Business Operations Phase E (registry 74-78) ---------------------------------------- + // Protected workforce pay data, field costing and California pay data reporting (2026-09-19). All fall + // back to department administrators; a member always sees and answers their own demographic response + // and files their own resource usage without any of them. + + /// Phase E (registry 74): aggregate internal field-cost summaries and margins for bids, calls and deployments — categories and totals, never a line, rate or person. Defaults to department administrators. + ViewInternalCosts = 74, + /// Phase E (registry 75): employer identity, establishments, workers, employment periods, job assignments, compensation profiles, pay / employer-cost components and annual pay fact imports. Defaults to department administrators. + ManageWorkforceCompensation = 75, + /// Phase E (registry 76): read compensation profiles, work entries and cost-run lines (values still require a current Protected Data Grant). Defaults to department administrators. + ViewWorkforceCompensation = 76, + /// Phase E (registry 77): the California CRD report wizard — runs, employee snapshots, overrides, aggregation, validation, remarks, certification observation, corrections and the compliance officer's demographic records. Defaults to department administrators. + ManagePayDataReporting = 77, + /// Phase E (registry 78): freeze a validated run and download its export artifacts and portal worksheet. Defaults to department administrators. + ExportPayDataReporting = 78 } } diff --git a/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs b/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs new file mode 100644 index 00000000..0dbc38ec --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.CostRecovery.CalOesMars; + +namespace Resgrid.Model.Repositories +{ + // Workforce & Business Operations plan, Phase C-M3 (C3): Cal OES MARS shadow-table repositories (registry M0219). + // Every query is department-scoped; snapshots and observations are corrected by a superseding row, never rewritten. + + public interface ICalOesMarsAgencyProfileRepository : IRepository + { + Task GetByDepartmentAsync(int departmentId); + } + + public interface ICalOesMarsResourceProfileRepository : IRepository + { + Task GetByIdForDepartmentAsync(string resourceProfileId, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + Task> GetByUnitIdsAsync(int departmentId, IEnumerable unitIds); + } + + public interface ICalOesMarsRateProfileRepository : IRepository + { + Task GetByIdForDepartmentAsync(string rateProfileId, int departmentId); + Task> GetForDepartmentAsync(int departmentId, int? submissionYear = null); + /// Profiles of every submission type effective on the date (the as-of-dispatch lookup). + Task> GetEffectiveAsync(int departmentId, DateTime asOf); + } + + public interface ICalOesMarsRateLineRepository : IRepository + { + Task GetByIdForDepartmentAsync(string rateLineId, int departmentId); + Task> GetByProfileAsync(string rateProfileId); + Task> GetByProfilesAsync(IEnumerable rateProfileIds); + } + + public interface ICalOesMarsAdministrativeRateInputRepository : IRepository + { + Task GetByIdForDepartmentAsync(string inputId, int departmentId); + Task> GetByProfileAsync(string rateProfileId); + } + + public interface ICalOesMarsAgreementSnapshotRepository : IRepository + { + Task GetByIdForDepartmentAsync(string agreementSnapshotId, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + } + + public interface ICalOesMarsWorkItemRepository : IRepository + { + Task GetByIdForDepartmentAsync(string workItemId, int departmentId); + Task> GetByDeploymentAsync(string deploymentId, int departmentId); + Task> GetByExternalIdAsync(int departmentId, string marsRecordId); + /// Open items (every state but Closed) for the department queue. + Task> GetActionQueueAsync(int departmentId, int? recordType = null); + /// Items whose external state is not settled (submitted, returned, approved without an invoice, invoices not paid). + Task> GetUnreconciledAsync(int departmentId); + /// Departments with any open item (the worker's sweep scope). + Task> GetDepartmentsWithOpenItemsAsync(); + } + + public interface ICalOesMarsReimbursementLineRepository : IRepository + { + Task> GetByWorkItemAsync(string workItemId); + Task DeleteByWorkItemAsync(string workItemId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IContractorRepositories.cs b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs index 2f56e9c0..2cbc581c 100644 --- a/Core/Resgrid.Model/Repositories/IContractorRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs @@ -63,7 +63,7 @@ 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> GetByContactIdAsync(int departmentId, string contactId, int skip, int take); Task> GetByContractAsync(string serviceContractId); /// Submitted bids (all departments) whose ValidUntil is behind . Task> GetExpiryCandidatesAsync(DateTime asOfUtc); diff --git a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs index 83c6c137..0ef40811 100644 --- a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs @@ -18,6 +18,8 @@ public interface IDeploymentRepository : IRepository Task> GetForDepartmentAsync(int departmentId, bool openOnly, int skip, int take); Task CountForDepartmentAsync(int departmentId, bool openOnly); Task> GetByIdsAsync(int departmentId, IEnumerable deploymentIds); + /// Non-deleted deployments raised under one service contract, newest first (capped at 500). + Task> GetByContractAsync(int departmentId, string serviceContractId); } public interface IDeploymentUnitRepository : IRepository diff --git a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs index 976ac103..431fed58 100644 --- a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs @@ -51,6 +51,8 @@ public class InvoiceListFilter { public IEnumerable Statuses { get; set; } public string ContactId { get; set; } + /// Invoices generated under one service contract (Phase C contractor billing). + public string ServiceContractId { get; set; } public DateTime? IssuedFromUtc { get; set; } public DateTime? IssuedToUtc { get; set; } public int Skip { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IWorkforceRepositories.cs b/Core/Resgrid.Model/Repositories/IWorkforceRepositories.cs new file mode 100644 index 00000000..109fbab0 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IWorkforceRepositories.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Workforce; + +namespace Resgrid.Model.Repositories +{ + // Workforce & Business Operations plan, Phase E (E2/E3): repositories for M0220–M0223. Every query is + // department-scoped; protected columns come back as envelopes and are resolved by the services' seams. + + #region M0220 employment + + public interface IWorkforceEmployerProfileRepository : IRepository + { + Task GetActiveForDepartmentAsync(int departmentId); + Task> GetForDepartmentAsync(int departmentId); + /// Departments with an active employer profile (worker 49 readiness sweep). + Task> GetDepartmentsWithActiveProfilesAsync(); + } + + public interface IWorkforceAffiliatedEntityRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + } + + public interface IWorkforceEstablishmentRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + } + + public interface IWorkforceLaborContractorRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + } + + public interface IWorkforceWorkerRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task GetByUserIdAsync(int departmentId, string userId); + Task> GetForDepartmentAsync(int departmentId); + } + + public interface IWorkforceEmploymentRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByWorkerAsync(string workerId); + Task> GetForDepartmentAsync(int departmentId); + /// Employments whose period overlaps the window. + Task> GetActiveInWindowAsync(int departmentId, DateTime from, DateTime to); + } + + public interface IWorkforceJobAssignmentRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByEmploymentAsync(string employmentId); + Task> GetByEmploymentsAsync(IEnumerable employmentIds); + } + + #endregion + + #region M0221 compensation + + public interface IEmployeeCompensationProfileRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByEmploymentAsync(string employmentId); + Task> GetDefaultsForDepartmentAsync(int departmentId); + Task> GetByEmploymentsAsync(IEnumerable employmentIds); + } + + public interface IEmployeePayComponentRepository : IRepository + { + Task> GetByProfileAsync(string profileId); + Task> GetByProfilesAsync(IEnumerable profileIds); + } + + public interface IEmployeeCostComponentRepository : IRepository + { + Task> GetByProfileAsync(string profileId); + Task> GetByProfilesAsync(IEnumerable profileIds); + } + + public interface IWorkforceWorkEntryRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByWorkerAsync(string workerId, DateTime from, DateTime to); + Task> GetByDeploymentAsync(string deploymentId); + Task> GetByCallAsync(int callId); + Task> GetForDepartmentInWindowAsync(int departmentId, DateTime from, DateTime to); + Task GetByExternalIdAsync(int departmentId, string externalSource, string externalId); + } + + public interface IWorkforceAnnualPayFactRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForYearAsync(int departmentId, int reportingYear, int reportType); + Task> GetByEmploymentAsync(string employmentId); + } + + #endregion + + #region M0222 costing + + public interface IResourceCostProfileRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForDepartmentAsync(int departmentId); + Task> GetByUnitIdsAsync(int departmentId, IEnumerable unitIds); + } + + public interface IResourceCostComponentRepository : IRepository + { + Task> GetByProfileAsync(string profileId); + Task> GetByProfilesAsync(IEnumerable profileIds); + } + + public interface IResourceUsageEntryRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByDeploymentAsync(string deploymentId); + Task> GetByCallAsync(int callId); + Task> GetByUnitAsync(int departmentId, int unitId, DateTime from, DateTime to); + } + + public interface IFieldCostRunRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByDeploymentAsync(string deploymentId, int departmentId); + Task> GetByBidAsync(string bidId, int departmentId); + Task> GetByCallAsync(int callId, int departmentId); + Task> GetForDepartmentAsync(int departmentId, int skip, int take); + } + + public interface IFieldCostLineRepository : IRepository + { + Task> GetByRunAsync(string runId); + Task DeleteByRunAsync(string runId, CancellationToken cancellationToken = default); + } + + #endregion + + #region M0223 pay data reporting + + public interface IPayDataReportingDemographicRepository : IRepository + { + Task GetCurrentForWorkerAsync(string workerId, DateTime asOf); + Task> GetCurrentForDepartmentAsync(int departmentId, DateTime asOf); + } + + public interface IPayDataReportRunRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetForDepartmentAsync(int departmentId, int? reportingYear = null); + Task> GetDepartmentsWithRunsAsync(int reportingYear); + } + + public interface IPayDataReportEmployeeSnapshotRepository : IRepository + { + Task> GetByRunAsync(string runId); + Task DeleteByRunAsync(string runId, CancellationToken cancellationToken = default); + } + + public interface IPayDataReportRowRepository : IRepository + { + Task> GetByRunAsync(string runId); + Task DeleteByRunAsync(string runId, CancellationToken cancellationToken = default); + } + + public interface IPayDataExportArtifactRepository : IRepository + { + Task GetByIdForDepartmentAsync(string id, int departmentId); + Task> GetByRunAsync(string runId); + Task> GetExpiredUnpurgedAsync(DateTime asOf); + } + + #endregion +} diff --git a/Core/Resgrid.Model/Services/IBidsService.cs b/Core/Resgrid.Model/Services/IBidsService.cs index 5691dfda..2c4b9bec 100644 --- a/Core/Resgrid.Model/Services/IBidsService.cs +++ b/Core/Resgrid.Model/Services/IBidsService.cs @@ -16,7 +16,9 @@ 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); + Task> GetBidsByContactIdAsync(string contactId, int departmentId, int skip = 0, int take = 100); + /// The department's bids raised under one contract (the contract detail page), newest first. + Task> GetBidsForContractAsync(string serviceContractId, int departmentId); /// The bid with its lines; null when missing or deleted. Task GetBidByIdAsync(string bidId, int departmentId); diff --git a/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs index c3b2752d..6fae6791 100644 --- a/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs +++ b/Core/Resgrid.Model/Services/IBusinessOperationsAccessService.cs @@ -19,9 +19,12 @@ public interface IBusinessOperationsAccessService /// The department may use the Cal OES MARS cost-recovery workspace (Phase C). Task CanUseCostRecoveryAsync(int departmentId); - /// The department may use workforce pay data, field costing and California pay data reporting (Phase E; also requires ADP). + /// The department may use protected workforce pay data and field costing (Phase E, Workforce.InternalCosting). Task CanUseWorkforceAsync(int departmentId); + /// The department may use California pay data reporting (Phase E, Compliance.CaliforniaPayDataReporting); additionally requires the department's Advanced Data Protection state to be Enabled. + Task CanUsePayDataReportingAsync(int departmentId); + /// The department holds an active Business Operations add-on window right now (no flag or module checks). Task HasActiveAddonAsync(int departmentId); } diff --git a/Core/Resgrid.Model/Services/ICalOesMarsReimbursementCalculator.cs b/Core/Resgrid.Model/Services/ICalOesMarsReimbursementCalculator.cs new file mode 100644 index 00000000..9bf0a41c --- /dev/null +++ b/Core/Resgrid.Model/Services/ICalOesMarsReimbursementCalculator.cs @@ -0,0 +1,27 @@ +using Resgrid.Model.CostRecovery.CalOesMars; + +namespace Resgrid.Model.Services +{ + /// + /// Pure expected-reimbursement arithmetic for a prepared F-42 / expense claim (plan C4, decision 37): personnel + /// straight/overtime under the agreement's compensation method, official apparatus / support vehicle / POV mileage + /// / special-equipment lines from the effective rate lines, evidence-gated expenses, and the administrative line. + /// Every line carries its rate line, eligibility and reason; nothing here reads a database or a Phase E cost. + /// + public interface ICalOesMarsReimbursementCalculator + { + CalOesMarsReimbursementResult Calculate(CalOesMarsReimbursementInput input); + } + + /// + /// The seam a future authorized Cal OES connector would implement (plan C4 "no connector fiction"). P0 ships + /// ManualCalOesMarsGateway: it exposes the reviewed portal address, stores no credential and performs + /// zero external writes; every external fact is a manual observation recorded by a MARS manager. + /// + public interface ICalOesMarsExternalGateway + { + string Name { get; } + bool SupportsExternalWrites { get; } + string GetPortalUrl(CalOesMarsWorkItem workItem); + } +} diff --git a/Core/Resgrid.Model/Services/ICalOesMarsService.cs b/Core/Resgrid.Model/Services/ICalOesMarsService.cs new file mode 100644 index 00000000..85c2c6bb --- /dev/null +++ b/Core/Resgrid.Model/Services/ICalOesMarsService.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.CostRecovery.CalOesMars; + +namespace Resgrid.Model.Services +{ + /// + /// Cal OES MARS / CFAA cost recovery (Workforce & Business Operations plan, C4 and C11; decisions 36-38). + /// Resgrid owns preparation, local approval, expected-reimbursement calculation, evidence, handoff and + /// reconciliation; Cal OES MARS owns acceptance, correction queues, generated invoices and paid status. Nothing + /// here writes to MARS: the P0 gateway is manual (), and only a recorded + /// observation advances an external state. Callers authorize (permission 79 for management; rostered members for + /// their own incident-bound drafts). + /// + public interface ICalOesMarsService + { + #region Readiness and agency + + Task GetAgencyReadinessAsync(int departmentId, DateTime? dispatchOn = null); + Task GetAgencyProfileAsync(int departmentId); + Task SaveAgencyProfileAsync(CalOesMarsAgencyProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task MarkAgencyVerifiedAsync(int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + #endregion + + #region F-5 resource inventory crosswalk + + Task> GetResourceProfilesAsync(int departmentId); + Task GetResourceProfileAsync(string resourceProfileId, int departmentId); + /// Maps the selected units to draft crosswalk rows (designator, plate, VIN, ownership) without touching Unit state; existing rows are returned as-is. + Task> BuildResourceInventoryF5DraftAsync(int departmentId, IEnumerable unitIds, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveResourceProfileAsync(CalOesMarsResourceProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecordResourceObservationAsync(string resourceProfileId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteResourceProfileAsync(string resourceProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + #endregion + + #region Annual rate profiles + + Task> GetRateProfilesAsync(int departmentId, int? submissionYear = null); + Task GetRateProfileAsync(string rateProfileId, int departmentId); + Task SaveRateProfileAsync(CalOesMarsRateProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Upserts the profile's lines by id (missing ids are deleted). Refused once the profile is signed or submitted. + Task SaveRateLinesAsync(string rateProfileId, int departmentId, List lines, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveAdministrativeInputsAsync(string rateProfileId, int departmentId, List inputs, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Allowable indirect ÷ allowable direct from the reviewed inputs, compared with the de-minimis option; records the method chosen. Blocks on unresolved double-count flags. + Task BuildAdministrativeRateDraftAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SetRateProfileStatusAsync(string rateProfileId, int departmentId, CalOesMarsRateProfileStatuses status, string signedByName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecordRateProfileObservationAsync(string rateProfileId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteRateProfileAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + #endregion + + #region Agreements + + Task> GetAgreementsAsync(int departmentId); + Task GetAgreementAsync(string agreementSnapshotId, int departmentId); + Task SaveAgreementAsync(CalOesMarsAgreementSnapshot agreement, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecordAgreementObservationAsync(string agreementSnapshotId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteAgreementAsync(string agreementSnapshotId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// The agreement covering the classification as of initial dispatch (specific classification first, then the department-wide one). + Task SelectAgreementAsync(int departmentId, string classificationCode, DateTime dispatchOn); + + #endregion + + #region Work items (F-42, expense claims, invoices) + + Task> GetActionQueueAsync(int departmentId, string userId, bool managerScope); + Task> GetWorkItemsForDeploymentAsync(string deploymentId, int departmentId); + Task GetWorkItemAsync(string workItemId, int departmentId); + /// Whether the user may open the item without permission 79: rostered on its deployment. + Task IsRosteredForWorkItemAsync(string workItemId, int departmentId, string userId); + + /// One F-42 per ordered resource / request (the fill); a redispatch supersedes the earlier item. Time facts pre-fill from the DTRs; a DTR is not an F-42. + Task BuildF42DraftAsync(string deploymentId, int departmentId, string rmsExternalOrderFillId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Groups the deployment's dated expenses for the resource / F-42 (or the travel-only path when no F-42 exists). + Task BuildExpenseClaimDraftAsync(string deploymentId, int departmentId, string f42WorkItemId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Saves the editable parts of a local snapshot (comments, signatures, rotations, documentation-only, attachments). Refused once external. + Task SaveF42SnapshotAsync(string workItemId, int departmentId, CalOesMarsF42Snapshot snapshot, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveExpenseSnapshotAsync(string workItemId, int departmentId, CalOesMarsExpenseClaimSnapshot snapshot, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + /// Field-by-field errors / warnings against the pinned checklist; a clean run moves Draft/NeedsReview → ReadyForPortal, errors move it to NeedsReview. + Task ValidateForPortalAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Immutable expected lines from the profile effective at initial dispatch (decision 37). Replaces the item's earlier lines while it is local. + Task CalculateExpectedReimbursementAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// The no-store side-by-side copy view plus the portal link. Requires an explicit actor attestation; never marks the item submitted. + Task OpenPortalHandoffAsync(string workItemId, int departmentId, bool attested, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Evidence packet zip (manifest JSON, printable snapshot, supporting attachments). Labelled "not an accepted MARS import file". + Task BuildEvidencePacketAsync(string workItemId, int departmentId, string userId); + Task RenderWorkItemHtmlAsync(string workItemId, int departmentId); + + Task RecordExternalSubmissionAsync(string workItemId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Maps the observed status through the authority profile (Cal OES Review / Agency Review / Approved / Documentation Only). A returned item creates a new local revision. + Task RecordExternalStatusAsync(string workItemId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecordMarsInvoiceAsync(int departmentId, string deploymentId, CalOesMarsInvoiceObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ApproveOrRejectObservedInvoiceAsync(string invoiceWorkItemId, int departmentId, bool approve, string decisionTitle, string comment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task RecordPaymentAsync(string invoiceWorkItemId, int departmentId, CalOesMarsPaymentObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task CloseWorkItemAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteWorkItemAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Expected vs observed comparison for an invoice item: covered F-42/expense items, their expected totals, the invoiced / paid amounts and the variance. + Task GetInvoiceReconciliationAsync(string invoiceWorkItemId, int departmentId); + + #endregion + + #region Worker 32 + + /// Value-minimized digest per department per day to permission-79 holders: annual deadlines, released resources without an F-42, returned items, invoices awaiting local approval. Never changes a state. + Task RunReminderSweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + + #endregion + } + + /// The administrative-rate worksheet result. + public sealed class CalOesMarsAdministrativeRateDraft + { + public string RateProfileId { get; set; } + public decimal AllowableDirect { get; set; } + public decimal AllowableIndirect { get; set; } + public decimal ExcludedUnallowable { get; set; } + public decimal ExcludedIncidentDirect { get; set; } + public decimal? CalculatedPercent { get; set; } + public decimal DeMinimisPercent { get; set; } + public int MethodChosen { get; set; } + public decimal? ChosenPercent { get; set; } + public List Blockers { get; set; } = new List(); + public bool IsReady => Blockers.Count == 0; + } + + public sealed class CalOesMarsInvoiceReconciliation + { + public CalOesMarsWorkItem Invoice { get; set; } + public CalOesMarsInvoiceSnapshot Snapshot { get; set; } + public List CoveredItems { get; set; } = new List(); + public decimal ExpectedTotal { get; set; } + public decimal? InvoicedTotal { get; set; } + public decimal? PaidTotal { get; set; } + public decimal? Variance => InvoicedTotal.HasValue ? InvoicedTotal.Value - ExpectedTotal : null; + } +} diff --git a/Core/Resgrid.Model/Services/IDeploymentService.cs b/Core/Resgrid.Model/Services/IDeploymentService.cs index 4d38b2b9..d76a5e43 100644 --- a/Core/Resgrid.Model/Services/IDeploymentService.cs +++ b/Core/Resgrid.Model/Services/IDeploymentService.cs @@ -20,6 +20,8 @@ public interface IDeploymentService Task GetDeploymentByExternalOrderIdAsync(string rmsExternalOrderId, int departmentId); Task> GetDeploymentsForDepartmentAsync(int departmentId, bool openOnly, int skip = 0, int take = 100); Task CountDeploymentsForDepartmentAsync(int departmentId, bool openOnly); + /// Deployments raised under one service contract (the contract detail page), newest first. + Task> GetDeploymentsForContractAsync(string serviceContractId, int departmentId); /// Deployments the member is or was rostered on (the field user's scope). Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly); Task IsRosteredAsync(string deploymentId, int departmentId, string userId); diff --git a/Core/Resgrid.Model/Services/IWorkforceServices.cs b/Core/Resgrid.Model/Services/IWorkforceServices.cs new file mode 100644 index 00000000..f25e7e03 --- /dev/null +++ b/Core/Resgrid.Model/Services/IWorkforceServices.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Workforce; + +namespace Resgrid.Model.Services +{ + // Workforce & Business Operations plan, Phase E (E3): the five Phase E services. Every value that identifies or + // prices a person is written and read through the ADP seam (catalog 28); callers authorize (permissions 74–78). + + /// Employer identity, affiliates, establishments, labor contractors, workers, employment periods, job assignments, work entries and annual pay facts (plus CSV imports with dry-run). + public interface IWorkforceService + { + Task GetEmployerProfileAsync(int departmentId); + Task SaveEmployerProfileAsync(WorkforceEmployerProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetAffiliatesAsync(int departmentId); + Task SaveAffiliateAsync(WorkforceAffiliatedEntity entity, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteAffiliateAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetEstablishmentsAsync(int departmentId); + Task GetEstablishmentAsync(string id, int departmentId); + Task SaveEstablishmentAsync(WorkforceEstablishment establishment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteEstablishmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetLaborContractorsAsync(int departmentId); + Task SaveLaborContractorAsync(WorkforceLaborContractor contractor, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteLaborContractorAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetWorkersAsync(int departmentId); + Task GetWorkerAsync(string id, int departmentId); + /// The worker row for a Resgrid user, created on first use. + Task GetOrCreateWorkerForUserAsync(int departmentId, string userId, string actorUserId, CancellationToken cancellationToken = default); + Task SaveWorkerAsync(WorkforceWorker worker, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetEmploymentsAsync(int departmentId); + Task> GetEmploymentsForWorkerAsync(string workerId, int departmentId); + Task GetEmploymentAsync(string id, int departmentId); + /// Validates that a worker's periods never overlap. + Task SaveEmploymentAsync(WorkforceEmployment employment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteEmploymentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Validates that an employment's assignments never overlap and that the establishment is the department's. + Task SaveJobAssignmentAsync(WorkforceJobAssignment assignment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteJobAssignmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetWorkEntriesAsync(int departmentId, DateTime from, DateTime to); + Task SaveWorkEntryAsync(WorkforceWorkEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetAnnualPayFactsAsync(int departmentId, int reportingYear, PayDataReportTypes reportType); + /// Corrections create a new version superseding the current fact for the employment / year / allocation. + Task SaveAnnualPayFactAsync(WorkforceAnnualPayFact fact, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Canonical CSV: ExternalWorkerKey|UserId, ReportingYear, ReportType, ClientAllocationKey, W2Box5, W2Box1, ActualWorkedHours, PaidLeaveHours, DaysWorked, WeeksWorked, ExemptProxyMethod, ProxyAverageHoursPerDay, ClientAllocatedEarnings, ClientAllocatedHours, ClientAllocatedWeeks. Dry-run validates and reconciles totals before anything commits. + Task ImportAnnualPayFactsAsync(int departmentId, string csv, bool dryRun, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } + + /// Compensation profiles (employee, role default, department default), pay / employer-cost components and the loaded-cost estimate for an approved work quantity. + public interface ICompensationCostService + { + Task> GetProfilesForEmploymentAsync(string employmentId, int departmentId); + Task> GetDefaultProfilesAsync(int departmentId); + Task GetProfileAsync(string profileId, int departmentId); + Task SaveProfileAsync(EmployeeCompensationProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveComponentsAsync(string profileId, int departmentId, List payComponents, List costComponents, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ApproveProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Employee profile → role default → department default as of the date, decrypted through the costing workload. Null when nothing resolves. + Task<(EmployeeCompensationProfile Profile, bool IsFallback)> ResolveProfileAsync(string employmentId, int? personnelRoleId, int departmentId, DateTime asOf); + Task CalculateLoadedCostAsync(int departmentId, LaborWorkQuantity work, int? personnelRoleId, DateTime asOf, string currency = "USD"); + /// The narrow aggregate the Cal OES MARS Salary Survey draft may consume: mean of the current actual hourly rates (plus components paid for each overtime hour) per classification — never a pay-range midpoint, never an individual. + Task> GetClassificationRateAggregateAsync(int departmentId, DateTime asOf, string calOesAuthorityProfileCode); + } + + /// Resource cost profiles / components / usage entries and the internal field-cost runs for bids, calls and deployments. + public interface IFieldCostingService + { + Task> GetResourceProfilesAsync(int departmentId); + Task GetResourceProfileAsync(string profileId, int departmentId); + Task SaveResourceProfileAsync(ResourceCostProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveResourceComponentsAsync(string profileId, int departmentId, List components, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteResourceProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetUsageForDeploymentAsync(string deploymentId, int departmentId); + Task> GetUsageForCallAsync(int callId, int departmentId); + /// Canonicalises distance (km → miles), derives distance / engine hours from meters, and queues conflicting automatic vs manual readings for review. + Task SaveUsageEntryAsync(ResourceUsageEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task DeleteUsageEntryAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + + Task> GetRunsAsync(int departmentId, int skip = 0, int take = 100); + Task> GetRunsForDeploymentAsync(string deploymentId, int departmentId); + Task> GetRunsForBidAsync(string bidId, int departmentId); + Task> GetRunsForCallAsync(int callId, int departmentId); + Task GetRunAsync(string runId, int departmentId); + /// Estimate from the bid's lines (personnel hours × role / department default compensation, vehicle hours × resource profile) with revenue = the bid's estimated total. + Task EstimateBidCostAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Actual from work entries and resource usage against the call; no revenue. + Task CalculateCallCostAsync(int callId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Actual from approved DTR entries (personnel + unit hours), usage entries and expenses through the date; revenue from invoices, the bid estimate or the selected MARS recovery snapshot. + Task CalculateDeploymentCostAsync(string deploymentId, int departmentId, DateTime? throughDate, RevenueSources revenueSource, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task FreezeCostRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task CompareEstimateToActualAsync(string deploymentId, int departmentId); + /// Aggregate categories only (ViewInternalCosts); never a line, rate or person. + Task GetFieldCostSummaryAsync(string runId, int departmentId); + Task DeleteRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + } + + /// The separately stored demographic responses: a worker's own self-identification and the compliance officer's completeness view. + public interface IPayDataDemographicsService + { + Task GetOwnAsync(int departmentId, string userId); + Task SaveOwnAsync(int departmentId, string userId, PayDataReportingDemographic response, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task GetCompletenessAsync(int departmentId, DateTime asOf); + /// A compliance officer's record for a worker (employment record / reliable record / observer perception); requires a reason and is reviewed. + Task SaveForWorkerAsync(int departmentId, string workerId, PayDataReportingDemographic response, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Values for one worker (ManagePayDataReporting + a current grant); the personnel screens never call this. + Task GetForWorkerAsync(int departmentId, string workerId, DateTime asOf); + } + + /// The California CRD report wizard (plan E3): create → build snapshots → aggregate → validate → freeze and export → attest. + public interface ICaPayDataReportingService + { + Task> GetRunsAsync(int departmentId, int? reportingYear = null); + Task GetRunAsync(string runId, int departmentId); + Task> GetSnapshotsAsync(string runId, int departmentId); + Task> GetRowsAsync(string runId, int departmentId); + Task CreateRunAsync(int departmentId, int reportingYear, PayDataReportTypes reportType, DateTime snapshotStart, DateTime snapshotEnd, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task BuildEmployeeSnapshotsAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task OverrideSnapshotAsync(string runId, string snapshotId, int departmentId, bool include, string jobCategoryCode, int? workMode, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task AggregateRowsAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task SaveRemarksAsync(string runId, int departmentId, string runRemarks, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task ValidateRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// Immutable snapshots + rows, the worksheet and the CSV / XLSX artifacts (checksum-stable for unchanged input). + Task FreezeAndExportAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task> GetArtifactsAsync(string runId, int departmentId); + /// Short-lived, no-store download of an artifact (audited). + Task DownloadArtifactAsync(string artifactId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task GetWorksheetAsync(string runId, int departmentId); + Task MarkCertifiedExternallyAsync(string runId, int departmentId, string certificationReference, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// A correction supersedes the frozen run with a new draft carrying its settings. + Task CreateCorrectionAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task VoidRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + Task GetReadinessAsync(int departmentId, int reportingYear); + /// Worker 49: value-free reminder during the filing season, once per department per day. + Task RunReadinessSweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default); + Task PurgeExpiredArtifactsAsync(DateTime asOfUtc, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Workforce/CaPayDataSchemaProfile.cs b/Core/Resgrid.Model/Workforce/CaPayDataSchemaProfile.cs new file mode 100644 index 00000000..4fd49706 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/CaPayDataSchemaProfile.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.Workforce +{ + /// + /// A reviewed, versioned description of one California CRD Pay Data Reporting year (plan E1): the official + /// source, the job-category / pay-band / race-ethnicity-sex code lists, the upload columns in exact order with + /// their types and limits, the snapshot window and the rounding rule. CRD rejects stale templates, so a new + /// reporting year is a new profile; historical runs keep the profile they were frozen with. The first + /// implementation profile is Reporting Year 2025 (filed in 2026). Its column headers, code lists and source + /// checksum are data the reviewer confirms against the official template before the first filing. + /// + public sealed class CaPayDataSchemaProfile + { + public string Code { get; } + public int ReportingYear { get; } + public DateTime ReviewedOn { get; } + public string SourceUrl { get; } + public string SourceTitle { get; } + /// SHA-256 of the official template, recorded by the reviewer; null until confirmed. + public string SourceChecksum { get; } + public IReadOnlyList JobCategories { get; } + public IReadOnlyList PayBands { get; } + public IReadOnlyList RaceEthnicities { get; } + public IReadOnlyList Sexes { get; } + public IReadOnlyList PayrollColumns { get; } + public IReadOnlyList LaborContractorColumns { get; } + public DateTime SnapshotWindowStart { get; } + public DateTime SnapshotWindowEnd { get; } + public int MaxFileBytes { get; } + public int RateDecimals { get; } + /// Statutory due date: the second Wednesday in May of the filing year. + public DateTime DueDate { get; } + + private CaPayDataSchemaProfile(string code, int year, DateTime reviewedOn, string sourceUrl, string sourceTitle, string sourceChecksum, IReadOnlyList jobCategories, + IReadOnlyList payBands, IReadOnlyList races, IReadOnlyList sexes, IReadOnlyList payroll, IReadOnlyList contractor) + { + Code = code; ReportingYear = year; ReviewedOn = reviewedOn; SourceUrl = sourceUrl; SourceTitle = sourceTitle; SourceChecksum = sourceChecksum; + JobCategories = jobCategories; PayBands = payBands; RaceEthnicities = races; Sexes = sexes; PayrollColumns = payroll; LaborContractorColumns = contractor; + SnapshotWindowStart = new DateTime(year, 10, 1); SnapshotWindowEnd = new DateTime(year, 12, 31); MaxFileBytes = 20 * 1024 * 1024; RateDecimals = 2; + DueDate = SecondWednesdayOfMay(year + 1); + } + + public const string CurrentCode = "CRD-RY2025"; + public static readonly IReadOnlyList All = new[] { BuildReportingYear2025() }; + public static CaPayDataSchemaProfile Current => All.Last(); + public static CaPayDataSchemaProfile Get(string code) => All.FirstOrDefault(p => string.Equals(p.Code, code, StringComparison.OrdinalIgnoreCase)); + public static CaPayDataSchemaProfile ForYear(int reportingYear) => All.FirstOrDefault(p => p.ReportingYear == reportingYear); + + public static DateTime SecondWednesdayOfMay(int filingYear) + { + var first = new DateTime(filingYear, 5, 1); + var offset = ((int)DayOfWeek.Wednesday - (int)first.DayOfWeek + 7) % 7; + return first.AddDays(offset + 7); + } + + public bool IsSnapshotInWindow(DateTime start, DateTime end) => start.Date >= SnapshotWindowStart && end.Date <= SnapshotWindowEnd && end.Date >= start.Date && (end.Date - start.Date).TotalDays <= 31; + + public CaPayDataPayBand PayBandFor(decimal annualEarnings) => PayBands.FirstOrDefault(b => annualEarnings >= b.Minimum && (!b.Maximum.HasValue || annualEarnings <= b.Maximum.Value)); + + /// Combined race/ethnicity/sex upload code: race letter + sex digits (Hispanic/Latino takes precedence; two or more races → G; MENA → H). + public string DemographicCode(string hispanicLatino, IReadOnlyCollection raceCodes, string sexCode) + { + var sex = Sexes.FirstOrDefault(s => string.Equals(s.Code, sexCode, StringComparison.OrdinalIgnoreCase))?.Code; + if (sex == null) return null; + string race; + if (string.Equals(hispanicLatino, "Yes", StringComparison.OrdinalIgnoreCase)) race = "A"; + else + { + var known = (raceCodes ?? Array.Empty()).Where(c => RaceEthnicities.Any(r => r.Code == c && r.Code != "A")).Distinct().ToList(); + if (known.Count == 0) return null; + race = known.Count > 1 ? "G" : known[0]; + } + return race + sex; + } + + private static CaPayDataSchemaProfile BuildReportingYear2025() + { + var jobs = new[] + { + new CaPayDataCode("1", "Executive or senior level officials and managers"), new CaPayDataCode("2", "First or mid-level officials and managers"), new CaPayDataCode("3", "Professionals"), + new CaPayDataCode("4", "Technicians"), new CaPayDataCode("5", "Sales workers"), new CaPayDataCode("6", "Administrative support workers"), new CaPayDataCode("7", "Craft workers"), + new CaPayDataCode("8", "Operatives"), new CaPayDataCode("9", "Laborers and helpers"), new CaPayDataCode("10", "Service workers") + }; + var bands = new[] + { + new CaPayDataPayBand("1", 0m, 19239m), new CaPayDataPayBand("2", 19240m, 24439m), new CaPayDataPayBand("3", 24440m, 30679m), new CaPayDataPayBand("4", 30680m, 38999m), + new CaPayDataPayBand("5", 39000m, 49919m), new CaPayDataPayBand("6", 49920m, 62919m), new CaPayDataPayBand("7", 62920m, 80079m), new CaPayDataPayBand("8", 80080m, 101919m), + new CaPayDataPayBand("9", 101920m, 128959m), new CaPayDataPayBand("10", 128960m, 163799m), new CaPayDataPayBand("11", 163800m, 207999m), new CaPayDataPayBand("12", 208000m, null) + }; + var races = new[] + { + new CaPayDataCode("A", "Hispanic or Latino"), new CaPayDataCode("B", "White (not Hispanic or Latino)"), new CaPayDataCode("C", "Black or African American"), + new CaPayDataCode("D", "Native Hawaiian or Other Pacific Islander"), new CaPayDataCode("E", "Asian"), new CaPayDataCode("F", "American Indian or Alaska Native"), + new CaPayDataCode("G", "Two or more races"), new CaPayDataCode("H", "Middle Eastern or North African") + }; + var sexes = new[] { new CaPayDataCode("10", "Female"), new CaPayDataCode("20", "Male"), new CaPayDataCode("30", "Non-binary") }; + var establishment = new[] + { + new CaPayDataColumn("Establishment Name", "text", 100, true), new CaPayDataColumn("Establishment Address", "text", 100, true), new CaPayDataColumn("Establishment City", "text", 50, true), + new CaPayDataColumn("Establishment State", "text", 2, true), new CaPayDataColumn("Establishment Zip", "text", 10, true), new CaPayDataColumn("NAICS Code", "text", 6, true), + new CaPayDataColumn("Major Activity", "text", 100, true), new CaPayDataColumn("Total Number of Employees at Establishment", "integer", 10, true), + new CaPayDataColumn("Was this establishment reported in prior year?", "yesno", 3, true), new CaPayDataColumn("Is this the Headquarters?", "yesno", 3, true) + }; + var group = new[] + { + new CaPayDataColumn("Job Category", "code", 2, true), new CaPayDataColumn("Race/Ethnicity/Sex", "code", 3, true), new CaPayDataColumn("Pay Band", "code", 2, true), + new CaPayDataColumn("Number of Employees", "integer", 10, true), new CaPayDataColumn("Total Hours", "integer", 10, true), + new CaPayDataColumn("Mean Hourly Rate", "decimal", 12, true), new CaPayDataColumn("Median Hourly Rate", "decimal", 12, true), + new CaPayDataColumn("Non-Remote Employees", "integer", 10, true), new CaPayDataColumn("Remote Employees Located in California", "integer", 10, true), + new CaPayDataColumn("Remote Employees Located Outside California", "integer", 10, true), new CaPayDataColumn("Row-Level Clarifying Remarks", "text", 500, false) + }; + var payroll = establishment.Concat(group).ToList(); + var contractor = new[] { new CaPayDataColumn("Labor Contractor Name", "text", 100, true), new CaPayDataColumn("Labor Contractor FEIN", "text", 10, true) }.Concat(establishment).Concat(group).ToList(); + return new CaPayDataSchemaProfile(CurrentCode, 2025, new DateTime(2026, 8, 21), "https://calcivilrights.ca.gov/paydatareporting/", "CRD Pay Data Reporting — Reporting Year 2025 handbook and templates", null, + jobs, bands, races, sexes, payroll, contractor); + } + } + + public sealed class CaPayDataCode + { + public string Code { get; } + public string Label { get; } + public CaPayDataCode(string code, string label) { Code = code; Label = label; } + } + + public sealed class CaPayDataPayBand + { + public string Code { get; } + public decimal Minimum { get; } + public decimal? Maximum { get; } + public CaPayDataPayBand(string code, decimal minimum, decimal? maximum) { Code = code; Minimum = minimum; Maximum = maximum; } + } + + public sealed class CaPayDataColumn + { + public string Header { get; } + /// text, integer, decimal, code, yesno. + public string Type { get; } + public int MaxLength { get; } + public bool Required { get; } + public CaPayDataColumn(string header, string type, int maxLength, bool required) { Header = header; Type = type; MaxLength = maxLength; Required = required; } + } +} diff --git a/Core/Resgrid.Model/Workforce/CompensationEntities.cs b/Core/Resgrid.Model/Workforce/CompensationEntities.cs new file mode 100644 index 00000000..5f401c7e --- /dev/null +++ b/Core/Resgrid.Model/Workforce/CompensationEntities.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Globalization; +using Newtonsoft.Json; + +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (M0221): compensation profiles, pay / employer-cost components, + // work entries and annual pay facts. Monetary values are ADP catalog 28 text columns (the envelope needs a text + // column); the typed accessors parse them once decrypted. Hours, dates and codes are routing metadata. + + public static class ProtectedDecimal + { + public static decimal? Parse(string value) => decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + public static string Format(decimal? value) => value?.ToString("0.####", CultureInfo.InvariantCulture); + } + + /// An employee's (or a role / department default) effective-dated compensation profile. + public class EmployeeCompensationProfile : IEntity + { + [Required] + public string EmployeeCompensationProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int Scope { get; set; } + public string WorkforceEmploymentId { get; set; } + public int? PersonnelRoleId { get; set; } + public DateTime EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string Currency { get; set; } = "USD"; + /// . + public int PayBasis { get; set; } + /// ADP catalog 28: base amount for the pay basis. + public string BaseAmount { get; set; } + /// ADP catalog 28: regular hourly equivalent. + public string RegularHourlyEquivalent { get; set; } + public decimal? StandardHoursPerDay { get; set; } + public decimal? StandardHoursPerWeek { get; set; } + public decimal? StandardHoursPerYear { get; set; } + /// ADP catalog 28: multipliers per pay code as JSON ({"Overtime":1.5,"DoubleTime":2,"Standby":0.5,"Travel":1}). + public string RateMultipliersJson { get; set; } + public string Source { get; set; } + public string ImportBatchId { get; set; } + public string SourceChecksum { get; set; } + public bool IsApproved { get; set; } + public string ApprovedByUserId { get; set; } + public DateTime? ApprovedOn { get; set; } + public int RowVersion { get; set; } = 1; + 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 decimal? BaseAmountValue { get => ProtectedDecimal.Parse(BaseAmount); set => BaseAmount = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? RegularHourlyEquivalentValue { get => ProtectedDecimal.Parse(RegularHourlyEquivalent); set => RegularHourlyEquivalent = ProtectedDecimal.Format(value); } + [NotMapped] public List PayComponents { get; set; } = new List(); + [NotMapped] public List CostComponents { get; set; } = new List(); + public bool Covers(DateTime asOf) => EffectiveOn.Date <= asOf.Date && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "EmployeeCompensationProfiles"; + [NotMapped] public string IdName => "EmployeeCompensationProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => EmployeeCompensationProfileId; set => EmployeeCompensationProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "BaseAmountValue", "RegularHourlyEquivalentValue", "PayComponents", "CostComponents" }; + } + + /// A pay component (specialty, incentive, longevity …) on a compensation profile. + public class EmployeePayComponent : IEntity + { + [Required] + public string EmployeePayComponentId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string EmployeeCompensationProfileId { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// . + public int Category { get; set; } + public string Name { get; set; } + /// . + public int Basis { get; set; } + /// ADP catalog 28: amount or percent. + public string Amount { get; set; } + /// Comma-separated names the component applies to (blank = all). + public string EligiblePayCodesCsv { get; set; } + /// True when the component is paid with every overtime hour (the CFAA Salary Survey includes it; fixed-period incentives are excluded). + public bool PaidForEachOvertimeHour { get; set; } + public string SourceAgreement { get; set; } + public int RowVersion { get; set; } = 1; + 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 decimal? AmountValue { get => ProtectedDecimal.Parse(Amount); set => Amount = ProtectedDecimal.Format(value); } + public bool Covers(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "EmployeePayComponents"; + [NotMapped] public string IdName => "EmployeePayComponentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => EmployeePayComponentId; set => EmployeePayComponentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "AmountValue" }; + } + + /// An employer-cost component (payroll tax, workers' comp, pension, benefits, overhead) on a profile. + public class EmployeeCostComponent : IEntity + { + [Required] + public string EmployeeCostComponentId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string EmployeeCompensationProfileId { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// . + public int Category { get; set; } + public string Name { get; set; } + /// . + public int Basis { get; set; } + /// ADP catalog 28: rate (percent) or amount. + public string RateAmount { get; set; } + public string EligiblePayCodesCsv { get; set; } + /// ADP catalog 28: optional annual cap. + public string Cap { get; set; } + public string Source { get; set; } + public int RowVersion { get; set; } = 1; + 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 decimal? RateAmountValue { get => ProtectedDecimal.Parse(RateAmount); set => RateAmount = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? CapValue { get => ProtectedDecimal.Parse(Cap); set => Cap = ProtectedDecimal.Format(value); } + public bool Covers(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "EmployeeCostComponents"; + [NotMapped] public string IdName => "EmployeeCostComponentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => EmployeeCostComponentId; set => EmployeeCostComponentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "RateAmountValue", "CapValue" }; + } + + /// An imported / field work fact (hours by type, place, source reference) — a fact store, not a punch clock. + public class WorkforceWorkEntry : IEntity + { + [Required] + public string WorkforceWorkEntryId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string WorkforceWorkerId { get; set; } + public string WorkforceEmploymentId { get; set; } + public DateTime WorkDate { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public decimal Hours { get; set; } + /// . + public int HoursType { get; set; } + public string WorkforceEstablishmentId { get; set; } + public string WorkCountry { get; set; } + public string WorkSubdivision { get; set; } + /// . + public int WorkMode { get; set; } + public int? CallId { get; set; } + public string DeploymentId { get; set; } + public string DeploymentTimeReportId { get; set; } + /// ADP catalog 28: the approved payroll cost of the entry when the payroll system supplied it. + public string ApprovedPayrollCost { get; set; } + public string ExternalSource { get; set; } + public string ExternalId { get; set; } + public string ImportBatchId { get; set; } + public bool IsApproved { get; set; } + public bool IsReconciled { get; set; } + public int RowVersion { get; set; } = 1; + 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 decimal? ApprovedPayrollCostValue { get => ProtectedDecimal.Parse(ApprovedPayrollCost); set => ApprovedPayrollCost = ProtectedDecimal.Format(value); } + + [NotMapped] public string TableName => "WorkforceWorkEntries"; + [NotMapped] public string IdName => "WorkforceWorkEntryId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceWorkEntryId; set => WorkforceWorkEntryId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "ApprovedPayrollCostValue" }; + } + + /// The annual W-2 / client-allocated facts CRD reporting needs (unique per employment, year and client allocation; corrections version). + public class WorkforceAnnualPayFact : IEntity + { + [Required] + public string WorkforceAnnualPayFactId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string WorkforceEmploymentId { get; set; } + public int ReportingYear { get; set; } + /// . + public int ReportType { get; set; } + public string ClientAllocationKey { get; set; } + /// ADP catalog 28. + public string W2Box5 { get; set; } + /// ADP catalog 28. + public string W2Box1 { get; set; } + /// ADP catalog 28: the earnings the report uses. + public string EarningsUsed { get; set; } + /// . + public int EarningsSource { get; set; } + public decimal? ActualWorkedHours { get; set; } + public decimal? PaidLeaveHours { get; set; } + public decimal? ReportableHours { get; set; } + public int? DaysWorked { get; set; } + public decimal? WeeksWorked { get; set; } + /// . + public int ExemptProxyMethod { get; set; } + public decimal? ProxyAverageHoursPerDay { get; set; } + /// ADP catalog 28: contractor earnings allocated to this client. + public string ClientAllocatedEarnings { get; set; } + public decimal? ClientAllocatedHours { get; set; } + public decimal? ClientAllocatedWeeks { get; set; } + public string Source { get; set; } + public string ImportBatchId { get; set; } + public string SourceChecksum { get; set; } + public bool IsReconciled { get; set; } + public bool IsApproved { get; set; } + public int Version { get; set; } = 1; + public string SupersedesFactId { 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 decimal? W2Box5Value { get => ProtectedDecimal.Parse(W2Box5); set => W2Box5 = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? W2Box1Value { get => ProtectedDecimal.Parse(W2Box1); set => W2Box1 = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? EarningsUsedValue { get => ProtectedDecimal.Parse(EarningsUsed); set => EarningsUsed = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? ClientAllocatedEarningsValue { get => ProtectedDecimal.Parse(ClientAllocatedEarnings); set => ClientAllocatedEarnings = ProtectedDecimal.Format(value); } + + [NotMapped] public string TableName => "WorkforceAnnualPayFacts"; + [NotMapped] public string IdName => "WorkforceAnnualPayFactId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceAnnualPayFactId; set => WorkforceAnnualPayFactId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "W2Box5Value", "W2Box1Value", "EarningsUsedValue", "ClientAllocatedEarningsValue" }; + } +} diff --git a/Core/Resgrid.Model/Workforce/CostingEntities.cs b/Core/Resgrid.Model/Workforce/CostingEntities.cs new file mode 100644 index 00000000..d469fc55 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/CostingEntities.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (M0222): resource (unit / asset / external) cost profiles and + // components, usage entries and the internal field-cost runs. Resource costs are operational facts gated by + // ViewInternalCosts (74); personnel lines snapshot their pay detail into an ADP catalog 28 column so a run's + // aggregate is visible without exposing an individual's rate. + + /// Acquisition, salvage, useful life and allocation basis for one unit / asset / external resource. + public class ResourceCostProfile : IEntity + { + [Required] + public string ResourceCostProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int SubjectType { get; set; } + public int? UnitId { get; set; } + public string InventoryAssetId { get; set; } + public string ExternalResourceKey { get; set; } + public string Name { get; set; } + public DateTime EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string Currency { get; set; } = "USD"; + public decimal? AcquisitionCost { get; set; } + public DateTime? AcquisitionDate { get; set; } + public DateTime? InServiceDate { get; set; } + public decimal? SalvageValue { get; set; } + /// . + public int DepreciationMethod { get; set; } + /// . + public int AllocationBasis { get; set; } + /// Useful life in the allocation basis unit (miles, kilometres, hours or days). + public decimal? UsefulLifeQuantity { get; set; } + public int? UsefulLifeMonths { get; set; } + /// Expected annual utilization in the allocation basis unit (for time-life and fixed-annual allocation). + public decimal? ExpectedAnnualUtilization { get; set; } + public string Source { get; set; } + public bool IsApproved { get; set; } + public string ApprovedByUserId { get; set; } + public DateTime? ApprovedOn { get; set; } + public int RowVersion { get; set; } = 1; + 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; } + + [NotMapped] public List Components { get; set; } = new List(); + [NotMapped] public string SubjectName { get; set; } + public bool Covers(DateTime asOf) => EffectiveOn.Date <= asOf.Date && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "ResourceCostProfiles"; + [NotMapped] public string IdName => "ResourceCostProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => ResourceCostProfileId; set => ResourceCostProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Components", "SubjectName" }; + } + + /// A variable or fixed cost component of a resource profile. + public class ResourceCostComponent : IEntity + { + [Required] + public string ResourceCostComponentId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string ResourceCostProfileId { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// . + public int Category { get; set; } + /// . + public int Basis { get; set; } + /// Direct rate / amount for the basis; null when the component is derived (consumption × unit price, or acquisition-calculated depreciation). + public decimal? Rate { get; set; } + /// Fuel / energy consumption per basis unit (e.g. gallons per mile). + public decimal? ConsumptionQuantity { get; set; } + public string ConsumptionUnit { get; set; } + /// Commodity unit price (e.g. $ per gallon). + public decimal? UnitPrice { get; set; } + /// . + public int Source { get; set; } + public DateTime? SourceWindowStart { get; set; } + public DateTime? SourceWindowEnd { get; set; } + public decimal? SourceMeterStart { get; set; } + public decimal? SourceMeterEnd { get; set; } + public bool IsApproved { get; set; } + public int RowVersion { get; set; } = 1; + 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 Covers(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "ResourceCostComponents"; + [NotMapped] public string IdName => "ResourceCostComponentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => ResourceCostComponentId; set => ResourceCostComponentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One usage observation of a resource (distance, engine / operating / idle hours, days, fuel) against a Call / Deployment / DTR. + public class ResourceUsageEntry : IEntity + { + [Required] + public string ResourceUsageEntryId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int SubjectType { get; set; } + public int? UnitId { get; set; } + public string InventoryAssetId { get; set; } + public string ExternalResourceKey { get; set; } + public int? CallId { get; set; } + public string DeploymentId { get; set; } + public string DeploymentTimeReportId { get; set; } + public DateTime UsageDate { get; set; } + /// . + public int Phase { get; set; } + public decimal? StartOdometer { get; set; } + public decimal? EndOdometer { get; set; } + /// "mi" or "km" of the odometer / distance as recorded. + public string DistanceUnit { get; set; } = "mi"; + public decimal? OriginalDistance { get; set; } + /// Distance in miles (canonical). + public decimal? CanonicalDistanceMiles { get; set; } + public decimal? StartEngineMeter { get; set; } + public decimal? EndEngineMeter { get; set; } + public decimal? EngineHours { get; set; } + public decimal? OperatingHours { get; set; } + public decimal? IdleHours { get; set; } + public decimal? DeployedDays { get; set; } + public decimal? StandbyDays { get; set; } + public decimal? FuelQuantity { get; set; } + public string FuelUnit { get; set; } + public decimal? FuelActualCost { get; set; } + /// . + public int Source { get; set; } + public string ExternalId { get; set; } + public bool IsApproved { get; set; } + public bool NeedsReview { get; set; } + public string ReviewReason { get; set; } + public int RowVersion { get; set; } = 1; + 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; } + + [NotMapped] public string TableName => "ResourceUsageEntries"; + [NotMapped] public string IdName => "ResourceUsageEntryId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => ResourceUsageEntryId; set => ResourceUsageEntryId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A frozen-able internal cost run for a Bid, Call or Deployment (estimate or actual), with revenue / recovery comparison. + public class FieldCostRun : IEntity + { + [Required] + public string FieldCostRunId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int ContextType { get; set; } + public string BidId { get; set; } + public int? CallId { get; set; } + public string DeploymentId { get; set; } + /// . + public int RunType { get; set; } + public DateTime? ThroughDate { get; set; } + public string Currency { get; set; } = "USD"; + /// . + public int Status { get; set; } + /// Version cutoffs: "profileId:rowVersion,…" of every compensation / resource profile the run consumed. + public string InputVersions { get; set; } + /// . + public int RevenueSource { get; set; } + public decimal? RevenueAmount { get; set; } + public string RevenueSourceId { get; set; } + public string RevenueSourceVersion { get; set; } + public decimal PersonnelTotal { get; set; } + public decimal ResourceTotal { get; set; } + public decimal ConsumableTotal { get; set; } + public decimal ExpenseTotal { get; set; } + public decimal OverheadTotal { get; set; } + public decimal TotalLoadedCost { get; set; } + public decimal? ContributionMargin { get; set; } + public decimal? ContributionMarginPercent { get; set; } + public decimal BreakEvenRevenue { get; set; } + public int MissingInputCount { get; set; } + public string SupersedesRunId { get; set; } + public string FrozenByUserId { get; set; } + public DateTime? FrozenOn { get; set; } + public int RowVersion { get; set; } = 1; + 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; } + + [NotMapped] public List Lines { get; set; } = new List(); + [NotMapped] public bool IsFrozen => Status == (int)FieldCostRunStatuses.Frozen || Status == (int)FieldCostRunStatuses.Superseded; + + [NotMapped] public string TableName => "FieldCostRuns"; + [NotMapped] public string IdName => "FieldCostRunId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => FieldCostRunId; set => FieldCostRunId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Lines", "IsFrozen" }; + } + + /// One cost line of a run. Personnel lines keep their rate / multiplier detail in the protected column. + public class FieldCostLine : IEntity + { + [Required] + public string FieldCostLineId { get; set; } + [Required] + public string FieldCostRunId { get; set; } + public int DepartmentId { get; set; } + public DateTime? LineDate { get; set; } + /// . + public int Category { get; set; } + /// "Employment", "Unit", "InventoryAsset", "External", "Expense", "Consumable", "Overhead". + public string SubjectType { get; set; } + public string SubjectId { get; set; } + public string SubjectLabel { get; set; } + public string Component { get; set; } + public decimal Quantity { get; set; } + public string Unit { get; set; } + /// Snapshotted rate for resource / expense lines; null for personnel lines (their detail is protected). + public decimal? Rate { get; set; } + public decimal Amount { get; set; } + /// ADP catalog 28: JSON with the personnel rate, multiplier, component ids and versions. + public string ProtectedDetailJson { get; set; } + public string SourceType { get; set; } + public string SourceId { get; set; } + public bool IsEstimated { get; set; } + public bool IsFallback { get; set; } + public string ReviewReason { get; set; } + public int SortOrder { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "FieldCostLines"; + [NotMapped] public string IdName => "FieldCostLineId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => FieldCostLineId; set => FieldCostLineId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Workforce/PayDataEntities.cs b/Core/Resgrid.Model/Workforce/PayDataEntities.cs new file mode 100644 index 00000000..edbcd3d3 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/PayDataEntities.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (M0223): California §12999 pay-data reporting. Demographics live + // in their own table / repository / service (never joined by personnel or compensation queries); runs, employee + // snapshots, aggregate rows and export artifacts are immutable once frozen. Values are ADP catalog 28. + + /// A worker's separately collected demographic responses (voluntary self-identification first). + public class PayDataReportingDemographic : IEntity + { + [Required] + public string PayDataReportingDemographicId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string WorkforceWorkerId { get; set; } + public DateTime EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + /// ADP catalog 28: "Yes" / "No" / "Declined". + public string HispanicLatino { get; set; } + /// ADP catalog 28: comma-separated stable race / ethnicity codes (see ). + public string RaceEthnicityCodes { get; set; } + /// ADP catalog 28: sex reporting code (F / M / N / Declined). + public string SexCode { get; set; } + public bool DeclinedRaceEthnicity { get; set; } + public bool DeclinedSex { get; set; } + /// . + public int CollectionSource { get; set; } + public DateTime? CollectedOn { get; set; } + public string CollectedByUserId { get; set; } + public DateTime? ReviewedOn { get; set; } + public string ReviewedByUserId { get; set; } + public int Version { get; set; } = 1; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "PayDataReportingDemographics"; + [NotMapped] public string IdName => "PayDataReportingDemographicId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => PayDataReportingDemographicId; set => PayDataReportingDemographicId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One CRD report run (payroll or labor contractor) for a reporting year and snapshot period. + public class PayDataReportRun : IEntity + { + [Required] + public string PayDataReportRunId { get; set; } + [Required] + public int DepartmentId { get; set; } + /// . + public int ReportType { get; set; } + public int ReportingYear { get; set; } + public string SchemaProfileCode { get; set; } + public string SchemaProfileHash { get; set; } + public DateTime SnapshotStart { get; set; } + public DateTime SnapshotEnd { get; set; } + /// ADP catalog 28: the employer / affiliate identity as of the run. + public string EmployerSnapshotJson { get; set; } + public DateTime? SourceCutoff { get; set; } + /// . + public int Status { get; set; } + public int EmployeeCount { get; set; } + public int RowCount { get; set; } + public int ExceptionCount { get; set; } + public int WarningCount { get; set; } + public string ValidationSummaryJson { get; set; } + /// ADP catalog 28: run-level clarifying remarks. + public string RunRemarks { get; set; } + public string SupersedesRunId { get; set; } + public string ReviewedByUserId { get; set; } + public DateTime? ReviewedOn { get; set; } + public string FrozenByUserId { get; set; } + public DateTime? FrozenOn { get; set; } + public string ExportedByUserId { get; set; } + public DateTime? ExportedOn { get; set; } + public string CertifiedByUserId { get; set; } + public DateTime? CertifiedOn { get; set; } + public string CertificationReference { get; set; } + public string CertifiedArtifactChecksum { get; set; } + public int RowVersion { get; set; } = 1; + 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 bool IsFrozen => Status >= (int)PayDataReportRunStatuses.FrozenForExport && Status != (int)PayDataReportRunStatuses.Void; + [NotMapped] public bool IsEditable => Status is (int)PayDataReportRunStatuses.Draft or (int)PayDataReportRunStatuses.Validated; + + [NotMapped] public string TableName => "PayDataReportRuns"; + [NotMapped] public string IdName => "PayDataReportRunId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => PayDataReportRunId; set => PayDataReportRunId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsFrozen", "IsEditable" }; + } + + /// One employee's resolved facts for a run (immutable after freeze). + public class PayDataReportEmployeeSnapshot : IEntity + { + [Required] + public string PayDataReportEmployeeSnapshotId { get; set; } + [Required] + public string PayDataReportRunId { get; set; } + public int DepartmentId { get; set; } + [Required] + public string WorkforceWorkerId { get; set; } + public string WorkforceEmploymentId { get; set; } + public string WorkforceEstablishmentId { get; set; } + public string WorkforceLaborContractorId { get; set; } + public string JobCategoryCode { get; set; } + /// ADP catalog 28: the derived combined race/ethnicity/sex upload code. + public string DemographicCode { get; set; } + public string PayBandCode { get; set; } + public string ExemptionCode { get; set; } + public string EmploymentTypeCode { get; set; } + /// . + public int WorkMode { get; set; } + /// ADP catalog 28. + public string AnnualEarnings { get; set; } + /// . + public int EarningsSource { get; set; } + public decimal AnnualHours { get; set; } + public decimal AnnualWeeks { get; set; } + /// ADP catalog 28: annual earnings ÷ reportable hours. + public string HourlyRate { get; set; } + public bool IsIncluded { get; set; } = true; + public string ExceptionCodesCsv { get; set; } + public string OverrideReason { get; set; } + public string OverrideByUserId { get; set; } + public string SourceVersions { 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 decimal? AnnualEarningsValue { get => ProtectedDecimal.Parse(AnnualEarnings); set => AnnualEarnings = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? HourlyRateValue { get => ProtectedDecimal.Parse(HourlyRate); set => HourlyRate = ProtectedDecimal.Format(value); } + [NotMapped] public string WorkerDisplayName { get; set; } + [NotMapped] public List ExceptionCodes => string.IsNullOrWhiteSpace(ExceptionCodesCsv) ? new List() : new List(ExceptionCodesCsv.Split(',', StringSplitOptions.RemoveEmptyEntries)); + + [NotMapped] public string TableName => "PayDataReportEmployeeSnapshots"; + [NotMapped] public string IdName => "PayDataReportEmployeeSnapshotId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => PayDataReportEmployeeSnapshotId; set => PayDataReportEmployeeSnapshotId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "AnnualEarningsValue", "HourlyRateValue", "WorkerDisplayName", "ExceptionCodes" }; + } + + /// One aggregated upload row (establishment × job category × demographic × pay band …). + public class PayDataReportRow : IEntity + { + [Required] + public string PayDataReportRowId { get; set; } + [Required] + public string PayDataReportRunId { get; set; } + public int DepartmentId { get; set; } + public string WorkforceEstablishmentId { get; set; } + public string WorkforceLaborContractorId { get; set; } + public string JobCategoryCode { get; set; } + /// ADP catalog 28. + public string DemographicCode { get; set; } + public string PayBandCode { get; set; } + public string ExemptionCode { get; set; } + public string EmploymentTypeCode { get; set; } + public int EmployeeCount { get; set; } + public decimal AnnualHours { get; set; } + public decimal AnnualWeeks { get; set; } + /// ADP catalog 28. + public string MeanHourlyRate { get; set; } + /// ADP catalog 28. + public string MedianHourlyRate { get; set; } + public int NonRemoteCount { get; set; } + public int RemoteWithinCaliforniaCount { get; set; } + public int RemoteOutsideCaliforniaCount { get; set; } + /// ADP catalog 28. + public string RowRemarks { get; set; } + public string ContributingSnapshotIdsCsv { get; set; } + public int SortOrder { get; set; } + public DateTime AddedOn { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public decimal? MeanHourlyRateValue { get => ProtectedDecimal.Parse(MeanHourlyRate); set => MeanHourlyRate = ProtectedDecimal.Format(value); } + [NotMapped] public decimal? MedianHourlyRateValue { get => ProtectedDecimal.Parse(MedianHourlyRate); set => MedianHourlyRate = ProtectedDecimal.Format(value); } + + [NotMapped] public string TableName => "PayDataReportRows"; + [NotMapped] public string IdName => "PayDataReportRowId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => PayDataReportRowId; set => PayDataReportRowId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "MeanHourlyRateValue", "MedianHourlyRateValue" }; + } + + /// A short-lived encrypted export (CSV / XLSX) of a frozen run; purged after the configured window. + public class PayDataExportArtifact : IEntity + { + [Required] + public string PayDataExportArtifactId { get; set; } + [Required] + public string PayDataReportRunId { get; set; } + public int DepartmentId { get; set; } + public string SchemaProfileCode { get; set; } + public string SchemaProfileHash { get; set; } + /// . + public int Format { get; set; } + public string FileName { get; set; } + public string Checksum { get; set; } + /// ADP catalog 28 (binary). + public byte[] Data { get; set; } + public int Size { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ExpiresOn { get; set; } + public DateTime? PurgedOn { get; set; } + public string ExportedByUserId { get; set; } + public int DownloadCount { get; set; } + public DateTime? LastDownloadedOn { get; set; } + public string LastDownloadedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public bool IsAvailable => !PurgedOn.HasValue && ExpiresOn > DateTime.UtcNow; + + [NotMapped] public string TableName => "PayDataExportArtifacts"; + [NotMapped] public string IdName => "PayDataExportArtifactId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => PayDataExportArtifactId; set => PayDataExportArtifactId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsAvailable" }; + } +} diff --git a/Core/Resgrid.Model/Workforce/WorkforceContracts.cs b/Core/Resgrid.Model/Workforce/WorkforceContracts.cs new file mode 100644 index 00000000..bf06ec95 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/WorkforceContracts.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (E3): the pure calculators' inputs and outputs, the report run's + // validation / exception contracts, import results and the aggregate cost summary a mobile client may see. + + #region Labor cost + + /// One approved work quantity to price for one employment. + public sealed class LaborWorkQuantity + { + public string WorkforceEmploymentId { get; set; } + public string SubjectLabel { get; set; } + public DateTime? WorkDate { get; set; } + /// . + public int PayCode { get; set; } + public decimal Hours { get; set; } + public string SourceType { get; set; } + public string SourceId { get; set; } + /// Approved payroll cost supplied by the payroll system (actual runs); when present it replaces the estimate. + public decimal? ApprovedPayrollCost { get; set; } + } + + public sealed class LaborCostInput + { + public LaborWorkQuantity Work { get; set; } + /// The effective profile (employee, role default or department default) — null when none resolves. + public EmployeeCompensationProfile Profile { get; set; } + public bool IsFallback { get; set; } + public DateTime AsOf { get; set; } + public string Currency { get; set; } = "USD"; + } + + public sealed class LaborCostResult + { + public decimal BaseRate { get; set; } + public decimal Multiplier { get; set; } = 1m; + public decimal PayAmount { get; set; } + public decimal PayComponentAmount { get; set; } + public decimal EmployerCostAmount { get; set; } + public decimal LoadedCost => Math.Round(PayAmount + PayComponentAmount + EmployerCostAmount, 2, MidpointRounding.AwayFromZero); + public bool NeedsReview { get; set; } + public bool IsFallback { get; set; } + public bool IsEstimated { get; set; } = true; + public List ReviewReasons { get; set; } = new List(); + public List Details { get; set; } = new List(); + } + + public sealed class LaborCostDetail + { + public string Kind { get; set; } + public string Name { get; set; } + public string Basis { get; set; } + public decimal Rate { get; set; } + public decimal Amount { get; set; } + public string ComponentId { get; set; } + public int? Version { get; set; } + } + + public static class LaborReviewReasons + { + public const string NoProfile = "no_compensation_profile"; + public const string RoleFallback = "role_default_fallback"; + public const string DepartmentFallback = "department_default_fallback"; + public const string CurrencyMismatch = "currency_mismatch"; + public const string UnapprovedProfile = "unapproved_profile"; + public const string NoRate = "no_rate_for_basis"; + public const string PayCodeMultiplierMissing = "pay_code_multiplier_missing"; + public const string ApprovedPayrollCostUsed = "approved_payroll_cost_used"; + } + + #endregion + + #region Resource cost + + public sealed class ResourceUsageQuantity + { + public string SubjectLabel { get; set; } + public DateTime? UsageDate { get; set; } + public decimal Miles { get; set; } + public decimal EngineHours { get; set; } + public decimal OperatingHours { get; set; } + public decimal IdleHours { get; set; } + public decimal Days { get; set; } + public decimal Deployments { get; set; } + public decimal? ActualFuelCost { get; set; } + public string SourceType { get; set; } + public string SourceId { get; set; } + } + + public sealed class ResourceCostInput + { + public ResourceUsageQuantity Usage { get; set; } + public ResourceCostProfile Profile { get; set; } + public bool IsFallback { get; set; } + public DateTime AsOf { get; set; } + /// Component ids of repairs already charged as direct lines (double-count prevention for rolling maintenance). + public HashSet ExcludedComponentIds { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public sealed class ResourceCostResult + { + public List Details { get; set; } = new List(); + public decimal Total => Math.Round(Details.Sum(d => d.Amount), 2, MidpointRounding.AwayFromZero); + public bool NeedsReview { get; set; } + public bool IsFallback { get; set; } + public List ReviewReasons { get; set; } = new List(); + } + + public sealed class ResourceCostDetail + { + /// name. + public string Category { get; set; } + public string Basis { get; set; } + public decimal Quantity { get; set; } + public string Unit { get; set; } + public decimal Rate { get; set; } + public decimal Amount { get; set; } + public string ComponentId { get; set; } + public int? Version { get; set; } + public bool Blocked { get; set; } + public string Reason { get; set; } + } + + public static class ResourceReviewReasons + { + public const string NoProfile = "no_resource_profile"; + public const string ClassFallback = "class_default_fallback"; + public const string DepreciationInputsMissing = "depreciation_inputs_missing"; + public const string FuelInputsMissing = "fuel_inputs_missing"; + public const string UtilizationMissing = "utilization_missing"; + public const string RollingWindowInsufficient = "rolling_window_insufficient"; + public const string RepairDoubleCounted = "repair_double_counted"; + public const string ComponentUnapproved = "component_unapproved"; + } + + #endregion + + #region Field cost runs + + public sealed class FieldCostSummary + { + public string FieldCostRunId { get; set; } + public int ContextType { get; set; } + public int RunType { get; set; } + public int Status { get; set; } + public DateTime? ThroughDate { get; set; } + public string Currency { get; set; } + public decimal PersonnelTotal { get; set; } + public decimal ResourceTotal { get; set; } + public decimal ConsumableTotal { get; set; } + public decimal ExpenseTotal { get; set; } + public decimal OverheadTotal { get; set; } + public decimal TotalLoadedCost { get; set; } + public int RevenueSource { get; set; } + public decimal? RevenueAmount { get; set; } + public decimal? ContributionMargin { get; set; } + public decimal? ContributionMarginPercent { get; set; } + public decimal BreakEvenRevenue { get; set; } + public int MissingInputCount { get; set; } + public DateTime? FrozenOn { get; set; } + } + + public sealed class FieldCostComparison + { + public FieldCostRun Estimate { get; set; } + public FieldCostRun Actual { get; set; } + public decimal PersonnelVariance => (Actual?.PersonnelTotal ?? 0) - (Estimate?.PersonnelTotal ?? 0); + public decimal ResourceVariance => (Actual?.ResourceTotal ?? 0) - (Estimate?.ResourceTotal ?? 0); + public decimal ExpenseVariance => (Actual?.ExpenseTotal ?? 0) - (Estimate?.ExpenseTotal ?? 0); + public decimal TotalVariance => (Actual?.TotalLoadedCost ?? 0) - (Estimate?.TotalLoadedCost ?? 0); + public decimal? RevenueVariance => Actual?.RevenueAmount.HasValue == true && Estimate?.RevenueAmount.HasValue == true ? Actual.RevenueAmount - Estimate.RevenueAmount : null; + } + + /// What a bid estimate prices: assumed hours per employment / role and assumed resource usage. + public sealed class BidCostAssumptions + { + public List Labor { get; set; } = new List(); + public List<(int? PersonnelRoleId, int PayCode, decimal Hours, string Label)> RoleLabor { get; set; } = new List<(int?, int, decimal, string)>(); + public List<(int SubjectType, int? UnitId, string AssetId, string ExternalKey, ResourceUsageQuantity Usage)> Resources { get; set; } = new List<(int, int?, string, string, ResourceUsageQuantity)>(); + public decimal ExpenseAllowance { get; set; } + } + + #endregion + + #region Pay data reporting + + public sealed class PayDataValidationIssue + { + public string Code { get; set; } + public string Scope { get; set; } + public string SubjectId { get; set; } + public string Detail { get; set; } + public bool IsBlocking { get; set; } + } + + public sealed class PayDataValidationResult + { + public string RunId { get; set; } + public DateTime ValidatedOn { get; set; } + public List Errors { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + public bool CanFreeze => Errors.Count == 0; + } + + public static class PayDataValidationCodes + { + public const string ProfileStale = "profile_stale"; + public const string SnapshotOutsideWindow = "snapshot_outside_window"; + public const string EmployerIdentityMissing = "employer_identity_missing"; + public const string CoverageUndeclared = "coverage_undeclared"; + public const string NoEmployees = "no_employees"; + public const string EstablishmentMissing = "establishment_missing"; + public const string EstablishmentNaicsMissing = "establishment_naics_missing"; + public const string EstablishmentAddressMissing = "establishment_address_missing"; + public const string ContractorIdentityMissing = "contractor_identity_missing"; + public const string JobCategoryMissing = "job_category_missing"; + public const string JobCategoryUnknown = "job_category_unknown"; + public const string DemographicMissing = "demographic_missing"; + public const string AnnualFactMissing = "annual_fact_missing"; + public const string AnnualFactUnapproved = "annual_fact_unapproved"; + public const string EarningsMissing = "earnings_missing"; + public const string EarningsBox1Fallback = "earnings_box1_fallback"; + public const string HoursZero = "hours_zero"; + public const string WeeksMissing = "weeks_missing"; + public const string ExemptProxyUsed = "exempt_proxy_used"; + public const string AssignmentOverlap = "assignment_overlap"; + public const string RemoteCountsMismatch = "remote_counts_mismatch"; + public const string EmployeeCountMismatch = "employee_count_mismatch"; + public const string FieldTooLong = "field_too_long"; + public const string FileTooLarge = "file_too_large"; + public const string ObserverPerceptionUsed = "observer_perception_used"; + public const string ManualOverride = "manual_override"; + public const string WorkModeUnresolved = "work_mode_unresolved"; + } + + /// The portal-entry worksheet (employer totals, establishments, affiliates, snapshot dates, filing contact) — protected, no-store. + public sealed class PayDataPortalWorksheet + { + public string RunId { get; set; } + public string ProfileCode { get; set; } + public int ReportType { get; set; } + public int ReportingYear { get; set; } + public DateTime SnapshotStart { get; set; } + public DateTime SnapshotEnd { get; set; } + public string EmployerLegalName { get; set; } + public string EmployerFein { get; set; } + public string EmployerSein { get; set; } + public string EmployerSosNumber { get; set; } + public string EmployerNaics { get; set; } + public string EddAddress { get; set; } + public string HeadquartersAddress { get; set; } + public bool IsIntegratedEnterprise { get; set; } + public string FilingContactName { get; set; } + public string FilingContactEmail { get; set; } + public string FilingContactPhone { get; set; } + public int? UsEmployeeCount { get; set; } + public int? CaliforniaEmployeeCount { get; set; } + public int SnapshotEmployeeCount { get; set; } + public List Establishments { get; set; } = new List(); + public List Affiliates { get; set; } = new List(); + public string RunRemarks { get; set; } + public DateTime DueDate { get; set; } + } + + public sealed class PayDataWorksheetEstablishment + { + public string Code { get; set; } + public string Name { get; set; } + public string Address { get; set; } + public string City { get; set; } + public string State { get; set; } + public string Zip { get; set; } + public string Naics { get; set; } + public string MajorActivity { get; set; } + public bool IsHeadquarters { get; set; } + public bool? WasFiledPriorYear { get; set; } + public int EmployeeCount { get; set; } + } + + public sealed class PayDataWorksheetAffiliate + { + public string LegalName { get; set; } + public string Fein { get; set; } + public string Sein { get; set; } + public string SosNumber { get; set; } + public string HeadquartersAddress { get; set; } + } + + /// Completeness view for the compliance officer: counts only, never values. + public sealed class DemographicCompleteness + { + public int ActiveWorkers { get; set; } + public int WithResponse { get; set; } + public int SelfIdentified { get; set; } + public int Declined { get; set; } + public int ObserverPerception { get; set; } + public int Missing => Math.Max(0, ActiveWorkers - WithResponse); + } + + public sealed class PayDataReadiness + { + public int ReportingYear { get; set; } + public string ProfileCode { get; set; } + public bool ProfileAvailable { get; set; } + public DateTime DueDate { get; set; } + public int CoverageStatus { get; set; } + public int OpenRuns { get; set; } + public int UnresolvedExceptions { get; set; } + public int DemographicsMissing { get; set; } + public int AnnualFactsMissing { get; set; } + public bool HasFrozenRun { get; set; } + public bool HasCertifiedRun { get; set; } + } + + #endregion + + #region Imports + + public sealed class WorkforceImportResult + { + public bool DryRun { get; set; } + public int Total { get; set; } + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public string ImportBatchId { get; set; } + public List Issues { get; set; } = new List(); + public bool HasErrors => Issues.Any(i => i.IsError); + } + + public sealed class WorkforceImportIssue + { + public int Line { get; set; } + public string Code { get; set; } + public string Detail { get; set; } + public bool IsError { get; set; } + } + + #endregion +} diff --git a/Core/Resgrid.Model/Workforce/WorkforceEntities.cs b/Core/Resgrid.Model/Workforce/WorkforceEntities.cs new file mode 100644 index 00000000..5202513f --- /dev/null +++ b/Core/Resgrid.Model/Workforce/WorkforceEntities.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (M0220): employer identity, affiliates, establishments, labor + // contractors, workers, employment periods and job assignments. Identifiers and addresses are ADP catalog 28 + // fields (Personnel family); dates, codes and flags are routing metadata. + + /// The department's employer identity for CRD reporting (one active profile, versioned). + public class WorkforceEmployerProfile : IEntity + { + [Required] + public string WorkforceEmployerProfileId { get; set; } + [Required] + public int DepartmentId { get; set; } + public string LegalName { get; set; } + /// ADP catalog 28. + public string Fein { get; set; } + /// ADP catalog 28 (California employer account number). + public string Sein { get; set; } + /// ADP catalog 28 (California Secretary of State number). + public string SosNumber { get; set; } + public string Naics { get; set; } + /// ADP catalog 28. + public string EddAddress { get; set; } + /// ADP catalog 28. + public string HeadquartersAddress { get; set; } + public bool IsIntegratedEnterprise { get; set; } + /// ADP catalog 28. + public string FilingContactName { get; set; } + /// ADP catalog 28. + public string FilingContactEmail { get; set; } + /// ADP catalog 28. + public string FilingContactPhone { get; set; } + /// ; employer-declared, never determined by Resgrid. + public int CoverageStatus { get; set; } + public int? UsEmployeeCount { get; set; } + public int? CaliforniaEmployeeCount { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public bool IsActive { get; set; } = true; + public int RowVersion { get; set; } = 1; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "WorkforceEmployerProfiles"; + [NotMapped] public string IdName => "WorkforceEmployerProfileId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceEmployerProfileId; set => WorkforceEmployerProfileId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// An affiliated entity of an integrated enterprise. + public class WorkforceAffiliatedEntity : IEntity + { + [Required] + public string WorkforceAffiliatedEntityId { get; set; } + [Required] + public int DepartmentId { get; set; } + public string WorkforceEmployerProfileId { get; set; } + public string LegalName { get; set; } + /// ADP catalog 28. + public string Fein { get; set; } + /// ADP catalog 28. + public string Sein { get; set; } + /// ADP catalog 28. + public string SosNumber { get; set; } + /// ADP catalog 28. + public string HeadquartersAddress { get; set; } + public DateTime? EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public int RowVersion { get; set; } = 1; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "WorkforceAffiliatedEntities"; + [NotMapped] public string IdName => "WorkforceAffiliatedEntityId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceAffiliatedEntityId; set => WorkforceAffiliatedEntityId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// A CRD establishment: a stable economic unit with a physical address (never an employee's home). + public class WorkforceEstablishment : IEntity + { + [Required] + public string WorkforceEstablishmentId { get; set; } + [Required] + public int DepartmentId { get; set; } + public string WorkforceAffiliatedEntityId { get; set; } + [Required] + public string Code { get; set; } + [Required] + public string Name { get; set; } + /// ADP catalog 28. + public string PhysicalAddress { get; set; } + public string City { get; set; } + public string StateCode { get; set; } + public string PostalCode { get; set; } + public string Naics { get; set; } + public string MajorActivity { get; set; } + public bool IsHeadquarters { get; set; } + public bool? WasFiledPriorYear { get; set; } + public DateTime? ActiveFrom { get; set; } + public DateTime? ActiveTo { get; set; } + public string TimeZoneId { get; set; } + public int RowVersion { get; set; } = 1; + 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 bool IsCalifornia => string.Equals(StateCode, "CA", StringComparison.OrdinalIgnoreCase); + public bool IsActiveOn(DateTime asOf) => (!ActiveFrom.HasValue || ActiveFrom.Value.Date <= asOf.Date) && (!ActiveTo.HasValue || ActiveTo.Value.Date >= asOf.Date); + + [NotMapped] public string TableName => "WorkforceEstablishments"; + [NotMapped] public string IdName => "WorkforceEstablishmentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceEstablishmentId; set => WorkforceEstablishmentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "IsCalifornia" }; + } + + /// A labor contractor supplying workers to the department (the CRD labor-contractor report's client relationship). + public class WorkforceLaborContractor : IEntity + { + [Required] + public string WorkforceLaborContractorId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string LegalName { get; set; } + public string OwnershipName { get; set; } + public string Dba { get; set; } + /// ADP catalog 28. + public string Fein { get; set; } + /// "FEIN" or an approved alternate identifier type. + public string IdentifierType { get; set; } = "FEIN"; + /// ADP catalog 28. + public string ContactDetails { get; set; } + public DateTime? RelationshipStartOn { get; set; } + public DateTime? RelationshipEndOn { get; set; } + public string Provenance { get; set; } + public bool IsActive { get; set; } = true; + public int RowVersion { get; set; } = 1; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string TableName => "WorkforceLaborContractors"; + [NotMapped] public string IdName => "WorkforceLaborContractorId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceLaborContractorId; set => WorkforceLaborContractorId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// One stable department worker identity: a Resgrid user or an external (non-login) worker. + public class WorkforceWorker : IEntity + { + [Required] + public string WorkforceWorkerId { get; set; } + [Required] + public int DepartmentId { get; set; } + public string UserId { get; set; } + /// ADP catalog 28: the contractor's / payroll system's key for a non-login worker. + public string ExternalWorkerKey { get; set; } + /// ADP catalog 28: how a non-login worker is shown to authorized users. + public string DisplayLabel { get; set; } + public bool IsActive { get; set; } = true; + public int RowVersion { get; set; } = 1; + public bool IsDeleted { get; set; } + public DateTime AddedOn { get; set; } + public string AddedByUserId { get; set; } + public DateTime? EditedOn { get; set; } + public string EditedByUserId { get; set; } + public bool IsProtected { get; set; } + public int? ProtectedCatalogVersion { get; set; } + + [NotMapped] public string DisplayName { get; set; } + + [NotMapped] public string TableName => "WorkforceWorkers"; + [NotMapped] public string IdName => "WorkforceWorkerId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceWorkerId; set => WorkforceWorkerId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "DisplayName" }; + } + + /// A non-overlapping employment period of a worker. + public class WorkforceEmployment : IEntity + { + [Required] + public string WorkforceEmploymentId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string WorkforceWorkerId { get; set; } + public string WorkforceAffiliatedEntityId { get; set; } + public string WorkforceLaborContractorId { get; set; } + /// . + public int WorkerKind { get; set; } + public DateTime StartOn { get; set; } + public DateTime? EndOn { get; set; } + /// . + public int EmploymentType { get; set; } + /// . + public int ExemptionStatus { get; set; } + public string DefaultEstablishmentId { get; set; } + /// . + public int CaliforniaEmployeeBasis { get; set; } + public int? PersonnelRoleId { get; set; } + public int RowVersion { get; set; } = 1; + 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; } + + [NotMapped] public List Assignments { get; set; } = new List(); + [NotMapped] public string WorkerDisplayName { get; set; } + public bool Covers(DateTime from, DateTime to) => StartOn.Date <= to.Date && (!EndOn.HasValue || EndOn.Value.Date >= from.Date); + public bool Overlaps(WorkforceEmployment other) => StartOn.Date <= (other.EndOn ?? DateTime.MaxValue).Date && (EndOn ?? DateTime.MaxValue).Date >= other.StartOn.Date; + + [NotMapped] public string TableName => "WorkforceEmployments"; + [NotMapped] public string IdName => "WorkforceEmploymentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceEmploymentId; set => WorkforceEmploymentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Assignments", "WorkerDisplayName" }; + } + + /// An effective-dated job assignment: establishment, title, CRD job category and the optional Cal OES MARS classification crosswalk. + public class WorkforceJobAssignment : IEntity + { + [Required] + public string WorkforceJobAssignmentId { get; set; } + [Required] + public int DepartmentId { get; set; } + [Required] + public string WorkforceEmploymentId { get; set; } + public DateTime EffectiveOn { get; set; } + public DateTime? ExpiresOn { get; set; } + public string WorkforceEstablishmentId { get; set; } + public string JobTitle { get; set; } + public string SocCode { get; set; } + public string SocVersion { get; set; } + /// The CRD schema profile the job category code belongs to (e.g. CRD-RY2025). + public string CaPayDataProfileCode { get; set; } + /// CRD job category code within the profile (1–10 in Reporting Year 2025). + public string JobCategoryCode { get; set; } + public string CalOesMarsAuthorityProfileCode { get; set; } + public string CalOesMarsClassificationCode { get; set; } + public string MappingProvenance { get; set; } + /// . + public int WorkMode { get; set; } + public string WorkCountry { get; set; } + public string WorkSubdivision { get; set; } + public int RowVersion { get; set; } = 1; + 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 Covers(DateTime asOf) => EffectiveOn.Date <= asOf.Date && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date); + public bool Overlaps(WorkforceJobAssignment other) => EffectiveOn.Date <= (other.ExpiresOn ?? DateTime.MaxValue).Date && (ExpiresOn ?? DateTime.MaxValue).Date >= other.EffectiveOn.Date; + + [NotMapped] public string TableName => "WorkforceJobAssignments"; + [NotMapped] public string IdName => "WorkforceJobAssignmentId"; + [NotMapped] public int IdType => 1; + [NotMapped] [JsonIgnore] public object IdValue { get => WorkforceJobAssignmentId; set => WorkforceJobAssignmentId = (string)value; } + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Workforce/WorkforceEnums.cs b/Core/Resgrid.Model/Workforce/WorkforceEnums.cs new file mode 100644 index 00000000..a275adc7 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/WorkforceEnums.cs @@ -0,0 +1,310 @@ +namespace Resgrid.Model.Workforce +{ + // Workforce & Business Operations plan, Phase E (E2): protected workforce pay data, field costing and California + // §12999 pay-data reporting. Every enum is routing metadata; the values that identify or price a person are + // stored through the Advanced Data Protection seam (WorkforceProtectedFields, catalog 28). + + #region Employment (M0220) + + public enum CaliforniaPayDataCoverageStatuses + { + Unknown = 0, + NotCovered = 1, + CoveredPayroll = 2, + CoveredLaborContractor = 3, + CoveredBoth = 4 + } + + public enum WorkerKinds + { + PayrollEmployee = 0, + LaborContractorEmployee = 1, + Volunteer = 2, + IndependentContractor = 3 + } + + public enum EmploymentTypes + { + Unknown = 0, + FullTime = 1, + PartTime = 2, + Intermittent = 3 + } + + public enum ExemptionStatuses + { + Unknown = 0, + Exempt = 1, + NonExempt = 2 + } + + /// Why a worker counts as a California employee (E1: never inferred from a home address). + public enum CaliforniaEmployeeBases + { + NotCalifornia = 0, + AssignedToCaliforniaEstablishment = 1, + WorksInCalifornia = 2, + Both = 3 + } + + /// CRD remote-work classification; exactly one per employee snapshot. + public enum WorkModes + { + NonRemote = 0, + RemoteWithinCalifornia = 1, + RemoteOutsideCaliforniaAssignedToCaliforniaEstablishment = 2 + } + + #endregion + + #region Compensation (M0221) + + public enum PayBases + { + Hourly = 0, + Salary = 1, + Daily = 2, + Shift = 3, + Stipend = 4 + } + + public enum PayCodes + { + Regular = 0, + Overtime = 1, + DoubleTime = 2, + Standby = 3, + Travel = 4, + PaidLeave = 5, + Other = 6 + } + + public enum PayComponentCategories + { + Specialty = 0, + Incentive = 1, + Longevity = 2, + Education = 3, + Ems = 4, + HazMat = 5, + Usar = 6, + Differential = 7, + Other = 8 + } + + public enum PayComponentBases + { + PerHour = 0, + PercentOfBase = 1, + PerShift = 2, + PerPayPeriod = 3, + FixedAnnual = 4 + } + + public enum CostComponentCategories + { + EmployerPayrollTax = 0, + WorkersCompensation = 1, + RetirementPension = 2, + HealthBenefits = 3, + OtherBenefits = 4, + FixedLaborCost = 5, + AllocatedOverhead = 6, + Other = 7 + } + + public enum CostComponentBases + { + PercentOfEligiblePay = 0, + PerHour = 1, + PerShift = 2, + PerDay = 3, + FixedAnnual = 4 + } + + /// Which profile a compensation / cost row belongs to (E3 precedence: employee → role default → department default). + public enum CompensationScopes + { + Employee = 0, + RoleDefault = 1, + DepartmentDefault = 2 + } + + public enum WorkHoursTypes + { + Regular = 0, + Overtime = 1, + DoubleTime = 2, + Standby = 3, + Travel = 4, + PaidLeave = 5, + Other = 6 + } + + public enum ExemptProxyMethods + { + None = 0, + ActualPlusPaidLeave = 1, + DaysTimesAverageHours = 2 + } + + public enum EarningsSources + { + W2Box5 = 0, + W2Box1Fallback = 1, + ClientAllocated = 2 + } + + #endregion + + #region Costing (M0222) + + public enum ResourceSubjectTypes + { + Unit = 0, + InventoryAsset = 1, + External = 2 + } + + public enum DepreciationMethods + { + StraightLine = 0 + } + + public enum AllocationBases + { + Mile = 0, + Kilometer = 1, + EngineHour = 2, + OperatingHour = 3, + Day = 4 + } + + public enum ResourceCostCategories + { + FuelEnergy = 0, + Maintenance = 1, + TiresWear = 2, + Depreciation = 3, + InsuranceLicensing = 4, + LeaseRental = 5, + Storage = 6, + FixedOverhead = 7, + Consumables = 8, + Other = 9 + } + + public enum ResourceCostBases + { + PerMile = 0, + PerKilometer = 1, + PerEngineHour = 2, + PerOperatingHour = 3, + PerIdleHour = 4, + PerDay = 5, + PerDeployment = 6, + FixedAnnual = 7 + } + + public enum ResourceCostSources + { + Manual = 0, + Imported = 1, + AcquisitionCalculated = 2, + WorkOrderRollingActual = 3 + } + + public enum UsagePhases + { + Mobilization = 0, + Standby = 1, + Incident = 2, + Return = 3 + } + + public enum UsageSources + { + Manual = 0, + Dtr = 1, + Gps = 2, + HardwareTracker = 3, + Import = 4 + } + + public enum FieldCostContextTypes + { + Bid = 0, + Call = 1, + Deployment = 2 + } + + public enum FieldCostRunTypes + { + Estimate = 0, + Actual = 1 + } + + public enum FieldCostRunStatuses + { + Draft = 0, + NeedsReview = 1, + Frozen = 2, + Superseded = 3 + } + + public enum RevenueSources + { + None = 0, + BidEstimate = 1, + CustomerInvoice = 2, + CalOesMarsExpected = 3, + CalOesMarsApproved = 4, + CalOesMarsPaid = 5 + } + + public enum FieldCostCategories + { + Personnel = 0, + Resource = 1, + Consumable = 2, + Expense = 3, + Overhead = 4 + } + + #endregion + + #region Pay data reporting (M0223) + + public enum PayDataReportTypes + { + PayrollEmployee = 0, + LaborContractorEmployee = 1 + } + + public enum PayDataReportRunStatuses + { + Draft = 0, + Validated = 1, + FrozenForExport = 2, + Exported = 3, + CertifiedExternally = 4, + Correction = 5, + Void = 6 + } + + public enum DemographicCollectionSources + { + SelfIdentified = 0, + EmploymentRecord = 1, + ReliableRecord = 2, + ObserverPerception = 3 + } + + public enum PayDataExportFormats + { + Csv = 0, + Xlsx = 1 + } + + #endregion +} diff --git a/Core/Resgrid.Model/Workforce/WorkforcePermissionCatalog.cs b/Core/Resgrid.Model/Workforce/WorkforcePermissionCatalog.cs new file mode 100644 index 00000000..1767040b --- /dev/null +++ b/Core/Resgrid.Model/Workforce/WorkforcePermissionCatalog.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Department-configurable workforce permissions (Workforce & Business Operations plan, Phase E; registry + /// 74-78). All fall back to department administrators. A member always sees and answers their own demographic + /// response and files their own resource usage without any of them; every protected value additionally needs a + /// current Protected Data Grant to render. + /// + public static class WorkforcePermissionCatalog + { + public static readonly IReadOnlyList All = new[] + { + new RecordPermissionDescriptor(PermissionTypes.ViewInternalCosts, PermissionActions.DepartmentAdminsOnly, false, "ViewInternalCostsNote", false), + new RecordPermissionDescriptor(PermissionTypes.ManageWorkforceCompensation, PermissionActions.DepartmentAdminsOnly, false, "ManageWorkforceCompensationNote", false), + new RecordPermissionDescriptor(PermissionTypes.ViewWorkforceCompensation, PermissionActions.DepartmentAdminsOnly, false, "ViewWorkforceCompensationNote", false), + new RecordPermissionDescriptor(PermissionTypes.ManagePayDataReporting, PermissionActions.DepartmentAdminsOnly, false, "ManagePayDataReportingNote", false), + new RecordPermissionDescriptor(PermissionTypes.ExportPayDataReporting, PermissionActions.DepartmentAdminsOnly, false, "ExportPayDataReportingNote", false) + }; + } +} diff --git a/Core/Resgrid.Model/Workforce/WorkforceProtectedFields.cs b/Core/Resgrid.Model/Workforce/WorkforceProtectedFields.cs new file mode 100644 index 00000000..fa5a4c76 --- /dev/null +++ b/Core/Resgrid.Model/Workforce/WorkforceProtectedFields.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Workforce +{ + /// + /// ADP catalog 28 (Workforce & Business Operations plan, Phase E; registered with M0220–M0224): every value that + /// identifies or prices a person — employer / affiliate / contractor identifiers and addresses, external worker keys, + /// compensation amounts, pay and cost components, approved payroll cost, annual earnings, demographic responses, + /// report snapshots, aggregate rows, remarks and export files. Personnel family (ViewProtectedPersonnelData). + /// Hours, dates, codes, counts and run totals are routing metadata. Accessor maps drive the generic RMS seams; + /// costing and reporting workloads decrypt through the workforce-costing / pay-data-reporting purposes. + /// + public static class WorkforceProtectedFields + { + public const int CatalogVersion = 28; + public const string CostingWorkloadPurpose = "workforce-costing"; + public const string ReportingWorkloadPurpose = "pay-data-reporting"; + + private static IReadOnlyDictionary Get, Action Set)> Map(params (string Field, Func Get, Action Set)[] entries) + { + var map = new Dictionary, Action)>(StringComparer.OrdinalIgnoreCase); + foreach (var (field, get, set) in entries) map[field] = (get, set); + return map; + } + + public static readonly IReadOnlyDictionary Get, Action Set)> Employer = Map( + ("workforceemployerprofiles.fein", e => e.Fein, (e, v) => e.Fein = v), + ("workforceemployerprofiles.sein", e => e.Sein, (e, v) => e.Sein = v), + ("workforceemployerprofiles.sosnumber", e => e.SosNumber, (e, v) => e.SosNumber = v), + ("workforceemployerprofiles.eddaddress", e => e.EddAddress, (e, v) => e.EddAddress = v), + ("workforceemployerprofiles.headquartersaddress", e => e.HeadquartersAddress, (e, v) => e.HeadquartersAddress = v), + ("workforceemployerprofiles.filingcontactname", e => e.FilingContactName, (e, v) => e.FilingContactName = v), + ("workforceemployerprofiles.filingcontactemail", e => e.FilingContactEmail, (e, v) => e.FilingContactEmail = v), + ("workforceemployerprofiles.filingcontactphone", e => e.FilingContactPhone, (e, v) => e.FilingContactPhone = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Affiliate = Map( + ("workforceaffiliatedentities.fein", e => e.Fein, (e, v) => e.Fein = v), + ("workforceaffiliatedentities.sein", e => e.Sein, (e, v) => e.Sein = v), + ("workforceaffiliatedentities.sosnumber", e => e.SosNumber, (e, v) => e.SosNumber = v), + ("workforceaffiliatedentities.headquartersaddress", e => e.HeadquartersAddress, (e, v) => e.HeadquartersAddress = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Establishment = Map( + ("workforceestablishments.physicaladdress", e => e.PhysicalAddress, (e, v) => e.PhysicalAddress = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Contractor = Map( + ("workforcelaborcontractors.fein", e => e.Fein, (e, v) => e.Fein = v), + ("workforcelaborcontractors.contactdetails", e => e.ContactDetails, (e, v) => e.ContactDetails = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Worker = Map( + ("workforceworkers.externalworkerkey", e => e.ExternalWorkerKey, (e, v) => e.ExternalWorkerKey = v), + ("workforceworkers.displaylabel", e => e.DisplayLabel, (e, v) => e.DisplayLabel = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Compensation = Map( + ("employeecompensationprofiles.baseamount", e => e.BaseAmount, (e, v) => e.BaseAmount = v), + ("employeecompensationprofiles.regularhourlyequivalent", e => e.RegularHourlyEquivalent, (e, v) => e.RegularHourlyEquivalent = v), + ("employeecompensationprofiles.ratemultipliersjson", e => e.RateMultipliersJson, (e, v) => e.RateMultipliersJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> PayComponent = Map( + ("employeepaycomponents.amount", e => e.Amount, (e, v) => e.Amount = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> CostComponent = Map( + ("employeecostcomponents.rateamount", e => e.RateAmount, (e, v) => e.RateAmount = v), + ("employeecostcomponents.cap", e => e.Cap, (e, v) => e.Cap = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> WorkEntry = Map( + ("workforceworkentries.approvedpayrollcost", e => e.ApprovedPayrollCost, (e, v) => e.ApprovedPayrollCost = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> AnnualFact = Map( + ("workforceannualpayfacts.w2box5", e => e.W2Box5, (e, v) => e.W2Box5 = v), + ("workforceannualpayfacts.w2box1", e => e.W2Box1, (e, v) => e.W2Box1 = v), + ("workforceannualpayfacts.earningsused", e => e.EarningsUsed, (e, v) => e.EarningsUsed = v), + ("workforceannualpayfacts.clientallocatedearnings", e => e.ClientAllocatedEarnings, (e, v) => e.ClientAllocatedEarnings = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> CostLine = Map( + ("fieldcostlines.protecteddetailjson", e => e.ProtectedDetailJson, (e, v) => e.ProtectedDetailJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Demographic = Map( + ("paydatareportingdemographics.hispaniclatino", e => e.HispanicLatino, (e, v) => e.HispanicLatino = v), + ("paydatareportingdemographics.raceethnicitycodes", e => e.RaceEthnicityCodes, (e, v) => e.RaceEthnicityCodes = v), + ("paydatareportingdemographics.sexcode", e => e.SexCode, (e, v) => e.SexCode = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> ReportRun = Map( + ("paydatareportruns.employersnapshotjson", e => e.EmployerSnapshotJson, (e, v) => e.EmployerSnapshotJson = v), + ("paydatareportruns.runremarks", e => e.RunRemarks, (e, v) => e.RunRemarks = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> EmployeeSnapshot = Map( + ("paydatareportemployeesnapshots.demographiccode", e => e.DemographicCode, (e, v) => e.DemographicCode = v), + ("paydatareportemployeesnapshots.annualearnings", e => e.AnnualEarnings, (e, v) => e.AnnualEarnings = v), + ("paydatareportemployeesnapshots.hourlyrate", e => e.HourlyRate, (e, v) => e.HourlyRate = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> ReportRow = Map( + ("paydatareportrows.demographiccode", e => e.DemographicCode, (e, v) => e.DemographicCode = v), + ("paydatareportrows.meanhourlyrate", e => e.MeanHourlyRate, (e, v) => e.MeanHourlyRate = v), + ("paydatareportrows.medianhourlyrate", e => e.MedianHourlyRate, (e, v) => e.MedianHourlyRate = v), + ("paydatareportrows.rowremarks", e => e.RowRemarks, (e, v) => e.RowRemarks = v)); + + public const string ExportDataFieldId = "paydataexportartifacts.data"; + + /// (table, column, binary) for the catalog registration and the table bindings. + public static IEnumerable<(string Table, string Column, bool Binary)> All() + { + yield return ("WorkforceEmployerProfiles", "Fein", false); + yield return ("WorkforceEmployerProfiles", "Sein", false); + yield return ("WorkforceEmployerProfiles", "SosNumber", false); + yield return ("WorkforceEmployerProfiles", "EddAddress", false); + yield return ("WorkforceEmployerProfiles", "HeadquartersAddress", false); + yield return ("WorkforceEmployerProfiles", "FilingContactName", false); + yield return ("WorkforceEmployerProfiles", "FilingContactEmail", false); + yield return ("WorkforceEmployerProfiles", "FilingContactPhone", false); + yield return ("WorkforceAffiliatedEntities", "Fein", false); + yield return ("WorkforceAffiliatedEntities", "Sein", false); + yield return ("WorkforceAffiliatedEntities", "SosNumber", false); + yield return ("WorkforceAffiliatedEntities", "HeadquartersAddress", false); + yield return ("WorkforceEstablishments", "PhysicalAddress", false); + yield return ("WorkforceLaborContractors", "Fein", false); + yield return ("WorkforceLaborContractors", "ContactDetails", false); + yield return ("WorkforceWorkers", "ExternalWorkerKey", false); + yield return ("WorkforceWorkers", "DisplayLabel", false); + yield return ("EmployeeCompensationProfiles", "BaseAmount", false); + yield return ("EmployeeCompensationProfiles", "RegularHourlyEquivalent", false); + yield return ("EmployeeCompensationProfiles", "RateMultipliersJson", false); + yield return ("EmployeePayComponents", "Amount", false); + yield return ("EmployeeCostComponents", "RateAmount", false); + yield return ("EmployeeCostComponents", "Cap", false); + yield return ("WorkforceWorkEntries", "ApprovedPayrollCost", false); + yield return ("WorkforceAnnualPayFacts", "W2Box5", false); + yield return ("WorkforceAnnualPayFacts", "W2Box1", false); + yield return ("WorkforceAnnualPayFacts", "EarningsUsed", false); + yield return ("WorkforceAnnualPayFacts", "ClientAllocatedEarnings", false); + yield return ("FieldCostLines", "ProtectedDetailJson", false); + yield return ("PayDataReportingDemographics", "HispanicLatino", false); + yield return ("PayDataReportingDemographics", "RaceEthnicityCodes", false); + yield return ("PayDataReportingDemographics", "SexCode", false); + yield return ("PayDataReportRuns", "EmployerSnapshotJson", false); + yield return ("PayDataReportRuns", "RunRemarks", false); + yield return ("PayDataReportEmployeeSnapshots", "DemographicCode", false); + yield return ("PayDataReportEmployeeSnapshots", "AnnualEarnings", false); + yield return ("PayDataReportEmployeeSnapshots", "HourlyRate", false); + yield return ("PayDataReportRows", "DemographicCode", false); + yield return ("PayDataReportRows", "MeanHourlyRate", false); + yield return ("PayDataReportRows", "MedianHourlyRate", false); + yield return ("PayDataReportRows", "RowRemarks", false); + yield return ("PayDataExportArtifacts", "Data", true); + } + + /// Tables bound directly on DepartmentId with the IsProtected marker (every Phase E table carries one). + public static readonly IReadOnlyList<(string Table, string PkColumn)> Tables = new[] + { + ("WorkforceEmployerProfiles", "WorkforceEmployerProfileId"), ("WorkforceAffiliatedEntities", "WorkforceAffiliatedEntityId"), ("WorkforceEstablishments", "WorkforceEstablishmentId"), + ("WorkforceLaborContractors", "WorkforceLaborContractorId"), ("WorkforceWorkers", "WorkforceWorkerId"), + ("EmployeeCompensationProfiles", "EmployeeCompensationProfileId"), ("EmployeePayComponents", "EmployeePayComponentId"), ("EmployeeCostComponents", "EmployeeCostComponentId"), + ("WorkforceWorkEntries", "WorkforceWorkEntryId"), ("WorkforceAnnualPayFacts", "WorkforceAnnualPayFactId"), ("FieldCostLines", "FieldCostLineId"), + ("PayDataReportingDemographics", "PayDataReportingDemographicId"), ("PayDataReportRuns", "PayDataReportRunId"), ("PayDataReportEmployeeSnapshots", "PayDataReportEmployeeSnapshotId"), + ("PayDataReportRows", "PayDataReportRowId"), ("PayDataExportArtifacts", "PayDataExportArtifactId") + }; + } +} diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index d09bb8d6..486c8332 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -121,6 +121,86 @@ AdpColumnSpec Companion(string table, string column, bool boolean = false) => { Text("Deployments", "Notes") }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceEmployerProfiles. + AdpTableBinding.Direct("WorkforceEmployerProfiles", "WorkforceEmployerProfileId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceEmployerProfiles", "Fein"), Text("WorkforceEmployerProfiles", "Sein"), Text("WorkforceEmployerProfiles", "SosNumber"), Text("WorkforceEmployerProfiles", "EddAddress"), Text("WorkforceEmployerProfiles", "HeadquartersAddress"), Text("WorkforceEmployerProfiles", "FilingContactName"), Text("WorkforceEmployerProfiles", "FilingContactEmail"), Text("WorkforceEmployerProfiles", "FilingContactPhone") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceAffiliatedEntities. + AdpTableBinding.Direct("WorkforceAffiliatedEntities", "WorkforceAffiliatedEntityId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceAffiliatedEntities", "Fein"), Text("WorkforceAffiliatedEntities", "Sein"), Text("WorkforceAffiliatedEntities", "SosNumber"), Text("WorkforceAffiliatedEntities", "HeadquartersAddress") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceEstablishments. + AdpTableBinding.Direct("WorkforceEstablishments", "WorkforceEstablishmentId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceEstablishments", "PhysicalAddress") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceLaborContractors. + AdpTableBinding.Direct("WorkforceLaborContractors", "WorkforceLaborContractorId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceLaborContractors", "Fein"), Text("WorkforceLaborContractors", "ContactDetails") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceWorkers. + AdpTableBinding.Direct("WorkforceWorkers", "WorkforceWorkerId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceWorkers", "ExternalWorkerKey"), Text("WorkforceWorkers", "DisplayLabel") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): EmployeeCompensationProfiles. + AdpTableBinding.Direct("EmployeeCompensationProfiles", "EmployeeCompensationProfileId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("EmployeeCompensationProfiles", "BaseAmount"), Text("EmployeeCompensationProfiles", "RegularHourlyEquivalent"), Text("EmployeeCompensationProfiles", "RateMultipliersJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): EmployeePayComponents. + AdpTableBinding.Direct("EmployeePayComponents", "EmployeePayComponentId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("EmployeePayComponents", "Amount") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): EmployeeCostComponents. + AdpTableBinding.Direct("EmployeeCostComponents", "EmployeeCostComponentId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("EmployeeCostComponents", "RateAmount"), Text("EmployeeCostComponents", "Cap") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceWorkEntries. + AdpTableBinding.Direct("WorkforceWorkEntries", "WorkforceWorkEntryId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceWorkEntries", "ApprovedPayrollCost") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): WorkforceAnnualPayFacts. + AdpTableBinding.Direct("WorkforceAnnualPayFacts", "WorkforceAnnualPayFactId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("WorkforceAnnualPayFacts", "W2Box5"), Text("WorkforceAnnualPayFacts", "W2Box1"), Text("WorkforceAnnualPayFacts", "EarningsUsed"), Text("WorkforceAnnualPayFacts", "ClientAllocatedEarnings") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): FieldCostLines. + AdpTableBinding.Direct("FieldCostLines", "FieldCostLineId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("FieldCostLines", "ProtectedDetailJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): PayDataReportingDemographics. + AdpTableBinding.Direct("PayDataReportingDemographics", "PayDataReportingDemographicId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("PayDataReportingDemographics", "HispanicLatino"), Text("PayDataReportingDemographics", "RaceEthnicityCodes"), Text("PayDataReportingDemographics", "SexCode") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): PayDataReportRuns. + AdpTableBinding.Direct("PayDataReportRuns", "PayDataReportRunId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("PayDataReportRuns", "EmployerSnapshotJson"), Text("PayDataReportRuns", "RunRemarks") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): PayDataReportEmployeeSnapshots. + AdpTableBinding.Direct("PayDataReportEmployeeSnapshots", "PayDataReportEmployeeSnapshotId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("PayDataReportEmployeeSnapshots", "DemographicCode"), Text("PayDataReportEmployeeSnapshots", "AnnualEarnings"), Text("PayDataReportEmployeeSnapshots", "HourlyRate") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): PayDataReportRows. + AdpTableBinding.Direct("PayDataReportRows", "PayDataReportRowId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("PayDataReportRows", "DemographicCode"), Text("PayDataReportRows", "MeanHourlyRate"), Text("PayDataReportRows", "MedianHourlyRate"), Text("PayDataReportRows", "RowRemarks") + }) with { ProtectedMarkerColumn = "IsProtected" }, + // Workforce & Business Operations plan, Phase E (ADP catalog 28): PayDataExportArtifacts. + AdpTableBinding.Direct("PayDataExportArtifacts", "PayDataExportArtifactId", pkIsNumeric: false, "DepartmentId", new[] + { + Binary("PayDataExportArtifacts", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, AdpTableBinding.Direct("Contacts", "ContactId", pkIsNumeric: false, "DepartmentId", new[] { diff --git a/Core/Resgrid.Services/BusinessOperationsAccessService.cs b/Core/Resgrid.Services/BusinessOperationsAccessService.cs index 99f5649f..ce9a9651 100644 --- a/Core/Resgrid.Services/BusinessOperationsAccessService.cs +++ b/Core/Resgrid.Services/BusinessOperationsAccessService.cs @@ -12,20 +12,39 @@ public class BusinessOperationsAccessService : IBusinessOperationsAccessService private readonly IFeatureToggleService _flags; private readonly IDepartmentSettingsService _settings; private readonly ISubscriptionsService _subscriptions; + private readonly IDepartmentDataProtectionService _dataProtection; - public BusinessOperationsAccessService(IFeatureToggleService flags, IDepartmentSettingsService settings, ISubscriptionsService subscriptions) + public BusinessOperationsAccessService(IFeatureToggleService flags, IDepartmentSettingsService settings, ISubscriptionsService subscriptions, IDepartmentDataProtectionService dataProtection = null) { _flags = flags; _settings = settings; _subscriptions = subscriptions; + _dataProtection = dataProtection; } public Task CanUseInvoicingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CustomerInvoicing); public Task CanUseContractorBillingAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.ContractorBilling); public Task CanUseCostRecoveryAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.CalOesMars); - // The Phase E flag key is declared when that phase is authored; until then the capability is off. - public Task CanUseWorkforceAsync(int departmentId) => CanUseAsync(departmentId, "Workforce.InternalCosting"); + public Task CanUseWorkforceAsync(int departmentId) => CanUseAsync(departmentId, FeatureFlagKeys.WorkforceInternalCosting); + + /// Phase E reporting: the capability flag plus an Enabled ADP state — the report's inputs and outputs only exist as protected data. + public async Task CanUsePayDataReportingAsync(int departmentId) + { + if (!await CanUseAsync(departmentId, FeatureFlagKeys.CaliforniaPayDataReporting)) + return false; + try + { + if (_dataProtection == null) + return false; + return await _dataProtection.GetStateAsync(departmentId, bypassCache: true) == DepartmentDataProtectionState.Enabled; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return false; + } + } private async Task CanUseAsync(int departmentId, string capabilityFlag) { diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs new file mode 100644 index 00000000..afbff191 --- /dev/null +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model.CostRecovery.CalOesMars; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; + +namespace Resgrid.Services.CostRecovery +{ + /// + /// Pure CFAA expected-reimbursement arithmetic (Workforce & Business Operations plan, C4; decision 37). Rules, + /// in order: (1) personnel — portal-to-portal pays every committed hour, actual-hours pays the DTR hours; overtime + /// per the agreement's method (after 8 / after 12 hours per day, none, or per-agreement which is flagged); + /// (2) official apparatus and support vehicles — hourly or daily rate line by resource code; (3) POV mileage by + /// the per-mile line; (4) special equipment by FEMA code; (5) rental lines and (6) expenses — eligible only with a + /// receipt (uncertain when pre-approval is missing); (7) administrative — the profile's percentage of the eligible + /// personnel total. A missing rate leaves an Excluded line with the reason so the manager sees the gap instead of + /// a silent zero. No Phase E cost, depreciation, maintenance or overhead enters any line. + /// + public sealed class CalOesMarsReimbursementCalculator : ICalOesMarsReimbursementCalculator + { + public CalOesMarsReimbursementResult Calculate(CalOesMarsReimbursementInput input) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var result = new CalOesMarsReimbursementResult(); + var rates = input.RateLines ?? new List(); + var sort = 0; + + if (input.F42 != null) + { + var agreement = input.Agreement; + if (agreement == null) result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoAgreement, "No MOU/MOA/GBR compensation method covers the dispatch date; personnel are paid on actual hours without overtime until one is recorded.")); + var portalToPortal = agreement?.CompensationMethod == (int)CalOesMarsCompensationMethods.PortalToPortal; + var overtime = agreement == null ? CalOesMarsOvertimeMethods.None : (CalOesMarsOvertimeMethods)agreement.OvertimeMethod; + if (overtime == CalOesMarsOvertimeMethods.PerAgreement) result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.OvertimePerAgreement, "The agreement's overtime method is not one the calculator models; every hour is estimated at the straight rate.")); + + foreach (var person in input.F42.Personnel ?? new List()) + { + var rate = FindSalary(rates, person.ClassificationCode); + if (rate == null) + { + result.Lines.Add(Line(CalOesMarsLineKinds.Personnel, person, 0, "hour", 0, null, CalOesMarsEligibilityStates.Excluded, CalOesMarsExceptionCodes.NoSalaryRate, ref sort, input)); + result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoSalaryRate, $"No Salary Survey / Attachment A line for classification '{person.ClassificationCode ?? "(none)"}' ({person.Name}).")); + continue; + } + var (straight, ot) = Hours(person, portalToPortal && rate.PortalToPortalEligible, overtime, rate.OvertimeEligible); + if (straight == 0 && ot == 0 && !portalToPortal) + result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoActualHours, $"{person.Name}: no daily time report hours; nothing to reimburse under an actual-hours agreement.")); + var straightRate = rate.StraightRate ?? 0m; + var overtimeRate = rate.OvertimeRate ?? (straightRate * 1.5m); + result.Lines.Add(Line(CalOesMarsLineKinds.Personnel, person, straight, "hour", straightRate, rate, CalOesMarsEligibilityStates.Eligible, null, ref sort, input)); + if (ot > 0) result.Lines.Add(Line(CalOesMarsLineKinds.Personnel, person, ot, "overtime hour", overtimeRate, rate, CalOesMarsEligibilityStates.Eligible, "overtime", ref sort, input)); + } + + foreach (var vehicle in input.F42.Vehicles ?? new List()) + { + switch ((vehicle.Kind ?? string.Empty).ToLowerInvariant()) + { + case "apparatus": + AddVehicle(result, vehicle, rates, CalOesMarsRateLineKinds.OfficialApparatus, CalOesMarsLineKinds.Apparatus, CalOesMarsExceptionCodes.NoApparatusRate, ref sort, input); + break; + case "support": + AddVehicle(result, vehicle, rates, CalOesMarsRateLineKinds.OfficialSupportVehicle, CalOesMarsLineKinds.SupportVehicle, CalOesMarsExceptionCodes.NoSupportRate, ref sort, input); + break; + case "pov": + { + var miles = vehicle.Miles ?? (vehicle.StartOdometer.HasValue && vehicle.EndOdometer.HasValue ? Math.Max(0, vehicle.EndOdometer.Value - vehicle.StartOdometer.Value) : (decimal?)null); + var rate = rates.FirstOrDefault(r => r.LineKind == (int)CalOesMarsRateLineKinds.PrivatelyOwnedVehicle && r.Basis == (int)CalOesMarsRateBases.PerMile); + if (rate == null) + { + result.Lines.Add(VehicleLine(CalOesMarsLineKinds.PovMileage, vehicle, miles ?? 0, "mile", 0, null, CalOesMarsEligibilityStates.Excluded, CalOesMarsExceptionCodes.NoPovRate, ref sort, input)); + result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoPovRate, $"No POV mileage line in the effective Rate Letter ({vehicle.Designator}).")); + } + else if (!miles.HasValue) + { + result.Lines.Add(VehicleLine(CalOesMarsLineKinds.PovMileage, vehicle, 0, "mile", rate.StraightRate ?? 0, rate, CalOesMarsEligibilityStates.Uncertain, CalOesMarsExceptionCodes.MileageWithoutOdometer, ref sort, input)); + result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.MileageWithoutOdometer, $"{vehicle.Designator}: no odometer readings or miles recorded.")); + } + else result.Lines.Add(VehicleLine(CalOesMarsLineKinds.PovMileage, vehicle, miles.Value, "mile", rate.StraightRate ?? 0, rate, CalOesMarsEligibilityStates.Eligible, null, ref sort, input)); + break; + } + case "equipment": + { + var rate = rates.FirstOrDefault(r => r.LineKind == (int)CalOesMarsRateLineKinds.SpecialEquipment && (Same(r.FemaCode, vehicle.FemaCode) || Same(r.ResourceCode, vehicle.ResourceCode))); + if (rate == null) + { + result.Lines.Add(VehicleLine(CalOesMarsLineKinds.SpecialEquipment, vehicle, vehicle.CommittedHours, "hour", 0, null, CalOesMarsEligibilityStates.Excluded, CalOesMarsExceptionCodes.NoSpecialEquipmentRate, ref sort, input)); + result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoSpecialEquipmentRate, $"No special-equipment / FEMA line for '{vehicle.FemaCode ?? vehicle.ResourceCode ?? vehicle.Designator}'.")); + } + else + { + var (qty, unit) = rate.Basis == (int)CalOesMarsRateBases.Daily ? (vehicle.CommittedDays, "day") : (vehicle.CommittedHours, "hour"); + result.Lines.Add(VehicleLine(CalOesMarsLineKinds.SpecialEquipment, vehicle, qty, unit, rate.StraightRate ?? 0, rate, CalOesMarsEligibilityStates.Eligible, null, ref sort, input)); + } + break; + } + } + } + } + + if (input.Expenses != null) + { + foreach (var expense in input.Expenses.Lines ?? new List()) + { + var kind = string.Equals(expense.Category, "Rental", StringComparison.OrdinalIgnoreCase) ? CalOesMarsLineKinds.Rental : CalOesMarsLineKinds.Expense; + var state = CalOesMarsEligibilityStates.Eligible; + string reason = null; + if (!expense.ReceiptAttachmentId.HasValue) { state = CalOesMarsEligibilityStates.Excluded; reason = CalOesMarsExceptionCodes.ExpenseWithoutReceipt; result.Exceptions.Add(Exception(reason, $"{expense.Date:yyyy-MM-dd} {expense.Category} {expense.Amount:N2}: no receipt attached.")); } + else if (!expense.PreApproved && kind == CalOesMarsLineKinds.Expense && !string.Equals(expense.Category, "Meal", StringComparison.OrdinalIgnoreCase)) { state = CalOesMarsEligibilityStates.Uncertain; reason = CalOesMarsExceptionCodes.ExpenseNotPreApproved; result.Exceptions.Add(Exception(reason, $"{expense.Date:yyyy-MM-dd} {expense.Category} {expense.Amount:N2}: not pre-approved; MARS may require ICS-213 evidence.")); } + result.Lines.Add(new CalOesMarsReimbursementLine + { + LineKind = (int)kind, LineDate = expense.Date, SourceExpenseId = expense.DeploymentExpenseId, Quantity = 1, Unit = "each", Rate = expense.Amount, + ExpectedAmount = Round(expense.Amount), EligibilityState = (int)state, EligibilityReason = reason, SortOrder = sort++, SourceVersions = input.RateProfileVersion, SubjectName = expense.Description + }); + } + } + + var personnelEligible = result.Lines.Where(l => l.LineKind == (int)CalOesMarsLineKinds.Personnel && l.EligibilityState == (int)CalOesMarsEligibilityStates.Eligible).Sum(l => l.ExpectedAmount); + if (personnelEligible > 0) + { + if (input.AdministrativeRatePercent.HasValue && input.AdministrativeRatePercent.Value > 0) + { + result.Lines.Add(new CalOesMarsReimbursementLine + { + LineKind = (int)CalOesMarsLineKinds.Administrative, Quantity = personnelEligible, Unit = "personnel $", Rate = input.AdministrativeRatePercent.Value / 100m, + ExpectedAmount = Round(personnelEligible * input.AdministrativeRatePercent.Value / 100m), EligibilityState = (int)CalOesMarsEligibilityStates.Eligible, SortOrder = sort++, SourceVersions = input.RateProfileVersion, SubjectName = "Administrative rate" + }); + } + else result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoAdministrativeRate, "No administrative rate is recorded for the dispatch date; no administrative line is estimated.")); + } + + return result; + } + + /// Straight and overtime hours for one person under the agreement. + public static (decimal Straight, decimal Overtime) Hours(CalOesMarsF42Person person, bool portalToPortal, CalOesMarsOvertimeMethods overtime, bool overtimeEligible) + { + var threshold = overtime switch + { + CalOesMarsOvertimeMethods.AfterEightHoursPerDay => 8m, + CalOesMarsOvertimeMethods.AfterTwelveHoursPerDay => 12m, + _ => (decimal?)null + }; + if (portalToPortal) + { + // Every committed hour is paid; overtime accrues per calendar day of the commitment when the agreement says so. + var total = person.CommittedHours > 0 ? person.CommittedHours : (person.CommittedOn.HasValue && person.ReleasedOn.HasValue ? (decimal)(person.ReleasedOn.Value - person.CommittedOn.Value).TotalHours : 0m); + total = Math.Max(0, Math.Round(total, 2)); + if (!threshold.HasValue || !overtimeEligible) return (total, 0); + var days = Math.Max(1, (int)Math.Ceiling(total / 24m)); + var straight = Math.Min(total, days * threshold.Value); + return (straight, Math.Max(0, total - straight)); + } + decimal s = 0, o = 0; + foreach (var day in (person.ActualHours ?? new List()).GroupBy(h => h.Date.Date)) + { + var hours = Math.Max(0, day.Sum(h => h.Hours)); + if (!threshold.HasValue || !overtimeEligible) { s += hours; continue; } + s += Math.Min(hours, threshold.Value); + o += Math.Max(0, hours - threshold.Value); + } + return (Math.Round(s, 2), Math.Round(o, 2)); + } + + private static void AddVehicle(CalOesMarsReimbursementResult result, CalOesMarsF42Vehicle vehicle, List rates, CalOesMarsRateLineKinds rateKind, CalOesMarsLineKinds lineKind, string missingCode, ref int sort, CalOesMarsReimbursementInput input) + { + var rate = rates.FirstOrDefault(r => r.LineKind == (int)rateKind && Same(r.ResourceCode, vehicle.ResourceCode)) ?? rates.FirstOrDefault(r => r.LineKind == (int)rateKind && string.IsNullOrWhiteSpace(r.ResourceCode)); + if (rate == null) + { + result.Lines.Add(VehicleLine(lineKind, vehicle, vehicle.CommittedHours, "hour", 0, null, CalOesMarsEligibilityStates.Excluded, missingCode, ref sort, input)); + result.Exceptions.Add(Exception(missingCode, $"No {rateKind} rate line for resource code '{vehicle.ResourceCode ?? "(none)"}' ({vehicle.Designator}).")); + return; + } + var (qty, unit) = rate.Basis == (int)CalOesMarsRateBases.Daily ? (vehicle.CommittedDays, "day") : (vehicle.CommittedHours, "hour"); + result.Lines.Add(VehicleLine(lineKind, vehicle, qty, unit, rate.StraightRate ?? 0, rate, CalOesMarsEligibilityStates.Eligible, null, ref sort, input)); + } + + private static CalOesMarsRateLine FindSalary(List rates, string classification) => + rates.FirstOrDefault(r => (r.LineKind == (int)CalOesMarsRateLineKinds.SalarySurvey || r.LineKind == (int)CalOesMarsRateLineKinds.AttachmentANonSuppression) && Same(r.ClassificationCode, classification)); + + private static bool Same(string a, string b) => !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + + private static CalOesMarsReimbursementLine Line(CalOesMarsLineKinds kind, CalOesMarsF42Person person, decimal quantity, string unit, decimal rate, CalOesMarsRateLine rateLine, CalOesMarsEligibilityStates state, string reason, ref int sort, CalOesMarsReimbursementInput input) => new CalOesMarsReimbursementLine + { + LineKind = (int)kind, SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, SubjectId = person.DeploymentPersonnelId ?? person.UserId, SubjectName = person.Name, Quantity = quantity, Unit = unit, Rate = rate, + RateLineId = rateLine?.CalOesMarsRateLineId, RateLineVersion = rateLine?.RowVersion, ExpectedAmount = Round(quantity * rate), EligibilityState = (int)state, EligibilityReason = reason, SortOrder = sort++, + SourceVersions = input.RateProfileVersion, LineDate = person.CommittedOn + }; + + private static CalOesMarsReimbursementLine VehicleLine(CalOesMarsLineKinds kind, CalOesMarsF42Vehicle vehicle, decimal quantity, string unit, decimal rate, CalOesMarsRateLine rateLine, CalOesMarsEligibilityStates state, string reason, ref int sort, CalOesMarsReimbursementInput input) => new CalOesMarsReimbursementLine + { + LineKind = (int)kind, SubjectType = string.IsNullOrWhiteSpace(vehicle.DeploymentEquipmentId) ? (int)DeploymentTimeSubjectTypes.Unit : (int)DeploymentTimeSubjectTypes.Equipment, SubjectId = vehicle.DeploymentUnitId ?? vehicle.DeploymentEquipmentId ?? vehicle.Designator, + SubjectName = vehicle.Designator, Quantity = quantity, Unit = unit, Rate = rate, RateLineId = rateLine?.CalOesMarsRateLineId, RateLineVersion = rateLine?.RowVersion, ExpectedAmount = Round(quantity * rate), + EligibilityState = (int)state, EligibilityReason = reason, SortOrder = sort++, SourceVersions = input.RateProfileVersion + }; + + private static CalOesMarsValidationIssue Exception(string code, string detail) => new CalOesMarsValidationIssue { Code = code, Detail = detail }; + + public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + } + + /// P0 gateway: the reviewed portal address only. Stores no credential; performs zero external writes. + public sealed class ManualCalOesMarsGateway : ICalOesMarsExternalGateway + { + public string Name => "manual"; + public bool SupportsExternalWrites => false; + public string GetPortalUrl(CalOesMarsWorkItem workItem) => Config.CostRecoveryConfig.CalOesMarsPortalUrl; + } +} diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs new file mode 100644 index 00000000..d97897e0 --- /dev/null +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs @@ -0,0 +1,937 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +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.CostRecovery.CalOesMars; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services.CostRecovery +{ + /// + /// Work items: F-42 / expense-claim projection from the deployment facts, checklist validation, expected + /// reimbursement, the no-store handoff, the evidence packet, and the observation / reconciliation state machine. + /// + public partial class CalOesMarsService + { + private static readonly int[] F42AttachmentTypes = { (int)DeploymentAttachmentTypes.SignedF42, (int)DeploymentAttachmentTypes.PaperF42, (int)DeploymentAttachmentTypes.CrewRotationApproval, (int)DeploymentAttachmentTypes.LossDamage, (int)DeploymentAttachmentTypes.ExternalOrder, (int)DeploymentAttachmentTypes.SignedServiceRequest }; + + #region Queue and reads + + public async Task> GetActionQueueAsync(int departmentId, string userId, bool managerScope) + { + var items = (await _workItems.GetActionQueueAsync(departmentId))?.ToList() ?? new List(); + var deploymentIds = items.Where(i => !string.IsNullOrWhiteSpace(i.DeploymentId)).Select(i => i.DeploymentId).Distinct().ToList(); + var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var id in deploymentIds) + { + var deployment = await _deploymentService.GetDeploymentByIdAsync(id, departmentId); + if (deployment != null) deployments[id] = deployment; + } + var result = new List(); + var now = DateTime.UtcNow; + foreach (var item in items) + { + deployments.TryGetValue(item.DeploymentId ?? string.Empty, out var deployment); + var mine = deployment != null && !string.IsNullOrWhiteSpace(userId) && deployment.Personnel.Any(p => p.IsActive && string.Equals(p.UserId, userId, StringComparison.OrdinalIgnoreCase)); + // Field users see only their own incident-bound F-42 / expense drafts; managers see the department queue. + if (!managerScope && (!mine || item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice)) continue; + var validation = Deserialize(item.ValidationSummaryJson); + var snapshot = item.RecordType == (int)CalOesMarsRecordTypes.F42 ? Deserialize(item.SnapshotJson) : null; + item.DeploymentName = deployment?.Name; + result.Add(new CalOesMarsQueueItem + { + WorkItem = item, DeploymentName = deployment?.Name, IncidentNumber = snapshot?.IncidentNumber ?? deployment?.IncidentNumber, RequestNumber = snapshot?.RequestNumber ?? deployment?.RequestNumber, + ErrorCount = validation?.Errors.Count ?? 0, WarningCount = validation?.Warnings.Count ?? 0, AgeDays = (int)Math.Floor((now - item.AddedOn).TotalDays), IsMine = mine + }); + } + return result.OrderBy(r => r.WorkItem.LocalState).ThenByDescending(r => r.AgeDays).ToList(); + } + + public async Task> GetWorkItemsForDeploymentAsync(string deploymentId, int departmentId) + { + if (string.IsNullOrWhiteSpace(deploymentId)) return new List(); + return (await _workItems.GetByDeploymentAsync(deploymentId, departmentId))?.ToList() ?? new List(); + } + + public async Task GetWorkItemAsync(string workItemId, int departmentId) + { + if (string.IsNullOrWhiteSpace(workItemId)) return null; + var item = await _workItems.GetByIdForDepartmentAsync(workItemId, departmentId); + if (item == null || item.IsDeleted) return null; + item.Lines = (await _lines.GetByWorkItemAsync(workItemId))?.ToList() ?? new List(); + if (!string.IsNullOrWhiteSpace(item.DeploymentId)) item.DeploymentName = (await _deploymentService.GetDeploymentByIdAsync(item.DeploymentId, departmentId))?.Name; + return item; + } + + public async Task IsRosteredForWorkItemAsync(string workItemId, int departmentId, string userId) + { + var item = await _workItems.GetByIdForDepartmentAsync(workItemId, departmentId); + if (item == null || item.IsDeleted || string.IsNullOrWhiteSpace(item.DeploymentId) || string.IsNullOrWhiteSpace(userId)) return false; + if (item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) return false; + return await _deploymentService.IsRosteredAsync(item.DeploymentId, departmentId, userId); + } + + #endregion + + #region F-42 and expense projection + + public async Task BuildF42DraftAsync(string deploymentId, int departmentId, string rmsExternalOrderFillId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deploymentService.GetDeploymentByIdAsync(deploymentId, departmentId) ?? throw new InvalidOperationException("calmars_deployment_not_found"); + var context = await _deploymentService.GetExternalContextAsync(deploymentId, departmentId, userId); + RmsExternalOrderFill fill = null; + if (!string.IsNullOrWhiteSpace(rmsExternalOrderFillId)) + { + fill = context?.Fills.FirstOrDefault(f => string.Equals(f.RmsExternalOrderFillId, rmsExternalOrderFillId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("calmars_fill_not_found"); + } + else if (context != null && context.Fills.Count > 1) throw new InvalidOperationException("calmars_fill_required"); + else fill = context?.Fills.FirstOrDefault(); + + var agency = await GetAgencyProfileAsync(departmentId); + var dispatchOn = fill?.MobilizedOn ?? fill?.FilledOn ?? deployment.StartOn ?? deployment.AddedOn; + var authority = CalOesMarsAuthorityProfile.ForDispatch(dispatchOn) ?? CalOesMarsAuthorityProfile.Current; + var existingItems = (await _workItems.GetByDeploymentAsync(deploymentId, departmentId))?.Where(w => w.RecordType == (int)CalOesMarsRecordTypes.F42 && w.LocalState != (int)CalOesMarsLocalStates.Closed && string.Equals(w.RmsExternalOrderFillId ?? string.Empty, fill?.RmsExternalOrderFillId ?? string.Empty, StringComparison.OrdinalIgnoreCase)).ToList() ?? new List(); + var current = existingItems.OrderByDescending(w => w.AddedOn).FirstOrDefault(); + var previous = current == null ? null : Deserialize(current.SnapshotJson); + + var snapshot = new CalOesMarsF42Snapshot + { + AuthorityProfileCode = authority.Code, + MacsDesignator = agency?.MacsDesignator, + AgencyName = agency?.AgencyName, + IncidentName = context?.Order?.IncidentName ?? deployment.Name, + IncidentNumber = context?.Order?.IncidentNumber ?? deployment.IncidentNumber, + OrderNumber = context?.Order?.OrderNumber ?? deployment.ResourceOrderNumber, + RequestNumber = fill?.RequestNumber ?? deployment.RequestNumber, + ParentRequestNumber = fill?.ParentRequestNumber, + ResourceKind = fill?.ResourceKind, + ResourceType = fill?.ResourceType, + StrikeTeamOrTaskForce = fill?.AgencyUnitId, + ReportingLocation = fill?.HostAgency ?? context?.Order?.RequestingAgency, + PointOfHire = fill?.PointOfHire ?? deployment.PointOfHire, + DispatchedOn = dispatchOn, + CommittedOn = fill?.CheckedInOn ?? fill?.AssignedOn ?? dispatchOn, + ReleasedOn = fill?.ReleasedOn ?? fill?.DemobilizedOn, + ReturnedOn = fill?.ReturnedOn ?? (deployment.Status == (int)DeploymentStatuses.Completed ? deployment.EndOn : null), + OverheadPosition = fill?.Position, + IsRedispatch = current != null && current.IsExternal, + PreviousOrderNumber = previous?.OrderNumber, + PreviousRequestNumber = previous?.RequestNumber, + // Locally authored facts survive a rebuild. + Comments = previous?.Comments, LossDamage = previous?.LossDamage, SupplyNumbers = previous?.SupplyNumbers, + RespondingSignerName = previous?.RespondingSignerName, RespondingSignedOn = previous?.RespondingSignedOn, + IncidentAuthorizerName = previous?.IncidentAuthorizerName, IncidentAuthorizedOn = previous?.IncidentAuthorizedOn, + DocumentationOnly = previous?.DocumentationOnly ?? false, + Rotations = previous?.Rotations ?? new List() + }; + + // Vehicles: rostered units through the F-5 crosswalk; issued equipment as special equipment. + var unitIds = deployment.Units.Where(u => u.IsActive).Select(u => u.UnitId).ToList(); + var crosswalk = (await _resources.GetByUnitIdsAsync(departmentId, unitIds))?.ToList() ?? new List(); + var entries = (await _timeEntries.GetByDeploymentAsync(deploymentId))?.ToList() ?? new List(); + var reports = await _timeTracking.GetTimeReportsAsync(deploymentId, departmentId); + var usable = reports.Where(r => r.Status is (int)DeploymentTimeReportStatuses.Submitted or (int)DeploymentTimeReportStatuses.Approved or (int)DeploymentTimeReportStatuses.Billed).ToDictionary(r => r.DeploymentTimeReportId, StringComparer.OrdinalIgnoreCase); + var committedHours = Hours(snapshot.CommittedOn ?? dispatchOn, snapshot.ReturnedOn ?? snapshot.ReleasedOn); + foreach (var unit in deployment.Units.Where(u => u.IsActive)) + { + var profile = crosswalk.FirstOrDefault(r => r.UnitId == unit.UnitId && r.IsCurrent(dispatchOn)) ?? crosswalk.FirstOrDefault(r => r.UnitId == unit.UnitId); + var unitHours = entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Unit && e.DeploymentUnitId == unit.DeploymentUnitId && usable.ContainsKey(e.DeploymentTimeReportId)).Sum(e => Hours(e)); + var hours = unitHours > 0 ? unitHours : committedHours; + snapshot.Vehicles.Add(new CalOesMarsF42Vehicle + { + Kind = string.Equals(profile?.ResourceKind, "Support", StringComparison.OrdinalIgnoreCase) ? "Support" : string.Equals(profile?.ResourceKind, "POV", StringComparison.OrdinalIgnoreCase) ? "POV" : "Apparatus", + DeploymentUnitId = unit.DeploymentUnitId, ResourceProfileId = profile?.CalOesMarsResourceProfileId, Designator = profile?.UnitDesignator ?? unit.UnitName ?? unit.CallSign, + ResourceCode = profile?.ResourceType, LicensePlate = profile?.LicensePlate, Vin = profile?.Vin, SerialNumber = profile?.SerialNumber, + CommittedHours = Math.Round(hours, 2), CommittedDays = hours <= 0 ? 0 : Math.Ceiling(hours / 24m), + Miles = entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Unit && e.DeploymentUnitId == unit.DeploymentUnitId && e.MileageKm.HasValue).Sum(e => e.MileageKm) is decimal km && km > 0 ? Math.Round(km * 0.621371m, 1) : null + }); + } + foreach (var equipment in deployment.Equipment.Where(e => !e.ReturnedOn.HasValue || e.ReturnedOn > dispatchOn)) + { + var hours = entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Equipment && e.DeploymentEquipmentId == equipment.DeploymentEquipmentId && usable.ContainsKey(e.DeploymentTimeReportId)).Sum(e => Hours(e)); + snapshot.Vehicles.Add(new CalOesMarsF42Vehicle { Kind = "Equipment", DeploymentEquipmentId = equipment.DeploymentEquipmentId, Designator = equipment.FreeTextName ?? equipment.InventoryAssetId ?? equipment.InventoryItemId, CommittedHours = Math.Round(hours > 0 ? hours : committedHours, 2), CommittedDays = Math.Ceiling((hours > 0 ? hours : committedHours) / 24m) }); + } + + // Personnel: the roster (filtered to the fill when the roster is tagged), names from profiles, actual hours from usable DTRs. + var roster = deployment.Personnel.Where(p => p.IsActive).ToList(); + if (fill != null && roster.Any(p => !string.IsNullOrWhiteSpace(p.RmsExternalOrderFillId))) roster = roster.Where(p => string.Equals(p.RmsExternalOrderFillId, fill.RmsExternalOrderFillId, StringComparison.OrdinalIgnoreCase)).ToList(); + var profiles = roster.Count == 0 ? new List() : (await _userProfileService.GetSelectedUserProfilesAsync(roster.Select(p => p.UserId).Distinct().ToList()))?.ToList() ?? new List(); + foreach (var member in roster) + { + var profile = profiles.FirstOrDefault(p => string.Equals(p.UserId, member.UserId, StringComparison.OrdinalIgnoreCase)); + var kept = previous?.Personnel.FirstOrDefault(p => string.Equals(p.DeploymentPersonnelId, member.DeploymentPersonnelId, StringComparison.OrdinalIgnoreCase)); + var person = new CalOesMarsF42Person + { + DeploymentPersonnelId = member.DeploymentPersonnelId, UserId = member.UserId, Name = member.DisplayName ?? profile?.FullName.AsFirstNameLastName ?? member.UserId, + Rank = kept?.Rank ?? member.CertificationCode, ClassificationCode = kept?.ClassificationCode ?? member.CertificationCode, + CommittedOn = kept?.CommittedOn ?? snapshot.CommittedOn, ReleasedOn = kept?.ReleasedOn ?? snapshot.ReturnedOn ?? snapshot.ReleasedOn + }; + person.CommittedHours = Math.Round(Hours(person.CommittedOn, person.ReleasedOn), 2); + person.ActualHours = entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Personnel && e.DeploymentPersonnelId == member.DeploymentPersonnelId && usable.ContainsKey(e.DeploymentTimeReportId)) + .GroupBy(e => new { e.StartTime.Date, e.DeploymentTimeReportId }).Select(g => new CalOesMarsDailyHours { Date = g.Key.Date, TimeReportId = g.Key.DeploymentTimeReportId, Hours = Math.Round(g.Sum(e => Hours(e)), 2) }).OrderBy(h => h.Date).ToList(); + snapshot.Personnel.Add(person); + } + snapshot.SourceTimeReportIds = usable.Keys.ToList(); + snapshot.AttachmentIds = (await _deploymentService.GetAttachmentsAsync(deploymentId, departmentId))?.Where(a => F42AttachmentTypes.Contains(a.AttachmentType)).Select(a => a.DeploymentAttachmentId).ToList() ?? new List(); + + var agreement = await SelectAgreementAsync(departmentId, null, dispatchOn); + var effective = (await _rateProfiles.GetEffectiveAsync(departmentId, dispatchOn))?.ToList() ?? new List(); + var now = DateTime.UtcNow; + CalOesMarsWorkItem target; + if (current != null && !current.IsExternal) target = current; + else + { + target = new CalOesMarsWorkItem { DepartmentId = departmentId, DeploymentId = deploymentId, RecordType = (int)CalOesMarsRecordTypes.F42, LocalState = (int)CalOesMarsLocalStates.Draft, AddedOn = now, AddedByUserId = userId, SupersedesWorkItemId = current?.CalOesMarsWorkItemId }; + // A redispatch closes the first resource/request interval on the earlier item; release is not return. + if (current != null && previous != null && !previous.ReturnedOn.HasValue) { previous.ReturnedOn = previous.ReleasedOn; current.SnapshotJson = JsonConvert.SerializeObject(previous); current.EditedOn = now; current.EditedByUserId = userId; await _workItems.SaveOrUpdateAsync(current, cancellationToken); } + } + var before = ReferenceEquals(target, current) ? Snapshot(current) : null; + target.RmsExternalOrderId = context?.Order?.RmsExternalOrderId ?? deployment.RmsExternalOrderId; + target.RmsExternalOrderFillId = fill?.RmsExternalOrderFillId; + target.AuthorityProfileCode = authority.Code; + target.RateProfileVersion = RateVersion(effective); + target.AgreementSnapshotId = agreement?.CalOesMarsAgreementSnapshotId; + target.SnapshotJson = JsonConvert.SerializeObject(snapshot); + target.SourceChecksum = Sha256(target.SnapshotJson); + if (target.LocalState == (int)CalOesMarsLocalStates.ReadyForPortal) target.LocalState = (int)CalOesMarsLocalStates.NeedsReview; + if (ReferenceEquals(target, current)) { target.RowVersion++; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _workItems.SaveOrUpdateAsync(target, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemPrepared, ipAddress, userAgent, before, saved); + return await GetWorkItemAsync(saved.CalOesMarsWorkItemId, departmentId); + } + + public async Task BuildExpenseClaimDraftAsync(string deploymentId, int departmentId, string f42WorkItemId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deploymentService.GetDeploymentByIdAsync(deploymentId, departmentId) ?? throw new InvalidOperationException("calmars_deployment_not_found"); + CalOesMarsWorkItem f42 = null; + CalOesMarsF42Snapshot f42Snapshot = null; + if (!string.IsNullOrWhiteSpace(f42WorkItemId)) + { + f42 = await GetWorkItemAsync(f42WorkItemId, departmentId); + if (f42 == null || f42.RecordType != (int)CalOesMarsRecordTypes.F42 || !string.Equals(f42.DeploymentId, deploymentId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("calmars_f42_not_found"); + f42Snapshot = Deserialize(f42.SnapshotJson); + } + var expenses = await _timeTracking.GetExpensesAsync(deploymentId, departmentId); + if (f42 != null && !string.IsNullOrWhiteSpace(f42.RmsExternalOrderFillId) && expenses.Any(e => !string.IsNullOrWhiteSpace(e.RmsExternalOrderFillId))) + expenses = expenses.Where(e => string.IsNullOrWhiteSpace(e.RmsExternalOrderFillId) || string.Equals(e.RmsExternalOrderFillId, f42.RmsExternalOrderFillId, StringComparison.OrdinalIgnoreCase)).ToList(); + var existing = (await _workItems.GetByDeploymentAsync(deploymentId, departmentId))?.Where(w => w.RecordType == (int)CalOesMarsRecordTypes.ExpenseClaim && !w.IsExternal && w.LocalState != (int)CalOesMarsLocalStates.Closed) + .Select(w => new { Item = w, Snapshot = Deserialize(w.SnapshotJson) }) + .FirstOrDefault(x => string.Equals(x.Snapshot?.F42WorkItemId ?? string.Empty, f42?.CalOesMarsWorkItemId ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + var dispatchOn = f42Snapshot?.DispatchedOn ?? deployment.StartOn ?? deployment.AddedOn; + var authority = CalOesMarsAuthorityProfile.ForDispatch(dispatchOn) ?? CalOesMarsAuthorityProfile.Current; + var snapshot = new CalOesMarsExpenseClaimSnapshot + { + AuthorityProfileCode = authority.Code, F42WorkItemId = f42?.CalOesMarsWorkItemId, RequestNumber = f42Snapshot?.RequestNumber ?? deployment.RequestNumber, IncidentNumber = f42Snapshot?.IncidentNumber ?? deployment.IncidentNumber, + TravelOnly = f42 == null, SignerName = existing?.Snapshot?.SignerName, SignedOn = existing?.Snapshot?.SignedOn, ApproverName = existing?.Snapshot?.ApproverName, ApprovedOn = existing?.Snapshot?.ApprovedOn + }; + foreach (var expense in expenses.OrderBy(e => e.ExpenseDate)) + { + snapshot.Lines.Add(new CalOesMarsExpenseLine + { + DeploymentExpenseId = expense.DeploymentExpenseId, Date = expense.ExpenseDate, City = expense.City, Category = ExpenseCategory(expense.ExpenseType), Amount = expense.Amount, + Description = expense.Description, ReceiptAttachmentId = expense.ReceiptAttachmentId, PreApproved = expense.PreApproved + }); + } + var now = DateTime.UtcNow; + var target = existing?.Item ?? new CalOesMarsWorkItem { DepartmentId = departmentId, DeploymentId = deploymentId, RecordType = (int)CalOesMarsRecordTypes.ExpenseClaim, LocalState = (int)CalOesMarsLocalStates.Draft, AddedOn = now, AddedByUserId = userId }; + var before = existing == null ? null : Snapshot(existing.Item); + target.RmsExternalOrderId = f42?.RmsExternalOrderId ?? deployment.RmsExternalOrderId; + target.RmsExternalOrderFillId = f42?.RmsExternalOrderFillId; + target.AuthorityProfileCode = authority.Code; + target.RateProfileVersion = f42?.RateProfileVersion ?? RateVersion((await _rateProfiles.GetEffectiveAsync(departmentId, dispatchOn))?.ToList()); + target.AgreementSnapshotId = f42?.AgreementSnapshotId; + target.SnapshotJson = JsonConvert.SerializeObject(snapshot); + target.SourceChecksum = Sha256(target.SnapshotJson); + if (target.LocalState == (int)CalOesMarsLocalStates.ReadyForPortal) target.LocalState = (int)CalOesMarsLocalStates.NeedsReview; + if (existing != null) { target.RowVersion++; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _workItems.SaveOrUpdateAsync(target, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemPrepared, ipAddress, userAgent, before, saved); + return await GetWorkItemAsync(saved.CalOesMarsWorkItemId, departmentId); + } + + public static string ExpenseCategory(int expenseType) => (DeploymentExpenseTypes)expenseType switch + { + DeploymentExpenseTypes.PerDiemMeal => "Meal", + DeploymentExpenseTypes.Accommodation => "Lodging", + DeploymentExpenseTypes.PrivateAccommodation => "Lodging", + _ => "Miscellaneous" + }; + + public async Task SaveF42SnapshotAsync(string workItemId, int departmentId, CalOesMarsF42Snapshot snapshot, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + var item = await RequireLocalAsync(workItemId, departmentId, CalOesMarsRecordTypes.F42); + var current = Deserialize(item.SnapshotJson) ?? new CalOesMarsF42Snapshot(); + var before = Snapshot(item); + // Only the locally authored boxes are editable; the projected facts come from the deployment / order / fill / DTRs. + current.Comments = Trim(snapshot.Comments); + current.LossDamage = Trim(snapshot.LossDamage); + current.SupplyNumbers = Trim(snapshot.SupplyNumbers); + current.StrikeTeamOrTaskForce = Trim(snapshot.StrikeTeamOrTaskForce) ?? current.StrikeTeamOrTaskForce; + current.ReportingLocation = Trim(snapshot.ReportingLocation) ?? current.ReportingLocation; + current.OverheadPosition = Trim(snapshot.OverheadPosition) ?? current.OverheadPosition; + current.ResourceType = Trim(snapshot.ResourceType) ?? current.ResourceType; + current.RespondingSignerName = Trim(snapshot.RespondingSignerName); + current.RespondingSignedOn = string.IsNullOrWhiteSpace(current.RespondingSignerName) ? null : snapshot.RespondingSignedOn ?? current.RespondingSignedOn ?? DateTime.UtcNow; + current.IncidentAuthorizerName = Trim(snapshot.IncidentAuthorizerName); + current.IncidentAuthorizedOn = string.IsNullOrWhiteSpace(current.IncidentAuthorizerName) ? null : snapshot.IncidentAuthorizedOn ?? current.IncidentAuthorizedOn ?? DateTime.UtcNow; + current.DocumentationOnly = snapshot.DocumentationOnly; + current.Rotations = snapshot.Rotations?.Where(r => r != null).ToList() ?? new List(); + if (snapshot.ReturnedOn.HasValue) current.ReturnedOn = snapshot.ReturnedOn; + foreach (var edited in snapshot.Personnel ?? new List()) + { + var person = current.Personnel.FirstOrDefault(p => string.Equals(p.DeploymentPersonnelId, edited.DeploymentPersonnelId, StringComparison.OrdinalIgnoreCase)); + if (person == null) continue; + person.Rank = Trim(edited.Rank) ?? person.Rank; + person.ClassificationCode = Trim(edited.ClassificationCode) ?? person.ClassificationCode; + if (edited.CommittedOn.HasValue) person.CommittedOn = edited.CommittedOn; + if (edited.ReleasedOn.HasValue) person.ReleasedOn = edited.ReleasedOn; + person.CommittedHours = Math.Round(Hours(person.CommittedOn, person.ReleasedOn), 2); + } + foreach (var edited in snapshot.Vehicles ?? new List()) + { + var vehicle = current.Vehicles.FirstOrDefault(v => (!string.IsNullOrWhiteSpace(edited.DeploymentUnitId) && v.DeploymentUnitId == edited.DeploymentUnitId) || (!string.IsNullOrWhiteSpace(edited.DeploymentEquipmentId) && v.DeploymentEquipmentId == edited.DeploymentEquipmentId)); + if (vehicle == null) continue; + vehicle.Kind = Trim(edited.Kind) ?? vehicle.Kind; + vehicle.ResourceCode = Trim(edited.ResourceCode) ?? vehicle.ResourceCode; + vehicle.FemaCode = Trim(edited.FemaCode); + vehicle.StartOdometer = edited.StartOdometer; vehicle.EndOdometer = edited.EndOdometer; + if (edited.Miles.HasValue) vehicle.Miles = edited.Miles; + if (edited.CommittedHours > 0) { vehicle.CommittedHours = edited.CommittedHours; vehicle.CommittedDays = Math.Ceiling(edited.CommittedHours / 24m); } + } + item.SnapshotJson = JsonConvert.SerializeObject(current); + item.SourceChecksum = Sha256(item.SnapshotJson); + if (item.LocalState == (int)CalOesMarsLocalStates.ReadyForPortal) item.LocalState = (int)CalOesMarsLocalStates.NeedsReview; + item.RowVersion++; item.EditedOn = DateTime.UtcNow; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemPrepared, ipAddress, userAgent, before, item); + return await GetWorkItemAsync(workItemId, departmentId); + } + + public async Task SaveExpenseSnapshotAsync(string workItemId, int departmentId, CalOesMarsExpenseClaimSnapshot snapshot, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + var item = await RequireLocalAsync(workItemId, departmentId, CalOesMarsRecordTypes.ExpenseClaim); + var current = Deserialize(item.SnapshotJson) ?? new CalOesMarsExpenseClaimSnapshot(); + var before = Snapshot(item); + current.SignerName = Trim(snapshot.SignerName); + current.SignedOn = string.IsNullOrWhiteSpace(current.SignerName) ? null : snapshot.SignedOn ?? current.SignedOn ?? DateTime.UtcNow; + current.ApproverName = Trim(snapshot.ApproverName); + current.ApprovedOn = string.IsNullOrWhiteSpace(current.ApproverName) ? null : snapshot.ApprovedOn ?? current.ApprovedOn ?? DateTime.UtcNow; + current.TravelOnly = snapshot.TravelOnly; + item.SnapshotJson = JsonConvert.SerializeObject(current); + item.SourceChecksum = Sha256(item.SnapshotJson); + if (item.LocalState == (int)CalOesMarsLocalStates.ReadyForPortal) item.LocalState = (int)CalOesMarsLocalStates.NeedsReview; + item.RowVersion++; item.EditedOn = DateTime.UtcNow; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemPrepared, ipAddress, userAgent, before, item); + return await GetWorkItemAsync(workItemId, departmentId); + } + + #endregion + + #region Validation, calculation, handoff + + public async Task ValidateForPortalAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + var agency = await GetAgencyProfileAsync(departmentId); + var authority = CalOesMarsAuthorityProfile.Get(item.AuthorityProfileCode); + var result = new CalOesMarsValidationResult { WorkItemId = workItemId, AuthorityProfileCode = item.AuthorityProfileCode, ValidatedOn = DateTime.UtcNow }; + if (authority == null || !authority.IsReviewed) Error(result, "agency", CalOesMarsValidationCodes.AuthorityProfileMissing, item.AuthorityProfileCode); + if (agency == null) Error(result, "agency", CalOesMarsValidationCodes.AgencyProfileMissing, null); + else if (string.IsNullOrWhiteSpace(agency.MacsDesignator)) Error(result, "agency", CalOesMarsValidationCodes.MacsMissing, null); + + switch ((CalOesMarsRecordTypes)item.RecordType) + { + case CalOesMarsRecordTypes.F42: + { + var s = Deserialize(item.SnapshotJson) ?? new CalOesMarsF42Snapshot(); + var attachments = (await _deploymentService.GetAttachmentsAsync(item.DeploymentId, departmentId)) ?? new List(); + ValidateF42(result, s, authority ?? CalOesMarsAuthorityProfile.Current, attachments, item); + var crosswalk = (await _resources.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + foreach (var vehicle in s.Vehicles.Where(v => v.Kind != "Equipment" && (string.IsNullOrWhiteSpace(v.ResourceProfileId) || crosswalk.All(c => c.CalOesMarsResourceProfileId != v.ResourceProfileId)))) + Warn(result, "apparatus", CalOesMarsValidationCodes.VehicleNotInInventory, vehicle.Designator); + var effectiveLines = await EffectiveRateLinesAsync(departmentId, s.DispatchedOn ?? item.AddedOn); + if (effectiveLines.Count == 0) Warn(result, "personnel", CalOesMarsValidationCodes.RateProfileMissing, (s.DispatchedOn ?? item.AddedOn).ToString("yyyy-MM-dd")); + foreach (var person in s.Personnel.Where(p => !string.IsNullOrWhiteSpace(p.ClassificationCode) && !(authority ?? CalOesMarsAuthorityProfile.Current).SalaryClassifications.Contains(p.ClassificationCode, StringComparer.OrdinalIgnoreCase) && effectiveLines.All(l => !string.Equals(l.ClassificationCode, p.ClassificationCode, StringComparison.OrdinalIgnoreCase)))) + Warn(result, "personnel", CalOesMarsValidationCodes.ClassificationUnmapped, $"{person.Name}: {person.ClassificationCode}"); + if (string.IsNullOrWhiteSpace(item.AgreementSnapshotId) || await GetAgreementAsync(item.AgreementSnapshotId, departmentId) == null) Error(result, "personnel", CalOesMarsValidationCodes.AgreementMissing, null); + break; + } + case CalOesMarsRecordTypes.ExpenseClaim: + { + var s = Deserialize(item.SnapshotJson) ?? new CalOesMarsExpenseClaimSnapshot(); + if (s.Lines.Count == 0) Error(result, "lines", CalOesMarsValidationCodes.ExpenseNoLines, null); + foreach (var line in s.Lines.Where(l => !l.ReceiptAttachmentId.HasValue)) + { + if (string.Equals(line.Category, "Meal", StringComparison.OrdinalIgnoreCase)) Warn(result, "lines", CalOesMarsValidationCodes.ExpenseReceiptMissing, $"{line.Date:yyyy-MM-dd} {line.Category} {line.Amount:N2}"); + else Error(result, "lines", CalOesMarsValidationCodes.ExpenseReceiptMissing, $"{line.Date:yyyy-MM-dd} {line.Category} {line.Amount:N2}"); + } + if (!s.TravelOnly) + { + var f42 = string.IsNullOrWhiteSpace(s.F42WorkItemId) ? null : await _workItems.GetByIdForDepartmentAsync(s.F42WorkItemId, departmentId); + if (f42 == null || !f42.IsExternal) Error(result, "resource", CalOesMarsValidationCodes.ExpenseF42NotSubmitted, s.RequestNumber); + } + if (string.IsNullOrWhiteSpace(s.SignerName)) Error(result, "signature", CalOesMarsValidationCodes.ExpenseSignatureMissing, null); + if (string.IsNullOrWhiteSpace(s.ApproverName)) Warn(result, "signature", CalOesMarsValidationCodes.ExpenseApprovalMissing, null); + break; + } + default: + break; + } + + var before = Snapshot(item); + item.ValidationSummaryJson = JsonConvert.SerializeObject(result); + if (item.RecordType is (int)CalOesMarsRecordTypes.F42 or (int)CalOesMarsRecordTypes.ExpenseClaim && item.IsLocallyEditable) + item.LocalState = result.IsReadyForPortal ? (int)CalOesMarsLocalStates.ReadyForPortal : (item.LocalState == (int)CalOesMarsLocalStates.ReturnedForAgencyReview ? item.LocalState : (int)CalOesMarsLocalStates.NeedsReview); + item.RowVersion++; item.EditedOn = DateTime.UtcNow; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemValidated, ipAddress, userAgent, before, item); + return result; + } + + /// The F-42 checklist (pure; the authority profile's boxes and prefixes drive it). + public static void ValidateF42(CalOesMarsValidationResult result, CalOesMarsF42Snapshot s, CalOesMarsAuthorityProfile authority, IReadOnlyCollection attachments, CalOesMarsWorkItem item = null) + { + if (string.IsNullOrWhiteSpace(s.IncidentNumber) && string.IsNullOrWhiteSpace(s.IncidentName)) Error(result, "incident", CalOesMarsValidationCodes.IncidentMissing, null); + if (string.IsNullOrWhiteSpace(s.OrderNumber)) Error(result, "order", CalOesMarsValidationCodes.OrderMissing, null); + if (string.IsNullOrWhiteSpace(s.RequestNumber)) Error(result, "request", CalOesMarsValidationCodes.RequestMissing, null); + else if (!IsValidRequestNumber(s.RequestNumber, authority)) Error(result, "request", CalOesMarsValidationCodes.RequestPrefixInvalid, s.RequestNumber); + if (string.IsNullOrWhiteSpace(s.ResourceType) && string.IsNullOrWhiteSpace(s.ResourceKind) && string.IsNullOrWhiteSpace(s.OverheadPosition)) Error(result, "resource", CalOesMarsValidationCodes.ResourceMissing, null); + if (!s.DispatchedOn.HasValue) Error(result, "dispatch", CalOesMarsValidationCodes.DispatchMissing, null); + if (s.DispatchedOn.HasValue && s.ReturnedOn.HasValue && s.ReturnedOn < s.DispatchedOn) Error(result, "return", CalOesMarsValidationCodes.ReturnBeforeDispatch, $"{s.DispatchedOn:g} → {s.ReturnedOn:g}"); + if (!s.ReturnedOn.HasValue && s.ReleasedOn.HasValue) Warn(result, "return", CalOesMarsValidationCodes.ReleaseIsNotReturn, s.ReleasedOn?.ToString("g")); + if (s.Personnel.Count == 0 && string.IsNullOrWhiteSpace(s.OverheadPosition)) Error(result, "personnel", CalOesMarsValidationCodes.PersonnelMissing, null); + var windowStart = s.DispatchedOn; var windowEnd = s.ReturnedOn ?? s.ReleasedOn; + foreach (var person in s.Personnel) + { + if ((windowStart.HasValue && person.CommittedOn.HasValue && person.CommittedOn < windowStart.Value.AddHours(-1)) || (windowEnd.HasValue && person.ReleasedOn.HasValue && person.ReleasedOn > windowEnd.Value.AddHours(1))) + Warn(result, "personnel", CalOesMarsValidationCodes.PersonnelIntervalOutside, person.Name); + } + var duplicates = s.Vehicles.GroupBy(v => (v.LicensePlate ?? v.Vin ?? v.Designator ?? string.Empty).Trim().ToUpperInvariant()).Where(g => g.Key.Length > 0 && g.Count() > 1).Select(g => g.Key).ToList(); + foreach (var duplicate in duplicates) Error(result, "apparatus", CalOesMarsValidationCodes.DuplicateVehicle, duplicate); + foreach (var rotation in s.Rotations.Where(r => !r.ApprovalAttachmentId.HasValue || attachments.All(a => a.DeploymentAttachmentId != r.ApprovalAttachmentId.Value))) + Error(result, "rotation", CalOesMarsValidationCodes.RotationUndocumented, rotation.On.ToString("yyyy-MM-dd")); + if (string.IsNullOrWhiteSpace(s.RespondingSignerName)) Error(result, "responding-signature", CalOesMarsValidationCodes.RespondingSignatureMissing, null); + if (string.IsNullOrWhiteSpace(s.IncidentAuthorizerName)) + { + if (s.DocumentationOnly) Warn(result, "incident-signature", CalOesMarsValidationCodes.IncidentAuthorizationMissing, null); + else Error(result, "incident-signature", CalOesMarsValidationCodes.IncidentAuthorizationMissing, null); + } + if (!attachments.Any(a => (a.AttachmentType == (int)DeploymentAttachmentTypes.SignedF42 || a.AttachmentType == (int)DeploymentAttachmentTypes.PaperF42) && (s.AttachmentIds.Count == 0 || s.AttachmentIds.Contains(a.DeploymentAttachmentId)))) + Error(result, "attachments", CalOesMarsValidationCodes.PaperFallbackMissing, null); + if (s.DocumentationOnly) Warn(result, "comments", CalOesMarsValidationCodes.DocumentationOnly, null); + } + + public static bool IsValidRequestNumber(string requestNumber, CalOesMarsAuthorityProfile authority) + { + var value = (requestNumber ?? string.Empty).Trim().ToUpperInvariant(); + if (value.Length < 2) return false; + var prefix = value.Substring(0, 1); + if (!authority.RequestPrefixes.Contains(prefix)) return false; + var rest = value.Substring(1).TrimStart('-', ' '); + return rest.Length > 0 && rest.All(c => char.IsDigit(c) || c == '.' || c == '-'); + } + + public async Task CalculateExpectedReimbursementAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (item.RecordType is not ((int)CalOesMarsRecordTypes.F42 or (int)CalOesMarsRecordTypes.ExpenseClaim)) throw new InvalidOperationException("calmars_work_item_not_calculable"); + if (item.LocalState is (int)CalOesMarsLocalStates.Paid or (int)CalOesMarsLocalStates.Closed) throw new InvalidOperationException("calmars_work_item_settled"); + var input = new CalOesMarsReimbursementInput { RateProfileVersion = item.RateProfileVersion }; + DateTime dispatchOn; + if (item.RecordType == (int)CalOesMarsRecordTypes.F42) + { + input.F42 = Deserialize(item.SnapshotJson) ?? new CalOesMarsF42Snapshot(); + dispatchOn = input.F42.DispatchedOn ?? item.AddedOn; + } + else + { + input.Expenses = Deserialize(item.SnapshotJson) ?? new CalOesMarsExpenseClaimSnapshot(); + var f42 = string.IsNullOrWhiteSpace(input.Expenses.F42WorkItemId) ? null : await _workItems.GetByIdForDepartmentAsync(input.Expenses.F42WorkItemId, departmentId); + dispatchOn = Deserialize(f42?.SnapshotJson)?.DispatchedOn ?? item.AddedOn; + } + // Everything is selected as of initial dispatch (decision 37): a later rate letter never rewrites an earlier claim. + var effective = (await _rateProfiles.GetEffectiveAsync(departmentId, dispatchOn))?.ToList() ?? new List(); + input.RateLines = (await _rateLines.GetByProfilesAsync(effective.Select(p => p.CalOesMarsRateProfileId)))?.ToList() ?? new List(); + input.Agreement = string.IsNullOrWhiteSpace(item.AgreementSnapshotId) ? await SelectAgreementAsync(departmentId, null, dispatchOn) : await GetAgreementAsync(item.AgreementSnapshotId, departmentId); + var administrative = effective.Where(p => p.AdministrativeRateMethod != (int)CalOesMarsAdministrativeRateMethods.None && p.AdministrativeRateValue.HasValue).OrderByDescending(p => p.SubmissionType == (int)CalOesMarsSubmissionTypes.AdministrativeRate).ThenByDescending(p => p.EffectiveOn).FirstOrDefault(); + input.AdministrativeRatePercent = administrative?.AdministrativeRateValue; + if (string.IsNullOrWhiteSpace(input.RateProfileVersion)) input.RateProfileVersion = RateVersion(effective); + + var result = _calculator.Calculate(input); + var before = Snapshot(item); + await TransactionAsync(async () => + { + await _lines.DeleteByWorkItemAsync(workItemId, cancellationToken); + var now = DateTime.UtcNow; + foreach (var line in result.Lines) + { + line.CalOesMarsReimbursementLineId = null; + line.CalOesMarsWorkItemId = workItemId; + line.DepartmentId = departmentId; + line.DeploymentId = item.DeploymentId; + line.AddedOn = now; + line.AddedByUserId = userId; + await _lines.SaveOrUpdateAsync(line, cancellationToken); + } + item.ExpectedTotal = result.ExpectedTotal; + item.RateProfileVersion = input.RateProfileVersion; + item.AgreementSnapshotId ??= input.Agreement?.CalOesMarsAgreementSnapshotId; + item.RowVersion++; item.EditedOn = now; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + return true; + }, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsReimbursementCalculated, ipAddress, userAgent, before, item); + return result; + } + + public async Task OpenPortalHandoffAsync(string workItemId, int departmentId, bool attested, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (Config.CostRecoveryConfig.HandoffAttestationRequired && !attested) throw new InvalidOperationException("calmars_attestation_required"); + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + var validation = Deserialize(item.ValidationSummaryJson); + if (item.RecordType is (int)CalOesMarsRecordTypes.F42 or (int)CalOesMarsRecordTypes.ExpenseClaim && (validation == null || !validation.IsReadyForPortal) && !item.IsExternal) throw new InvalidOperationException("calmars_not_ready"); + var manifest = await BuildManifestAsync(item, departmentId, userId); + manifest.Validation = validation; + // Opening the handoff is audited and never changes the mirror state (plan C4: "Prepared for MARS", not "Submitted"). + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemOpenedForHandoff, ipAddress, userAgent, null, new { item.CalOesMarsWorkItemId, item.RecordType, item.LocalState, manifest.Checksum, attested }); + return manifest; + } + + private async Task BuildManifestAsync(CalOesMarsWorkItem item, int departmentId, string userId) + { + var authority = CalOesMarsAuthorityProfile.Get(item.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; + var manifest = new CalOesMarsHandoffManifest + { + WorkItemId = item.CalOesMarsWorkItemId, RecordType = item.RecordType, AuthorityProfileCode = item.AuthorityProfileCode, RateProfileVersion = item.RateProfileVersion, AgreementSnapshotId = item.AgreementSnapshotId, + GeneratedOn = DateTime.UtcNow, GeneratedByUserId = userId, PortalUrl = _gateway.GetPortalUrl(item) + }; + switch ((CalOesMarsRecordTypes)item.RecordType) + { + case CalOesMarsRecordTypes.F42: + { + var s = Deserialize(item.SnapshotJson) ?? new CalOesMarsF42Snapshot(); + var values = new Dictionary + { + ["agency"] = Join(s.MacsDesignator, s.AgencyName), ["incident"] = Join(s.IncidentName, s.IncidentNumber), ["order"] = s.OrderNumber, ["request"] = Join(s.RequestNumber, s.ParentRequestNumber), + ["resource"] = Join(s.ResourceType ?? s.ResourceKind, s.StrikeTeamOrTaskForce, s.OverheadPosition), ["dispatch"] = s.DispatchedOn?.ToString("yyyy-MM-dd HH:mm"), + ["return"] = s.ReturnedOn?.ToString("yyyy-MM-dd HH:mm") ?? (s.ReleasedOn.HasValue ? "Released " + s.ReleasedOn.Value.ToString("yyyy-MM-dd HH:mm") : null), + ["apparatus"] = string.Join("; ", s.Vehicles.Select(v => $"{v.Kind} {v.Designator} {v.ResourceCode} {v.LicensePlate} {v.CommittedHours}h".Trim())), + ["personnel"] = string.Join("; ", s.Personnel.Select(p => $"{p.Name} ({p.Rank ?? p.ClassificationCode}) {(p.ActualHours.Count > 0 ? p.ActualHours.Sum(h => h.Hours) + "h actual" : p.CommittedHours + "h committed")}")), + ["rotation"] = s.Rotations.Count == 0 ? null : string.Join("; ", s.Rotations.Select(r => r.On.ToString("yyyy-MM-dd"))), + ["comments"] = Join(s.Comments, s.LossDamage, s.SupplyNumbers), ["responding-signature"] = Join(s.RespondingSignerName, s.RespondingSignedOn?.ToString("yyyy-MM-dd")), + ["incident-signature"] = Join(s.IncidentAuthorizerName, s.IncidentAuthorizedOn?.ToString("yyyy-MM-dd")), ["attachments"] = s.AttachmentIds.Count.ToString() + }; + foreach (var box in authority.F42Boxes) manifest.Fields.Add(new CalOesMarsHandoffField { Box = box.Id, Label = box.Label, Value = values.TryGetValue(box.Id, out var v) ? v : null, Source = box.Source }); + if (!string.IsNullOrWhiteSpace(item.DeploymentId)) + manifest.SupportingAttachmentNames = (await _deploymentService.GetAttachmentsAsync(item.DeploymentId, departmentId))?.Where(a => s.AttachmentIds.Contains(a.DeploymentAttachmentId)).Select(a => a.FileName ?? a.Name).ToList() ?? new List(); + break; + } + case CalOesMarsRecordTypes.ExpenseClaim: + { + var s = Deserialize(item.SnapshotJson) ?? new CalOesMarsExpenseClaimSnapshot(); + manifest.Fields.Add(new CalOesMarsHandoffField { Box = "resource", Label = "Resource / F-42", Value = s.TravelOnly ? "Travel only (unmatched)" : Join(s.RequestNumber, s.IncidentNumber), Source = "Snapshot" }); + foreach (var line in s.Lines) manifest.Fields.Add(new CalOesMarsHandoffField { Box = "line", Label = $"{line.Date:yyyy-MM-dd} {line.Category}", Value = $"{line.City} {line.Amount:N2} {line.Description}".Trim(), Source = "DeploymentExpense:" + line.DeploymentExpenseId }); + manifest.Fields.Add(new CalOesMarsHandoffField { Box = "signature", Label = "Signature / approval", Value = Join(s.SignerName, s.ApproverName), Source = "Snapshot" }); + break; + } + case CalOesMarsRecordTypes.GeneratedInvoice: + { + var s = Deserialize(item.SnapshotJson) ?? new CalOesMarsInvoiceSnapshot(); + manifest.Fields.Add(new CalOesMarsHandoffField { Box = "invoice", Label = "MARS invoice", Value = Join(s.MarsInvoiceId, s.InvoiceDate?.ToString("yyyy-MM-dd"), s.InvoicedTotal?.ToString("N2")), Source = "Observed" }); + break; + } + } + manifest.Checksum = Sha256(JsonConvert.SerializeObject(manifest.Fields) + item.SourceChecksum); + return manifest; + } + + public async Task BuildEvidencePacketAsync(string workItemId, int departmentId, string userId) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + var manifest = await BuildManifestAsync(item, departmentId, userId); + manifest.Validation = Deserialize(item.ValidationSummaryJson); + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + Add(zip, "README.txt", Encoding.UTF8.GetBytes("Resgrid Cal OES MARS evidence packet.\r\nThis is NOT an accepted MARS import file. It carries the prepared record, its checklist result, source and checksum metadata and the supporting documents for manual entry in the MARS portal.\r\n" + + $"Work item: {item.CalOesMarsWorkItemId}\r\nRecord type: {(CalOesMarsRecordTypes)item.RecordType}\r\nAuthority profile: {item.AuthorityProfileCode}\r\nRate profile version: {item.RateProfileVersion}\r\nAgreement snapshot: {item.AgreementSnapshotId}\r\nSnapshot checksum: {item.SourceChecksum}\r\nManifest checksum: {manifest.Checksum}\r\nGenerated: {manifest.GeneratedOn:u}\r\n")); + Add(zip, "manifest.json", Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(manifest, Formatting.Indented))); + Add(zip, "snapshot.json", Encoding.UTF8.GetBytes(item.SnapshotJson ?? "{}")); + Add(zip, "record.html", Encoding.UTF8.GetBytes(await RenderWorkItemHtmlAsync(workItemId, departmentId))); + if (!string.IsNullOrWhiteSpace(item.DeploymentId)) + { + var ids = Deserialize(item.RecordType == (int)CalOesMarsRecordTypes.F42 ? item.SnapshotJson : null)?.AttachmentIds + ?? Deserialize(item.RecordType == (int)CalOesMarsRecordTypes.ExpenseClaim ? item.SnapshotJson : null)?.Lines.Where(l => l.ReceiptAttachmentId.HasValue).Select(l => l.ReceiptAttachmentId.Value).Distinct().ToList() + ?? new List(); + foreach (var id in ids) + { + var attachment = await _deploymentService.GetAttachmentAsync(id, departmentId, true); + if (attachment?.Data == null || attachment.Data.Length == 0) continue; + Add(zip, "supporting/" + SafeName(attachment.FileName ?? attachment.Name ?? $"attachment-{id}"), attachment.Data); + } + } + } + return stream.ToArray(); + } + + private static void Add(ZipArchive zip, string path, byte[] data) + { + var entry = zip.CreateEntry(path, CompressionLevel.Optimal); + using var target = entry.Open(); + target.Write(data, 0, data.Length); + } + + private static string SafeName(string name) => string.Concat((name ?? "file").Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + + public async Task RenderWorkItemHtmlAsync(string workItemId, int departmentId) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + var manifest = await BuildManifestAsync(item, departmentId, null); + var sb = new StringBuilder(); + sb.Append("Prepared for MARS"); + sb.Append("

Prepared for Cal OES MARS — ").Append(WebUtility.HtmlEncode(((CalOesMarsRecordTypes)item.RecordType).ToString())).Append("

"); + sb.Append("

Authority profile ").Append(WebUtility.HtmlEncode(item.AuthorityProfileCode ?? "—")).Append(" · rate profile ").Append(WebUtility.HtmlEncode(item.RateProfileVersion ?? "—")).Append(" · checksum ").Append(WebUtility.HtmlEncode(manifest.Checksum)).Append(" · not an accepted MARS import file

"); + sb.Append(""); + foreach (var field in manifest.Fields) sb.Append(""); + sb.Append("
BoxValueSource
").Append(WebUtility.HtmlEncode(field.Label)).Append("").Append(WebUtility.HtmlEncode(field.Value ?? "")).Append("").Append(WebUtility.HtmlEncode(field.Source ?? "")).Append("
"); + if (item.Lines.Count > 0) + { + sb.Append("

Expected reimbursement (estimate; Cal OES determines the allowed amount)

"); + foreach (var line in item.Lines) + sb.Append(""); + sb.Append("
KindSubjectQtyUnitRateExpectedEligibility
").Append((CalOesMarsLineKinds)line.LineKind).Append("").Append(WebUtility.HtmlEncode(line.SubjectName ?? line.SubjectId ?? "")).Append("").Append(line.Quantity.ToString("0.##")).Append("").Append(WebUtility.HtmlEncode(line.Unit ?? "")).Append("").Append(line.Rate.ToString("N2")).Append("").Append(line.ExpectedAmount.ToString("N2")).Append("").Append((CalOesMarsEligibilityStates)line.EligibilityState).Append(string.IsNullOrWhiteSpace(line.EligibilityReason) ? "" : " · " + WebUtility.HtmlEncode(line.EligibilityReason)).Append("
Expected total").Append((item.ExpectedTotal ?? 0).ToString("N2")).Append("
"); + } + sb.Append(""); + return sb.ToString(); + } + + #endregion + + #region External observation and reconciliation + + public async Task RecordExternalSubmissionAsync(string workItemId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (item.RecordType is not ((int)CalOesMarsRecordTypes.F42 or (int)CalOesMarsRecordTypes.ExpenseClaim)) throw new InvalidOperationException("calmars_work_item_not_submittable"); + if (item.LocalState != (int)CalOesMarsLocalStates.ReadyForPortal) throw new InvalidOperationException("calmars_not_ready"); + var before = Snapshot(item); + var now = DateTime.UtcNow; + item.LocalState = (int)CalOesMarsLocalStates.SubmittedExternal; + item.MarsRecordId = Trim(observation.ExternalId); + item.ObservedExternalStatus = Trim(observation.ExternalStatus) ?? "Cal OES Review"; + item.ObservedOn = observation.ObservedOn ?? now; + item.ObservedSource = Trim(observation.Source) ?? CalOesMarsObservationSources.Manual; + item.SubmittedByUserId = userId; item.SubmittedOn = now; + item.SourceArtifact = observation.ArtifactAttachmentId?.ToString() ?? item.SourceArtifact; + item.RowVersion++; item.EditedOn = now; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsExternalStatusObserved, ipAddress, userAgent, before, item); + return item; + } + + public async Task RecordExternalStatusAsync(string workItemId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (!item.IsExternal || item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) throw new InvalidOperationException("calmars_work_item_not_external"); + var authority = CalOesMarsAuthorityProfile.Get(item.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; + var mapped = authority.MapRecordStatus(observation.ExternalStatus) ?? throw new InvalidOperationException("calmars_status_unknown"); + var before = Snapshot(item); + var now = DateTime.UtcNow; + item.ObservedExternalStatus = observation.ExternalStatus.Trim(); + item.ObservedOn = observation.ObservedOn ?? now; + item.ObservedSource = Trim(observation.Source) ?? CalOesMarsObservationSources.Manual; + item.MarsRecordId = Trim(observation.ExternalId) ?? item.MarsRecordId; + item.CorrectionComment = Trim(observation.Comment) ?? item.CorrectionComment; + CalOesMarsWorkItem revision = null; + if (mapped == CalOesMarsLocalStates.ReturnedForAgencyReview) + { + // A returned record closes this revision and opens a new local one carrying the reviewer's comment. + item.LocalState = (int)CalOesMarsLocalStates.Closed; + revision = new CalOesMarsWorkItem + { + DepartmentId = departmentId, DeploymentId = item.DeploymentId, RmsExternalOrderId = item.RmsExternalOrderId, RmsExternalOrderFillId = item.RmsExternalOrderFillId, RecordType = item.RecordType, + LocalState = (int)CalOesMarsLocalStates.ReturnedForAgencyReview, MarsRecordId = item.MarsRecordId, ObservedExternalStatus = item.ObservedExternalStatus, ObservedOn = item.ObservedOn, ObservedSource = item.ObservedSource, + CorrectionComment = Trim(observation.Comment), AuthorityProfileCode = item.AuthorityProfileCode, RateProfileVersion = item.RateProfileVersion, AgreementSnapshotId = item.AgreementSnapshotId, + SnapshotJson = item.SnapshotJson, SourceChecksum = item.SourceChecksum, ExpectedTotal = item.ExpectedTotal, SupersedesWorkItemId = item.CalOesMarsWorkItemId, AddedOn = now, AddedByUserId = userId + }; + } + else + { + item.LocalState = (int)mapped; + if (mapped == CalOesMarsLocalStates.Approved) { item.ApprovedOn = item.ObservedOn; item.ApprovedByUserId = userId; } + } + item.RowVersion++; item.EditedOn = now; item.EditedByUserId = userId; + await TransactionAsync(async () => + { + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + if (revision != null) + { + revision = await _workItems.SaveOrUpdateAsync(revision, cancellationToken); + var sort = 0; + foreach (var line in item.Lines) + { + var copy = line.CloneJson(); copy.CalOesMarsReimbursementLineId = null; copy.CalOesMarsWorkItemId = revision.CalOesMarsWorkItemId; copy.SortOrder = sort++; copy.AddedOn = now; copy.AddedByUserId = userId; + await _lines.SaveOrUpdateAsync(copy, cancellationToken); + } + } + return true; + }, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsExternalStatusObserved, ipAddress, userAgent, before, item); + return revision == null ? item : await GetWorkItemAsync(revision.CalOesMarsWorkItemId, departmentId); + } + + public async Task RecordMarsInvoiceAsync(int departmentId, string deploymentId, CalOesMarsInvoiceObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + if (string.IsNullOrWhiteSpace(observation.MarsInvoiceId)) throw new InvalidOperationException("calmars_invoice_id_required"); + if (observation.InvoicedTotal < 0) throw new InvalidOperationException("calmars_invoice_amount_invalid"); + if ((await _workItems.GetByExternalIdAsync(departmentId, observation.MarsInvoiceId.Trim()))?.Any(w => w.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) == true) throw new InvalidOperationException("calmars_invoice_duplicate"); + var covered = new List(); + foreach (var id in (observation.CoveredWorkItemIds ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct()) + { + var item = await _workItems.GetByIdForDepartmentAsync(id, departmentId); + if (item == null || item.IsDeleted || item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) throw new InvalidOperationException("calmars_work_item_not_found"); + if (!item.IsExternal) throw new InvalidOperationException("calmars_work_item_not_external"); + covered.Add(item); + } + var authority = CalOesMarsAuthorityProfile.Current; + var now = DateTime.UtcNow; + var snapshot = new CalOesMarsInvoiceSnapshot { MarsInvoiceId = observation.MarsInvoiceId.Trim(), InvoiceDate = observation.InvoiceDate, InvoicedTotal = observation.InvoicedTotal, PayingEntity = Trim(observation.PayingEntity), CoveredWorkItemIds = covered.Select(c => c.CalOesMarsWorkItemId).ToList(), InvoiceAttachmentId = observation.InvoiceAttachmentId }; + // A MARS invoice is a work item, never a Phase B Invoice (decision 36): it takes no invoice number, joins no aging and sends no e-mail. + var invoice = new CalOesMarsWorkItem + { + DepartmentId = departmentId, DeploymentId = Trim(deploymentId) ?? covered.FirstOrDefault()?.DeploymentId, RmsExternalOrderId = covered.FirstOrDefault()?.RmsExternalOrderId, RecordType = (int)CalOesMarsRecordTypes.GeneratedInvoice, + LocalState = (int)(authority.MapInvoiceStatus(observation.ExternalStatus) ?? CalOesMarsLocalStates.PendingLocalAgencyApproval), MarsInvoiceId = snapshot.MarsInvoiceId, + ObservedExternalStatus = Trim(observation.ExternalStatus) ?? "Pending Local Agency Approval", ObservedOn = observation.ObservedOn ?? now, ObservedSource = CalOesMarsObservationSources.Manual, + AuthorityProfileCode = authority.Code, SnapshotJson = JsonConvert.SerializeObject(snapshot), ExpectedTotal = covered.Sum(c => c.ExpectedTotal ?? 0), ApprovedTotal = observation.InvoicedTotal, + CorrectionComment = Trim(observation.Comment), SourceArtifact = observation.InvoiceAttachmentId?.ToString(), AddedOn = now, AddedByUserId = userId + }; + invoice.SourceChecksum = Sha256(invoice.SnapshotJson); + var saved = await _workItems.SaveOrUpdateAsync(invoice, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsExternalStatusObserved, ipAddress, userAgent, null, saved); + return await GetWorkItemAsync(saved.CalOesMarsWorkItemId, departmentId); + } + + public async Task ApproveOrRejectObservedInvoiceAsync(string invoiceWorkItemId, int departmentId, bool approve, string decisionTitle, string comment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var invoice = await GetWorkItemAsync(invoiceWorkItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (invoice.RecordType != (int)CalOesMarsRecordTypes.GeneratedInvoice) throw new InvalidOperationException("calmars_work_item_not_invoice"); + if (invoice.LocalState != (int)CalOesMarsLocalStates.PendingLocalAgencyApproval) throw new InvalidOperationException("calmars_invoice_not_pending"); + if (string.IsNullOrWhiteSpace(decisionTitle)) throw new InvalidOperationException("calmars_decision_title_required"); + if (!approve && string.IsNullOrWhiteSpace(comment)) throw new InvalidOperationException("calmars_rejection_comment_required"); + var before = Snapshot(invoice); + var snapshot = Deserialize(invoice.SnapshotJson) ?? new CalOesMarsInvoiceSnapshot(); + snapshot.LocalDecisionTitle = decisionTitle.Trim(); + snapshot.LocalDecisionComment = Trim(comment); + var now = DateTime.UtcNow; + invoice.SnapshotJson = JsonConvert.SerializeObject(snapshot); + invoice.CorrectionComment = Trim(comment); + if (approve) { invoice.LocalState = (int)CalOesMarsLocalStates.PendingPayingEntityApproval; invoice.ApprovedByUserId = userId; invoice.ApprovedOn = now; } + else { invoice.LocalState = (int)CalOesMarsLocalStates.LocalAgencyRejected; invoice.RejectedByUserId = userId; invoice.RejectedOn = now; } + invoice.RowVersion++; invoice.EditedOn = now; invoice.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(invoice, cancellationToken); + Audit(departmentId, userId, approve ? AuditLogTypes.CalOesMarsInvoiceApproved : AuditLogTypes.CalOesMarsInvoiceRejected, ipAddress, userAgent, before, invoice); + return invoice; + } + + public async Task RecordPaymentAsync(string invoiceWorkItemId, int departmentId, CalOesMarsPaymentObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + if (observation.PaidTotal < 0) throw new InvalidOperationException("calmars_payment_amount_invalid"); + var invoice = await GetWorkItemAsync(invoiceWorkItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (invoice.RecordType != (int)CalOesMarsRecordTypes.GeneratedInvoice) throw new InvalidOperationException("calmars_work_item_not_invoice"); + if (invoice.LocalState is not ((int)CalOesMarsLocalStates.PendingPayingEntityApproval or (int)CalOesMarsLocalStates.PendingLocalAgencyApproval)) throw new InvalidOperationException("calmars_invoice_not_payable"); + if (invoice.LocalState == (int)CalOesMarsLocalStates.PendingLocalAgencyApproval) throw new InvalidOperationException("calmars_invoice_not_approved"); + var before = Snapshot(invoice); + var now = DateTime.UtcNow; + // Paid is only ever an observed external fact (plan C11 acceptance 8), never derived from the local expected amount. + invoice.LocalState = (int)CalOesMarsLocalStates.Paid; + invoice.PaidTotal = observation.PaidTotal; + invoice.PaidOn = observation.PaidOn; + invoice.PaymentReference = Trim(observation.PaymentReference); + invoice.ObservedExternalStatus = Trim(observation.PayingEntityStatus) ?? "Paid"; + invoice.ObservedOn = observation.ObservedOn ?? now; + invoice.CorrectionComment = Trim(observation.Comment) ?? invoice.CorrectionComment; + invoice.RowVersion++; invoice.EditedOn = now; invoice.EditedByUserId = userId; + var snapshot = Deserialize(invoice.SnapshotJson) ?? new CalOesMarsInvoiceSnapshot(); + await TransactionAsync(async () => + { + await _workItems.SaveOrUpdateAsync(invoice, cancellationToken); + foreach (var id in snapshot.CoveredWorkItemIds) + { + var covered = await _workItems.GetByIdForDepartmentAsync(id, departmentId); + if (covered == null || covered.IsDeleted || covered.LocalState is (int)CalOesMarsLocalStates.Paid or (int)CalOesMarsLocalStates.Closed) continue; + covered.LocalState = (int)CalOesMarsLocalStates.Paid; covered.PaidOn = observation.PaidOn; covered.MarsInvoiceId = invoice.MarsInvoiceId; + covered.RowVersion++; covered.EditedOn = now; covered.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(covered, cancellationToken); + } + return true; + }, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsPaymentReconciled, ipAddress, userAgent, before, invoice); + return invoice; + } + + public async Task CloseWorkItemAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (item.LocalState is not ((int)CalOesMarsLocalStates.Paid or (int)CalOesMarsLocalStates.DocumentationOnly or (int)CalOesMarsLocalStates.LocalAgencyRejected or (int)CalOesMarsLocalStates.Approved)) throw new InvalidOperationException("calmars_work_item_not_closable"); + var before = Snapshot(item); + item.LocalState = (int)CalOesMarsLocalStates.Closed; + item.RowVersion++; item.EditedOn = DateTime.UtcNow; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsExternalStatusObserved, ipAddress, userAgent, before, item); + return item; + } + + public async Task DeleteWorkItemAsync(string workItemId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var item = await GetWorkItemAsync(workItemId, departmentId); + if (item == null) return false; + if (item.IsExternal) throw new InvalidOperationException("calmars_work_item_external"); + var before = Snapshot(item); + item.IsDeleted = true; item.EditedOn = DateTime.UtcNow; item.EditedByUserId = userId; + await _workItems.SaveOrUpdateAsync(item, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsWorkItemDeleted, ipAddress, userAgent, before, item); + return true; + } + + public async Task GetInvoiceReconciliationAsync(string invoiceWorkItemId, int departmentId) + { + var invoice = await GetWorkItemAsync(invoiceWorkItemId, departmentId); + if (invoice == null || invoice.RecordType != (int)CalOesMarsRecordTypes.GeneratedInvoice) return null; + var snapshot = Deserialize(invoice.SnapshotJson) ?? new CalOesMarsInvoiceSnapshot(); + var reconciliation = new CalOesMarsInvoiceReconciliation { Invoice = invoice, Snapshot = snapshot, InvoicedTotal = snapshot.InvoicedTotal ?? invoice.ApprovedTotal, PaidTotal = invoice.PaidTotal }; + foreach (var id in snapshot.CoveredWorkItemIds) + { + var covered = await GetWorkItemAsync(id, departmentId); + if (covered != null) reconciliation.CoveredItems.Add(covered); + } + reconciliation.ExpectedTotal = reconciliation.CoveredItems.Sum(c => c.ExpectedTotal ?? 0); + return reconciliation; + } + + #endregion + + #region Worker 32 + + public async Task RunReminderSweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + if (!Config.CostRecoveryConfig.ReminderEnabled || _communication?.Value == null) return 0; + var departments = ((await _workItems.GetDepartmentsWithOpenItemsAsync())?.ToList() ?? new List()) + .Concat((await _agencies.GetAllAsync())?.Where(a => !a.IsDeleted && a.IsActive).Select(a => a.DepartmentId) ?? Enumerable.Empty()).Distinct().ToList(); + var notified = 0; + var dayKey = asOfUtc.Date.GetHashCode(); + foreach (var departmentId in departments) + { + cancellationToken.ThrowIfCancellationRequested(); + if (departmentEnabled != null && !await departmentEnabled(departmentId)) continue; + var key = HashCode.Combine(dayKey, departmentId); + lock (RemindedToday) { if (RemindedToday.Contains(key)) continue; } + var lines = new List(); + try + { + // Value-minimized: counts, names and dates only — never amounts, identifiers or reviewer comments (plan C8). + var effective = (await _rateProfiles.GetEffectiveAsync(departmentId, asOfUtc.Date))?.ToList() ?? new List(); + foreach (var profile in effective.Where(p => p.ExpiresOn.HasValue && p.ExpiresOn.Value.Date <= asOfUtc.Date.AddDays(Config.CostRecoveryConfig.AnnualDeadlineLeadDays) && p.SubmissionType != (int)CalOesMarsSubmissionTypes.RateLetter)) + lines.Add($"{(CalOesMarsSubmissionTypes)profile.SubmissionType} {profile.SubmissionYear} expires {profile.ExpiresOn:yyyy-MM-dd}."); + if (!effective.Any(p => p.SubmissionType == (int)CalOesMarsSubmissionTypes.SalarySurvey)) lines.Add($"No Salary Survey covers {asOfUtc:yyyy-MM-dd}."); + foreach (var agreement in (await _agreements.GetForDepartmentAsync(departmentId))?.Where(a => a.CoversDate(asOfUtc.Date) && a.EndOn.HasValue && a.EndOn.Value.Date <= asOfUtc.Date.AddDays(Config.CostRecoveryConfig.AgreementExpiryLeadDays)) ?? Enumerable.Empty()) + lines.Add($"Agreement {(agreement.ClassificationTitle ?? agreement.ClassificationCode ?? "(all classifications)")} ends {agreement.EndOn:yyyy-MM-dd}."); + + var queue = (await _workItems.GetActionQueueAsync(departmentId))?.ToList() ?? new List(); + var returned = queue.Count(w => w.LocalState == (int)CalOesMarsLocalStates.ReturnedForAgencyReview); + if (returned > 0) lines.Add($"{returned} record(s) returned for agency review."); + var invoices = queue.Count(w => w.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice && w.LocalState == (int)CalOesMarsLocalStates.PendingLocalAgencyApproval); + if (invoices > 0) lines.Add($"{invoices} MARS invoice(s) awaiting local approval."); + + var deployments = await _deploymentService.GetDeploymentsForDepartmentAsync(departmentId, false, 0, 200); + var due = asOfUtc.AddDays(-Config.CostRecoveryConfig.F42DueDaysAfterRelease); + foreach (var deployment in deployments.Where(d => d.FinanceMode == (int)DeploymentFinanceModes.CostRecovery && d.Status is (int)DeploymentStatuses.Demobilizing or (int)DeploymentStatuses.Completed && (d.StatusChangedOn ?? d.EndOn ?? d.AddedOn) <= due)) + { + var items = queue.Where(w => string.Equals(w.DeploymentId, deployment.DeploymentId, StringComparison.OrdinalIgnoreCase) && w.RecordType == (int)CalOesMarsRecordTypes.F42).ToList(); + if (!items.Any(w => w.LocalState >= (int)CalOesMarsLocalStates.ReadyForPortal)) lines.Add($"{deployment.Name}: released {Config.CostRecoveryConfig.F42DueDaysAfterRelease}+ days ago without a ready or submitted F-42."); + var expenses = queue.Where(w => string.Equals(w.DeploymentId, deployment.DeploymentId, StringComparison.OrdinalIgnoreCase) && w.RecordType == (int)CalOesMarsRecordTypes.ExpenseClaim).ToList(); + foreach (var expense in expenses.Where(e => !e.IsExternal)) + { + var validation = Deserialize(expense.ValidationSummaryJson); + if (validation != null && validation.Errors.Any(x => x.Code == CalOesMarsValidationCodes.ExpenseReceiptMissing)) lines.Add($"{deployment.Name}: expense claim is missing receipt evidence."); + } + } + } + catch (Exception ex) { Logging.LogException(ex, $"Cal OES MARS reminder sweep failed for department {departmentId}."); continue; } + if (lines.Count == 0) continue; + lock (RemindedToday) { RemindedToday.Add(key); if (RemindedToday.Count > 50_000) RemindedToday.Clear(); } + await NotifyManagersAsync(departmentId, "Cal OES MARS: " + string.Join(" ", lines)); + notified++; + } + return notified; + } + + private async Task NotifyManagersAsync(int departmentId, string message) + { + try + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); + // Permission 79 defaults to department administrators; the digest goes to them (a narrower role assignment still includes admins). + foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Cal OES MARS"); + } + catch (Exception ex) { Logging.LogException(ex, $"Cal OES MARS digest could not be sent for department {departmentId}."); } + } + + #endregion + + #region Helpers + + private async Task RequireLocalAsync(string workItemId, int departmentId, CalOesMarsRecordTypes recordType) + { + var item = await GetWorkItemAsync(workItemId, departmentId) ?? throw new InvalidOperationException("calmars_work_item_not_found"); + if (item.RecordType != (int)recordType) throw new InvalidOperationException("calmars_work_item_type_mismatch"); + if (!item.IsLocallyEditable) throw new InvalidOperationException("calmars_work_item_external"); + return item; + } + + private async Task> EffectiveRateLinesAsync(int departmentId, DateTime dispatchOn) + { + var effective = (await _rateProfiles.GetEffectiveAsync(departmentId, dispatchOn))?.ToList() ?? new List(); + return (await _rateLines.GetByProfilesAsync(effective.Select(p => p.CalOesMarsRateProfileId)))?.ToList() ?? new List(); + } + + private static string RateVersion(List effective) => effective == null || effective.Count == 0 ? null : string.Join(",", effective.Select(p => $"{p.CalOesMarsRateProfileId}:{p.RowVersion}")); + + private static decimal Hours(DateTime? from, DateTime? to) => from.HasValue && to.HasValue && to > from ? (decimal)(to.Value - from.Value).TotalHours : 0m; + + private static decimal Hours(DeploymentTimeEntry entry) => entry.EndTime > entry.StartTime ? Math.Max(0, (decimal)(entry.EndTime - entry.StartTime).TotalHours - entry.UnpaidBreakMinutes / 60m) : 0m; + + private static string Join(params string[] parts) { var value = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); return value.Length == 0 ? null : value; } + + private static void Error(CalOesMarsValidationResult result, string box, string code, string detail) => result.Errors.Add(new CalOesMarsValidationIssue { Box = box, Code = code, Detail = detail }); + private static void Warn(CalOesMarsValidationResult result, string box, string code, string detail) => result.Warnings.Add(new CalOesMarsValidationIssue { Box = box, Code = code, Detail = detail }); + + /// Reads a typed snapshot; a corrupt payload logs and reads as null rather than failing the page. + public static T Deserialize(string json) where T : class + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonConvert.DeserializeObject(json); } + catch (Exception ex) { Logging.LogException(ex, "Cal OES MARS snapshot could not be read."); return null; } + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs new file mode 100644 index 00000000..5689971c --- /dev/null +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs @@ -0,0 +1,743 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.CostRecovery.CalOesMars; +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; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.CostRecovery +{ + /// + /// Cal OES MARS / CFAA cost recovery (Workforce & Business Operations plan, Phase C-M3; C4, C11; decisions + /// 36-38). Prepares agency / resource / rate / agreement readiness, projects F-42 and expense-claim work items from + /// the immutable deployment, order, fill, roster, DTR and expense facts, validates them against the pinned + /// authority profile, calculates expected reimbursement through the pure calculator, renders the no-store handoff + /// view, and reconciles what a MARS manager observed in the portal. It never writes to MARS (the P0 gateway is + /// manual), never stores a portal credential, and never marks a record submitted from a handoff or a download. + /// MARS records are never Phase B invoices (decision 36). Callers authorize. + /// + public partial class CalOesMarsService : ICalOesMarsService + { + private static readonly HashSet RemindedToday = new HashSet(); + + private readonly ICalOesMarsAgencyProfileRepository _agencies; + private readonly ICalOesMarsResourceProfileRepository _resources; + private readonly ICalOesMarsRateProfileRepository _rateProfiles; + private readonly ICalOesMarsRateLineRepository _rateLines; + private readonly ICalOesMarsAdministrativeRateInputRepository _adminInputs; + private readonly ICalOesMarsAgreementSnapshotRepository _agreements; + private readonly ICalOesMarsWorkItemRepository _workItems; + private readonly ICalOesMarsReimbursementLineRepository _lines; + private readonly IDeploymentService _deploymentService; + private readonly ITimeTrackingService _timeTracking; + private readonly IDeploymentTimeEntryRepository _timeEntries; + private readonly IUnitsService _unitsService; + private readonly IUserProfileService _userProfileService; + private readonly IDepartmentsService _departmentsService; + private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; + private readonly ICalOesMarsReimbursementCalculator _calculator; + private readonly ICalOesMarsExternalGateway _gateway; + private readonly Lazy _communication; + private readonly Lazy _departmentSettings; + + public CalOesMarsService(ICalOesMarsAgencyProfileRepository agencies, ICalOesMarsResourceProfileRepository resources, ICalOesMarsRateProfileRepository rateProfiles, + ICalOesMarsRateLineRepository rateLines, ICalOesMarsAdministrativeRateInputRepository adminInputs, ICalOesMarsAgreementSnapshotRepository agreements, + ICalOesMarsWorkItemRepository workItems, ICalOesMarsReimbursementLineRepository lines, IDeploymentService deploymentService, ITimeTrackingService timeTracking, + IDeploymentTimeEntryRepository timeEntries, IUnitsService unitsService, IUserProfileService userProfileService, IDepartmentsService departmentsService, + IEventAggregator eventAggregator, IUnitOfWork unitOfWork, ICalOesMarsReimbursementCalculator calculator, ICalOesMarsExternalGateway gateway, + Lazy communication = null, Lazy departmentSettings = null) + { + _agencies = agencies; + _resources = resources; + _rateProfiles = rateProfiles; + _rateLines = rateLines; + _adminInputs = adminInputs; + _agreements = agreements; + _workItems = workItems; + _lines = lines; + _deploymentService = deploymentService; + _timeTracking = timeTracking; + _timeEntries = timeEntries; + _unitsService = unitsService; + _userProfileService = userProfileService; + _departmentsService = departmentsService; + _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; + _calculator = calculator; + _gateway = gateway; + _communication = communication; + _departmentSettings = departmentSettings; + } + + #region Readiness and agency + + public async Task GetAgencyReadinessAsync(int departmentId, DateTime? dispatchOn = null) + { + var asOf = (dispatchOn ?? DateTime.UtcNow).Date; + var profile = CalOesMarsAuthorityProfile.ForDispatch(asOf); + var readiness = new CalOesMarsReadiness { AsOf = asOf, AuthorityProfileCode = profile?.Code, AuthorityProfileCurrent = profile != null && profile.IsReviewed }; + if (profile == null) readiness.Items.Add(Item("authority", CalOesMarsReadinessSeverities.Blocker, "ReadinessAuthorityMissing", asOf.ToString("yyyy-MM-dd"), "Agency")); + + var agency = await GetAgencyProfileAsync(departmentId); + readiness.Agency = agency; + if (agency == null) readiness.Items.Add(Item("agency", CalOesMarsReadinessSeverities.Blocker, "ReadinessAgencyMissing", null, "Agency")); + else + { + if (string.IsNullOrWhiteSpace(agency.MacsDesignator)) readiness.Items.Add(Item("macs", CalOesMarsReadinessSeverities.Blocker, "ReadinessMacsMissing", null, "Agency")); + if (string.IsNullOrWhiteSpace(agency.FeinReference)) readiness.Items.Add(Item("fein", CalOesMarsReadinessSeverities.Warning, "ReadinessFeinMissing", null, "Agency")); + if (string.IsNullOrWhiteSpace(agency.UeiReference)) readiness.Items.Add(Item("uei", CalOesMarsReadinessSeverities.Warning, "ReadinessUeiMissing", null, "Agency")); + if (string.IsNullOrWhiteSpace(agency.FiscalSupplierReference)) readiness.Items.Add(Item("fiscal", CalOesMarsReadinessSeverities.Warning, "ReadinessFiscalMissing", null, "Agency")); + if (!agency.VerifiedOn.HasValue || agency.VerifiedOn.Value < asOf.AddDays(-365)) readiness.Items.Add(Item("verified", CalOesMarsReadinessSeverities.Warning, "ReadinessAgencyUnverified", agency.VerifiedOn?.ToString("yyyy-MM-dd"), "Agency")); + } + + var resources = (await _resources.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + readiness.ResourceProfiles = resources.Count; + readiness.ResourceMismatches = resources.Count(r => r.ReviewState == (int)CalOesMarsReviewStates.Mismatch); + if (resources.Count == 0) readiness.Items.Add(Item("resources", CalOesMarsReadinessSeverities.Warning, "ReadinessNoResources", null, "Resources")); + if (readiness.ResourceMismatches > 0) readiness.Items.Add(Item("resource-mismatch", CalOesMarsReadinessSeverities.Warning, "ReadinessResourceMismatch", readiness.ResourceMismatches.ToString(), "Resources")); + + var effective = (await _rateProfiles.GetEffectiveAsync(departmentId, asOf))?.ToList() ?? new List(); + readiness.CurrentRateProfiles = effective; + var hasSalary = effective.Any(p => p.SubmissionType == (int)CalOesMarsSubmissionTypes.SalarySurvey && (p.BaseRateAccepted || p.Status >= (int)CalOesMarsRateProfileStatuses.Reviewed)); + if (!hasSalary) readiness.Items.Add(Item("salary", CalOesMarsReadinessSeverities.Blocker, "ReadinessSalaryMissing", asOf.Year.ToString(), "Rates")); + if (!effective.Any(p => p.SubmissionType == (int)CalOesMarsSubmissionTypes.AdministrativeRate || p.AdministrativeRateMethod != (int)CalOesMarsAdministrativeRateMethods.None)) + readiness.Items.Add(Item("administrative", CalOesMarsReadinessSeverities.Warning, "ReadinessAdministrativeMissing", null, "Rates")); + if (!effective.Any(p => p.SubmissionType == (int)CalOesMarsSubmissionTypes.RateLetter)) readiness.Items.Add(Item("rate-letter", CalOesMarsReadinessSeverities.Warning, "ReadinessRateLetterMissing", null, "Rates")); + foreach (var expiring in effective.Where(p => p.ExpiresOn.HasValue && p.ExpiresOn.Value.Date <= asOf.AddDays(Config.CostRecoveryConfig.AnnualDeadlineLeadDays))) + readiness.Items.Add(Item("rate-expiring:" + expiring.CalOesMarsRateProfileId, CalOesMarsReadinessSeverities.Warning, "ReadinessRateExpiring", $"{(CalOesMarsSubmissionTypes)expiring.SubmissionType} {expiring.SubmissionYear} → {expiring.ExpiresOn:yyyy-MM-dd}", "Rates")); + foreach (var unsigned in effective.Where(p => p.Status < (int)CalOesMarsRateProfileStatuses.SignedLocally && !p.BaseRateAccepted && p.SubmissionType != (int)CalOesMarsSubmissionTypes.RateLetter)) + readiness.Items.Add(Item("rate-unsigned:" + unsigned.CalOesMarsRateProfileId, CalOesMarsReadinessSeverities.Warning, "ReadinessRateUnsigned", $"{(CalOesMarsSubmissionTypes)unsigned.SubmissionType} {unsigned.SubmissionYear}", "Rates")); + + var agreements = (await _agreements.GetForDepartmentAsync(departmentId))?.Where(a => a.CoversDate(asOf)).ToList() ?? new List(); + readiness.CurrentAgreements = agreements; + if (agreements.Count == 0) readiness.Items.Add(Item("agreement", CalOesMarsReadinessSeverities.Blocker, "ReadinessAgreementMissing", asOf.ToString("yyyy-MM-dd"), "Agreements")); + foreach (var expiring in agreements.Where(a => a.EndOn.HasValue && a.EndOn.Value.Date <= asOf.AddDays(Config.CostRecoveryConfig.AgreementExpiryLeadDays))) + readiness.Items.Add(Item("agreement-expiring:" + expiring.CalOesMarsAgreementSnapshotId, CalOesMarsReadinessSeverities.Warning, "ReadinessAgreementExpiring", $"{expiring.ClassificationTitle ?? expiring.ClassificationCode ?? "*"} → {expiring.EndOn:yyyy-MM-dd}", "Agreements")); + + var queue = (await _workItems.GetActionQueueAsync(departmentId))?.ToList() ?? new List(); + readiness.OpenWorkItems = queue.Count; + readiness.ReturnedWorkItems = queue.Count(w => w.LocalState == (int)CalOesMarsLocalStates.ReturnedForAgencyReview); + readiness.InvoicesAwaitingLocalApproval = queue.Count(w => w.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice && w.LocalState == (int)CalOesMarsLocalStates.PendingLocalAgencyApproval); + if (readiness.ReturnedWorkItems > 0) readiness.Items.Add(Item("returned", CalOesMarsReadinessSeverities.Warning, "ReadinessReturnedItems", readiness.ReturnedWorkItems.ToString(), "Queue")); + if (readiness.InvoicesAwaitingLocalApproval > 0) readiness.Items.Add(Item("invoices", CalOesMarsReadinessSeverities.Warning, "ReadinessInvoicesAwaiting", readiness.InvoicesAwaitingLocalApproval.ToString(), "Reconciliation")); + return readiness; + } + + private static CalOesMarsReadinessItem Item(string key, CalOesMarsReadinessSeverities severity, string messageKey, string detail, string area) => + new CalOesMarsReadinessItem { Key = key, Severity = (int)severity, MessageKey = messageKey, Detail = detail, Area = area }; + + public async Task GetAgencyProfileAsync(int departmentId) + { + var agency = await _agencies.GetByDepartmentAsync(departmentId); + return agency == null || agency.IsDeleted ? null : agency; + } + + public async Task SaveAgencyProfileAsync(CalOesMarsAgencyProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrWhiteSpace(profile.AgencyName)) throw new InvalidOperationException("calmars_agency_name_required"); + var now = DateTime.UtcNow; + var existing = await _agencies.GetByDepartmentAsync(profile.DepartmentId); + var before = existing == null ? null : Snapshot(existing); + var target = existing ?? new CalOesMarsAgencyProfile { DepartmentId = profile.DepartmentId, AddedOn = now, AddedByUserId = userId, AuthorityProfileCode = CalOesMarsAuthorityProfile.Current.Code }; + var changedIdentity = existing != null && (Trim(existing.MacsDesignator) != Trim(profile.MacsDesignator) || Trim(existing.FeinReference) != Trim(profile.FeinReference) || Trim(existing.UeiReference) != Trim(profile.UeiReference) || Trim(existing.FiscalSupplierReference) != Trim(profile.FiscalSupplierReference)); + target.AuthorityProfileCode = string.IsNullOrWhiteSpace(profile.AuthorityProfileCode) ? target.AuthorityProfileCode ?? CalOesMarsAuthorityProfile.Current.Code : profile.AuthorityProfileCode; + target.MacsDesignator = Trim(profile.MacsDesignator)?.ToUpperInvariant(); + target.AgencyName = profile.AgencyName.Trim(); + target.AgencyCategory = Trim(profile.AgencyCategory); + target.ContactName = Trim(profile.ContactName); + target.ContactPhone = Trim(profile.ContactPhone); + target.ContactEmail = Trim(profile.ContactEmail); + target.Address = Trim(profile.Address); + target.FeinReference = Trim(profile.FeinReference); + target.UeiReference = Trim(profile.UeiReference); + target.SamReference = Trim(profile.SamReference); + target.FiscalSupplierReference = Trim(profile.FiscalSupplierReference); + target.PortalAccountRole = Trim(profile.PortalAccountRole); + target.PortalAccountReference = Trim(profile.PortalAccountReference); + target.SourceArtifact = Trim(profile.SourceArtifact); + target.SourceChecksum = Trim(profile.SourceChecksum); + target.IsActive = profile.IsActive; + target.IsDeleted = false; + // An identifier change invalidates the last verification; the readiness dashboard asks for a fresh check. + if (changedIdentity) { target.VerifiedOn = null; target.VerifiedByUserId = null; } + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _agencies.SaveOrUpdateAsync(target, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.CalOesMarsAgencyProfileChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task MarkAgencyVerifiedAsync(int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var agency = await GetAgencyProfileAsync(departmentId) ?? throw new InvalidOperationException("calmars_agency_not_found"); + var before = Snapshot(agency); + agency.VerifiedOn = DateTime.UtcNow; + agency.VerifiedByUserId = userId; + agency.EditedOn = agency.VerifiedOn; + agency.EditedByUserId = userId; + var saved = await _agencies.SaveOrUpdateAsync(agency, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsAgencyProfileChanged, ipAddress, userAgent, before, saved); + return saved; + } + + #endregion + + #region F-5 resource inventory crosswalk + + public async Task> GetResourceProfilesAsync(int departmentId) + { + var rows = (await _resources.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await NameResourcesAsync(rows, departmentId); + return rows; + } + + public async Task GetResourceProfileAsync(string resourceProfileId, int departmentId) + { + if (string.IsNullOrWhiteSpace(resourceProfileId)) return null; + var row = await _resources.GetByIdForDepartmentAsync(resourceProfileId, departmentId); + if (row == null || row.IsDeleted) return null; + await NameResourcesAsync(new[] { row }, departmentId); + return row; + } + + public async Task> BuildResourceInventoryF5DraftAsync(int departmentId, IEnumerable unitIds, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var ids = (unitIds ?? Enumerable.Empty()).Distinct().ToList(); + var result = new List(); + if (ids.Count == 0) return result; + var existing = (await _resources.GetByUnitIdsAsync(departmentId, ids))?.ToDictionary(r => r.UnitId ?? 0) ?? new Dictionary(); + var units = (await _unitsService.GetUnitsForDepartmentAsync(departmentId))?.Where(u => ids.Contains(u.UnitId)).ToList() ?? new List(); + var now = DateTime.UtcNow; + foreach (var unit in units) + { + if (existing.TryGetValue(unit.UnitId, out var current)) { result.Add(current); continue; } + // A draft crosswalk row: identity copied from the Unit at this moment (the Unit itself is never written). + var draft = new CalOesMarsResourceProfile + { + DepartmentId = departmentId, SubjectType = (int)CalOesMarsSubjectTypes.Unit, UnitId = unit.UnitId, UnitDesignator = unit.Name, ResourceType = GuessResourceType(unit.Type), + ResourceKind = "Apparatus", CodeScheme = "MARS-F5", LicensePlate = Trim(unit.PlateNumber), Vin = Trim(unit.VIN), Ownership = (int)CalOesMarsOwnerships.LocalAgency, + ReviewState = (int)CalOesMarsReviewStates.Draft, EffectiveOn = now.Date, AddedOn = now, AddedByUserId = userId + }; + var saved = await _resources.SaveOrUpdateAsync(draft, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsResourceProfileChanged, ipAddress, userAgent, null, saved); + result.Add(saved); + } + await NameResourcesAsync(result, departmentId); + return result; + } + + private static string GuessResourceType(string unitType) + { + var type = (unitType ?? string.Empty).Trim(); + if (type.Length == 0) return null; + return CalOesMarsAuthorityProfile.Current.ResourceTypes.FirstOrDefault(t => string.Equals(t, type, StringComparison.OrdinalIgnoreCase)) + ?? CalOesMarsAuthorityProfile.Current.ResourceTypes.FirstOrDefault(t => t.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0 || type.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0); + } + + public async Task SaveResourceProfileAsync(CalOesMarsResourceProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (!Enum.IsDefined(typeof(CalOesMarsSubjectTypes), profile.SubjectType)) throw new InvalidOperationException("calmars_resource_subject_invalid"); + if (profile.SubjectType == (int)CalOesMarsSubjectTypes.Unit && !profile.UnitId.HasValue) throw new InvalidOperationException("calmars_resource_unit_required"); + if (profile.SubjectType == (int)CalOesMarsSubjectTypes.External && string.IsNullOrWhiteSpace(profile.ExternalResourceName)) throw new InvalidOperationException("calmars_resource_name_required"); + if (profile.ExpiresOn.HasValue && profile.EffectiveOn.HasValue && profile.ExpiresOn < profile.EffectiveOn) throw new InvalidOperationException("calmars_dates_invalid"); + if (profile.UnitId.HasValue) + { + var unit = await _unitsService.GetUnitByIdAsync(profile.UnitId.Value); + if (unit == null || unit.DepartmentId != profile.DepartmentId) throw new InvalidOperationException("calmars_resource_unit_not_found"); + } + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(profile.CalOesMarsResourceProfileId) ? null : await _resources.GetByIdForDepartmentAsync(profile.CalOesMarsResourceProfileId, profile.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("calmars_resource_not_found"); + var before = existing == null ? null : Snapshot(existing); + var target = existing ?? new CalOesMarsResourceProfile { DepartmentId = profile.DepartmentId, AddedOn = now, AddedByUserId = userId, ReviewState = (int)CalOesMarsReviewStates.Draft }; + target.SubjectType = profile.SubjectType; + target.UnitId = profile.SubjectType == (int)CalOesMarsSubjectTypes.Unit ? profile.UnitId : null; + target.InventoryAssetId = profile.SubjectType == (int)CalOesMarsSubjectTypes.InventoryAsset ? Trim(profile.InventoryAssetId) : null; + target.ExternalResourceName = Trim(profile.ExternalResourceName); + target.MarsResourceId = Trim(profile.MarsResourceId); + target.ResourceType = Trim(profile.ResourceType); + target.ResourceKind = Trim(profile.ResourceKind) ?? "Apparatus"; + target.CodeScheme = Trim(profile.CodeScheme) ?? "MARS-F5"; + target.UnitDesignator = Trim(profile.UnitDesignator); + target.LicensePlate = Trim(profile.LicensePlate); + target.Vin = Trim(profile.Vin); + target.SerialNumber = Trim(profile.SerialNumber); + target.Ownership = Enum.IsDefined(typeof(CalOesMarsOwnerships), profile.Ownership) ? profile.Ownership : (int)CalOesMarsOwnerships.LocalAgency; + target.EffectiveOn = profile.EffectiveOn; + target.ExpiresOn = profile.ExpiresOn; + target.SourceArtifact = Trim(profile.SourceArtifact); + target.SourceChecksum = Trim(profile.SourceChecksum); + if (existing != null) + { + // An edit after an observation needs a fresh look against MARS. + if (existing.ReviewState == (int)CalOesMarsReviewStates.Observed) target.ReviewState = (int)CalOesMarsReviewStates.Reviewed; + else if (profile.ReviewState == (int)CalOesMarsReviewStates.Reviewed || profile.ReviewState == (int)CalOesMarsReviewStates.Draft) target.ReviewState = profile.ReviewState; + target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; + } + else if (profile.ReviewState == (int)CalOesMarsReviewStates.Reviewed) target.ReviewState = profile.ReviewState; + var saved = await _resources.SaveOrUpdateAsync(target, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.CalOesMarsResourceProfileChanged, ipAddress, userAgent, before, saved); + await NameResourcesAsync(new[] { saved }, profile.DepartmentId); + return saved; + } + + public async Task RecordResourceObservationAsync(string resourceProfileId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + var row = await GetResourceProfileAsync(resourceProfileId, departmentId) ?? throw new InvalidOperationException("calmars_resource_not_found"); + var before = Snapshot(row); + row.MarsResourceId = Trim(observation.ExternalId) ?? row.MarsResourceId; + row.ObservedExternalStatus = Trim(observation.ExternalStatus); + row.ObservedOn = observation.ObservedOn ?? DateTime.UtcNow; + row.ReviewState = string.Equals(observation.ExternalStatus, "mismatch", StringComparison.OrdinalIgnoreCase) ? (int)CalOesMarsReviewStates.Mismatch : (int)CalOesMarsReviewStates.Observed; + row.SourceChecksum = Trim(observation.ArtifactChecksum) ?? row.SourceChecksum; + row.RowVersion++; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId; + var saved = await _resources.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsResourceProfileChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task DeleteResourceProfileAsync(string resourceProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await GetResourceProfileAsync(resourceProfileId, departmentId); + if (row == null) return false; + var before = Snapshot(row); + row.IsDeleted = true; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId; + await _resources.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsResourceProfileChanged, ipAddress, userAgent, before, row); + return true; + } + + private async Task NameResourcesAsync(IEnumerable rows, int departmentId) + { + var list = rows?.ToList() ?? new List(); + if (list.Count == 0) return; + Dictionary units = null; + foreach (var row in list) + { + if (row.UnitId.HasValue) + { + units ??= (await _unitsService.GetUnitsForDepartmentAsync(departmentId))?.ToDictionary(u => u.UnitId, u => u.Name) ?? new Dictionary(); + row.SubjectName = units.TryGetValue(row.UnitId.Value, out var name) ? name : row.UnitDesignator; + } + else row.SubjectName = row.ExternalResourceName ?? row.UnitDesignator ?? row.InventoryAssetId; + } + } + + #endregion + + #region Annual rate profiles + + public async Task> GetRateProfilesAsync(int departmentId, int? submissionYear = null) => + (await _rateProfiles.GetForDepartmentAsync(departmentId, submissionYear))?.ToList() ?? new List(); + + public async Task GetRateProfileAsync(string rateProfileId, int departmentId) + { + if (string.IsNullOrWhiteSpace(rateProfileId)) return null; + var profile = await _rateProfiles.GetByIdForDepartmentAsync(rateProfileId, departmentId); + if (profile == null || profile.IsDeleted) return null; + profile.Lines = (await _rateLines.GetByProfileAsync(rateProfileId))?.ToList() ?? new List(); + profile.AdministrativeInputs = (await _adminInputs.GetByProfileAsync(rateProfileId))?.ToList() ?? new List(); + return profile; + } + + public async Task SaveRateProfileAsync(CalOesMarsRateProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (profile.SubmissionYear < 2000 || profile.SubmissionYear > 2100) throw new InvalidOperationException("calmars_rate_year_invalid"); + if (!Enum.IsDefined(typeof(CalOesMarsSubmissionTypes), profile.SubmissionType)) throw new InvalidOperationException("calmars_rate_type_invalid"); + if (profile.ExpiresOn.HasValue && profile.EffectiveOn.HasValue && profile.ExpiresOn < profile.EffectiveOn) throw new InvalidOperationException("calmars_dates_invalid"); + if (profile.AdministrativeRateValue.HasValue && (profile.AdministrativeRateValue < 0 || profile.AdministrativeRateValue > 100)) throw new InvalidOperationException("calmars_rate_percent_invalid"); + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(profile.CalOesMarsRateProfileId) ? null : await _rateProfiles.GetByIdForDepartmentAsync(profile.CalOesMarsRateProfileId, profile.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("calmars_rate_not_found"); + if (existing != null && !existing.IsEditable) throw new InvalidOperationException("calmars_rate_locked"); + var before = existing == null ? null : Snapshot(existing); + var target = existing ?? new CalOesMarsRateProfile { DepartmentId = profile.DepartmentId, Status = (int)CalOesMarsRateProfileStatuses.Draft, AddedOn = now, AddedByUserId = userId, AuthorityProfileCode = CalOesMarsAuthorityProfile.Current.Code }; + target.SubmissionYear = profile.SubmissionYear; + target.SubmissionType = profile.SubmissionType; + target.EffectiveOn = profile.EffectiveOn ?? new DateTime(profile.SubmissionYear, 1, 1); + target.ExpiresOn = profile.ExpiresOn; + target.BaseRateAccepted = profile.BaseRateAccepted; + target.AdministrativeRateMethod = Enum.IsDefined(typeof(CalOesMarsAdministrativeRateMethods), profile.AdministrativeRateMethod) ? profile.AdministrativeRateMethod : (int)CalOesMarsAdministrativeRateMethods.None; + target.AdministrativeRateValue = target.AdministrativeRateMethod == (int)CalOesMarsAdministrativeRateMethods.None ? null : profile.AdministrativeRateValue; + target.AuthorityProfileCode = Trim(profile.AuthorityProfileCode) ?? target.AuthorityProfileCode; + target.SourceUrl = Trim(profile.SourceUrl); + target.SourceDate = profile.SourceDate; + target.SourceArtifact = Trim(profile.SourceArtifact); + target.SourceChecksum = Trim(profile.SourceChecksum); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _rateProfiles.SaveOrUpdateAsync(target, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.CalOesMarsRateProfileChanged, ipAddress, userAgent, before, saved); + return await GetRateProfileAsync(saved.CalOesMarsRateProfileId, profile.DepartmentId); + } + + public async Task SaveRateLinesAsync(string rateProfileId, int departmentId, List lines, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found"); + if (!profile.IsEditable) throw new InvalidOperationException("calmars_rate_locked"); + var before = Snapshot(profile); + var incoming = (lines ?? new List()).Where(l => l != null).ToList(); + foreach (var line in incoming) + { + if (!Enum.IsDefined(typeof(CalOesMarsRateLineKinds), line.LineKind)) throw new InvalidOperationException("calmars_rate_line_kind_invalid"); + if (!Enum.IsDefined(typeof(CalOesMarsRateBases), line.Basis)) throw new InvalidOperationException("calmars_rate_line_basis_invalid"); + if ((line.StraightRate ?? 0) < 0 || (line.OvertimeRate ?? 0) < 0) throw new InvalidOperationException("calmars_rate_line_rate_invalid"); + var salary = line.LineKind is (int)CalOesMarsRateLineKinds.SalarySurvey or (int)CalOesMarsRateLineKinds.AttachmentANonSuppression; + if (salary && string.IsNullOrWhiteSpace(line.ClassificationCode)) throw new InvalidOperationException("calmars_rate_line_classification_required"); + if (!salary && line.LineKind != (int)CalOesMarsRateLineKinds.AdministrativeRate && line.LineKind != (int)CalOesMarsRateLineKinds.PrivatelyOwnedVehicle && line.LineKind != (int)CalOesMarsRateLineKinds.MealLodgingIncidentals && string.IsNullOrWhiteSpace(line.ResourceCode) && string.IsNullOrWhiteSpace(line.FemaCode)) + throw new InvalidOperationException("calmars_rate_line_resource_required"); + } + await TransactionAsync(async () => + { + var current = profile.Lines.ToDictionary(l => l.CalOesMarsRateLineId, StringComparer.OrdinalIgnoreCase); + var kept = new HashSet(incoming.Where(l => !string.IsNullOrWhiteSpace(l.CalOesMarsRateLineId) && current.ContainsKey(l.CalOesMarsRateLineId)).Select(l => l.CalOesMarsRateLineId), StringComparer.OrdinalIgnoreCase); + var now = DateTime.UtcNow; + foreach (var stale in current.Values.Where(l => !kept.Contains(l.CalOesMarsRateLineId))) + { + stale.IsDeleted = true; stale.EditedOn = now; stale.EditedByUserId = userId; + await _rateLines.SaveOrUpdateAsync(stale, cancellationToken); + } + var sort = 0; + foreach (var line in incoming) + { + var existing = !string.IsNullOrWhiteSpace(line.CalOesMarsRateLineId) && current.TryGetValue(line.CalOesMarsRateLineId, out var found) ? found : null; + var target = existing ?? new CalOesMarsRateLine { CalOesMarsRateProfileId = rateProfileId, DepartmentId = departmentId, AddedOn = now, AddedByUserId = userId }; + target.LineKind = line.LineKind; + target.ClassificationCode = Trim(line.ClassificationCode); + target.ResourceCode = Trim(line.ResourceCode); + target.FemaCode = Trim(line.FemaCode); + target.Description = Trim(line.Description); + target.Basis = line.Basis; + target.StraightRate = line.StraightRate; + target.OvertimeRate = line.OvertimeRate; + target.IncludesWorkersComp = line.IncludesWorkersComp; + target.IncludesUnemploymentInsurance = line.IncludesUnemploymentInsurance; + target.PortalToPortalEligible = line.PortalToPortalEligible; + target.OvertimeEligible = line.OvertimeEligible; + target.Authority = Enum.IsDefined(typeof(CalOesMarsRateAuthorities), line.Authority) ? line.Authority : (int)CalOesMarsRateAuthorities.AgencySubmitted; + target.SourceInputVersions = Trim(line.SourceInputVersions); + target.SourceArtifact = Trim(line.SourceArtifact); + target.SortOrder = sort++; + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + await _rateLines.SaveOrUpdateAsync(target, cancellationToken); + } + profile.RowVersion++; profile.EditedOn = now; profile.EditedByUserId = userId; + await _rateProfiles.SaveOrUpdateAsync(profile, cancellationToken); + return true; + }, cancellationToken); + var reloaded = await GetRateProfileAsync(rateProfileId, departmentId); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsRateProfileChanged, ipAddress, userAgent, before, reloaded); + return reloaded; + } + + public async Task SaveAdministrativeInputsAsync(string rateProfileId, int departmentId, List inputs, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found"); + if (!profile.IsEditable) throw new InvalidOperationException("calmars_rate_locked"); + var before = Snapshot(profile); + var incoming = (inputs ?? new List()).Where(i => i != null).ToList(); + foreach (var input in incoming) + { + if (!Enum.IsDefined(typeof(CalOesMarsCostClassifications), input.Classification)) throw new InvalidOperationException("calmars_admin_input_classification_invalid"); + if (input.FiscalYear < 2000 || input.FiscalYear > profile.SubmissionYear) throw new InvalidOperationException("calmars_admin_input_year_invalid"); + if (input.Amount < 0) throw new InvalidOperationException("calmars_admin_input_amount_invalid"); + } + await TransactionAsync(async () => + { + var current = profile.AdministrativeInputs.ToDictionary(i => i.CalOesMarsAdministrativeRateInputId, StringComparer.OrdinalIgnoreCase); + var kept = new HashSet(incoming.Where(i => !string.IsNullOrWhiteSpace(i.CalOesMarsAdministrativeRateInputId) && current.ContainsKey(i.CalOesMarsAdministrativeRateInputId)).Select(i => i.CalOesMarsAdministrativeRateInputId), StringComparer.OrdinalIgnoreCase); + var now = DateTime.UtcNow; + foreach (var stale in current.Values.Where(i => !kept.Contains(i.CalOesMarsAdministrativeRateInputId))) + { + stale.IsDeleted = true; stale.EditedOn = now; stale.EditedByUserId = userId; + await _adminInputs.SaveOrUpdateAsync(stale, cancellationToken); + } + foreach (var input in incoming) + { + var existing = !string.IsNullOrWhiteSpace(input.CalOesMarsAdministrativeRateInputId) && current.TryGetValue(input.CalOesMarsAdministrativeRateInputId, out var found) ? found : null; + var target = existing ?? new CalOesMarsAdministrativeRateInput { CalOesMarsRateProfileId = rateProfileId, DepartmentId = departmentId, AddedOn = now, AddedByUserId = userId }; + target.FiscalYear = input.FiscalYear; + target.FunctionCode = Trim(input.FunctionCode); + target.CategoryCode = Trim(input.CategoryCode); + target.CategoryProfileVersion = Trim(input.CategoryProfileVersion) ?? CalOesMarsAuthorityProfile.Current.Code; + target.Classification = input.Classification; + target.ActualAmount = input.Amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); + target.SourceSystem = Trim(input.SourceSystem); + target.SourceLine = Trim(input.SourceLine); + target.IncidentDirectExclusion = input.IncidentDirectExclusion; + target.DoubleCountMarker = input.DoubleCountMarker; + target.ReviewStatus = Enum.IsDefined(typeof(CalOesMarsInputReviewStatuses), input.ReviewStatus) ? input.ReviewStatus : (int)CalOesMarsInputReviewStatuses.Pending; + target.ReviewReason = Trim(input.ReviewReason); + target.SourceArtifact = Trim(input.SourceArtifact); + if (existing != null) { target.InputVersion = existing.InputVersion + 1; target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + await _adminInputs.SaveOrUpdateAsync(target, cancellationToken); + } + return true; + }, cancellationToken); + var reloaded = await GetRateProfileAsync(rateProfileId, departmentId); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsRateProfileChanged, ipAddress, userAgent, before, reloaded); + return reloaded; + } + + public async Task BuildAdministrativeRateDraftAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found"); + var authority = CalOesMarsAuthorityProfile.Get(profile.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; + var draft = BuildAdministrativeRateDraft(profile, authority); + if (profile.IsEditable && draft.IsReady) + { + var before = Snapshot(profile); + profile.AdministrativeRateMethod = draft.MethodChosen; + profile.AdministrativeRateValue = draft.ChosenPercent; + profile.RowVersion++; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _rateProfiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsRateDraftBuilt, ipAddress, userAgent, before, profile); + } + return draft; + } + + /// Allowable indirect ÷ allowable direct from the reviewed inputs (pure; the de-minimis option comes from the authority profile). + public static CalOesMarsAdministrativeRateDraft BuildAdministrativeRateDraft(CalOesMarsRateProfile profile, CalOesMarsAuthorityProfile authority) + { + var draft = new CalOesMarsAdministrativeRateDraft { RateProfileId = profile.CalOesMarsRateProfileId, DeMinimisPercent = authority.DeMinimisAdministrativePercent }; + var inputs = (profile.AdministrativeInputs ?? new List()).Where(i => !i.IsDeleted).ToList(); + if (inputs.Count == 0) draft.Blockers.Add("no_inputs"); + if (inputs.Any(i => i.DoubleCountMarker && i.ReviewStatus == (int)CalOesMarsInputReviewStatuses.Pending)) draft.Blockers.Add("double_count_unresolved"); + if (inputs.Any(i => i.ReviewStatus == (int)CalOesMarsInputReviewStatuses.Pending && !i.DoubleCountMarker)) draft.Blockers.Add("inputs_pending_review"); + foreach (var input in inputs.Where(i => i.ReviewStatus == (int)CalOesMarsInputReviewStatuses.Accepted)) + { + if (input.IncidentDirectExclusion) { draft.ExcludedIncidentDirect += input.Amount; continue; } + switch ((CalOesMarsCostClassifications)input.Classification) + { + case CalOesMarsCostClassifications.Direct: draft.AllowableDirect += input.Amount; break; + case CalOesMarsCostClassifications.Indirect: draft.AllowableIndirect += input.Amount; break; + default: draft.ExcludedUnallowable += input.Amount; break; + } + } + if (draft.AllowableDirect > 0) draft.CalculatedPercent = Math.Round(draft.AllowableIndirect / draft.AllowableDirect * 100m, 4, MidpointRounding.AwayFromZero); + else if (inputs.Count > 0) draft.Blockers.Add("no_direct_base"); + if (draft.IsReady) + { + // The larger allowable option is recorded; a department may still override the method on the profile. + var useCalculated = draft.CalculatedPercent.HasValue && draft.CalculatedPercent.Value > draft.DeMinimisPercent; + draft.MethodChosen = (int)(useCalculated ? CalOesMarsAdministrativeRateMethods.Calculated : CalOesMarsAdministrativeRateMethods.DeMinimis); + draft.ChosenPercent = useCalculated ? draft.CalculatedPercent : draft.DeMinimisPercent; + } + else if (inputs.Count == 0) + { + draft.MethodChosen = (int)CalOesMarsAdministrativeRateMethods.DeMinimis; + draft.ChosenPercent = draft.DeMinimisPercent; + } + return draft; + } + + public async Task SetRateProfileStatusAsync(string rateProfileId, int departmentId, CalOesMarsRateProfileStatuses status, string signedByName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found"); + if (!IsValidRateTransition((CalOesMarsRateProfileStatuses)profile.Status, status)) throw new InvalidOperationException("calmars_rate_transition_invalid"); + if (status == CalOesMarsRateProfileStatuses.SignedLocally && string.IsNullOrWhiteSpace(signedByName)) throw new InvalidOperationException("calmars_rate_signer_required"); + if (status == CalOesMarsRateProfileStatuses.Reviewed && profile.SubmissionType != (int)CalOesMarsSubmissionTypes.RateLetter && !profile.BaseRateAccepted && profile.Lines.Count == 0 && profile.AdministrativeRateMethod == (int)CalOesMarsAdministrativeRateMethods.None) + throw new InvalidOperationException("calmars_rate_empty"); + var before = Snapshot(profile); + profile.Status = (int)status; + if (status == CalOesMarsRateProfileStatuses.SignedLocally) { profile.SignedOn = DateTime.UtcNow; profile.SignedByName = signedByName.Trim(); } + profile.RowVersion++; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _rateProfiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsRateReviewed, ipAddress, userAgent, before, profile); + return profile; + } + + public static bool IsValidRateTransition(CalOesMarsRateProfileStatuses from, CalOesMarsRateProfileStatuses to) => (from, to) switch + { + (CalOesMarsRateProfileStatuses.Draft, CalOesMarsRateProfileStatuses.Reviewed) => true, + (CalOesMarsRateProfileStatuses.Reviewed, CalOesMarsRateProfileStatuses.Draft) => true, + (CalOesMarsRateProfileStatuses.Reviewed, CalOesMarsRateProfileStatuses.SignedLocally) => true, + (CalOesMarsRateProfileStatuses.SignedLocally, CalOesMarsRateProfileStatuses.Reviewed) => true, + (CalOesMarsRateProfileStatuses.SignedLocally, CalOesMarsRateProfileStatuses.SubmittedExternal) => true, + (CalOesMarsRateProfileStatuses.SubmittedExternal, CalOesMarsRateProfileStatuses.Accepted) => true, + (CalOesMarsRateProfileStatuses.SubmittedExternal, CalOesMarsRateProfileStatuses.Reviewed) => true, + (_, CalOesMarsRateProfileStatuses.Superseded) => from != CalOesMarsRateProfileStatuses.Superseded, + _ => false + }; + + public async Task RecordRateProfileObservationAsync(string rateProfileId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found"); + if (profile.Status < (int)CalOesMarsRateProfileStatuses.SignedLocally) throw new InvalidOperationException("calmars_rate_not_signed"); + var before = Snapshot(profile); + profile.ObservedExternalStatus = Trim(observation.ExternalStatus); + profile.ObservedOn = observation.ObservedOn ?? DateTime.UtcNow; + profile.SourceChecksum = Trim(observation.ArtifactChecksum) ?? profile.SourceChecksum; + if (string.Equals(observation.ExternalStatus, "Accepted", StringComparison.OrdinalIgnoreCase) || string.Equals(observation.ExternalStatus, "Approved", StringComparison.OrdinalIgnoreCase)) profile.Status = (int)CalOesMarsRateProfileStatuses.Accepted; + else if (profile.Status == (int)CalOesMarsRateProfileStatuses.SignedLocally) profile.Status = (int)CalOesMarsRateProfileStatuses.SubmittedExternal; + profile.RowVersion++; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _rateProfiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsExternalStatusObserved, ipAddress, userAgent, before, profile); + return profile; + } + + public async Task DeleteRateProfileAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await GetRateProfileAsync(rateProfileId, departmentId); + if (profile == null) return false; + if (!profile.IsEditable) throw new InvalidOperationException("calmars_rate_locked"); + var before = Snapshot(profile); + profile.IsDeleted = true; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _rateProfiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsRateProfileChanged, ipAddress, userAgent, before, profile); + return true; + } + + #endregion + + #region Agreements + + public async Task> GetAgreementsAsync(int departmentId) => (await _agreements.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + + public async Task GetAgreementAsync(string agreementSnapshotId, int departmentId) + { + if (string.IsNullOrWhiteSpace(agreementSnapshotId)) return null; + var row = await _agreements.GetByIdForDepartmentAsync(agreementSnapshotId, departmentId); + return row == null || row.IsDeleted ? null : row; + } + + public async Task SaveAgreementAsync(CalOesMarsAgreementSnapshot agreement, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (agreement == null) throw new ArgumentNullException(nameof(agreement)); + if (!Enum.IsDefined(typeof(CalOesMarsDocumentKinds), agreement.DocumentKind)) throw new InvalidOperationException("calmars_agreement_kind_invalid"); + if (!Enum.IsDefined(typeof(CalOesMarsCompensationMethods), agreement.CompensationMethod)) throw new InvalidOperationException("calmars_agreement_method_invalid"); + if (!Enum.IsDefined(typeof(CalOesMarsOvertimeMethods), agreement.OvertimeMethod)) throw new InvalidOperationException("calmars_agreement_overtime_invalid"); + if (agreement.EndOn.HasValue && agreement.StartOn.HasValue && agreement.EndOn < agreement.StartOn) throw new InvalidOperationException("calmars_dates_invalid"); + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(agreement.CalOesMarsAgreementSnapshotId) ? null : await _agreements.GetByIdForDepartmentAsync(agreement.CalOesMarsAgreementSnapshotId, agreement.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("calmars_agreement_not_found"); + var before = existing == null ? null : Snapshot(existing); + // A snapshot referenced by an external work item is immutable: the edit becomes a new version. + var referenced = existing != null && (await _workItems.GetActionQueueAsync(agreement.DepartmentId))?.Any(w => w.AgreementSnapshotId == existing.CalOesMarsAgreementSnapshotId && w.IsExternal) == true; + var target = existing == null || referenced ? new CalOesMarsAgreementSnapshot { DepartmentId = agreement.DepartmentId, AddedOn = now, AddedByUserId = userId, RowVersion = referenced ? existing.RowVersion + 1 : 1 } : existing; + target.ClassificationCode = Trim(agreement.ClassificationCode); + target.ClassificationTitle = Trim(agreement.ClassificationTitle); + target.DocumentKind = agreement.DocumentKind; + target.CompensationMethod = agreement.CompensationMethod; + target.OvertimeMethod = agreement.OvertimeMethod; + target.StartOn = agreement.StartOn; + target.EndOn = agreement.EndOn; + target.AttachmentId = agreement.AttachmentId; + target.AttachmentChecksum = Trim(agreement.AttachmentChecksum); + target.SourceArtifact = Trim(agreement.SourceArtifact); + target.SourceChecksum = Trim(agreement.SourceChecksum); + if (ReferenceEquals(target, existing)) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _agreements.SaveOrUpdateAsync(target, cancellationToken); + if (referenced) + { + existing.EndOn = existing.EndOn ?? now.Date; existing.EditedOn = now; existing.EditedByUserId = userId; + await _agreements.SaveOrUpdateAsync(existing, cancellationToken); + } + Audit(agreement.DepartmentId, userId, AuditLogTypes.CalOesMarsAgreementChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task RecordAgreementObservationAsync(string agreementSnapshotId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (observation == null) throw new ArgumentNullException(nameof(observation)); + var row = await GetAgreementAsync(agreementSnapshotId, departmentId) ?? throw new InvalidOperationException("calmars_agreement_not_found"); + var before = Snapshot(row); + row.ExternalApprovalStatus = Trim(observation.ExternalStatus); + row.ObservedOn = observation.ObservedOn ?? DateTime.UtcNow; + row.AttachmentChecksum = Trim(observation.ArtifactChecksum) ?? row.AttachmentChecksum; + row.RowVersion++; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId; + var saved = await _agreements.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsAgreementObserved, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task DeleteAgreementAsync(string agreementSnapshotId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await GetAgreementAsync(agreementSnapshotId, departmentId); + if (row == null) return false; + if ((await _workItems.GetActionQueueAsync(departmentId))?.Any(w => w.AgreementSnapshotId == row.CalOesMarsAgreementSnapshotId) == true) throw new InvalidOperationException("calmars_agreement_in_use"); + var before = Snapshot(row); + row.IsDeleted = true; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId; + await _agreements.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.CalOesMarsAgreementChanged, ipAddress, userAgent, before, row); + return true; + } + + public async Task SelectAgreementAsync(int departmentId, string classificationCode, DateTime dispatchOn) + { + var candidates = (await _agreements.GetForDepartmentAsync(departmentId))?.Where(a => a.CoversDate(dispatchOn)).ToList() ?? new List(); + return candidates.Where(a => !string.IsNullOrWhiteSpace(classificationCode) && string.Equals(a.ClassificationCode, classificationCode, StringComparison.OrdinalIgnoreCase)).OrderByDescending(a => a.StartOn ?? DateTime.MinValue).FirstOrDefault() + ?? candidates.Where(a => string.IsNullOrWhiteSpace(a.ClassificationCode)).OrderByDescending(a => a.StartOn ?? DateTime.MinValue).FirstOrDefault(); + } + + #endregion + + #region Helpers + + /// Runs the action in the scope's transaction, joining one the caller already opened (tests pass no unit of work and run unwrapped). + private async Task TransactionAsync(Func> action, CancellationToken cancellationToken) + { + if (_unitOfWork == null || _unitOfWork.Transaction != null) return await action(); + try + { + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); + var result = await action(); + _unitOfWork.CommitChanges(); + return result; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + + 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 CalOesMarsRateProfile profile: profile.Lines = null; profile.AdministrativeInputs = null; break; + case CalOesMarsWorkItem item: item.Lines = null; item.SnapshotJson = item.SnapshotJson == null ? null : "sha256:" + Sha256(item.SnapshotJson); break; + } + return clone.CloneJsonToString(); + } + + internal static string Sha256(string value) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty))).ToLowerInvariant(); + } + + 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/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs index 5b74e7e9..05947406 100644 --- a/Core/Resgrid.Services/Invoicing/BidsService.cs +++ b/Core/Resgrid.Services/Invoicing/BidsService.cs @@ -82,10 +82,17 @@ public async Task> GetBidsForDepartmentAsync(int departmentId, BidStat 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) + public async Task> GetBidsByContactIdAsync(string contactId, int departmentId, int skip = 0, int take = 100) { if (string.IsNullOrWhiteSpace(contactId)) return new List(); - return (await _bids.GetByContactIdAsync(departmentId, contactId))?.ToList() ?? new List(); + return (await _bids.GetByContactIdAsync(departmentId, contactId, skip, take))?.ToList() ?? new List(); + } + + public async Task> GetBidsForContractAsync(string serviceContractId, int departmentId) + { + if (string.IsNullOrWhiteSpace(serviceContractId)) return new List(); + // The contract query is keyed on the contract id alone; the department filter keeps a guessed id from another department's page. + return (await _bids.GetByContractAsync(serviceContractId))?.Where(b => b.DepartmentId == departmentId).ToList() ?? new List(); } public Task GetBidByIdAsync(string bidId, int departmentId) => LoadAsync(bidId, departmentId); diff --git a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs index ad0d291a..b8120b17 100644 --- a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs +++ b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs @@ -258,8 +258,11 @@ public async Task SendDeploymentInvoiceAsync(string invoiceId, int depa 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(); + // One system-wide read, split on ApprovedOn (the repository already excludes null ApprovedOn and uses an inclusive bound). + var cutoff = asOfUtc.AddDays(-Math.Max(0, unbilledDays)); + var unbilled = (await _reports.GetUnbilledApprovedBeforeAsync(asOfUtc))?.ToList() ?? new List(); + var stale = unbilled.Where(r => r.ApprovedOn.HasValue && r.ApprovedOn.Value <= cutoff).ToList(); + var recent = unbilled.Where(r => !r.ApprovedOn.HasValue || r.ApprovedOn.Value > cutoff).ToList(); var notified = 0; var dayKey = asOfUtc.Date.GetHashCode(); foreach (var group in stale.Concat(recent).GroupBy(r => r.DepartmentId)) diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs index 075bd447..e3bc34d5 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -117,6 +117,14 @@ public async Task> GetDeploymentsForDepartmentAsync(int departm public Task CountDeploymentsForDepartmentAsync(int departmentId, bool openOnly) => _deployments.CountForDepartmentAsync(departmentId, openOnly); + public async Task> GetDeploymentsForContractAsync(string serviceContractId, int departmentId) + { + if (string.IsNullOrWhiteSpace(serviceContractId)) return new List(); + var deployments = (await _deployments.GetByContractAsync(departmentId, serviceContractId))?.ToList() ?? new List(); + await ResolveDeploymentsAsync(deployments, departmentId); + return deployments; + } + public async Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly) { var rows = (await _personnel.GetForUserAsync(departmentId, userId))?.ToList() ?? new List(); diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs index e0c47f46..be5db91f 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs @@ -94,7 +94,7 @@ 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) - + (useCallerAttachment && attachment.Contents.Count > 0 ? " The packet also contains: " + string.Join("; ", attachment.Contents) + "." : 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 }; diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 547114a2..c86d11ac 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -743,6 +743,15 @@ void Prevention(string table, string column, ProtectedFieldClassification classi list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ContactsFamily, table, column, binary ? ProtectedFieldStorageKind.Binary : ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedContactData, PermissionTypes.ViewProtectedContactData, Resgrid.Model.Invoicing.DeploymentProtectedFields.CatalogVersion)); + + // Workforce & Business Operations plan, Phase E (catalog 28, registered with M0220–M0224): everything that identifies + // or prices a person — employer / affiliate / contractor identifiers and addresses, external worker keys, compensation, + // pay and cost components, approved payroll cost, annual earnings, demographic responses, report snapshots, aggregate + // rows, remarks and export files (Personnel family). Hours, dates, codes, counts and run totals stay metadata. + foreach (var (table, column, binary) in Resgrid.Model.Workforce.WorkforceProtectedFields.All()) + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", PersonnelFamily, table, column, + binary ? ProtectedFieldStorageKind.Binary : ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Pii, + PermissionTypes.ViewProtectedPersonnelData, PermissionTypes.ViewProtectedPersonnelData, Resgrid.Model.Workforce.WorkforceProtectedFields.CatalogVersion)); return list; } } diff --git a/Core/Resgrid.Services/Search/SystemActionCatalog.cs b/Core/Resgrid.Services/Search/SystemActionCatalog.cs index 39b56364..a2d3e46f 100644 --- a/Core/Resgrid.Services/Search/SystemActionCatalog.cs +++ b/Core/Resgrid.Services/Search/SystemActionCatalog.cs @@ -15,6 +15,7 @@ public static class SystemActionCatalog public const string View = "View"; public const string Create = "Create"; public const string Update = "Update"; + public const string Reconcile = "Reconcile"; // ResgridClaimTypes.Resources private const string Call = "Call"; @@ -37,6 +38,11 @@ public static class SystemActionCatalog private const string Deployments = "Deployments"; private const string Bids = "Bids"; private const string ServiceContracts = "ServiceContracts"; + private const string MutualAidReimbursement = "MutualAidReimbursement"; + private const string Workforce = "Workforce"; + private const string WorkforceCompensation = "WorkforceCompensation"; + private const string InternalCosts = "InternalCosts"; + private const string PayDataReporting = "PayDataReporting"; private const string Group = "Group"; private const string Protocols = "Protocols"; private const string Forms = "Forms"; @@ -167,6 +173,19 @@ private static SystemActionDefinition Act(string key, string title, string descr 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), + // Cal OES MARS cost recovery (Phase C-M3): readiness, annual rates, the incident action queue and reconciliation. Every CalOesMars* table stays out of the search index (decision 41). + Nav("cal-oes-mars", "Cal OES MARS", "CFAA cost recovery readiness: agency, F-5 resources, annual rates and agreements", "/User/CalOesMars", new[] { "mars", "cal oes", "cfaa", "mutual aid reimbursement", "cost recovery", "f-42", "f42" }, MutualAidReimbursement, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CalOesMars), + Nav("cal-oes-mars-queue", "MARS Action Queue", "F-42 and expense claims to prepare, validate and hand off to the MARS portal", "/User/CalOesMars/Queue", new[] { "mars queue", "f-42", "expense claim", "mars handoff" }, MutualAidReimbursement, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CalOesMars), + Nav("cal-oes-mars-rates", "MARS Annual Rates", "Salary Survey, Attachment A, Administrative Rate, Rate Letter and Special Equipment snapshots", "/User/CalOesMars/Rates", new[] { "salary survey", "administrative rate", "rate letter", "attachment a", "special equipment" }, MutualAidReimbursement, Update, SystemActionModules.BusinessOperations, FeatureFlagKeys.CalOesMars), + Nav("cal-oes-mars-reconciliation", "MARS Reconciliation", "Observed MARS invoices, local approval and payment reconciliation", "/User/CalOesMars/Reconciliation", new[] { "mars invoice", "reconciliation", "paying entity", "cfaa payment" }, MutualAidReimbursement, Reconcile, SystemActionModules.BusinessOperations, FeatureFlagKeys.CalOesMars), + // Workforce pay data, field costing and California pay data reporting (Phase E). Every Workforce* / Employee* / PayData* / FieldCost* table stays out of the search index. + Nav("workforce", "Workforce", "Employer identity, establishments, workers, employments and job assignments", "/User/Workforce", new[] { "workforce", "employer", "establishment", "employment", "job assignment", "labor contractor" }, Workforce, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.WorkforceInternalCosting), + Nav("workforce-compensation", "Compensation Profiles", "Employee, role-default and department-default compensation with pay and employer-cost components", "/User/Workforce/Compensation", new[] { "compensation", "pay rate", "hourly rate", "salary", "employer cost", "benefits", "overtime multiplier" }, WorkforceCompensation, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.WorkforceInternalCosting), + Nav("workforce-annual-facts", "Annual Pay Facts", "W-2 earnings and hours per employment for pay data reporting, with CSV import", "/User/Workforce/AnnualFacts", new[] { "w-2", "w2", "annual earnings", "pay facts", "import" }, WorkforceCompensation, Update, SystemActionModules.BusinessOperations, FeatureFlagKeys.WorkforceInternalCosting), + Nav("resource-costs", "Resource Cost Profiles", "Depreciation, fuel, maintenance and fixed costs per unit or asset", "/User/Workforce/ResourceCosts", new[] { "resource cost", "depreciation", "fuel", "maintenance cost", "vehicle cost", "engine hour" }, InternalCosts, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.WorkforceInternalCosting), + Nav("cost-runs", "Field Cost Runs", "Internal loaded cost and margin for bids, calls and deployments", "/User/Workforce/CostRuns", new[] { "cost run", "margin", "loaded cost", "break even", "bid estimate", "deployment cost" }, InternalCosts, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.WorkforceInternalCosting), + Nav("pay-data-reporting", "California Pay Data Reporting", "CRD pay data report runs: snapshots, aggregation, validation, export and the portal worksheet", "/User/Workforce/PayData", new[] { "pay data", "crd", "california", "12999", "pay data report", "demographics" }, PayDataReporting, View, SystemActionModules.BusinessOperations, FeatureFlagKeys.CaliforniaPayDataReporting), + Nav("my-demographics", "My Demographic Response", "Your voluntary self-identification for California pay data reporting", "/User/Workforce/MyDemographics", new[] { "self-identification", "demographics", "race", "ethnicity", "sex" }, flag: FeatureFlagKeys.CaliforniaPayDataReporting), // ---- 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 c056e23c..d04131dc 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -49,6 +49,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M3: Cal OES MARS cost recovery (manual gateway only). + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase E: protected workforce pay data, field costing and California pay data reporting. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().As().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs b/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs new file mode 100644 index 00000000..20ceb025 --- /dev/null +++ b/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs @@ -0,0 +1,695 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +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.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.Workforce +{ + /// + /// The California CRD pay data report wizard (plan E3/E5): create → build employee snapshots → aggregate → validate + /// → freeze and export → attest externally. Everything a person could be identified or priced by (demographic + /// code, earnings, hourly rates, employer identity, remarks, export bytes) rides ADP catalog 28; the engine decrypts + /// through the pay-data-reporting workload purpose, users through their grant. A frozen run is immutable — + /// a correction supersedes it. Resgrid never files: the artifacts and worksheet are what the officer carries to the + /// CRD portal, and "certified" is only ever an observation the officer records. + /// + public class CaPayDataReportingService : ICaPayDataReportingService + { + private static readonly HashSet RemindedToday = new HashSet(); + + private readonly IPayDataReportRunRepository _runs; + private readonly IPayDataReportEmployeeSnapshotRepository _snapshots; + private readonly IPayDataReportRowRepository _rows; + private readonly IPayDataExportArtifactRepository _artifacts; + private readonly IWorkforceEmployerProfileRepository _employers; + private readonly IWorkforceAffiliatedEntityRepository _affiliates; + private readonly IWorkforceEstablishmentRepository _establishments; + private readonly IWorkforceLaborContractorRepository _contractors; + private readonly IWorkforceWorkerRepository _workers; + private readonly IWorkforceEmploymentRepository _employments; + private readonly IWorkforceJobAssignmentRepository _assignments; + private readonly IWorkforceWorkEntryRepository _workEntries; + private readonly IWorkforceAnnualPayFactRepository _annualFacts; + private readonly IPayDataReportingDemographicRepository _demographics; + private readonly IUserProfileService _userProfileService; + private readonly IDepartmentsService _departmentsService; + private readonly Lazy _communication; + private readonly Lazy _departmentSettings; + private readonly IEventAggregator _eventAggregator; + private readonly WorkforceProtectionSeam _seam; + + public CaPayDataReportingService(IPayDataReportRunRepository runs, IPayDataReportEmployeeSnapshotRepository snapshots, IPayDataReportRowRepository rows, IPayDataExportArtifactRepository artifacts, + IWorkforceEmployerProfileRepository employers, IWorkforceAffiliatedEntityRepository affiliates, IWorkforceEstablishmentRepository establishments, IWorkforceLaborContractorRepository contractors, + IWorkforceWorkerRepository workers, IWorkforceEmploymentRepository employments, IWorkforceJobAssignmentRepository assignments, IWorkforceWorkEntryRepository workEntries, IWorkforceAnnualPayFactRepository annualFacts, + IPayDataReportingDemographicRepository demographics, IUserProfileService userProfileService, IDepartmentsService departmentsService, Lazy communication, Lazy departmentSettings, + IEventAggregator eventAggregator, Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + { + _runs = runs; + _snapshots = snapshots; + _rows = rows; + _artifacts = artifacts; + _employers = employers; + _affiliates = affiliates; + _establishments = establishments; + _contractors = contractors; + _workers = workers; + _employments = employments; + _assignments = assignments; + _workEntries = workEntries; + _annualFacts = annualFacts; + _demographics = demographics; + _userProfileService = userProfileService; + _departmentsService = departmentsService; + _communication = communication; + _departmentSettings = departmentSettings; + _eventAggregator = eventAggregator; + _seam = new WorkforceProtectionSeam(protectedWrite, protectedRead, grant); + } + + #region Reads + + public async Task> GetRunsAsync(int departmentId, int? reportingYear = null) => + (await _runs.GetForDepartmentAsync(departmentId, reportingYear))?.Where(r => !r.IsDeleted).OrderByDescending(r => r.ReportingYear).ThenByDescending(r => r.AddedOn).ToList() ?? new List(); + + public async Task GetRunAsync(string runId, int departmentId) + { + if (string.IsNullOrWhiteSpace(runId)) return null; + var run = await _runs.GetByIdForDepartmentAsync(runId, departmentId); + if (run == null || run.IsDeleted) return null; + await _seam.ResolveForReadAsync(new[] { run }, departmentId, WorkforceProtectedFields.ReportRun); + return run; + } + + public async Task> GetSnapshotsAsync(string runId, int departmentId) + { + var run = await RequireRunAsync(runId, departmentId); + var rows = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.OrderBy(s => s.WorkforceEstablishmentId).ThenBy(s => s.JobCategoryCode).ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.EmployeeSnapshot); + await NameSnapshotsAsync(rows, departmentId); + return rows; + } + + public async Task> GetRowsAsync(string runId, int departmentId) + { + var run = await RequireRunAsync(runId, departmentId); + var rows = (await _rows.GetByRunAsync(run.PayDataReportRunId))?.OrderBy(r => r.SortOrder).ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.ReportRow); + return rows; + } + + private async Task NameSnapshotsAsync(IReadOnlyList rows, int departmentId) + { + if (rows.Count == 0) return; + var workers = (await _workers.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + var userIds = workers.Where(w => !string.IsNullOrWhiteSpace(w.UserId)).Select(w => w.UserId).Distinct().ToList(); + var profiles = userIds.Count == 0 ? new List() : (await _userProfileService.GetSelectedUserProfilesAsync(userIds))?.ToList() ?? new List(); + foreach (var row in rows) + { + var worker = workers.FirstOrDefault(w => w.WorkforceWorkerId == row.WorkforceWorkerId); + var profile = worker?.UserId == null ? null : profiles.FirstOrDefault(p => string.Equals(p.UserId, worker.UserId, StringComparison.OrdinalIgnoreCase)); + row.WorkerDisplayName = profile?.FullName.AsFirstNameLastName ?? (worker != null && !WorkforceProtectionSeam.IsUnavailable(worker.DisplayLabel) ? worker.DisplayLabel : null) ?? worker?.UserId ?? row.WorkforceWorkerId; + } + } + + #endregion + + #region Lifecycle + + public async Task CreateRunAsync(int departmentId, int reportingYear, PayDataReportTypes reportType, DateTime snapshotStart, DateTime snapshotEnd, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = CaPayDataSchemaProfile.ForYear(reportingYear) ?? throw new InvalidOperationException("paydata_profile_unavailable"); + if (!profile.IsSnapshotInWindow(snapshotStart, snapshotEnd)) throw new InvalidOperationException("paydata_snapshot_window_invalid"); + var employer = await _employers.GetActiveForDepartmentAsync(departmentId); + var run = new PayDataReportRun + { + DepartmentId = departmentId, ReportType = (int)reportType, ReportingYear = reportingYear, SchemaProfileCode = profile.Code, SchemaProfileHash = ProfileHash(profile), + SnapshotStart = snapshotStart.Date, SnapshotEnd = snapshotEnd.Date, Status = (int)PayDataReportRunStatuses.Draft, AddedOn = DateTime.UtcNow, AddedByUserId = userId, + EmployerSnapshotJson = await EmployerSnapshotAsync(employer, departmentId) + }; + var saved = await _seam.SaveAsync(_runs, run, null, departmentId, WorkforceProtectedFields.ReportRun, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportCreated, ipAddress, userAgent, null, saved); + return await GetRunAsync(saved.PayDataReportRunId, departmentId); + } + + private async Task EmployerSnapshotAsync(WorkforceEmployerProfile employer, int departmentId) + { + if (employer == null) return null; + await _seam.ResolveForWorkloadAsync(new[] { employer }, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Employer); + var affiliates = (await _affiliates.GetForDepartmentAsync(departmentId))?.Where(a => !a.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(affiliates, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Affiliate); + return JsonConvert.SerializeObject(new + { + employer.WorkforceEmployerProfileId, employer.RowVersion, employer.LegalName, employer.Fein, employer.Sein, employer.SosNumber, employer.Naics, employer.EddAddress, employer.HeadquartersAddress, employer.IsIntegratedEnterprise, + employer.FilingContactName, employer.FilingContactEmail, employer.FilingContactPhone, employer.CoverageStatus, employer.UsEmployeeCount, employer.CaliforniaEmployeeCount, + Affiliates = affiliates.Select(a => new { a.WorkforceAffiliatedEntityId, a.LegalName, a.Fein, a.Sein, a.SosNumber, a.HeadquartersAddress }).ToList() + }); + } + + public async Task BuildEmployeeSnapshotsAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? throw new InvalidOperationException("paydata_profile_unavailable"); + var before = Snapshot(run); + await _rows.DeleteByRunAsync(run.PayDataReportRunId, cancellationToken); + await _snapshots.DeleteByRunAsync(run.PayDataReportRunId, cancellationToken); + + var yearStart = new DateTime(run.ReportingYear, 1, 1); var yearEnd = new DateTime(run.ReportingYear, 12, 31); + var kind = run.ReportType == (int)PayDataReportTypes.LaborContractorEmployee ? WorkerKinds.LaborContractorEmployee : WorkerKinds.PayrollEmployee; + var employments = (await _employments.GetActiveInWindowAsync(departmentId, run.SnapshotStart, run.SnapshotEnd))?.Where(e => !e.IsDeleted && e.WorkerKind == (int)kind).ToList() ?? new List(); + var establishments = (await _establishments.GetForDepartmentAsync(departmentId))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + var assignments = employments.Count == 0 ? new List() : (await _assignments.GetByEmploymentsAsync(employments.Select(e => e.WorkforceEmploymentId)))?.Where(a => !a.IsDeleted).ToList() ?? new List(); + var facts = (await _annualFacts.GetForYearAsync(departmentId, run.ReportingYear, run.ReportType))?.Where(f => !f.IsDeleted).GroupBy(f => (f.WorkforceEmploymentId, f.ClientAllocationKey ?? string.Empty)).Select(g => g.OrderByDescending(f => f.Version).First()).ToList() ?? new List(); + var factsResolved = await _seam.ResolveForWorkloadAsync(facts, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.AnnualFact); + // Demographic responses are collected after the snapshot period: the current response counts, not the one that existed on the snapshot date. + var demographics = (await _demographics.GetCurrentForDepartmentAsync(departmentId, DateTime.UtcNow.Date))?.Where(d => !d.IsDeleted).GroupBy(d => d.WorkforceWorkerId).Select(g => g.OrderByDescending(d => d.Version).First()).ToList() ?? new List(); + var demographicsResolved = await _seam.ResolveForWorkloadAsync(demographics, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Demographic); + var workEntries = (await _workEntries.GetForDepartmentInWindowAsync(departmentId, yearStart, yearEnd))?.Where(w => !w.IsDeleted).ToList() ?? new List(); + + var snapshots = new List(); + foreach (var employment in employments) + { + // Snapshot-period assignment governs job category and work mode; the establishment is the one with the most hours in the year when work entries exist. + var assignment = assignments.Where(a => a.WorkforceEmploymentId == employment.WorkforceEmploymentId && a.EffectiveOn.Date <= run.SnapshotEnd && (!a.ExpiresOn.HasValue || a.ExpiresOn.Value.Date >= run.SnapshotStart)).OrderByDescending(a => a.EffectiveOn).FirstOrDefault(); + var hoursByEstablishment = workEntries.Where(w => w.WorkforceEmploymentId == employment.WorkforceEmploymentId && !string.IsNullOrWhiteSpace(w.WorkforceEstablishmentId)).GroupBy(w => w.WorkforceEstablishmentId).OrderByDescending(g => g.Sum(w => w.Hours)).FirstOrDefault(); + var establishmentId = hoursByEstablishment?.Key ?? assignment?.WorkforceEstablishmentId ?? employment.DefaultEstablishmentId; + var establishment = establishments.FirstOrDefault(e => e.WorkforceEstablishmentId == establishmentId); + var inCalifornia = employment.CaliforniaEmployeeBasis != (int)CaliforniaEmployeeBases.NotCalifornia || establishment?.IsCalifornia == true || assignment?.WorkMode == (int)WorkModes.RemoteWithinCalifornia + || string.Equals(assignment?.WorkSubdivision, "CA", StringComparison.OrdinalIgnoreCase) || workEntries.Any(w => w.WorkforceEmploymentId == employment.WorkforceEmploymentId && string.Equals(w.WorkSubdivision, "CA", StringComparison.OrdinalIgnoreCase)); + if (!inCalifornia) continue; + + var exceptions = new List(); + var demographic = demographics.FirstOrDefault(d => d.WorkforceWorkerId == employment.WorkforceWorkerId); + string demographicCode = null; + if (demographic == null || !demographicsResolved || WorkforceProtectionSeam.IsUnavailable(demographic.SexCode) || WorkforceProtectionSeam.IsUnavailable(demographic.RaceEthnicityCodes)) exceptions.Add(PayDataValidationCodes.DemographicMissing); + else + { + demographicCode = profile.DemographicCode(demographic.HispanicLatino, (demographic.RaceEthnicityCodes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim()).ToList(), demographic.SexCode); + if (demographicCode == null) exceptions.Add(PayDataValidationCodes.DemographicMissing); + if (demographic.CollectionSource == (int)DemographicCollectionSources.ObserverPerception) exceptions.Add(PayDataValidationCodes.ObserverPerceptionUsed); + } + var fact = facts.FirstOrDefault(f => f.WorkforceEmploymentId == employment.WorkforceEmploymentId); + decimal? earnings = null; decimal hours = 0m; decimal weeks = 0m; var earningsSource = 0; + if (fact == null || !factsResolved || WorkforceProtectionSeam.IsUnavailable(fact.EarningsUsed)) exceptions.Add(PayDataValidationCodes.AnnualFactMissing); + else + { + if (!fact.IsApproved) exceptions.Add(PayDataValidationCodes.AnnualFactUnapproved); + earnings = fact.EarningsUsedValue; earningsSource = fact.EarningsSource; hours = fact.ReportableHours ?? 0m; weeks = fact.WeeksWorked ?? 0m; + if (!earnings.HasValue) exceptions.Add(PayDataValidationCodes.EarningsMissing); + if (fact.EarningsSource == (int)EarningsSources.W2Box1Fallback) exceptions.Add(PayDataValidationCodes.EarningsBox1Fallback); + if (hours <= 0) exceptions.Add(PayDataValidationCodes.HoursZero); + if (weeks <= 0) exceptions.Add(PayDataValidationCodes.WeeksMissing); + if (fact.ExemptProxyMethod == (int)ExemptProxyMethods.DaysTimesAverageHours) exceptions.Add(PayDataValidationCodes.ExemptProxyUsed); + } + if (establishment == null) exceptions.Add(PayDataValidationCodes.EstablishmentMissing); + if (assignment == null || string.IsNullOrWhiteSpace(assignment.JobCategoryCode)) exceptions.Add(PayDataValidationCodes.JobCategoryMissing); + else if (profile.JobCategories.All(j => j.Code != assignment.JobCategoryCode)) exceptions.Add(PayDataValidationCodes.JobCategoryUnknown); + if (assignment == null) exceptions.Add(PayDataValidationCodes.WorkModeUnresolved); + if (run.ReportType == (int)PayDataReportTypes.LaborContractorEmployee && string.IsNullOrWhiteSpace(employment.WorkforceLaborContractorId)) exceptions.Add(PayDataValidationCodes.ContractorIdentityMissing); + var overlapping = assignments.Count(a => a.WorkforceEmploymentId == employment.WorkforceEmploymentId && a.EffectiveOn.Date <= run.SnapshotEnd && (!a.ExpiresOn.HasValue || a.ExpiresOn.Value.Date >= run.SnapshotStart)); + if (overlapping > 1 && assignments.Where(a => a.WorkforceEmploymentId == employment.WorkforceEmploymentId).Any(a => assignments.Any(o => o != a && o.WorkforceEmploymentId == a.WorkforceEmploymentId && a.Overlaps(o)))) exceptions.Add(PayDataValidationCodes.AssignmentOverlap); + + var rate = PayDataAggregator.HourlyRate(earnings, hours); + snapshots.Add(new PayDataReportEmployeeSnapshot + { + PayDataReportRunId = run.PayDataReportRunId, DepartmentId = departmentId, WorkforceWorkerId = employment.WorkforceWorkerId, WorkforceEmploymentId = employment.WorkforceEmploymentId, + WorkforceEstablishmentId = establishment?.WorkforceEstablishmentId, WorkforceLaborContractorId = employment.WorkforceLaborContractorId, JobCategoryCode = assignment?.JobCategoryCode, + DemographicCode = demographicCode, PayBandCode = earnings.HasValue ? profile.PayBandFor(earnings.Value)?.Code : null, + ExemptionCode = employment.ExemptionStatus == (int)ExemptionStatuses.Exempt ? "Exempt" : employment.ExemptionStatus == (int)ExemptionStatuses.NonExempt ? "NonExempt" : null, + EmploymentTypeCode = employment.EmploymentType == (int)EmploymentTypes.Unknown ? null : ((EmploymentTypes)employment.EmploymentType).ToString(), + WorkMode = assignment?.WorkMode ?? (int)WorkModes.NonRemote, AnnualEarningsValue = earnings, EarningsSource = earningsSource, AnnualHours = hours, AnnualWeeks = weeks, + HourlyRateValue = rate.HasValue ? Math.Round(rate.Value, 4, MidpointRounding.AwayFromZero) : null, IsIncluded = true, ExceptionCodesCsv = exceptions.Count == 0 ? null : string.Join(",", exceptions.Distinct()), + SourceVersions = JsonConvert.SerializeObject(new { Employment = new { employment.WorkforceEmploymentId, employment.RowVersion }, Assignment = assignment == null ? null : new { assignment.WorkforceJobAssignmentId, assignment.RowVersion }, Fact = fact == null ? null : new { fact.WorkforceAnnualPayFactId, fact.Version }, Demographic = demographic == null ? null : new { demographic.PayDataReportingDemographicId, demographic.Version } }), + AddedOn = DateTime.UtcNow, AddedByUserId = userId + }); + } + foreach (var snapshot in snapshots) await _seam.SaveAsync(_snapshots, snapshot, null, departmentId, WorkforceProtectedFields.EmployeeSnapshot, cancellationToken); + run.EmployeeCount = snapshots.Count; run.RowCount = 0; run.SourceCutoff = DateTime.UtcNow; run.Status = (int)PayDataReportRunStatuses.Draft; run.ValidationSummaryJson = null; + run.ExceptionCount = snapshots.Count(s => s.ExceptionCodes.Any(IsBlocking)); run.WarningCount = snapshots.Count(s => s.ExceptionCodes.Any(c => !IsBlocking(c))); + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportCreated, ipAddress, userAgent, before, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + public static bool IsBlocking(string code) => code is PayDataValidationCodes.AnnualFactMissing or PayDataValidationCodes.AnnualFactUnapproved or PayDataValidationCodes.EarningsMissing or PayDataValidationCodes.HoursZero + or PayDataValidationCodes.WeeksMissing or PayDataValidationCodes.DemographicMissing or PayDataValidationCodes.JobCategoryMissing or PayDataValidationCodes.JobCategoryUnknown or PayDataValidationCodes.WorkModeUnresolved + or PayDataValidationCodes.EstablishmentMissing or PayDataValidationCodes.ContractorIdentityMissing or PayDataValidationCodes.AssignmentOverlap; + + public async Task OverrideSnapshotAsync(string runId, string snapshotId, int departmentId, bool include, string jobCategoryCode, int? workMode, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + if (string.IsNullOrWhiteSpace(reason)) throw new InvalidOperationException("paydata_reason_required"); + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? throw new InvalidOperationException("paydata_profile_unavailable"); + var snapshot = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.FirstOrDefault(s => s.PayDataReportEmployeeSnapshotId == snapshotId) ?? throw new InvalidOperationException("paydata_snapshot_not_found"); + var before = snapshot.CloneJson(); foreach (var a in WorkforceProtectedFields.EmployeeSnapshot) a.Value.Set(before, WorkforceService.Marker(a.Value.Get(before))); + var codes = snapshot.ExceptionCodes.ToList(); + if (!string.IsNullOrWhiteSpace(jobCategoryCode)) + { + if (profile.JobCategories.All(j => j.Code != jobCategoryCode.Trim())) throw new InvalidOperationException("workforce_job_category_invalid"); + snapshot.JobCategoryCode = jobCategoryCode.Trim(); + codes.Remove(PayDataValidationCodes.JobCategoryMissing); codes.Remove(PayDataValidationCodes.JobCategoryUnknown); + } + if (workMode.HasValue) + { + if (!Enum.IsDefined(typeof(WorkModes), workMode.Value)) throw new InvalidOperationException("workforce_work_mode_invalid"); + snapshot.WorkMode = workMode.Value; + codes.Remove(PayDataValidationCodes.WorkModeUnresolved); + } + snapshot.IsIncluded = include; + if (!codes.Contains(PayDataValidationCodes.ManualOverride)) codes.Add(PayDataValidationCodes.ManualOverride); + snapshot.ExceptionCodesCsv = string.Join(",", codes); + snapshot.OverrideReason = reason.Trim(); snapshot.OverrideByUserId = userId; + // The envelope is bound to the row; the protected columns are unchanged, so a plain update keeps them. + await _snapshots.SaveOrUpdateAsync(snapshot, cancellationToken); + await _rows.DeleteByRunAsync(run.PayDataReportRunId, cancellationToken); + var all = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.ToList() ?? new List(); + run.RowCount = 0; run.Status = (int)PayDataReportRunStatuses.Draft; run.ValidationSummaryJson = null; + run.ExceptionCount = all.Count(s => s.IsIncluded && s.ExceptionCodes.Any(IsBlocking)); run.WarningCount = all.Count(s => s.IsIncluded && s.ExceptionCodes.Any(c => !IsBlocking(c))); + await TouchAsync(run, userId, cancellationToken); + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.PayDataReportCreated, ipAddress, userAgent); + var after = snapshot.CloneJson(); foreach (var a in WorkforceProtectedFields.EmployeeSnapshot) a.Value.Set(after, WorkforceService.Marker(a.Value.Get(after))); + audit.Before = before.CloneJsonToString(); audit.After = after.CloneJsonToString(); + _eventAggregator.SendMessage(audit); + await _seam.ResolveForReadAsync(new[] { snapshot }, departmentId, WorkforceProtectedFields.EmployeeSnapshot); + return snapshot; + } + + public async Task AggregateRowsAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? throw new InvalidOperationException("paydata_profile_unavailable"); + var before = Snapshot(run); + var snapshots = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.ToList() ?? new List(); + if (snapshots.Count == 0) throw new InvalidOperationException("paydata_no_snapshots"); + if (!await _seam.ResolveForWorkloadAsync(snapshots, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.EmployeeSnapshot)) throw new InvalidOperationException("paydata_workload_denied"); + await _rows.DeleteByRunAsync(run.PayDataReportRunId, cancellationToken); + var rows = PayDataAggregator.Aggregate(snapshots, profile); + foreach (var row in rows) await _seam.SaveAsync(_rows, row, null, departmentId, WorkforceProtectedFields.ReportRow, cancellationToken); + run.RowCount = rows.Count; run.Status = (int)PayDataReportRunStatuses.Draft; run.ValidationSummaryJson = null; + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportCreated, ipAddress, userAgent, before, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + public async Task SaveRemarksAsync(string runId, int departmentId, string runRemarks, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + var existing = run.CloneJson(); + var before = Snapshot(run); + if (runRemarks != ProtectedDataEnvelope.RedactionValue) + { + if (!string.IsNullOrEmpty(runRemarks) && runRemarks.Length > 500) throw new InvalidOperationException("paydata_remarks_too_long"); + run.RunRemarks = string.IsNullOrWhiteSpace(runRemarks) ? null : runRemarks.Trim(); + } + run.RowVersion++; run.EditedOn = DateTime.UtcNow; run.EditedByUserId = userId; + await _seam.SaveAsync(_runs, run, existing, departmentId, WorkforceProtectedFields.ReportRun, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportCreated, ipAddress, userAgent, before, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + public async Task ValidateRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + var before = Snapshot(run); + var result = await ValidateCoreAsync(run, departmentId); + run.ValidationSummaryJson = JsonConvert.SerializeObject(new { result.ValidatedOn, Errors = result.Errors.Select(e => new { e.Code, e.Scope, e.SubjectId }).ToList(), Warnings = result.Warnings.Select(w => new { w.Code, w.Scope, w.SubjectId }).ToList() }); + run.ExceptionCount = result.Errors.Count; run.WarningCount = result.Warnings.Count; + run.Status = result.CanFreeze ? (int)PayDataReportRunStatuses.Validated : (int)PayDataReportRunStatuses.Draft; + run.ReviewedByUserId = userId; run.ReviewedOn = result.ValidatedOn; + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportValidated, ipAddress, userAgent, before, run); + return result; + } + + private async Task ValidateCoreAsync(PayDataReportRun run, int departmentId) + { + var result = new PayDataValidationResult { RunId = run.PayDataReportRunId, ValidatedOn = DateTime.UtcNow }; + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode); + if (profile == null) { result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.ProfileStale, Scope = "run", IsBlocking = true }); return result; } + if (!string.Equals(run.SchemaProfileHash, ProfileHash(profile), StringComparison.Ordinal)) result.Warnings.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.ProfileStale, Scope = "run" }); + if (!profile.IsSnapshotInWindow(run.SnapshotStart, run.SnapshotEnd)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.SnapshotOutsideWindow, Scope = "run", IsBlocking = true }); + + var employer = await _employers.GetActiveForDepartmentAsync(departmentId); + if (employer == null) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EmployerIdentityMissing, Scope = "employer", IsBlocking = true }); + else + { + await _seam.ResolveForWorkloadAsync(new[] { employer }, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Employer); + if (string.IsNullOrWhiteSpace(employer.LegalName) || Missing(employer.Fein) || Missing(employer.Sein) || Missing(employer.EddAddress)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EmployerIdentityMissing, Scope = "employer", SubjectId = employer.WorkforceEmployerProfileId, IsBlocking = true }); + if (employer.CoverageStatus == (int)CaliforniaPayDataCoverageStatuses.Unknown) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.CoverageUndeclared, Scope = "employer", IsBlocking = true }); + else if (employer.CoverageStatus == (int)CaliforniaPayDataCoverageStatuses.NotCovered) result.Warnings.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.CoverageUndeclared, Scope = "employer", Detail = "declared not covered" }); + } + + var snapshots = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.ToList() ?? new List(); + var included = snapshots.Where(s => s.IsIncluded).ToList(); + if (included.Count == 0) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.NoEmployees, Scope = "run", IsBlocking = true }); + foreach (var snapshot in included) + foreach (var code in snapshot.ExceptionCodes) + (IsBlocking(code) ? result.Errors : result.Warnings).Add(new PayDataValidationIssue { Code = code, Scope = "employee", SubjectId = snapshot.PayDataReportEmployeeSnapshotId, IsBlocking = IsBlocking(code) }); + + var establishments = (await _establishments.GetForDepartmentAsync(departmentId))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(establishments, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Establishment); + foreach (var id in included.Select(s => s.WorkforceEstablishmentId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct()) + { + var establishment = establishments.FirstOrDefault(e => e.WorkforceEstablishmentId == id); + if (establishment == null) { result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EstablishmentMissing, Scope = "establishment", SubjectId = id, IsBlocking = true }); continue; } + if (string.IsNullOrWhiteSpace(establishment.Naics)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EstablishmentNaicsMissing, Scope = "establishment", SubjectId = id, IsBlocking = true, Detail = establishment.Code }); + if (Missing(establishment.PhysicalAddress) || string.IsNullOrWhiteSpace(establishment.City) || string.IsNullOrWhiteSpace(establishment.StateCode) || string.IsNullOrWhiteSpace(establishment.PostalCode)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EstablishmentAddressMissing, Scope = "establishment", SubjectId = id, IsBlocking = true, Detail = establishment.Code }); + } + if (run.ReportType == (int)PayDataReportTypes.LaborContractorEmployee) + { + var contractors = (await _contractors.GetForDepartmentAsync(departmentId))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(contractors, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Contractor); + foreach (var id in included.Select(s => s.WorkforceLaborContractorId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct()) + { + var contractor = contractors.FirstOrDefault(c => c.WorkforceLaborContractorId == id); + if (contractor == null || string.IsNullOrWhiteSpace(contractor.LegalName) || Missing(contractor.Fein)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.ContractorIdentityMissing, Scope = "contractor", SubjectId = id, IsBlocking = true }); + } + } + + var rows = (await _rows.GetByRunAsync(run.PayDataReportRunId))?.OrderBy(r => r.SortOrder).ToList() ?? new List(); + if (rows.Count == 0 && included.Count > 0) result.Errors.Add(new PayDataValidationIssue { Code = "rows_not_aggregated", Scope = "run", IsBlocking = true }); + else if (rows.Count > 0) + { + await _seam.ResolveForWorkloadAsync(rows, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.ReportRow); + await _seam.ResolveForWorkloadAsync(snapshots, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.EmployeeSnapshot); + PayDataAggregator.ValidateRows(rows, snapshots, profile, result); + var columns = PayDataAggregator.Columns(profile, run.ReportType); + var exports = BuildExportContext(establishments, included); + long bytes = 0; + foreach (var row in rows) + { + var cells = PayDataAggregator.Cells(row, profile, run.ReportType, exports.Establishments.TryGetValue(row.WorkforceEstablishmentId ?? string.Empty, out var e) ? e : null, exports.Contractors.TryGetValue(row.WorkforceLaborContractorId ?? string.Empty, out var c) ? c : null); + PayDataAggregator.ValidateCells(columns, cells, row.PayDataReportRowId, result); + bytes += cells.Sum(v => (v?.Length ?? 0) + 3); + } + if (bytes > profile.MaxFileBytes) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.FileTooLarge, Scope = "run", IsBlocking = true }); + } + if (!string.IsNullOrWhiteSpace(run.RunRemarks) && !WorkforceProtectionSeam.IsUnavailable(run.RunRemarks) && run.RunRemarks.Length > 500) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.FieldTooLong, Scope = "run", IsBlocking = true, Detail = "Clarifying Remarks" }); + return result; + } + + private static bool Missing(string value) => string.IsNullOrWhiteSpace(value) || WorkforceProtectionSeam.IsUnavailable(value); + + private sealed class ExportContext + { + public Dictionary Establishments { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary Contractors { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static ExportContext BuildExportContext(List establishments, List included, List contractors = null) + { + var context = new ExportContext(); + foreach (var establishment in establishments) + context.Establishments[establishment.WorkforceEstablishmentId] = new PayDataAggregator.ExportEstablishment + { + Id = establishment.WorkforceEstablishmentId, Name = establishment.Name, Address = establishment.PhysicalAddress, City = establishment.City, State = establishment.StateCode, Zip = establishment.PostalCode, Naics = establishment.Naics, MajorActivity = establishment.MajorActivity, + TotalEmployees = included.Count(s => s.WorkforceEstablishmentId == establishment.WorkforceEstablishmentId), FiledPriorYear = establishment.WasFiledPriorYear, IsHeadquarters = establishment.IsHeadquarters + }; + foreach (var contractor in contractors ?? new List()) + context.Contractors[contractor.WorkforceLaborContractorId] = new PayDataAggregator.ExportContractor { Id = contractor.WorkforceLaborContractorId, Name = contractor.LegalName, Fein = contractor.Fein }; + return context; + } + + public async Task FreezeAndExportAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireEditableAsync(runId, departmentId); + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? throw new InvalidOperationException("paydata_profile_unavailable"); + var validation = await ValidateCoreAsync(run, departmentId); + if (!validation.CanFreeze) throw new InvalidOperationException("paydata_validation_failed"); + var before = Snapshot(run); + var snapshots = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.Where(s => s.IsIncluded).ToList() ?? new List(); + var rows = (await _rows.GetByRunAsync(run.PayDataReportRunId))?.OrderBy(r => r.SortOrder).ToList() ?? new List(); + if (!await _seam.ResolveForWorkloadAsync(rows, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.ReportRow)) throw new InvalidOperationException("paydata_workload_denied"); + var establishments = (await _establishments.GetForDepartmentAsync(departmentId))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(establishments, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Establishment); + var contractors = run.ReportType == (int)PayDataReportTypes.LaborContractorEmployee ? (await _contractors.GetForDepartmentAsync(departmentId))?.Where(c => !c.IsDeleted).ToList() ?? new List() : new List(); + if (contractors.Count > 0) await _seam.ResolveForWorkloadAsync(contractors, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Contractor); + var context = BuildExportContext(establishments, snapshots, contractors); + var columns = PayDataAggregator.Columns(profile, run.ReportType); + var cells = rows.Select(row => (IReadOnlyList)PayDataAggregator.Cells(row, profile, run.ReportType, context.Establishments.TryGetValue(row.WorkforceEstablishmentId ?? string.Empty, out var e) ? e : null, context.Contractors.TryGetValue(row.WorkforceLaborContractorId ?? string.Empty, out var c) ? c : null)).ToList(); + var now = DateTime.UtcNow; + var stem = $"CRD-PayData-{run.ReportingYear}-{(run.ReportType == (int)PayDataReportTypes.LaborContractorEmployee ? "LaborContractor" : "Payroll")}-{run.PayDataReportRunId.Substring(0, Math.Min(8, run.PayDataReportRunId.Length))}"; + var csv = PayDataAggregator.RenderCsv(columns, cells); + var xlsx = PayDataAggregator.RenderXlsx(columns, cells); + var csvArtifact = await _seam.SaveArtifactAsync(_artifacts, new PayDataExportArtifact { PayDataReportRunId = run.PayDataReportRunId, DepartmentId = departmentId, SchemaProfileCode = profile.Code, SchemaProfileHash = run.SchemaProfileHash, Format = (int)PayDataExportFormats.Csv, FileName = stem + ".csv", Checksum = PayDataAggregator.Sha256(csv), Data = csv, Size = csv.Length, CreatedOn = now, ExpiresOn = now.AddDays(Math.Max(1, Config.WorkforceConfig.ExportArtifactRetentionDays)), ExportedByUserId = userId }, departmentId, cancellationToken); + await _seam.SaveArtifactAsync(_artifacts, new PayDataExportArtifact { PayDataReportRunId = run.PayDataReportRunId, DepartmentId = departmentId, SchemaProfileCode = profile.Code, SchemaProfileHash = run.SchemaProfileHash, Format = (int)PayDataExportFormats.Xlsx, FileName = stem + ".xlsx", Checksum = PayDataAggregator.Sha256(xlsx), Data = xlsx, Size = xlsx.Length, CreatedOn = now, ExpiresOn = now.AddDays(Math.Max(1, Config.WorkforceConfig.ExportArtifactRetentionDays)), ExportedByUserId = userId }, departmentId, cancellationToken); + run.Status = (int)PayDataReportRunStatuses.Exported; run.FrozenByUserId = userId; run.FrozenOn = now; run.ExportedByUserId = userId; run.ExportedOn = now; run.CertifiedArtifactChecksum = csvArtifact.Checksum; + run.ExceptionCount = 0; run.WarningCount = validation.Warnings.Count; + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportFrozen, ipAddress, userAgent, before, run); + Audit(departmentId, userId, AuditLogTypes.PayDataReportExported, ipAddress, userAgent, null, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + public async Task> GetArtifactsAsync(string runId, int departmentId) + { + var run = await RequireRunAsync(runId, departmentId); + // Listings never carry the bytes (and never mutate the stored row). + return (await _artifacts.GetByRunAsync(run.PayDataReportRunId))?.OrderBy(a => a.Format).Select(a => { var copy = a.CloneJson(); copy.Data = null; return copy; }).ToList() ?? new List(); + } + + public async Task DownloadArtifactAsync(string artifactId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var artifact = await _artifacts.GetByIdForDepartmentAsync(artifactId ?? string.Empty, departmentId) ?? throw new InvalidOperationException("paydata_artifact_not_found"); + if (!artifact.IsAvailable || artifact.Data == null) throw new InvalidOperationException("paydata_artifact_unavailable"); + await _seam.ResolveArtifactForReadAsync(artifact, departmentId); + if (artifact.Data == null) throw new InvalidOperationException("paydata_artifact_unavailable"); + var data = artifact.Data; + artifact.DownloadCount++; artifact.LastDownloadedOn = DateTime.UtcNow; artifact.LastDownloadedByUserId = userId; + // Counters only: the stored envelope stays as it is. + var stored = await _artifacts.GetByIdForDepartmentAsync(artifact.PayDataExportArtifactId, departmentId); + stored.DownloadCount = artifact.DownloadCount; stored.LastDownloadedOn = artifact.LastDownloadedOn; stored.LastDownloadedByUserId = userId; + await _artifacts.SaveOrUpdateAsync(stored, cancellationToken); + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.PayDataReportExported, ipAddress, userAgent); + audit.After = JsonConvert.SerializeObject(new { artifact.PayDataExportArtifactId, artifact.PayDataReportRunId, artifact.FileName, artifact.Checksum, artifact.DownloadCount, Action = "download" }); + _eventAggregator.SendMessage(audit); + artifact.Data = data; + return artifact; + } + + public async Task GetWorksheetAsync(string runId, int departmentId) + { + var run = await GetRunAsync(runId, departmentId) ?? throw new InvalidOperationException("paydata_run_not_found"); + var profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? CaPayDataSchemaProfile.Current; + var employer = await _employers.GetActiveForDepartmentAsync(departmentId); + if (employer != null) await _seam.ResolveForReadAsync(new[] { employer }, departmentId, WorkforceProtectedFields.Employer); + var affiliates = (await _affiliates.GetForDepartmentAsync(departmentId))?.Where(a => !a.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForReadAsync(affiliates, departmentId, WorkforceProtectedFields.Affiliate); + var establishments = (await _establishments.GetForDepartmentAsync(departmentId))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForReadAsync(establishments, departmentId, WorkforceProtectedFields.Establishment); + var snapshots = (await _snapshots.GetByRunAsync(run.PayDataReportRunId))?.Where(s => s.IsIncluded).ToList() ?? new List(); + var worksheet = new PayDataPortalWorksheet + { + RunId = run.PayDataReportRunId, ProfileCode = run.SchemaProfileCode, ReportType = run.ReportType, ReportingYear = run.ReportingYear, SnapshotStart = run.SnapshotStart, SnapshotEnd = run.SnapshotEnd, + EmployerLegalName = employer?.LegalName, EmployerFein = employer?.Fein, EmployerSein = employer?.Sein, EmployerSosNumber = employer?.SosNumber, EmployerNaics = employer?.Naics, EddAddress = employer?.EddAddress, HeadquartersAddress = employer?.HeadquartersAddress, + IsIntegratedEnterprise = employer?.IsIntegratedEnterprise ?? false, FilingContactName = employer?.FilingContactName, FilingContactEmail = employer?.FilingContactEmail, FilingContactPhone = employer?.FilingContactPhone, + UsEmployeeCount = employer?.UsEmployeeCount, CaliforniaEmployeeCount = employer?.CaliforniaEmployeeCount, SnapshotEmployeeCount = snapshots.Count, RunRemarks = run.RunRemarks, DueDate = profile.DueDate + }; + foreach (var establishment in establishments.Where(e => snapshots.Any(s => s.WorkforceEstablishmentId == e.WorkforceEstablishmentId)).OrderBy(e => e.Code)) + worksheet.Establishments.Add(new PayDataWorksheetEstablishment { Code = establishment.Code, Name = establishment.Name, Address = establishment.PhysicalAddress, City = establishment.City, State = establishment.StateCode, Zip = establishment.PostalCode, Naics = establishment.Naics, MajorActivity = establishment.MajorActivity, IsHeadquarters = establishment.IsHeadquarters, WasFiledPriorYear = establishment.WasFiledPriorYear, EmployeeCount = snapshots.Count(s => s.WorkforceEstablishmentId == establishment.WorkforceEstablishmentId) }); + foreach (var affiliate in affiliates.OrderBy(a => a.LegalName)) + worksheet.Affiliates.Add(new PayDataWorksheetAffiliate { LegalName = affiliate.LegalName, Fein = affiliate.Fein, Sein = affiliate.Sein, SosNumber = affiliate.SosNumber, HeadquartersAddress = affiliate.HeadquartersAddress }); + return worksheet; + } + + public async Task MarkCertifiedExternallyAsync(string runId, int departmentId, string certificationReference, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireRunAsync(runId, departmentId); + if (run.Status != (int)PayDataReportRunStatuses.Exported) throw new InvalidOperationException("paydata_run_not_exported"); + if (string.IsNullOrWhiteSpace(certificationReference)) throw new InvalidOperationException("paydata_certification_reference_required"); + var before = Snapshot(run); + run.Status = (int)PayDataReportRunStatuses.CertifiedExternally; run.CertifiedByUserId = userId; run.CertifiedOn = DateTime.UtcNow; run.CertificationReference = certificationReference.Trim(); + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportMarkedCertified, ipAddress, userAgent, before, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + public async Task CreateCorrectionAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireRunAsync(runId, departmentId); + // Exported or certified runs can be corrected; a run already marked Correction can take a new correction only once the previous one was voided. + if (run.Status != (int)PayDataReportRunStatuses.Exported && run.Status != (int)PayDataReportRunStatuses.CertifiedExternally && run.Status != (int)PayDataReportRunStatuses.Correction) throw new InvalidOperationException("paydata_run_not_frozen"); + var open = (await GetRunsAsync(departmentId, run.ReportingYear)).Any(r => r.SupersedesRunId == run.PayDataReportRunId && r.Status != (int)PayDataReportRunStatuses.Void); + if (open) throw new InvalidOperationException("paydata_correction_exists"); + var before = Snapshot(run); + var employer = await _employers.GetActiveForDepartmentAsync(departmentId); + var correction = new PayDataReportRun + { + DepartmentId = departmentId, ReportType = run.ReportType, ReportingYear = run.ReportingYear, SchemaProfileCode = run.SchemaProfileCode, SchemaProfileHash = run.SchemaProfileHash, SnapshotStart = run.SnapshotStart, SnapshotEnd = run.SnapshotEnd, + Status = (int)PayDataReportRunStatuses.Draft, SupersedesRunId = run.PayDataReportRunId, AddedOn = DateTime.UtcNow, AddedByUserId = userId, EmployerSnapshotJson = await EmployerSnapshotAsync(employer, departmentId) + }; + var saved = await _seam.SaveAsync(_runs, correction, null, departmentId, WorkforceProtectedFields.ReportRun, cancellationToken); + // The superseded run stays immutable and is marked as corrected. + run.Status = (int)PayDataReportRunStatuses.Correction; + await TouchAsync(run, userId, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.PayDataReportCorrected, ipAddress, userAgent, before, saved); + return await GetRunAsync(saved.PayDataReportRunId, departmentId); + } + + public async Task VoidRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await RequireRunAsync(runId, departmentId); + if (run.Status == (int)PayDataReportRunStatuses.CertifiedExternally || run.Status == (int)PayDataReportRunStatuses.Correction) throw new InvalidOperationException("paydata_run_certified"); + if (run.Status == (int)PayDataReportRunStatuses.Void) return await GetRunAsync(run.PayDataReportRunId, departmentId); + var before = Snapshot(run); + run.Status = (int)PayDataReportRunStatuses.Void; + await TouchAsync(run, userId, cancellationToken); + foreach (var artifact in (await _artifacts.GetByRunAsync(run.PayDataReportRunId))?.Where(a => !a.PurgedOn.HasValue) ?? Enumerable.Empty()) + { artifact.Data = null; artifact.PurgedOn = DateTime.UtcNow; await _artifacts.SaveOrUpdateAsync(artifact, cancellationToken); } + Audit(departmentId, userId, AuditLogTypes.PayDataReportCorrected, ipAddress, userAgent, before, run); + return await GetRunAsync(run.PayDataReportRunId, departmentId); + } + + #endregion + + #region Readiness and worker 49 + + public async Task GetReadinessAsync(int departmentId, int reportingYear) + { + var profile = CaPayDataSchemaProfile.ForYear(reportingYear); + var employer = await _employers.GetActiveForDepartmentAsync(departmentId); + var runs = await GetRunsAsync(departmentId, reportingYear); + var yearEnd = new DateTime(reportingYear, 12, 31); + var employments = (await _employments.GetActiveInWindowAsync(departmentId, yearEnd, yearEnd))?.Where(e => !e.IsDeleted && (e.WorkerKind == (int)WorkerKinds.PayrollEmployee || e.WorkerKind == (int)WorkerKinds.LaborContractorEmployee)).ToList() ?? new List(); + var workers = employments.Select(e => e.WorkforceWorkerId).Distinct().ToList(); + var responses = (await _demographics.GetCurrentForDepartmentAsync(departmentId, DateTime.UtcNow.Date))?.Where(d => !d.IsDeleted).Select(d => d.WorkforceWorkerId).Distinct().ToList() ?? new List(); + var facts = (await _annualFacts.GetForYearAsync(departmentId, reportingYear, (int)PayDataReportTypes.PayrollEmployee))?.Where(f => !f.IsDeleted).Select(f => f.WorkforceEmploymentId).Distinct().ToList() ?? new List(); + var contractorFacts = (await _annualFacts.GetForYearAsync(departmentId, reportingYear, (int)PayDataReportTypes.LaborContractorEmployee))?.Where(f => !f.IsDeleted).Select(f => f.WorkforceEmploymentId).Distinct().ToList() ?? new List(); + var active = runs.Where(r => r.Status != (int)PayDataReportRunStatuses.Void).ToList(); + return new PayDataReadiness + { + ReportingYear = reportingYear, ProfileCode = profile?.Code, ProfileAvailable = profile != null, DueDate = profile?.DueDate ?? CaPayDataSchemaProfile.SecondWednesdayOfMay(reportingYear + 1), + CoverageStatus = employer?.CoverageStatus ?? (int)CaliforniaPayDataCoverageStatuses.Unknown, + OpenRuns = active.Count(r => r.IsEditable), UnresolvedExceptions = active.Where(r => r.IsEditable).Sum(r => r.ExceptionCount), + DemographicsMissing = workers.Count(w => !responses.Contains(w)), + AnnualFactsMissing = employments.Count(e => e.WorkerKind == (int)WorkerKinds.PayrollEmployee ? !facts.Contains(e.WorkforceEmploymentId) : !contractorFacts.Contains(e.WorkforceEmploymentId)), + HasFrozenRun = active.Any(r => r.IsFrozen), HasCertifiedRun = active.Any(r => r.Status == (int)PayDataReportRunStatuses.CertifiedExternally || (r.Status == (int)PayDataReportRunStatuses.Correction && r.CertifiedOn.HasValue)) + }; + } + + public async Task RunReadinessSweepAsync(DateTime asOfUtc, Func> departmentEnabled = null, CancellationToken cancellationToken = default) + { + if (!Config.WorkforceConfig.ReadinessReminderEnabled || _communication?.Value == null) return 0; + if (asOfUtc.Month < Config.WorkforceConfig.FilingSeasonStartMonth || asOfUtc.Month > Config.WorkforceConfig.FilingSeasonEndMonth) return 0; + var reportingYear = asOfUtc.Year - 1; + var departments = ((await _employers.GetDepartmentsWithActiveProfilesAsync())?.ToList() ?? new List()).Concat((await _runs.GetDepartmentsWithRunsAsync(reportingYear))?.ToList() ?? new List()).Distinct().ToList(); + var notified = 0; + var dayKey = asOfUtc.Date.GetHashCode(); + foreach (var departmentId in departments) + { + cancellationToken.ThrowIfCancellationRequested(); + if (departmentEnabled != null && !await departmentEnabled(departmentId)) continue; + var key = HashCode.Combine(dayKey, departmentId); + lock (RemindedToday) { if (RemindedToday.Contains(key)) continue; } + string message; + try + { + var readiness = await GetReadinessAsync(departmentId, reportingYear); + if (readiness.CoverageStatus == (int)CaliforniaPayDataCoverageStatuses.NotCovered || (readiness.HasCertifiedRun && readiness.OpenRuns == 0)) continue; + if (readiness.DueDate.Date < asOfUtc.Date.AddDays(-30)) continue; + // Value-free: dates, states and counts only (plan E5) — never a name, code, earnings or rate. + var lines = new List { $"Reporting year {reportingYear} is due {readiness.DueDate:yyyy-MM-dd} ({Math.Max(0, (readiness.DueDate.Date - asOfUtc.Date).Days)} days)." }; + if (readiness.CoverageStatus == (int)CaliforniaPayDataCoverageStatuses.Unknown) lines.Add("Coverage status is not declared."); + if (!readiness.ProfileAvailable) lines.Add("No reviewed CRD schema profile covers this year."); + lines.Add(readiness.HasFrozenRun ? "A report has been exported but not marked certified." : readiness.OpenRuns > 0 ? $"{readiness.OpenRuns} open run(s), {readiness.UnresolvedExceptions} unresolved exception(s)." : "No report run has been started."); + if (readiness.DemographicsMissing > 0) lines.Add($"{readiness.DemographicsMissing} worker(s) have no demographic response."); + if (readiness.AnnualFactsMissing > 0) lines.Add($"{readiness.AnnualFactsMissing} employment(s) have no annual pay fact."); + message = "California pay data reporting: " + string.Join(" ", lines); + } + catch (Exception ex) { Logging.LogException(ex, $"Pay data readiness sweep failed for department {departmentId}."); continue; } + lock (RemindedToday) { RemindedToday.Add(key); if (RemindedToday.Count > 50_000) RemindedToday.Clear(); } + await NotifyAdminsAsync(departmentId, message); + 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); + // Permission 77 defaults to department administrators; the digest goes to them (a narrower assignment still includes admins). + foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Pay Data Reporting"); + } + catch (Exception ex) { Logging.LogException(ex, $"Pay data readiness digest could not be sent for department {departmentId}."); } + } + + public async Task PurgeExpiredArtifactsAsync(DateTime asOfUtc, CancellationToken cancellationToken = default) + { + var purged = 0; + foreach (var artifact in (await _artifacts.GetExpiredUnpurgedAsync(asOfUtc))?.ToList() ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + artifact.Data = null; artifact.PurgedOn = asOfUtc; + await _artifacts.SaveOrUpdateAsync(artifact, cancellationToken); + purged++; + } + return purged; + } + + #endregion + + #region Helpers + + /// Stable digest of the reviewed profile (codes, bands and column headers) so a run records exactly which schema it was built with. + public static string ProfileHash(CaPayDataSchemaProfile profile) + { + var text = new StringBuilder(profile.Code).Append('|').Append(profile.ReportingYear).Append('|') + .Append(string.Join(",", profile.JobCategories.Select(j => j.Code))).Append('|').Append(string.Join(",", profile.PayBands.Select(b => b.Code + ":" + b.Minimum.ToString(CultureInfo.InvariantCulture) + "-" + (b.Maximum?.ToString(CultureInfo.InvariantCulture) ?? "")))).Append('|') + .Append(string.Join(",", profile.RaceEthnicities.Select(r => r.Code))).Append('|').Append(string.Join(",", profile.Sexes.Select(s => s.Code))).Append('|') + .Append(string.Join(";", profile.PayrollColumns.Select(c => c.Header))).Append('|').Append(string.Join(";", profile.LaborContractorColumns.Select(c => c.Header))); + return PayDataAggregator.Sha256(Encoding.UTF8.GetBytes(text.ToString())); + } + + private async Task RequireRunAsync(string runId, int departmentId) + { + var run = string.IsNullOrWhiteSpace(runId) ? null : await _runs.GetByIdForDepartmentAsync(runId, departmentId); + if (run == null || run.IsDeleted) throw new InvalidOperationException("paydata_run_not_found"); + return run; + } + + private async Task RequireEditableAsync(string runId, int departmentId) + { + var run = await RequireRunAsync(runId, departmentId); + if (!run.IsEditable) throw new InvalidOperationException("paydata_run_frozen"); + return run; + } + + private async Task TouchAsync(PayDataReportRun run, string userId, CancellationToken cancellationToken) + { + run.RowVersion++; run.EditedOn = DateTime.UtcNow; run.EditedByUserId = userId; + await _runs.SaveOrUpdateAsync(run, cancellationToken); + } + + internal static string Snapshot(PayDataReportRun run) + { + var clone = run.CloneJson(); + foreach (var a in WorkforceProtectedFields.ReportRun) a.Value.Set(clone, WorkforceService.Marker(a.Value.Get(clone))); + clone.ValidationSummaryJson = null; + return clone.CloneJsonToString(); + } + + private void Audit(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent, string before, PayDataReportRun 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/Workforce/CompensationCostService.cs b/Core/Resgrid.Services/Workforce/CompensationCostService.cs new file mode 100644 index 00000000..18ab475f --- /dev/null +++ b/Core/Resgrid.Services/Workforce/CompensationCostService.cs @@ -0,0 +1,315 @@ +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.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.Workforce +{ + /// + /// Compensation profiles (employee, role default, department default), pay / employer-cost components and the + /// loaded-cost estimate for an approved work quantity (plan E3 ICompensationCostService). Every amount and + /// multiplier rides the ADP seam (catalog 28); the costing engine decrypts through the workforce-costing + /// workload purpose and never returns a value it could not resolve — it flags the estimate for review instead. + /// + public class CompensationCostService : ICompensationCostService + { + private readonly IEmployeeCompensationProfileRepository _profiles; + private readonly IEmployeePayComponentRepository _payComponents; + private readonly IEmployeeCostComponentRepository _costComponents; + private readonly IWorkforceEmploymentRepository _employments; + private readonly IWorkforceJobAssignmentRepository _assignments; + private readonly IEventAggregator _eventAggregator; + private readonly WorkforceProtectionSeam _seam; + + public CompensationCostService(IEmployeeCompensationProfileRepository profiles, IEmployeePayComponentRepository payComponents, IEmployeeCostComponentRepository costComponents, + IWorkforceEmploymentRepository employments, IWorkforceJobAssignmentRepository assignments, IEventAggregator eventAggregator, + Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + { + _profiles = profiles; + _payComponents = payComponents; + _costComponents = costComponents; + _employments = employments; + _assignments = assignments; + _eventAggregator = eventAggregator; + _seam = new WorkforceProtectionSeam(protectedWrite, protectedRead, grant); + } + + #region Profiles + + public async Task> GetProfilesForEmploymentAsync(string employmentId, int departmentId) + { + if (string.IsNullOrWhiteSpace(employmentId)) return new List(); + var rows = (await _profiles.GetByEmploymentAsync(employmentId))?.Where(p => p.DepartmentId == departmentId && !p.IsDeleted).OrderByDescending(p => p.EffectiveOn).ToList() ?? new List(); + await LoadComponentsAsync(rows, departmentId, null); + return rows; + } + + public async Task> GetDefaultProfilesAsync(int departmentId) + { + var rows = (await _profiles.GetDefaultsForDepartmentAsync(departmentId))?.Where(p => !p.IsDeleted).OrderBy(p => p.Scope).ThenBy(p => p.PersonnelRoleId).ThenByDescending(p => p.EffectiveOn).ToList() ?? new List(); + await LoadComponentsAsync(rows, departmentId, null); + return rows; + } + + public async Task GetProfileAsync(string profileId, int departmentId) + { + if (string.IsNullOrWhiteSpace(profileId)) return null; + var row = await _profiles.GetByIdForDepartmentAsync(profileId, departmentId); + if (row == null || row.IsDeleted) return null; + await LoadComponentsAsync(new[] { row }, departmentId, null); + return row; + } + + /// Loads components and resolves protected values: a user read honours the grant, a workload read (purpose supplied) decrypts for the engine. + private async Task LoadComponentsAsync(IReadOnlyList rows, int departmentId, string workloadPurpose) + { + if (rows.Count == 0) return true; + var ids = rows.Select(r => r.EmployeeCompensationProfileId).ToList(); + var pay = (await _payComponents.GetByProfilesAsync(ids))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + var cost = (await _costComponents.GetByProfilesAsync(ids))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + var ok = true; + if (workloadPurpose == null) + { + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.Compensation); + await _seam.ResolveForReadAsync(pay, departmentId, WorkforceProtectedFields.PayComponent); + await _seam.ResolveForReadAsync(cost, departmentId, WorkforceProtectedFields.CostComponent); + } + else + { + ok &= await _seam.ResolveForWorkloadAsync(rows, departmentId, workloadPurpose, WorkforceProtectedFields.Compensation); + ok &= await _seam.ResolveForWorkloadAsync(pay, departmentId, workloadPurpose, WorkforceProtectedFields.PayComponent); + ok &= await _seam.ResolveForWorkloadAsync(cost, departmentId, workloadPurpose, WorkforceProtectedFields.CostComponent); + } + foreach (var row in rows) + { + row.PayComponents = pay.Where(c => c.EmployeeCompensationProfileId == row.EmployeeCompensationProfileId).OrderBy(c => c.Category).ThenBy(c => c.Name).ToList(); + row.CostComponents = cost.Where(c => c.EmployeeCompensationProfileId == row.EmployeeCompensationProfileId).OrderBy(c => c.Category).ThenBy(c => c.Name).ToList(); + } + return ok; + } + + public async Task SaveProfileAsync(EmployeeCompensationProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (!Enum.IsDefined(typeof(CompensationScopes), profile.Scope)) throw new InvalidOperationException("workforce_scope_invalid"); + if (!Enum.IsDefined(typeof(PayBases), profile.PayBasis)) throw new InvalidOperationException("workforce_pay_basis_invalid"); + if (profile.ExpiresOn.HasValue && profile.ExpiresOn < profile.EffectiveOn) throw new InvalidOperationException("workforce_dates_invalid"); + var scope = (CompensationScopes)profile.Scope; + if (scope == CompensationScopes.Employee) + { + var employment = await _employments.GetByIdForDepartmentAsync(profile.WorkforceEmploymentId ?? string.Empty, profile.DepartmentId); + if (employment == null || employment.IsDeleted) throw new InvalidOperationException("workforce_employment_not_found"); + profile.PersonnelRoleId = null; + } + else if (scope == CompensationScopes.RoleDefault) { if (!profile.PersonnelRoleId.HasValue) throw new InvalidOperationException("workforce_role_required"); profile.WorkforceEmploymentId = null; } + else { profile.WorkforceEmploymentId = null; profile.PersonnelRoleId = null; } + var existing = string.IsNullOrWhiteSpace(profile.EmployeeCompensationProfileId) ? null : await _profiles.GetByIdForDepartmentAsync(profile.EmployeeCompensationProfileId, profile.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + // A profile's period never overlaps another of the same scope / subject. + var siblings = scope == CompensationScopes.Employee + ? (await _profiles.GetByEmploymentAsync(profile.WorkforceEmploymentId))?.ToList() ?? new List() + : (await _profiles.GetDefaultsForDepartmentAsync(profile.DepartmentId))?.Where(p => p.Scope == profile.Scope && p.PersonnelRoleId == profile.PersonnelRoleId).ToList() ?? new List(); + if (siblings.Any(s => !s.IsDeleted && s.EmployeeCompensationProfileId != profile.EmployeeCompensationProfileId && Overlaps(s, profile))) throw new InvalidOperationException("workforce_profile_overlap"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new EmployeeCompensationProfile { DepartmentId = profile.DepartmentId, AddedOn = now, AddedByUserId = userId }; + var priorRates = existing == null ? null : (existing.BaseAmount, existing.RegularHourlyEquivalent, existing.RateMultipliersJson, existing.PayBasis).ToString(); + target.Scope = profile.Scope; target.WorkforceEmploymentId = profile.WorkforceEmploymentId; target.PersonnelRoleId = profile.PersonnelRoleId; + target.EffectiveOn = profile.EffectiveOn.Date; target.ExpiresOn = profile.ExpiresOn?.Date; target.Currency = string.IsNullOrWhiteSpace(profile.Currency) ? "USD" : profile.Currency.Trim().ToUpperInvariant(); + target.PayBasis = profile.PayBasis; target.StandardHoursPerDay = profile.StandardHoursPerDay; target.StandardHoursPerWeek = profile.StandardHoursPerWeek; target.StandardHoursPerYear = profile.StandardHoursPerYear; + target.Source = Trim(profile.Source) ?? "manual"; target.ImportBatchId = Trim(profile.ImportBatchId); target.SourceChecksum = Trim(profile.SourceChecksum); + foreach (var accessor in WorkforceProtectedFields.Compensation) Keep(existing, target, profile, accessor.Value); + // Any change to the rates un-approves the profile; approval is an explicit step. + if (priorRates != null && priorRates != (target.BaseAmount, target.RegularHourlyEquivalent, target.RateMultipliersJson, target.PayBasis).ToString()) { target.IsApproved = false; target.ApprovedByUserId = null; target.ApprovedOn = null; } + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_profiles, target, existing, profile.DepartmentId, WorkforceProtectedFields.Compensation, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.WorkforceCompensationChanged, ipAddress, userAgent, before, saved); + return await GetProfileAsync(saved.EmployeeCompensationProfileId, profile.DepartmentId); + } + + private static bool Overlaps(EmployeeCompensationProfile a, EmployeeCompensationProfile b) => a.EffectiveOn.Date <= (b.ExpiresOn ?? DateTime.MaxValue).Date && (a.ExpiresOn ?? DateTime.MaxValue).Date >= b.EffectiveOn.Date; + + public async Task SaveComponentsAsync(string profileId, int departmentId, List payComponents, List costComponents, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(profileId ?? string.Empty, departmentId); + if (profile == null || profile.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var now = DateTime.UtcNow; + var existingPay = (await _payComponents.GetByProfileAsync(profile.EmployeeCompensationProfileId))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + var existingCost = (await _costComponents.GetByProfileAsync(profile.EmployeeCompensationProfileId))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + var before = Snapshot(profile); + foreach (var input in payComponents ?? new List()) + { + if (!Enum.IsDefined(typeof(PayComponentCategories), input.Category) || !Enum.IsDefined(typeof(PayComponentBases), input.Basis)) throw new InvalidOperationException("workforce_component_invalid"); + var existing = string.IsNullOrWhiteSpace(input.EmployeePayComponentId) ? null : existingPay.FirstOrDefault(c => c.EmployeePayComponentId == input.EmployeePayComponentId); + var target = existing ?? new EmployeePayComponent { DepartmentId = departmentId, EmployeeCompensationProfileId = profile.EmployeeCompensationProfileId, AddedOn = now, AddedByUserId = userId }; + target.EffectiveOn = input.EffectiveOn?.Date; target.ExpiresOn = input.ExpiresOn?.Date; target.Category = input.Category; target.Name = Trim(input.Name); target.Basis = input.Basis; + target.EligiblePayCodesCsv = Trim(input.EligiblePayCodesCsv); target.PaidForEachOvertimeHour = input.PaidForEachOvertimeHour; target.SourceAgreement = Trim(input.SourceAgreement); + foreach (var accessor in WorkforceProtectedFields.PayComponent) Keep(existing, target, input, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + await _seam.SaveAsync(_payComponents, target, existing, departmentId, WorkforceProtectedFields.PayComponent, cancellationToken); + } + foreach (var stale in existingPay.Where(c => (payComponents ?? new List()).All(i => i.EmployeePayComponentId != c.EmployeePayComponentId))) + { + stale.IsDeleted = true; stale.EditedOn = now; stale.EditedByUserId = userId; + await _payComponents.SaveOrUpdateAsync(stale, cancellationToken); + } + foreach (var input in costComponents ?? new List()) + { + if (!Enum.IsDefined(typeof(CostComponentCategories), input.Category) || !Enum.IsDefined(typeof(CostComponentBases), input.Basis)) throw new InvalidOperationException("workforce_component_invalid"); + var existing = string.IsNullOrWhiteSpace(input.EmployeeCostComponentId) ? null : existingCost.FirstOrDefault(c => c.EmployeeCostComponentId == input.EmployeeCostComponentId); + var target = existing ?? new EmployeeCostComponent { DepartmentId = departmentId, EmployeeCompensationProfileId = profile.EmployeeCompensationProfileId, AddedOn = now, AddedByUserId = userId }; + target.EffectiveOn = input.EffectiveOn?.Date; target.ExpiresOn = input.ExpiresOn?.Date; target.Category = input.Category; target.Name = Trim(input.Name); target.Basis = input.Basis; + target.EligiblePayCodesCsv = Trim(input.EligiblePayCodesCsv); target.Source = Trim(input.Source); + foreach (var accessor in WorkforceProtectedFields.CostComponent) Keep(existing, target, input, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + await _seam.SaveAsync(_costComponents, target, existing, departmentId, WorkforceProtectedFields.CostComponent, cancellationToken); + } + foreach (var stale in existingCost.Where(c => (costComponents ?? new List()).All(i => i.EmployeeCostComponentId != c.EmployeeCostComponentId))) + { + stale.IsDeleted = true; stale.EditedOn = now; stale.EditedByUserId = userId; + await _costComponents.SaveOrUpdateAsync(stale, cancellationToken); + } + profile.IsApproved = false; profile.ApprovedByUserId = null; profile.ApprovedOn = null; profile.RowVersion++; profile.EditedOn = now; profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.WorkforceCompensationChanged, ipAddress, userAgent, before, profile); + return await GetProfileAsync(profile.EmployeeCompensationProfileId, departmentId); + } + + public async Task ApproveProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(profileId ?? string.Empty, departmentId); + if (profile == null || profile.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + if (profile.IsApproved) return await GetProfileAsync(profile.EmployeeCompensationProfileId, departmentId); + var before = Snapshot(profile); + profile.IsApproved = true; profile.ApprovedByUserId = userId; profile.ApprovedOn = DateTime.UtcNow; profile.RowVersion++; profile.EditedOn = profile.ApprovedOn; profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.WorkforceCompensationChanged, ipAddress, userAgent, before, profile); + return await GetProfileAsync(profile.EmployeeCompensationProfileId, departmentId); + } + + public async Task DeleteProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(profileId ?? string.Empty, departmentId); + if (profile == null || profile.IsDeleted) return false; + var before = Snapshot(profile); + profile.IsDeleted = true; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.WorkforceCompensationChanged, ipAddress, userAgent, before, profile); + return true; + } + + #endregion + + #region Costing + + public async Task<(EmployeeCompensationProfile Profile, bool IsFallback)> ResolveProfileAsync(string employmentId, int? personnelRoleId, int departmentId, DateTime asOf) + { + var candidates = new List<(EmployeeCompensationProfile Profile, bool IsFallback)>(); + if (!string.IsNullOrWhiteSpace(employmentId)) + { + var employee = (await _profiles.GetByEmploymentAsync(employmentId))?.Where(p => p.DepartmentId == departmentId && !p.IsDeleted && p.Scope == (int)CompensationScopes.Employee && p.Covers(asOf)).OrderByDescending(p => p.EffectiveOn).FirstOrDefault(); + if (employee != null) candidates.Add((employee, false)); + if (!personnelRoleId.HasValue) + { + var employment = await _employments.GetByIdForDepartmentAsync(employmentId, departmentId); + personnelRoleId = employment?.PersonnelRoleId; + } + } + if (candidates.Count == 0) + { + var defaults = (await _profiles.GetDefaultsForDepartmentAsync(departmentId))?.Where(p => !p.IsDeleted && p.Covers(asOf)).ToList() ?? new List(); + var role = personnelRoleId.HasValue ? defaults.Where(p => p.Scope == (int)CompensationScopes.RoleDefault && p.PersonnelRoleId == personnelRoleId).OrderByDescending(p => p.EffectiveOn).FirstOrDefault() : null; + if (role != null) candidates.Add((role, true)); + else + { + var department = defaults.Where(p => p.Scope == (int)CompensationScopes.DepartmentDefault).OrderByDescending(p => p.EffectiveOn).FirstOrDefault(); + if (department != null) candidates.Add((department, true)); + } + } + if (candidates.Count == 0) return (null, false); + var chosen = candidates[0]; + var resolved = await LoadComponentsAsync(new[] { chosen.Profile }, departmentId, WorkforceProtectedFields.CostingWorkloadPurpose); + if (!resolved || WorkforceProtectionSeam.IsUnavailable(chosen.Profile.BaseAmount) || WorkforceProtectionSeam.IsUnavailable(chosen.Profile.RegularHourlyEquivalent)) + { + // The workload could not decrypt: the engine must not guess. Blank the rates so the calculator flags NoRate. + chosen.Profile.BaseAmount = null; chosen.Profile.RegularHourlyEquivalent = null; chosen.Profile.RateMultipliersJson = null; + foreach (var c in chosen.Profile.PayComponents) c.Amount = null; + foreach (var c in chosen.Profile.CostComponents) { c.RateAmount = null; c.Cap = null; } + } + return chosen; + } + + public async Task CalculateLoadedCostAsync(int departmentId, LaborWorkQuantity work, int? personnelRoleId, DateTime asOf, string currency = "USD") + { + if (work == null) throw new ArgumentNullException(nameof(work)); + var (profile, isFallback) = await ResolveProfileAsync(work.WorkforceEmploymentId, personnelRoleId, departmentId, asOf); + return FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = work, Profile = profile, IsFallback = isFallback, AsOf = asOf, Currency = currency }); + } + + public async Task> GetClassificationRateAggregateAsync(int departmentId, DateTime asOf, string calOesAuthorityProfileCode) + { + var result = new List<(string, int, decimal, decimal)>(); + var employments = (await _employments.GetActiveInWindowAsync(departmentId, asOf, asOf))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + if (employments.Count == 0) return result; + var assignments = (await _assignments.GetByEmploymentsAsync(employments.Select(e => e.WorkforceEmploymentId)))?.Where(a => !a.IsDeleted && a.Covers(asOf) && !string.IsNullOrWhiteSpace(a.CalOesMarsClassificationCode) && (string.IsNullOrWhiteSpace(calOesAuthorityProfileCode) || string.Equals(a.CalOesMarsAuthorityProfileCode, calOesAuthorityProfileCode, StringComparison.OrdinalIgnoreCase))).ToList() ?? new List(); + if (assignments.Count == 0) return result; + var profiles = (await _profiles.GetByEmploymentsAsync(assignments.Select(a => a.WorkforceEmploymentId).Distinct()))?.Where(p => !p.IsDeleted && p.IsApproved && p.Scope == (int)CompensationScopes.Employee && p.Covers(asOf)).ToList() ?? new List(); + var current = profiles.GroupBy(p => p.WorkforceEmploymentId).Select(g => g.OrderByDescending(p => p.EffectiveOn).First()).ToList(); + if (current.Count == 0) return result; + var resolved = await LoadComponentsAsync(current, departmentId, WorkforceProtectedFields.CostingWorkloadPurpose); + if (!resolved) return result; + var samples = new List<(string Code, decimal Rate, decimal OvertimeAdder)>(); + foreach (var profile in current) + { + var rate = FieldCostCalculator.HourlyRate(profile); + if (!rate.HasValue) continue; + var classification = assignments.First(a => a.WorkforceEmploymentId == profile.WorkforceEmploymentId).CalOesMarsClassificationCode; + var adder = profile.PayComponents.Where(c => c.PaidForEachOvertimeHour && c.Basis == (int)PayComponentBases.PerHour && c.Covers(asOf) && c.AmountValue.HasValue).Sum(c => c.AmountValue.Value); + samples.Add((classification, rate.Value, adder)); + } + foreach (var group in samples.GroupBy(s => s.Code, StringComparer.OrdinalIgnoreCase).OrderBy(g => g.Key)) + result.Add((group.Key, group.Count(), FieldCostCalculator.Round(group.Average(s => s.Rate)), FieldCostCalculator.Round(group.Average(s => s.OvertimeAdder)))); + return result; + } + + #endregion + + #region Helpers + + private static void Keep(T existing, T target, T input, (Func Get, Action Set) accessor) + { + var value = accessor.Get(input); + if (existing != null && value == ProtectedDataEnvelope.RedactionValue) { accessor.Set(target, accessor.Get(existing)); return; } + accessor.Set(target, string.IsNullOrWhiteSpace(value) ? null : value.Trim()); + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + internal static string Snapshot(EmployeeCompensationProfile profile) + { + var clone = profile.CloneJson(); + clone.PayComponents = null; clone.CostComponents = null; + foreach (var a in WorkforceProtectedFields.Compensation) a.Value.Set(clone, WorkforceService.Marker(a.Value.Get(clone))); + return clone.CloneJsonToString(); + } + + private void Audit(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent, string before, EmployeeCompensationProfile 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/Workforce/FieldCostCalculator.cs b/Core/Resgrid.Services/Workforce/FieldCostCalculator.cs new file mode 100644 index 00000000..04877e63 --- /dev/null +++ b/Core/Resgrid.Services/Workforce/FieldCostCalculator.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Resgrid.Model.Workforce; + +namespace Resgrid.Services.Workforce +{ + /// + /// The one pure line calculator behind every field-cost run (Workforce & Business Operations plan, E3). + /// Labor: base rate for the pay basis × pay-code multiplier, plus eligible pay components (per hour / + /// percent of base / fixed spread over the standard year) and employer-cost components (percent of eligible + /// pay / per hour / per shift / per day / fixed annual ÷ standard hours). Resources: each applicable variable + /// component × approved usage, fuel as a direct rate or consumption × unit price (actual fuel cost overrides + /// both), straight-line depreciation = (acquisition − salvage) ÷ useful life (or annual depreciation ÷ expected + /// utilization for a time life), fixed annual components allocated by expected utilization. Anything that + /// cannot be priced becomes a blocked detail with its reason — never a silent zero. + /// + public static class FieldCostCalculator + { + public const decimal MilesPerKilometer = 0.621371m; + + #region Labor + + public static LaborCostResult CalculateLabor(LaborCostInput input) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var work = input.Work ?? throw new ArgumentException("Work quantity is required.", nameof(input)); + var result = new LaborCostResult { IsFallback = input.IsFallback }; + if (input.IsFallback) result.ReviewReasons.Add(input.Profile?.Scope == (int)CompensationScopes.DepartmentDefault ? LaborReviewReasons.DepartmentFallback : LaborReviewReasons.RoleFallback); + + // An approved payroll cost from the payroll system is the actual; nothing is estimated on top of it. + if (work.ApprovedPayrollCost.HasValue) + { + result.PayAmount = Round(work.ApprovedPayrollCost.Value); + result.IsEstimated = false; + result.ReviewReasons.Add(LaborReviewReasons.ApprovedPayrollCostUsed); + result.Details.Add(new LaborCostDetail { Kind = "Payroll", Name = "Approved payroll cost", Basis = "actual", Rate = work.ApprovedPayrollCost.Value, Amount = result.PayAmount }); + result.NeedsReview = input.IsFallback; + return result; + } + + var profile = input.Profile; + if (profile == null) + { + result.NeedsReview = true; + result.ReviewReasons.Add(LaborReviewReasons.NoProfile); + return result; + } + if (!profile.IsApproved) { result.NeedsReview = true; result.ReviewReasons.Add(LaborReviewReasons.UnapprovedProfile); } + if (!string.IsNullOrWhiteSpace(input.Currency) && !string.IsNullOrWhiteSpace(profile.Currency) && !string.Equals(input.Currency, profile.Currency, StringComparison.OrdinalIgnoreCase)) + { + result.NeedsReview = true; + result.ReviewReasons.Add(LaborReviewReasons.CurrencyMismatch); + } + + var baseRate = HourlyRate(profile); + if (!baseRate.HasValue) + { + result.NeedsReview = true; + result.ReviewReasons.Add(LaborReviewReasons.NoRate); + return result; + } + var multipliers = Multipliers(profile); + var code = (PayCodes)work.PayCode; + var multiplier = code == PayCodes.Regular || code == PayCodes.PaidLeave ? 1m : multipliers.TryGetValue(code.ToString(), out var m) ? m : DefaultMultiplier(code); + if (code != PayCodes.Regular && code != PayCodes.PaidLeave && !multipliers.ContainsKey(code.ToString())) result.ReviewReasons.Add(LaborReviewReasons.PayCodeMultiplierMissing); + + result.BaseRate = baseRate.Value; + result.Multiplier = multiplier; + result.PayAmount = Round(work.Hours * baseRate.Value * multiplier); + result.Details.Add(new LaborCostDetail { Kind = "Pay", Name = code.ToString(), Basis = "hour", Rate = Round4(baseRate.Value * multiplier), Amount = result.PayAmount, ComponentId = profile.EmployeeCompensationProfileId, Version = profile.RowVersion }); + + var standardYearHours = profile.StandardHoursPerYear ?? (profile.StandardHoursPerWeek.HasValue ? profile.StandardHoursPerWeek.Value * 52m : 2080m); + var standardDayHours = profile.StandardHoursPerDay ?? 8m; + var codeName = code.ToString(); + foreach (var component in (profile.PayComponents ?? new List()).Where(c => !c.IsDeleted && c.Covers(input.AsOf) && Eligible(c.EligiblePayCodesCsv, codeName))) + { + var amount = component.AmountValue; + if (!amount.HasValue) { result.NeedsReview = true; result.ReviewReasons.Add(LaborReviewReasons.NoRate); continue; } + // Fixed-period components are paid regardless of overtime; only components flagged PaidForEachOvertimeHour follow OT hours. + if (code == PayCodes.Overtime || code == PayCodes.DoubleTime) { if (!component.PaidForEachOvertimeHour) continue; } + var value = (PayComponentBases)component.Basis switch + { + PayComponentBases.PerHour => work.Hours * amount.Value, + PayComponentBases.PercentOfBase => result.PayAmount * amount.Value / 100m, + PayComponentBases.PerShift => standardDayHours > 0 ? work.Hours / standardDayHours * amount.Value : 0m, + PayComponentBases.PerPayPeriod => standardYearHours > 0 ? work.Hours / standardYearHours * amount.Value * 26m : 0m, + PayComponentBases.FixedAnnual => standardYearHours > 0 ? work.Hours / standardYearHours * amount.Value : 0m, + _ => 0m + }; + value = Round(value); + result.PayComponentAmount += value; + result.Details.Add(new LaborCostDetail { Kind = "PayComponent", Name = component.Name ?? ((PayComponentCategories)component.Category).ToString(), Basis = ((PayComponentBases)component.Basis).ToString(), Rate = amount.Value, Amount = value, ComponentId = component.EmployeePayComponentId, Version = component.RowVersion }); + } + + var eligiblePay = result.PayAmount + result.PayComponentAmount; + foreach (var component in (profile.CostComponents ?? new List()).Where(c => !c.IsDeleted && c.Covers(input.AsOf) && Eligible(c.EligiblePayCodesCsv, codeName))) + { + var rate = component.RateAmountValue; + if (!rate.HasValue) { result.NeedsReview = true; result.ReviewReasons.Add(LaborReviewReasons.NoRate); continue; } + var value = (CostComponentBases)component.Basis switch + { + CostComponentBases.PercentOfEligiblePay => eligiblePay * rate.Value / 100m, + CostComponentBases.PerHour => work.Hours * rate.Value, + CostComponentBases.PerShift => standardDayHours > 0 ? work.Hours / standardDayHours * rate.Value : 0m, + CostComponentBases.PerDay => standardDayHours > 0 ? Math.Ceiling(work.Hours / standardDayHours) * rate.Value : 0m, + CostComponentBases.FixedAnnual => standardYearHours > 0 ? work.Hours / standardYearHours * rate.Value : 0m, + _ => 0m + }; + var cap = component.CapValue; + if (cap.HasValue && value > cap.Value) value = cap.Value; + value = Round(value); + result.EmployerCostAmount += value; + result.Details.Add(new LaborCostDetail { Kind = "EmployerCost", Name = component.Name ?? ((CostComponentCategories)component.Category).ToString(), Basis = ((CostComponentBases)component.Basis).ToString(), Rate = rate.Value, Amount = value, ComponentId = component.EmployeeCostComponentId, Version = component.RowVersion }); + } + result.NeedsReview = result.NeedsReview || input.IsFallback; + return result; + } + + /// The profile's regular hourly rate for its pay basis. + public static decimal? HourlyRate(EmployeeCompensationProfile profile) + { + if (profile == null) return null; + if (profile.RegularHourlyEquivalentValue.HasValue) return profile.RegularHourlyEquivalentValue; + var amount = profile.BaseAmountValue; + if (!amount.HasValue) return null; + var perDay = profile.StandardHoursPerDay ?? 8m; + var perYear = profile.StandardHoursPerYear ?? (profile.StandardHoursPerWeek.HasValue ? profile.StandardHoursPerWeek.Value * 52m : 2080m); + return (PayBases)profile.PayBasis switch + { + PayBases.Hourly => amount, + PayBases.Salary => perYear > 0 ? Round4(amount.Value / perYear) : null, + PayBases.Daily => perDay > 0 ? Round4(amount.Value / perDay) : null, + PayBases.Shift => perDay > 0 ? Round4(amount.Value / perDay) : null, + PayBases.Stipend => perYear > 0 ? Round4(amount.Value / perYear) : null, + _ => null + }; + } + + public static Dictionary Multipliers(EmployeeCompensationProfile profile) + { + if (string.IsNullOrWhiteSpace(profile?.RateMultipliersJson) || WorkforceProtectionSeam.IsUnavailable(profile.RateMultipliersJson)) return new Dictionary(StringComparer.OrdinalIgnoreCase); + try { return new Dictionary(JsonConvert.DeserializeObject>(profile.RateMultipliersJson) ?? new Dictionary(), StringComparer.OrdinalIgnoreCase); } + catch { return new Dictionary(StringComparer.OrdinalIgnoreCase); } + } + + public static decimal DefaultMultiplier(PayCodes code) => code switch { PayCodes.Overtime => 1.5m, PayCodes.DoubleTime => 2m, PayCodes.Standby => 1m, PayCodes.Travel => 1m, _ => 1m }; + + private static bool Eligible(string csv, string code) => string.IsNullOrWhiteSpace(csv) || csv.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim()).Contains(code, StringComparer.OrdinalIgnoreCase); + + #endregion + + #region Resources + + public static ResourceCostResult CalculateResource(ResourceCostInput input) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var usage = input.Usage ?? throw new ArgumentException("Usage quantity is required.", nameof(input)); + var result = new ResourceCostResult { IsFallback = input.IsFallback }; + if (input.IsFallback) { result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.ClassFallback); } + var profile = input.Profile; + if (profile == null) { result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.NoProfile); return result; } + + var components = (profile.Components ?? new List()).Where(c => !c.IsDeleted && c.Covers(input.AsOf)).ToList(); + var hasDepreciationComponent = components.Any(c => c.Category == (int)ResourceCostCategories.Depreciation); + // Straight-line depreciation from the acquisition facts when no explicit depreciation component exists. + if (!hasDepreciationComponent && (profile.AcquisitionCost.HasValue || profile.UsefulLifeQuantity.HasValue || profile.UsefulLifeMonths.HasValue)) + { + var rate = DepreciationRate(profile); + var (qty, unit) = QuantityFor((AllocationBases)profile.AllocationBasis, usage); + if (!rate.HasValue) + { + result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.DepreciationInputsMissing); + result.Details.Add(new ResourceCostDetail { Category = ResourceCostCategories.Depreciation.ToString(), Basis = ((AllocationBases)profile.AllocationBasis).ToString(), Quantity = qty, Unit = unit, Blocked = true, Reason = ResourceReviewReasons.DepreciationInputsMissing }); + } + else result.Details.Add(new ResourceCostDetail { Category = ResourceCostCategories.Depreciation.ToString(), Basis = ((AllocationBases)profile.AllocationBasis).ToString(), Quantity = qty, Unit = unit, Rate = rate.Value, Amount = Round(qty * rate.Value), ComponentId = profile.ResourceCostProfileId, Version = profile.RowVersion }); + } + + var fuelOverridden = false; + foreach (var component in components) + { + var category = (ResourceCostCategories)component.Category; + var name = category.ToString(); + if (!component.IsApproved) { result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.ComponentUnapproved); } + if (input.ExcludedComponentIds.Contains(component.ResourceCostComponentId)) + { + result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.RepairDoubleCounted); + result.Details.Add(new ResourceCostDetail { Category = name, Basis = ((ResourceCostBases)component.Basis).ToString(), Blocked = true, Reason = ResourceReviewReasons.RepairDoubleCounted, ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + continue; + } + if (category == ResourceCostCategories.FuelEnergy && usage.ActualFuelCost.HasValue) + { + if (!fuelOverridden) { result.Details.Add(new ResourceCostDetail { Category = name, Basis = "actual", Quantity = 1, Unit = "actual", Rate = usage.ActualFuelCost.Value, Amount = Round(usage.ActualFuelCost.Value), ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); fuelOverridden = true; } + continue; + } + if (category == ResourceCostCategories.Maintenance && component.Source == (int)ResourceCostSources.WorkOrderRollingActual && (!component.SourceMeterStart.HasValue || !component.SourceMeterEnd.HasValue || component.SourceMeterEnd <= component.SourceMeterStart)) + { + result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.RollingWindowInsufficient); + result.Details.Add(new ResourceCostDetail { Category = name, Basis = ((ResourceCostBases)component.Basis).ToString(), Blocked = true, Reason = ResourceReviewReasons.RollingWindowInsufficient, ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + continue; + } + var basis = (ResourceCostBases)component.Basis; + var (quantity, unit) = QuantityFor(basis, usage, profile); + decimal? rate = component.Rate; + if (!rate.HasValue && category == ResourceCostCategories.FuelEnergy && component.ConsumptionQuantity.HasValue && component.UnitPrice.HasValue) rate = component.ConsumptionQuantity.Value * component.UnitPrice.Value; + if (basis == ResourceCostBases.FixedAnnual) + { + var utilization = profile.ExpectedAnnualUtilization; + if (!utilization.HasValue || utilization <= 0 || !rate.HasValue) + { + result.NeedsReview = true; result.ReviewReasons.Add(ResourceReviewReasons.UtilizationMissing); + result.Details.Add(new ResourceCostDetail { Category = name, Basis = basis.ToString(), Blocked = true, Reason = ResourceReviewReasons.UtilizationMissing, ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + continue; + } + var (allocQty, allocUnit) = QuantityFor((AllocationBases)profile.AllocationBasis, usage); + var perUnit = Round4(rate.Value / utilization.Value); + result.Details.Add(new ResourceCostDetail { Category = name, Basis = "FixedAnnual/" + allocUnit, Quantity = allocQty, Unit = allocUnit, Rate = perUnit, Amount = Round(allocQty * perUnit), ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + continue; + } + if (!rate.HasValue) + { + var reason = category == ResourceCostCategories.FuelEnergy ? ResourceReviewReasons.FuelInputsMissing : ResourceReviewReasons.NoProfile; + result.NeedsReview = true; result.ReviewReasons.Add(reason); + result.Details.Add(new ResourceCostDetail { Category = name, Basis = basis.ToString(), Quantity = quantity, Unit = unit, Blocked = true, Reason = reason, ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + continue; + } + result.Details.Add(new ResourceCostDetail { Category = name, Basis = basis.ToString(), Quantity = quantity, Unit = unit, Rate = Round4(rate.Value), Amount = Round(quantity * rate.Value), ComponentId = component.ResourceCostComponentId, Version = component.RowVersion }); + } + return result; + } + + /// Straight-line depreciation per allocation unit: (acquisition − salvage) ÷ useful-life quantity, or the annual amount ÷ expected annual utilization for a time life. + public static decimal? DepreciationRate(ResourceCostProfile profile) + { + if (profile == null || !profile.AcquisitionCost.HasValue) return null; + var depreciable = profile.AcquisitionCost.Value - (profile.SalvageValue ?? 0m); + if (depreciable < 0) return null; + if (profile.UsefulLifeQuantity.HasValue && profile.UsefulLifeQuantity.Value > 0) return Round4(depreciable / profile.UsefulLifeQuantity.Value); + if (profile.UsefulLifeMonths.HasValue && profile.UsefulLifeMonths.Value > 0 && profile.ExpectedAnnualUtilization.HasValue && profile.ExpectedAnnualUtilization.Value > 0) + return Round4(depreciable / (profile.UsefulLifeMonths.Value / 12m) / profile.ExpectedAnnualUtilization.Value); + return null; + } + + public static (decimal Quantity, string Unit) QuantityFor(AllocationBases basis, ResourceUsageQuantity usage) => basis switch + { + AllocationBases.Mile => (usage.Miles, "mile"), + AllocationBases.Kilometer => (Round(usage.Miles / MilesPerKilometer), "km"), + AllocationBases.EngineHour => (usage.EngineHours, "engine hour"), + AllocationBases.OperatingHour => (usage.OperatingHours, "operating hour"), + AllocationBases.Day => (usage.Days, "day"), + _ => (0m, "?") + }; + + public static (decimal Quantity, string Unit) QuantityFor(ResourceCostBases basis, ResourceUsageQuantity usage, ResourceCostProfile profile) => basis switch + { + ResourceCostBases.PerMile => (usage.Miles, "mile"), + ResourceCostBases.PerKilometer => (Round(usage.Miles / MilesPerKilometer), "km"), + ResourceCostBases.PerEngineHour => (usage.EngineHours, "engine hour"), + ResourceCostBases.PerOperatingHour => (usage.OperatingHours, "operating hour"), + ResourceCostBases.PerIdleHour => (usage.IdleHours, "idle hour"), + ResourceCostBases.PerDay => (usage.Days, "day"), + ResourceCostBases.PerDeployment => (usage.Deployments, "deployment"), + _ => QuantityFor((AllocationBases)profile.AllocationBasis, usage) + }; + + public static decimal ToMiles(decimal distance, string unit) => string.Equals(unit, "km", StringComparison.OrdinalIgnoreCase) ? Round(distance * MilesPerKilometer) : distance; + + #endregion + + public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero); + public static decimal Round4(decimal value) => Math.Round(value, 4, MidpointRounding.AwayFromZero); + } +} diff --git a/Core/Resgrid.Services/Workforce/FieldCostingService.cs b/Core/Resgrid.Services/Workforce/FieldCostingService.cs new file mode 100644 index 00000000..11e49505 --- /dev/null +++ b/Core/Resgrid.Services/Workforce/FieldCostingService.cs @@ -0,0 +1,728 @@ +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.CostRecovery.CalOesMars; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.Workforce +{ + /// + /// Resource cost profiles / components / usage entries and the internal field-cost runs for bids, calls and + /// deployments (plan E3 IFieldCostingService, E4 run semantics). Resource costing is plain numeric data + /// gated by ViewInternalCosts (74); personnel lines carry no rate in the clear — the priced detail (rate, + /// multiplier, components, employment) is an ADP catalog 28 envelope on FieldCostLines.ProtectedDetailJson. + /// A frozen run is immutable; a recalculation supersedes it. Revenue is only ever an observed number: a bid's + /// estimated total, customer invoices, or the selected Cal OES MARS recovery snapshot. + /// + public class FieldCostingService : IFieldCostingService + { + private readonly IResourceCostProfileRepository _profiles; + private readonly IResourceCostComponentRepository _components; + private readonly IResourceUsageEntryRepository _usage; + private readonly IFieldCostRunRepository _runs; + private readonly IFieldCostLineRepository _lines; + private readonly IWorkforceWorkerRepository _workers; + private readonly IWorkforceEmploymentRepository _employments; + private readonly IWorkforceWorkEntryRepository _workEntries; + private readonly ICompensationCostService _compensation; + private readonly IBidRepository _bids; + private readonly IBidLineItemRepository _bidLines; + private readonly IDeploymentRepository _deployments; + private readonly IDeploymentPersonnelRepository _deploymentPersonnel; + private readonly IDeploymentUnitRepository _deploymentUnits; + private readonly IDeploymentTimeReportRepository _reports; + private readonly IDeploymentTimeEntryRepository _entries; + private readonly IDeploymentExpenseRepository _expenses; + private readonly IInvoiceRepository _invoices; + private readonly Lazy _calOesMars; + private readonly IUnitsService _unitsService; + private readonly IEventAggregator _eventAggregator; + private readonly WorkforceProtectionSeam _seam; + + public FieldCostingService(IResourceCostProfileRepository profiles, IResourceCostComponentRepository components, IResourceUsageEntryRepository usage, IFieldCostRunRepository runs, IFieldCostLineRepository lines, + IWorkforceWorkerRepository workers, IWorkforceEmploymentRepository employments, IWorkforceWorkEntryRepository workEntries, ICompensationCostService compensation, + IBidRepository bids, IBidLineItemRepository bidLines, IDeploymentRepository deployments, IDeploymentPersonnelRepository deploymentPersonnel, IDeploymentUnitRepository deploymentUnits, + IDeploymentTimeReportRepository reports, IDeploymentTimeEntryRepository entries, IDeploymentExpenseRepository expenses, IInvoiceRepository invoices, Lazy calOesMars, + IUnitsService unitsService, IEventAggregator eventAggregator, + Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + { + _profiles = profiles; + _components = components; + _usage = usage; + _runs = runs; + _lines = lines; + _workers = workers; + _employments = employments; + _workEntries = workEntries; + _compensation = compensation; + _bids = bids; + _bidLines = bidLines; + _deployments = deployments; + _deploymentPersonnel = deploymentPersonnel; + _deploymentUnits = deploymentUnits; + _reports = reports; + _entries = entries; + _expenses = expenses; + _invoices = invoices; + _calOesMars = calOesMars; + _unitsService = unitsService; + _eventAggregator = eventAggregator; + _seam = new WorkforceProtectionSeam(protectedWrite, protectedRead, grant); + } + + #region Resource profiles + + public async Task> GetResourceProfilesAsync(int departmentId) + { + var rows = (await _profiles.GetForDepartmentAsync(departmentId))?.Where(p => !p.IsDeleted).OrderBy(p => p.Name).ToList() ?? new List(); + await LoadComponentsAsync(rows); + await NameSubjectsAsync(rows, departmentId); + return rows; + } + + public async Task GetResourceProfileAsync(string profileId, int departmentId) + { + if (string.IsNullOrWhiteSpace(profileId)) return null; + var row = await _profiles.GetByIdForDepartmentAsync(profileId, departmentId); + if (row == null || row.IsDeleted) return null; + await LoadComponentsAsync(new[] { row }); + await NameSubjectsAsync(new[] { row }, departmentId); + return row; + } + + private async Task LoadComponentsAsync(IReadOnlyList rows) + { + if (rows.Count == 0) return; + var components = (await _components.GetByProfilesAsync(rows.Select(r => r.ResourceCostProfileId)))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + foreach (var row in rows) row.Components = components.Where(c => c.ResourceCostProfileId == row.ResourceCostProfileId).OrderBy(c => c.Category).ToList(); + } + + private async Task NameSubjectsAsync(IReadOnlyList rows, int departmentId) + { + if (rows.All(r => !r.UnitId.HasValue)) { foreach (var r in rows) r.SubjectName = r.Name ?? r.ExternalResourceKey ?? r.InventoryAssetId; return; } + var units = (await _unitsService.GetUnitsForDepartmentAsync(departmentId))?.ToList() ?? new List(); + foreach (var row in rows) row.SubjectName = row.UnitId.HasValue ? units.FirstOrDefault(u => u.UnitId == row.UnitId)?.Name ?? row.Name : row.Name ?? row.ExternalResourceKey ?? row.InventoryAssetId; + } + + public async Task SaveResourceProfileAsync(ResourceCostProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (!Enum.IsDefined(typeof(ResourceSubjectTypes), profile.SubjectType)) throw new InvalidOperationException("workforce_subject_invalid"); + if (profile.SubjectType == (int)ResourceSubjectTypes.Unit && !profile.UnitId.HasValue) throw new InvalidOperationException("workforce_unit_required"); + if (profile.SubjectType == (int)ResourceSubjectTypes.InventoryAsset && string.IsNullOrWhiteSpace(profile.InventoryAssetId)) throw new InvalidOperationException("workforce_asset_required"); + if (profile.SubjectType == (int)ResourceSubjectTypes.External && string.IsNullOrWhiteSpace(profile.ExternalResourceKey)) throw new InvalidOperationException("workforce_external_key_required"); + if (!Enum.IsDefined(typeof(AllocationBases), profile.AllocationBasis)) throw new InvalidOperationException("workforce_allocation_invalid"); + if (profile.ExpiresOn.HasValue && profile.ExpiresOn < profile.EffectiveOn) throw new InvalidOperationException("workforce_dates_invalid"); + if (profile.AcquisitionCost < 0 || profile.SalvageValue < 0 || profile.UsefulLifeQuantity < 0 || profile.ExpectedAnnualUtilization < 0) throw new InvalidOperationException("workforce_amount_invalid"); + if (profile.SubjectType == (int)ResourceSubjectTypes.Unit) + { + var unit = await _unitsService.GetUnitByIdAsync(profile.UnitId.Value); + if (unit == null || unit.DepartmentId != profile.DepartmentId) throw new InvalidOperationException("workforce_unit_not_found"); + } + var existing = string.IsNullOrWhiteSpace(profile.ResourceCostProfileId) ? null : await _profiles.GetByIdForDepartmentAsync(profile.ResourceCostProfileId, profile.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var siblings = (await _profiles.GetForDepartmentAsync(profile.DepartmentId))?.Where(p => !p.IsDeleted && p.ResourceCostProfileId != profile.ResourceCostProfileId && SameSubject(p, profile) && Overlaps(p, profile)).ToList() ?? new List(); + if (siblings.Count > 0) throw new InvalidOperationException("workforce_profile_overlap"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new ResourceCostProfile { DepartmentId = profile.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.SubjectType = profile.SubjectType; target.UnitId = profile.SubjectType == (int)ResourceSubjectTypes.Unit ? profile.UnitId : null; + target.InventoryAssetId = profile.SubjectType == (int)ResourceSubjectTypes.InventoryAsset ? profile.InventoryAssetId?.Trim() : null; + target.ExternalResourceKey = profile.SubjectType == (int)ResourceSubjectTypes.External ? profile.ExternalResourceKey?.Trim() : null; + target.Name = Trim(profile.Name); target.EffectiveOn = profile.EffectiveOn.Date; target.ExpiresOn = profile.ExpiresOn?.Date; target.Currency = string.IsNullOrWhiteSpace(profile.Currency) ? "USD" : profile.Currency.Trim().ToUpperInvariant(); + target.AcquisitionCost = profile.AcquisitionCost; target.AcquisitionDate = profile.AcquisitionDate; target.InServiceDate = profile.InServiceDate; target.SalvageValue = profile.SalvageValue; + target.DepreciationMethod = (int)DepreciationMethods.StraightLine; target.AllocationBasis = profile.AllocationBasis; target.UsefulLifeQuantity = profile.UsefulLifeQuantity; target.UsefulLifeMonths = profile.UsefulLifeMonths; + target.ExpectedAnnualUtilization = profile.ExpectedAnnualUtilization; target.Source = Trim(profile.Source) ?? "manual"; + if (existing != null && (existing.AcquisitionCost != target.AcquisitionCost || existing.SalvageValue != target.SalvageValue || existing.UsefulLifeQuantity != target.UsefulLifeQuantity || existing.UsefulLifeMonths != target.UsefulLifeMonths || existing.ExpectedAnnualUtilization != target.ExpectedAnnualUtilization || existing.AllocationBasis != target.AllocationBasis)) + { target.IsApproved = false; target.ApprovedByUserId = null; target.ApprovedOn = null; } + if (existing == null) target.IsApproved = profile.IsApproved; + if (target.IsApproved && target.ApprovedOn == null) { target.ApprovedByUserId = userId; target.ApprovedOn = now; } + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _profiles.SaveOrUpdateAsync(target, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.ResourceCostProfileChanged, ipAddress, userAgent, before, Snapshot(saved)); + return await GetResourceProfileAsync(saved.ResourceCostProfileId, profile.DepartmentId); + } + + private static bool SameSubject(ResourceCostProfile a, ResourceCostProfile b) => a.SubjectType == b.SubjectType && a.UnitId == b.UnitId && string.Equals(a.InventoryAssetId, b.InventoryAssetId, StringComparison.OrdinalIgnoreCase) && string.Equals(a.ExternalResourceKey, b.ExternalResourceKey, StringComparison.OrdinalIgnoreCase); + private static bool Overlaps(ResourceCostProfile a, ResourceCostProfile b) => a.EffectiveOn.Date <= (b.ExpiresOn ?? DateTime.MaxValue).Date && (a.ExpiresOn ?? DateTime.MaxValue).Date >= b.EffectiveOn.Date; + + public async Task SaveResourceComponentsAsync(string profileId, int departmentId, List components, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(profileId ?? string.Empty, departmentId); + if (profile == null || profile.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = Snapshot(profile); + var now = DateTime.UtcNow; + var existingRows = (await _components.GetByProfileAsync(profile.ResourceCostProfileId))?.Where(c => !c.IsDeleted).ToList() ?? new List(); + var inputs = components ?? new List(); + foreach (var input in inputs) + { + if (!Enum.IsDefined(typeof(ResourceCostCategories), input.Category) || !Enum.IsDefined(typeof(ResourceCostBases), input.Basis) || !Enum.IsDefined(typeof(ResourceCostSources), input.Source)) throw new InvalidOperationException("workforce_component_invalid"); + if (input.Rate < 0 || input.UnitPrice < 0 || input.ConsumptionQuantity < 0) throw new InvalidOperationException("workforce_amount_invalid"); + var existing = string.IsNullOrWhiteSpace(input.ResourceCostComponentId) ? null : existingRows.FirstOrDefault(c => c.ResourceCostComponentId == input.ResourceCostComponentId); + var target = existing ?? new ResourceCostComponent { DepartmentId = departmentId, ResourceCostProfileId = profile.ResourceCostProfileId, AddedOn = now, AddedByUserId = userId }; + target.EffectiveOn = input.EffectiveOn?.Date; target.ExpiresOn = input.ExpiresOn?.Date; target.Category = input.Category; target.Basis = input.Basis; target.Rate = input.Rate; + target.ConsumptionQuantity = input.ConsumptionQuantity; target.ConsumptionUnit = Trim(input.ConsumptionUnit); target.UnitPrice = input.UnitPrice; target.Source = input.Source; + target.SourceWindowStart = input.SourceWindowStart; target.SourceWindowEnd = input.SourceWindowEnd; target.SourceMeterStart = input.SourceMeterStart; target.SourceMeterEnd = input.SourceMeterEnd; + target.IsApproved = input.IsApproved; + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + await _components.SaveOrUpdateAsync(target, cancellationToken); + } + foreach (var stale in existingRows.Where(c => inputs.All(i => i.ResourceCostComponentId != c.ResourceCostComponentId))) + { + stale.IsDeleted = true; stale.EditedOn = now; stale.EditedByUserId = userId; + await _components.SaveOrUpdateAsync(stale, cancellationToken); + } + profile.RowVersion++; profile.EditedOn = now; profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ResourceCostProfileChanged, ipAddress, userAgent, before, Snapshot(profile)); + return await GetResourceProfileAsync(profile.ResourceCostProfileId, departmentId); + } + + public async Task DeleteResourceProfileAsync(string profileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var profile = await _profiles.GetByIdForDepartmentAsync(profileId ?? string.Empty, departmentId); + if (profile == null || profile.IsDeleted) return false; + var before = Snapshot(profile); + profile.IsDeleted = true; profile.EditedOn = DateTime.UtcNow; profile.EditedByUserId = userId; + await _profiles.SaveOrUpdateAsync(profile, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ResourceCostProfileChanged, ipAddress, userAgent, before, Snapshot(profile)); + return true; + } + + #endregion + + #region Usage entries + + public async Task> GetUsageForDeploymentAsync(string deploymentId, int departmentId) => + string.IsNullOrWhiteSpace(deploymentId) ? new List() : (await _usage.GetByDeploymentAsync(deploymentId))?.Where(u => u.DepartmentId == departmentId && !u.IsDeleted).OrderBy(u => u.UsageDate).ToList() ?? new List(); + + public async Task> GetUsageForCallAsync(int callId, int departmentId) => + (await _usage.GetByCallAsync(callId))?.Where(u => u.DepartmentId == departmentId && !u.IsDeleted).OrderBy(u => u.UsageDate).ToList() ?? new List(); + + public async Task SaveUsageEntryAsync(ResourceUsageEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + if (!Enum.IsDefined(typeof(ResourceSubjectTypes), entry.SubjectType) || !Enum.IsDefined(typeof(UsagePhases), entry.Phase) || !Enum.IsDefined(typeof(UsageSources), entry.Source)) throw new InvalidOperationException("workforce_usage_invalid"); + if (string.IsNullOrWhiteSpace(entry.DeploymentId) && !entry.CallId.HasValue) throw new InvalidOperationException("workforce_usage_context_required"); + if (entry.SubjectType == (int)ResourceSubjectTypes.Unit && !entry.UnitId.HasValue) throw new InvalidOperationException("workforce_unit_required"); + if (!string.IsNullOrWhiteSpace(entry.DeploymentId)) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(entry.DeploymentId, entry.DepartmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("workforce_deployment_not_found"); + } + Canonicalize(entry); + if (entry.CanonicalDistanceMiles < 0 || entry.EngineHours < 0 || entry.OperatingHours < 0 || entry.IdleHours < 0 || entry.DeployedDays < 0 || entry.StandbyDays < 0 || entry.FuelQuantity < 0 || entry.FuelActualCost < 0) throw new InvalidOperationException("workforce_amount_invalid"); + var existing = string.IsNullOrWhiteSpace(entry.ResourceUsageEntryId) ? null : await _usage.GetByIdForDepartmentAsync(entry.ResourceUsageEntryId, entry.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = existing == null ? null : existing.CloneJsonToString(); + var now = DateTime.UtcNow; + var target = existing ?? new ResourceUsageEntry { DepartmentId = entry.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.SubjectType = entry.SubjectType; target.UnitId = entry.UnitId; target.InventoryAssetId = Trim(entry.InventoryAssetId); target.ExternalResourceKey = Trim(entry.ExternalResourceKey); + target.CallId = entry.CallId; target.DeploymentId = Trim(entry.DeploymentId); target.DeploymentTimeReportId = Trim(entry.DeploymentTimeReportId); target.UsageDate = entry.UsageDate.Date; target.Phase = entry.Phase; + target.StartOdometer = entry.StartOdometer; target.EndOdometer = entry.EndOdometer; target.DistanceUnit = entry.DistanceUnit; target.OriginalDistance = entry.OriginalDistance; target.CanonicalDistanceMiles = entry.CanonicalDistanceMiles; + target.StartEngineMeter = entry.StartEngineMeter; target.EndEngineMeter = entry.EndEngineMeter; target.EngineHours = entry.EngineHours; target.OperatingHours = entry.OperatingHours; target.IdleHours = entry.IdleHours; + target.DeployedDays = entry.DeployedDays; target.StandbyDays = entry.StandbyDays; target.FuelQuantity = entry.FuelQuantity; target.FuelUnit = Trim(entry.FuelUnit); target.FuelActualCost = entry.FuelActualCost; + target.Source = entry.Source; target.ExternalId = Trim(entry.ExternalId); target.IsApproved = entry.IsApproved; + target.NeedsReview = false; target.ReviewReason = null; + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + // Conflicting automatic vs manual readings for the same unit / date / context are queued for review (both sides). + var siblings = (!string.IsNullOrWhiteSpace(target.DeploymentId) ? await _usage.GetByDeploymentAsync(target.DeploymentId) : await _usage.GetByCallAsync(target.CallId.Value))? + .Where(u => !u.IsDeleted && u.ResourceUsageEntryId != target.ResourceUsageEntryId && u.UnitId == target.UnitId && u.SubjectType == target.SubjectType && u.UsageDate.Date == target.UsageDate.Date && IsAutomatic(u.Source) != IsAutomatic(target.Source) && u.CanonicalDistanceMiles.HasValue && target.CanonicalDistanceMiles.HasValue).ToList() ?? new List(); + foreach (var sibling in siblings) + { + var reference = Math.Max(sibling.CanonicalDistanceMiles.Value, target.CanonicalDistanceMiles.Value); + if (reference <= 0) continue; + var variance = Math.Abs(sibling.CanonicalDistanceMiles.Value - target.CanonicalDistanceMiles.Value) / reference * 100m; + if (variance <= Config.WorkforceConfig.UsageConflictTolerancePercent) continue; + target.NeedsReview = true; target.ReviewReason = "distance_conflict"; + if (!sibling.NeedsReview) { sibling.NeedsReview = true; sibling.ReviewReason = "distance_conflict"; await _usage.SaveOrUpdateAsync(sibling, cancellationToken); } + } + var saved = await _usage.SaveOrUpdateAsync(target, cancellationToken); + Audit(entry.DepartmentId, userId, AuditLogTypes.ResourceUsageChanged, ipAddress, userAgent, before, saved.CloneJsonToString()); + return saved; + } + + private static bool IsAutomatic(int source) => source == (int)UsageSources.Gps || source == (int)UsageSources.HardwareTracker; + + /// Distance and engine hours derive from meters when readings exist; distance is canonical in miles. + public static void Canonicalize(ResourceUsageEntry entry) + { + var unit = string.IsNullOrWhiteSpace(entry.DistanceUnit) ? "mi" : entry.DistanceUnit.Trim().ToLowerInvariant(); + entry.DistanceUnit = unit == "km" ? "km" : "mi"; + if (entry.StartOdometer.HasValue && entry.EndOdometer.HasValue && entry.EndOdometer >= entry.StartOdometer) entry.OriginalDistance = entry.EndOdometer - entry.StartOdometer; + entry.CanonicalDistanceMiles = entry.OriginalDistance.HasValue ? FieldCostCalculator.ToMiles(entry.OriginalDistance.Value, entry.DistanceUnit) : null; + if (entry.StartEngineMeter.HasValue && entry.EndEngineMeter.HasValue && entry.EndEngineMeter >= entry.StartEngineMeter) entry.EngineHours = FieldCostCalculator.Round(entry.EndEngineMeter.Value - entry.StartEngineMeter.Value); + } + + public async Task DeleteUsageEntryAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var row = await _usage.GetByIdForDepartmentAsync(id ?? string.Empty, departmentId); + if (row == null || row.IsDeleted) return false; + var before = row.CloneJsonToString(); + row.IsDeleted = true; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId; + await _usage.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.ResourceUsageChanged, ipAddress, userAgent, before, row.CloneJsonToString()); + return true; + } + + #endregion + + #region Runs + + public async Task> GetRunsAsync(int departmentId, int skip = 0, int take = 100) => (await _runs.GetForDepartmentAsync(departmentId, Math.Max(0, skip), Math.Clamp(take, 1, 500)))?.Where(r => !r.IsDeleted).ToList() ?? new List(); + public async Task> GetRunsForDeploymentAsync(string deploymentId, int departmentId) => string.IsNullOrWhiteSpace(deploymentId) ? new List() : (await _runs.GetByDeploymentAsync(deploymentId, departmentId))?.Where(r => !r.IsDeleted).OrderByDescending(r => r.AddedOn).ToList() ?? new List(); + public async Task> GetRunsForBidAsync(string bidId, int departmentId) => string.IsNullOrWhiteSpace(bidId) ? new List() : (await _runs.GetByBidAsync(bidId, departmentId))?.Where(r => !r.IsDeleted).OrderByDescending(r => r.AddedOn).ToList() ?? new List(); + public async Task> GetRunsForCallAsync(int callId, int departmentId) => (await _runs.GetByCallAsync(callId, departmentId))?.Where(r => !r.IsDeleted).OrderByDescending(r => r.AddedOn).ToList() ?? new List(); + + public async Task GetRunAsync(string runId, int departmentId) + { + if (string.IsNullOrWhiteSpace(runId)) return null; + var run = await _runs.GetByIdForDepartmentAsync(runId, departmentId); + if (run == null || run.IsDeleted) return null; + run.Lines = (await _lines.GetByRunAsync(run.FieldCostRunId))?.OrderBy(l => l.SortOrder).ToList() ?? new List(); + await _seam.ResolveForReadAsync(run.Lines, departmentId, WorkforceProtectedFields.CostLine); + return run; + } + + public async Task EstimateBidCostAsync(string bidId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var bid = await _bids.GetByIdForDepartmentAsync(bidId ?? string.Empty, departmentId); + if (bid == null || bid.IsDeleted) throw new InvalidOperationException("workforce_bid_not_found"); + var lines = (await _bidLines.GetByBidAsync(bid.BidId))?.OrderBy(l => l.SortOrder).ToList() ?? new List(); + var asOf = bid.RequestedStartOn ?? DateTime.UtcNow.Date; + var builder = new RunBuilder("USD", asOf); + var profiles = await ActiveResourceProfilesAsync(departmentId, asOf); + foreach (var line in lines) + { + var hoursPerDay = line.EstimatedHoursPerDay ?? 8m; + var days = line.EstimatedDays ?? 1m; + var quantity = line.Quantity <= 0 ? 1m : line.Quantity; + switch ((BidLineTypes)line.LineType) + { + case BidLineTypes.PersonnelCertification: + case BidLineTypes.Crew: + { + var heads = quantity * (line.CrewSize ?? 1); + var hours = FieldCostCalculator.Round(hoursPerDay * days * heads); + if (hours <= 0) break; + var regular = Math.Min(hours, Config.WorkforceConfig.DailyOvertimeThresholdHours * days * heads); + var overtime = hours - regular; + var work = new LaborWorkQuantity { SubjectLabel = line.Description, WorkDate = asOf, PayCode = (int)PayCodes.Regular, Hours = regular, SourceType = "BidLineItem", SourceId = line.BidLineItemId }; + builder.AddLabor(FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = work, Profile = (await _compensation.ResolveProfileAsync(null, null, departmentId, asOf)).Profile, IsFallback = true, AsOf = asOf, Currency = builder.Currency }), work, "BidLineItem", line.BidLineItemId, line.Description, true); + if (overtime > 0) + { + var otWork = new LaborWorkQuantity { SubjectLabel = line.Description, WorkDate = asOf, PayCode = (int)PayCodes.Overtime, Hours = overtime, SourceType = "BidLineItem", SourceId = line.BidLineItemId }; + builder.AddLabor(FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = otWork, Profile = (await _compensation.ResolveProfileAsync(null, null, departmentId, asOf)).Profile, IsFallback = true, AsOf = asOf, Currency = builder.Currency }), otWork, "BidLineItem", line.BidLineItemId, line.Description, true); + } + break; + } + case BidLineTypes.Vehicle: + case BidLineTypes.Equipment: + { + var usage = new ResourceUsageQuantity { SubjectLabel = line.Description, UsageDate = asOf, Days = days * quantity, OperatingHours = hoursPerDay * days * quantity, Deployments = quantity, SourceType = "BidLineItem", SourceId = line.BidLineItemId }; + var profile = profiles.FirstOrDefault(p => p.SubjectType == (int)ResourceSubjectTypes.External && !string.IsNullOrWhiteSpace(line.RateScheduleEntryId) && string.Equals(p.ExternalResourceKey, line.RateScheduleEntryId, StringComparison.OrdinalIgnoreCase)); + builder.AddResource(FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = usage, Profile = profile, IsFallback = profile != null, AsOf = asOf }), usage, "BidLineItem", line.BidLineItemId, line.Description, true); + break; + } + default: + break; + } + } + var run = builder.Build(new FieldCostRun { DepartmentId = departmentId, ContextType = (int)FieldCostContextTypes.Bid, BidId = bid.BidId, RunType = (int)FieldCostRunTypes.Estimate, ThroughDate = bid.RequestedEndOn, AddedOn = DateTime.UtcNow, AddedByUserId = userId }); + run.RevenueSource = (int)RevenueSources.BidEstimate; run.RevenueAmount = bid.EstimatedTotal; run.RevenueSourceId = bid.BidId; run.RevenueSourceVersion = bid.EditedOn?.ToString("O") ?? bid.AddedOn.ToString("O"); + ApplyMargin(run); + return await PersistRunAsync(run, builder.Lines, departmentId, userId, ipAddress, userAgent, cancellationToken); + } + + public async Task CalculateCallCostAsync(int callId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var workEntries = (await _workEntries.GetByCallAsync(callId))?.Where(w => w.DepartmentId == departmentId && !w.IsDeleted).ToList() ?? new List(); + var usage = await GetUsageForCallAsync(callId, departmentId); + if (workEntries.Count == 0 && usage.Count == 0) throw new InvalidOperationException("workforce_no_inputs"); + var asOf = workEntries.Select(w => w.WorkDate).Concat(usage.Select(u => u.UsageDate)).DefaultIfEmpty(DateTime.UtcNow.Date).Max(); + var builder = new RunBuilder("USD", asOf); + await _seam.ResolveForWorkloadAsync(workEntries, departmentId, WorkforceProtectedFields.CostingWorkloadPurpose, WorkforceProtectedFields.WorkEntry); + foreach (var entry in workEntries.OrderBy(w => w.WorkDate)) + { + var work = new LaborWorkQuantity { WorkforceEmploymentId = entry.WorkforceEmploymentId, SubjectLabel = ((WorkHoursTypes)entry.HoursType).ToString(), WorkDate = entry.WorkDate, PayCode = PayCodeFor((WorkHoursTypes)entry.HoursType), Hours = entry.Hours, SourceType = "WorkforceWorkEntry", SourceId = entry.WorkforceWorkEntryId, ApprovedPayrollCost = WorkforceProtectionSeam.IsUnavailable(entry.ApprovedPayrollCost) ? null : entry.ApprovedPayrollCostValue }; + var (profile, fallback) = await _compensation.ResolveProfileAsync(entry.WorkforceEmploymentId, null, departmentId, entry.WorkDate); + builder.AddLabor(FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = work, Profile = profile, IsFallback = fallback, AsOf = entry.WorkDate, Currency = builder.Currency }), work, "WorkforceWorkEntry", entry.WorkforceWorkEntryId, work.SubjectLabel, false); + } + await AddUsageLinesAsync(builder, usage, departmentId, asOf, false); + var run = builder.Build(new FieldCostRun { DepartmentId = departmentId, ContextType = (int)FieldCostContextTypes.Call, CallId = callId, RunType = (int)FieldCostRunTypes.Actual, ThroughDate = asOf, AddedOn = DateTime.UtcNow, AddedByUserId = userId }); + run.RevenueSource = (int)RevenueSources.None; run.RevenueAmount = null; + ApplyMargin(run); + return await PersistRunAsync(run, builder.Lines, departmentId, userId, ipAddress, userAgent, cancellationToken); + } + + public async Task CalculateDeploymentCostAsync(string deploymentId, int departmentId, DateTime? throughDate, RevenueSources revenueSource, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId ?? string.Empty, departmentId); + if (deployment == null || deployment.IsDeleted) throw new InvalidOperationException("workforce_deployment_not_found"); + var through = (throughDate ?? DateTime.UtcNow).Date; + var personnel = (await _deploymentPersonnel.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); + var units = (await _deploymentUnits.GetByDeploymentAsync(deployment.DeploymentId))?.ToList() ?? new List(); + var reports = (await _reports.GetByDeploymentAsync(deployment.DeploymentId))?.Where(r => !r.IsDeleted && r.DepartmentId == departmentId && (r.Status == (int)DeploymentTimeReportStatuses.Approved || r.Status == (int)DeploymentTimeReportStatuses.Billed) && r.ReportDate.Date <= through).ToList() ?? new List(); + var reportIds = new HashSet(reports.Select(r => r.DeploymentTimeReportId)); + var entries = (await _entries.GetByDeploymentAsync(deployment.DeploymentId))?.Where(e => e.DeploymentTimeReportId != null && reportIds.Contains(e.DeploymentTimeReportId)).ToList() ?? new List(); + var builder = new RunBuilder(string.IsNullOrWhiteSpace(deployment.Currency) ? "USD" : deployment.Currency, through); + + // Personnel: approved DTR hours by member and day; hours above the daily threshold price at Overtime. A payroll-approved + // cost on a matching work entry replaces the estimate. + var workEntries = (await _workEntries.GetByDeploymentAsync(deployment.DeploymentId))?.Where(w => w.DepartmentId == departmentId && !w.IsDeleted).ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(workEntries, departmentId, WorkforceProtectedFields.CostingWorkloadPurpose, WorkforceProtectedFields.WorkEntry); + var workerCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var employmentCache = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var group in entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Personnel && !string.IsNullOrWhiteSpace(e.DeploymentPersonnelId)).GroupBy(e => (e.DeploymentPersonnelId, Date: e.StartTime.Date)).OrderBy(g => g.Key.Date)) + { + var member = personnel.FirstOrDefault(p => p.DeploymentPersonnelId == group.Key.DeploymentPersonnelId); + if (member == null) continue; + if (!workerCache.TryGetValue(member.UserId, out var worker)) { worker = await _workers.GetByUserIdAsync(departmentId, member.UserId); workerCache[member.UserId] = worker; } + List employments = null; + if (worker != null && !employmentCache.TryGetValue(worker.WorkforceWorkerId, out employments)) { employments = (await _employments.GetByWorkerAsync(worker.WorkforceWorkerId))?.Where(e => !e.IsDeleted).ToList() ?? new List(); employmentCache[worker.WorkforceWorkerId] = employments; } + var employment = employments?.FirstOrDefault(e => e.Covers(group.Key.Date, group.Key.Date)); + var label = member.CallSign ?? "Personnel"; + var approved = worker == null ? null : workEntries.FirstOrDefault(w => w.WorkforceWorkerId == worker.WorkforceWorkerId && w.WorkDate.Date == group.Key.Date && !WorkforceProtectionSeam.IsUnavailable(w.ApprovedPayrollCost) && w.ApprovedPayrollCostValue.HasValue); + var byCode = new List<(int PayCode, decimal Hours)>(); + var deploymentHours = group.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Deployment).Sum(e => e.Hours); + if (deploymentHours > 0) + { + var regular = Math.Min(deploymentHours, Config.WorkforceConfig.DailyOvertimeThresholdHours); + byCode.Add(((int)PayCodes.Regular, FieldCostCalculator.Round(regular))); + if (deploymentHours > regular) byCode.Add(((int)PayCodes.Overtime, FieldCostCalculator.Round(deploymentHours - regular))); + } + var standby = group.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Standby).Sum(e => e.Hours); + if (standby > 0) byCode.Add(((int)PayCodes.Standby, FieldCostCalculator.Round(standby))); + var travel = group.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Travel).Sum(e => e.Hours); + if (travel > 0) byCode.Add(((int)PayCodes.Travel, FieldCostCalculator.Round(travel))); + var first = true; + foreach (var (payCode, hours) in byCode) + { + var work = new LaborWorkQuantity { WorkforceEmploymentId = employment?.WorkforceEmploymentId, SubjectLabel = label, WorkDate = group.Key.Date, PayCode = payCode, Hours = hours, SourceType = "DeploymentTimeEntry", SourceId = group.First().DeploymentTimeReportId, ApprovedPayrollCost = first ? approved?.ApprovedPayrollCostValue : null }; + first = false; + var (profile, fallback) = employment == null ? await _compensation.ResolveProfileAsync(null, null, departmentId, group.Key.Date) : await _compensation.ResolveProfileAsync(employment.WorkforceEmploymentId, employment.PersonnelRoleId, departmentId, group.Key.Date); + var result = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = work, Profile = profile, IsFallback = fallback || employment == null, AsOf = group.Key.Date, Currency = builder.Currency }); + if (employment == null && !result.ReviewReasons.Contains(LaborReviewReasons.NoProfile)) { result.NeedsReview = true; result.ReviewReasons.Add("no_employment_for_member"); } + builder.AddLabor(result, work, "DeploymentPersonnel", member.DeploymentPersonnelId, label, false); + } + } + + // Units: DTR unit hours (operating / idle / days) merged with usage entries (distance, engine hours, fuel). + var usage = await GetUsageForDeploymentAsync(deployment.DeploymentId, departmentId); + var profiles = await ActiveResourceProfilesAsync(departmentId, through); + var unitNames = (await _unitsService.GetUnitsForDepartmentAsync(departmentId))?.ToDictionary(u => u.UnitId, u => u.Name) ?? new Dictionary(); + foreach (var unit in units) + { + var unitEntries = entries.Where(e => e.SubjectType == (int)DeploymentTimeSubjectTypes.Unit && e.DeploymentUnitId == unit.DeploymentUnitId).ToList(); + var unitUsage = usage.Where(u => u.UnitId == unit.UnitId && u.UsageDate.Date <= through).ToList(); + if (unitEntries.Count == 0 && unitUsage.Count == 0) continue; + var quantity = new ResourceUsageQuantity + { + SubjectLabel = unitNames.TryGetValue(unit.UnitId, out var unitName) ? unitName : unit.CallSign ?? $"Unit {unit.UnitId}", + UsageDate = through, + OperatingHours = FieldCostCalculator.Round(unitEntries.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Deployment || e.EntryType == (int)DeploymentTimeEntryTypes.Travel).Sum(e => e.Hours) + unitUsage.Sum(u => u.OperatingHours ?? 0m)), + IdleHours = FieldCostCalculator.Round(unitEntries.Where(e => e.EntryType == (int)DeploymentTimeEntryTypes.Standby).Sum(e => e.Hours) + unitUsage.Sum(u => u.IdleHours ?? 0m)), + EngineHours = FieldCostCalculator.Round(unitUsage.Sum(u => u.EngineHours ?? 0m)), + Miles = FieldCostCalculator.Round(unitEntries.Sum(e => FieldCostCalculator.ToMiles(e.MileageKm ?? 0m, "km")) + unitUsage.Sum(u => u.CanonicalDistanceMiles ?? 0m)), + Days = Math.Max(unitEntries.Select(e => e.StartTime.Date).Distinct().Count(), unitUsage.Sum(u => (u.DeployedDays ?? 0m) + (u.StandbyDays ?? 0m))), + Deployments = 1, + ActualFuelCost = unitUsage.Any(u => u.FuelActualCost.HasValue) ? unitUsage.Sum(u => u.FuelActualCost ?? 0m) : null, + SourceType = "DeploymentUnit", SourceId = unit.DeploymentUnitId + }; + var profile = profiles.FirstOrDefault(p => p.SubjectType == (int)ResourceSubjectTypes.Unit && p.UnitId == unit.UnitId); + var result = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = quantity, Profile = profile, IsFallback = false, AsOf = through }); + if (unitUsage.Any(u => u.NeedsReview)) { result.NeedsReview = true; result.ReviewReasons.Add("usage_needs_review"); } + builder.AddResource(result, quantity, "DeploymentUnit", unit.DeploymentUnitId, quantity.SubjectLabel, false); + } + // Usage for resources that are not on the unit roster (assets, external keys). + await AddUsageLinesAsync(builder, usage.Where(u => u.UsageDate.Date <= through && (u.SubjectType != (int)ResourceSubjectTypes.Unit || units.All(x => x.UnitId != u.UnitId))).ToList(), departmentId, through, false); + + // Expenses through the date (Phase C expense rows; not per-person, not protected). + foreach (var expense in (await _expenses.GetByDeploymentAsync(deployment.DeploymentId))?.Where(e => !e.IsDeleted && e.ExpenseDate.Date <= through).OrderBy(e => e.ExpenseDate) ?? Enumerable.Empty()) + builder.AddExpense(expense.ExpenseDate, ((DeploymentExpenseTypes)expense.ExpenseType).ToString(), expense.Description, expense.Amount, "DeploymentExpense", expense.DeploymentExpenseId); + + var run = builder.Build(new FieldCostRun { DepartmentId = departmentId, ContextType = (int)FieldCostContextTypes.Deployment, DeploymentId = deployment.DeploymentId, RunType = (int)FieldCostRunTypes.Actual, ThroughDate = through, AddedOn = DateTime.UtcNow, AddedByUserId = userId }); + await ApplyRevenueAsync(run, deployment, revenueSource, departmentId); + ApplyMargin(run); + return await PersistRunAsync(run, builder.Lines, departmentId, userId, ipAddress, userAgent, cancellationToken); + } + + private async Task AddUsageLinesAsync(RunBuilder builder, List usage, int departmentId, DateTime asOf, bool estimated) + { + if (usage.Count == 0) return; + var profiles = await ActiveResourceProfilesAsync(departmentId, asOf); + var unitNames = usage.Any(u => u.UnitId.HasValue) ? (await _unitsService.GetUnitsForDepartmentAsync(departmentId))?.ToDictionary(u => u.UnitId, u => u.Name) ?? new Dictionary() : new Dictionary(); + foreach (var group in usage.GroupBy(u => (u.SubjectType, u.UnitId, u.InventoryAssetId, u.ExternalResourceKey))) + { + var rows = group.ToList(); + var label = group.Key.UnitId.HasValue && unitNames.TryGetValue(group.Key.UnitId.Value, out var name) ? name : group.Key.InventoryAssetId ?? group.Key.ExternalResourceKey ?? "Resource"; + var quantity = new ResourceUsageQuantity + { + SubjectLabel = label, UsageDate = rows.Max(r => r.UsageDate), + Miles = FieldCostCalculator.Round(rows.Sum(r => r.CanonicalDistanceMiles ?? 0m)), EngineHours = FieldCostCalculator.Round(rows.Sum(r => r.EngineHours ?? 0m)), + OperatingHours = FieldCostCalculator.Round(rows.Sum(r => r.OperatingHours ?? 0m)), IdleHours = FieldCostCalculator.Round(rows.Sum(r => r.IdleHours ?? 0m)), + Days = rows.Sum(r => (r.DeployedDays ?? 0m) + (r.StandbyDays ?? 0m)), Deployments = 1, + ActualFuelCost = rows.Any(r => r.FuelActualCost.HasValue) ? rows.Sum(r => r.FuelActualCost ?? 0m) : null, + SourceType = "ResourceUsageEntry", SourceId = string.Join(",", rows.Select(r => r.ResourceUsageEntryId)) + }; + var profile = profiles.FirstOrDefault(p => p.SubjectType == group.Key.SubjectType && p.UnitId == group.Key.UnitId && string.Equals(p.InventoryAssetId, group.Key.InventoryAssetId, StringComparison.OrdinalIgnoreCase) && string.Equals(p.ExternalResourceKey, group.Key.ExternalResourceKey, StringComparison.OrdinalIgnoreCase)); + var result = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = quantity, Profile = profile, IsFallback = false, AsOf = asOf }); + if (rows.Any(r => r.NeedsReview)) { result.NeedsReview = true; result.ReviewReasons.Add("usage_needs_review"); } + builder.AddResource(result, quantity, "ResourceUsageEntry", rows[0].ResourceUsageEntryId, label, estimated); + } + } + + private async Task> ActiveResourceProfilesAsync(int departmentId, DateTime asOf) + { + var rows = (await _profiles.GetForDepartmentAsync(departmentId))?.Where(p => !p.IsDeleted && p.Covers(asOf)).ToList() ?? new List(); + await LoadComponentsAsync(rows); + return rows; + } + + private static int PayCodeFor(WorkHoursTypes type) => type switch + { + WorkHoursTypes.Overtime => (int)PayCodes.Overtime, + WorkHoursTypes.DoubleTime => (int)PayCodes.DoubleTime, + WorkHoursTypes.Standby => (int)PayCodes.Standby, + WorkHoursTypes.Travel => (int)PayCodes.Travel, + WorkHoursTypes.PaidLeave => (int)PayCodes.PaidLeave, + _ => (int)PayCodes.Regular + }; + + private async Task ApplyRevenueAsync(FieldCostRun run, Deployment deployment, RevenueSources source, int departmentId) + { + run.RevenueSource = (int)source; + run.RevenueAmount = null; run.RevenueSourceId = null; run.RevenueSourceVersion = null; + switch (source) + { + case RevenueSources.BidEstimate: + { + if (string.IsNullOrWhiteSpace(deployment.BidId)) return; + var bid = await _bids.GetByIdForDepartmentAsync(deployment.BidId, departmentId); + if (bid == null || bid.IsDeleted) return; + run.RevenueAmount = bid.EstimatedTotal; run.RevenueSourceId = bid.BidId; run.RevenueSourceVersion = bid.EditedOn?.ToString("O") ?? bid.AddedOn.ToString("O"); + return; + } + case RevenueSources.CustomerInvoice: + { + var invoices = (await _invoices.GetForDepartmentAsync(departmentId, new InvoiceListFilter { Skip = 0, Take = 1000 }))?.Where(i => !i.IsDeleted && i.DeploymentId == deployment.DeploymentId && i.Status != (int)InvoiceStatus.Void && i.Status != (int)InvoiceStatus.Draft).ToList() ?? new List(); + if (invoices.Count == 0) return; + run.RevenueAmount = FieldCostCalculator.Round(invoices.Sum(i => i.Total)); run.RevenueSourceId = string.Join(",", invoices.Select(i => i.InvoiceId)); run.RevenueSourceVersion = invoices.Max(i => i.EditedOn ?? i.AddedOn).ToString("O"); + return; + } + case RevenueSources.CalOesMarsExpected: + case RevenueSources.CalOesMarsApproved: + case RevenueSources.CalOesMarsPaid: + { + var items = (await _calOesMars.Value.GetWorkItemsForDeploymentAsync(deployment.DeploymentId, departmentId))?.Where(w => w.RecordType == (int)CalOesMarsRecordTypes.F42 || w.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice).ToList() ?? new List(); + var current = items.Where(w => items.All(o => o.SupersedesWorkItemId != w.CalOesMarsWorkItemId)).ToList(); + if (current.Count == 0) return; + decimal? total = source switch + { + RevenueSources.CalOesMarsExpected => current.Any(w => w.ExpectedTotal.HasValue) ? current.Sum(w => w.ExpectedTotal ?? 0m) : null, + RevenueSources.CalOesMarsApproved => current.Any(w => w.ApprovedTotal.HasValue) ? current.Sum(w => w.ApprovedTotal ?? 0m) : null, + _ => current.Any(w => w.PaidTotal.HasValue) ? current.Sum(w => w.PaidTotal ?? 0m) : null + }; + if (!total.HasValue) return; + run.RevenueAmount = FieldCostCalculator.Round(total.Value); run.RevenueSourceId = string.Join(",", current.Select(w => w.CalOesMarsWorkItemId)); run.RevenueSourceVersion = string.Join(",", current.Select(w => w.RowVersion)); + return; + } + default: + return; + } + } + + internal static void ApplyMargin(FieldCostRun run) + { + run.TotalLoadedCost = FieldCostCalculator.Round(run.PersonnelTotal + run.ResourceTotal + run.ConsumableTotal + run.ExpenseTotal + run.OverheadTotal); + run.BreakEvenRevenue = run.TotalLoadedCost; + if (run.RevenueAmount.HasValue) + { + run.ContributionMargin = FieldCostCalculator.Round(run.RevenueAmount.Value - run.TotalLoadedCost); + run.ContributionMarginPercent = run.RevenueAmount.Value == 0 ? null : FieldCostCalculator.Round(run.ContributionMargin.Value / run.RevenueAmount.Value * 100m); + } + else { run.ContributionMargin = null; run.ContributionMarginPercent = null; } + } + + /// Earlier unfrozen runs of the same context and type are replaced; the latest frozen one is recorded as superseded-by-this. + private async Task PersistRunAsync(FieldCostRun run, List lines, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken) + { + var previous = run.ContextType switch + { + (int)FieldCostContextTypes.Bid => await GetRunsForBidAsync(run.BidId, departmentId), + (int)FieldCostContextTypes.Call => await GetRunsForCallAsync(run.CallId.Value, departmentId), + _ => await GetRunsForDeploymentAsync(run.DeploymentId, departmentId) + }; + previous = previous.Where(p => p.RunType == run.RunType).ToList(); + foreach (var stale in previous.Where(p => !p.IsFrozen)) + { + await _lines.DeleteByRunAsync(stale.FieldCostRunId, cancellationToken); + stale.IsDeleted = true; stale.EditedOn = DateTime.UtcNow; stale.EditedByUserId = userId; + await _runs.SaveOrUpdateAsync(stale, cancellationToken); + } + run.SupersedesRunId = previous.Where(p => p.Status == (int)FieldCostRunStatuses.Frozen).OrderByDescending(p => p.FrozenOn).FirstOrDefault()?.FieldCostRunId; + var saved = await _runs.SaveOrUpdateAsync(run, cancellationToken); + var order = 0; + foreach (var line in lines) + { + line.FieldCostRunId = saved.FieldCostRunId; line.DepartmentId = departmentId; line.SortOrder = order++; line.AddedOn = saved.AddedOn; line.AddedByUserId = userId; + await _seam.SaveAsync(_lines, line, null, departmentId, WorkforceProtectedFields.CostLine, cancellationToken); + } + Audit(departmentId, userId, AuditLogTypes.FieldCostRunCreated, ipAddress, userAgent, null, RunSnapshot(saved)); + return await GetRunAsync(saved.FieldCostRunId, departmentId); + } + + public async Task FreezeCostRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await _runs.GetByIdForDepartmentAsync(runId ?? string.Empty, departmentId); + if (run == null || run.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + if (run.IsFrozen) return await GetRunAsync(run.FieldCostRunId, departmentId); + var before = RunSnapshot(run); + var now = DateTime.UtcNow; + run.Status = (int)FieldCostRunStatuses.Frozen; run.FrozenByUserId = userId; run.FrozenOn = now; run.EditedOn = now; run.EditedByUserId = userId; + await _runs.SaveOrUpdateAsync(run, cancellationToken); + if (!string.IsNullOrWhiteSpace(run.SupersedesRunId)) + { + var superseded = await _runs.GetByIdForDepartmentAsync(run.SupersedesRunId, departmentId); + if (superseded != null && superseded.Status == (int)FieldCostRunStatuses.Frozen) { superseded.Status = (int)FieldCostRunStatuses.Superseded; superseded.EditedOn = now; superseded.EditedByUserId = userId; await _runs.SaveOrUpdateAsync(superseded, cancellationToken); } + } + Audit(departmentId, userId, AuditLogTypes.FieldCostRunFrozen, ipAddress, userAgent, before, RunSnapshot(run)); + return await GetRunAsync(run.FieldCostRunId, departmentId); + } + + public async Task CompareEstimateToActualAsync(string deploymentId, int departmentId) + { + var deployment = await _deployments.GetByIdForDepartmentAsync(deploymentId ?? string.Empty, departmentId); + if (deployment == null || deployment.IsDeleted) return null; + var actual = (await GetRunsForDeploymentAsync(deployment.DeploymentId, departmentId)).Where(r => r.RunType == (int)FieldCostRunTypes.Actual).OrderByDescending(r => r.Status == (int)FieldCostRunStatuses.Frozen).ThenByDescending(r => r.AddedOn).FirstOrDefault(); + var estimate = string.IsNullOrWhiteSpace(deployment.BidId) ? null : (await GetRunsForBidAsync(deployment.BidId, departmentId)).Where(r => r.RunType == (int)FieldCostRunTypes.Estimate).OrderByDescending(r => r.Status == (int)FieldCostRunStatuses.Frozen).ThenByDescending(r => r.AddedOn).FirstOrDefault(); + return new FieldCostComparison { Estimate = estimate, Actual = actual }; + } + + public async Task GetFieldCostSummaryAsync(string runId, int departmentId) + { + var run = await _runs.GetByIdForDepartmentAsync(runId ?? string.Empty, departmentId); + if (run == null || run.IsDeleted) return null; + return new FieldCostSummary + { + FieldCostRunId = run.FieldCostRunId, ContextType = run.ContextType, RunType = run.RunType, Status = run.Status, ThroughDate = run.ThroughDate, Currency = run.Currency, + PersonnelTotal = run.PersonnelTotal, ResourceTotal = run.ResourceTotal, ConsumableTotal = run.ConsumableTotal, ExpenseTotal = run.ExpenseTotal, OverheadTotal = run.OverheadTotal, TotalLoadedCost = run.TotalLoadedCost, + RevenueSource = run.RevenueSource, RevenueAmount = run.RevenueAmount, ContributionMargin = run.ContributionMargin, ContributionMarginPercent = run.ContributionMarginPercent, BreakEvenRevenue = run.BreakEvenRevenue, + MissingInputCount = run.MissingInputCount, FrozenOn = run.FrozenOn + }; + } + + public async Task DeleteRunAsync(string runId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var run = await _runs.GetByIdForDepartmentAsync(runId ?? string.Empty, departmentId); + if (run == null || run.IsDeleted) return false; + if (run.IsFrozen) throw new InvalidOperationException("workforce_run_frozen"); + await _lines.DeleteByRunAsync(run.FieldCostRunId, cancellationToken); + run.IsDeleted = true; run.EditedOn = DateTime.UtcNow; run.EditedByUserId = userId; + await _runs.SaveOrUpdateAsync(run, cancellationToken); + Audit(departmentId, userId, AuditLogTypes.FieldCostRunCreated, ipAddress, userAgent, RunSnapshot(run), null); + return true; + } + + #endregion + + #region Run builder + + /// Accumulates lines and totals for one run. Personnel lines keep the rate out of the clear column and put the priced detail into the protected envelope. + internal sealed class RunBuilder + { + private readonly Dictionary _versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + public string Currency { get; } + public DateTime AsOf { get; } + public List Lines { get; } = new List(); + public decimal Personnel { get; private set; } + public decimal Resource { get; private set; } + public decimal Consumable { get; private set; } + public decimal Expense { get; private set; } + public int Missing { get; private set; } + + public RunBuilder(string currency, DateTime asOf) { Currency = string.IsNullOrWhiteSpace(currency) ? "USD" : currency.ToUpperInvariant(); AsOf = asOf; } + + public void AddLabor(LaborCostResult result, LaborWorkQuantity work, string subjectType, string subjectId, string label, bool estimated) + { + Personnel += result.LoadedCost; + if (result.NeedsReview) Missing++; + foreach (var d in result.Details) if (!string.IsNullOrWhiteSpace(d.ComponentId)) _versions[d.ComponentId] = d.Version; + Lines.Add(new FieldCostLine + { + LineDate = work.WorkDate, Category = (int)FieldCostCategories.Personnel, SubjectType = subjectType, SubjectId = subjectId, SubjectLabel = label, Component = ((PayCodes)work.PayCode).ToString(), + Quantity = work.Hours, Unit = "hour", Rate = null, Amount = result.LoadedCost, SourceType = work.SourceType, SourceId = work.SourceId, IsEstimated = estimated || result.IsEstimated, IsFallback = result.IsFallback, + ReviewReason = result.ReviewReasons.Count == 0 ? null : string.Join(",", result.ReviewReasons.Distinct()), + ProtectedDetailJson = JsonConvert.SerializeObject(new { work.WorkforceEmploymentId, result.BaseRate, result.Multiplier, result.PayAmount, result.PayComponentAmount, result.EmployerCostAmount, result.Details }) + }); + } + + public void AddResource(ResourceCostResult result, ResourceUsageQuantity usage, string subjectType, string subjectId, string label, bool estimated) + { + if (result.NeedsReview) Missing++; + if (result.Details.Count == 0) + { + Lines.Add(new FieldCostLine { LineDate = usage.UsageDate, Category = (int)FieldCostCategories.Resource, SubjectType = subjectType, SubjectId = subjectId, SubjectLabel = label, Component = "NoProfile", Quantity = usage.Miles + usage.EngineHours + usage.OperatingHours + usage.Days, Unit = "mixed", Rate = null, Amount = 0m, SourceType = usage.SourceType, SourceId = usage.SourceId, IsEstimated = estimated, IsFallback = result.IsFallback, ReviewReason = string.Join(",", result.ReviewReasons.Distinct()) }); + return; + } + foreach (var d in result.Details) + { + if (!string.IsNullOrWhiteSpace(d.ComponentId)) _versions[d.ComponentId] = d.Version; + var category = d.Category == ResourceCostCategories.Consumables.ToString() ? FieldCostCategories.Consumable : FieldCostCategories.Resource; + if (category == FieldCostCategories.Consumable) Consumable += d.Amount; else Resource += d.Amount; + Lines.Add(new FieldCostLine + { + LineDate = usage.UsageDate, Category = (int)category, SubjectType = subjectType, SubjectId = subjectId, SubjectLabel = label, Component = d.Category, Quantity = d.Quantity, Unit = d.Unit ?? d.Basis, Rate = d.Blocked ? null : d.Rate, Amount = d.Amount, + SourceType = usage.SourceType, SourceId = usage.SourceId, IsEstimated = estimated, IsFallback = result.IsFallback, ReviewReason = d.Blocked ? d.Reason : (result.ReviewReasons.Contains("usage_needs_review") ? "usage_needs_review" : null) + }); + } + } + + public void AddExpense(DateTime date, string component, string description, decimal amount, string sourceType, string sourceId) + { + var value = FieldCostCalculator.Round(amount); + Expense += value; + Lines.Add(new FieldCostLine { LineDate = date, Category = (int)FieldCostCategories.Expense, SubjectType = "Expense", SubjectId = sourceId, SubjectLabel = description, Component = component, Quantity = 1, Unit = "each", Rate = value, Amount = value, SourceType = sourceType, SourceId = sourceId, IsEstimated = false }); + } + + public FieldCostRun Build(FieldCostRun run) + { + run.Currency = Currency; + run.PersonnelTotal = FieldCostCalculator.Round(Personnel); run.ResourceTotal = FieldCostCalculator.Round(Resource); run.ConsumableTotal = FieldCostCalculator.Round(Consumable); run.ExpenseTotal = FieldCostCalculator.Round(Expense); run.OverheadTotal = 0m; + run.MissingInputCount = Missing; + run.Status = Missing > 0 ? (int)FieldCostRunStatuses.NeedsReview : (int)FieldCostRunStatuses.Draft; + run.InputVersions = JsonConvert.SerializeObject(_versions); + return run; + } + } + + #endregion + + #region Helpers + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string Snapshot(ResourceCostProfile profile) { var clone = profile.CloneJson(); clone.Components = null; clone.SubjectName = null; return clone.CloneJsonToString(); } + private static string RunSnapshot(FieldCostRun run) { var clone = run.CloneJson(); clone.Lines = null; return clone.CloneJsonToString(); } + + private void Audit(int departmentId, string userId, AuditLogTypes type, string ipAddress, string userAgent, string before, string after) + { + var audit = DeploymentService.NewAuditEvent(departmentId, userId, type, ipAddress, userAgent); + audit.Before = before; audit.After = after; + _eventAggregator.SendMessage(audit); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Workforce/PayDataAggregator.cs b/Core/Resgrid.Services/Workforce/PayDataAggregator.cs new file mode 100644 index 00000000..0ba90d92 --- /dev/null +++ b/Core/Resgrid.Services/Workforce/PayDataAggregator.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Model.Workforce; + +namespace Resgrid.Services.Workforce +{ + /// + /// Pure CRD reporting math (Workforce & Business Operations plan, E1/E3): the individual hourly rate, the + /// pay band, the exact group key, arithmetic mean and true ordered median inside a group, the three remote + /// counts, the reconciliation checks, and the CSV / XLSX rendering of a profile's columns. Employee facts stay + /// at full precision; only the exported aggregate is rounded. + /// + public static class PayDataAggregator + { + public const string HispanicYes = "Yes"; + + /// Earnings ÷ reportable hours. Zero / missing hours is a blocking exception, never a $0 rate. + public static decimal? HourlyRate(decimal? earnings, decimal hours) => earnings.HasValue && hours > 0 ? earnings.Value / hours : null; + + public static string GroupKey(PayDataReportEmployeeSnapshot s) => string.Join("|", s.WorkforceEstablishmentId ?? string.Empty, s.WorkforceLaborContractorId ?? string.Empty, s.JobCategoryCode ?? string.Empty, s.DemographicCode ?? string.Empty, s.PayBandCode ?? string.Empty, s.ExemptionCode ?? string.Empty, s.EmploymentTypeCode ?? string.Empty); + + public static decimal Mean(IReadOnlyCollection values) => values.Count == 0 ? 0m : values.Sum() / values.Count; + + public static decimal Median(IReadOnlyCollection values) + { + if (values.Count == 0) return 0m; + var sorted = values.OrderBy(v => v).ToList(); + var mid = sorted.Count / 2; + return sorted.Count % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2m; + } + + /// Groups included snapshots on the exact profile key and computes the aggregate row (mean / median from individual rates). + public static List Aggregate(IReadOnlyList snapshots, CaPayDataSchemaProfile profile) + { + var rows = new List(); + var sort = 0; + foreach (var group in snapshots.Where(s => s.IsIncluded).GroupBy(GroupKey).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var first = group.First(); + var rates = group.Select(s => s.HourlyRateValue).Where(r => r.HasValue).Select(r => r.Value).ToList(); + rows.Add(new PayDataReportRow + { + DepartmentId = first.DepartmentId, PayDataReportRunId = first.PayDataReportRunId, WorkforceEstablishmentId = first.WorkforceEstablishmentId, WorkforceLaborContractorId = first.WorkforceLaborContractorId, + JobCategoryCode = first.JobCategoryCode, DemographicCode = first.DemographicCode, PayBandCode = first.PayBandCode, ExemptionCode = first.ExemptionCode, EmploymentTypeCode = first.EmploymentTypeCode, + EmployeeCount = group.Count(), AnnualHours = group.Sum(s => s.AnnualHours), AnnualWeeks = group.Sum(s => s.AnnualWeeks), + MeanHourlyRateValue = Math.Round(Mean(rates), profile.RateDecimals, MidpointRounding.AwayFromZero), MedianHourlyRateValue = Math.Round(Median(rates), profile.RateDecimals, MidpointRounding.AwayFromZero), + NonRemoteCount = group.Count(s => s.WorkMode == (int)WorkModes.NonRemote), RemoteWithinCaliforniaCount = group.Count(s => s.WorkMode == (int)WorkModes.RemoteWithinCalifornia), + RemoteOutsideCaliforniaCount = group.Count(s => s.WorkMode == (int)WorkModes.RemoteOutsideCaliforniaAssignedToCaliforniaEstablishment), + ContributingSnapshotIdsCsv = string.Join(",", group.Select(s => s.PayDataReportEmployeeSnapshotId)), SortOrder = sort++, AddedOn = DateTime.UtcNow + }); + } + return rows; + } + + /// Profile field / length / code / reconciliation checks on the aggregate rows. + public static void ValidateRows(IReadOnlyList rows, IReadOnlyList snapshots, CaPayDataSchemaProfile profile, PayDataValidationResult result) + { + var included = snapshots.Where(s => s.IsIncluded).ToList(); + if (rows.Sum(r => r.EmployeeCount) != included.Count) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EmployeeCountMismatch, Scope = "run", IsBlocking = true, Detail = $"{rows.Sum(r => r.EmployeeCount)} vs {included.Count}" }); + foreach (var row in rows) + { + if (row.NonRemoteCount + row.RemoteWithinCaliforniaCount + row.RemoteOutsideCaliforniaCount != row.EmployeeCount) + result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.RemoteCountsMismatch, Scope = "row", SubjectId = row.PayDataReportRowId, IsBlocking = true, Detail = GroupLabel(row) }); + if (profile.JobCategories.All(j => j.Code != row.JobCategoryCode)) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.JobCategoryUnknown, Scope = "row", SubjectId = row.PayDataReportRowId, IsBlocking = true, Detail = row.JobCategoryCode }); + if (!string.IsNullOrWhiteSpace(row.RowRemarks) && row.RowRemarks.Length > 500) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.FieldTooLong, Scope = "row", SubjectId = row.PayDataReportRowId, IsBlocking = true, Detail = "Row-Level Clarifying Remarks" }); + } + foreach (var establishment in included.GroupBy(s => s.WorkforceEstablishmentId ?? string.Empty)) + { + var rowTotal = rows.Where(r => (r.WorkforceEstablishmentId ?? string.Empty) == establishment.Key).Sum(r => r.EmployeeCount); + if (rowTotal != establishment.Count()) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.EmployeeCountMismatch, Scope = "establishment", SubjectId = establishment.Key, IsBlocking = true, Detail = $"{rowTotal} vs {establishment.Count()}" }); + } + } + + public static string GroupLabel(PayDataReportRow row) => $"{row.JobCategoryCode}/{row.DemographicCode}/{row.PayBandCode}"; + + #region Export + + public sealed class ExportEstablishment + { + public string Id { get; set; } + public string Name { get; set; } + public string Address { get; set; } + public string City { get; set; } + public string State { get; set; } + public string Zip { get; set; } + public string Naics { get; set; } + public string MajorActivity { get; set; } + public int TotalEmployees { get; set; } + public bool? FiledPriorYear { get; set; } + public bool IsHeadquarters { get; set; } + } + + public sealed class ExportContractor + { + public string Id { get; set; } + public string Name { get; set; } + public string Fein { get; set; } + } + + /// The exact upload cells for one row in the profile's column order. + public static List Cells(PayDataReportRow row, CaPayDataSchemaProfile profile, int reportType, ExportEstablishment establishment, ExportContractor contractor) + { + var cells = new List(); + if (reportType == (int)PayDataReportTypes.LaborContractorEmployee) { cells.Add(contractor?.Name); cells.Add(contractor?.Fein); } + cells.Add(establishment?.Name); cells.Add(establishment?.Address); cells.Add(establishment?.City); cells.Add(establishment?.State); cells.Add(establishment?.Zip); cells.Add(establishment?.Naics); cells.Add(establishment?.MajorActivity); + cells.Add(establishment == null ? null : establishment.TotalEmployees.ToString(CultureInfo.InvariantCulture)); cells.Add(establishment?.FiledPriorYear == true ? "Yes" : "No"); cells.Add(establishment?.IsHeadquarters == true ? "Yes" : "No"); + cells.Add(row.JobCategoryCode); cells.Add(row.DemographicCode); cells.Add(row.PayBandCode); + cells.Add(row.EmployeeCount.ToString(CultureInfo.InvariantCulture)); cells.Add(Math.Round(row.AnnualHours, 0, MidpointRounding.AwayFromZero).ToString("0", CultureInfo.InvariantCulture)); + cells.Add((row.MeanHourlyRateValue ?? 0).ToString("0.00", CultureInfo.InvariantCulture)); cells.Add((row.MedianHourlyRateValue ?? 0).ToString("0.00", CultureInfo.InvariantCulture)); + cells.Add(row.NonRemoteCount.ToString(CultureInfo.InvariantCulture)); cells.Add(row.RemoteWithinCaliforniaCount.ToString(CultureInfo.InvariantCulture)); cells.Add(row.RemoteOutsideCaliforniaCount.ToString(CultureInfo.InvariantCulture)); + cells.Add(row.RowRemarks); + return cells; + } + + public static IReadOnlyList Columns(CaPayDataSchemaProfile profile, int reportType) => reportType == (int)PayDataReportTypes.LaborContractorEmployee ? profile.LaborContractorColumns : profile.PayrollColumns; + + public static void ValidateCells(IReadOnlyList columns, IReadOnlyList cells, string rowId, PayDataValidationResult result) + { + for (var i = 0; i < columns.Count && i < cells.Count; i++) + { + var column = columns[i]; var value = cells[i]; + if (column.Required && string.IsNullOrWhiteSpace(value)) result.Errors.Add(new PayDataValidationIssue { Code = "required_" + Slug(column.Header), Scope = "row", SubjectId = rowId, IsBlocking = true, Detail = column.Header }); + else if (!string.IsNullOrEmpty(value) && value.Length > column.MaxLength) result.Errors.Add(new PayDataValidationIssue { Code = PayDataValidationCodes.FieldTooLong, Scope = "row", SubjectId = rowId, IsBlocking = true, Detail = column.Header }); + } + } + + private static string Slug(string header) => new string(header.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); + + public static byte[] RenderCsv(IReadOnlyList columns, IEnumerable> rows) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(",", columns.Select(c => Csv(c.Header)))); + foreach (var row in rows) sb.AppendLine(string.Join(",", row.Select(Csv))); + return new UTF8Encoding(false).GetBytes(sb.ToString()); + } + + private static string Csv(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0 ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + } + + /// A minimal SpreadsheetML workbook (one sheet, inline strings) — no third-party dependency. + public static byte[] RenderXlsx(IReadOnlyList columns, IEnumerable> rows, string sheetName = "PayData") + { + var sheet = new StringBuilder(); + sheet.Append(""); + var r = 1; + sheet.Append(XlsxRow(r++, columns.Select(c => c.Header).ToList())); + foreach (var row in rows) sheet.Append(XlsxRow(r++, row)); + sheet.Append(""); + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + Add(zip, "[Content_Types].xml", ""); + Add(zip, "_rels/.rels", ""); + Add(zip, "xl/workbook.xml", ""); + Add(zip, "xl/_rels/workbook.xml.rels", ""); + Add(zip, "xl/worksheets/sheet1.xml", sheet.ToString()); + } + return stream.ToArray(); + } + + private static string XlsxRow(int index, IReadOnlyList cells) + { + var sb = new StringBuilder(""); + for (var c = 0; c < cells.Count; c++) + { + var value = cells[c] ?? string.Empty; + var reference = ColumnName(c) + index; + if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) && value.Trim().Length > 0 && value == value.Trim() && !value.StartsWith("0") || value == "0") + sb.Append("").Append(value).Append(""); + else + sb.Append("").Append(Xml(value)).Append(""); + } + return sb.Append("").ToString(); + } + + private static string ColumnName(int index) + { + var name = string.Empty; + index++; + while (index > 0) { var rem = (index - 1) % 26; name = (char)('A' + rem) + name; index = (index - 1) / 26; } + return name; + } + + private static string Xml(string value) => System.Security.SecurityElement.Escape(value ?? string.Empty); + + private static void Add(ZipArchive zip, string path, string content) + { + var entry = zip.CreateEntry(path, CompressionLevel.Optimal); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(content); + } + + public static string Sha256(byte[] data) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(data ?? Array.Empty())).ToLowerInvariant(); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Workforce/PayDataDemographicsService.cs b/Core/Resgrid.Services/Workforce/PayDataDemographicsService.cs new file mode 100644 index 00000000..1dce1a6c --- /dev/null +++ b/Core/Resgrid.Services/Workforce/PayDataDemographicsService.cs @@ -0,0 +1,158 @@ +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.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.Workforce +{ + /// + /// The separately stored demographic responses for California pay data reporting (plan E1/E3): a worker's own + /// self-identification, the compliance officer's completeness view (counts only), and the officer's record for a + /// worker (employment record / reliable record / observer perception — always with a reason, always reviewed). + /// Values ride ADP catalog 28; the costing engine never reads this table and no other screen renders it. + /// + public class PayDataDemographicsService : IPayDataDemographicsService + { + private readonly IPayDataReportingDemographicRepository _demographics; + private readonly IWorkforceWorkerRepository _workers; + private readonly IWorkforceEmploymentRepository _employments; + private readonly IWorkforceService _workforceService; + private readonly IEventAggregator _eventAggregator; + private readonly WorkforceProtectionSeam _seam; + + public PayDataDemographicsService(IPayDataReportingDemographicRepository demographics, IWorkforceWorkerRepository workers, IWorkforceEmploymentRepository employments, IWorkforceService workforceService, IEventAggregator eventAggregator, + Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + { + _demographics = demographics; + _workers = workers; + _employments = employments; + _workforceService = workforceService; + _eventAggregator = eventAggregator; + _seam = new WorkforceProtectionSeam(protectedWrite, protectedRead, grant); + } + + public async Task GetOwnAsync(int departmentId, string userId) + { + if (string.IsNullOrWhiteSpace(userId)) return null; + var worker = await _workers.GetByUserIdAsync(departmentId, userId); + if (worker == null || worker.IsDeleted) return null; + var row = await _demographics.GetCurrentForWorkerAsync(worker.WorkforceWorkerId, DateTime.UtcNow.Date); + if (row == null || row.IsDeleted) return null; + // The subject reads their own answers: resolved through the reporting workload purpose, never through a grant they would not hold. + await _seam.ResolveForWorkloadAsync(new[] { row }, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Demographic); + return row; + } + + public async Task SaveOwnAsync(int departmentId, string userId, PayDataReportingDemographic response, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (response == null) throw new ArgumentNullException(nameof(response)); + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A user id is required.", nameof(userId)); + var worker = await _workforceService.GetOrCreateWorkerForUserAsync(departmentId, userId, userId, cancellationToken); + response.CollectionSource = (int)DemographicCollectionSources.SelfIdentified; + response.CollectedByUserId = userId; + return await SaveVersionAsync(departmentId, worker.WorkforceWorkerId, response, userId, userId, ipAddress, userAgent, cancellationToken); + } + + public async Task SaveForWorkerAsync(int departmentId, string workerId, PayDataReportingDemographic response, string reason, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (response == null) throw new ArgumentNullException(nameof(response)); + if (string.IsNullOrWhiteSpace(reason)) throw new InvalidOperationException("paydata_reason_required"); + if (response.CollectionSource == (int)DemographicCollectionSources.SelfIdentified || !Enum.IsDefined(typeof(DemographicCollectionSources), response.CollectionSource)) throw new InvalidOperationException("paydata_collection_source_invalid"); + var worker = await _workers.GetByIdForDepartmentAsync(workerId ?? string.Empty, departmentId); + if (worker == null || worker.IsDeleted) throw new InvalidOperationException("workforce_worker_not_found"); + response.CollectedByUserId = userId; + response.ReviewedByUserId = userId; + response.ReviewedOn = DateTime.UtcNow; + return await SaveVersionAsync(departmentId, worker.WorkforceWorkerId, response, userId, reason, ipAddress, userAgent, cancellationToken); + } + + private async Task SaveVersionAsync(int departmentId, string workerId, PayDataReportingDemographic response, string userId, string reason, string ipAddress, string userAgent, CancellationToken cancellationToken) + { + var profile = CaPayDataSchemaProfile.Current; + var hispanic = Normalize(response.HispanicLatino); + if (hispanic != null && hispanic != "Yes" && hispanic != "No") throw new InvalidOperationException("paydata_hispanic_invalid"); + var races = (response.RaceEthnicityCodes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim().ToUpperInvariant()).Distinct().ToList(); + if (races.Any(c => profile.RaceEthnicities.All(r => r.Code != c) || c == "A" || c == "G")) throw new InvalidOperationException("paydata_race_invalid"); + var sex = Normalize(response.SexCode); + if (sex != null && profile.Sexes.All(s => s.Code != sex)) throw new InvalidOperationException("paydata_sex_invalid"); + if (!response.DeclinedRaceEthnicity && hispanic == null && races.Count == 0) throw new InvalidOperationException("paydata_race_required"); + if (!response.DeclinedSex && sex == null) throw new InvalidOperationException("paydata_sex_required"); + var today = DateTime.UtcNow.Date; + var current = await _demographics.GetCurrentForWorkerAsync(workerId, today); + if (current != null && current.IsDeleted) current = null; + var before = current == null ? null : Snapshot(current); + if (current != null) + { + // Versions are kept: the current row closes the day before the new one starts; a same-day re-answer replaces it. + if (current.EffectiveOn.Date < today) current.ExpiresOn = today.AddDays(-1); + else current.IsDeleted = true; + current.EditedOn = DateTime.UtcNow; current.EditedByUserId = userId; + await _demographics.SaveOrUpdateAsync(current, cancellationToken); + } + var target = new PayDataReportingDemographic + { + DepartmentId = departmentId, WorkforceWorkerId = workerId, EffectiveOn = today, ExpiresOn = null, + HispanicLatino = response.DeclinedRaceEthnicity ? null : hispanic, RaceEthnicityCodes = response.DeclinedRaceEthnicity ? null : (races.Count == 0 ? null : string.Join(",", races)), SexCode = response.DeclinedSex ? null : sex, + DeclinedRaceEthnicity = response.DeclinedRaceEthnicity, DeclinedSex = response.DeclinedSex, CollectionSource = response.CollectionSource, CollectedOn = DateTime.UtcNow, CollectedByUserId = response.CollectedByUserId ?? userId, + ReviewedOn = response.ReviewedOn, ReviewedByUserId = response.ReviewedByUserId, Version = (current?.Version ?? 0) + 1, AddedOn = DateTime.UtcNow, AddedByUserId = userId + }; + var saved = await _seam.SaveAsync(_demographics, target, null, departmentId, WorkforceProtectedFields.Demographic, cancellationToken); + var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.PayDataDemographicChanged, ipAddress, userAgent); + audit.Before = before; audit.After = Snapshot(saved); + if (!string.IsNullOrWhiteSpace(reason) && reason != userId) audit.After = audit.After.TrimEnd('}') + ",\"Reason\":" + Newtonsoft.Json.JsonConvert.ToString(reason) + "}"; + _eventAggregator.SendMessage(audit); + return saved; + } + + public async Task GetCompletenessAsync(int departmentId, DateTime asOf) + { + var employments = (await _employments.GetActiveInWindowAsync(departmentId, asOf.Date, asOf.Date))?.Where(e => !e.IsDeleted).ToList() ?? new List(); + var activeWorkers = employments.Select(e => e.WorkforceWorkerId).Distinct().ToList(); + var responses = (await _demographics.GetCurrentForDepartmentAsync(departmentId, asOf.Date))?.Where(d => !d.IsDeleted && activeWorkers.Contains(d.WorkforceWorkerId)).GroupBy(d => d.WorkforceWorkerId).Select(g => g.OrderByDescending(d => d.Version).First()).ToList() ?? new List(); + // Counts only — flags and sources, never the coded values (which stay enveloped). + return new DemographicCompleteness + { + ActiveWorkers = activeWorkers.Count, + WithResponse = responses.Count, + SelfIdentified = responses.Count(r => r.CollectionSource == (int)DemographicCollectionSources.SelfIdentified && !r.DeclinedRaceEthnicity && !r.DeclinedSex), + Declined = responses.Count(r => r.DeclinedRaceEthnicity || r.DeclinedSex), + ObserverPerception = responses.Count(r => r.CollectionSource == (int)DemographicCollectionSources.ObserverPerception) + }; + } + + public async Task GetForWorkerAsync(int departmentId, string workerId, DateTime asOf) + { + var worker = await _workers.GetByIdForDepartmentAsync(workerId ?? string.Empty, departmentId); + if (worker == null || worker.IsDeleted) return null; + var row = await _demographics.GetCurrentForWorkerAsync(worker.WorkforceWorkerId, asOf.Date); + if (row == null || row.IsDeleted) return null; + await _seam.ResolveForReadAsync(new[] { row }, departmentId, WorkforceProtectedFields.Demographic); + return row; + } + + private static string Normalize(string value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim(); + if (string.Equals(trimmed, "yes", StringComparison.OrdinalIgnoreCase)) return "Yes"; + if (string.Equals(trimmed, "no", StringComparison.OrdinalIgnoreCase)) return "No"; + return trimmed; + } + + internal static string Snapshot(PayDataReportingDemographic row) + { + var clone = row.CloneJson(); + foreach (var a in WorkforceProtectedFields.Demographic) a.Value.Set(clone, WorkforceService.Marker(a.Value.Get(clone))); + return clone.CloneJsonToString(); + } + } +} diff --git a/Core/Resgrid.Services/Workforce/WorkforceProtectionSeam.cs b/Core/Resgrid.Services/Workforce/WorkforceProtectionSeam.cs new file mode 100644 index 00000000..5f9052ac --- /dev/null +++ b/Core/Resgrid.Services/Workforce/WorkforceProtectionSeam.cs @@ -0,0 +1,123 @@ +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.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; + +namespace Resgrid.Services.Workforce +{ + /// + /// Advanced Data Protection seam shared by the Phase E services (ADP catalog 28, Personnel family). Writes run + /// as a workload caller (encrypted at rest, sentinel-safe: a REDACTED value posted back from an unrevealed page + /// keeps the stored envelope); user reads honour the caller's Protected Data Grant; the costing and reporting + /// engines decrypt through their broker purposes. Without the protection services rows are written and read + /// as-is (a department that has not enrolled). No plaintext twin, cache or log ever carries a resolved value. + /// + public sealed class WorkforceProtectionSeam + { + private readonly Lazy _write; + private readonly Lazy _read; + private readonly IProtectedGrantContext _grant; + + public WorkforceProtectionSeam(Lazy write, Lazy read, IProtectedGrantContext grant) + { + _write = write; + _read = read; + _grant = grant; + } + + public bool IsActive => _write?.Value != null; + + public static void Mark(IEntity row) + { + switch (row) + { + case WorkforceEmployerProfile r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceAffiliatedEntity r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceEstablishment r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceLaborContractor r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceWorker r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case EmployeeCompensationProfile r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case EmployeePayComponent r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case EmployeeCostComponent r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceWorkEntry r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case WorkforceAnnualPayFact r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case FieldCostLine r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case PayDataReportingDemographic r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case PayDataReportRun r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case PayDataReportEmployeeSnapshot r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case PayDataReportRow r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + case PayDataExportArtifact r: r.IsProtected = true; r.ProtectedCatalogVersion = Math.Max(r.ProtectedCatalogVersion ?? 0, WorkforceProtectedFields.CatalogVersion); break; + } + } + + /// Insert or update through the write seam. A new row is allocated first (the key binds the envelope), then enveloped and written again. + public async Task SaveAsync(IRepository repository, T entity, T existing, int departmentId, IReadOnlyDictionary Get, Action Set)> accessors, CancellationToken cancellationToken) where T : class, IEntity + { + if (!IsActive) return await repository.SaveOrUpdateAsync(entity, cancellationToken); + var key = entity.IdValue as string; + if (string.IsNullOrWhiteSpace(key)) + { + var held = accessors.ToDictionary(a => a.Key, a => a.Value.Get(entity), StringComparer.OrdinalIgnoreCase); + if (held.Values.All(string.IsNullOrEmpty)) return await repository.SaveOrUpdateAsync(entity, cancellationToken); + foreach (var accessor in accessors) accessor.Value.Set(entity, null); + var allocated = await repository.SaveOrUpdateAsync(entity, cancellationToken); + foreach (var accessor in accessors) accessor.Value.Set(allocated, held[accessor.Key]); + var inserted = await _write.Value.PrepareRecordsEntityWriteAsync(departmentId, allocated, null, (string)allocated.IdValue, accessors, () => Mark(allocated), null, null, true, cancellationToken); + if (inserted != null && !inserted.Success) throw new InvalidOperationException("workforce_protected_write_refused"); + return await repository.SaveOrUpdateAsync(allocated, cancellationToken); + } + var result = await _write.Value.PrepareRecordsEntityWriteAsync(departmentId, entity, existing, key, accessors, () => Mark(entity), null, null, true, cancellationToken); + if (result != null && !result.Success) throw new InvalidOperationException("workforce_protected_write_refused"); + return await repository.SaveOrUpdateAsync(entity, cancellationToken); + } + + /// Binary column (export artifacts). + public async Task SaveArtifactAsync(IPayDataExportArtifactRepository repository, PayDataExportArtifact artifact, int departmentId, CancellationToken cancellationToken) + { + if (!IsActive) return await repository.SaveOrUpdateAsync(artifact, cancellationToken); + var data = artifact.Data; + artifact.Data = null; + var allocated = await repository.SaveOrUpdateAsync(artifact, cancellationToken); + var result = await _write.Value.PrepareRecordsBinaryWriteAsync(departmentId, WorkforceProtectedFields.ExportDataFieldId, allocated.PayDataExportArtifactId, data, bytes => allocated.Data = bytes, () => Mark(allocated), null, null, true, cancellationToken); + if (result != null && !result.Success) throw new InvalidOperationException("workforce_protected_write_refused"); + if (allocated.Data == null) allocated.Data = data; + return await repository.SaveOrUpdateAsync(allocated, cancellationToken); + } + + /// User read: a grant holder sees the values, everyone else REDACTED. Never throws. + public async Task ResolveForReadAsync(IReadOnlyList rows, int departmentId, IReadOnlyDictionary Get, Action Set)> accessors) where T : class, IEntity + { + if (_read?.Value == null || rows == null || rows.Count == 0) return; + try { await _read.Value.ResolveRecordsEntitiesForReadAsync(departmentId, rows.Select(r => (r, (string)r.IdValue)).ToList(), accessors, _grant?.GrantToken, _grant?.UserId); } + catch (Exception ex) { Logging.LogException(ex, "Protected workforce rows could not be resolved for read."); } + } + + /// Workload read for the costing / reporting engines (broker purpose allow-listed in DataProtectionConfig). Never throws; a denied purpose leaves envelopes in place and the caller flags NeedsReview. + public async Task ResolveForWorkloadAsync(IReadOnlyList rows, int departmentId, string purpose, IReadOnlyDictionary Get, Action Set)> accessors) where T : class, IEntity + { + if (_read?.Value == null || rows == null || rows.Count == 0) return true; + try + { + var result = await _read.Value.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, rows.Select(r => (r, (string)r.IdValue)).ToList(), accessors); + return result == null || !result.IsProtected || result.RedactedFields == null || result.RedactedFields.Count == 0; + } + catch (Exception ex) { Logging.LogException(ex, $"Protected workforce rows could not be resolved for the {purpose} workload."); return false; } + } + + public async Task ResolveArtifactForReadAsync(PayDataExportArtifact artifact, int departmentId) + { + if (_read?.Value == null || artifact?.Data == null) return; + try { await _read.Value.ResolveRecordsBinaryForReadAsync(departmentId, WorkforceProtectedFields.ExportDataFieldId, artifact.PayDataExportArtifactId, artifact.Data, bytes => artifact.Data = bytes, _grant?.GrantToken, _grant?.UserId); } + catch (Exception ex) { Logging.LogException(ex, "Protected export artifact could not be resolved for read."); artifact.Data = null; } + } + + /// True when a value is still an envelope or the redaction sentinel (the caller lacked a grant or the purpose was denied). + public static bool IsUnavailable(string value) => value != null && (ProtectedDataEnvelope.HasEnvelopePrefix(value) || value == ProtectedDataEnvelope.RedactionValue); + } +} diff --git a/Core/Resgrid.Services/Workforce/WorkforceService.cs b/Core/Resgrid.Services/Workforce/WorkforceService.cs new file mode 100644 index 00000000..2468fce2 --- /dev/null +++ b/Core/Resgrid.Services/Workforce/WorkforceService.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Services.Workforce +{ + /// + /// Employer identity, affiliates, establishments, labor contractors, workers, employment periods, job + /// assignments, work entries and annual pay facts (Workforce & Business Operations plan, E3 + /// IWorkforceService / IWorkforceImportService). Identifiers, addresses, external worker keys, + /// approved payroll cost and annual earnings ride the ADP seam (catalog 28); overlapping employment periods + /// and job assignments are refused; imports dry-run before anything commits. Callers authorize (75/76/77). + /// + public class WorkforceService : IWorkforceService + { + private readonly IWorkforceEmployerProfileRepository _employers; + private readonly IWorkforceAffiliatedEntityRepository _affiliates; + private readonly IWorkforceEstablishmentRepository _establishments; + private readonly IWorkforceLaborContractorRepository _contractors; + private readonly IWorkforceWorkerRepository _workers; + private readonly IWorkforceEmploymentRepository _employments; + private readonly IWorkforceJobAssignmentRepository _assignments; + private readonly IWorkforceWorkEntryRepository _workEntries; + private readonly IWorkforceAnnualPayFactRepository _annualFacts; + private readonly IUserProfileService _userProfileService; + private readonly IEventAggregator _eventAggregator; + private readonly WorkforceProtectionSeam _seam; + + public WorkforceService(IWorkforceEmployerProfileRepository employers, IWorkforceAffiliatedEntityRepository affiliates, IWorkforceEstablishmentRepository establishments, + IWorkforceLaborContractorRepository contractors, IWorkforceWorkerRepository workers, IWorkforceEmploymentRepository employments, IWorkforceJobAssignmentRepository assignments, + IWorkforceWorkEntryRepository workEntries, IWorkforceAnnualPayFactRepository annualFacts, IUserProfileService userProfileService, IEventAggregator eventAggregator, + Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + { + _employers = employers; + _affiliates = affiliates; + _establishments = establishments; + _contractors = contractors; + _workers = workers; + _employments = employments; + _assignments = assignments; + _workEntries = workEntries; + _annualFacts = annualFacts; + _userProfileService = userProfileService; + _eventAggregator = eventAggregator; + _seam = new WorkforceProtectionSeam(protectedWrite, protectedRead, grant); + } + + #region Employer, affiliates, establishments, contractors + + public async Task GetEmployerProfileAsync(int departmentId) + { + var profile = await _employers.GetActiveForDepartmentAsync(departmentId); + if (profile == null) return null; + await _seam.ResolveForReadAsync(new[] { profile }, departmentId, WorkforceProtectedFields.Employer); + return profile; + } + + public async Task SaveEmployerProfileAsync(WorkforceEmployerProfile profile, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrWhiteSpace(profile.LegalName)) throw new InvalidOperationException("workforce_employer_name_required"); + if (!string.IsNullOrWhiteSpace(profile.Naics) && (profile.Naics.Trim().Length != 6 || !profile.Naics.Trim().All(char.IsDigit))) throw new InvalidOperationException("workforce_naics_invalid"); + if (!Enum.IsDefined(typeof(CaliforniaPayDataCoverageStatuses), profile.CoverageStatus)) throw new InvalidOperationException("workforce_coverage_invalid"); + var existing = await _employers.GetActiveForDepartmentAsync(profile.DepartmentId); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceEmployerProfile { DepartmentId = profile.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.LegalName = profile.LegalName.Trim(); + target.Naics = Trim(profile.Naics); + target.IsIntegratedEnterprise = profile.IsIntegratedEnterprise; + target.CoverageStatus = profile.CoverageStatus; + target.UsEmployeeCount = profile.UsEmployeeCount; + target.CaliforniaEmployeeCount = profile.CaliforniaEmployeeCount; + target.EffectiveOn = profile.EffectiveOn; + target.ExpiresOn = profile.ExpiresOn; + target.IsActive = true; + foreach (var accessor in WorkforceProtectedFields.Employer) Keep(existing, target, profile, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_employers, target, existing, profile.DepartmentId, WorkforceProtectedFields.Employer, cancellationToken); + Audit(profile.DepartmentId, userId, AuditLogTypes.WorkforceEmployerProfileChanged, ipAddress, userAgent, before, saved); + return await GetEmployerProfileAsync(profile.DepartmentId); + } + + public async Task> GetAffiliatesAsync(int departmentId) + { + var rows = (await _affiliates.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.Affiliate); + return rows; + } + + public async Task SaveAffiliateAsync(WorkforceAffiliatedEntity entity, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + if (string.IsNullOrWhiteSpace(entity.LegalName)) throw new InvalidOperationException("workforce_affiliate_name_required"); + var existing = string.IsNullOrWhiteSpace(entity.WorkforceAffiliatedEntityId) ? null : await _affiliates.GetByIdForDepartmentAsync(entity.WorkforceAffiliatedEntityId, entity.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceAffiliatedEntity { DepartmentId = entity.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.WorkforceEmployerProfileId = Trim(entity.WorkforceEmployerProfileId) ?? (await _employers.GetActiveForDepartmentAsync(entity.DepartmentId))?.WorkforceEmployerProfileId; + target.LegalName = entity.LegalName.Trim(); + target.EffectiveOn = entity.EffectiveOn; target.ExpiresOn = entity.ExpiresOn; + foreach (var accessor in WorkforceProtectedFields.Affiliate) Keep(existing, target, entity, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_affiliates, target, existing, entity.DepartmentId, WorkforceProtectedFields.Affiliate, cancellationToken); + Audit(entity.DepartmentId, userId, AuditLogTypes.WorkforceEmployerProfileChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public Task DeleteAffiliateAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SoftDeleteAsync(_affiliates, () => _affiliates.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEmployerProfileChanged, cancellationToken); + + public async Task> GetEstablishmentsAsync(int departmentId) + { + var rows = (await _establishments.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.Establishment); + return rows; + } + + public async Task GetEstablishmentAsync(string id, int departmentId) + { + if (string.IsNullOrWhiteSpace(id)) return null; + var row = await _establishments.GetByIdForDepartmentAsync(id, departmentId); + if (row == null || row.IsDeleted) return null; + await _seam.ResolveForReadAsync(new[] { row }, departmentId, WorkforceProtectedFields.Establishment); + return row; + } + + public async Task SaveEstablishmentAsync(WorkforceEstablishment establishment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (establishment == null) throw new ArgumentNullException(nameof(establishment)); + if (string.IsNullOrWhiteSpace(establishment.Code) || string.IsNullOrWhiteSpace(establishment.Name)) throw new InvalidOperationException("workforce_establishment_code_required"); + if (!string.IsNullOrWhiteSpace(establishment.Naics) && (establishment.Naics.Trim().Length != 6 || !establishment.Naics.Trim().All(char.IsDigit))) throw new InvalidOperationException("workforce_naics_invalid"); + if (establishment.ActiveTo.HasValue && establishment.ActiveFrom.HasValue && establishment.ActiveTo < establishment.ActiveFrom) throw new InvalidOperationException("workforce_dates_invalid"); + var all = (await _establishments.GetForDepartmentAsync(establishment.DepartmentId))?.ToList() ?? new List(); + if (all.Any(e => e.WorkforceEstablishmentId != establishment.WorkforceEstablishmentId && string.Equals(e.Code, establishment.Code.Trim(), StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("workforce_establishment_code_duplicate"); + var existing = string.IsNullOrWhiteSpace(establishment.WorkforceEstablishmentId) ? null : await _establishments.GetByIdForDepartmentAsync(establishment.WorkforceEstablishmentId, establishment.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceEstablishment { DepartmentId = establishment.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.WorkforceAffiliatedEntityId = Trim(establishment.WorkforceAffiliatedEntityId); + target.Code = establishment.Code.Trim(); target.Name = establishment.Name.Trim(); + target.City = Trim(establishment.City); target.StateCode = Trim(establishment.StateCode)?.ToUpperInvariant(); target.PostalCode = Trim(establishment.PostalCode); + target.Naics = Trim(establishment.Naics); target.MajorActivity = Trim(establishment.MajorActivity); + target.IsHeadquarters = establishment.IsHeadquarters; target.WasFiledPriorYear = establishment.WasFiledPriorYear; + target.ActiveFrom = establishment.ActiveFrom; target.ActiveTo = establishment.ActiveTo; target.TimeZoneId = Trim(establishment.TimeZoneId); + foreach (var accessor in WorkforceProtectedFields.Establishment) Keep(existing, target, establishment, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_establishments, target, existing, establishment.DepartmentId, WorkforceProtectedFields.Establishment, cancellationToken); + Audit(establishment.DepartmentId, userId, AuditLogTypes.WorkforceEstablishmentChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public Task DeleteEstablishmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SoftDeleteAsync(_establishments, () => _establishments.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEstablishmentChanged, cancellationToken); + + public async Task> GetLaborContractorsAsync(int departmentId) + { + var rows = (await _contractors.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.Contractor); + return rows; + } + + public async Task SaveLaborContractorAsync(WorkforceLaborContractor contractor, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (contractor == null) throw new ArgumentNullException(nameof(contractor)); + if (string.IsNullOrWhiteSpace(contractor.LegalName)) throw new InvalidOperationException("workforce_contractor_name_required"); + if (contractor.RelationshipEndOn.HasValue && contractor.RelationshipStartOn.HasValue && contractor.RelationshipEndOn < contractor.RelationshipStartOn) throw new InvalidOperationException("workforce_dates_invalid"); + var existing = string.IsNullOrWhiteSpace(contractor.WorkforceLaborContractorId) ? null : await _contractors.GetByIdForDepartmentAsync(contractor.WorkforceLaborContractorId, contractor.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceLaborContractor { DepartmentId = contractor.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.LegalName = contractor.LegalName.Trim(); target.OwnershipName = Trim(contractor.OwnershipName); target.Dba = Trim(contractor.Dba); + target.IdentifierType = Trim(contractor.IdentifierType) ?? "FEIN"; + target.RelationshipStartOn = contractor.RelationshipStartOn; target.RelationshipEndOn = contractor.RelationshipEndOn; + target.Provenance = Trim(contractor.Provenance); target.IsActive = contractor.IsActive; + foreach (var accessor in WorkforceProtectedFields.Contractor) Keep(existing, target, contractor, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_contractors, target, existing, contractor.DepartmentId, WorkforceProtectedFields.Contractor, cancellationToken); + Audit(contractor.DepartmentId, userId, AuditLogTypes.WorkforceEmployerProfileChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public Task DeleteLaborContractorAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SoftDeleteAsync(_contractors, () => _contractors.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEmployerProfileChanged, cancellationToken); + + #endregion + + #region Workers, employments, assignments + + public async Task> GetWorkersAsync(int departmentId) + { + var rows = (await _workers.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.Worker); + await NameWorkersAsync(rows); + return rows; + } + + public async Task GetWorkerAsync(string id, int departmentId) + { + if (string.IsNullOrWhiteSpace(id)) return null; + var row = await _workers.GetByIdForDepartmentAsync(id, departmentId); + if (row == null || row.IsDeleted) return null; + await _seam.ResolveForReadAsync(new[] { row }, departmentId, WorkforceProtectedFields.Worker); + await NameWorkersAsync(new[] { row }); + return row; + } + + public async Task GetOrCreateWorkerForUserAsync(int departmentId, string userId, string actorUserId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A user id is required.", nameof(userId)); + var existing = await _workers.GetByUserIdAsync(departmentId, userId); + if (existing != null) return existing; + var created = await _workers.SaveOrUpdateAsync(new WorkforceWorker { DepartmentId = departmentId, UserId = userId, AddedOn = DateTime.UtcNow, AddedByUserId = actorUserId }, cancellationToken); + return created; + } + + public async Task SaveWorkerAsync(WorkforceWorker worker, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (worker == null) throw new ArgumentNullException(nameof(worker)); + if (string.IsNullOrWhiteSpace(worker.UserId) && string.IsNullOrWhiteSpace(worker.ExternalWorkerKey) && string.IsNullOrWhiteSpace(worker.WorkforceWorkerId)) throw new InvalidOperationException("workforce_worker_identity_required"); + var existing = string.IsNullOrWhiteSpace(worker.WorkforceWorkerId) ? null : await _workers.GetByIdForDepartmentAsync(worker.WorkforceWorkerId, worker.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + if (existing == null && !string.IsNullOrWhiteSpace(worker.UserId) && await _workers.GetByUserIdAsync(worker.DepartmentId, worker.UserId) != null) throw new InvalidOperationException("workforce_worker_duplicate"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceWorker { DepartmentId = worker.DepartmentId, AddedOn = now, AddedByUserId = userId }; + target.UserId = Trim(worker.UserId) ?? target.UserId; + target.IsActive = worker.IsActive; + foreach (var accessor in WorkforceProtectedFields.Worker) Keep(existing, target, worker, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_workers, target, existing, worker.DepartmentId, WorkforceProtectedFields.Worker, cancellationToken); + Audit(worker.DepartmentId, userId, AuditLogTypes.WorkforceEmploymentChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task> GetEmploymentsAsync(int departmentId) + { + var rows = (await _employments.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await LoadAssignmentsAsync(rows); + return rows; + } + + public async Task> GetEmploymentsForWorkerAsync(string workerId, int departmentId) + { + var rows = (await _employments.GetByWorkerAsync(workerId))?.Where(e => e.DepartmentId == departmentId).ToList() ?? new List(); + await LoadAssignmentsAsync(rows); + return rows; + } + + public async Task GetEmploymentAsync(string id, int departmentId) + { + if (string.IsNullOrWhiteSpace(id)) return null; + var row = await _employments.GetByIdForDepartmentAsync(id, departmentId); + if (row == null || row.IsDeleted) return null; + await LoadAssignmentsAsync(new[] { row }); + return row; + } + + private async Task LoadAssignmentsAsync(IReadOnlyList rows) + { + if (rows.Count == 0) return; + var assignments = (await _assignments.GetByEmploymentsAsync(rows.Select(r => r.WorkforceEmploymentId)))?.ToList() ?? new List(); + foreach (var row in rows) row.Assignments = assignments.Where(a => a.WorkforceEmploymentId == row.WorkforceEmploymentId).OrderBy(a => a.EffectiveOn).ToList(); + } + + public async Task SaveEmploymentAsync(WorkforceEmployment employment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (employment == null) throw new ArgumentNullException(nameof(employment)); + if (string.IsNullOrWhiteSpace(employment.WorkforceWorkerId)) throw new InvalidOperationException("workforce_worker_required"); + if (!Enum.IsDefined(typeof(WorkerKinds), employment.WorkerKind)) throw new InvalidOperationException("workforce_worker_kind_invalid"); + if (employment.EndOn.HasValue && employment.EndOn < employment.StartOn) throw new InvalidOperationException("workforce_dates_invalid"); + if (employment.WorkerKind == (int)WorkerKinds.LaborContractorEmployee && string.IsNullOrWhiteSpace(employment.WorkforceLaborContractorId)) throw new InvalidOperationException("workforce_contractor_required"); + var worker = await _workers.GetByIdForDepartmentAsync(employment.WorkforceWorkerId, employment.DepartmentId); + if (worker == null || worker.IsDeleted) throw new InvalidOperationException("workforce_worker_not_found"); + var siblings = (await _employments.GetByWorkerAsync(employment.WorkforceWorkerId))?.Where(e => e.WorkforceEmploymentId != employment.WorkforceEmploymentId).ToList() ?? new List(); + if (siblings.Any(s => s.Overlaps(employment))) throw new InvalidOperationException("workforce_employment_overlap"); + if (!string.IsNullOrWhiteSpace(employment.DefaultEstablishmentId) && (await _establishments.GetByIdForDepartmentAsync(employment.DefaultEstablishmentId, employment.DepartmentId)) == null) throw new InvalidOperationException("workforce_establishment_not_found"); + var existing = string.IsNullOrWhiteSpace(employment.WorkforceEmploymentId) ? null : await _employments.GetByIdForDepartmentAsync(employment.WorkforceEmploymentId, employment.DepartmentId); + if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceEmployment { DepartmentId = employment.DepartmentId, WorkforceWorkerId = employment.WorkforceWorkerId, AddedOn = now, AddedByUserId = userId }; + target.WorkforceAffiliatedEntityId = Trim(employment.WorkforceAffiliatedEntityId); + target.WorkforceLaborContractorId = Trim(employment.WorkforceLaborContractorId); + target.WorkerKind = employment.WorkerKind; target.StartOn = employment.StartOn.Date; target.EndOn = employment.EndOn?.Date; + target.EmploymentType = Enum.IsDefined(typeof(EmploymentTypes), employment.EmploymentType) ? employment.EmploymentType : 0; + target.ExemptionStatus = Enum.IsDefined(typeof(ExemptionStatuses), employment.ExemptionStatus) ? employment.ExemptionStatus : 0; + target.DefaultEstablishmentId = Trim(employment.DefaultEstablishmentId); + target.CaliforniaEmployeeBasis = Enum.IsDefined(typeof(CaliforniaEmployeeBases), employment.CaliforniaEmployeeBasis) ? employment.CaliforniaEmployeeBasis : 0; + target.PersonnelRoleId = employment.PersonnelRoleId; + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _employments.SaveOrUpdateAsync(target, cancellationToken); + Audit(employment.DepartmentId, userId, AuditLogTypes.WorkforceEmploymentChanged, ipAddress, userAgent, before, saved); + return await GetEmploymentAsync(saved.WorkforceEmploymentId, employment.DepartmentId); + } + + public Task DeleteEmploymentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SoftDeleteAsync(_employments, () => _employments.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEmploymentChanged, cancellationToken); + + public async Task SaveJobAssignmentAsync(WorkforceJobAssignment assignment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (assignment == null) throw new ArgumentNullException(nameof(assignment)); + var employment = await _employments.GetByIdForDepartmentAsync(assignment.WorkforceEmploymentId ?? string.Empty, assignment.DepartmentId); + if (employment == null || employment.IsDeleted) throw new InvalidOperationException("workforce_employment_not_found"); + if (assignment.ExpiresOn.HasValue && assignment.ExpiresOn < assignment.EffectiveOn) throw new InvalidOperationException("workforce_dates_invalid"); + if (!string.IsNullOrWhiteSpace(assignment.WorkforceEstablishmentId) && (await _establishments.GetByIdForDepartmentAsync(assignment.WorkforceEstablishmentId, assignment.DepartmentId)) == null) throw new InvalidOperationException("workforce_establishment_not_found"); + if (!string.IsNullOrWhiteSpace(assignment.JobCategoryCode)) + { + var profile = CaPayDataSchemaProfile.Get(assignment.CaPayDataProfileCode) ?? CaPayDataSchemaProfile.Current; + if (profile.JobCategories.All(j => j.Code != assignment.JobCategoryCode.Trim())) throw new InvalidOperationException("workforce_job_category_invalid"); + assignment.CaPayDataProfileCode = profile.Code; + } + if (!Enum.IsDefined(typeof(WorkModes), assignment.WorkMode)) throw new InvalidOperationException("workforce_work_mode_invalid"); + var siblings = (await _assignments.GetByEmploymentAsync(employment.WorkforceEmploymentId))?.Where(a => a.WorkforceJobAssignmentId != assignment.WorkforceJobAssignmentId).ToList() ?? new List(); + if (siblings.Any(s => s.Overlaps(assignment))) throw new InvalidOperationException("workforce_assignment_overlap"); + var existing = string.IsNullOrWhiteSpace(assignment.WorkforceJobAssignmentId) ? null : await _assignments.GetByIdForDepartmentAsync(assignment.WorkforceJobAssignmentId, assignment.DepartmentId); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceJobAssignment { DepartmentId = assignment.DepartmentId, WorkforceEmploymentId = employment.WorkforceEmploymentId, AddedOn = now, AddedByUserId = userId }; + target.EffectiveOn = assignment.EffectiveOn.Date; target.ExpiresOn = assignment.ExpiresOn?.Date; + target.WorkforceEstablishmentId = Trim(assignment.WorkforceEstablishmentId) ?? employment.DefaultEstablishmentId; + target.JobTitle = Trim(assignment.JobTitle); target.SocCode = Trim(assignment.SocCode); target.SocVersion = Trim(assignment.SocVersion); + target.CaPayDataProfileCode = Trim(assignment.CaPayDataProfileCode); target.JobCategoryCode = Trim(assignment.JobCategoryCode); + target.CalOesMarsAuthorityProfileCode = Trim(assignment.CalOesMarsAuthorityProfileCode); target.CalOesMarsClassificationCode = Trim(assignment.CalOesMarsClassificationCode); + target.MappingProvenance = Trim(assignment.MappingProvenance); + target.WorkMode = assignment.WorkMode; target.WorkCountry = Trim(assignment.WorkCountry)?.ToUpperInvariant(); target.WorkSubdivision = Trim(assignment.WorkSubdivision)?.ToUpperInvariant(); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _assignments.SaveOrUpdateAsync(target, cancellationToken); + Audit(assignment.DepartmentId, userId, AuditLogTypes.WorkforceEmploymentChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public Task DeleteJobAssignmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SoftDeleteAsync(_assignments, () => _assignments.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEmploymentChanged, cancellationToken); + + #endregion + + #region Work entries and annual facts + + public async Task> GetWorkEntriesAsync(int departmentId, DateTime from, DateTime to) + { + var rows = (await _workEntries.GetForDepartmentInWindowAsync(departmentId, from, to))?.ToList() ?? new List(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.WorkEntry); + return rows; + } + + public async Task SaveWorkEntryAsync(WorkforceWorkEntry entry, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + if (string.IsNullOrWhiteSpace(entry.WorkforceWorkerId)) throw new InvalidOperationException("workforce_worker_required"); + if (entry.Hours < 0 || entry.Hours > 24) throw new InvalidOperationException("workforce_hours_invalid"); + if (!Enum.IsDefined(typeof(WorkHoursTypes), entry.HoursType)) throw new InvalidOperationException("workforce_hours_type_invalid"); + var worker = await _workers.GetByIdForDepartmentAsync(entry.WorkforceWorkerId, entry.DepartmentId); + if (worker == null || worker.IsDeleted) throw new InvalidOperationException("workforce_worker_not_found"); + if (string.IsNullOrWhiteSpace(entry.WorkforceEmploymentId)) + entry.WorkforceEmploymentId = (await _employments.GetByWorkerAsync(worker.WorkforceWorkerId))?.FirstOrDefault(e => !e.IsDeleted && e.Covers(entry.WorkDate, entry.WorkDate))?.WorkforceEmploymentId; + var existing = string.IsNullOrWhiteSpace(entry.WorkforceWorkEntryId) ? null : await _workEntries.GetByIdForDepartmentAsync(entry.WorkforceWorkEntryId, entry.DepartmentId); + if (existing == null && !string.IsNullOrWhiteSpace(entry.ExternalSource) && !string.IsNullOrWhiteSpace(entry.ExternalId)) existing = await _workEntries.GetByExternalIdAsync(entry.DepartmentId, entry.ExternalSource, entry.ExternalId); + var before = existing == null ? null : Snapshot(existing); + var now = DateTime.UtcNow; + var target = existing ?? new WorkforceWorkEntry { DepartmentId = entry.DepartmentId, WorkforceWorkerId = worker.WorkforceWorkerId, AddedOn = now, AddedByUserId = userId }; + target.WorkforceEmploymentId = entry.WorkforceEmploymentId; target.WorkDate = entry.WorkDate.Date; target.StartTime = entry.StartTime; target.EndTime = entry.EndTime; + target.Hours = entry.Hours; target.HoursType = entry.HoursType; target.WorkforceEstablishmentId = Trim(entry.WorkforceEstablishmentId); + target.WorkCountry = Trim(entry.WorkCountry)?.ToUpperInvariant(); target.WorkSubdivision = Trim(entry.WorkSubdivision)?.ToUpperInvariant(); target.WorkMode = Enum.IsDefined(typeof(WorkModes), entry.WorkMode) ? entry.WorkMode : 0; + target.CallId = entry.CallId; target.DeploymentId = Trim(entry.DeploymentId); target.DeploymentTimeReportId = Trim(entry.DeploymentTimeReportId); + target.ExternalSource = Trim(entry.ExternalSource); target.ExternalId = Trim(entry.ExternalId); target.ImportBatchId = Trim(entry.ImportBatchId); + target.IsApproved = entry.IsApproved; target.IsReconciled = entry.IsReconciled; + foreach (var accessor in WorkforceProtectedFields.WorkEntry) Keep(existing, target, entry, accessor.Value); + if (existing != null) { target.RowVersion = existing.RowVersion + 1; target.EditedOn = now; target.EditedByUserId = userId; } + var saved = await _seam.SaveAsync(_workEntries, target, existing, entry.DepartmentId, WorkforceProtectedFields.WorkEntry, cancellationToken); + Audit(entry.DepartmentId, userId, AuditLogTypes.WorkforceCompensationChanged, ipAddress, userAgent, before, saved); + return saved; + } + + public async Task> GetAnnualPayFactsAsync(int departmentId, int reportingYear, PayDataReportTypes reportType) + { + var rows = (await _annualFacts.GetForYearAsync(departmentId, reportingYear, (int)reportType))?.ToList() ?? new List(); + // Only the current version per employment / allocation. + rows = rows.GroupBy(f => (f.WorkforceEmploymentId, f.ClientAllocationKey ?? string.Empty)).Select(g => g.OrderByDescending(f => f.Version).First()).ToList(); + await _seam.ResolveForReadAsync(rows, departmentId, WorkforceProtectedFields.AnnualFact); + return rows; + } + + public async Task SaveAnnualPayFactAsync(WorkforceAnnualPayFact fact, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + if (fact == null) throw new ArgumentNullException(nameof(fact)); + var employment = await _employments.GetByIdForDepartmentAsync(fact.WorkforceEmploymentId ?? string.Empty, fact.DepartmentId); + if (employment == null || employment.IsDeleted) throw new InvalidOperationException("workforce_employment_not_found"); + if (fact.ReportingYear < 2020 || fact.ReportingYear > 2100) throw new InvalidOperationException("workforce_year_invalid"); + if (!Enum.IsDefined(typeof(PayDataReportTypes), fact.ReportType)) throw new InvalidOperationException("workforce_report_type_invalid"); + Normalize(fact); + var current = (await _annualFacts.GetByEmploymentAsync(employment.WorkforceEmploymentId))?.Where(f => !f.IsDeleted && f.ReportingYear == fact.ReportingYear && f.ReportType == fact.ReportType && (f.ClientAllocationKey ?? string.Empty) == (fact.ClientAllocationKey ?? string.Empty)).OrderByDescending(f => f.Version).FirstOrDefault(); + var now = DateTime.UtcNow; + // Corrections version: the stored fact is immutable, the new row supersedes it. + var target = new WorkforceAnnualPayFact + { + DepartmentId = fact.DepartmentId, WorkforceEmploymentId = employment.WorkforceEmploymentId, ReportingYear = fact.ReportingYear, ReportType = fact.ReportType, ClientAllocationKey = Trim(fact.ClientAllocationKey), + EarningsSource = fact.EarningsSource, ActualWorkedHours = fact.ActualWorkedHours, PaidLeaveHours = fact.PaidLeaveHours, ReportableHours = fact.ReportableHours, DaysWorked = fact.DaysWorked, WeeksWorked = fact.WeeksWorked, + ExemptProxyMethod = fact.ExemptProxyMethod, ProxyAverageHoursPerDay = fact.ProxyAverageHoursPerDay, ClientAllocatedHours = fact.ClientAllocatedHours, ClientAllocatedWeeks = fact.ClientAllocatedWeeks, + Source = Trim(fact.Source) ?? "manual", ImportBatchId = Trim(fact.ImportBatchId), SourceChecksum = Trim(fact.SourceChecksum), IsReconciled = fact.IsReconciled, IsApproved = fact.IsApproved, + Version = (current?.Version ?? 0) + 1, SupersedesFactId = current?.WorkforceAnnualPayFactId, AddedOn = now, AddedByUserId = userId, + W2Box5 = fact.W2Box5, W2Box1 = fact.W2Box1, EarningsUsed = fact.EarningsUsed, ClientAllocatedEarnings = fact.ClientAllocatedEarnings + }; + var saved = await _seam.SaveAsync(_annualFacts, target, null, fact.DepartmentId, WorkforceProtectedFields.AnnualFact, cancellationToken); + Audit(fact.DepartmentId, userId, AuditLogTypes.WorkforceAnnualPayFactImported, ipAddress, userAgent, current == null ? null : Snapshot(current), saved); + return saved; + } + + /// E1 rules: Box 5 is the earnings; Box 1 only when Box 5 is absent (remarked); reportable hours = worked + paid leave, or the exempt proxy days × average hours. + public static void Normalize(WorkforceAnnualPayFact fact) + { + if (fact.ReportType == (int)PayDataReportTypes.LaborContractorEmployee) + { + fact.EarningsUsedValue = fact.ClientAllocatedEarningsValue; + fact.EarningsSource = (int)EarningsSources.ClientAllocated; + fact.ReportableHours = fact.ClientAllocatedHours ?? fact.ReportableHours; + fact.WeeksWorked = fact.ClientAllocatedWeeks ?? fact.WeeksWorked; + return; + } + if (fact.W2Box5Value.HasValue) { fact.EarningsUsedValue = fact.W2Box5Value; fact.EarningsSource = (int)EarningsSources.W2Box5; } + else if (fact.W2Box1Value.HasValue) { fact.EarningsUsedValue = fact.W2Box1Value; fact.EarningsSource = (int)EarningsSources.W2Box1Fallback; } + if (fact.ExemptProxyMethod == (int)ExemptProxyMethods.DaysTimesAverageHours && fact.DaysWorked.HasValue && fact.ProxyAverageHoursPerDay.HasValue) + fact.ReportableHours = fact.DaysWorked.Value * fact.ProxyAverageHoursPerDay.Value; + else if (fact.ActualWorkedHours.HasValue || fact.PaidLeaveHours.HasValue) + fact.ReportableHours = (fact.ActualWorkedHours ?? 0) + (fact.PaidLeaveHours ?? 0); + } + + public async Task ImportAnnualPayFactsAsync(int departmentId, string csv, bool dryRun, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + { + var result = new WorkforceImportResult { DryRun = dryRun, ImportBatchId = Guid.NewGuid().ToString() }; + if (string.IsNullOrWhiteSpace(csv)) { result.Issues.Add(new WorkforceImportIssue { Line = 0, Code = "empty", IsError = true }); return result; } + var workers = (await _workers.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + await _seam.ResolveForWorkloadAsync(workers, departmentId, WorkforceProtectedFields.ReportingWorkloadPurpose, WorkforceProtectedFields.Worker); + var employments = (await _employments.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + var rows = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + using var reader = new StringReader(csv); + string line; var number = 0; string[] header = null; + while ((line = reader.ReadLine()) != null) + { + number++; + if (string.IsNullOrWhiteSpace(line)) continue; + var cells = ParseCsvLine(line); + if (header == null) { header = cells.Select(c => c.Trim().ToLowerInvariant()).ToArray(); continue; } + result.Total++; + string Cell(string name) { var i = Array.IndexOf(header, name.ToLowerInvariant()); return i >= 0 && i < cells.Count ? cells[i].Trim() : null; } + var key = Cell("ExternalWorkerKey"); var user = Cell("UserId"); + var worker = !string.IsNullOrWhiteSpace(user) ? workers.FirstOrDefault(w => string.Equals(w.UserId, user, StringComparison.OrdinalIgnoreCase)) : workers.FirstOrDefault(w => string.Equals(w.ExternalWorkerKey, key, StringComparison.OrdinalIgnoreCase)); + if (worker == null) { result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "worker_not_found", Detail = user ?? key, IsError = true }); continue; } + if (!int.TryParse(Cell("ReportingYear"), out var year)) { result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "year_invalid", IsError = true }); continue; } + var typeText = Cell("ReportType"); var type = string.Equals(typeText, "LaborContractorEmployee", StringComparison.OrdinalIgnoreCase) || typeText == "1" ? PayDataReportTypes.LaborContractorEmployee : PayDataReportTypes.PayrollEmployee; + var employment = employments.Where(e => e.WorkforceWorkerId == worker.WorkforceWorkerId && !e.IsDeleted && e.Covers(new DateTime(year, 1, 1), new DateTime(year, 12, 31))).OrderByDescending(e => e.StartOn).FirstOrDefault(); + if (employment == null) { result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "employment_not_found", Detail = $"{user ?? key} {year}", IsError = true }); continue; } + var allocation = Cell("ClientAllocationKey"); + var dedupe = $"{employment.WorkforceEmploymentId}|{year}|{(int)type}|{allocation}"; + if (!seen.Add(dedupe)) { result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "duplicate_row", Detail = dedupe, IsError = true }); continue; } + var fact = new WorkforceAnnualPayFact + { + DepartmentId = departmentId, WorkforceEmploymentId = employment.WorkforceEmploymentId, ReportingYear = year, ReportType = (int)type, ClientAllocationKey = Trim(allocation), + W2Box5Value = Dec(Cell("W2Box5")), W2Box1Value = Dec(Cell("W2Box1")), ActualWorkedHours = Dec(Cell("ActualWorkedHours")), PaidLeaveHours = Dec(Cell("PaidLeaveHours")), + DaysWorked = int.TryParse(Cell("DaysWorked"), out var days) ? days : null, WeeksWorked = Dec(Cell("WeeksWorked")), + ExemptProxyMethod = string.Equals(Cell("ExemptProxyMethod"), "DaysTimesAverageHours", StringComparison.OrdinalIgnoreCase) ? (int)ExemptProxyMethods.DaysTimesAverageHours : string.IsNullOrWhiteSpace(Cell("ExemptProxyMethod")) ? 0 : (int)ExemptProxyMethods.ActualPlusPaidLeave, + ProxyAverageHoursPerDay = Dec(Cell("ProxyAverageHoursPerDay")), ClientAllocatedEarningsValue = Dec(Cell("ClientAllocatedEarnings")), ClientAllocatedHours = Dec(Cell("ClientAllocatedHours")), ClientAllocatedWeeks = Dec(Cell("ClientAllocatedWeeks")), + Source = "import", ImportBatchId = result.ImportBatchId, SourceChecksum = PayDataAggregator.Sha256(System.Text.Encoding.UTF8.GetBytes(line)), IsReconciled = true, IsApproved = false + }; + Normalize(fact); + if (!fact.EarningsUsedValue.HasValue) result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "earnings_missing", Detail = user ?? key, IsError = true }); + if (!(fact.ReportableHours > 0)) result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "hours_missing", Detail = user ?? key, IsError = true }); + if (fact.EarningsSource == (int)EarningsSources.W2Box1Fallback) result.Issues.Add(new WorkforceImportIssue { Line = number, Code = "box1_fallback", Detail = user ?? key, IsError = false }); + rows.Add(fact); + } + if (result.HasErrors || dryRun) { result.Skipped = result.Total; return result; } + foreach (var fact in rows) + { + var current = (await _annualFacts.GetByEmploymentAsync(fact.WorkforceEmploymentId))?.Any(f => !f.IsDeleted && f.ReportingYear == fact.ReportingYear && f.ReportType == fact.ReportType && (f.ClientAllocationKey ?? string.Empty) == (fact.ClientAllocationKey ?? string.Empty)) == true; + await SaveAnnualPayFactAsync(fact, userId, ipAddress, userAgent, cancellationToken); + if (current) result.Updated++; else result.Created++; + } + return result; + } + + private static decimal? Dec(string value) => decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; + + public static List ParseCsvLine(string line) + { + var cells = new List(); var current = new System.Text.StringBuilder(); var quoted = false; + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (quoted) { if (c == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } else quoted = false; } else current.Append(c); } + else if (c == '"') quoted = true; + else if (c == ',') { cells.Add(current.ToString()); current.Clear(); } + else current.Append(c); + } + cells.Add(current.ToString()); + return cells; + } + + #endregion + + #region Helpers + + private async Task NameWorkersAsync(IReadOnlyList rows) + { + var userIds = rows.Where(r => !string.IsNullOrWhiteSpace(r.UserId)).Select(r => r.UserId).Distinct().ToList(); + var profiles = userIds.Count == 0 ? new List() : (await _userProfileService.GetSelectedUserProfilesAsync(userIds))?.ToList() ?? new List(); + foreach (var row in rows) + { + var profile = string.IsNullOrWhiteSpace(row.UserId) ? null : profiles.FirstOrDefault(p => string.Equals(p.UserId, row.UserId, StringComparison.OrdinalIgnoreCase)); + row.DisplayName = profile?.FullName.AsFirstNameLastName ?? (WorkforceProtectionSeam.IsUnavailable(row.DisplayLabel) ? ProtectedDataEnvelope.RedactionValue : row.DisplayLabel) ?? row.UserId ?? row.WorkforceWorkerId; + } + } + + /// A REDACTED value posted back from an unrevealed page keeps the stored envelope; anything else is the new value. + private static void Keep(T existing, T target, T input, (Func Get, Action Set) accessor) + { + var value = accessor.Get(input); + if (existing != null && value == ProtectedDataEnvelope.RedactionValue) { accessor.Set(target, accessor.Get(existing)); return; } + accessor.Set(target, string.IsNullOrWhiteSpace(value) ? null : value.Trim()); + } + + private async Task SoftDeleteAsync(IRepository repository, Func> load, Func deleted, Action setDeleted, Action stamp, int departmentId, string userId, string ipAddress, string userAgent, AuditLogTypes type, CancellationToken cancellationToken) where T : class, IEntity + { + var row = await load(); + if (row == null || deleted(row)) return false; + var before = Snapshot(row); + setDeleted(row, true); stamp(row, DateTime.UtcNow, userId); + await repository.SaveOrUpdateAsync(row, cancellationToken); + Audit(departmentId, userId, type, ipAddress, userAgent, before, row); + return true; + } + + private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Decision 27: audit snapshots carry ids, versions, codes and dates — every protected value is replaced by a field marker. + internal static string Snapshot(T entity) + { + var clone = entity.CloneJson(); + switch (clone) + { + case WorkforceEmployerProfile r: foreach (var a in WorkforceProtectedFields.Employer) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceAffiliatedEntity r: foreach (var a in WorkforceProtectedFields.Affiliate) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceEstablishment r: foreach (var a in WorkforceProtectedFields.Establishment) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceLaborContractor r: foreach (var a in WorkforceProtectedFields.Contractor) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceWorker r: foreach (var a in WorkforceProtectedFields.Worker) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceEmployment r: r.Assignments = null; break; + case WorkforceWorkEntry r: foreach (var a in WorkforceProtectedFields.WorkEntry) a.Value.Set(r, Marker(a.Value.Get(r))); break; + case WorkforceAnnualPayFact r: foreach (var a in WorkforceProtectedFields.AnnualFact) a.Value.Set(r, Marker(a.Value.Get(r))); break; + } + return clone.CloneJsonToString(); + } + + internal static string Marker(string value) => string.IsNullOrEmpty(value) ? null : "[protected]"; + + 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/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs index 0de43977..1dc986a6 100644 --- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs +++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs @@ -1670,7 +1670,7 @@ public static void AddRecordClaims(ClaimsIdentity identity, bool isAdmin, List

Configure a feature's catalog/rules/settings (Phase D certification setup).

public const string Setup = "Setup"; + /// Reconcile an external system's invoice / payment against local records (Cal OES MARS, Phase C-M3). + public const string Reconcile = "Reconcile"; } public static class Memberships @@ -67,6 +69,13 @@ public static class Resources public const string TimeReports = "TimeReports"; public const string Bids = "Bids"; public const string ServiceContracts = "ServiceContracts"; + /// Cal OES MARS cost recovery (Workforce & Business Operations plan, C-M3; permission 79). + public const string MutualAidReimbursement = "MutualAidReimbursement"; + /// Workforce & Business Operations plan, Phase E (permissions 74-78): employer / establishments / workers / employments. + public const string Workforce = "Workforce"; + public const string WorkforceCompensation = "WorkforceCompensation"; + public const string InternalCosts = "InternalCosts"; + public const string PayDataReporting = "PayDataReporting"; 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 63d75602..d092d510 100644 --- a/Providers/Resgrid.Providers.Claims/ResgridResources.cs +++ b/Providers/Resgrid.Providers.Claims/ResgridResources.cs @@ -214,6 +214,18 @@ public static class ResgridResources public const string Bids_Delete = "Bids_Delete"; public const string ServiceContracts_View = "ServiceContracts_View"; public const string ServiceContracts_Update = "ServiceContracts_Update"; + public const string MutualAidReimbursement_View = "MutualAidReimbursement_View"; + public const string MutualAidReimbursement_Update = "MutualAidReimbursement_Update"; + public const string MutualAidReimbursement_Submit = "MutualAidReimbursement_Submit"; + public const string MutualAidReimbursement_Reconcile = "MutualAidReimbursement_Reconcile"; + public const string Workforce_View = "Workforce_View"; + public const string Workforce_Update = "Workforce_Update"; + public const string WorkforceCompensation_View = "WorkforceCompensation_View"; + public const string WorkforceCompensation_Update = "WorkforceCompensation_Update"; + public const string InternalCosts_View = "InternalCosts_View"; + public const string PayDataReporting_View = "PayDataReporting_View"; + public const string PayDataReporting_Update = "PayDataReporting_Update"; + public const string PayDataReporting_Export = "PayDataReporting_Export"; 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.Migrations/Migrations/M0220_AddWorkforceEmploymentAndEstablishments.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0220_AddWorkforceEmploymentAndEstablishments.cs new file mode 100644 index 00000000..ff2b77ba --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0220_AddWorkforceEmploymentAndEstablishments.cs @@ -0,0 +1,209 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase E (E2): employer identity, affiliated entities, establishments, labor contractors, workers, employment periods and job assignments. Identifiers and addresses are ADP catalog 28 columns (text, envelope-ready) with IsProtected/ProtectedCatalogVersion markers. Registry M0220. Guarded for safe retry. + /// + [Migration(220)] + public class M0220_AddWorkforceEmploymentAndEstablishments : Migration + { + public override void Up() + { + if (!Schema.Table("WorkforceEmployerProfiles").Exists()) + { + Create.Table("WorkforceEmployerProfiles") + .WithColumn("WorkforceEmployerProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("LegalName").AsString(250).Nullable() + .WithColumn("Fein").AsString(int.MaxValue).Nullable() + .WithColumn("Sein").AsString(int.MaxValue).Nullable() + .WithColumn("SosNumber").AsString(int.MaxValue).Nullable() + .WithColumn("Naics").AsString(6).Nullable() + .WithColumn("EddAddress").AsString(int.MaxValue).Nullable() + .WithColumn("HeadquartersAddress").AsString(int.MaxValue).Nullable() + .WithColumn("IsIntegratedEnterprise").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("FilingContactName").AsString(int.MaxValue).Nullable() + .WithColumn("FilingContactEmail").AsString(int.MaxValue).Nullable() + .WithColumn("FilingContactPhone").AsString(int.MaxValue).Nullable() + .WithColumn("CoverageStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UsEmployeeCount").AsInt32().Nullable() + .WithColumn("CaliforniaEmployeeCount").AsInt32().Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceEmployerProfiles_Department").OnTable("WorkforceEmployerProfiles").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("WorkforceAffiliatedEntities").Exists()) + { + Create.Table("WorkforceAffiliatedEntities") + .WithColumn("WorkforceAffiliatedEntityId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceEmployerProfileId").AsString(36).Nullable() + .WithColumn("LegalName").AsString(250).Nullable() + .WithColumn("Fein").AsString(int.MaxValue).Nullable() + .WithColumn("Sein").AsString(int.MaxValue).Nullable() + .WithColumn("SosNumber").AsString(int.MaxValue).Nullable() + .WithColumn("HeadquartersAddress").AsString(int.MaxValue).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceAffiliatedEntities_Department").OnTable("WorkforceAffiliatedEntities").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("WorkforceEstablishments").Exists()) + { + Create.Table("WorkforceEstablishments") + .WithColumn("WorkforceEstablishmentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceAffiliatedEntityId").AsString(36).Nullable() + .WithColumn("Code").AsString(50).Nullable() + .WithColumn("Name").AsString(250).Nullable() + .WithColumn("PhysicalAddress").AsString(int.MaxValue).Nullable() + .WithColumn("City").AsString(100).Nullable() + .WithColumn("StateCode").AsString(2).Nullable() + .WithColumn("PostalCode").AsString(10).Nullable() + .WithColumn("Naics").AsString(6).Nullable() + .WithColumn("MajorActivity").AsString(250).Nullable() + .WithColumn("IsHeadquarters").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("WasFiledPriorYear").AsBoolean().Nullable() + .WithColumn("ActiveFrom").AsDateTime2().Nullable() + .WithColumn("ActiveTo").AsDateTime2().Nullable() + .WithColumn("TimeZoneId").AsString(100).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceEstablishments_Department").OnTable("WorkforceEstablishments").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_WorkforceEstablishments_Code' AND object_id = OBJECT_ID('WorkforceEstablishments')) CREATE UNIQUE INDEX [UX_WorkforceEstablishments_Code] ON [WorkforceEstablishments] ([DepartmentId], [Code]) WHERE [IsDeleted] = 0;"); + } + if (!Schema.Table("WorkforceLaborContractors").Exists()) + { + Create.Table("WorkforceLaborContractors") + .WithColumn("WorkforceLaborContractorId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("LegalName").AsString(250).Nullable() + .WithColumn("OwnershipName").AsString(250).Nullable() + .WithColumn("Dba").AsString(250).Nullable() + .WithColumn("Fein").AsString(int.MaxValue).Nullable() + .WithColumn("IdentifierType").AsString(20).Nullable() + .WithColumn("ContactDetails").AsString(int.MaxValue).Nullable() + .WithColumn("RelationshipStartOn").AsDateTime2().Nullable() + .WithColumn("RelationshipEndOn").AsDateTime2().Nullable() + .WithColumn("Provenance").AsString(500).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceLaborContractors_Department").OnTable("WorkforceLaborContractors").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("WorkforceWorkers").Exists()) + { + Create.Table("WorkforceWorkers") + .WithColumn("WorkforceWorkerId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).Nullable() + .WithColumn("ExternalWorkerKey").AsString(int.MaxValue).Nullable() + .WithColumn("DisplayLabel").AsString(int.MaxValue).Nullable() + .WithColumn("IsActive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceWorkers_Department").OnTable("WorkforceWorkers").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_WorkforceWorkers_User' AND object_id = OBJECT_ID('WorkforceWorkers')) CREATE UNIQUE INDEX [UX_WorkforceWorkers_User] ON [WorkforceWorkers] ([DepartmentId], [UserId]) WHERE [UserId] IS NOT NULL AND [IsDeleted] = 0;"); + } + if (!Schema.Table("WorkforceEmployments").Exists()) + { + Create.Table("WorkforceEmployments") + .WithColumn("WorkforceEmploymentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceWorkerId").AsString(36).Nullable() + .WithColumn("WorkforceAffiliatedEntityId").AsString(36).Nullable() + .WithColumn("WorkforceLaborContractorId").AsString(36).Nullable() + .WithColumn("WorkerKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StartOn").AsDateTime2().NotNullable() + .WithColumn("EndOn").AsDateTime2().Nullable() + .WithColumn("EmploymentType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ExemptionStatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("DefaultEstablishmentId").AsString(36).Nullable() + .WithColumn("CaliforniaEmployeeBasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("PersonnelRoleId").AsInt32().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_WorkforceEmployments_Worker").OnTable("WorkforceEmployments").OnColumn("WorkforceWorkerId").Ascending().OnColumn("StartOn").Ascending(); + Create.Index("IX_WorkforceEmployments_Department").OnTable("WorkforceEmployments").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("WorkforceJobAssignments").Exists()) + { + Create.Table("WorkforceJobAssignments") + .WithColumn("WorkforceJobAssignmentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceEmploymentId").AsString(36).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("WorkforceEstablishmentId").AsString(36).Nullable() + .WithColumn("JobTitle").AsString(250).Nullable() + .WithColumn("SocCode").AsString(20).Nullable() + .WithColumn("SocVersion").AsString(20).Nullable() + .WithColumn("CaPayDataProfileCode").AsString(50).Nullable() + .WithColumn("JobCategoryCode").AsString(10).Nullable() + .WithColumn("CalOesMarsAuthorityProfileCode").AsString(50).Nullable() + .WithColumn("CalOesMarsClassificationCode").AsString(100).Nullable() + .WithColumn("MappingProvenance").AsString(500).Nullable() + .WithColumn("WorkMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WorkCountry").AsString(2).Nullable() + .WithColumn("WorkSubdivision").AsString(10).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_WorkforceJobAssignments_Employment").OnTable("WorkforceJobAssignments").OnColumn("WorkforceEmploymentId").Ascending().OnColumn("EffectiveOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("WorkforceJobAssignments").Exists()) Delete.Table("WorkforceJobAssignments"); + if (Schema.Table("WorkforceEmployments").Exists()) Delete.Table("WorkforceEmployments"); + if (Schema.Table("WorkforceWorkers").Exists()) Delete.Table("WorkforceWorkers"); + if (Schema.Table("WorkforceLaborContractors").Exists()) Delete.Table("WorkforceLaborContractors"); + if (Schema.Table("WorkforceEstablishments").Exists()) Delete.Table("WorkforceEstablishments"); + if (Schema.Table("WorkforceAffiliatedEntities").Exists()) Delete.Table("WorkforceAffiliatedEntities"); + if (Schema.Table("WorkforceEmployerProfiles").Exists()) Delete.Table("WorkforceEmployerProfiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0221_AddWorkforceCompensationAndAnnualFacts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0221_AddWorkforceCompensationAndAnnualFacts.cs new file mode 100644 index 00000000..f0dccff4 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0221_AddWorkforceCompensationAndAnnualFacts.cs @@ -0,0 +1,186 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase E (E2): compensation profiles, pay / employer-cost components, work entries and annual pay facts. Monetary values are ADP catalog 28 text columns. Registry M0221. Guarded for safe retry. + /// + [Migration(221)] + public class M0221_AddWorkforceCompensationAndAnnualFacts : Migration + { + public override void Up() + { + if (!Schema.Table("EmployeeCompensationProfiles").Exists()) + { + Create.Table("EmployeeCompensationProfiles") + .WithColumn("EmployeeCompensationProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Scope").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WorkforceEmploymentId").AsString(36).Nullable() + .WithColumn("PersonnelRoleId").AsInt32().Nullable() + .WithColumn("EffectiveOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Currency").AsString(3).Nullable() + .WithColumn("PayBasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("BaseAmount").AsString(int.MaxValue).Nullable() + .WithColumn("RegularHourlyEquivalent").AsString(int.MaxValue).Nullable() + .WithColumn("StandardHoursPerDay").AsDecimal(9,2).Nullable() + .WithColumn("StandardHoursPerWeek").AsDecimal(9,2).Nullable() + .WithColumn("StandardHoursPerYear").AsDecimal(9,2).Nullable() + .WithColumn("RateMultipliersJson").AsString(int.MaxValue).Nullable() + .WithColumn("Source").AsString(100).Nullable() + .WithColumn("ImportBatchId").AsString(36).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ApprovedByUserId").AsString(128).Nullable() + .WithColumn("ApprovedOn").AsDateTime2().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_EmployeeCompensationProfiles_Employment").OnTable("EmployeeCompensationProfiles").OnColumn("WorkforceEmploymentId").Ascending().OnColumn("EffectiveOn").Ascending(); + Create.Index("IX_EmployeeCompensationProfiles_Department").OnTable("EmployeeCompensationProfiles").OnColumn("DepartmentId").Ascending().OnColumn("Scope").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("EmployeePayComponents").Exists()) + { + Create.Table("EmployeePayComponents") + .WithColumn("EmployeePayComponentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("EmployeeCompensationProfileId").AsString(36).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(100).Nullable() + .WithColumn("Basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Amount").AsString(int.MaxValue).Nullable() + .WithColumn("EligiblePayCodesCsv").AsString(100).Nullable() + .WithColumn("PaidForEachOvertimeHour").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SourceAgreement").AsString(250).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_EmployeePayComponents_Profile").OnTable("EmployeePayComponents").OnColumn("EmployeeCompensationProfileId").Ascending(); + } + if (!Schema.Table("EmployeeCostComponents").Exists()) + { + Create.Table("EmployeeCostComponents") + .WithColumn("EmployeeCostComponentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("EmployeeCompensationProfileId").AsString(36).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Name").AsString(100).Nullable() + .WithColumn("Basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RateAmount").AsString(int.MaxValue).Nullable() + .WithColumn("EligiblePayCodesCsv").AsString(100).Nullable() + .WithColumn("Cap").AsString(int.MaxValue).Nullable() + .WithColumn("Source").AsString(250).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_EmployeeCostComponents_Profile").OnTable("EmployeeCostComponents").OnColumn("EmployeeCompensationProfileId").Ascending(); + } + if (!Schema.Table("WorkforceWorkEntries").Exists()) + { + Create.Table("WorkforceWorkEntries") + .WithColumn("WorkforceWorkEntryId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceWorkerId").AsString(36).Nullable() + .WithColumn("WorkforceEmploymentId").AsString(36).Nullable() + .WithColumn("WorkDate").AsDateTime2().NotNullable() + .WithColumn("StartTime").AsDateTime2().Nullable() + .WithColumn("EndTime").AsDateTime2().Nullable() + .WithColumn("Hours").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("HoursType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WorkforceEstablishmentId").AsString(36).Nullable() + .WithColumn("WorkCountry").AsString(2).Nullable() + .WithColumn("WorkSubdivision").AsString(10).Nullable() + .WithColumn("WorkMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("DeploymentId").AsString(36).Nullable() + .WithColumn("DeploymentTimeReportId").AsString(36).Nullable() + .WithColumn("ApprovedPayrollCost").AsString(int.MaxValue).Nullable() + .WithColumn("ExternalSource").AsString(100).Nullable() + .WithColumn("ExternalId").AsString(200).Nullable() + .WithColumn("ImportBatchId").AsString(36).Nullable() + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsReconciled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceWorkEntries_Worker").OnTable("WorkforceWorkEntries").OnColumn("WorkforceWorkerId").Ascending().OnColumn("WorkDate").Ascending(); + Create.Index("IX_WorkforceWorkEntries_Deployment").OnTable("WorkforceWorkEntries").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_WorkforceWorkEntries_Call").OnTable("WorkforceWorkEntries").OnColumn("CallId").Ascending(); + } + if (!Schema.Table("WorkforceAnnualPayFacts").Exists()) + { + Create.Table("WorkforceAnnualPayFacts") + .WithColumn("WorkforceAnnualPayFactId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceEmploymentId").AsString(36).Nullable() + .WithColumn("ReportingYear").AsInt32().NotNullable() + .WithColumn("ReportType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ClientAllocationKey").AsString(100).Nullable() + .WithColumn("W2Box5").AsString(int.MaxValue).Nullable() + .WithColumn("W2Box1").AsString(int.MaxValue).Nullable() + .WithColumn("EarningsUsed").AsString(int.MaxValue).Nullable() + .WithColumn("EarningsSource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ActualWorkedHours").AsDecimal(12,2).Nullable() + .WithColumn("PaidLeaveHours").AsDecimal(12,2).Nullable() + .WithColumn("ReportableHours").AsDecimal(12,2).Nullable() + .WithColumn("DaysWorked").AsInt32().Nullable() + .WithColumn("WeeksWorked").AsDecimal(9,2).Nullable() + .WithColumn("ExemptProxyMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ProxyAverageHoursPerDay").AsDecimal(9,2).Nullable() + .WithColumn("ClientAllocatedEarnings").AsString(int.MaxValue).Nullable() + .WithColumn("ClientAllocatedHours").AsDecimal(12,2).Nullable() + .WithColumn("ClientAllocatedWeeks").AsDecimal(9,2).Nullable() + .WithColumn("Source").AsString(100).Nullable() + .WithColumn("ImportBatchId").AsString(36).Nullable() + .WithColumn("SourceChecksum").AsString(128).Nullable() + .WithColumn("IsReconciled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Version").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SupersedesFactId").AsString(36).Nullable() + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_WorkforceAnnualPayFacts_Employment").OnTable("WorkforceAnnualPayFacts").OnColumn("WorkforceEmploymentId").Ascending().OnColumn("ReportingYear").Ascending(); + Create.Index("IX_WorkforceAnnualPayFacts_Department").OnTable("WorkforceAnnualPayFacts").OnColumn("DepartmentId").Ascending().OnColumn("ReportingYear").Ascending().OnColumn("ReportType").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("WorkforceAnnualPayFacts").Exists()) Delete.Table("WorkforceAnnualPayFacts"); + if (Schema.Table("WorkforceWorkEntries").Exists()) Delete.Table("WorkforceWorkEntries"); + if (Schema.Table("EmployeeCostComponents").Exists()) Delete.Table("EmployeeCostComponents"); + if (Schema.Table("EmployeePayComponents").Exists()) Delete.Table("EmployeePayComponents"); + if (Schema.Table("EmployeeCompensationProfiles").Exists()) Delete.Table("EmployeeCompensationProfiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0222_AddResourceAndFieldCosting.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0222_AddResourceAndFieldCosting.cs new file mode 100644 index 00000000..cd67bfe3 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0222_AddResourceAndFieldCosting.cs @@ -0,0 +1,202 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase E (E2): resource cost profiles and components, usage entries and the internal field-cost runs / lines. Personnel line detail is an ADP catalog 28 column. Registry M0222. Guarded for safe retry. + /// + [Migration(222)] + public class M0222_AddResourceAndFieldCosting : Migration + { + public override void Up() + { + if (!Schema.Table("ResourceCostProfiles").Exists()) + { + Create.Table("ResourceCostProfiles") + .WithColumn("ResourceCostProfileId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubjectType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UnitId").AsInt32().Nullable() + .WithColumn("InventoryAssetId").AsString(36).Nullable() + .WithColumn("ExternalResourceKey").AsString(100).Nullable() + .WithColumn("Name").AsString(250).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Currency").AsString(3).Nullable() + .WithColumn("AcquisitionCost").AsDecimal(18,2).Nullable() + .WithColumn("AcquisitionDate").AsDateTime2().Nullable() + .WithColumn("InServiceDate").AsDateTime2().Nullable() + .WithColumn("SalvageValue").AsDecimal(18,2).Nullable() + .WithColumn("DepreciationMethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AllocationBasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UsefulLifeQuantity").AsDecimal(18,2).Nullable() + .WithColumn("UsefulLifeMonths").AsInt32().Nullable() + .WithColumn("ExpectedAnnualUtilization").AsDecimal(18,2).Nullable() + .WithColumn("Source").AsString(100).Nullable() + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ApprovedByUserId").AsString(128).Nullable() + .WithColumn("ApprovedOn").AsDateTime2().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_ResourceCostProfiles_Department").OnTable("ResourceCostProfiles").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending(); + Create.Index("IX_ResourceCostProfiles_Unit").OnTable("ResourceCostProfiles").OnColumn("UnitId").Ascending(); + } + if (!Schema.Table("ResourceCostComponents").Exists()) + { + Create.Table("ResourceCostComponents") + .WithColumn("ResourceCostComponentId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ResourceCostProfileId").AsString(36).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().Nullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("Category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Rate").AsDecimal(18,4).Nullable() + .WithColumn("ConsumptionQuantity").AsDecimal(18,4).Nullable() + .WithColumn("ConsumptionUnit").AsString(20).Nullable() + .WithColumn("UnitPrice").AsDecimal(18,4).Nullable() + .WithColumn("Source").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SourceWindowStart").AsDateTime2().Nullable() + .WithColumn("SourceWindowEnd").AsDateTime2().Nullable() + .WithColumn("SourceMeterStart").AsDecimal(18,2).Nullable() + .WithColumn("SourceMeterEnd").AsDecimal(18,2).Nullable() + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_ResourceCostComponents_Profile").OnTable("ResourceCostComponents").OnColumn("ResourceCostProfileId").Ascending(); + } + if (!Schema.Table("ResourceUsageEntries").Exists()) + { + Create.Table("ResourceUsageEntries") + .WithColumn("ResourceUsageEntryId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubjectType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UnitId").AsInt32().Nullable() + .WithColumn("InventoryAssetId").AsString(36).Nullable() + .WithColumn("ExternalResourceKey").AsString(100).Nullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("DeploymentId").AsString(36).Nullable() + .WithColumn("DeploymentTimeReportId").AsString(36).Nullable() + .WithColumn("UsageDate").AsDateTime2().NotNullable() + .WithColumn("Phase").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("StartOdometer").AsDecimal(18,2).Nullable() + .WithColumn("EndOdometer").AsDecimal(18,2).Nullable() + .WithColumn("DistanceUnit").AsString(5).Nullable() + .WithColumn("OriginalDistance").AsDecimal(18,2).Nullable() + .WithColumn("CanonicalDistanceMiles").AsDecimal(18,2).Nullable() + .WithColumn("StartEngineMeter").AsDecimal(18,2).Nullable() + .WithColumn("EndEngineMeter").AsDecimal(18,2).Nullable() + .WithColumn("EngineHours").AsDecimal(18,2).Nullable() + .WithColumn("OperatingHours").AsDecimal(18,2).Nullable() + .WithColumn("IdleHours").AsDecimal(18,2).Nullable() + .WithColumn("DeployedDays").AsDecimal(9,2).Nullable() + .WithColumn("StandbyDays").AsDecimal(9,2).Nullable() + .WithColumn("FuelQuantity").AsDecimal(18,2).Nullable() + .WithColumn("FuelUnit").AsString(10).Nullable() + .WithColumn("FuelActualCost").AsDecimal(18,2).Nullable() + .WithColumn("Source").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ExternalId").AsString(200).Nullable() + .WithColumn("IsApproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("NeedsReview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ReviewReason").AsString(500).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_ResourceUsageEntries_Deployment").OnTable("ResourceUsageEntries").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_ResourceUsageEntries_Call").OnTable("ResourceUsageEntries").OnColumn("CallId").Ascending(); + Create.Index("IX_ResourceUsageEntries_Unit").OnTable("ResourceUsageEntries").OnColumn("UnitId").Ascending().OnColumn("UsageDate").Ascending(); + } + if (!Schema.Table("FieldCostRuns").Exists()) + { + Create.Table("FieldCostRuns") + .WithColumn("FieldCostRunId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ContextType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("BidId").AsString(36).Nullable() + .WithColumn("CallId").AsInt32().Nullable() + .WithColumn("DeploymentId").AsString(36).Nullable() + .WithColumn("RunType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ThroughDate").AsDateTime2().Nullable() + .WithColumn("Currency").AsString(3).Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("InputVersions").AsString(int.MaxValue).Nullable() + .WithColumn("RevenueSource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RevenueAmount").AsDecimal(18,2).Nullable() + .WithColumn("RevenueSourceId").AsString(36).Nullable() + .WithColumn("RevenueSourceVersion").AsString(50).Nullable() + .WithColumn("PersonnelTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ResourceTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ConsumableTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ExpenseTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("OverheadTotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("TotalLoadedCost").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ContributionMargin").AsDecimal(18,2).Nullable() + .WithColumn("ContributionMarginPercent").AsDecimal(9,4).Nullable() + .WithColumn("BreakEvenRevenue").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("MissingInputCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SupersedesRunId").AsString(36).Nullable() + .WithColumn("FrozenByUserId").AsString(128).Nullable() + .WithColumn("FrozenOn").AsDateTime2().Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable(); + Create.Index("IX_FieldCostRuns_Department").OnTable("FieldCostRuns").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending(); + Create.Index("IX_FieldCostRuns_Deployment").OnTable("FieldCostRuns").OnColumn("DeploymentId").Ascending(); + Create.Index("IX_FieldCostRuns_Bid").OnTable("FieldCostRuns").OnColumn("BidId").Ascending(); + Create.Index("IX_FieldCostRuns_Call").OnTable("FieldCostRuns").OnColumn("CallId").Ascending(); + } + if (!Schema.Table("FieldCostLines").Exists()) + { + Create.Table("FieldCostLines") + .WithColumn("FieldCostLineId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("FieldCostRunId").AsString(36).Nullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("LineDate").AsDateTime2().Nullable() + .WithColumn("Category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SubjectType").AsString(30).Nullable() + .WithColumn("SubjectId").AsString(128).Nullable() + .WithColumn("SubjectLabel").AsString(250).Nullable() + .WithColumn("Component").AsString(100).Nullable() + .WithColumn("Quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("Unit").AsString(20).Nullable() + .WithColumn("Rate").AsDecimal(18,4).Nullable() + .WithColumn("Amount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("ProtectedDetailJson").AsString(int.MaxValue).Nullable() + .WithColumn("SourceType").AsString(50).Nullable() + .WithColumn("SourceId").AsString(128).Nullable() + .WithColumn("IsEstimated").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("IsFallback").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ReviewReason").AsString(250).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_FieldCostLines_Run").OnTable("FieldCostLines").OnColumn("FieldCostRunId").Ascending().OnColumn("SortOrder").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("FieldCostLines").Exists()) Delete.Table("FieldCostLines"); + if (Schema.Table("FieldCostRuns").Exists()) Delete.Table("FieldCostRuns"); + if (Schema.Table("ResourceUsageEntries").Exists()) Delete.Table("ResourceUsageEntries"); + if (Schema.Table("ResourceCostComponents").Exists()) Delete.Table("ResourceCostComponents"); + if (Schema.Table("ResourceCostProfiles").Exists()) Delete.Table("ResourceCostProfiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0223_AddCaliforniaPayDataReporting.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0223_AddCaliforniaPayDataReporting.cs new file mode 100644 index 00000000..087cae5d --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0223_AddCaliforniaPayDataReporting.cs @@ -0,0 +1,181 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase E (E2): separately stored demographic responses, CRD report runs, immutable employee snapshots and aggregate rows, and short-lived encrypted export artifacts (ADP catalog 28). Registry M0223. Guarded for safe retry. + /// + [Migration(223)] + public class M0223_AddCaliforniaPayDataReporting : Migration + { + public override void Up() + { + if (!Schema.Table("PayDataReportingDemographics").Exists()) + { + Create.Table("PayDataReportingDemographics") + .WithColumn("PayDataReportingDemographicId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceWorkerId").AsString(36).Nullable() + .WithColumn("EffectiveOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().Nullable() + .WithColumn("HispanicLatino").AsString(int.MaxValue).Nullable() + .WithColumn("RaceEthnicityCodes").AsString(int.MaxValue).Nullable() + .WithColumn("SexCode").AsString(int.MaxValue).Nullable() + .WithColumn("DeclinedRaceEthnicity").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DeclinedSex").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CollectionSource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CollectedOn").AsDateTime2().Nullable() + .WithColumn("CollectedByUserId").AsString(128).Nullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable() + .WithColumn("Version").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_PayDataReportingDemographics_Worker").OnTable("PayDataReportingDemographics").OnColumn("WorkforceWorkerId").Ascending().OnColumn("EffectiveOn").Ascending(); + } + if (!Schema.Table("PayDataReportRuns").Exists()) + { + Create.Table("PayDataReportRuns") + .WithColumn("PayDataReportRunId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ReportType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ReportingYear").AsInt32().NotNullable() + .WithColumn("SchemaProfileCode").AsString(50).Nullable() + .WithColumn("SchemaProfileHash").AsString(128).Nullable() + .WithColumn("SnapshotStart").AsDateTime2().NotNullable() + .WithColumn("SnapshotEnd").AsDateTime2().NotNullable() + .WithColumn("EmployerSnapshotJson").AsString(int.MaxValue).Nullable() + .WithColumn("SourceCutoff").AsDateTime2().Nullable() + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EmployeeCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RowCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ExceptionCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WarningCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ValidationSummaryJson").AsString(int.MaxValue).Nullable() + .WithColumn("RunRemarks").AsString(int.MaxValue).Nullable() + .WithColumn("SupersedesRunId").AsString(36).Nullable() + .WithColumn("ReviewedByUserId").AsString(128).Nullable() + .WithColumn("ReviewedOn").AsDateTime2().Nullable() + .WithColumn("FrozenByUserId").AsString(128).Nullable() + .WithColumn("FrozenOn").AsDateTime2().Nullable() + .WithColumn("ExportedByUserId").AsString(128).Nullable() + .WithColumn("ExportedOn").AsDateTime2().Nullable() + .WithColumn("CertifiedByUserId").AsString(128).Nullable() + .WithColumn("CertifiedOn").AsDateTime2().Nullable() + .WithColumn("CertificationReference").AsString(200).Nullable() + .WithColumn("CertifiedArtifactChecksum").AsString(128).Nullable() + .WithColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_PayDataReportRuns_Department").OnTable("PayDataReportRuns").OnColumn("DepartmentId").Ascending().OnColumn("ReportingYear").Ascending().OnColumn("ReportType").Ascending().OnColumn("IsDeleted").Ascending(); + } + if (!Schema.Table("PayDataReportEmployeeSnapshots").Exists()) + { + Create.Table("PayDataReportEmployeeSnapshots") + .WithColumn("PayDataReportEmployeeSnapshotId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("PayDataReportRunId").AsString(36).Nullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceWorkerId").AsString(36).Nullable() + .WithColumn("WorkforceEmploymentId").AsString(36).Nullable() + .WithColumn("WorkforceEstablishmentId").AsString(36).Nullable() + .WithColumn("WorkforceLaborContractorId").AsString(36).Nullable() + .WithColumn("JobCategoryCode").AsString(10).Nullable() + .WithColumn("DemographicCode").AsString(int.MaxValue).Nullable() + .WithColumn("PayBandCode").AsString(10).Nullable() + .WithColumn("ExemptionCode").AsString(10).Nullable() + .WithColumn("EmploymentTypeCode").AsString(10).Nullable() + .WithColumn("WorkMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AnnualEarnings").AsString(int.MaxValue).Nullable() + .WithColumn("EarningsSource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AnnualHours").AsDecimal(12,2).NotNullable().WithDefaultValue(0) + .WithColumn("AnnualWeeks").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("HourlyRate").AsString(int.MaxValue).Nullable() + .WithColumn("IsIncluded").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("ExceptionCodesCsv").AsString(500).Nullable() + .WithColumn("OverrideReason").AsString(500).Nullable() + .WithColumn("OverrideByUserId").AsString(128).Nullable() + .WithColumn("SourceVersions").AsString(int.MaxValue).Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("AddedByUserId").AsString(128).Nullable() + .WithColumn("EditedOn").AsDateTime2().Nullable() + .WithColumn("EditedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_PayDataReportEmployeeSnapshots_Run").OnTable("PayDataReportEmployeeSnapshots").OnColumn("PayDataReportRunId").Ascending(); + } + if (!Schema.Table("PayDataReportRows").Exists()) + { + Create.Table("PayDataReportRows") + .WithColumn("PayDataReportRowId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("PayDataReportRunId").AsString(36).Nullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("WorkforceEstablishmentId").AsString(36).Nullable() + .WithColumn("WorkforceLaborContractorId").AsString(36).Nullable() + .WithColumn("JobCategoryCode").AsString(10).Nullable() + .WithColumn("DemographicCode").AsString(int.MaxValue).Nullable() + .WithColumn("PayBandCode").AsString(10).Nullable() + .WithColumn("ExemptionCode").AsString(10).Nullable() + .WithColumn("EmploymentTypeCode").AsString(10).Nullable() + .WithColumn("EmployeeCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AnnualHours").AsDecimal(12,2).NotNullable().WithDefaultValue(0) + .WithColumn("AnnualWeeks").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("MeanHourlyRate").AsString(int.MaxValue).Nullable() + .WithColumn("MedianHourlyRate").AsString(int.MaxValue).Nullable() + .WithColumn("NonRemoteCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RemoteWithinCaliforniaCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RemoteOutsideCaliforniaCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("RowRemarks").AsString(int.MaxValue).Nullable() + .WithColumn("ContributingSnapshotIdsCsv").AsString(int.MaxValue).Nullable() + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("AddedOn").AsDateTime2().NotNullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_PayDataReportRows_Run").OnTable("PayDataReportRows").OnColumn("PayDataReportRunId").Ascending().OnColumn("SortOrder").Ascending(); + } + if (!Schema.Table("PayDataExportArtifacts").Exists()) + { + Create.Table("PayDataExportArtifacts") + .WithColumn("PayDataExportArtifactId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("PayDataReportRunId").AsString(36).Nullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SchemaProfileCode").AsString(50).Nullable() + .WithColumn("SchemaProfileHash").AsString(128).Nullable() + .WithColumn("Format").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("FileName").AsString(250).Nullable() + .WithColumn("Checksum").AsString(128).Nullable() + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("Size").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ExpiresOn").AsDateTime2().NotNullable() + .WithColumn("PurgedOn").AsDateTime2().Nullable() + .WithColumn("ExportedByUserId").AsString(128).Nullable() + .WithColumn("DownloadCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LastDownloadedOn").AsDateTime2().Nullable() + .WithColumn("LastDownloadedByUserId").AsString(128).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable(); + Create.Index("IX_PayDataExportArtifacts_Run").OnTable("PayDataExportArtifacts").OnColumn("PayDataReportRunId").Ascending(); + Create.Index("IX_PayDataExportArtifacts_Expiry").OnTable("PayDataExportArtifacts").OnColumn("ExpiresOn").Ascending().OnColumn("PurgedOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("PayDataExportArtifacts").Exists()) Delete.Table("PayDataExportArtifacts"); + if (Schema.Table("PayDataReportRows").Exists()) Delete.Table("PayDataReportRows"); + if (Schema.Table("PayDataReportEmployeeSnapshots").Exists()) Delete.Table("PayDataReportEmployeeSnapshots"); + if (Schema.Table("PayDataReportRuns").Exists()) Delete.Table("PayDataReportRuns"); + if (Schema.Table("PayDataReportingDemographics").Exists()) Delete.Table("PayDataReportingDemographics"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0224_SeedWorkforceFeaturesAndIndexes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0224_SeedWorkforceFeaturesAndIndexes.cs new file mode 100644 index 00000000..5f2dded6 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0224_SeedWorkforceFeaturesAndIndexes.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Workforce & Business Operations plan, Phase E (E2): seeds the two Phase E feature flags off with Business.Operations prerequisites — Workforce.InternalCosting and Compliance.CaliforniaPayDataReporting (the latter also fails closed without an Enabled Advanced Data Protection enrollment) — and the cross-table indexes. ADP catalog 28 is registered in code (WorkforceProtectedFields). Registry M0224. Guarded for safe retry. + /// + [Migration(224)] + public class M0224_SeedWorkforceFeaturesAndIndexes : Migration + { + public override void Up() + { + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Workforce.InternalCosting') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Workforce.InternalCosting', 'Workforce internal costing', 'Protected employee compensation profiles, resource cost profiles and internal field-cost / margin runs for bids, calls and deployments (Workforce & Business Operations plan, Phase E). Requires Business.Operations and an Advanced Data Protection enrollment. Seeded off.', 'Business', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlagPrerequisites] p JOIN [FeatureFlags] f ON f.[FeatureFlagId] = p.[FeatureFlagId] JOIN [FeatureFlags] r ON r.[FeatureFlagId] = p.[RequiredFeatureFlagId] WHERE f.[FlagKey] = 'Workforce.InternalCosting' AND r.[FlagKey] = 'Business.Operations') INSERT INTO [FeatureFlagPrerequisites] ([FeatureFlagId], [RequiredFeatureFlagId], [RequiredValue]) SELECT f.[FeatureFlagId], r.[FeatureFlagId], NULL FROM [FeatureFlags] f CROSS JOIN [FeatureFlags] r WHERE f.[FlagKey] = 'Workforce.InternalCosting' AND r.[FlagKey] = 'Business.Operations';"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Compliance.CaliforniaPayDataReporting') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Compliance.CaliforniaPayDataReporting', 'California pay data reporting', 'California CRD (Government Code 12999) Payroll Employee and Labor Contractor Employee report preparation and export (Workforce & Business Operations plan, Phase E). Requires Business.Operations and an Enabled Advanced Data Protection enrollment; the user files through the CRD portal. Seeded off.', 'Business', 0);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlagPrerequisites] p JOIN [FeatureFlags] f ON f.[FeatureFlagId] = p.[FeatureFlagId] JOIN [FeatureFlags] r ON r.[FeatureFlagId] = p.[RequiredFeatureFlagId] WHERE f.[FlagKey] = 'Compliance.CaliforniaPayDataReporting' AND r.[FlagKey] = 'Business.Operations') INSERT INTO [FeatureFlagPrerequisites] ([FeatureFlagId], [RequiredFeatureFlagId], [RequiredValue]) SELECT f.[FeatureFlagId], r.[FeatureFlagId], NULL FROM [FeatureFlags] f CROSS JOIN [FeatureFlags] r WHERE f.[FlagKey] = 'Compliance.CaliforniaPayDataReporting' AND r.[FlagKey] = 'Business.Operations';"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_WorkforceWorkEntries_Employment' AND object_id = OBJECT_ID('WorkforceWorkEntries')) CREATE INDEX [IX_WorkforceWorkEntries_Employment] ON [WorkforceWorkEntries] ([WorkforceEmploymentId], [WorkDate]);"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_WorkforceAnnualPayFacts_Version' AND object_id = OBJECT_ID('WorkforceAnnualPayFacts')) CREATE UNIQUE INDEX [UX_WorkforceAnnualPayFacts_Version] ON [WorkforceAnnualPayFacts] ([WorkforceEmploymentId], [ReportingYear], [ReportType], [ClientAllocationKey], [Version]) WHERE [IsDeleted] = 0;"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_PayDataReportingDemographics_Worker' AND object_id = OBJECT_ID('PayDataReportingDemographics')) CREATE UNIQUE INDEX [UX_PayDataReportingDemographics_Worker] ON [PayDataReportingDemographics] ([WorkforceWorkerId], [EffectiveOn]) WHERE [IsDeleted] = 0;"); + } + + public override void Down() + { + Execute.Sql("DELETE FROM [FeatureFlagPrerequisites] WHERE [FeatureFlagId] IN (SELECT [FeatureFlagId] FROM [FeatureFlags] WHERE [FlagKey] = 'Workforce.InternalCosting');"); + Execute.Sql("DELETE FROM [FeatureFlags] WHERE [FlagKey] = 'Workforce.InternalCosting';"); + Execute.Sql("DELETE FROM [FeatureFlagPrerequisites] WHERE [FeatureFlagId] IN (SELECT [FeatureFlagId] FROM [FeatureFlags] WHERE [FlagKey] = 'Compliance.CaliforniaPayDataReporting');"); + Execute.Sql("DELETE FROM [FeatureFlags] WHERE [FlagKey] = 'Compliance.CaliforniaPayDataReporting';"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0220_AddWorkforceEmploymentAndEstablishmentsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0220_AddWorkforceEmploymentAndEstablishmentsPg.cs new file mode 100644 index 00000000..ccaa4421 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0220_AddWorkforceEmploymentAndEstablishmentsPg.cs @@ -0,0 +1,209 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0220 (Workforce & Business Operations plan, Phase E). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(220)] + public class M0220_AddWorkforceEmploymentAndEstablishmentsPg : Migration + { + public override void Up() + { + if (!Schema.Table("workforceemployerprofiles").Exists()) + { + Create.Table("workforceemployerprofiles") + .WithColumn("workforceemployerprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("legalname").AsString(250).Nullable() + .WithColumn("fein").AsCustom("text").Nullable() + .WithColumn("sein").AsCustom("text").Nullable() + .WithColumn("sosnumber").AsCustom("text").Nullable() + .WithColumn("naics").AsString(6).Nullable() + .WithColumn("eddaddress").AsCustom("text").Nullable() + .WithColumn("headquartersaddress").AsCustom("text").Nullable() + .WithColumn("isintegratedenterprise").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("filingcontactname").AsCustom("text").Nullable() + .WithColumn("filingcontactemail").AsCustom("text").Nullable() + .WithColumn("filingcontactphone").AsCustom("text").Nullable() + .WithColumn("coveragestatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("usemployeecount").AsInt32().Nullable() + .WithColumn("californiaemployeecount").AsInt32().Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceemployerprofiles_department ON workforceemployerprofiles (departmentid, isdeleted);"); + } + if (!Schema.Table("workforceaffiliatedentities").Exists()) + { + Create.Table("workforceaffiliatedentities") + .WithColumn("workforceaffiliatedentityid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceemployerprofileid").AsString(36).Nullable() + .WithColumn("legalname").AsString(250).Nullable() + .WithColumn("fein").AsCustom("text").Nullable() + .WithColumn("sein").AsCustom("text").Nullable() + .WithColumn("sosnumber").AsCustom("text").Nullable() + .WithColumn("headquartersaddress").AsCustom("text").Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceaffiliatedentities_department ON workforceaffiliatedentities (departmentid, isdeleted);"); + } + if (!Schema.Table("workforceestablishments").Exists()) + { + Create.Table("workforceestablishments") + .WithColumn("workforceestablishmentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceaffiliatedentityid").AsString(36).Nullable() + .WithColumn("code").AsString(50).Nullable() + .WithColumn("name").AsString(250).Nullable() + .WithColumn("physicaladdress").AsCustom("text").Nullable() + .WithColumn("city").AsString(100).Nullable() + .WithColumn("statecode").AsString(2).Nullable() + .WithColumn("postalcode").AsString(10).Nullable() + .WithColumn("naics").AsString(6).Nullable() + .WithColumn("majoractivity").AsString(250).Nullable() + .WithColumn("isheadquarters").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("wasfiledprioryear").AsBoolean().Nullable() + .WithColumn("activefrom").AsDateTime2().Nullable() + .WithColumn("activeto").AsDateTime2().Nullable() + .WithColumn("timezoneid").AsString(100).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceestablishments_department ON workforceestablishments (departmentid, isdeleted);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_workforceestablishments_code ON workforceestablishments (departmentid, code) WHERE isdeleted = FALSE;"); + } + if (!Schema.Table("workforcelaborcontractors").Exists()) + { + Create.Table("workforcelaborcontractors") + .WithColumn("workforcelaborcontractorid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("legalname").AsString(250).Nullable() + .WithColumn("ownershipname").AsString(250).Nullable() + .WithColumn("dba").AsString(250).Nullable() + .WithColumn("fein").AsCustom("text").Nullable() + .WithColumn("identifiertype").AsString(20).Nullable() + .WithColumn("contactdetails").AsCustom("text").Nullable() + .WithColumn("relationshipstarton").AsDateTime2().Nullable() + .WithColumn("relationshipendon").AsDateTime2().Nullable() + .WithColumn("provenance").AsString(500).Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforcelaborcontractors_department ON workforcelaborcontractors (departmentid, isdeleted);"); + } + if (!Schema.Table("workforceworkers").Exists()) + { + Create.Table("workforceworkers") + .WithColumn("workforceworkerid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsString(128).Nullable() + .WithColumn("externalworkerkey").AsCustom("text").Nullable() + .WithColumn("displaylabel").AsCustom("text").Nullable() + .WithColumn("isactive").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceworkers_department ON workforceworkers (departmentid, isdeleted);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_workforceworkers_user ON workforceworkers (departmentid, userid) WHERE userid is not null and isdeleted = FALSE;"); + } + if (!Schema.Table("workforceemployments").Exists()) + { + Create.Table("workforceemployments") + .WithColumn("workforceemploymentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceworkerid").AsString(36).Nullable() + .WithColumn("workforceaffiliatedentityid").AsString(36).Nullable() + .WithColumn("workforcelaborcontractorid").AsString(36).Nullable() + .WithColumn("workerkind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("starton").AsDateTime2().NotNullable() + .WithColumn("endon").AsDateTime2().Nullable() + .WithColumn("employmenttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("exemptionstatus").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("defaultestablishmentid").AsString(36).Nullable() + .WithColumn("californiaemployeebasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("personnelroleid").AsInt32().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceemployments_worker ON workforceemployments (workforceworkerid, starton);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceemployments_department ON workforceemployments (departmentid, isdeleted);"); + } + if (!Schema.Table("workforcejobassignments").Exists()) + { + Create.Table("workforcejobassignments") + .WithColumn("workforcejobassignmentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceemploymentid").AsString(36).Nullable() + .WithColumn("effectiveon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("workforceestablishmentid").AsString(36).Nullable() + .WithColumn("jobtitle").AsString(250).Nullable() + .WithColumn("soccode").AsString(20).Nullable() + .WithColumn("socversion").AsString(20).Nullable() + .WithColumn("capaydataprofilecode").AsString(50).Nullable() + .WithColumn("jobcategorycode").AsString(10).Nullable() + .WithColumn("caloesmarsauthorityprofilecode").AsString(50).Nullable() + .WithColumn("caloesmarsclassificationcode").AsString(100).Nullable() + .WithColumn("mappingprovenance").AsString(500).Nullable() + .WithColumn("workmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("workcountry").AsString(2).Nullable() + .WithColumn("worksubdivision").AsString(10).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforcejobassignments_employment ON workforcejobassignments (workforceemploymentid, effectiveon);"); + } + } + + public override void Down() + { + if (Schema.Table("workforcejobassignments").Exists()) Delete.Table("workforcejobassignments"); + if (Schema.Table("workforceemployments").Exists()) Delete.Table("workforceemployments"); + if (Schema.Table("workforceworkers").Exists()) Delete.Table("workforceworkers"); + if (Schema.Table("workforcelaborcontractors").Exists()) Delete.Table("workforcelaborcontractors"); + if (Schema.Table("workforceestablishments").Exists()) Delete.Table("workforceestablishments"); + if (Schema.Table("workforceaffiliatedentities").Exists()) Delete.Table("workforceaffiliatedentities"); + if (Schema.Table("workforceemployerprofiles").Exists()) Delete.Table("workforceemployerprofiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0221_AddWorkforceCompensationAndAnnualFactsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0221_AddWorkforceCompensationAndAnnualFactsPg.cs new file mode 100644 index 00000000..f17d1eeb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0221_AddWorkforceCompensationAndAnnualFactsPg.cs @@ -0,0 +1,186 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0221 (Workforce & Business Operations plan, Phase E). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(221)] + public class M0221_AddWorkforceCompensationAndAnnualFactsPg : Migration + { + public override void Up() + { + if (!Schema.Table("employeecompensationprofiles").Exists()) + { + Create.Table("employeecompensationprofiles") + .WithColumn("employeecompensationprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("scope").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("workforceemploymentid").AsString(36).Nullable() + .WithColumn("personnelroleid").AsInt32().Nullable() + .WithColumn("effectiveon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("currency").AsString(3).Nullable() + .WithColumn("paybasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("baseamount").AsCustom("text").Nullable() + .WithColumn("regularhourlyequivalent").AsCustom("text").Nullable() + .WithColumn("standardhoursperday").AsDecimal(9,2).Nullable() + .WithColumn("standardhoursperweek").AsDecimal(9,2).Nullable() + .WithColumn("standardhoursperyear").AsDecimal(9,2).Nullable() + .WithColumn("ratemultipliersjson").AsCustom("text").Nullable() + .WithColumn("source").AsString(100).Nullable() + .WithColumn("importbatchid").AsString(36).Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("approvedbyuserid").AsString(128).Nullable() + .WithColumn("approvedon").AsDateTime2().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_employeecompensationprofiles_employment ON employeecompensationprofiles (workforceemploymentid, effectiveon);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_employeecompensationprofiles_department ON employeecompensationprofiles (departmentid, scope, isdeleted);"); + } + if (!Schema.Table("employeepaycomponents").Exists()) + { + Create.Table("employeepaycomponents") + .WithColumn("employeepaycomponentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("employeecompensationprofileid").AsString(36).Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(100).Nullable() + .WithColumn("basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("amount").AsCustom("text").Nullable() + .WithColumn("eligiblepaycodescsv").AsString(100).Nullable() + .WithColumn("paidforeachovertimehour").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sourceagreement").AsString(250).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_employeepaycomponents_profile ON employeepaycomponents (employeecompensationprofileid);"); + } + if (!Schema.Table("employeecostcomponents").Exists()) + { + Create.Table("employeecostcomponents") + .WithColumn("employeecostcomponentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("employeecompensationprofileid").AsString(36).Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("name").AsString(100).Nullable() + .WithColumn("basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rateamount").AsCustom("text").Nullable() + .WithColumn("eligiblepaycodescsv").AsString(100).Nullable() + .WithColumn("cap").AsCustom("text").Nullable() + .WithColumn("source").AsString(250).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_employeecostcomponents_profile ON employeecostcomponents (employeecompensationprofileid);"); + } + if (!Schema.Table("workforceworkentries").Exists()) + { + Create.Table("workforceworkentries") + .WithColumn("workforceworkentryid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceworkerid").AsString(36).Nullable() + .WithColumn("workforceemploymentid").AsString(36).Nullable() + .WithColumn("workdate").AsDateTime2().NotNullable() + .WithColumn("starttime").AsDateTime2().Nullable() + .WithColumn("endtime").AsDateTime2().Nullable() + .WithColumn("hours").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("hourstype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("workforceestablishmentid").AsString(36).Nullable() + .WithColumn("workcountry").AsString(2).Nullable() + .WithColumn("worksubdivision").AsString(10).Nullable() + .WithColumn("workmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("deploymentid").AsString(36).Nullable() + .WithColumn("deploymenttimereportid").AsString(36).Nullable() + .WithColumn("approvedpayrollcost").AsCustom("text").Nullable() + .WithColumn("externalsource").AsString(100).Nullable() + .WithColumn("externalid").AsString(200).Nullable() + .WithColumn("importbatchid").AsString(36).Nullable() + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isreconciled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceworkentries_worker ON workforceworkentries (workforceworkerid, workdate);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceworkentries_deployment ON workforceworkentries (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceworkentries_call ON workforceworkentries (callid);"); + } + if (!Schema.Table("workforceannualpayfacts").Exists()) + { + Create.Table("workforceannualpayfacts") + .WithColumn("workforceannualpayfactid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceemploymentid").AsString(36).Nullable() + .WithColumn("reportingyear").AsInt32().NotNullable() + .WithColumn("reporttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("clientallocationkey").AsString(100).Nullable() + .WithColumn("w2box5").AsCustom("text").Nullable() + .WithColumn("w2box1").AsCustom("text").Nullable() + .WithColumn("earningsused").AsCustom("text").Nullable() + .WithColumn("earningssource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("actualworkedhours").AsDecimal(12,2).Nullable() + .WithColumn("paidleavehours").AsDecimal(12,2).Nullable() + .WithColumn("reportablehours").AsDecimal(12,2).Nullable() + .WithColumn("daysworked").AsInt32().Nullable() + .WithColumn("weeksworked").AsDecimal(9,2).Nullable() + .WithColumn("exemptproxymethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("proxyaveragehoursperday").AsDecimal(9,2).Nullable() + .WithColumn("clientallocatedearnings").AsCustom("text").Nullable() + .WithColumn("clientallocatedhours").AsDecimal(12,2).Nullable() + .WithColumn("clientallocatedweeks").AsDecimal(9,2).Nullable() + .WithColumn("source").AsString(100).Nullable() + .WithColumn("importbatchid").AsString(36).Nullable() + .WithColumn("sourcechecksum").AsString(128).Nullable() + .WithColumn("isreconciled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("version").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("supersedesfactid").AsString(36).Nullable() + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceannualpayfacts_employment ON workforceannualpayfacts (workforceemploymentid, reportingyear);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceannualpayfacts_department ON workforceannualpayfacts (departmentid, reportingyear, reporttype);"); + } + } + + public override void Down() + { + if (Schema.Table("workforceannualpayfacts").Exists()) Delete.Table("workforceannualpayfacts"); + if (Schema.Table("workforceworkentries").Exists()) Delete.Table("workforceworkentries"); + if (Schema.Table("employeecostcomponents").Exists()) Delete.Table("employeecostcomponents"); + if (Schema.Table("employeepaycomponents").Exists()) Delete.Table("employeepaycomponents"); + if (Schema.Table("employeecompensationprofiles").Exists()) Delete.Table("employeecompensationprofiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0222_AddResourceAndFieldCostingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0222_AddResourceAndFieldCostingPg.cs new file mode 100644 index 00000000..e2ddba97 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0222_AddResourceAndFieldCostingPg.cs @@ -0,0 +1,202 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0222 (Workforce & Business Operations plan, Phase E). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(222)] + public class M0222_AddResourceAndFieldCostingPg : Migration + { + public override void Up() + { + if (!Schema.Table("resourcecostprofiles").Exists()) + { + Create.Table("resourcecostprofiles") + .WithColumn("resourcecostprofileid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("subjecttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("unitid").AsInt32().Nullable() + .WithColumn("inventoryassetid").AsString(36).Nullable() + .WithColumn("externalresourcekey").AsString(100).Nullable() + .WithColumn("name").AsString(250).Nullable() + .WithColumn("effectiveon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("currency").AsString(3).Nullable() + .WithColumn("acquisitioncost").AsDecimal(18,2).Nullable() + .WithColumn("acquisitiondate").AsDateTime2().Nullable() + .WithColumn("inservicedate").AsDateTime2().Nullable() + .WithColumn("salvagevalue").AsDecimal(18,2).Nullable() + .WithColumn("depreciationmethod").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("allocationbasis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("usefullifequantity").AsDecimal(18,2).Nullable() + .WithColumn("usefullifemonths").AsInt32().Nullable() + .WithColumn("expectedannualutilization").AsDecimal(18,2).Nullable() + .WithColumn("source").AsString(100).Nullable() + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("approvedbyuserid").AsString(128).Nullable() + .WithColumn("approvedon").AsDateTime2().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourcecostprofiles_department ON resourcecostprofiles (departmentid, isdeleted);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourcecostprofiles_unit ON resourcecostprofiles (unitid);"); + } + if (!Schema.Table("resourcecostcomponents").Exists()) + { + Create.Table("resourcecostcomponents") + .WithColumn("resourcecostcomponentid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("resourcecostprofileid").AsString(36).Nullable() + .WithColumn("effectiveon").AsDateTime2().Nullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("basis").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rate").AsDecimal(18,4).Nullable() + .WithColumn("consumptionquantity").AsDecimal(18,4).Nullable() + .WithColumn("consumptionunit").AsString(20).Nullable() + .WithColumn("unitprice").AsDecimal(18,4).Nullable() + .WithColumn("source").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("sourcewindowstart").AsDateTime2().Nullable() + .WithColumn("sourcewindowend").AsDateTime2().Nullable() + .WithColumn("sourcemeterstart").AsDecimal(18,2).Nullable() + .WithColumn("sourcemeterend").AsDecimal(18,2).Nullable() + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourcecostcomponents_profile ON resourcecostcomponents (resourcecostprofileid);"); + } + if (!Schema.Table("resourceusageentries").Exists()) + { + Create.Table("resourceusageentries") + .WithColumn("resourceusageentryid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("subjecttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("unitid").AsInt32().Nullable() + .WithColumn("inventoryassetid").AsString(36).Nullable() + .WithColumn("externalresourcekey").AsString(100).Nullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("deploymentid").AsString(36).Nullable() + .WithColumn("deploymenttimereportid").AsString(36).Nullable() + .WithColumn("usagedate").AsDateTime2().NotNullable() + .WithColumn("phase").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("startodometer").AsDecimal(18,2).Nullable() + .WithColumn("endodometer").AsDecimal(18,2).Nullable() + .WithColumn("distanceunit").AsString(5).Nullable() + .WithColumn("originaldistance").AsDecimal(18,2).Nullable() + .WithColumn("canonicaldistancemiles").AsDecimal(18,2).Nullable() + .WithColumn("startenginemeter").AsDecimal(18,2).Nullable() + .WithColumn("endenginemeter").AsDecimal(18,2).Nullable() + .WithColumn("enginehours").AsDecimal(18,2).Nullable() + .WithColumn("operatinghours").AsDecimal(18,2).Nullable() + .WithColumn("idlehours").AsDecimal(18,2).Nullable() + .WithColumn("deployeddays").AsDecimal(9,2).Nullable() + .WithColumn("standbydays").AsDecimal(9,2).Nullable() + .WithColumn("fuelquantity").AsDecimal(18,2).Nullable() + .WithColumn("fuelunit").AsString(10).Nullable() + .WithColumn("fuelactualcost").AsDecimal(18,2).Nullable() + .WithColumn("source").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("externalid").AsString(200).Nullable() + .WithColumn("isapproved").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("needsreview").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("reviewreason").AsString(500).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourceusageentries_deployment ON resourceusageentries (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourceusageentries_call ON resourceusageentries (callid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_resourceusageentries_unit ON resourceusageentries (unitid, usagedate);"); + } + if (!Schema.Table("fieldcostruns").Exists()) + { + Create.Table("fieldcostruns") + .WithColumn("fieldcostrunid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("contexttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("bidid").AsString(36).Nullable() + .WithColumn("callid").AsInt32().Nullable() + .WithColumn("deploymentid").AsString(36).Nullable() + .WithColumn("runtype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("throughdate").AsDateTime2().Nullable() + .WithColumn("currency").AsString(3).Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("inputversions").AsCustom("text").Nullable() + .WithColumn("revenuesource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("revenueamount").AsDecimal(18,2).Nullable() + .WithColumn("revenuesourceid").AsString(36).Nullable() + .WithColumn("revenuesourceversion").AsString(50).Nullable() + .WithColumn("personneltotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("resourcetotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("consumabletotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("expensetotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("overheadtotal").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("totalloadedcost").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("contributionmargin").AsDecimal(18,2).Nullable() + .WithColumn("contributionmarginpercent").AsDecimal(9,4).Nullable() + .WithColumn("breakevenrevenue").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("missinginputcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("supersedesrunid").AsString(36).Nullable() + .WithColumn("frozenbyuserid").AsString(128).Nullable() + .WithColumn("frozenon").AsDateTime2().Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_fieldcostruns_department ON fieldcostruns (departmentid, isdeleted, status);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_fieldcostruns_deployment ON fieldcostruns (deploymentid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_fieldcostruns_bid ON fieldcostruns (bidid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_fieldcostruns_call ON fieldcostruns (callid);"); + } + if (!Schema.Table("fieldcostlines").Exists()) + { + Create.Table("fieldcostlines") + .WithColumn("fieldcostlineid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("fieldcostrunid").AsString(36).Nullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("linedate").AsDateTime2().Nullable() + .WithColumn("category").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("subjecttype").AsString(30).Nullable() + .WithColumn("subjectid").AsString(128).Nullable() + .WithColumn("subjectlabel").AsString(250).Nullable() + .WithColumn("component").AsString(100).Nullable() + .WithColumn("quantity").AsDecimal(18,4).NotNullable().WithDefaultValue(0) + .WithColumn("unit").AsString(20).Nullable() + .WithColumn("rate").AsDecimal(18,4).Nullable() + .WithColumn("amount").AsDecimal(18,2).NotNullable().WithDefaultValue(0) + .WithColumn("protecteddetailjson").AsCustom("text").Nullable() + .WithColumn("sourcetype").AsString(50).Nullable() + .WithColumn("sourceid").AsString(128).Nullable() + .WithColumn("isestimated").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("isfallback").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("reviewreason").AsString(250).Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_fieldcostlines_run ON fieldcostlines (fieldcostrunid, sortorder);"); + } + } + + public override void Down() + { + if (Schema.Table("fieldcostlines").Exists()) Delete.Table("fieldcostlines"); + if (Schema.Table("fieldcostruns").Exists()) Delete.Table("fieldcostruns"); + if (Schema.Table("resourceusageentries").Exists()) Delete.Table("resourceusageentries"); + if (Schema.Table("resourcecostcomponents").Exists()) Delete.Table("resourcecostcomponents"); + if (Schema.Table("resourcecostprofiles").Exists()) Delete.Table("resourcecostprofiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0223_AddCaliforniaPayDataReportingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0223_AddCaliforniaPayDataReportingPg.cs new file mode 100644 index 00000000..f1787fb2 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0223_AddCaliforniaPayDataReportingPg.cs @@ -0,0 +1,181 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0223 (Workforce & Business Operations plan, Phase E). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(223)] + public class M0223_AddCaliforniaPayDataReportingPg : Migration + { + public override void Up() + { + if (!Schema.Table("paydatareportingdemographics").Exists()) + { + Create.Table("paydatareportingdemographics") + .WithColumn("paydatareportingdemographicid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceworkerid").AsString(36).Nullable() + .WithColumn("effectiveon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().Nullable() + .WithColumn("hispaniclatino").AsCustom("text").Nullable() + .WithColumn("raceethnicitycodes").AsCustom("text").Nullable() + .WithColumn("sexcode").AsCustom("text").Nullable() + .WithColumn("declinedraceethnicity").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("declinedsex").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("collectionsource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("collectedon").AsDateTime2().Nullable() + .WithColumn("collectedbyuserid").AsString(128).Nullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable() + .WithColumn("version").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydatareportingdemographics_worker ON paydatareportingdemographics (workforceworkerid, effectiveon);"); + } + if (!Schema.Table("paydatareportruns").Exists()) + { + Create.Table("paydatareportruns") + .WithColumn("paydatareportrunid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("reporttype").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("reportingyear").AsInt32().NotNullable() + .WithColumn("schemaprofilecode").AsString(50).Nullable() + .WithColumn("schemaprofilehash").AsString(128).Nullable() + .WithColumn("snapshotstart").AsDateTime2().NotNullable() + .WithColumn("snapshotend").AsDateTime2().NotNullable() + .WithColumn("employersnapshotjson").AsCustom("text").Nullable() + .WithColumn("sourcecutoff").AsDateTime2().Nullable() + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("employeecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rowcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("exceptioncount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("warningcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("validationsummaryjson").AsCustom("text").Nullable() + .WithColumn("runremarks").AsCustom("text").Nullable() + .WithColumn("supersedesrunid").AsString(36).Nullable() + .WithColumn("reviewedbyuserid").AsString(128).Nullable() + .WithColumn("reviewedon").AsDateTime2().Nullable() + .WithColumn("frozenbyuserid").AsString(128).Nullable() + .WithColumn("frozenon").AsDateTime2().Nullable() + .WithColumn("exportedbyuserid").AsString(128).Nullable() + .WithColumn("exportedon").AsDateTime2().Nullable() + .WithColumn("certifiedbyuserid").AsString(128).Nullable() + .WithColumn("certifiedon").AsDateTime2().Nullable() + .WithColumn("certificationreference").AsString(200).Nullable() + .WithColumn("certifiedartifactchecksum").AsString(128).Nullable() + .WithColumn("rowversion").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydatareportruns_department ON paydatareportruns (departmentid, reportingyear, reporttype, isdeleted);"); + } + if (!Schema.Table("paydatareportemployeesnapshots").Exists()) + { + Create.Table("paydatareportemployeesnapshots") + .WithColumn("paydatareportemployeesnapshotid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("paydatareportrunid").AsString(36).Nullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceworkerid").AsString(36).Nullable() + .WithColumn("workforceemploymentid").AsString(36).Nullable() + .WithColumn("workforceestablishmentid").AsString(36).Nullable() + .WithColumn("workforcelaborcontractorid").AsString(36).Nullable() + .WithColumn("jobcategorycode").AsString(10).Nullable() + .WithColumn("demographiccode").AsCustom("text").Nullable() + .WithColumn("paybandcode").AsString(10).Nullable() + .WithColumn("exemptioncode").AsString(10).Nullable() + .WithColumn("employmenttypecode").AsString(10).Nullable() + .WithColumn("workmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("annualearnings").AsCustom("text").Nullable() + .WithColumn("earningssource").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("annualhours").AsDecimal(12,2).NotNullable().WithDefaultValue(0) + .WithColumn("annualweeks").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("hourlyrate").AsCustom("text").Nullable() + .WithColumn("isincluded").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("exceptioncodescsv").AsString(500).Nullable() + .WithColumn("overridereason").AsString(500).Nullable() + .WithColumn("overridebyuserid").AsString(128).Nullable() + .WithColumn("sourceversions").AsCustom("text").Nullable() + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("addedbyuserid").AsString(128).Nullable() + .WithColumn("editedon").AsDateTime2().Nullable() + .WithColumn("editedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydatareportemployeesnapshots_run ON paydatareportemployeesnapshots (paydatareportrunid);"); + } + if (!Schema.Table("paydatareportrows").Exists()) + { + Create.Table("paydatareportrows") + .WithColumn("paydatareportrowid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("paydatareportrunid").AsString(36).Nullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("workforceestablishmentid").AsString(36).Nullable() + .WithColumn("workforcelaborcontractorid").AsString(36).Nullable() + .WithColumn("jobcategorycode").AsString(10).Nullable() + .WithColumn("demographiccode").AsCustom("text").Nullable() + .WithColumn("paybandcode").AsString(10).Nullable() + .WithColumn("exemptioncode").AsString(10).Nullable() + .WithColumn("employmenttypecode").AsString(10).Nullable() + .WithColumn("employeecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("annualhours").AsDecimal(12,2).NotNullable().WithDefaultValue(0) + .WithColumn("annualweeks").AsDecimal(9,2).NotNullable().WithDefaultValue(0) + .WithColumn("meanhourlyrate").AsCustom("text").Nullable() + .WithColumn("medianhourlyrate").AsCustom("text").Nullable() + .WithColumn("nonremotecount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("remotewithincaliforniacount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("remoteoutsidecaliforniacount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("rowremarks").AsCustom("text").Nullable() + .WithColumn("contributingsnapshotidscsv").AsCustom("text").Nullable() + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("addedon").AsDateTime2().NotNullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydatareportrows_run ON paydatareportrows (paydatareportrunid, sortorder);"); + } + if (!Schema.Table("paydataexportartifacts").Exists()) + { + Create.Table("paydataexportartifacts") + .WithColumn("paydataexportartifactid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("paydatareportrunid").AsString(36).Nullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("schemaprofilecode").AsString(50).Nullable() + .WithColumn("schemaprofilehash").AsString(128).Nullable() + .WithColumn("format").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("filename").AsString(250).Nullable() + .WithColumn("checksum").AsString(128).Nullable() + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("size").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("expireson").AsDateTime2().NotNullable() + .WithColumn("purgedon").AsDateTime2().Nullable() + .WithColumn("exportedbyuserid").AsString(128).Nullable() + .WithColumn("downloadcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("lastdownloadedon").AsDateTime2().Nullable() + .WithColumn("lastdownloadedbyuserid").AsString(128).Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable(); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydataexportartifacts_run ON paydataexportartifacts (paydatareportrunid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_paydataexportartifacts_expiry ON paydataexportartifacts (expireson, purgedon);"); + } + } + + public override void Down() + { + if (Schema.Table("paydataexportartifacts").Exists()) Delete.Table("paydataexportartifacts"); + if (Schema.Table("paydatareportrows").Exists()) Delete.Table("paydatareportrows"); + if (Schema.Table("paydatareportemployeesnapshots").Exists()) Delete.Table("paydatareportemployeesnapshots"); + if (Schema.Table("paydatareportruns").Exists()) Delete.Table("paydatareportruns"); + if (Schema.Table("paydatareportingdemographics").Exists()) Delete.Table("paydatareportingdemographics"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0224_SeedWorkforceFeaturesAndIndexesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0224_SeedWorkforceFeaturesAndIndexesPg.cs new file mode 100644 index 00000000..dfc6e82d --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0224_SeedWorkforceFeaturesAndIndexesPg.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of M0224 (Workforce & Business Operations plan, Phase E). Same number, lower-case identifiers, guarded for safe retry. + /// + [Migration(224)] + public class M0224_SeedWorkforceFeaturesAndIndexesPg : Migration + { + public override void Up() + { + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Workforce.InternalCosting', 'Workforce internal costing', 'Protected employee compensation profiles, resource cost profiles and internal field-cost / margin runs for bids, calls and deployments (Workforce & Business Operations plan, Phase E). Requires Business.Operations and an Advanced Data Protection enrollment. Seeded off.', 'Business', FALSE WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Workforce.InternalCosting');"); + Execute.Sql("INSERT INTO featureflagprerequisites (featureflagid, requiredfeatureflagid, requiredvalue) SELECT f.featureflagid, r.featureflagid, NULL FROM featureflags f CROSS JOIN featureflags r WHERE f.flagkey = 'Workforce.InternalCosting' AND r.flagkey = 'Business.Operations' AND NOT EXISTS (SELECT 1 FROM featureflagprerequisites p WHERE p.featureflagid = f.featureflagid AND p.requiredfeatureflagid = r.featureflagid);"); + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Compliance.CaliforniaPayDataReporting', 'California pay data reporting', 'California CRD (Government Code 12999) Payroll Employee and Labor Contractor Employee report preparation and export (Workforce & Business Operations plan, Phase E). Requires Business.Operations and an Enabled Advanced Data Protection enrollment; the user files through the CRD portal. Seeded off.', 'Business', FALSE WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Compliance.CaliforniaPayDataReporting');"); + Execute.Sql("INSERT INTO featureflagprerequisites (featureflagid, requiredfeatureflagid, requiredvalue) SELECT f.featureflagid, r.featureflagid, NULL FROM featureflags f CROSS JOIN featureflags r WHERE f.flagkey = 'Compliance.CaliforniaPayDataReporting' AND r.flagkey = 'Business.Operations' AND NOT EXISTS (SELECT 1 FROM featureflagprerequisites p WHERE p.featureflagid = f.featureflagid AND p.requiredfeatureflagid = r.featureflagid);"); + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_workforceworkentries_employment ON workforceworkentries (workforceemploymentid, workdate);"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_workforceannualpayfacts_version ON workforceannualpayfacts (workforceemploymentid, reportingyear, reporttype, COALESCE(clientallocationkey, ''), version) WHERE isdeleted = FALSE;"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_paydatareportingdemographics_worker ON paydatareportingdemographics (workforceworkerid, effectiveon) WHERE isdeleted = FALSE;"); + } + + public override void Down() + { + Execute.Sql("DELETE FROM featureflagprerequisites WHERE featureflagid IN (SELECT featureflagid FROM featureflags WHERE flagkey = 'Workforce.InternalCosting');"); + Execute.Sql("DELETE FROM featureflags WHERE flagkey = 'Workforce.InternalCosting';"); + Execute.Sql("DELETE FROM featureflagprerequisites WHERE featureflagid IN (SELECT featureflagid FROM featureflags WHERE flagkey = 'Compliance.CaliforniaPayDataReporting');"); + Execute.Sql("DELETE FROM featureflags WHERE flagkey = 'Compliance.CaliforniaPayDataReporting';"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs new file mode 100644 index 00000000..d0726ea9 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.CostRecovery.CalOesMars; +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-M3 (C3): Cal OES MARS shadow-table repositories (registry M0219). + + public class CalOesMarsAgencyProfileRepository : RmsRepositoryBase, ICalOesMarsAgencyProfileRepository + { + public CalOesMarsAgencyProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByDepartmentAsync(int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsAgencyProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")}", new { DepartmentId = departmentId }); + } + + public class CalOesMarsResourceProfileRepository : RmsRepositoryBase, ICalOesMarsResourceProfileRepository + { + public CalOesMarsResourceProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string resourceProfileId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsResourceProfiles")} WHERE {Col("CalOesMarsResourceProfileId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = resourceProfileId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsResourceProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("UnitDesignator")}, {Col("ExternalResourceName")}", new { DepartmentId = departmentId }); + + public Task> GetByUnitIdsAsync(int departmentId, IEnumerable unitIds) + { + var ids = InListValue(unitIds); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("CalOesMarsResourceProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} AND {InList("UnitId", "Ids")}", new { DepartmentId = departmentId, Ids = ids }); + } + } + + public class CalOesMarsRateProfileRepository : RmsRepositoryBase, ICalOesMarsRateProfileRepository + { + public CalOesMarsRateProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string rateProfileId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsRateProfiles")} WHERE {Col("CalOesMarsRateProfileId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = rateProfileId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId, int? submissionYear = null) => + QueryAsync( + $"SELECT * FROM {Tbl("CalOesMarsRateProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")}" + (submissionYear.HasValue ? $" AND {Col("SubmissionYear")} = {P}Year" : string.Empty) + + $" ORDER BY {Col("SubmissionYear")} DESC, {Col("SubmissionType")}, {Col("EffectiveOn")} DESC", new { DepartmentId = departmentId, Year = submissionYear }); + + public Task> GetEffectiveAsync(int departmentId, DateTime asOf) => + QueryAsync( + $"SELECT * FROM {Tbl("CalOesMarsRateProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} AND {Col("Status")} <> {P}Superseded " + + $"AND ({Col("EffectiveOn")} IS NULL OR {Col("EffectiveOn")} <= {P}AsOf) AND ({Col("ExpiresOn")} IS NULL OR {Col("ExpiresOn")} >= {P}AsOf) ORDER BY {Col("SubmissionType")}, {Col("EffectiveOn")} DESC", + new { DepartmentId = departmentId, AsOf = DatabaseTimestamp(asOf.Date), Superseded = (int)CalOesMarsRateProfileStatuses.Superseded }); + } + + public class CalOesMarsRateLineRepository : RmsRepositoryBase, ICalOesMarsRateLineRepository + { + public CalOesMarsRateLineRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string rateLineId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsRateLines")} WHERE {Col("CalOesMarsRateLineId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = rateLineId, DepartmentId = departmentId }); + + public Task> GetByProfileAsync(string rateProfileId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsRateLines")} WHERE {Col("CalOesMarsRateProfileId")} = {P}Id AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("SortOrder")}, {Col("LineKind")}, {Col("ClassificationCode")}, {Col("ResourceCode")}", new { Id = rateProfileId }); + + public Task> GetByProfilesAsync(IEnumerable rateProfileIds) + { + var ids = InListValue(rateProfileIds); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("CalOesMarsRateLines")} WHERE {InList("CalOesMarsRateProfileId", "Ids")} AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("SortOrder")}", new { Ids = ids }); + } + } + + public class CalOesMarsAdministrativeRateInputRepository : RmsRepositoryBase, ICalOesMarsAdministrativeRateInputRepository + { + public CalOesMarsAdministrativeRateInputRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string inputId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsAdministrativeRateInputs")} WHERE {Col("CalOesMarsAdministrativeRateInputId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = inputId, DepartmentId = departmentId }); + + public Task> GetByProfileAsync(string rateProfileId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsAdministrativeRateInputs")} WHERE {Col("CalOesMarsRateProfileId")} = {P}Id AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("FiscalYear")}, {Col("FunctionCode")}, {Col("CategoryCode")}", new { Id = rateProfileId }); + } + + public class CalOesMarsAgreementSnapshotRepository : RmsRepositoryBase, ICalOesMarsAgreementSnapshotRepository + { + public CalOesMarsAgreementSnapshotRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string agreementSnapshotId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsAgreementSnapshots")} WHERE {Col("CalOesMarsAgreementSnapshotId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = agreementSnapshotId, DepartmentId = departmentId }); + + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsAgreementSnapshots")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} ORDER BY {Col("ClassificationCode")}, {Col("StartOn")} DESC", new { DepartmentId = departmentId }); + } + + public class CalOesMarsWorkItemRepository : RmsRepositoryBase, ICalOesMarsWorkItemRepository + { + public CalOesMarsWorkItemRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + private string False => IsPostgres ? "FALSE" : "0"; + + public Task GetByIdForDepartmentAsync(string workItemId, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("CalOesMarsWorkItemId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = workItemId, DepartmentId = departmentId }); + + public Task> GetByDeploymentAsync(string deploymentId, int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DeploymentId")} = {P}DeploymentId AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} ORDER BY {Col("RecordType")}, {Col("AddedOn")}", new { DeploymentId = deploymentId, DepartmentId = departmentId }); + + public Task> GetByExternalIdAsync(int departmentId, string marsRecordId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND ({Col("MarsRecordId")} = {P}ExternalId OR {Col("MarsInvoiceId")} = {P}ExternalId) AND {Col("IsDeleted")} = {False}", new { DepartmentId = departmentId, ExternalId = marsRecordId }); + + public Task> GetActionQueueAsync(int departmentId, int? recordType = null) => + QueryAsync( + $"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {Col("LocalState")} <> {P}Closed" + (recordType.HasValue ? $" AND {Col("RecordType")} = {P}RecordType" : string.Empty) + + $" ORDER BY {Col("LocalState")}, {Col("AddedOn")}", new { DepartmentId = departmentId, Closed = (int)CalOesMarsLocalStates.Closed, RecordType = recordType }); + + public Task> GetUnreconciledAsync(int departmentId) => + QueryAsync( + $"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {Col("LocalState")} IN ({P}Submitted, {P}Returned, {P}Approved, {P}PendingLocal, {P}PendingPaying, {P}Rejected) ORDER BY {Col("AddedOn")}", + new + { + DepartmentId = departmentId, Submitted = (int)CalOesMarsLocalStates.SubmittedExternal, Returned = (int)CalOesMarsLocalStates.ReturnedForAgencyReview, Approved = (int)CalOesMarsLocalStates.Approved, + PendingLocal = (int)CalOesMarsLocalStates.PendingLocalAgencyApproval, PendingPaying = (int)CalOesMarsLocalStates.PendingPayingEntityApproval, Rejected = (int)CalOesMarsLocalStates.LocalAgencyRejected + }); + + public Task> GetDepartmentsWithOpenItemsAsync() => + QueryAsync($"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("IsDeleted")} = {False} AND {Col("LocalState")} <> {P}Closed", new { Closed = (int)CalOesMarsLocalStates.Closed }); + } + + public class CalOesMarsReimbursementLineRepository : RmsRepositoryBase, ICalOesMarsReimbursementLineRepository + { + public CalOesMarsReimbursementLineRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByWorkItemAsync(string workItemId) => + QueryAsync($"SELECT * FROM {Tbl("CalOesMarsReimbursementLines")} WHERE {Col("CalOesMarsWorkItemId")} = {P}Id ORDER BY {Col("SortOrder")}", new { Id = workItemId }); + + public async Task DeleteByWorkItemAsync(string workItemId, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("CalOesMarsReimbursementLines")} WHERE {Col("CalOesMarsWorkItemId")} = {P}Id", new { Id = workItemId }, cancellationToken); + return true; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs index b2178417..725737d2 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs @@ -159,8 +159,9 @@ public Task> GetForDepartmentAsync(int departmentId, int? statu 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> GetByContactIdAsync(int departmentId, string contactId, int skip, int take) => + QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("BidNumber")} DESC {Paging()}", + new { DepartmentId = departmentId, ContactId = contactId, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 500) }); 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 }); diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs index 1a3412cb..25e0cae3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs @@ -41,6 +41,11 @@ public Task> GetByIdsAsync(int departmentId, IEnumerable public Task CountForDepartmentAsync(int departmentId, bool openOnly) => ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Deployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (openOnly ? $" AND {OpenStatuses}" : string.Empty), new { DepartmentId = departmentId }); + + public Task> GetByContractAsync(int departmentId, string serviceContractId) => + QueryAsync( + $"SELECT * FROM {Tbl("Deployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ServiceContractId")} = {P}ContractId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")} DESC {Paging()}", + new { DepartmentId = departmentId, ContractId = serviceContractId, Skip = 0, Take = 500 }); } public class DeploymentUnitRepository : RmsRepositoryBase, IDeploymentUnitRepository diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs index c365dc62..22d8ee00 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs @@ -156,6 +156,11 @@ private string FilterSql(InvoiceListFilter filter, DynamicParameters parameters) sql.Append($" AND {Col("ContactId")} = {P}ContactId"); parameters.Add("ContactId", filter.ContactId); } + if (!string.IsNullOrWhiteSpace(filter.ServiceContractId)) + { + sql.Append($" AND {Col("ServiceContractId")} = {P}ServiceContractId"); + parameters.Add("ServiceContractId", filter.ServiceContractId); + } if (filter.IssuedFromUtc.HasValue) { sql.Append($" AND {Col("IssuedOn")} >= {P}IssuedFrom"); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index aa26b6f4..a817a1f5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -53,6 +53,38 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M3: Cal OES MARS shadow tables (registry M0219). + 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(); + // Workforce & Business Operations plan, Phase E: protected workforce pay data, costing and CRD reporting (registry M0220–M0223). + 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().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().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 0e45dfc9..f108f71f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -267,6 +267,38 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M3: Cal OES MARS shadow tables (registry M0219). + 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(); + // Workforce & Business Operations plan, Phase E: protected workforce pay data, costing and CRD reporting (registry M0220–M0223). + 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().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().InstancePerLifetimeScope(); // Indoor Maps Repositories diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index 0a62c954..c0609b05 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -53,6 +53,38 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M3: Cal OES MARS shadow tables (registry M0219). + 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(); + // Workforce & Business Operations plan, Phase E: protected workforce pay data, costing and CRD reporting (registry M0220–M0223). + 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().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().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 221004f0..5b9ad613 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -53,6 +53,38 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Workforce & Business Operations plan, Phase C-M3: Cal OES MARS shadow tables (registry M0219). + 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(); + // Workforce & Business Operations plan, Phase E: protected workforce pay data, costing and CRD reporting (registry M0220–M0223). + 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().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().InstancePerLifetimeScope(); builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkforceRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkforceRepositories.cs new file mode 100644 index 00000000..2931a175 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkforceRepositories.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Workforce; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + // Workforce & Business Operations plan, Phase E (E2/E3): repositories for M0220–M0223 (dual-dialect Dapper). + + internal static class WorkforceSql + { + public static string False => Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres ? "FALSE" : "0"; + public static string True => Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres ? "TRUE" : "1"; + } + + public class WorkforceEmployerProfileRepository : RmsRepositoryBase, IWorkforceEmployerProfileRepository + { + public WorkforceEmployerProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetActiveForDepartmentAsync(int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceEmployerProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("IsActive")} = {WorkforceSql.True} ORDER BY {Col("RowVersion")} DESC", new { DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceEmployerProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")} DESC", new { DepartmentId = departmentId }); + public Task> GetDepartmentsWithActiveProfilesAsync() => + QueryAsync($"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("WorkforceEmployerProfiles")} WHERE {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("IsActive")} = {WorkforceSql.True}", new { }); + } + public class WorkforceAffiliatedEntityRepository : RmsRepositoryBase, IWorkforceAffiliatedEntityRepository + { + public WorkforceAffiliatedEntityRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceAffiliatedEntities")} WHERE {Col("WorkforceAffiliatedEntityId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceAffiliatedEntities")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("LegalName")}", new { DepartmentId = departmentId }); + } + public class WorkforceEstablishmentRepository : RmsRepositoryBase, IWorkforceEstablishmentRepository + { + public WorkforceEstablishmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceEstablishments")} WHERE {Col("WorkforceEstablishmentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceEstablishments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Code")}", new { DepartmentId = departmentId }); + } + public class WorkforceLaborContractorRepository : RmsRepositoryBase, IWorkforceLaborContractorRepository + { + public WorkforceLaborContractorRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceLaborContractors")} WHERE {Col("WorkforceLaborContractorId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceLaborContractors")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("LegalName")}", new { DepartmentId = departmentId }); + } + public class WorkforceWorkerRepository : RmsRepositoryBase, IWorkforceWorkerRepository + { + public WorkforceWorkerRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceWorkers")} WHERE {Col("WorkforceWorkerId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task GetByUserIdAsync(int departmentId, string userId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceWorkers")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("UserId")} = {P}UserId AND {Col("IsDeleted")} = {WorkforceSql.False}", new { DepartmentId = departmentId, UserId = userId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceWorkers")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")}", new { DepartmentId = departmentId }); + } + public class WorkforceEmploymentRepository : RmsRepositoryBase, IWorkforceEmploymentRepository + { + public WorkforceEmploymentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceEmployments")} WHERE {Col("WorkforceEmploymentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByWorkerAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceEmployments")} WHERE {Col("WorkforceWorkerId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("StartOn")}", new { Id = id }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceEmployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("StartOn")}", new { DepartmentId = departmentId }); + public Task> GetActiveInWindowAsync(int departmentId, DateTime from, DateTime to) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceEmployments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("StartOn")} <= {P}To AND ({Col("EndOn")} IS NULL OR {Col("EndOn")} >= {P}From) ORDER BY {Col("StartOn")}", + new { DepartmentId = departmentId, From = DatabaseTimestamp(from), To = DatabaseTimestamp(to) }); + } + public class WorkforceJobAssignmentRepository : RmsRepositoryBase, IWorkforceJobAssignmentRepository + { + public WorkforceJobAssignmentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceJobAssignments")} WHERE {Col("WorkforceJobAssignmentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByEmploymentAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceJobAssignments")} WHERE {Col("WorkforceEmploymentId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("EffectiveOn")}", new { Id = id }); + public Task> GetByEmploymentsAsync(IEnumerable ids) + { + var list = InListValue(ids); + if (list.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("WorkforceJobAssignments")} WHERE {InList("WorkforceEmploymentId", "Ids")} AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("EffectiveOn")}", new { Ids = list }); + } + } + public class EmployeeCompensationProfileRepository : RmsRepositoryBase, IEmployeeCompensationProfileRepository + { + public EmployeeCompensationProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("EmployeeCompensationProfiles")} WHERE {Col("EmployeeCompensationProfileId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByEmploymentAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("EmployeeCompensationProfiles")} WHERE {Col("WorkforceEmploymentId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("EffectiveOn")} DESC", new { Id = id }); + public Task> GetDefaultsForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("EmployeeCompensationProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("Scope")} <> {P}Employee ORDER BY {Col("Scope")}, {Col("EffectiveOn")} DESC", new { DepartmentId = departmentId, Employee = (int)CompensationScopes.Employee }); + public Task> GetByEmploymentsAsync(IEnumerable ids) + { + var list = InListValue(ids); + if (list.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("EmployeeCompensationProfiles")} WHERE {InList("WorkforceEmploymentId", "Ids")} AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("EffectiveOn")} DESC", new { Ids = list }); + } + } + public class EmployeePayComponentRepository : RmsRepositoryBase, IEmployeePayComponentRepository + { + public EmployeePayComponentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByProfileAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("EmployeePayComponents")} WHERE {Col("EmployeeCompensationProfileId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Id = id }); + public Task> GetByProfilesAsync(IEnumerable ids) + { + var list = InListValue(ids); + if (list.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("EmployeePayComponents")} WHERE {InList("EmployeeCompensationProfileId", "Ids")} AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Ids = list }); + } + } + public class EmployeeCostComponentRepository : RmsRepositoryBase, IEmployeeCostComponentRepository + { + public EmployeeCostComponentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByProfileAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("EmployeeCostComponents")} WHERE {Col("EmployeeCompensationProfileId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Id = id }); + public Task> GetByProfilesAsync(IEnumerable ids) + { + var list = InListValue(ids); + if (list.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("EmployeeCostComponents")} WHERE {InList("EmployeeCompensationProfileId", "Ids")} AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Ids = list }); + } + } + public class WorkforceWorkEntryRepository : RmsRepositoryBase, IWorkforceWorkEntryRepository + { + public WorkforceWorkEntryRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("WorkforceWorkEntryId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByWorkerAsync(string workerId, DateTime from, DateTime to) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("WorkforceWorkerId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("WorkDate")} >= {P}From AND {Col("WorkDate")} <= {P}To ORDER BY {Col("WorkDate")}", new { Id = workerId, From = DatabaseTimestamp(from), To = DatabaseTimestamp(to) }); + public Task> GetByDeploymentAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("WorkDate")}", new { Id = id }); + public Task> GetByCallAsync(int callId) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("CallId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("WorkDate")}", new { Id = callId }); + public Task> GetForDepartmentInWindowAsync(int departmentId, DateTime from, DateTime to) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("WorkDate")} >= {P}From AND {Col("WorkDate")} <= {P}To ORDER BY {Col("WorkDate")}", new { DepartmentId = departmentId, From = DatabaseTimestamp(from), To = DatabaseTimestamp(to) }); + public Task GetByExternalIdAsync(int departmentId, string externalSource, string externalId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceWorkEntries")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ExternalSource")} = {P}Source AND {Col("ExternalId")} = {P}ExternalId AND {Col("IsDeleted")} = {WorkforceSql.False}", new { DepartmentId = departmentId, Source = externalSource, ExternalId = externalId }); + } + public class WorkforceAnnualPayFactRepository : RmsRepositoryBase, IWorkforceAnnualPayFactRepository + { + public WorkforceAnnualPayFactRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("WorkforceAnnualPayFacts")} WHERE {Col("WorkforceAnnualPayFactId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForYearAsync(int departmentId, int reportingYear, int reportType) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceAnnualPayFacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ReportingYear")} = {P}Year AND {Col("ReportType")} = {P}Type AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("WorkforceEmploymentId")}, {Col("Version")} DESC", new { DepartmentId = departmentId, Year = reportingYear, Type = reportType }); + public Task> GetByEmploymentAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("WorkforceAnnualPayFacts")} WHERE {Col("WorkforceEmploymentId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("ReportingYear")} DESC, {Col("Version")} DESC", new { Id = id }); + } + public class ResourceCostProfileRepository : RmsRepositoryBase, IResourceCostProfileRepository + { + public ResourceCostProfileRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("ResourceCostProfiles")} WHERE {Col("ResourceCostProfileId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("ResourceCostProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Name")}, {Col("EffectiveOn")} DESC", new { DepartmentId = departmentId }); + public Task> GetByUnitIdsAsync(int departmentId, IEnumerable unitIds) + { + var ids = InListValue(unitIds); + if (ids.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("ResourceCostProfiles")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {InList("UnitId", "Ids")} ORDER BY {Col("EffectiveOn")} DESC", new { DepartmentId = departmentId, Ids = ids }); + } + } + public class ResourceCostComponentRepository : RmsRepositoryBase, IResourceCostComponentRepository + { + public ResourceCostComponentRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByProfileAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("ResourceCostComponents")} WHERE {Col("ResourceCostProfileId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Id = id }); + public Task> GetByProfilesAsync(IEnumerable ids) + { + var list = InListValue(ids); + if (list.Length == 0) return Task.FromResult(Enumerable.Empty()); + return QueryAsync($"SELECT * FROM {Tbl("ResourceCostComponents")} WHERE {InList("ResourceCostProfileId", "Ids")} AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("Category")}", new { Ids = list }); + } + } + public class ResourceUsageEntryRepository : RmsRepositoryBase, IResourceUsageEntryRepository + { + public ResourceUsageEntryRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("ResourceUsageEntries")} WHERE {Col("ResourceUsageEntryId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByDeploymentAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("ResourceUsageEntries")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("UsageDate")}", new { Id = id }); + public Task> GetByCallAsync(int callId) => + QueryAsync($"SELECT * FROM {Tbl("ResourceUsageEntries")} WHERE {Col("CallId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("UsageDate")}", new { Id = callId }); + public Task> GetByUnitAsync(int departmentId, int unitId, DateTime from, DateTime to) => + QueryAsync($"SELECT * FROM {Tbl("ResourceUsageEntries")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("UnitId")} = {P}UnitId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("UsageDate")} >= {P}From AND {Col("UsageDate")} <= {P}To ORDER BY {Col("UsageDate")}", new { DepartmentId = departmentId, UnitId = unitId, From = DatabaseTimestamp(from), To = DatabaseTimestamp(to) }); + } + public class FieldCostRunRepository : RmsRepositoryBase, IFieldCostRunRepository + { + public FieldCostRunRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("FieldCostRuns")} WHERE {Col("FieldCostRunId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByDeploymentAsync(string deploymentId, int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("FieldCostRuns")} WHERE {Col("DeploymentId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")} DESC", new { Id = deploymentId, DepartmentId = departmentId }); + public Task> GetByBidAsync(string bidId, int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("FieldCostRuns")} WHERE {Col("BidId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")} DESC", new { Id = bidId, DepartmentId = departmentId }); + public Task> GetByCallAsync(int callId, int departmentId) => + QueryAsync($"SELECT * FROM {Tbl("FieldCostRuns")} WHERE {Col("CallId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")} DESC", new { Id = callId, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId, int skip, int take) => + QueryAsync($"SELECT * FROM {Tbl("FieldCostRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} ORDER BY {Col("AddedOn")} DESC {Paging()}", new { DepartmentId = departmentId, Skip = skip, Take = take }); + } + public class FieldCostLineRepository : RmsRepositoryBase, IFieldCostLineRepository + { + public FieldCostLineRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByRunAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("FieldCostLines")} WHERE {Col("FieldCostRunId")} = {P}Id ORDER BY {Col("SortOrder")}", new { Id = id }); + public async Task DeleteByRunAsync(string id, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("FieldCostLines")} WHERE {Col("FieldCostRunId")} = {P}Id", new { Id = id }, cancellationToken); + return true; + } + } + public class PayDataReportingDemographicRepository : RmsRepositoryBase, IPayDataReportingDemographicRepository + { + public PayDataReportingDemographicRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetCurrentForWorkerAsync(string workerId, DateTime asOf) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("PayDataReportingDemographics")} WHERE {Col("WorkforceWorkerId")} = {P}Id AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("EffectiveOn")} <= {P}AsOf AND ({Col("ExpiresOn")} IS NULL OR {Col("ExpiresOn")} >= {P}AsOf) ORDER BY {Col("EffectiveOn")} DESC, {Col("Version")} DESC", new { Id = workerId, AsOf = DatabaseTimestamp(asOf) }); + public Task> GetCurrentForDepartmentAsync(int departmentId, DateTime asOf) => + QueryAsync($"SELECT * FROM {Tbl("PayDataReportingDemographics")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False} AND {Col("EffectiveOn")} <= {P}AsOf AND ({Col("ExpiresOn")} IS NULL OR {Col("ExpiresOn")} >= {P}AsOf) ORDER BY {Col("WorkforceWorkerId")}, {Col("EffectiveOn")} DESC", new { DepartmentId = departmentId, AsOf = DatabaseTimestamp(asOf) }); + } + public class PayDataReportRunRepository : RmsRepositoryBase, IPayDataReportRunRepository + { + public PayDataReportRunRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("PayDataReportRuns")} WHERE {Col("PayDataReportRunId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetForDepartmentAsync(int departmentId, int? reportingYear = null) => + QueryAsync($"SELECT * FROM {Tbl("PayDataReportRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {WorkforceSql.False}" + (reportingYear.HasValue ? $" AND {Col("ReportingYear")} = {P}Year" : string.Empty) + $" ORDER BY {Col("ReportingYear")} DESC, {Col("AddedOn")} DESC", new { DepartmentId = departmentId, Year = reportingYear }); + public Task> GetDepartmentsWithRunsAsync(int reportingYear) => + QueryAsync($"SELECT DISTINCT {Col("DepartmentId")} FROM {Tbl("PayDataReportRuns")} WHERE {Col("ReportingYear")} = {P}Year AND {Col("IsDeleted")} = {WorkforceSql.False}", new { Year = reportingYear }); + } + public class PayDataReportEmployeeSnapshotRepository : RmsRepositoryBase, IPayDataReportEmployeeSnapshotRepository + { + public PayDataReportEmployeeSnapshotRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByRunAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("PayDataReportEmployeeSnapshots")} WHERE {Col("PayDataReportRunId")} = {P}Id ORDER BY {Col("WorkforceWorkerId")}", new { Id = id }); + public async Task DeleteByRunAsync(string id, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("PayDataReportEmployeeSnapshots")} WHERE {Col("PayDataReportRunId")} = {P}Id", new { Id = id }, cancellationToken); + return true; + } + } + public class PayDataReportRowRepository : RmsRepositoryBase, IPayDataReportRowRepository + { + public PayDataReportRowRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetByRunAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("PayDataReportRows")} WHERE {Col("PayDataReportRunId")} = {P}Id ORDER BY {Col("SortOrder")}", new { Id = id }); + public async Task DeleteByRunAsync(string id, CancellationToken cancellationToken = default) + { + await ExecuteAsync($"DELETE FROM {Tbl("PayDataReportRows")} WHERE {Col("PayDataReportRunId")} = {P}Id", new { Id = id }, cancellationToken); + return true; + } + } + public class PayDataExportArtifactRepository : RmsRepositoryBase, IPayDataExportArtifactRepository + { + public PayDataExportArtifactRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(string id, int departmentId) => + QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("PayDataExportArtifacts")} WHERE {Col("PayDataExportArtifactId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = id, DepartmentId = departmentId }); + public Task> GetByRunAsync(string id) => + QueryAsync($"SELECT * FROM {Tbl("PayDataExportArtifacts")} WHERE {Col("PayDataReportRunId")} = {P}Id ORDER BY {Col("CreatedOn")} DESC", new { Id = id }); + public Task> GetExpiredUnpurgedAsync(DateTime asOf) => + QueryAsync($"SELECT * FROM {Tbl("PayDataExportArtifacts")} WHERE {Col("PurgedOn")} IS NULL AND {Col("ExpiresOn")} < {P}AsOf", new { AsOf = DatabaseTimestamp(asOf) }); + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index 82951ba5..f663c4c4 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -57,6 +57,12 @@ public void Permission_types_50_to_67_are_the_registry_names() ((int)PermissionTypes.ManageDeployments).Should().Be(118); ((int)PermissionTypes.ApproveTimeReports).Should().Be(119); ((int)PermissionTypes.ManageMutualAidReimbursement).Should().Be(79); + // Workforce & Business Operations Phase E took 74-78 on 2026-09-19 (registry). + ((int)PermissionTypes.ViewInternalCosts).Should().Be(74); + ((int)PermissionTypes.ManageWorkforceCompensation).Should().Be(75); + ((int)PermissionTypes.ViewWorkforceCompensation).Should().Be(76); + ((int)PermissionTypes.ManagePayDataReporting).Should().Be(77); + ((int)PermissionTypes.ExportPayDataReporting).Should().Be(78); } [Test] diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs new file mode 100644 index 00000000..dca7ceb8 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.CostRecovery.CalOesMars; +using Resgrid.Services.CostRecovery; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase C-M3: the pure CFAA expected-reimbursement calculator (plan C4, + /// decision 37; C11 acceptance 6) and the pure F-42 checklist / administrative-rate worksheet. + /// + [TestFixture] + public class CalOesMarsCalculatorTests + { + private static readonly DateTime Dispatch = new DateTime(2026, 8, 1, 6, 0, 0, DateTimeKind.Utc); + + private static List Rates() => new List + { + new CalOesMarsRateLine { CalOesMarsRateLineId = "sal-capt", LineKind = (int)CalOesMarsRateLineKinds.SalarySurvey, ClassificationCode = "Captain", StraightRate = 60, OvertimeRate = 90, OvertimeEligible = true, PortalToPortalEligible = true, RowVersion = 2 }, + new CalOesMarsRateLine { CalOesMarsRateLineId = "sal-ff", LineKind = (int)CalOesMarsRateLineKinds.SalarySurvey, ClassificationCode = "Firefighter", StraightRate = 40, OvertimeEligible = true, PortalToPortalEligible = true }, + new CalOesMarsRateLine { CalOesMarsRateLineId = "app-t3", LineKind = (int)CalOesMarsRateLineKinds.OfficialApparatus, ResourceCode = "Type 3 Engine", Basis = (int)CalOesMarsRateBases.Hourly, StraightRate = 85 }, + new CalOesMarsRateLine { CalOesMarsRateLineId = "sup", LineKind = (int)CalOesMarsRateLineKinds.OfficialSupportVehicle, Basis = (int)CalOesMarsRateBases.Daily, StraightRate = 150 }, + new CalOesMarsRateLine { CalOesMarsRateLineId = "pov", LineKind = (int)CalOesMarsRateLineKinds.PrivatelyOwnedVehicle, Basis = (int)CalOesMarsRateBases.PerMile, StraightRate = 0.67m }, + new CalOesMarsRateLine { CalOesMarsRateLineId = "fema", LineKind = (int)CalOesMarsRateLineKinds.SpecialEquipment, FemaCode = "8720", Basis = (int)CalOesMarsRateBases.Hourly, StraightRate = 22 } + }; + + private static CalOesMarsF42Snapshot Engine() => new CalOesMarsF42Snapshot + { + DispatchedOn = Dispatch, CommittedOn = Dispatch, ReturnedOn = Dispatch.AddHours(48), + Vehicles = + { + new CalOesMarsF42Vehicle { Kind = "Apparatus", Designator = "E-31", ResourceCode = "Type 3 Engine", CommittedHours = 48, CommittedDays = 2 }, + new CalOesMarsF42Vehicle { Kind = "Support", Designator = "U-1", CommittedHours = 48, CommittedDays = 2 }, + new CalOesMarsF42Vehicle { Kind = "POV", Designator = "POV Smith", StartOdometer = 1000, EndOdometer = 1120 }, + new CalOesMarsF42Vehicle { Kind = "Equipment", Designator = "Pump", FemaCode = "8720", CommittedHours = 10 } + }, + Personnel = + { + new CalOesMarsF42Person { DeploymentPersonnelId = "p1", Name = "A. Captain", ClassificationCode = "Captain", CommittedOn = Dispatch, ReleasedOn = Dispatch.AddHours(48), CommittedHours = 48, ActualHours = { new CalOesMarsDailyHours { Date = Dispatch.Date, Hours = 14 }, new CalOesMarsDailyHours { Date = Dispatch.Date.AddDays(1), Hours = 10 } } }, + new CalOesMarsF42Person { DeploymentPersonnelId = "p2", Name = "B. Firefighter", ClassificationCode = "Firefighter", CommittedOn = Dispatch, ReleasedOn = Dispatch.AddHours(48), CommittedHours = 48, ActualHours = { new CalOesMarsDailyHours { Date = Dispatch.Date, Hours = 8 } } }, + new CalOesMarsF42Person { DeploymentPersonnelId = "p3", Name = "C. Unknown", ClassificationCode = "Chaplain", CommittedHours = 48 } + } + }; + + [Test] + public void Actual_hours_agreement_pays_dtr_hours_with_overtime_after_eight_per_day() + { + var result = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput + { + F42 = Engine(), RateLines = Rates(), RateProfileVersion = "rp:1", AdministrativeRatePercent = 10, + Agreement = new CalOesMarsAgreementSnapshot { CompensationMethod = (int)CalOesMarsCompensationMethods.ActualHours, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterEightHoursPerDay } + }); + + // Captain: day 1 = 8 straight + 6 OT, day 2 = 8 straight + 2 OT → 16 × 60 + 8 × 90. + var captain = result.Lines.Where(l => l.SubjectId == "p1").ToList(); + captain.Should().HaveCount(2); + captain[0].Quantity.Should().Be(16); captain[0].Rate.Should().Be(60); captain[0].ExpectedAmount.Should().Be(960); + captain[1].Quantity.Should().Be(8); captain[1].Rate.Should().Be(90); captain[1].ExpectedAmount.Should().Be(720); + captain[0].RateLineId.Should().Be("sal-capt"); captain[0].RateLineVersion.Should().Be(2); captain[0].SourceVersions.Should().Be("rp:1"); + // Firefighter without an overtime rate: OT at 1.5× straight; 8 hours only → no OT line. + result.Lines.Where(l => l.SubjectId == "p2").Should().ContainSingle().Which.ExpectedAmount.Should().Be(320); + // Unknown classification: an Excluded line, never a silent zero. + var unknown = result.Lines.Single(l => l.SubjectId == "p3"); + unknown.EligibilityState.Should().Be((int)CalOesMarsEligibilityStates.Excluded); + unknown.EligibilityReason.Should().Be(CalOesMarsExceptionCodes.NoSalaryRate); + result.Exceptions.Should().Contain(e => e.Code == CalOesMarsExceptionCodes.NoSalaryRate && e.Detail.Contains("Chaplain")); + + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.Apparatus).ExpectedAmount.Should().Be(48 * 85); + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.SupportVehicle).Should().Match(l => l.Quantity == 2 && l.Unit == "day" && l.ExpectedAmount == 300); + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.PovMileage).Should().Match(l => l.Quantity == 120 && l.ExpectedAmount == 80.40m); + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.SpecialEquipment).ExpectedAmount.Should().Be(220); + // Administrative: 10 % of the eligible personnel total (960 + 720 + 320 = 2000). + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.Administrative).ExpectedAmount.Should().Be(200); + result.ExpectedTotal.Should().Be(960 + 720 + 320 + 4080 + 300 + 80.40m + 220 + 200); + } + + [Test] + public void Portal_to_portal_pays_every_committed_hour_and_flags_missing_rates_and_agreement() + { + var f42 = Engine(); + f42.Vehicles.RemoveAll(v => v.Kind != "Apparatus"); + f42.Vehicles[0].ResourceCode = "Type 1 Engine"; + f42.Personnel.RemoveAll(p => p.DeploymentPersonnelId != "p1"); + var result = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput + { + F42 = f42, RateLines = Rates(), + Agreement = new CalOesMarsAgreementSnapshot { CompensationMethod = (int)CalOesMarsCompensationMethods.PortalToPortal, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterTwelveHoursPerDay } + }); + // 48 committed hours over 2 days: 24 straight (12/day) + 24 overtime. + var captain = result.Lines.Where(l => l.SubjectId == "p1").ToList(); + captain[0].Quantity.Should().Be(24); captain[1].Quantity.Should().Be(24); + result.Lines.Single(l => l.LineKind == (int)CalOesMarsLineKinds.Apparatus).EligibilityState.Should().Be((int)CalOesMarsEligibilityStates.Excluded, "no Type 1 rate line"); + result.Exceptions.Select(e => e.Code).Should().Contain(new[] { CalOesMarsExceptionCodes.NoApparatusRate, CalOesMarsExceptionCodes.NoAdministrativeRate }); + + var noAgreement = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput { F42 = Engine(), RateLines = Rates() }); + noAgreement.Exceptions.Should().Contain(e => e.Code == CalOesMarsExceptionCodes.NoAgreement); + noAgreement.Lines.Where(l => l.SubjectId == "p1").Should().ContainSingle("without an agreement every DTR hour is straight time"); + } + + [Test] + public void Expenses_are_evidence_gated_and_never_carry_internal_cost() + { + var result = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput + { + Expenses = new CalOesMarsExpenseClaimSnapshot + { + Lines = + { + new CalOesMarsExpenseLine { DeploymentExpenseId = "e1", Date = Dispatch.Date, Category = "Meal", Amount = 18.5m, ReceiptAttachmentId = 1 }, + new CalOesMarsExpenseLine { DeploymentExpenseId = "e2", Date = Dispatch.Date, Category = "Lodging", Amount = 140, ReceiptAttachmentId = 2, PreApproved = false }, + new CalOesMarsExpenseLine { DeploymentExpenseId = "e3", Date = Dispatch.Date, Category = "Miscellaneous", Amount = 30 }, + new CalOesMarsExpenseLine { DeploymentExpenseId = "e4", Date = Dispatch.Date, Category = "Rental", Amount = 500, ReceiptAttachmentId = 3, PreApproved = true } + } + }, + AdministrativeRatePercent = 10 + }); + result.Lines.Single(l => l.SourceExpenseId == "e1").EligibilityState.Should().Be((int)CalOesMarsEligibilityStates.Eligible, "meals need no pre-approval"); + result.Lines.Single(l => l.SourceExpenseId == "e2").EligibilityState.Should().Be((int)CalOesMarsEligibilityStates.Uncertain, "lodging without pre-approval may need ICS-213 evidence"); + result.Lines.Single(l => l.SourceExpenseId == "e3").EligibilityState.Should().Be((int)CalOesMarsEligibilityStates.Excluded, "no receipt"); + result.Lines.Single(l => l.SourceExpenseId == "e4").LineKind.Should().Be((int)CalOesMarsLineKinds.Rental); + result.Lines.Should().NotContain(l => l.LineKind == (int)CalOesMarsLineKinds.Administrative, "the administrative rate applies to personnel, not expenses"); + result.ExpectedTotal.Should().Be(518.5m); + result.UncertainTotal.Should().Be(140); + } + + [Test] + public void F42_checklist_blocks_bad_prefix_missing_signatures_duplicates_and_undocumented_rotations() + { + var authority = CalOesMarsAuthorityProfile.Current; + var s = new CalOesMarsF42Snapshot + { + IncidentNumber = "CA-LNU-001234", OrderNumber = "O-1", RequestNumber = "X-12", ResourceType = "Type 3 Engine", DispatchedOn = Dispatch, ReleasedOn = Dispatch.AddHours(30), + Vehicles = { new CalOesMarsF42Vehicle { Kind = "Apparatus", Designator = "E-31", LicensePlate = "ABC123" }, new CalOesMarsF42Vehicle { Kind = "Support", Designator = "E-31 (dup)", LicensePlate = "abc123" } }, + Personnel = { new CalOesMarsF42Person { Name = "A", CommittedOn = Dispatch.AddHours(-5) } }, + Rotations = { new CalOesMarsF42Rotation { On = Dispatch.Date.AddDays(1) } } + }; + var result = new CalOesMarsValidationResult(); + CalOesMarsService.ValidateF42(result, s, authority, Array.Empty()); + result.Errors.Select(e => e.Code).Should().Contain(new[] + { + CalOesMarsValidationCodes.RequestPrefixInvalid, CalOesMarsValidationCodes.DuplicateVehicle, CalOesMarsValidationCodes.RotationUndocumented, + CalOesMarsValidationCodes.RespondingSignatureMissing, CalOesMarsValidationCodes.IncidentAuthorizationMissing, CalOesMarsValidationCodes.PaperFallbackMissing + }); + result.Warnings.Select(w => w.Code).Should().Contain(new[] { CalOesMarsValidationCodes.ReleaseIsNotReturn, CalOesMarsValidationCodes.PersonnelIntervalOutside }); + result.Errors.Single(e => e.Code == CalOesMarsValidationCodes.RequestPrefixInvalid).Box.Should().Be("request"); + result.IsReadyForPortal.Should().BeFalse(); + + CalOesMarsService.IsValidRequestNumber("E-12", authority).Should().BeTrue(); + CalOesMarsService.IsValidRequestNumber("O 3", authority).Should().BeTrue(); + CalOesMarsService.IsValidRequestNumber("c-5.1", authority).Should().BeTrue(); + CalOesMarsService.IsValidRequestNumber("X-12", authority).Should().BeFalse(); + CalOesMarsService.IsValidRequestNumber("E", authority).Should().BeFalse(); + CalOesMarsService.IsValidRequestNumber("E-abc", authority).Should().BeFalse(); + + // A return before the dispatch is an error; documentation-only softens the authorization to a warning. + s.RequestNumber = "E-12"; s.ReturnedOn = Dispatch.AddHours(-1); s.DocumentationOnly = true; s.Vehicles.RemoveAt(1); s.Rotations.Clear(); s.RespondingSignerName = "Chief"; + var second = new CalOesMarsValidationResult(); + CalOesMarsService.ValidateF42(second, s, authority, new[] { new Resgrid.Model.Invoicing.DeploymentAttachment { DeploymentAttachmentId = 5, AttachmentType = (int)Resgrid.Model.Invoicing.DeploymentAttachmentTypes.PaperF42 } }); + second.Errors.Select(e => e.Code).Should().BeEquivalentTo(new[] { CalOesMarsValidationCodes.ReturnBeforeDispatch }); + second.Warnings.Select(w => w.Code).Should().Contain(new[] { CalOesMarsValidationCodes.IncidentAuthorizationMissing, CalOesMarsValidationCodes.DocumentationOnly }); + } + + [Test] + public void Administrative_rate_worksheet_excludes_unallowable_and_incident_direct_and_compares_with_de_minimis() + { + var profile = new CalOesMarsRateProfile + { + CalOesMarsRateProfileId = "rp-admin", AdministrativeInputs = + { + new CalOesMarsAdministrativeRateInput { FiscalYear = 2025, Classification = (int)CalOesMarsCostClassifications.Direct, ActualAmount = "1000000", ReviewStatus = (int)CalOesMarsInputReviewStatuses.Accepted }, + new CalOesMarsAdministrativeRateInput { FiscalYear = 2025, Classification = (int)CalOesMarsCostClassifications.Indirect, ActualAmount = "150000", ReviewStatus = (int)CalOesMarsInputReviewStatuses.Accepted }, + new CalOesMarsAdministrativeRateInput { FiscalYear = 2025, Classification = (int)CalOesMarsCostClassifications.Unallowable, ActualAmount = "40000", ReviewStatus = (int)CalOesMarsInputReviewStatuses.Accepted }, + new CalOesMarsAdministrativeRateInput { FiscalYear = 2025, Classification = (int)CalOesMarsCostClassifications.Direct, ActualAmount = "25000", IncidentDirectExclusion = true, ReviewStatus = (int)CalOesMarsInputReviewStatuses.Accepted }, + new CalOesMarsAdministrativeRateInput { FiscalYear = 2025, Classification = (int)CalOesMarsCostClassifications.Indirect, ActualAmount = "9000", DoubleCountMarker = true, ReviewStatus = (int)CalOesMarsInputReviewStatuses.Pending } + } + }; + var blocked = CalOesMarsService.BuildAdministrativeRateDraft(profile, CalOesMarsAuthorityProfile.Current); + blocked.IsReady.Should().BeFalse(); + blocked.Blockers.Should().Contain("double_count_unresolved"); + + profile.AdministrativeInputs.Last().ReviewStatus = (int)CalOesMarsInputReviewStatuses.Excluded; + var draft = CalOesMarsService.BuildAdministrativeRateDraft(profile, CalOesMarsAuthorityProfile.Current); + draft.IsReady.Should().BeTrue(); + draft.AllowableDirect.Should().Be(1_000_000); + draft.AllowableIndirect.Should().Be(150_000); + draft.ExcludedUnallowable.Should().Be(40_000); + draft.ExcludedIncidentDirect.Should().Be(25_000); + draft.CalculatedPercent.Should().Be(15m); + draft.DeMinimisPercent.Should().Be(10m); + draft.MethodChosen.Should().Be((int)CalOesMarsAdministrativeRateMethods.Calculated); + draft.ChosenPercent.Should().Be(15m); + + var empty = CalOesMarsService.BuildAdministrativeRateDraft(new CalOesMarsRateProfile { CalOesMarsRateProfileId = "rp-empty" }, CalOesMarsAuthorityProfile.Current); + empty.IsReady.Should().BeFalse(); + empty.MethodChosen.Should().Be((int)CalOesMarsAdministrativeRateMethods.DeMinimis, "with no actuals the de-minimis option is the only choice"); + } + + [Test] + public void Authority_profile_maps_external_statuses_and_pins_its_sources() + { + var profile = CalOesMarsAuthorityProfile.Current; + profile.Code.Should().Be("CFAA-2026-08-21"); + profile.IsReviewed.Should().BeTrue(); + profile.MapRecordStatus("agency review").Should().Be(CalOesMarsLocalStates.ReturnedForAgencyReview); + profile.MapRecordStatus("Documentation Only").Should().Be(CalOesMarsLocalStates.DocumentationOnly); + profile.MapRecordStatus("Rejected").Should().BeNull("unknown vocabulary never guesses a local state"); + profile.MapInvoiceStatus("Pending Paying Entity Approval").Should().Be(CalOesMarsLocalStates.PendingPayingEntityApproval); + profile.F42Boxes.Select(b => b.Id).Should().Contain(new[] { "request", "responding-signature", "incident-signature", "attachments" }); + profile.RequestPrefixes.Should().BeEquivalentTo(new[] { "E", "O", "C", "S", "A" }); + CalOesMarsAuthorityProfile.ForDispatch(new DateTime(2019, 1, 1)).Should().BeNull("nothing covers a dispatch before the profile's effective date"); + CalOesMarsAuthorityProfile.ForDispatch(Dispatch).Should().BeSameAs(profile); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsLocalizationTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsLocalizationTests.cs new file mode 100644 index 00000000..66c8ac96 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalOesMarsLocalizationTests.cs @@ -0,0 +1,136 @@ +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 Resgrid.Model.CostRecovery.CalOesMars; +using File = System.IO.File; + +namespace Resgrid.Tests.Services +{ + /// + /// Twin of for Cal OES MARS (Workforce & Business Operations + /// plan, Phase C-M3): every resource key a view, the controller, the service's readiness / error codes, the + /// enum-derived labels, the checklist codes, the Security page row and the nav reference must exist, and every + /// supported culture must carry a complete, non-English translation. + /// + [TestFixture] + public class CalOesMarsLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + + [Test] + public void Mars_views_controller_service_and_enum_labels_resolve_real_resource_keys() + { + var root = RepositoryRoot(); + var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User"); + var own = Directory.GetFiles(Path.Combine(web, "Views", "CalOesMars"), "*.cshtml") + .Concat(new[] + { + Path.Combine(web, "Controllers", "CalOesMarsController.cs"), + Path.Combine(root, "Core", "Resgrid.Services", "CostRecovery", "CalOesMarsService.cs"), Path.Combine(root, "Core", "Resgrid.Services", "CostRecovery", "CalOesMarsService.WorkItems.cs"), + Path.Combine(web, "Views", "Shared", "_CalOesMarsShell.cshtml"), Path.Combine(web, "Views", "Shared", "_CalOesMarsMessage.cshtml") + }).ToList(); + var shared = new[] { Path.Combine(web, "Views", "Security", "Index.cshtml"), Path.Combine(web, "Views", "Shared", "_Navigation.cshtml") }; + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in own.Concat(shared)) + { + var source = File.ReadAllText(file); + var pattern = own.Contains(file) + ? @"(?)|Item\(""[^""]+"", CalOesMarsReadinessSeverities\.\w+, ""([A-Za-z]+)""" + : @"calOesLocalizer\[""([^""]+)""\]"; + foreach (Match match in Regex.Matches(source, pattern)) + keys.Add(match.Groups.Cast().Skip(1).First(g => g.Success).Value); + } + + // Keys the views and the service build by concatenation. + foreach (var s in Enum.GetNames()) keys.Add("State" + s); + foreach (var s in Enum.GetNames()) keys.Add("SubmissionType" + s); + foreach (var s in Enum.GetNames()) { keys.Add("RateStatus" + s); keys.Add("SetRateStatus" + s); } + foreach (var s in Enum.GetNames()) keys.Add("AdminMethod" + s); + foreach (var s in Enum.GetNames().Concat(Enum.GetNames())) keys.Add("LineKind" + s); + foreach (var s in Enum.GetNames()) keys.Add("Basis" + s); + foreach (var s in Enum.GetNames()) keys.Add("Authority" + s); + foreach (var s in Enum.GetNames()) keys.Add("CostClass" + s); + foreach (var s in Enum.GetNames()) keys.Add("ReviewStatus" + s); + foreach (var s in Enum.GetNames()) keys.Add("SubjectType" + s); + foreach (var s in Enum.GetNames()) keys.Add("Ownership" + s); + foreach (var s in Enum.GetNames()) keys.Add("ReviewState" + s); + foreach (var s in Enum.GetNames()) keys.Add("DocumentKind" + s); + foreach (var s in Enum.GetNames()) keys.Add("CompensationMethod" + s); + foreach (var s in Enum.GetNames()) keys.Add("OvertimeMethod" + s); + foreach (var s in Enum.GetNames()) keys.Add("RecordType" + s); + foreach (var s in Enum.GetNames()) keys.Add("Eligibility" + s); + foreach (var s in new[] { "Apparatus", "Support", "POV", "Equipment" }) keys.Add("Kind" + s); + foreach (var s in new[] { "Meal", "Lodging", "Miscellaneous", "Rental" }) keys.Add("Category" + s); + foreach (var s in new[] { "no_inputs", "double_count_unresolved", "inputs_pending_review", "no_direct_base" }) keys.Add("AdminBlocker_" + s); + foreach (var box in CalOesMarsAuthorityProfile.Current.F42Boxes) keys.Add("F42Box_" + box.Id); + foreach (var box in new[] { "lines", "signature" }) keys.Add("F42Box_" + box); + foreach (var code in typeof(CalOesMarsValidationCodes).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue())) keys.Add("Validation_" + code); + foreach (var key in new[] { "ReadinessRateExpiring", "ReadinessRateUnsigned", "ReadinessAgreementExpiring" }) keys.Add(key); + keys.Add(PermissionTypes.ManageMutualAidReimbursement.ToString()); + keys.Add(PermissionTypes.ManageMutualAidReimbursement + "Note"); + + var resources = Read(Path.Combine(ResourceDirectory(), "CalOesMars.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[] { "AdminMethodDeMinimis", "AuthorityCalOesRateLetter", "Basis", "Blocker", "CalOesMars", "Detail", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "Name", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "Status", "SubmissionTypeRateLetter", "Uei", "Version" }, + ["es"] = new[] { "AdminMethodDeMinimis", "CalOesMars", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "No", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "Uei" }, + ["fr"] = new[] { "AdminMethodDeMinimis", "BoxIncident", "BoxPersonnel", "F42Box_incident", "F42Box_personnel", "F42Box_rotation", "F42Box_signature", "CalOesMars", "Classification", "CostClassDirect", "CostClassIndirect", "Date", "Description", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "LineKindPersonnel", "Miles", "OwnershipCalFire", "OwnershipCalOes", "Provenance", "RecordTypeF42", "Source", "StrikeTeam", "Uei", "Version" }, + ["it"] = new[] { "AdminMethodDeMinimis", "CalOesMars", "Checklist", "Checksum", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "LineKindSalarySurvey", "No", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "RecordTypeSalarySurvey", "StrikeTeam", "SubmissionTypeSalarySurvey", "Uei" }, + ["pl"] = new[] { "AdminMethodDeMinimis", "AuthorityCalOesRateLetter", "CalOesMars", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "LineKindSalarySurvey", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "RecordTypeSalarySurvey", "Status", "StrikeTeam", "SubmissionTypeRateLetter", "SubmissionTypeSalarySurvey", "Uei" }, + ["sv"] = new[] { "AdminMethodDeMinimis", "AuthorityCalOesRateLetter", "BasisPerMile", "F42Box_order", "F42Box_rotation", "CalOesMars", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "LineKindSalarySurvey", "Miles", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "RecordTypeSalarySurvey", "StartOn", "Status", "StrikeTeam", "SubmissionTypeRateLetter", "SubmissionTypeSalarySurvey", "Uei", "Version" }, + ["ar"] = new[] { "CalOesMars", "Fein", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "Uei" }, + ["el"] = new[] { "AdminMethodDeMinimis", "CalOesMars", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "StrikeTeam", "Uei" }, + ["uk"] = new[] { "AdminMethodDeMinimis", "AuthorityCalOesRateLetter", "CalOesMars", "DocumentKindGbr", "DocumentKindMoa", "DocumentKindMou", "Fein", "LineKindSalarySurvey", "OwnershipCalFire", "OwnershipCalOes", "RecordTypeF42", "RecordTypeSalarySurvey", "StrikeTeam", "SubmissionTypeRateLetter", "SubmissionTypeSalarySurvey", "Uei" }, + }; + + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "CalOesMars.resx")); + var file = Path.Combine(ResourceDirectory(), "CalOesMars." + 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.CalOesMars.CalOesMars", 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", "CalOesMars"); + + 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/CalOesMarsServiceTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs new file mode 100644 index 00000000..afec681b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs @@ -0,0 +1,425 @@ +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 NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.CostRecovery.CalOesMars; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.CostRecovery; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase C-M3 (Cal OES MARS): readiness, F-42 / expense projection from + /// the deployment, order, fill, roster, DTR and expense facts, the checklist, expected reimbursement, the no-store + /// handoff, the observation state machine (submission, return with a new revision, approval), redispatch, + /// MARS-invoice reconciliation that never touches a Phase B invoice, and worker 32's value-minimized digest + /// (plan C11 acceptance 1, 4, 5, 6, 7, 8). + /// + [TestFixture] + public class CalOesMarsServiceTests + { + private const int DeptId = 7; + private const string User = "manager"; + private static readonly DateTime Dispatch = new DateTime(2026, 8, 1, 6, 0, 0, DateTimeKind.Utc); + + private List _agencies; + private List _resources; + private List _rateProfiles; + private List _rateLines; + private List _inputs; + private List _agreements; + private List _items; + private List _lines; + private List _audits; + private List _notifications; + private Deployment _deployment; + private DeploymentExternalContext _context; + private List _attachments; + private List _entries; + private List _reports; + private List _expenses; + private Mock _deployments; + private CalOesMarsService _service; + + [SetUp] + public void SetUp() + { + _agencies = new List(); _resources = new List(); _rateProfiles = new List(); _rateLines = new List(); + _inputs = new List(); _agreements = new List(); _items = new List(); _lines = new List(); + _audits = new List(); _notifications = new List(); + + var agencies = Repo(_agencies, a => a.CalOesMarsAgencyProfileId, (a, id) => a.CalOesMarsAgencyProfileId = id); + agencies.Setup(r => r.GetByDepartmentAsync(DeptId)).ReturnsAsync(() => _agencies.FirstOrDefault(a => !a.IsDeleted)); + agencies.Setup(r => r.GetAllAsync()).ReturnsAsync(() => _agencies.ToList()); + var resources = Repo(_resources, r => r.CalOesMarsResourceProfileId, (r, id) => r.CalOesMarsResourceProfileId = id); + resources.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _resources.FirstOrDefault(x => x.CalOesMarsResourceProfileId == id)); + resources.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _resources.Where(x => !x.IsDeleted).ToList()); + resources.Setup(r => r.GetByUnitIdsAsync(DeptId, It.IsAny>())).ReturnsAsync((int _, IEnumerable ids) => _resources.Where(x => !x.IsDeleted && x.UnitId.HasValue && ids.Contains(x.UnitId.Value)).ToList()); + var rateProfiles = Repo(_rateProfiles, p => p.CalOesMarsRateProfileId, (p, id) => p.CalOesMarsRateProfileId = id); + rateProfiles.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _rateProfiles.FirstOrDefault(x => x.CalOesMarsRateProfileId == id)); + rateProfiles.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int? year) => _rateProfiles.Where(x => !x.IsDeleted && (!year.HasValue || x.SubmissionYear == year)).ToList()); + rateProfiles.Setup(r => r.GetEffectiveAsync(DeptId, It.IsAny())).ReturnsAsync((int _, DateTime asOf) => _rateProfiles.Where(x => !x.IsDeleted && x.IsCurrent(asOf)).ToList()); + var rateLines = Repo(_rateLines, l => l.CalOesMarsRateLineId, (l, id) => l.CalOesMarsRateLineId = id); + rateLines.Setup(r => r.GetByProfileAsync(It.IsAny())).ReturnsAsync((string id) => _rateLines.Where(x => x.CalOesMarsRateProfileId == id && !x.IsDeleted).ToList()); + rateLines.Setup(r => r.GetByProfilesAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => _rateLines.Where(x => ids.Contains(x.CalOesMarsRateProfileId) && !x.IsDeleted).ToList()); + var inputs = Repo(_inputs, i => i.CalOesMarsAdministrativeRateInputId, (i, id) => i.CalOesMarsAdministrativeRateInputId = id); + inputs.Setup(r => r.GetByProfileAsync(It.IsAny())).ReturnsAsync((string id) => _inputs.Where(x => x.CalOesMarsRateProfileId == id && !x.IsDeleted).ToList()); + var agreements = Repo(_agreements, a => a.CalOesMarsAgreementSnapshotId, (a, id) => a.CalOesMarsAgreementSnapshotId = id); + agreements.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _agreements.FirstOrDefault(x => x.CalOesMarsAgreementSnapshotId == id)); + agreements.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _agreements.Where(x => !x.IsDeleted).ToList()); + var items = Repo(_items, w => w.CalOesMarsWorkItemId, (w, id) => w.CalOesMarsWorkItemId = id); + items.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _items.FirstOrDefault(x => x.CalOesMarsWorkItemId == id)); + items.Setup(r => r.GetByDeploymentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _items.Where(x => x.DeploymentId == id && !x.IsDeleted).ToList()); + items.Setup(r => r.GetByExternalIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string id) => _items.Where(x => !x.IsDeleted && (x.MarsRecordId == id || x.MarsInvoiceId == id)).ToList()); + items.Setup(r => r.GetActionQueueAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int? type) => _items.Where(x => !x.IsDeleted && x.LocalState != (int)CalOesMarsLocalStates.Closed && (!type.HasValue || x.RecordType == type)).ToList()); + items.Setup(r => r.GetDepartmentsWithOpenItemsAsync()).ReturnsAsync(() => _items.Where(x => !x.IsDeleted && x.LocalState != (int)CalOesMarsLocalStates.Closed).Select(x => x.DepartmentId).Distinct().ToList()); + var lines = Repo(_lines, l => l.CalOesMarsReimbursementLineId, (l, id) => l.CalOesMarsReimbursementLineId = id); + lines.Setup(r => r.GetByWorkItemAsync(It.IsAny())).ReturnsAsync((string id) => _lines.Where(x => x.CalOesMarsWorkItemId == id).ToList()); + lines.Setup(r => r.DeleteByWorkItemAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, CancellationToken _) => { _lines.RemoveAll(x => x.CalOesMarsWorkItemId == id); return true; }); + + // The immutable deployment facts: an engine on an RMS order with two requests, a roster of two, DTR hours and two expenses. + _deployment = new Deployment + { + DeploymentId = "dep-1", DepartmentId = DeptId, Name = "Ridge Fire engine", FinanceMode = (int)DeploymentFinanceModes.CostRecovery, Status = (int)DeploymentStatuses.Active, RmsExternalOrderId = "order-1", IncidentNumber = "CA-LNU-001234", StartOn = Dispatch, AddedOn = Dispatch, + Units = { new DeploymentUnit { DeploymentUnitId = "du-1", DeploymentId = "dep-1", DepartmentId = DeptId, UnitId = 31, UnitName = "E-31" } }, + Personnel = + { + new DeploymentPersonnel { DeploymentPersonnelId = "dp-1", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "alice", CertificationCode = "Captain", DisplayName = "Alice Captain", RmsExternalOrderFillId = "fill-e12" }, + new DeploymentPersonnel { DeploymentPersonnelId = "dp-2", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "bob", CertificationCode = "Firefighter", DisplayName = "Bob Firefighter", RmsExternalOrderFillId = "fill-e12" }, + new DeploymentPersonnel { DeploymentPersonnelId = "dp-3", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "carol", CertificationCode = "Firefighter", DisplayName = "Carol Overhead", RmsExternalOrderFillId = "fill-o3" } + } + }; + _context = new DeploymentExternalContext + { + Order = new RmsExternalOrder { RmsExternalOrderId = "order-1", DepartmentId = DeptId, OrderNumber = "CA-LNU-001234-O1", IncidentName = "Ridge Fire", IncidentNumber = "CA-LNU-001234", RequestingAgency = "LNU" }, + Fills = + { + new RmsExternalOrderFill { RmsExternalOrderFillId = "fill-e12", RmsExternalOrderId = "order-1", RequestNumber = "E-12", ResourceKind = "Engine", ResourceType = "Type 3 Engine", MobilizedOn = Dispatch, CheckedInOn = Dispatch.AddHours(4), ReleasedOn = Dispatch.AddHours(46) }, + new RmsExternalOrderFill { RmsExternalOrderFillId = "fill-o3", RmsExternalOrderId = "order-1", RequestNumber = "O-3", ResourceKind = "Overhead", Position = "DIVS", MobilizedOn = Dispatch } + } + }; + _attachments = new List { new DeploymentAttachment { DeploymentAttachmentId = 5, DeploymentId = "dep-1", DepartmentId = DeptId, AttachmentType = (int)DeploymentAttachmentTypes.PaperF42, Name = "Paper F-42", FileName = "f42.pdf", Data = new byte[] { 1, 2, 3 } } }; + _reports = new List { new DeploymentTimeReport { DeploymentTimeReportId = "dtr-1", DeploymentId = "dep-1", DepartmentId = DeptId, Status = (int)DeploymentTimeReportStatuses.Approved, ReportDate = Dispatch.Date }, new DeploymentTimeReport { DeploymentTimeReportId = "dtr-void", DeploymentId = "dep-1", DepartmentId = DeptId, Status = (int)DeploymentTimeReportStatuses.Void, ReportDate = Dispatch.Date } }; + _entries = new List + { + new DeploymentTimeEntry { DeploymentTimeEntryId = "te-1", DeploymentTimeReportId = "dtr-1", DeploymentId = "dep-1", SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, DeploymentPersonnelId = "dp-1", StartTime = Dispatch, EndTime = Dispatch.AddHours(14) }, + new DeploymentTimeEntry { DeploymentTimeEntryId = "te-2", DeploymentTimeReportId = "dtr-1", DeploymentId = "dep-1", SubjectType = (int)DeploymentTimeSubjectTypes.Unit, DeploymentUnitId = "du-1", StartTime = Dispatch, EndTime = Dispatch.AddHours(14), MileageKm = 160.9m }, + new DeploymentTimeEntry { DeploymentTimeEntryId = "te-void", DeploymentTimeReportId = "dtr-void", DeploymentId = "dep-1", SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, DeploymentPersonnelId = "dp-1", StartTime = Dispatch, EndTime = Dispatch.AddHours(99) } + }; + _expenses = new List + { + new DeploymentExpense { DeploymentExpenseId = "ex-1", DeploymentId = "dep-1", DepartmentId = DeptId, ExpenseDate = Dispatch.Date, ExpenseType = (int)DeploymentExpenseTypes.PerDiemMeal, City = "Napa", Amount = 18.5m, ReceiptAttachmentId = 9 }, + new DeploymentExpense { DeploymentExpenseId = "ex-2", DeploymentId = "dep-1", DepartmentId = DeptId, ExpenseDate = Dispatch.Date, ExpenseType = (int)DeploymentExpenseTypes.Accommodation, City = "Napa", Amount = 140 } + }; + _deployments = new Mock(); + _deployments.Setup(d => d.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment); + _deployments.Setup(d => d.GetExternalContextAsync("dep-1", DeptId, It.IsAny())).ReturnsAsync(() => _context); + _deployments.Setup(d => d.GetAttachmentsAsync("dep-1", DeptId)).ReturnsAsync(() => _attachments.ToList()); + _deployments.Setup(d => d.GetAttachmentAsync(It.IsAny(), DeptId, It.IsAny())).ReturnsAsync((int id, int _, bool __) => _attachments.FirstOrDefault(a => a.DeploymentAttachmentId == id)); + _deployments.Setup(d => d.IsRosteredAsync("dep-1", DeptId, It.IsAny())).ReturnsAsync((string _, int __, string user) => _deployment.Personnel.Any(p => p.UserId == user)); + _deployments.Setup(d => d.GetDeploymentsForDepartmentAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(() => new List { _deployment }); + var timeTracking = new Mock(); + timeTracking.Setup(t => t.GetTimeReportsAsync("dep-1", DeptId)).ReturnsAsync(() => _reports.ToList()); + timeTracking.Setup(t => t.GetExpensesAsync("dep-1", DeptId)).ReturnsAsync(() => _expenses.ToList()); + var entries = new Mock(); + entries.Setup(e => e.GetByDeploymentAsync("dep-1")).ReturnsAsync(() => _entries.ToList()); + var units = new Mock(); + units.Setup(u => u.GetUnitsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Unit { UnitId = 31, DepartmentId = DeptId, Name = "E-31", Type = "Type 3 Engine", PlateNumber = "1ABC234", VIN = "VIN31" } }); + units.Setup(u => u.GetUnitByIdAsync(31)).ReturnsAsync(new Unit { UnitId = 31, DepartmentId = DeptId, Name = "E-31" }); + var profiles = new Mock(); + profiles.Setup(p => p.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync(new List()); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test", TimeZone = "UTC" }); + departments.Setup(d => d.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + var events = new Mock(); + events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + var communication = new Mock(); + communication.Setup(c => c.SendNotificationAsync(It.IsAny(), DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, __, message, ___, ____, _____, ______, _______) => _notifications.Add(message)).ReturnsAsync(true); + + _service = new CalOesMarsService(agencies.Object, resources.Object, rateProfiles.Object, rateLines.Object, inputs.Object, agreements.Object, items.Object, lines.Object, + _deployments.Object, timeTracking.Object, entries.Object, units.Object, profiles.Object, departments.Object, events.Object, null, + new CalOesMarsReimbursementCalculator(), new ManualCalOesMarsGateway(), new Lazy(() => communication.Object)); + } + + 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; }); + return mock; + } + + private async Task SeedReadyDepartmentAsync() + { + await _service.SaveAgencyProfileAsync(new CalOesMarsAgencyProfile { DepartmentId = DeptId, AgencyName = "Test County Fire", MacsDesignator = "xtc", FeinReference = "12-3456789", UeiReference = "ABC123DEF456", FiscalSupplierReference = "0001234567" }, User, null, null); + await _service.MarkAgencyVerifiedAsync(DeptId, User, null, null); + var salary = await _service.SaveRateProfileAsync(new CalOesMarsRateProfile { DepartmentId = DeptId, SubmissionYear = 2026, SubmissionType = (int)CalOesMarsSubmissionTypes.SalarySurvey, EffectiveOn = new DateTime(2026, 1, 1), ExpiresOn = new DateTime(2026, 12, 31), AdministrativeRateMethod = (int)CalOesMarsAdministrativeRateMethods.DeMinimis, AdministrativeRateValue = 10 }, User, null, null); + await _service.SaveRateLinesAsync(salary.CalOesMarsRateProfileId, DeptId, new List + { + new CalOesMarsRateLine { LineKind = (int)CalOesMarsRateLineKinds.SalarySurvey, ClassificationCode = "Captain", StraightRate = 60, OvertimeRate = 90, OvertimeEligible = true, PortalToPortalEligible = true }, + new CalOesMarsRateLine { LineKind = (int)CalOesMarsRateLineKinds.SalarySurvey, ClassificationCode = "Firefighter", StraightRate = 40, OvertimeEligible = true, PortalToPortalEligible = true } + }, User, null, null); + await _service.SetRateProfileStatusAsync(salary.CalOesMarsRateProfileId, DeptId, CalOesMarsRateProfileStatuses.Reviewed, null, User, null, null); + var letter = await _service.SaveRateProfileAsync(new CalOesMarsRateProfile { DepartmentId = DeptId, SubmissionYear = 2026, SubmissionType = (int)CalOesMarsSubmissionTypes.RateLetter, EffectiveOn = new DateTime(2026, 1, 1) }, User, null, null); + await _service.SaveRateLinesAsync(letter.CalOesMarsRateProfileId, DeptId, new List + { + new CalOesMarsRateLine { LineKind = (int)CalOesMarsRateLineKinds.OfficialApparatus, ResourceCode = "Type 3 Engine", Basis = (int)CalOesMarsRateBases.Hourly, StraightRate = 85, Authority = (int)CalOesMarsRateAuthorities.CalOesRateLetter } + }, User, null, null); + await _service.SaveAgreementAsync(new CalOesMarsAgreementSnapshot { DepartmentId = DeptId, DocumentKind = (int)CalOesMarsDocumentKinds.Mou, CompensationMethod = (int)CalOesMarsCompensationMethods.ActualHours, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterEightHoursPerDay, StartOn = new DateTime(2026, 1, 1) }, User, null, null); + await _service.BuildResourceInventoryF5DraftAsync(DeptId, new[] { 31 }, User, null, null); + } + + [Test] + public async Task Readiness_blocks_without_agency_salary_survey_or_agreement_and_clears_once_they_exist() + { + var empty = await _service.GetAgencyReadinessAsync(DeptId, Dispatch); + empty.IsReady.Should().BeFalse(); + empty.Items.Where(i => i.Severity == (int)CalOesMarsReadinessSeverities.Blocker).Select(i => i.MessageKey).Should().BeEquivalentTo(new[] { "ReadinessAgencyMissing", "ReadinessSalaryMissing", "ReadinessAgreementMissing" }); + empty.AuthorityProfileCode.Should().Be(CalOesMarsAuthorityProfile.CurrentCode); + + await SeedReadyDepartmentAsync(); + var ready = await _service.GetAgencyReadinessAsync(DeptId, Dispatch); + ready.IsReady.Should().BeTrue(); + ready.Items.Should().NotContain(i => i.Severity == (int)CalOesMarsReadinessSeverities.Blocker); + ready.Items.Select(i => i.MessageKey).Should().Contain("ReadinessRateUnsigned", "the survey is reviewed but not signed"); + ready.Agency.MacsDesignator.Should().Be("XTC", "the designator is upper-cased"); + ready.ResourceProfiles.Should().Be(1); + _resources.Single().Should().Match(r => r.UnitId == 31 && r.LicensePlate == "1ABC234" && r.Vin == "VIN31" && r.ResourceType == "Type 3 Engine"); + // Changing an identifier clears the verification. + await _service.SaveAgencyProfileAsync(new CalOesMarsAgencyProfile { DepartmentId = DeptId, AgencyName = "Test County Fire", MacsDesignator = "XTC", FeinReference = "99-0000000" }, User, null, null); + (await _service.GetAgencyProfileAsync(DeptId)).VerifiedOn.Should().BeNull(); + } + + [Test] + public async Task F42_draft_projects_the_fill_roster_and_dtr_facts_then_the_checklist_gates_the_handoff_and_observation() + { + await SeedReadyDepartmentAsync(); + await FluentActions.Awaiting(() => _service.BuildF42DraftAsync("dep-1", DeptId, null, User, null, null)).Should().ThrowAsync().WithMessage("calmars_fill_required"); + + var item = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + item.RecordType.Should().Be((int)CalOesMarsRecordTypes.F42); + item.LocalState.Should().Be((int)CalOesMarsLocalStates.Draft); + item.RmsExternalOrderFillId.Should().Be("fill-e12"); + item.AgreementSnapshotId.Should().NotBeNullOrWhiteSpace("the agreement is selected as of initial dispatch"); + item.RateProfileVersion.Should().Contain(":"); + var s = CalOesMarsService.Deserialize(item.SnapshotJson); + s.MacsDesignator.Should().Be("XTC"); + s.IncidentNumber.Should().Be("CA-LNU-001234"); + s.OrderNumber.Should().Be("CA-LNU-001234-O1"); + s.RequestNumber.Should().Be("E-12"); + s.ResourceType.Should().Be("Type 3 Engine"); + s.DispatchedOn.Should().Be(Dispatch); + s.CommittedOn.Should().Be(Dispatch.AddHours(4)); + s.ReleasedOn.Should().Be(Dispatch.AddHours(46)); + s.ReturnedOn.Should().BeNull("release is not return"); + s.Personnel.Select(p => p.DeploymentPersonnelId).Should().BeEquivalentTo(new[] { "dp-1", "dp-2" }, "the roster is filtered to the request's fill"); + s.Personnel.Single(p => p.DeploymentPersonnelId == "dp-1").ActualHours.Should().ContainSingle().Which.Hours.Should().Be(14, "void reports never feed the F-42"); + s.Vehicles.Should().ContainSingle().Which.Should().Match(v => v.Kind == "Apparatus" && v.Designator == "E-31" && v.ResourceCode == "Type 3 Engine" && v.LicensePlate == "1ABC234" && v.CommittedHours == 14 && v.Miles == 100); + s.AttachmentIds.Should().Equal(5); + s.SourceTimeReportIds.Should().Equal("dtr-1"); + _audits.Should().Contain(a => a.Type == AuditLogTypes.CalOesMarsWorkItemPrepared); + + // Unsigned: the checklist blocks and the handoff refuses. + var first = await _service.ValidateForPortalAsync(item.CalOesMarsWorkItemId, DeptId, User, null, null); + first.IsReadyForPortal.Should().BeFalse(); + first.Errors.Select(e => e.Code).Should().Contain(new[] { CalOesMarsValidationCodes.RespondingSignatureMissing, CalOesMarsValidationCodes.IncidentAuthorizationMissing }); + (await _service.GetWorkItemAsync(item.CalOesMarsWorkItemId, DeptId)).LocalState.Should().Be((int)CalOesMarsLocalStates.NeedsReview); + await FluentActions.Awaiting(() => _service.OpenPortalHandoffAsync(item.CalOesMarsWorkItemId, DeptId, true, User, null, null)).Should().ThrowAsync().WithMessage("calmars_not_ready"); + await FluentActions.Awaiting(() => _service.RecordExternalSubmissionAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1" }, User, null, null)).Should().ThrowAsync().WithMessage("calmars_not_ready"); + + // Sign, set the return, run the checklist: ready. Opening the handoff never changes the state. + await _service.SaveF42SnapshotAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { ReturnedOn = Dispatch.AddHours(48), RespondingSignerName = "Chief Jones", IncidentAuthorizerName = "AREP Smith", Comments = "Relieved by E-32" }, User, null, null); + var second = await _service.ValidateForPortalAsync(item.CalOesMarsWorkItemId, DeptId, User, null, null); + second.Errors.Should().BeEmpty(); + second.Warnings.Select(w => w.Code).Should().NotContain(CalOesMarsValidationCodes.VehicleNotInInventory); + (await _service.GetWorkItemAsync(item.CalOesMarsWorkItemId, DeptId)).LocalState.Should().Be((int)CalOesMarsLocalStates.ReadyForPortal); + await FluentActions.Awaiting(() => _service.OpenPortalHandoffAsync(item.CalOesMarsWorkItemId, DeptId, false, User, null, null)).Should().ThrowAsync().WithMessage("calmars_attestation_required"); + var manifest = await _service.OpenPortalHandoffAsync(item.CalOesMarsWorkItemId, DeptId, true, User, null, null); + manifest.Fields.Select(f => f.Box).Should().Equal(CalOesMarsAuthorityProfile.Current.F42Boxes.Select(b => b.Id)); + manifest.Fields.Single(f => f.Box == "request").Value.Should().Be("E-12"); + manifest.NotAnImportFile.Should().BeTrue(); + manifest.Checksum.Should().NotBeNullOrWhiteSpace(); + (await _service.GetWorkItemAsync(item.CalOesMarsWorkItemId, DeptId)).LocalState.Should().Be((int)CalOesMarsLocalStates.ReadyForPortal, "opening the handoff is not a submission"); + _audits.Should().Contain(a => a.Type == AuditLogTypes.CalOesMarsWorkItemOpenedForHandoff); + + // Expected reimbursement is an estimate from the effective profile; the packet carries the paper F-42. + var calc = await _service.CalculateExpectedReimbursementAsync(item.CalOesMarsWorkItemId, DeptId, User, null, null); + calc.Lines.Should().Contain(l => l.LineKind == (int)CalOesMarsLineKinds.Personnel && l.SubjectId == "dp-1" && l.Quantity == 8 && l.Rate == 60); + calc.Lines.Should().Contain(l => l.LineKind == (int)CalOesMarsLineKinds.Personnel && l.SubjectId == "dp-1" && l.Quantity == 6 && l.Rate == 90); + calc.Lines.Should().Contain(l => l.LineKind == (int)CalOesMarsLineKinds.Apparatus && l.ExpectedAmount == 14 * 85); + calc.Lines.Should().Contain(l => l.LineKind == (int)CalOesMarsLineKinds.Administrative); + calc.Exceptions.Should().Contain(e => e.Code == CalOesMarsExceptionCodes.NoActualHours && e.Detail.Contains("Bob")); + (await _service.GetWorkItemAsync(item.CalOesMarsWorkItemId, DeptId)).ExpectedTotal.Should().Be(calc.ExpectedTotal); + _lines.Should().HaveCount(calc.Lines.Count); + using (var zip = new ZipArchive(new MemoryStream(await _service.BuildEvidencePacketAsync(item.CalOesMarsWorkItemId, DeptId, User)), ZipArchiveMode.Read)) + { + zip.Entries.Select(e => e.FullName).Should().Contain(new[] { "README.txt", "manifest.json", "snapshot.json", "record.html", "supporting/f42.pdf" }); + using var reader = new StreamReader(zip.GetEntry("README.txt").Open()); + (await reader.ReadToEndAsync()).Should().Contain("NOT an accepted MARS import file"); + } + + // Observed submitted, returned (new revision carries the comment; the old one closes), resubmitted and approved. + var submitted = await _service.RecordExternalSubmissionAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1001", ExternalStatus = "Cal OES Review" }, User, null, null); + submitted.LocalState.Should().Be((int)CalOesMarsLocalStates.SubmittedExternal); + submitted.MarsRecordId.Should().Be("F42-1001"); + submitted.IsLocallyEditable.Should().BeFalse(); + await FluentActions.Awaiting(() => _service.SaveF42SnapshotAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot(), User, null, null)).Should().ThrowAsync().WithMessage("calmars_work_item_external"); + await FluentActions.Awaiting(() => _service.RecordExternalStatusAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalStatus = "Rejected" }, User, null, null)).Should().ThrowAsync().WithMessage("calmars_status_unknown"); + var revision = await _service.RecordExternalStatusAsync(item.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalStatus = "Agency Review", Comment = "Box 14 missing the AREP title" }, User, null, null); + revision.CalOesMarsWorkItemId.Should().NotBe(item.CalOesMarsWorkItemId); + revision.SupersedesWorkItemId.Should().Be(item.CalOesMarsWorkItemId); + revision.LocalState.Should().Be((int)CalOesMarsLocalStates.ReturnedForAgencyReview); + revision.CorrectionComment.Should().Be("Box 14 missing the AREP title"); + revision.MarsRecordId.Should().Be("F42-1001"); + revision.Lines.Should().HaveCount(calc.Lines.Count, "the estimate travels with the revision"); + (await _service.GetWorkItemAsync(item.CalOesMarsWorkItemId, DeptId)).LocalState.Should().Be((int)CalOesMarsLocalStates.Closed); + await _service.SaveF42SnapshotAsync(revision.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { RespondingSignerName = "Chief Jones", IncidentAuthorizerName = "AREP Smith, Agency Rep", ReturnedOn = Dispatch.AddHours(48) }, User, null, null); + (await _service.ValidateForPortalAsync(revision.CalOesMarsWorkItemId, DeptId, User, null, null)).IsReadyForPortal.Should().BeTrue(); + await _service.RecordExternalSubmissionAsync(revision.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1001" }, User, null, null); + var approved = await _service.RecordExternalStatusAsync(revision.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalStatus = "Approved" }, User, null, null); + approved.LocalState.Should().Be((int)CalOesMarsLocalStates.Approved); + approved.ApprovedOn.Should().NotBeNull(); + + // The queue: managers see everything open, a rostered member only their own deployment's items, and never an invoice. + (await _service.GetActionQueueAsync(DeptId, "alice", true)).Select(q => q.WorkItem.CalOesMarsWorkItemId).Should().Contain(revision.CalOesMarsWorkItemId); + (await _service.GetActionQueueAsync(DeptId, "alice", false)).Should().OnlyContain(q => q.IsMine); + (await _service.GetActionQueueAsync(DeptId, "stranger", false)).Should().BeEmpty(); + (await _service.IsRosteredForWorkItemAsync(revision.CalOesMarsWorkItemId, DeptId, "bob")).Should().BeTrue(); + (await _service.IsRosteredForWorkItemAsync(revision.CalOesMarsWorkItemId, DeptId, "stranger")).Should().BeFalse(); + } + + [Test] + public async Task Redispatch_closes_the_first_interval_and_opens_a_superseding_f42() + { + await SeedReadyDepartmentAsync(); + var first = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + await _service.SaveF42SnapshotAsync(first.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { RespondingSignerName = "Chief", IncidentAuthorizerName = "AREP" }, User, null, null); + await _service.ValidateForPortalAsync(first.CalOesMarsWorkItemId, DeptId, User, null, null); + await _service.RecordExternalSubmissionAsync(first.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1" }, User, null, null); + + // The same request is rebuilt after the resource is sent on to a new incident: a new item supersedes the first, + // whose interval closes at its release time (release is not return, but the redispatch ends the first commitment). + var second = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + second.CalOesMarsWorkItemId.Should().NotBe(first.CalOesMarsWorkItemId); + second.SupersedesWorkItemId.Should().Be(first.CalOesMarsWorkItemId); + CalOesMarsService.Deserialize(second.SnapshotJson).IsRedispatch.Should().BeTrue(); + CalOesMarsService.Deserialize((await _service.GetWorkItemAsync(first.CalOesMarsWorkItemId, DeptId)).SnapshotJson).ReturnedOn.Should().Be(Dispatch.AddHours(46)); + // Rebuilding a still-local draft reuses it and keeps the locally authored boxes. + await _service.SaveF42SnapshotAsync(second.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { Comments = "Second interval" }, User, null, null); + var rebuilt = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + rebuilt.CalOesMarsWorkItemId.Should().Be(second.CalOesMarsWorkItemId); + CalOesMarsService.Deserialize(rebuilt.SnapshotJson).Comments.Should().Be("Second interval"); + // The overhead request gets its own F-42 with the overhead position and its own roster member. + var overhead = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-o3", User, null, null); + var s = CalOesMarsService.Deserialize(overhead.SnapshotJson); + s.OverheadPosition.Should().Be("DIVS"); + s.Personnel.Select(p => p.DeploymentPersonnelId).Should().Equal("dp-3"); + } + + [Test] + public async Task Expense_claim_links_to_a_submitted_f42_or_takes_the_travel_only_path() + { + await SeedReadyDepartmentAsync(); + var f42 = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + var claim = await _service.BuildExpenseClaimDraftAsync("dep-1", DeptId, f42.CalOesMarsWorkItemId, User, null, null); + var s = CalOesMarsService.Deserialize(claim.SnapshotJson); + s.F42WorkItemId.Should().Be(f42.CalOesMarsWorkItemId); + s.TravelOnly.Should().BeFalse(); + s.Lines.Select(l => l.Category).Should().Equal("Meal", "Lodging"); + s.Lines[1].ReceiptAttachmentId.Should().BeNull(); + + var blocked = await _service.ValidateForPortalAsync(claim.CalOesMarsWorkItemId, DeptId, User, null, null); + blocked.Errors.Select(e => e.Code).Should().Contain(new[] { CalOesMarsValidationCodes.ExpenseReceiptMissing, CalOesMarsValidationCodes.ExpenseF42NotSubmitted, CalOesMarsValidationCodes.ExpenseSignatureMissing }); + + _expenses[1].ReceiptAttachmentId = 10; + await _service.SaveF42SnapshotAsync(f42.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { RespondingSignerName = "Chief", IncidentAuthorizerName = "AREP" }, User, null, null); + await _service.ValidateForPortalAsync(f42.CalOesMarsWorkItemId, DeptId, User, null, null); + await _service.RecordExternalSubmissionAsync(f42.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1" }, User, null, null); + var rebuilt = await _service.BuildExpenseClaimDraftAsync("dep-1", DeptId, f42.CalOesMarsWorkItemId, User, null, null); + rebuilt.CalOesMarsWorkItemId.Should().Be(claim.CalOesMarsWorkItemId, "a local claim for the same F-42 is refreshed, not duplicated"); + await _service.SaveExpenseSnapshotAsync(claim.CalOesMarsWorkItemId, DeptId, new CalOesMarsExpenseClaimSnapshot { SignerName = "A. Captain", ApproverName = "Chief" }, User, null, null); + var ready = await _service.ValidateForPortalAsync(claim.CalOesMarsWorkItemId, DeptId, User, null, null); + ready.Errors.Should().BeEmpty(); + var calc = await _service.CalculateExpectedReimbursementAsync(claim.CalOesMarsWorkItemId, DeptId, User, null, null); + calc.ExpectedTotal.Should().Be(18.5m); + calc.UncertainTotal.Should().Be(140, "lodging without pre-approval"); + + var travel = await _service.BuildExpenseClaimDraftAsync("dep-1", DeptId, null, User, null, null); + CalOesMarsService.Deserialize(travel.SnapshotJson).TravelOnly.Should().BeTrue(); + travel.CalOesMarsWorkItemId.Should().NotBe(claim.CalOesMarsWorkItemId); + } + + [Test] + public async Task Mars_invoice_is_a_work_item_reconciled_against_expected_lines_and_paid_only_from_an_observed_payment() + { + await SeedReadyDepartmentAsync(); + var f42 = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + await _service.SaveF42SnapshotAsync(f42.CalOesMarsWorkItemId, DeptId, new CalOesMarsF42Snapshot { RespondingSignerName = "Chief", IncidentAuthorizerName = "AREP", ReturnedOn = Dispatch.AddHours(48) }, User, null, null); + await _service.ValidateForPortalAsync(f42.CalOesMarsWorkItemId, DeptId, User, null, null); + var calc = await _service.CalculateExpectedReimbursementAsync(f42.CalOesMarsWorkItemId, DeptId, User, null, null); + await FluentActions.Awaiting(() => _service.RecordMarsInvoiceAsync(DeptId, "dep-1", new CalOesMarsInvoiceObservation { MarsInvoiceId = "INV-9", InvoicedTotal = 100, CoveredWorkItemIds = { f42.CalOesMarsWorkItemId } }, User, null, null)).Should().ThrowAsync().WithMessage("calmars_work_item_not_external"); + await _service.RecordExternalSubmissionAsync(f42.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalId = "F42-1" }, User, null, null); + await _service.RecordExternalStatusAsync(f42.CalOesMarsWorkItemId, DeptId, new CalOesMarsExternalObservation { ExternalStatus = "Approved" }, User, null, null); + + var invoice = await _service.RecordMarsInvoiceAsync(DeptId, "dep-1", new CalOesMarsInvoiceObservation { MarsInvoiceId = "INV-9", InvoiceDate = Dispatch.AddDays(30), InvoicedTotal = calc.ExpectedTotal - 50, PayingEntity = "Cal OES", CoveredWorkItemIds = { f42.CalOesMarsWorkItemId } }, User, null, null); + invoice.RecordType.Should().Be((int)CalOesMarsRecordTypes.GeneratedInvoice); + invoice.LocalState.Should().Be((int)CalOesMarsLocalStates.PendingLocalAgencyApproval); + invoice.MarsInvoiceId.Should().Be("INV-9"); + invoice.ExpectedTotal.Should().Be(calc.ExpectedTotal); + await FluentActions.Awaiting(() => _service.RecordMarsInvoiceAsync(DeptId, "dep-1", new CalOesMarsInvoiceObservation { MarsInvoiceId = "INV-9", InvoicedTotal = 1 }, User, null, null)).Should().ThrowAsync().WithMessage("calmars_invoice_duplicate"); + var reconciliation = await _service.GetInvoiceReconciliationAsync(invoice.CalOesMarsWorkItemId, DeptId); + reconciliation.CoveredItems.Select(c => c.CalOesMarsWorkItemId).Should().Equal(f42.CalOesMarsWorkItemId); + reconciliation.Variance.Should().Be(-50); + + // A payment cannot be recorded before the local decision; a rejection needs a comment; approval routes to the paying entity. + await FluentActions.Awaiting(() => _service.RecordPaymentAsync(invoice.CalOesMarsWorkItemId, DeptId, new CalOesMarsPaymentObservation { PaidTotal = 1, PaidOn = Dispatch.AddDays(60) }, User, null, null)).Should().ThrowAsync().WithMessage("calmars_invoice_not_approved"); + await FluentActions.Awaiting(() => _service.ApproveOrRejectObservedInvoiceAsync(invoice.CalOesMarsWorkItemId, DeptId, false, "Fire Chief", null, User, null, null)).Should().ThrowAsync().WithMessage("calmars_rejection_comment_required"); + var approved = await _service.ApproveOrRejectObservedInvoiceAsync(invoice.CalOesMarsWorkItemId, DeptId, true, "Fire Chief", "Matches expected within tolerance", User, null, null); + approved.LocalState.Should().Be((int)CalOesMarsLocalStates.PendingPayingEntityApproval); + approved.ApprovedByUserId.Should().Be(User); + _audits.Should().Contain(a => a.Type == AuditLogTypes.CalOesMarsInvoiceApproved); + + var paid = await _service.RecordPaymentAsync(invoice.CalOesMarsWorkItemId, DeptId, new CalOesMarsPaymentObservation { PaidTotal = calc.ExpectedTotal - 50, PaidOn = Dispatch.AddDays(60), PaymentReference = "EFT-77" }, User, null, null); + paid.LocalState.Should().Be((int)CalOesMarsLocalStates.Paid); + paid.PaymentReference.Should().Be("EFT-77"); + (await _service.GetWorkItemAsync(f42.CalOesMarsWorkItemId, DeptId)).LocalState.Should().Be((int)CalOesMarsLocalStates.Paid, "the covered F-42 follows the observed payment"); + _audits.Should().Contain(a => a.Type == AuditLogTypes.CalOesMarsPaymentReconciled); + (await _service.CloseWorkItemAsync(invoice.CalOesMarsWorkItemId, DeptId, User, null, null)).LocalState.Should().Be((int)CalOesMarsLocalStates.Closed); + // Decision 36: no Phase B service is even a dependency of this service — a MARS invoice never becomes a customer invoice. + typeof(CalOesMarsService).GetConstructors().Single().GetParameters().Select(p => p.ParameterType).Should().NotContain(new[] { typeof(IInvoicingService), typeof(IInvoicePaymentsService) }); + } + + [Test] + public async Task Reminder_sweep_sends_one_value_minimized_digest_per_department_per_day() + { + await SeedReadyDepartmentAsync(); + _agreements.Single().EndOn = Dispatch.AddDays(10); + _deployment.Status = (int)DeploymentStatuses.Completed; _deployment.StatusChangedOn = Dispatch.AddDays(-30); + var f42 = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null); + await _service.CalculateExpectedReimbursementAsync(f42.CalOesMarsWorkItemId, DeptId, User, null, null); + var asOf = new DateTime(2031, 3, 5, 12, 0, 0, DateTimeKind.Utc); + + (await _service.RunReminderSweepAsync(asOf)).Should().Be(1); + _notifications.Should().ContainSingle(); + var digest = _notifications.Single(); + digest.Should().StartWith("Cal OES MARS:"); + digest.Should().Contain("Ridge Fire engine: released").And.Contain("without a ready or submitted F-42"); + digest.Should().Contain("No Salary Survey covers 2031-03-05"); + digest.Should().NotContain("$").And.NotContain(f42.ExpectedTotal?.ToString("N2") ?? "n/a", "digests carry no amounts"); + (await _service.RunReminderSweepAsync(asOf)).Should().Be(0, "one digest per department per day"); + (await _service.RunReminderSweepAsync(asOf, _ => Task.FromResult(false))).Should().Be(0, "a lapsed entitlement is skipped"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs index e0e6295e..06878b8b 100644 --- a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs @@ -91,7 +91,8 @@ public void SetUp() 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.GetByContactIdAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, string contactId, int __, int ___) => _bids.Where(b => b.ContactId == contactId && !b.IsDeleted).ToList()); + bids.Setup(r => r.GetByContractAsync(It.IsAny())).ReturnsAsync((string contractId) => _bids.Where(b => b.ServiceContractId == contractId && !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()); diff --git a/Tests/Resgrid.Tests/Services/FieldCostCalculatorTests.cs b/Tests/Resgrid.Tests/Services/FieldCostCalculatorTests.cs new file mode 100644 index 00000000..94de8f90 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/FieldCostCalculatorTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Workforce; +using Resgrid.Services.Workforce; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase E (E4 / E7 acceptance): the pure labor and resource calculators. + /// Fixtures: 8 regular hours at $30 plus 4 overtime hours at 1.5× plus a 35 % employer cost and a $4/hour + /// component = $615; a $60,000 apparatus with $12,000 salvage over 40,000 miles depreciates at $1.20/mile; a + /// 120-mile / 10-engine-hour / 1-day usage prices depreciation, fuel, maintenance and a fixed annual component. + /// + [TestFixture] + public class FieldCostCalculatorTests + { + private static readonly DateTime AsOf = new DateTime(2026, 6, 15); + + private static EmployeeCompensationProfile Profile(decimal hourly = 30m, bool approved = true) => new EmployeeCompensationProfile + { + EmployeeCompensationProfileId = "profile", DepartmentId = 1, Scope = (int)CompensationScopes.Employee, PayBasis = (int)PayBases.Hourly, BaseAmountValue = hourly, Currency = "USD", + EffectiveOn = new DateTime(2026, 1, 1), IsApproved = approved, RowVersion = 3, RateMultipliersJson = "{\"Overtime\":1.5,\"DoubleTime\":2}", + CostComponents = new List { new EmployeeCostComponent { EmployeeCostComponentId = "burden", Category = (int)CostComponentCategories.EmployerPayrollTax, Basis = (int)CostComponentBases.PercentOfEligiblePay, RateAmountValue = 35m, Name = "Burden" } }, + PayComponents = new List { new EmployeePayComponent { EmployeePayComponentId = "ems", Category = (int)PayComponentCategories.Ems, Basis = (int)PayComponentBases.PerHour, AmountValue = 4m, PaidForEachOvertimeHour = true, Name = "EMS" } } + }; + + [Test] + public void Labor_fixture_prices_regular_and_overtime_with_components_to_615() + { + var profile = Profile(); + var regular = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { PayCode = (int)PayCodes.Regular, Hours = 8 }, Profile = profile, AsOf = AsOf, Currency = "USD" }); + var overtime = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { PayCode = (int)PayCodes.Overtime, Hours = 4 }, Profile = profile, AsOf = AsOf, Currency = "USD" }); + + regular.BaseRate.Should().Be(30m); + regular.PayAmount.Should().Be(240m); + regular.PayComponentAmount.Should().Be(32m); + regular.EmployerCostAmount.Should().Be(95.2m, "35 % of pay + components"); + overtime.Multiplier.Should().Be(1.5m); + overtime.PayAmount.Should().Be(180m); + overtime.PayComponentAmount.Should().Be(16m, "the EMS adder is paid for each overtime hour"); + overtime.EmployerCostAmount.Should().Be(68.6m); + (regular.LoadedCost + overtime.LoadedCost).Should().Be(631.8m); + // Without the employer burden the fixture reduces to the plan's $615 example: 240 + 180 + 32 + 16 + 35 % of the base pay (147). + (regular.PayAmount + overtime.PayAmount + regular.PayComponentAmount + overtime.PayComponentAmount + (regular.PayAmount + overtime.PayAmount) * 0.35m).Should().Be(615m); + regular.NeedsReview.Should().BeFalse(); + regular.Details.Should().Contain(d => d.ComponentId == "profile" && d.Version == 3, "every line records the profile version it was priced with"); + } + + [Test] + public void Approved_payroll_cost_replaces_the_estimate_and_salary_profiles_derive_an_hourly_rate() + { + var actual = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { PayCode = (int)PayCodes.Regular, Hours = 8, ApprovedPayrollCost = 500m }, Profile = Profile(), AsOf = AsOf }); + actual.LoadedCost.Should().Be(500m); + actual.IsEstimated.Should().BeFalse(); + actual.ReviewReasons.Should().Contain(LaborReviewReasons.ApprovedPayrollCostUsed); + + var salary = new EmployeeCompensationProfile { PayBasis = (int)PayBases.Salary, BaseAmountValue = 104000m, StandardHoursPerWeek = 40, IsApproved = true, EffectiveOn = new DateTime(2026, 1, 1) }; + FieldCostCalculator.HourlyRate(salary).Should().Be(50m); + + var none = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { PayCode = (int)PayCodes.Regular, Hours = 8 }, Profile = null, AsOf = AsOf }); + none.NeedsReview.Should().BeTrue(); + none.ReviewReasons.Should().Contain(LaborReviewReasons.NoProfile); + none.LoadedCost.Should().Be(0m); + + var unapproved = FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { PayCode = (int)PayCodes.Regular, Hours = 8 }, Profile = Profile(approved: false), IsFallback = true, AsOf = AsOf }); + unapproved.NeedsReview.Should().BeTrue(); + unapproved.ReviewReasons.Should().Contain(LaborReviewReasons.UnapprovedProfile).And.Contain(LaborReviewReasons.RoleFallback); + } + + private static ResourceCostProfile Apparatus() => new ResourceCostProfile + { + ResourceCostProfileId = "engine", DepartmentId = 1, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = 5, AcquisitionCost = 60000m, SalvageValue = 12000m, UsefulLifeQuantity = 40000m, + AllocationBasis = (int)AllocationBases.Mile, ExpectedAnnualUtilization = 8000m, EffectiveOn = new DateTime(2026, 1, 1), IsApproved = true, RowVersion = 2, + Components = new List + { + new ResourceCostComponent { ResourceCostComponentId = "fuel", Category = (int)ResourceCostCategories.FuelEnergy, Basis = (int)ResourceCostBases.PerMile, ConsumptionQuantity = 0.25m, UnitPrice = 4m, IsApproved = true }, + new ResourceCostComponent { ResourceCostComponentId = "maint", Category = (int)ResourceCostCategories.Maintenance, Basis = (int)ResourceCostBases.PerEngineHour, Rate = 15m, Source = (int)ResourceCostSources.Manual, IsApproved = true }, + new ResourceCostComponent { ResourceCostComponentId = "ins", Category = (int)ResourceCostCategories.InsuranceLicensing, Basis = (int)ResourceCostBases.FixedAnnual, Rate = 4000m, IsApproved = true } + } + }; + + [Test] + public void Resource_fixture_depreciates_at_1_20_per_mile_and_prices_120_miles_10_engine_hours_1_day() + { + FieldCostCalculator.DepreciationRate(Apparatus()).Should().Be(1.2m); + var result = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = new ResourceUsageQuantity { Miles = 120, EngineHours = 10, Days = 1, Deployments = 1 }, Profile = Apparatus(), AsOf = AsOf }); + result.NeedsReview.Should().BeFalse(); + result.Details.Single(d => d.Category == "Depreciation").Amount.Should().Be(144m, "120 miles × $1.20"); + result.Details.Single(d => d.Category == "FuelEnergy").Amount.Should().Be(120m, "120 miles × 0.25 gal × $4"); + result.Details.Single(d => d.Category == "Maintenance").Amount.Should().Be(150m, "10 engine hours × $15"); + result.Details.Single(d => d.Category == "InsuranceLicensing").Amount.Should().Be(60m, "$4,000 ÷ 8,000 miles × 120 miles"); + result.Total.Should().Be(474m); + } + + [Test] + public void Resource_review_rules_flag_actual_fuel_double_counted_repairs_and_missing_windows() + { + var withFuel = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = new ResourceUsageQuantity { Miles = 120, EngineHours = 10, Days = 1, ActualFuelCost = 95m }, Profile = Apparatus(), AsOf = AsOf }); + withFuel.Details.Single(d => d.Category == "FuelEnergy").Amount.Should().Be(95m, "an actual fuel cost replaces the modelled fuel"); + + var excluded = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = new ResourceUsageQuantity { Miles = 120, EngineHours = 10, Days = 1 }, Profile = Apparatus(), AsOf = AsOf, ExcludedComponentIds = new HashSet(StringComparer.OrdinalIgnoreCase) { "maint" } }); + excluded.NeedsReview.Should().BeTrue(); + excluded.ReviewReasons.Should().Contain(ResourceReviewReasons.RepairDoubleCounted); + excluded.Details.Single(d => d.Category == "Maintenance").Blocked.Should().BeTrue(); + + var rolling = Apparatus(); + rolling.Components.Single(c => c.ResourceCostComponentId == "maint").Source = (int)ResourceCostSources.WorkOrderRollingActual; + var insufficient = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = new ResourceUsageQuantity { Miles = 120, EngineHours = 10, Days = 1 }, Profile = rolling, AsOf = AsOf }); + insufficient.ReviewReasons.Should().Contain(ResourceReviewReasons.RollingWindowInsufficient); + + var noProfile = FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = new ResourceUsageQuantity { Miles = 10 }, Profile = null, AsOf = AsOf }); + noProfile.ReviewReasons.Should().Contain(ResourceReviewReasons.NoProfile); + noProfile.Total.Should().Be(0m); + } + + [Test] + public void Distances_canonicalise_to_miles_and_usage_entries_derive_from_meters() + { + FieldCostCalculator.ToMiles(100m, "km").Should().Be(62.14m); + FieldCostCalculator.ToMiles(100m, "mi").Should().Be(100m); + var entry = new ResourceUsageEntry { StartOdometer = 1000, EndOdometer = 1100, DistanceUnit = "km", StartEngineMeter = 500.5m, EndEngineMeter = 510.5m }; + FieldCostingService.Canonicalize(entry); + entry.OriginalDistance.Should().Be(100m); + entry.CanonicalDistanceMiles.Should().Be(62.14m); + entry.EngineHours.Should().Be(10m); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs b/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs new file mode 100644 index 00000000..ac0e4b51 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Workforce; +using Resgrid.Services.Workforce; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase E (E5 / E7 acceptance): the CRD math and the reviewed schema + /// profile. Two employees at $25 and $24 per hour aggregate to a $24.50 mean and median; odd and even medians; + /// zero hours block a rate; Box 1 fallback is a remarked warning; remote counts reconcile; MENA and multiracial + /// codes; the RY2025 column order; the second Wednesday of May. + /// + [TestFixture] + public class PayDataAggregatorTests + { + private static readonly CaPayDataSchemaProfile Profile = CaPayDataSchemaProfile.Current; + + private static PayDataReportEmployeeSnapshot Snapshot(string id, decimal earnings, decimal hours, string demographic = "B20", string category = "10", int workMode = (int)WorkModes.NonRemote, string establishment = "hq") => new PayDataReportEmployeeSnapshot + { + PayDataReportEmployeeSnapshotId = id, PayDataReportRunId = "run", DepartmentId = 1, WorkforceEstablishmentId = establishment, JobCategoryCode = category, DemographicCode = demographic, + PayBandCode = Profile.PayBandFor(earnings)?.Code, AnnualEarningsValue = earnings, AnnualHours = hours, AnnualWeeks = 52, HourlyRateValue = PayDataAggregator.HourlyRate(earnings, hours), WorkMode = workMode, IsIncluded = true + }; + + [Test] + public void Two_employees_at_25_and_24_per_hour_aggregate_to_a_24_50_mean_and_median() + { + var rows = PayDataAggregator.Aggregate(new[] { Snapshot("a", 52000m, 2080m), Snapshot("b", 49920m, 2080m) }, Profile); + rows.Should().HaveCount(1, "same establishment, job category, demographic code and pay band"); + var row = rows[0]; + row.EmployeeCount.Should().Be(2); + row.AnnualHours.Should().Be(4160m); + row.MeanHourlyRateValue.Should().Be(24.50m); + row.MedianHourlyRateValue.Should().Be(24.50m); + row.NonRemoteCount.Should().Be(2); + row.PayBandCode.Should().Be("6", "$49,920–$62,919"); + } + + [Test] + public void Median_handles_odd_and_even_counts_and_zero_hours_yields_no_rate() + { + PayDataAggregator.Median(new[] { 10m, 30m, 20m }).Should().Be(20m); + PayDataAggregator.Median(new[] { 10m, 20m, 30m, 40m }).Should().Be(25m); + PayDataAggregator.Mean(Array.Empty()).Should().Be(0m); + PayDataAggregator.HourlyRate(50000m, 0m).Should().BeNull("zero hours cannot produce a rate"); + PayDataAggregator.HourlyRate(null, 2080m).Should().BeNull(); + } + + [Test] + public void Snapshots_split_rows_on_job_category_demographic_and_pay_band_and_reconcile_remote_counts() + { + var snapshots = new[] + { + Snapshot("a", 52000m, 2080m), Snapshot("b", 52000m, 2080m, workMode: (int)WorkModes.RemoteWithinCalifornia), + Snapshot("c", 52000m, 2080m, demographic: "H10"), Snapshot("d", 52000m, 2080m, category: "3"), Snapshot("e", 208000m, 2080m) + }; + var rows = PayDataAggregator.Aggregate(snapshots, Profile); + rows.Should().HaveCount(4); + var first = rows.Single(r => r.DemographicCode == "B20" && r.JobCategoryCode == "10" && r.PayBandCode == "6"); + first.EmployeeCount.Should().Be(2); first.NonRemoteCount.Should().Be(1); first.RemoteWithinCaliforniaCount.Should().Be(1); + rows.Single(r => r.DemographicCode == "H10").EmployeeCount.Should().Be(1, "MENA is its own code (H) in RY2025"); + rows.Single(r => r.PayBandCode == "12").EmployeeCount.Should().Be(1, "$208,000 and over"); + var validation = new PayDataValidationResult(); + PayDataAggregator.ValidateRows(rows, snapshots, Profile, validation); + validation.Errors.Should().BeEmpty(); + + rows[0].NonRemoteCount = 0; + var broken = new PayDataValidationResult(); + PayDataAggregator.ValidateRows(rows, snapshots, Profile, broken); + broken.Errors.Should().Contain(e => e.Code == PayDataValidationCodes.RemoteCountsMismatch); + } + + [Test] + public void Demographic_codes_follow_the_profile_precedence() + { + Profile.DemographicCode("Yes", new[] { "B", "E" }, "20").Should().Be("A20", "Hispanic or Latino takes precedence"); + Profile.DemographicCode("No", new[] { "B", "E" }, "10").Should().Be("G10", "two or more races"); + Profile.DemographicCode("No", new[] { "H" }, "30").Should().Be("H30", "Middle Eastern or North African"); + Profile.DemographicCode("No", new[] { "C" }, "20").Should().Be("C20"); + Profile.DemographicCode("No", Array.Empty(), "20").Should().BeNull("no race answered"); + Profile.DemographicCode("No", new[] { "C" }, null).Should().BeNull("no sex answered"); + } + + [Test] + public void Reviewed_profile_has_the_RY2025_shape_and_the_second_Wednesday_of_May() + { + Profile.Code.Should().Be("CRD-RY2025"); + Profile.ReportingYear.Should().Be(2025); + Profile.JobCategories.Select(j => j.Code).Should().Equal("1", "2", "3", "4", "5", "6", "7", "8", "9", "10"); + Profile.PayBands.Should().HaveCount(12); + Profile.PayBands.Last().Maximum.Should().BeNull(); + Profile.RaceEthnicities.Select(r => r.Code).Should().Equal("A", "B", "C", "D", "E", "F", "G", "H"); + Profile.Sexes.Select(s => s.Code).Should().Equal("10", "20", "30"); + Profile.PayrollColumns.Select(c => c.Header).Should().StartWith(new[] { "Establishment Name", "Establishment Address", "Establishment City", "Establishment State", "Establishment Zip", "NAICS Code", "Major Activity" }); + Profile.PayrollColumns.Select(c => c.Header).Should().EndWith(new[] { "Non-Remote Employees", "Remote Employees Located in California", "Remote Employees Located Outside California", "Row-Level Clarifying Remarks" }); + Profile.LaborContractorColumns.Take(2).Select(c => c.Header).Should().Equal("Labor Contractor Name", "Labor Contractor FEIN"); + Profile.LaborContractorColumns.Count.Should().Be(Profile.PayrollColumns.Count + 2); + Profile.DueDate.Should().Be(new DateTime(2026, 5, 13)); + CaPayDataSchemaProfile.SecondWednesdayOfMay(2025).Should().Be(new DateTime(2025, 5, 14)); + Profile.IsSnapshotInWindow(new DateTime(2025, 10, 1), new DateTime(2025, 10, 31)).Should().BeTrue(); + Profile.IsSnapshotInWindow(new DateTime(2025, 9, 15), new DateTime(2025, 10, 15)).Should().BeFalse(); + Profile.IsSnapshotInWindow(new DateTime(2025, 10, 1), new DateTime(2025, 12, 1)).Should().BeFalse("longer than one pay period"); + Profile.SourceChecksum.Should().BeNull("the reviewer records the handbook checksum before the profile is used in production"); + CaPayDataReportingService.ProfileHash(Profile).Should().HaveLength(64).And.Be(CaPayDataReportingService.ProfileHash(CaPayDataSchemaProfile.ForYear(2025))); + } + + [Test] + public void Cells_render_in_template_order_and_exports_are_checksum_stable() + { + var rows = PayDataAggregator.Aggregate(new[] { Snapshot("a", 52000m, 2080m), Snapshot("b", 49920m, 2080m) }, Profile); + var establishment = new PayDataAggregator.ExportEstablishment { Id = "hq", Name = "Station 1", Address = "1 Main St", City = "Sacramento", State = "CA", Zip = "95814", Naics = "922160", MajorActivity = "Fire protection", TotalEmployees = 2, FiledPriorYear = true, IsHeadquarters = true }; + var cells = PayDataAggregator.Cells(rows[0], Profile, (int)PayDataReportTypes.PayrollEmployee, establishment, null); + cells.Should().HaveCount(Profile.PayrollColumns.Count); + cells.Take(10).Should().Equal("Station 1", "1 Main St", "Sacramento", "CA", "95814", "922160", "Fire protection", "2", "Yes", "Yes"); + cells.Skip(10).Take(7).Should().Equal("10", "B20", "6", "2", "4160", "24.50", "24.50"); + var validation = new PayDataValidationResult(); + PayDataAggregator.ValidateCells(Profile.PayrollColumns, cells, "row", validation); + validation.Errors.Should().BeEmpty(); + + var csv1 = PayDataAggregator.RenderCsv(Profile.PayrollColumns, new[] { (IReadOnlyList)cells }); + var csv2 = PayDataAggregator.RenderCsv(Profile.PayrollColumns, new[] { (IReadOnlyList)cells }); + PayDataAggregator.Sha256(csv1).Should().Be(PayDataAggregator.Sha256(csv2)); + Encoding.UTF8.GetString(csv1).Split('\n')[0].Should().StartWith("Establishment Name,Establishment Address"); + var xlsx = PayDataAggregator.RenderXlsx(Profile.PayrollColumns, new[] { (IReadOnlyList)cells }); + using var archive = new ZipArchive(new MemoryStream(xlsx), ZipArchiveMode.Read); + archive.Entries.Select(e => e.FullName).Should().Contain("xl/worksheets/sheet1.xml").And.Contain("[Content_Types].xml"); + + var contractorCells = PayDataAggregator.Cells(rows[0], Profile, (int)PayDataReportTypes.LaborContractorEmployee, establishment, new PayDataAggregator.ExportContractor { Name = "Staffing Co", Fein = "12-3456789" }); + contractorCells.Take(2).Should().Equal("Staffing Co", "12-3456789"); + contractorCells.Should().HaveCount(Profile.LaborContractorColumns.Count); + } + + [Test] + public void Annual_fact_normalisation_prefers_box_5_and_derives_reportable_hours() + { + var fact = new WorkforceAnnualPayFact { ReportType = (int)PayDataReportTypes.PayrollEmployee, W2Box5Value = 52000m, W2Box1Value = 50000m, ActualWorkedHours = 2000m, PaidLeaveHours = 80m }; + WorkforceService.Normalize(fact); + fact.EarningsUsedValue.Should().Be(52000m); + fact.EarningsSource.Should().Be((int)EarningsSources.W2Box5); + fact.ReportableHours.Should().Be(2080m); + + var fallback = new WorkforceAnnualPayFact { ReportType = (int)PayDataReportTypes.PayrollEmployee, W2Box1Value = 50000m, ExemptProxyMethod = (int)ExemptProxyMethods.DaysTimesAverageHours, DaysWorked = 250, ProxyAverageHoursPerDay = 8m }; + WorkforceService.Normalize(fallback); + fallback.EarningsSource.Should().Be((int)EarningsSources.W2Box1Fallback, "Box 1 only when Box 5 is absent, and it is remarked"); + fallback.ReportableHours.Should().Be(2000m); + + var contractor = new WorkforceAnnualPayFact { ReportType = (int)PayDataReportTypes.LaborContractorEmployee, ClientAllocatedEarningsValue = 30000m, ClientAllocatedHours = 1200m, ClientAllocatedWeeks = 30m }; + WorkforceService.Normalize(contractor); + contractor.EarningsUsedValue.Should().Be(30000m); + contractor.EarningsSource.Should().Be((int)EarningsSources.ClientAllocated); + contractor.ReportableHours.Should().Be(1200m); + contractor.WeeksWorked.Should().Be(30m); + } + + [Test] + public void Csv_import_parser_handles_quotes_and_blocking_codes_are_the_documented_set() + { + WorkforceService.ParseCsvLine("a,\"b,c\",\"d\"\"e\",").Should().Equal("a", "b,c", "d\"e", ""); + CaPayDataReportingService.IsBlocking(PayDataValidationCodes.DemographicMissing).Should().BeTrue(); + CaPayDataReportingService.IsBlocking(PayDataValidationCodes.EarningsBox1Fallback).Should().BeFalse("a warning, not a block"); + CaPayDataReportingService.IsBlocking(PayDataValidationCodes.ObserverPerceptionUsed).Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index b4a11cb0..5df3723b 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -358,6 +358,12 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "UnitCertifications", "PersonnelCertificationCredits", // Deployment core, catalog v27 (Phase C): the wrapper's internal notes, read with DeploymentProtectedFields accessors (DeploymentService.Protection.cs). "Deployments", + // Workforce pay data, field costing and California pay data reporting, catalog v28 (Phase E): read with + // WorkforceProtectedFields accessors through WorkforceProtectionSeam (grant reads and the workforce-costing / + // pay-data-reporting workload purposes); export bytes use the binary resolver. + "WorkforceEmployerProfiles", "WorkforceAffiliatedEntities", "WorkforceEstablishments", "WorkforceLaborContractors", "WorkforceWorkers", + "EmployeeCompensationProfiles", "EmployeePayComponents", "EmployeeCostComponents", "WorkforceWorkEntries", "WorkforceAnnualPayFacts", + "FieldCostLines", "PayDataReportingDemographics", "PayDataReportRuns", "PayDataReportEmployeeSnapshots", "PayDataReportRows", "PayDataExportArtifacts", // 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/WorkforceLocalizationTests.cs b/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs new file mode 100644 index 00000000..98f80115 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs @@ -0,0 +1,146 @@ +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 Resgrid.Model.Workforce; +using File = System.IO.File; + +namespace Resgrid.Tests.Services +{ + /// + /// Twin of for the workforce workspace (Workforce & Business Operations + /// plan, Phase E): every resource key a view, the controller, the services' error codes, the enum-derived labels, + /// the validation / review / import codes, the CRD race and sex codes, the Security page rows and the nav + /// reference must exist, and every supported culture must carry a complete, non-English translation. + /// + [TestFixture] + public class WorkforceLocalizationTests + { + public static IEnumerable Cultures => SupportedLocales.GetSupportedCultures(); + + [Test] + public void Workforce_views_controller_services_and_enum_labels_resolve_real_resource_keys() + { + var root = RepositoryRoot(); + var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User"); + var own = Directory.GetFiles(Path.Combine(web, "Views", "Workforce"), "*.cshtml") + .Concat(Directory.GetFiles(Path.Combine(root, "Core", "Resgrid.Services", "Workforce"), "*.cs")) + .Concat(new[] { Path.Combine(web, "Controllers", "WorkforceController.cs"), Path.Combine(web, "Views", "Shared", "_WorkforceShell.cshtml"), Path.Combine(web, "Views", "Shared", "_WorkforceMessage.cshtml") }).ToList(); + var shared = new[] { Path.Combine(web, "Views", "Security", "Index.cshtml"), Path.Combine(web, "Views", "Shared", "_Navigation.cshtml") }; + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in own.Concat(shared)) + { + var source = File.ReadAllText(file); + var pattern = own.Contains(file) + ? @"(?().Skip(1).First(g => g.Success).Value); + } + + // Keys the views build by concatenation. + foreach (var s in Enum.GetNames()) keys.Add("Coverage" + s); + foreach (var s in Enum.GetNames()) keys.Add("WorkerKind" + s); + foreach (var s in Enum.GetNames()) keys.Add("EmploymentType" + s); + foreach (var s in Enum.GetNames()) keys.Add("Exemption" + s); + foreach (var s in Enum.GetNames()) keys.Add("CaBasis" + s); + foreach (var s in Enum.GetNames()) keys.Add("WorkMode" + s); + foreach (var s in Enum.GetNames()) keys.Add("PayBasis" + s); + foreach (var s in Enum.GetNames()) keys.Add("Scope" + s); + foreach (var s in Enum.GetNames()) keys.Add("PayCategory" + s); + foreach (var s in Enum.GetNames()) keys.Add("PayComponentBasis" + s); + foreach (var s in Enum.GetNames()) keys.Add("CostCategory" + s); + foreach (var s in Enum.GetNames()) keys.Add("CostComponentBasis" + s); + foreach (var s in Enum.GetNames()) keys.Add("HoursType" + s); + foreach (var s in Enum.GetNames()) keys.Add("EarningsSource" + s); + foreach (var s in Enum.GetNames()) keys.Add("ExemptProxy" + s); + foreach (var s in Enum.GetNames()) keys.Add("ReportType" + s); + foreach (var s in Enum.GetNames()) keys.Add("SubjectType" + s); + foreach (var s in Enum.GetNames()) keys.Add("Allocation" + s); + foreach (var s in Enum.GetNames()) keys.Add("ResourceCategory" + s); + foreach (var s in Enum.GetNames()) keys.Add("ResourceBasis" + s); + foreach (var s in Enum.GetNames()) keys.Add("ResourceSource" + s); + foreach (var s in Enum.GetNames()) keys.Add("Phase" + s); + foreach (var s in Enum.GetNames()) keys.Add("UsageSource" + s); + foreach (var s in Enum.GetNames()) keys.Add("Context" + s); + foreach (var s in Enum.GetNames()) keys.Add("RunType" + s); + foreach (var s in Enum.GetNames()) keys.Add("RunStatus" + s); + foreach (var s in Enum.GetNames()) keys.Add("Revenue" + s); + foreach (var s in Enum.GetNames()) keys.Add("Category" + s); + foreach (var s in Enum.GetNames()) keys.Add("Status" + s); + foreach (var s in Enum.GetNames()) keys.Add("CollectionSource" + s); + foreach (var code in Constants(typeof(PayDataValidationCodes))) keys.Add("Validation_" + code); + foreach (var code in Constants(typeof(LaborReviewReasons)).Concat(Constants(typeof(ResourceReviewReasons)))) keys.Add("Review_" + code); + foreach (var code in new[] { "rows_not_aggregated", "required" }) keys.Add("Validation_" + code); + foreach (var code in new[] { "no_employment_for_member", "usage_needs_review", "distance_conflict" }) keys.Add("Review_" + code); + foreach (var code in new[] { "empty", "worker_not_found", "year_invalid", "employment_not_found", "duplicate_row", "earnings_missing", "hours_missing", "box1_fallback" }) keys.Add("Import_" + code); + foreach (var race in CaPayDataSchemaProfile.Current.RaceEthnicities.Where(r => r.Code != "A" && r.Code != "G")) keys.Add("Race_" + race.Code); + foreach (var sex in CaPayDataSchemaProfile.Current.Sexes) keys.Add("Sex_" + sex.Code); + foreach (var descriptor in WorkforcePermissionCatalog.All) { keys.Add(descriptor.Type.ToString()); keys.Add(descriptor.Type + "Note"); } + + var resources = Read(Path.Combine(ResourceDirectory(), "Workforce.resx")); + keys.Except(resources.Keys).Should().BeEmpty("user interfaces and errors must not show untranslated resource identifiers"); + } + + private static IEnumerable Constants(Type type) => type.GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()); + + // These values are the same in both languages; they are reviewed, not untranslated. + private static readonly Dictionary SharedSpellings = new Dictionary + { + ["de"] = new[] { "Basis", "Code", "Dba", "Downloads", "EarningsSourceW2Box5", "Fein", "Format", "Naics", "Name", "PayCategoryUsar", "Phase", "RemoteInCa", "ReportTypeLaborContractorEmployee", "ReportTypePayrollEmployee", "Sein", "Status", "UsageSourceGps", "UsageSourceImport", "Version", "W2Box1", "W2Box5" }, + ["es"] = new[] { "Dba", "Fein", "Naics", "No", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "ResourceSourceManual", "Sein", "Total", "UsageSourceGps", "UsageSourceManual" }, + ["fr"] = new[] { "CategoryPersonnel", "Code", "Date", "Dba", "Distance", "EmploymentTypeIntermittent", "Exceptions", "Exemption", "Fein", "Format", "Miles", "Naics", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "Personnel", "Phase", "PhaseIncident", "Provenance", "ResourceCategoryMaintenance", "Sein", "Source", "Total", "UsageSourceGps", "UsageSourceImport", "Validation", "Version" }, + ["it"] = new[] { "Checksum", "Dba", "EmploymentTypePartTime", "Fein", "File", "Naics", "No", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "Sein", "UsageSourceGps" }, + ["pl"] = new[] { "Dba", "Fein", "Format", "Naics", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "Sein", "Status", "UsageSourceGps", "UsageSourceImport" }, + ["sv"] = new[] { "AllocationKilometer", "AllocationMile", "Dba", "EmploymentTypeIntermittent", "Fein", "Format", "Miles", "Naics", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "ResourceBasisPerKilometer", "ResourceBasisPerMile", "Sein", "StartOn", "Status", "UsageSourceGps", "UsageSourceImport", "Version" }, + ["ar"] = new[] { "Dba", "Fein", "Naics", "PayCategoryUsar", "Sein", "UsageSourceGps" }, + ["el"] = new[] { "Dba", "Fein", "Naics", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "Sein", "UsageSourceGps" }, + ["uk"] = new[] { "Dba", "Fein", "Naics", "PayCategoryEms", "PayCategoryHazMat", "PayCategoryUsar", "Sein", "UsageSourceGps" }, + }; + + [TestCaseSource(nameof(Cultures))] + public void Supported_culture_has_complete_compiled_translations_without_English_placeholders(string culture) + { + var baseline = Read(Path.Combine(ResourceDirectory(), "Workforce.resx")); + var file = Path.Combine(ResourceDirectory(), "Workforce." + 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.Workforce.Workforce", 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", "Workforce"); + + 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/WorkforceProtectionAndEventsTests.cs b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs index 260aa66b..02862242 100644 --- a/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs @@ -11,6 +11,7 @@ using Resgrid.Model.Certifications; using Resgrid.Model.Events; using Resgrid.Model.Invoicing; +using Resgrid.Model.Workforce; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -44,7 +45,10 @@ private sealed class Grant : IProtectedGrantContext 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.Version.Should().Be(28, "Phase D rides 26, the completion pass 27 and Phase E 28; nothing is deployed on any of them yet"); + catalog.GetAll().Where(f => f.AddedInCatalogVersion == 28).Select(f => f.FieldId).Should().BeEquivalentTo(WorkforceProtectedFields.All().Select(f => f.Table.ToLowerInvariant() + "." + f.Column.ToLowerInvariant())); + foreach (var table in WorkforceProtectedFields.Tables) + AdpTableBindings.V1.Should().Contain(b => string.Equals(b.TableName, table.Table, StringComparison.OrdinalIgnoreCase), table.Table); catalog.GetAll().Where(f => f.AddedInCatalogVersion == 27).Select(f => f.FieldId).Should().BeEquivalentTo(new[] { "personnelcertifications.statusreason", "unitcertifications.statusreason", "deployments.notes" diff --git a/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs b/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs new file mode 100644 index 00000000..0fbe9937 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs @@ -0,0 +1,478 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.CostRecovery.CalOesMars; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Services.Workforce; + +namespace Resgrid.Tests.Services +{ + /// + /// Workforce & Business Operations plan Phase E (E7 acceptance): overlapping employment periods are refused; + /// compensation resolves employee → role default → department default; a deployment cost run prices approved DTR + /// hours, unit usage and expenses, takes the Cal OES MARS recovery as revenue, freezes immutably and is superseded + /// by a recalculation; the CRD wizard runs create → snapshots → aggregate → validate → freeze/export → certify and + /// a correction supersedes a frozen run; demographics stay in their own table and the costing engine never + /// depends on them. + /// + [TestFixture] + public class WorkforceServicesTests + { + private const int DeptId = 9; + private const string User = "officer"; + + private List _employers; private List _affiliates; private List _establishments; private List _contractors; + private List _workers; private List _employments; private List _assignments; private List _workEntries; private List _facts; + private List _profiles; private List _payComponents; private List _costComponents; + private List _resourceProfiles; private List _resourceComponents; private List _usage; private List _runs; private List _lines; + private List _demographics; private List _reportRuns; private List _snapshots; private List _rows; private List _artifacts; + private List _audits; private List _notifications; + private List _deployments; private List _personnel; private List _units; private List _reports; private List _entries; private List _expenses; + private List _marsItems; + private WorkforceService _workforce; private CompensationCostService _compensation; private FieldCostingService _costing; private PayDataDemographicsService _demographicsService; private CaPayDataReportingService _reporting; + + [SetUp] + public void SetUp() + { + _employers = new(); _affiliates = new(); _establishments = new(); _contractors = new(); _workers = new(); _employments = new(); _assignments = new(); _workEntries = new(); _facts = new(); + _profiles = new(); _payComponents = new(); _costComponents = new(); _resourceProfiles = new(); _resourceComponents = new(); _usage = new(); _runs = new(); _lines = new(); + _demographics = new(); _reportRuns = new(); _snapshots = new(); _rows = new(); _artifacts = new(); _audits = new(); _notifications = new(); + _deployments = new(); _personnel = new(); _units = new(); _reports = new(); _entries = new(); _expenses = new(); _marsItems = new(); + + var employers = Repo(_employers, e => e.WorkforceEmployerProfileId, (e, v) => e.WorkforceEmployerProfileId = v); + employers.Setup(r => r.GetActiveForDepartmentAsync(DeptId)).ReturnsAsync(() => _employers.FirstOrDefault(e => !e.IsDeleted && e.IsActive)); + employers.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _employers.Where(e => !e.IsDeleted).ToList()); + employers.Setup(r => r.GetDepartmentsWithActiveProfilesAsync()).ReturnsAsync(() => _employers.Where(e => !e.IsDeleted && e.IsActive).Select(e => e.DepartmentId).Distinct().ToList()); + var affiliates = Repo(_affiliates, e => e.WorkforceAffiliatedEntityId, (e, v) => e.WorkforceAffiliatedEntityId = v); + affiliates.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _affiliates.Where(e => !e.IsDeleted).ToList()); + affiliates.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _affiliates.FirstOrDefault(e => e.WorkforceAffiliatedEntityId == id)); + var establishments = Repo(_establishments, e => e.WorkforceEstablishmentId, (e, v) => e.WorkforceEstablishmentId = v); + establishments.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _establishments.Where(e => !e.IsDeleted).ToList()); + establishments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _establishments.FirstOrDefault(e => e.WorkforceEstablishmentId == id)); + var contractors = Repo(_contractors, e => e.WorkforceLaborContractorId, (e, v) => e.WorkforceLaborContractorId = v); + contractors.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _contractors.Where(e => !e.IsDeleted).ToList()); + contractors.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _contractors.FirstOrDefault(e => e.WorkforceLaborContractorId == id)); + var workers = Repo(_workers, e => e.WorkforceWorkerId, (e, v) => e.WorkforceWorkerId = v); + workers.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _workers.Where(e => !e.IsDeleted).ToList()); + workers.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _workers.FirstOrDefault(e => e.WorkforceWorkerId == id)); + workers.Setup(r => r.GetByUserIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string u) => _workers.FirstOrDefault(e => !e.IsDeleted && e.UserId == u)); + var employments = Repo(_employments, e => e.WorkforceEmploymentId, (e, v) => e.WorkforceEmploymentId = v); + employments.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _employments.Where(e => !e.IsDeleted).ToList()); + employments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _employments.FirstOrDefault(e => e.WorkforceEmploymentId == id)); + employments.Setup(r => r.GetByWorkerAsync(It.IsAny())).ReturnsAsync((string w) => _employments.Where(e => !e.IsDeleted && e.WorkforceWorkerId == w).ToList()); + employments.Setup(r => r.GetActiveInWindowAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync((int _, DateTime f, DateTime t) => _employments.Where(e => !e.IsDeleted && e.Covers(f, t)).ToList()); + var assignments = Repo(_assignments, e => e.WorkforceJobAssignmentId, (e, v) => e.WorkforceJobAssignmentId = v); + assignments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _assignments.FirstOrDefault(e => e.WorkforceJobAssignmentId == id)); + assignments.Setup(r => r.GetByEmploymentAsync(It.IsAny())).ReturnsAsync((string e) => _assignments.Where(a => !a.IsDeleted && a.WorkforceEmploymentId == e).ToList()); + assignments.Setup(r => r.GetByEmploymentsAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => { var set = ids.ToHashSet(); return _assignments.Where(a => !a.IsDeleted && set.Contains(a.WorkforceEmploymentId)).ToList(); }); + var workEntries = Repo(_workEntries, e => e.WorkforceWorkEntryId, (e, v) => e.WorkforceWorkEntryId = v); + workEntries.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _workEntries.FirstOrDefault(e => e.WorkforceWorkEntryId == id)); + workEntries.Setup(r => r.GetByExternalIdAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync((int _, string s, string id) => _workEntries.FirstOrDefault(e => e.ExternalSource == s && e.ExternalId == id)); + workEntries.Setup(r => r.GetForDepartmentInWindowAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync((int _, DateTime f, DateTime t) => _workEntries.Where(e => !e.IsDeleted && e.WorkDate >= f && e.WorkDate <= t).ToList()); + workEntries.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _workEntries.Where(e => e.DeploymentId == d).ToList()); + workEntries.Setup(r => r.GetByCallAsync(It.IsAny())).ReturnsAsync((int c) => _workEntries.Where(e => e.CallId == c).ToList()); + var facts = Repo(_facts, e => e.WorkforceAnnualPayFactId, (e, v) => e.WorkforceAnnualPayFactId = v); + facts.Setup(r => r.GetForYearAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync((int _, int y, int t) => _facts.Where(f => !f.IsDeleted && f.ReportingYear == y && f.ReportType == t).ToList()); + facts.Setup(r => r.GetByEmploymentAsync(It.IsAny())).ReturnsAsync((string e) => _facts.Where(f => f.WorkforceEmploymentId == e).ToList()); + var profiles = Repo(_profiles, e => e.EmployeeCompensationProfileId, (e, v) => e.EmployeeCompensationProfileId = v); + profiles.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _profiles.FirstOrDefault(e => e.EmployeeCompensationProfileId == id)); + profiles.Setup(r => r.GetByEmploymentAsync(It.IsAny())).ReturnsAsync((string e) => _profiles.Where(p => p.WorkforceEmploymentId == e).ToList()); + profiles.Setup(r => r.GetByEmploymentsAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => { var set = ids.ToHashSet(); return _profiles.Where(p => set.Contains(p.WorkforceEmploymentId)).ToList(); }); + profiles.Setup(r => r.GetDefaultsForDepartmentAsync(DeptId)).ReturnsAsync(() => _profiles.Where(p => p.Scope != (int)CompensationScopes.Employee).ToList()); + var payComponents = Repo(_payComponents, e => e.EmployeePayComponentId, (e, v) => e.EmployeePayComponentId = v); + payComponents.Setup(r => r.GetByProfileAsync(It.IsAny())).ReturnsAsync((string p) => _payComponents.Where(c => c.EmployeeCompensationProfileId == p).ToList()); + payComponents.Setup(r => r.GetByProfilesAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => { var set = ids.ToHashSet(); return _payComponents.Where(c => set.Contains(c.EmployeeCompensationProfileId)).ToList(); }); + var costComponents = Repo(_costComponents, e => e.EmployeeCostComponentId, (e, v) => e.EmployeeCostComponentId = v); + costComponents.Setup(r => r.GetByProfileAsync(It.IsAny())).ReturnsAsync((string p) => _costComponents.Where(c => c.EmployeeCompensationProfileId == p).ToList()); + costComponents.Setup(r => r.GetByProfilesAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => { var set = ids.ToHashSet(); return _costComponents.Where(c => set.Contains(c.EmployeeCompensationProfileId)).ToList(); }); + var resourceProfiles = Repo(_resourceProfiles, e => e.ResourceCostProfileId, (e, v) => e.ResourceCostProfileId = v); + resourceProfiles.Setup(r => r.GetForDepartmentAsync(DeptId)).ReturnsAsync(() => _resourceProfiles.ToList()); + resourceProfiles.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _resourceProfiles.FirstOrDefault(e => e.ResourceCostProfileId == id)); + var resourceComponents = Repo(_resourceComponents, e => e.ResourceCostComponentId, (e, v) => e.ResourceCostComponentId = v); + resourceComponents.Setup(r => r.GetByProfileAsync(It.IsAny())).ReturnsAsync((string p) => _resourceComponents.Where(c => c.ResourceCostProfileId == p).ToList()); + resourceComponents.Setup(r => r.GetByProfilesAsync(It.IsAny>())).ReturnsAsync((IEnumerable ids) => { var set = ids.ToHashSet(); return _resourceComponents.Where(c => set.Contains(c.ResourceCostProfileId)).ToList(); }); + var usage = Repo(_usage, e => e.ResourceUsageEntryId, (e, v) => e.ResourceUsageEntryId = v); + usage.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _usage.FirstOrDefault(e => e.ResourceUsageEntryId == id)); + usage.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _usage.Where(u => u.DeploymentId == d).ToList()); + usage.Setup(r => r.GetByCallAsync(It.IsAny())).ReturnsAsync((int c) => _usage.Where(u => u.CallId == c).ToList()); + var runs = Repo(_runs, e => e.FieldCostRunId, (e, v) => e.FieldCostRunId = v); + runs.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _runs.FirstOrDefault(e => e.FieldCostRunId == id)); + runs.Setup(r => r.GetByDeploymentAsync(It.IsAny(), DeptId)).ReturnsAsync((string d, int _) => _runs.Where(x => x.DeploymentId == d).ToList()); + runs.Setup(r => r.GetByBidAsync(It.IsAny(), DeptId)).ReturnsAsync((string b, int _) => _runs.Where(x => x.BidId == b).ToList()); + runs.Setup(r => r.GetByCallAsync(It.IsAny(), DeptId)).ReturnsAsync((int c, int _) => _runs.Where(x => x.CallId == c).ToList()); + runs.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny(), It.IsAny())).ReturnsAsync(() => _runs.ToList()); + var lines = Repo(_lines, e => e.FieldCostLineId, (e, v) => e.FieldCostLineId = v); + lines.Setup(r => r.GetByRunAsync(It.IsAny())).ReturnsAsync((string run) => _lines.Where(l => l.FieldCostRunId == run).ToList()); + lines.Setup(r => r.DeleteByRunAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string run, CancellationToken _) => _lines.RemoveAll(l => l.FieldCostRunId == run) > 0); + var demographics = Repo(_demographics, e => e.PayDataReportingDemographicId, (e, v) => e.PayDataReportingDemographicId = v); + demographics.Setup(r => r.GetCurrentForWorkerAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string w, DateTime d) => _demographics.Where(x => !x.IsDeleted && x.WorkforceWorkerId == w && x.EffectiveOn <= d && (!x.ExpiresOn.HasValue || x.ExpiresOn >= d)).OrderByDescending(x => x.Version).FirstOrDefault()); + demographics.Setup(r => r.GetCurrentForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync((int _, DateTime d) => _demographics.Where(x => !x.IsDeleted && x.EffectiveOn <= d && (!x.ExpiresOn.HasValue || x.ExpiresOn >= d)).ToList()); + var reportRuns = Repo(_reportRuns, e => e.PayDataReportRunId, (e, v) => e.PayDataReportRunId = v); + reportRuns.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _reportRuns.FirstOrDefault(e => e.PayDataReportRunId == id)); + reportRuns.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int? y) => _reportRuns.Where(x => !y.HasValue || x.ReportingYear == y).ToList()); + reportRuns.Setup(r => r.GetDepartmentsWithRunsAsync(It.IsAny())).ReturnsAsync((int y) => _reportRuns.Where(x => x.ReportingYear == y).Select(x => x.DepartmentId).Distinct().ToList()); + var snapshots = Repo(_snapshots, e => e.PayDataReportEmployeeSnapshotId, (e, v) => e.PayDataReportEmployeeSnapshotId = v); + snapshots.Setup(r => r.GetByRunAsync(It.IsAny())).ReturnsAsync((string run) => _snapshots.Where(s => s.PayDataReportRunId == run).ToList()); + snapshots.Setup(r => r.DeleteByRunAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string run, CancellationToken _) => _snapshots.RemoveAll(s => s.PayDataReportRunId == run) > 0); + var rows = Repo(_rows, e => e.PayDataReportRowId, (e, v) => e.PayDataReportRowId = v); + rows.Setup(r => r.GetByRunAsync(It.IsAny())).ReturnsAsync((string run) => _rows.Where(s => s.PayDataReportRunId == run).ToList()); + rows.Setup(r => r.DeleteByRunAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string run, CancellationToken _) => _rows.RemoveAll(s => s.PayDataReportRunId == run) > 0); + var artifacts = Repo(_artifacts, e => e.PayDataExportArtifactId, (e, v) => e.PayDataExportArtifactId = v); + artifacts.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _artifacts.FirstOrDefault(e => e.PayDataExportArtifactId == id)); + artifacts.Setup(r => r.GetByRunAsync(It.IsAny())).ReturnsAsync((string run) => _artifacts.Where(a => a.PayDataReportRunId == run).ToList()); + artifacts.Setup(r => r.GetExpiredUnpurgedAsync(It.IsAny())).ReturnsAsync((DateTime d) => _artifacts.Where(a => !a.PurgedOn.HasValue && a.ExpiresOn <= d).ToList()); + + var deployments = new Mock(); + deployments.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _deployments.FirstOrDefault(d => d.DeploymentId == id)); + var personnel = new Mock(); + personnel.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _personnel.Where(p => p.DeploymentId == d).ToList()); + var units = new Mock(); + units.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _units.Where(p => p.DeploymentId == d).ToList()); + var reports = new Mock(); + reports.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _reports.Where(p => p.DeploymentId == d).ToList()); + var entries = new Mock(); + entries.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _entries.Where(p => p.DeploymentId == d).ToList()); + var expenses = new Mock(); + expenses.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string d) => _expenses.Where(p => p.DeploymentId == d).ToList()); + var invoices = new Mock(); + invoices.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny())).ReturnsAsync(new List()); + var bids = new Mock(); var bidLines = new Mock(); + var mars = new Mock(); + mars.Setup(m => m.GetWorkItemsForDeploymentAsync(It.IsAny(), DeptId)).ReturnsAsync((string d, int _) => _marsItems.Where(w => w.DeploymentId == d).ToList()); + var unitsService = new Mock(); + unitsService.Setup(u => u.GetUnitsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Unit { UnitId = 5, DepartmentId = DeptId, Name = "Engine 1" } }); + unitsService.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = DeptId, Name = "Engine 1" }); + var userProfiles = new Mock(); + userProfiles.Setup(p => p.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync((List ids) => ids.Select(id => new UserProfile { UserId = id, FirstName = "Member", LastName = id }).ToList()); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Dept" }); + departments.Setup(d => d.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + var communication = new Mock(); + communication.Setup(c => c.SendNotificationAsync(It.IsAny(), DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string u, int d, string m, string n, Department dep, string t, UserProfile p, bool ic) => { _notifications.Add(m); return true; }); + var events = new Mock(); + events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); + + _workforce = new WorkforceService(employers.Object, affiliates.Object, establishments.Object, contractors.Object, workers.Object, employments.Object, assignments.Object, workEntries.Object, facts.Object, userProfiles.Object, events.Object); + _compensation = new CompensationCostService(profiles.Object, payComponents.Object, costComponents.Object, employments.Object, assignments.Object, events.Object); + _costing = new FieldCostingService(resourceProfiles.Object, resourceComponents.Object, usage.Object, runs.Object, lines.Object, workers.Object, employments.Object, workEntries.Object, _compensation, + bids.Object, bidLines.Object, deployments.Object, personnel.Object, units.Object, reports.Object, entries.Object, expenses.Object, invoices.Object, new Lazy(() => mars.Object), unitsService.Object, events.Object); + _demographicsService = new PayDataDemographicsService(demographics.Object, workers.Object, employments.Object, _workforce, events.Object); + _reporting = new CaPayDataReportingService(reportRuns.Object, snapshots.Object, rows.Object, artifacts.Object, employers.Object, affiliates.Object, establishments.Object, contractors.Object, workers.Object, employments.Object, assignments.Object, + workEntries.Object, facts.Object, demographics.Object, userProfiles.Object, departments.Object, new Lazy(() => communication.Object), null, events.Object); + } + + 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; }); + return mock; + } + + private async Task<(WorkforceWorker Worker, WorkforceEmployment Employment, WorkforceEstablishment Establishment)> SeedWorkerAsync(string userId = "u1", int? roleId = null) + { + var establishment = _establishments.FirstOrDefault() ?? await _workforce.SaveEstablishmentAsync(new WorkforceEstablishment { DepartmentId = DeptId, Code = "HQ", Name = "Station 1", PhysicalAddress = "1 Main St", City = "Sacramento", StateCode = "CA", PostalCode = "95814", Naics = "922160", MajorActivity = "Fire protection", IsHeadquarters = true }, User, null, null); + var worker = await _workforce.GetOrCreateWorkerForUserAsync(DeptId, userId, User); + var employment = await _workforce.SaveEmploymentAsync(new WorkforceEmployment { DepartmentId = DeptId, WorkforceWorkerId = worker.WorkforceWorkerId, WorkerKind = (int)WorkerKinds.PayrollEmployee, StartOn = new DateTime(2024, 1, 1), EmploymentType = (int)EmploymentTypes.FullTime, ExemptionStatus = (int)ExemptionStatuses.NonExempt, CaliforniaEmployeeBasis = (int)CaliforniaEmployeeBases.Both, DefaultEstablishmentId = establishment.WorkforceEstablishmentId, PersonnelRoleId = roleId }, User, null, null); + await _workforce.SaveJobAssignmentAsync(new WorkforceJobAssignment { DepartmentId = DeptId, WorkforceEmploymentId = employment.WorkforceEmploymentId, EffectiveOn = new DateTime(2024, 1, 1), JobTitle = "Firefighter", JobCategoryCode = "10", WorkforceEstablishmentId = establishment.WorkforceEstablishmentId, WorkMode = (int)WorkModes.NonRemote, WorkCountry = "US", WorkSubdivision = "CA" }, User, null, null); + return (worker, employment, establishment); + } + + [Test] + public async Task Employment_periods_and_job_assignments_never_overlap() + { + var (worker, employment, establishment) = await SeedWorkerAsync(); + Func overlap = () => _workforce.SaveEmploymentAsync(new WorkforceEmployment { DepartmentId = DeptId, WorkforceWorkerId = worker.WorkforceWorkerId, WorkerKind = (int)WorkerKinds.PayrollEmployee, StartOn = new DateTime(2025, 6, 1) }, User, null, null); + await overlap.Should().ThrowAsync().WithMessage("workforce_employment_overlap"); + Func assignment = () => _workforce.SaveJobAssignmentAsync(new WorkforceJobAssignment { DepartmentId = DeptId, WorkforceEmploymentId = employment.WorkforceEmploymentId, EffectiveOn = new DateTime(2025, 1, 1), JobCategoryCode = "2", WorkforceEstablishmentId = establishment.WorkforceEstablishmentId }, User, null, null); + await assignment.Should().ThrowAsync().WithMessage("workforce_assignment_overlap"); + Func unknownCategory = () => _workforce.SaveJobAssignmentAsync(new WorkforceJobAssignment { DepartmentId = DeptId, WorkforceEmploymentId = employment.WorkforceEmploymentId, EffectiveOn = new DateTime(2030, 1, 1), JobCategoryCode = "99" }, User, null, null); + await unknownCategory.Should().ThrowAsync().WithMessage("workforce_job_category_invalid"); + // A closed period followed by a new one is fine. + employment.EndOn = new DateTime(2025, 12, 31); + await _workforce.SaveEmploymentAsync(employment, User, null, null); + var next = await _workforce.SaveEmploymentAsync(new WorkforceEmployment { DepartmentId = DeptId, WorkforceWorkerId = worker.WorkforceWorkerId, WorkerKind = (int)WorkerKinds.PayrollEmployee, StartOn = new DateTime(2026, 1, 1) }, User, null, null); + next.WorkforceEmploymentId.Should().NotBe(employment.WorkforceEmploymentId); + (await _workforce.GetEmploymentsForWorkerAsync(worker.WorkforceWorkerId, DeptId)).Should().HaveCount(2); + _audits.Should().Contain(a => a.Type == AuditLogTypes.WorkforceEmploymentChanged); + } + + [Test] + public async Task Compensation_resolves_employee_then_role_default_then_department_default_and_prices_the_fixture() + { + var (_, employment, _) = await SeedWorkerAsync(roleId: 3); + var department = await _compensation.SaveProfileAsync(new EmployeeCompensationProfile { DepartmentId = DeptId, Scope = (int)CompensationScopes.DepartmentDefault, PayBasis = (int)PayBases.Hourly, BaseAmountValue = 20m, EffectiveOn = new DateTime(2024, 1, 1) }, User, null, null); + await _compensation.ApproveProfileAsync(department.EmployeeCompensationProfileId, DeptId, User, null, null); + var resolved = await _compensation.ResolveProfileAsync(employment.WorkforceEmploymentId, null, DeptId, new DateTime(2026, 6, 1)); + resolved.Profile.Scope.Should().Be((int)CompensationScopes.DepartmentDefault); resolved.IsFallback.Should().BeTrue(); + + var role = await _compensation.SaveProfileAsync(new EmployeeCompensationProfile { DepartmentId = DeptId, Scope = (int)CompensationScopes.RoleDefault, PersonnelRoleId = 3, PayBasis = (int)PayBases.Hourly, BaseAmountValue = 25m, EffectiveOn = new DateTime(2024, 1, 1) }, User, null, null); + await _compensation.ApproveProfileAsync(role.EmployeeCompensationProfileId, DeptId, User, null, null); + resolved = await _compensation.ResolveProfileAsync(employment.WorkforceEmploymentId, null, DeptId, new DateTime(2026, 6, 1)); + resolved.Profile.Scope.Should().Be((int)CompensationScopes.RoleDefault, "the employment's personnel role picks the role default"); + + var employee = await _compensation.SaveProfileAsync(new EmployeeCompensationProfile { DepartmentId = DeptId, Scope = (int)CompensationScopes.Employee, WorkforceEmploymentId = employment.WorkforceEmploymentId, PayBasis = (int)PayBases.Hourly, BaseAmountValue = 30m, EffectiveOn = new DateTime(2024, 1, 1), RateMultipliersJson = "{\"Overtime\":1.5}" }, User, null, null); + await _compensation.SaveComponentsAsync(employee.EmployeeCompensationProfileId, DeptId, + new List { new EmployeePayComponent { Category = (int)PayComponentCategories.Ems, Basis = (int)PayComponentBases.PerHour, AmountValue = 4m, PaidForEachOvertimeHour = true } }, + new List { new EmployeeCostComponent { Category = (int)CostComponentCategories.EmployerPayrollTax, Basis = (int)CostComponentBases.PercentOfEligiblePay, RateAmountValue = 35m } }, User, null, null); + await _compensation.ApproveProfileAsync(employee.EmployeeCompensationProfileId, DeptId, User, null, null); + resolved = await _compensation.ResolveProfileAsync(employment.WorkforceEmploymentId, null, DeptId, new DateTime(2026, 6, 1)); + resolved.Profile.Scope.Should().Be((int)CompensationScopes.Employee); resolved.IsFallback.Should().BeFalse(); + resolved.Profile.PayComponents.Should().HaveCount(1); resolved.Profile.CostComponents.Should().HaveCount(1); + + var regular = await _compensation.CalculateLoadedCostAsync(DeptId, new LaborWorkQuantity { WorkforceEmploymentId = employment.WorkforceEmploymentId, PayCode = (int)PayCodes.Regular, Hours = 8 }, null, new DateTime(2026, 6, 1)); + var overtime = await _compensation.CalculateLoadedCostAsync(DeptId, new LaborWorkQuantity { WorkforceEmploymentId = employment.WorkforceEmploymentId, PayCode = (int)PayCodes.Overtime, Hours = 4 }, null, new DateTime(2026, 6, 1)); + (regular.LoadedCost + overtime.LoadedCost).Should().Be(631.8m); + regular.NeedsReview.Should().BeFalse(); + + // Editing a rate removes the approval; the Salary Survey aggregate never returns an individual. + var edit = employee.CloneJson(); + edit.BaseAmountValue = 31m; + var edited = await _compensation.SaveProfileAsync(edit, User, null, null); + edited.IsApproved.Should().BeFalse(); + (await _compensation.GetClassificationRateAggregateAsync(DeptId, new DateTime(2026, 6, 1), null)).Should().BeEmpty("no assignment carries a MARS classification and unapproved profiles never feed the survey"); + } + + [Test] + public async Task Deployment_cost_run_prices_dtr_hours_usage_and_expenses_takes_mars_recovery_as_revenue_and_freezes_immutably() + { + var (worker, employment, _) = await SeedWorkerAsync(); + var profile = await _compensation.SaveProfileAsync(new EmployeeCompensationProfile { DepartmentId = DeptId, Scope = (int)CompensationScopes.Employee, WorkforceEmploymentId = employment.WorkforceEmploymentId, PayBasis = (int)PayBases.Hourly, BaseAmountValue = 30m, EffectiveOn = new DateTime(2024, 1, 1) }, User, null, null); + await _compensation.ApproveProfileAsync(profile.EmployeeCompensationProfileId, DeptId, User, null, null); + var engine = await _costing.SaveResourceProfileAsync(new ResourceCostProfile { DepartmentId = DeptId, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = 5, Name = "Engine 1", AcquisitionCost = 60000m, SalvageValue = 12000m, UsefulLifeQuantity = 40000m, AllocationBasis = (int)AllocationBases.Mile, EffectiveOn = new DateTime(2024, 1, 1), IsApproved = true }, User, null, null); + await _costing.SaveResourceComponentsAsync(engine.ResourceCostProfileId, DeptId, new List { new ResourceCostComponent { Category = (int)ResourceCostCategories.FuelEnergy, Basis = (int)ResourceCostBases.PerMile, Rate = 1m, IsApproved = true } }, User, null, null); + + _deployments.Add(new Deployment { DeploymentId = "dep", DepartmentId = DeptId, Name = "Fire", Currency = "USD", StartOn = new DateTime(2026, 8, 1) }); + _personnel.Add(new DeploymentPersonnel { DeploymentPersonnelId = "p1", DeploymentId = "dep", DepartmentId = DeptId, UserId = "u1", CallSign = "FF1" }); + _units.Add(new DeploymentUnit { DeploymentUnitId = "du1", DeploymentId = "dep", DepartmentId = DeptId, UnitId = 5 }); + _reports.Add(new DeploymentTimeReport { DeploymentTimeReportId = "dtr1", DeploymentId = "dep", DepartmentId = DeptId, ReportDate = new DateTime(2026, 8, 1), Status = (int)DeploymentTimeReportStatuses.Approved }); + _reports.Add(new DeploymentTimeReport { DeploymentTimeReportId = "dtr2", DeploymentId = "dep", DepartmentId = DeptId, ReportDate = new DateTime(2026, 8, 2), Status = (int)DeploymentTimeReportStatuses.Draft }); + _entries.Add(new DeploymentTimeEntry { DeploymentTimeEntryId = "e1", DeploymentTimeReportId = "dtr1", DeploymentId = "dep", DepartmentId = DeptId, SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, DeploymentPersonnelId = "p1", EntryType = (int)DeploymentTimeEntryTypes.Deployment, StartTime = new DateTime(2026, 8, 1, 6, 0, 0), EndTime = new DateTime(2026, 8, 1, 18, 0, 0) }); + _entries.Add(new DeploymentTimeEntry { DeploymentTimeEntryId = "e2", DeploymentTimeReportId = "dtr1", DeploymentId = "dep", DepartmentId = DeptId, SubjectType = (int)DeploymentTimeSubjectTypes.Unit, DeploymentUnitId = "du1", EntryType = (int)DeploymentTimeEntryTypes.Deployment, StartTime = new DateTime(2026, 8, 1, 6, 0, 0), EndTime = new DateTime(2026, 8, 1, 18, 0, 0) }); + _entries.Add(new DeploymentTimeEntry { DeploymentTimeEntryId = "e3", DeploymentTimeReportId = "dtr2", DeploymentId = "dep", DepartmentId = DeptId, SubjectType = (int)DeploymentTimeSubjectTypes.Personnel, DeploymentPersonnelId = "p1", EntryType = (int)DeploymentTimeEntryTypes.Deployment, StartTime = new DateTime(2026, 8, 2, 6, 0, 0), EndTime = new DateTime(2026, 8, 2, 18, 0, 0) }); + await _costing.SaveUsageEntryAsync(new ResourceUsageEntry { DepartmentId = DeptId, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = 5, DeploymentId = "dep", UsageDate = new DateTime(2026, 8, 1), Phase = (int)UsagePhases.Incident, StartOdometer = 1000, EndOdometer = 1100, DistanceUnit = "mi", Source = (int)UsageSources.Manual }, User, null, null); + _expenses.Add(new DeploymentExpense { DeploymentExpenseId = "x1", DeploymentId = "dep", DepartmentId = DeptId, ExpenseDate = new DateTime(2026, 8, 1), ExpenseType = 0, Description = "Meals", Amount = 45m }); + _marsItems.Add(new CalOesMarsWorkItem { CalOesMarsWorkItemId = "w1", DepartmentId = DeptId, DeploymentId = "dep", RecordType = (int)CalOesMarsRecordTypes.F42, ExpectedTotal = 900m, ApprovedTotal = 850m, RowVersion = 2 }); + + var run = await _costing.CalculateDeploymentCostAsync("dep", DeptId, new DateTime(2026, 8, 31), RevenueSources.CalOesMarsApproved, User, null, null); + // 12 DTR hours: 8 regular × $30 + 4 overtime × $45 = $420 (the draft report on the 2nd is excluded). + run.PersonnelTotal.Should().Be(420m); + // 100 miles × $1.20 depreciation + 100 miles × $1 fuel. + run.ResourceTotal.Should().Be(220m); + run.ExpenseTotal.Should().Be(45m); + run.TotalLoadedCost.Should().Be(685m); + run.RevenueSource.Should().Be((int)RevenueSources.CalOesMarsApproved); + run.RevenueAmount.Should().Be(850m); + run.ContributionMargin.Should().Be(165m); + run.BreakEvenRevenue.Should().Be(685m); + run.Status.Should().Be((int)FieldCostRunStatuses.Draft); + run.Lines.Where(l => l.Category == (int)FieldCostCategories.Personnel).Should().OnlyContain(l => l.Rate == null, "personnel lines carry no rate in the clear"); + run.Lines.Where(l => l.Category == (int)FieldCostCategories.Personnel).Should().OnlyContain(l => l.ProtectedDetailJson.Contains("\"BaseRate\":30"), "the priced detail rides the protected column"); + run.Lines.Should().Contain(l => l.Category == (int)FieldCostCategories.Resource && l.Component == "Depreciation" && l.Amount == 120m); + + var summary = await _costing.GetFieldCostSummaryAsync(run.FieldCostRunId, DeptId); + summary.TotalLoadedCost.Should().Be(685m); + summary.GetType().GetProperty("Lines").Should().BeNull("the mobile summary never carries lines"); + + var frozen = await _costing.FreezeCostRunAsync(run.FieldCostRunId, DeptId, User, null, null); + frozen.IsFrozen.Should().BeTrue(); + Func delete = () => _costing.DeleteRunAsync(run.FieldCostRunId, DeptId, User, null, null); + await delete.Should().ThrowAsync().WithMessage("workforce_run_frozen"); + + var again = await _costing.CalculateDeploymentCostAsync("dep", DeptId, new DateTime(2026, 8, 31), RevenueSources.CalOesMarsExpected, User, null, null); + again.SupersedesRunId.Should().Be(run.FieldCostRunId); + again.RevenueAmount.Should().Be(900m); + (await _costing.GetRunAsync(run.FieldCostRunId, DeptId)).Status.Should().Be((int)FieldCostRunStatuses.Frozen, "a frozen run is only marked superseded once its successor freezes"); + await _costing.FreezeCostRunAsync(again.FieldCostRunId, DeptId, User, null, null); + (await _costing.GetRunAsync(run.FieldCostRunId, DeptId)).Status.Should().Be((int)FieldCostRunStatuses.Superseded); + _audits.Should().Contain(a => a.Type == AuditLogTypes.FieldCostRunFrozen); + } + + [Test] + public async Task Usage_readings_canonicalise_and_conflicting_automatic_and_manual_distances_queue_for_review() + { + _deployments.Add(new Deployment { DeploymentId = "dep", DepartmentId = DeptId, Name = "Fire" }); + var manual = await _costing.SaveUsageEntryAsync(new ResourceUsageEntry { DepartmentId = DeptId, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = 5, DeploymentId = "dep", UsageDate = new DateTime(2026, 8, 1), OriginalDistance = 100, DistanceUnit = "km", Source = (int)UsageSources.Manual }, User, null, null); + manual.CanonicalDistanceMiles.Should().Be(62.14m); + manual.NeedsReview.Should().BeFalse(); + var gps = await _costing.SaveUsageEntryAsync(new ResourceUsageEntry { DepartmentId = DeptId, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = 5, DeploymentId = "dep", UsageDate = new DateTime(2026, 8, 1), OriginalDistance = 90, DistanceUnit = "mi", Source = (int)UsageSources.Gps }, User, null, null); + gps.NeedsReview.Should().BeTrue(); + gps.ReviewReason.Should().Be("distance_conflict"); + (await _costing.GetUsageForDeploymentAsync("dep", DeptId)).Should().OnlyContain(u => u.NeedsReview); + } + + private async Task SeedReportAsync() + { + await _workforce.SaveEmployerProfileAsync(new WorkforceEmployerProfile { DepartmentId = DeptId, LegalName = "Test Fire District", Fein = "12-3456789", Sein = "123-4567-8", EddAddress = "1 Main St, Sacramento CA 95814", Naics = "922160", CoverageStatus = (int)CaliforniaPayDataCoverageStatuses.CoveredPayroll, UsEmployeeCount = 120, CaliforniaEmployeeCount = 120, FilingContactName = "Officer", FilingContactEmail = "officer@example.org" }, User, null, null); + var (a, ea, _) = await SeedWorkerAsync("u1"); + var (b, eb, _) = await SeedWorkerAsync("u2"); + await _demographicsService.SaveOwnAsync(DeptId, "u1", new PayDataReportingDemographic { HispanicLatino = "No", RaceEthnicityCodes = "B", SexCode = "20" }, null, null); + await _demographicsService.SaveOwnAsync(DeptId, "u2", new PayDataReportingDemographic { HispanicLatino = "No", RaceEthnicityCodes = "B", SexCode = "20" }, null, null); + await _workforce.SaveAnnualPayFactAsync(new WorkforceAnnualPayFact { DepartmentId = DeptId, WorkforceEmploymentId = ea.WorkforceEmploymentId, ReportingYear = 2025, ReportType = (int)PayDataReportTypes.PayrollEmployee, W2Box5Value = 52000m, ActualWorkedHours = 2080m, WeeksWorked = 52, IsApproved = true }, User, null, null); + await _workforce.SaveAnnualPayFactAsync(new WorkforceAnnualPayFact { DepartmentId = DeptId, WorkforceEmploymentId = eb.WorkforceEmploymentId, ReportingYear = 2025, ReportType = (int)PayDataReportTypes.PayrollEmployee, W2Box5Value = 49920m, ActualWorkedHours = 2080m, WeeksWorked = 52, IsApproved = true }, User, null, null); + return await _reporting.CreateRunAsync(DeptId, 2025, PayDataReportTypes.PayrollEmployee, new DateTime(2025, 10, 1), new DateTime(2025, 10, 31), User, null, null); + } + + [Test] + public async Task Report_run_walks_create_snapshots_aggregate_validate_freeze_export_and_certify_and_a_correction_supersedes_it() + { + var run = await SeedReportAsync(); + run.Status.Should().Be((int)PayDataReportRunStatuses.Draft); + run.SchemaProfileCode.Should().Be("CRD-RY2025"); + Func early = () => _reporting.AggregateRowsAsync(run.PayDataReportRunId, DeptId, User, null, null); + await early.Should().ThrowAsync().WithMessage("paydata_no_snapshots"); + + run = await _reporting.BuildEmployeeSnapshotsAsync(run.PayDataReportRunId, DeptId, User, null, null); + run.EmployeeCount.Should().Be(2); run.ExceptionCount.Should().Be(0); + var snapshots = await _reporting.GetSnapshotsAsync(run.PayDataReportRunId, DeptId); + snapshots.Should().OnlyContain(s => s.DemographicCode == "B20" && s.PayBandCode == "6" && s.JobCategoryCode == "10" && s.IsIncluded); + snapshots.Select(s => s.HourlyRateValue).Should().BeEquivalentTo(new decimal?[] { 25m, 24m }); + snapshots.Should().OnlyContain(s => s.WorkerDisplayName.StartsWith("Member")); + + run = await _reporting.AggregateRowsAsync(run.PayDataReportRunId, DeptId, User, null, null); + run.RowCount.Should().Be(1); + var rows = await _reporting.GetRowsAsync(run.PayDataReportRunId, DeptId); + rows.Single().MeanHourlyRateValue.Should().Be(24.5m); rows.Single().MedianHourlyRateValue.Should().Be(24.5m); rows.Single().EmployeeCount.Should().Be(2); + + var validation = await _reporting.ValidateRunAsync(run.PayDataReportRunId, DeptId, User, null, null); + validation.Errors.Should().BeEmpty(); validation.CanFreeze.Should().BeTrue(); + (await _reporting.GetRunAsync(run.PayDataReportRunId, DeptId)).Status.Should().Be((int)PayDataReportRunStatuses.Validated); + + run = await _reporting.FreezeAndExportAsync(run.PayDataReportRunId, DeptId, User, null, null); + run.Status.Should().Be((int)PayDataReportRunStatuses.Exported); + run.IsFrozen.Should().BeTrue(); + var artifacts = await _reporting.GetArtifactsAsync(run.PayDataReportRunId, DeptId); + artifacts.Select(a => a.Format).Should().BeEquivalentTo(new[] { (int)PayDataExportFormats.Csv, (int)PayDataExportFormats.Xlsx }); + artifacts.Should().OnlyContain(a => a.Data == null, "listings never carry the bytes"); + var csv = await _reporting.DownloadArtifactAsync(artifacts.Single(a => a.Format == (int)PayDataExportFormats.Csv).PayDataExportArtifactId, DeptId, User, null, null); + var text = System.Text.Encoding.UTF8.GetString(csv.Data); + text.Should().StartWith("Establishment Name,Establishment Address"); + text.Should().Contain("Station 1,1 Main St,Sacramento,CA,95814,922160,Fire protection,2,No,Yes,10,B20,6,2,4160,24.50,24.50,2,0,0,"); + csv.Checksum.Should().Be(run.CertifiedArtifactChecksum); + _audits.Should().Contain(a => a.Type == AuditLogTypes.PayDataReportExported && a.After.Contains("download")); + + var worksheet = await _reporting.GetWorksheetAsync(run.PayDataReportRunId, DeptId); + worksheet.EmployerLegalName.Should().Be("Test Fire District"); worksheet.SnapshotEmployeeCount.Should().Be(2); worksheet.Establishments.Single().EmployeeCount.Should().Be(2); worksheet.DueDate.Should().Be(new DateTime(2026, 5, 13)); + + Func frozenEdit = () => _reporting.BuildEmployeeSnapshotsAsync(run.PayDataReportRunId, DeptId, User, null, null); + await frozenEdit.Should().ThrowAsync().WithMessage("paydata_run_frozen"); + + run = await _reporting.MarkCertifiedExternallyAsync(run.PayDataReportRunId, DeptId, "CRD-2026-000123", User, null, null); + run.Status.Should().Be((int)PayDataReportRunStatuses.CertifiedExternally); + Func voidCertified = () => _reporting.VoidRunAsync(run.PayDataReportRunId, DeptId, User, null, null); + await voidCertified.Should().ThrowAsync().WithMessage("paydata_run_certified"); + + var correction = await _reporting.CreateCorrectionAsync(run.PayDataReportRunId, DeptId, User, null, null); + correction.SupersedesRunId.Should().Be(run.PayDataReportRunId); + correction.Status.Should().Be((int)PayDataReportRunStatuses.Draft); + (await _reporting.GetRunAsync(run.PayDataReportRunId, DeptId)).Status.Should().Be((int)PayDataReportRunStatuses.Correction); + Func second = () => _reporting.CreateCorrectionAsync(run.PayDataReportRunId, DeptId, User, null, null); + await second.Should().ThrowAsync().WithMessage("paydata_correction_exists"); + + var readiness = await _reporting.GetReadinessAsync(DeptId, 2025); + readiness.HasCertifiedRun.Should().BeTrue(); readiness.OpenRuns.Should().Be(1); readiness.DemographicsMissing.Should().Be(0); readiness.AnnualFactsMissing.Should().Be(0); + } + + [Test] + public async Task Validation_blocks_missing_demographics_and_facts_and_overrides_need_a_reason() + { + var run = await SeedReportAsync(); + var (c, ec, _) = await SeedWorkerAsync("u3"); + run = await _reporting.BuildEmployeeSnapshotsAsync(run.PayDataReportRunId, DeptId, User, null, null); + run.EmployeeCount.Should().Be(3); run.ExceptionCount.Should().Be(1, "u3 has neither a demographic response nor an annual fact"); + var snapshot = (await _reporting.GetSnapshotsAsync(run.PayDataReportRunId, DeptId)).Single(s => s.WorkforceWorkerId == c.WorkforceWorkerId); + snapshot.ExceptionCodes.Should().Contain(PayDataValidationCodes.DemographicMissing).And.Contain(PayDataValidationCodes.AnnualFactMissing); + await _reporting.AggregateRowsAsync(run.PayDataReportRunId, DeptId, User, null, null); + var validation = await _reporting.ValidateRunAsync(run.PayDataReportRunId, DeptId, User, null, null); + validation.CanFreeze.Should().BeFalse(); + validation.Errors.Should().Contain(e => e.Code == PayDataValidationCodes.DemographicMissing && e.SubjectId == snapshot.PayDataReportEmployeeSnapshotId); + Func freeze = () => _reporting.FreezeAndExportAsync(run.PayDataReportRunId, DeptId, User, null, null); + await freeze.Should().ThrowAsync().WithMessage("paydata_validation_failed"); + + Func noReason = () => _reporting.OverrideSnapshotAsync(run.PayDataReportRunId, snapshot.PayDataReportEmployeeSnapshotId, DeptId, false, null, null, " ", User, null, null); + await noReason.Should().ThrowAsync().WithMessage("paydata_reason_required"); + var excluded = await _reporting.OverrideSnapshotAsync(run.PayDataReportRunId, snapshot.PayDataReportEmployeeSnapshotId, DeptId, false, null, null, "Left before the snapshot period", User, null, null); + excluded.IsIncluded.Should().BeFalse(); + excluded.ExceptionCodes.Should().Contain(PayDataValidationCodes.ManualOverride); + await _reporting.AggregateRowsAsync(run.PayDataReportRunId, DeptId, User, null, null); + (await _reporting.ValidateRunAsync(run.PayDataReportRunId, DeptId, User, null, null)).CanFreeze.Should().BeTrue("excluded snapshots no longer block"); + } + + [Test] + public async Task Demographics_live_in_their_own_table_count_only_for_completeness_and_never_reach_the_costing_engine() + { + var (worker, _, _) = await SeedWorkerAsync("u1"); + (await _demographicsService.GetOwnAsync(DeptId, "u1")).Should().BeNull(); + var first = await _demographicsService.SaveOwnAsync(DeptId, "u1", new PayDataReportingDemographic { HispanicLatino = "Yes", RaceEthnicityCodes = "B,E", SexCode = "10" }, null, null); + first.CollectionSource.Should().Be((int)DemographicCollectionSources.SelfIdentified); first.Version.Should().Be(1); + var second = await _demographicsService.SaveOwnAsync(DeptId, "u1", new PayDataReportingDemographic { DeclinedRaceEthnicity = true, SexCode = "30" }, null, null); + second.Version.Should().Be(2); second.RaceEthnicityCodes.Should().BeNull(); second.HispanicLatino.Should().BeNull(); + (await _demographicsService.GetOwnAsync(DeptId, "u1")).PayDataReportingDemographicId.Should().Be(second.PayDataReportingDemographicId); + Func badRace = () => _demographicsService.SaveOwnAsync(DeptId, "u1", new PayDataReportingDemographic { RaceEthnicityCodes = "Z", SexCode = "10" }, null, null); + await badRace.Should().ThrowAsync().WithMessage("paydata_race_invalid"); + Func officerNeedsReason = () => _demographicsService.SaveForWorkerAsync(DeptId, worker.WorkforceWorkerId, new PayDataReportingDemographic { CollectionSource = (int)DemographicCollectionSources.ObserverPerception, RaceEthnicityCodes = "C", SexCode = "20", HispanicLatino = "No" }, "", User, null, null); + await officerNeedsReason.Should().ThrowAsync().WithMessage("paydata_reason_required"); + Func officerNotSelf = () => _demographicsService.SaveForWorkerAsync(DeptId, worker.WorkforceWorkerId, new PayDataReportingDemographic { CollectionSource = (int)DemographicCollectionSources.SelfIdentified, RaceEthnicityCodes = "C", SexCode = "20", HispanicLatino = "No" }, "reason", User, null, null); + await officerNotSelf.Should().ThrowAsync().WithMessage("paydata_collection_source_invalid"); + + await SeedWorkerAsync("u2"); + var completeness = await _demographicsService.GetCompletenessAsync(DeptId, DateTime.UtcNow); + completeness.ActiveWorkers.Should().Be(2); completeness.WithResponse.Should().Be(1); completeness.Declined.Should().Be(1); completeness.Missing.Should().Be(1); + _audits.Where(a => a.Type == AuditLogTypes.PayDataDemographicChanged).Should().OnlyContain(a => !a.After.Contains("\"B,E\"") && !a.After.Contains("\"10\""), "audit snapshots carry markers, never the coded values"); + + var costingDependencies = typeof(FieldCostingService).GetConstructors().Single().GetParameters().Select(p => p.ParameterType).ToList(); + costingDependencies.Should().NotContain(typeof(IPayDataReportingDemographicRepository)).And.NotContain(typeof(IPayDataDemographicsService), "the costing engine has no path to the demographic table"); + typeof(CompensationCostService).GetConstructors().Single().GetParameters().Select(p => p.ParameterType).Should().NotContain(typeof(IPayDataReportingDemographicRepository)); + } + + [Test] + public async Task Annual_fact_import_dry_runs_before_committing_and_versions_corrections() + { + var (worker, employment, _) = await SeedWorkerAsync("u1"); + var csv = "UserId,ReportingYear,ReportType,W2Box5,W2Box1,ActualWorkedHours,PaidLeaveHours,WeeksWorked\nu1,2025,PayrollEmployee,52000,50000,2000,80,52\nghost,2025,PayrollEmployee,1,1,1,1,1"; + var dry = await _workforce.ImportAnnualPayFactsAsync(DeptId, csv, true, User, null, null); + dry.DryRun.Should().BeTrue(); dry.Total.Should().Be(2); dry.HasErrors.Should().BeTrue(); dry.Issues.Should().Contain(i => i.Code == "worker_not_found" && i.Line == 3); + _facts.Should().BeEmpty("nothing commits while a row is in error"); + var commit = await _workforce.ImportAnnualPayFactsAsync(DeptId, csv, false, User, null, null); + commit.HasErrors.Should().BeTrue(); _facts.Should().BeEmpty(); + + var clean = await _workforce.ImportAnnualPayFactsAsync(DeptId, csv.Split('\n')[0] + "\n" + csv.Split('\n')[1], false, User, null, null); + clean.Created.Should().Be(1); clean.HasErrors.Should().BeFalse(); + var facts = await _workforce.GetAnnualPayFactsAsync(DeptId, 2025, PayDataReportTypes.PayrollEmployee); + facts.Single().EarningsUsedValue.Should().Be(52000m); facts.Single().ReportableHours.Should().Be(2080m); facts.Single().IsApproved.Should().BeFalse("imports arrive unapproved"); + + var corrected = await _workforce.SaveAnnualPayFactAsync(new WorkforceAnnualPayFact { DepartmentId = DeptId, WorkforceEmploymentId = employment.WorkforceEmploymentId, ReportingYear = 2025, ReportType = (int)PayDataReportTypes.PayrollEmployee, W2Box5Value = 53000m, ActualWorkedHours = 2080m, WeeksWorked = 52, IsApproved = true }, User, null, null); + corrected.Version.Should().Be(2); corrected.SupersedesFactId.Should().Be(facts.Single().WorkforceAnnualPayFactId); + (await _workforce.GetAnnualPayFactsAsync(DeptId, 2025, PayDataReportTypes.PayrollEmployee)).Single().EarningsUsedValue.Should().Be(53000m, "only the current version is listed"); + _facts.Should().HaveCount(2, "the superseded fact is kept"); + } + + [Test] + public async Task Readiness_sweep_sends_one_value_free_digest_per_department_in_season_and_purges_expired_artifacts() + { + await SeedReportAsync(); + var sent = await _reporting.RunReadinessSweepAsync(new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc), d => Task.FromResult(true)); + sent.Should().Be(1); + _notifications.Single().Should().StartWith("California pay data reporting: Reporting year 2025 is due 2026-05-13").And.Contain("1 open run(s)").And.NotContain("52000").And.NotContain("Test Fire District"); + (await _reporting.RunReadinessSweepAsync(new DateTime(2026, 3, 2, 18, 0, 0, DateTimeKind.Utc), d => Task.FromResult(true))).Should().Be(0, "once per department per day"); + (await _reporting.RunReadinessSweepAsync(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc), d => Task.FromResult(true))).Should().Be(0, "outside the filing season"); + + _artifacts.Add(new PayDataExportArtifact { PayDataExportArtifactId = "old", PayDataReportRunId = "r", DepartmentId = DeptId, Data = new byte[] { 1 }, ExpiresOn = new DateTime(2026, 1, 1), CreatedOn = new DateTime(2025, 12, 1) }); + (await _reporting.PurgeExpiredArtifactsAsync(new DateTime(2026, 2, 1))).Should().Be(1); + _artifacts.Single().Data.Should().BeNull(); _artifacts.Single().PurgedOn.Should().NotBeNull(); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs index daa347df..529b03d0 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs @@ -80,7 +80,7 @@ public async Task> GetBids(int? status = null, string c 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); + : await _bids.GetBidsByContactIdAsync(contactId, DepartmentId, skip, take); var result = new BidsResult { Data = bids.Select(b => Map(b, false)).ToList(), PageSize = bids.Count, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return result; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs new file mode 100644 index 00000000..8a6807fe --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.CostRecovery.CalOesMars; +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.CostRecovery.CalOesMars; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Cal OES MARS cost recovery (Workforce & Business Operations plan, C5). Gated by the CostRecovery.CalOesMars + /// entitlement. Rostered personnel get / save / validate their own incident-bound F-42 and expense drafts and see + /// them in the queue; MutualAidReimbursement_View exposes the department queue and readiness metadata, + /// _Update the expected-reimbursement run, _Submit the manual external observations. Agency identifiers, annual + /// rate inputs, the attested handoff and invoice decisions stay MVC-only in P0. Nothing here writes to MARS. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class CalOesMarsController : V4AuthenticatedApiControllerbase + { + private readonly ICalOesMarsService _mars; + private readonly IDeploymentService _deployments; + private readonly IBusinessOperationsAccessService _access; + private readonly ICalOesMarsExternalGateway _gateway; + + public CalOesMarsController(ICalOesMarsService mars, IDeploymentService deployments, IBusinessOperationsAccessService access, ICalOesMarsExternalGateway gateway) + { + _mars = mars; + _deployments = deployments; + _access = access; + _gateway = gateway; + } + + private Task EnabledAsync() => _access.CanUseCostRecoveryAsync(DepartmentId); + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageMutualAidReimbursement(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewMutualAidReimbursement(); + private static bool CanSubmit => IsAdmin || ClaimsAuthorizationHelper.CanSubmitMutualAidReimbursement(); + 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("calmars_", StringComparison.Ordinal) || ex.Message.StartsWith("deployments_", StringComparison.Ordinal); + + private ActionResult Failed(string reason, int status = StatusCodes.Status400BadRequest) where T : StandardApiResponseV4Base, new() + { + var failed = new T { PageSize = 0, Status = ResponseHelper.Failure }; + ResponseHelper.PopulateV4ResponseData(failed); + Response.Headers["X-Resgrid-Reason"] = reason; + return StatusCode(status, failed); + } + + private async Task CanTouchAsync(string workItemId) => CanManage || await _mars.IsRosteredForWorkItemAsync(workItemId, DepartmentId, UserId); + private async Task CanSeeAsync(string workItemId) => CanView || await _mars.IsRosteredForWorkItemAsync(workItemId, DepartmentId, UserId); + + [HttpGet("GetAccess")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAccess() + { + var result = new CalOesMarsAccessResult + { + Data = new CalOesMarsAccessData + { + Enabled = await EnabledAsync(), CanView = CanView, CanManage = CanManage, CanSubmit = CanSubmit, CanReconcile = IsAdmin || ClaimsAuthorizationHelper.CanReconcileMutualAidReimbursement(), + AuthorityProfileCode = CalOesMarsAuthorityProfile.Current.Code, PortalUrl = _gateway.GetPortalUrl(null) + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetReadiness")] + [Authorize(Policy = ResgridResources.MutualAidReimbursement_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetReadiness(DateTime? asOf = null) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + var r = await _mars.GetAgencyReadinessAsync(DepartmentId, asOf); + // Readiness reports presence of the agency identifiers, never their values (plan C5). + var result = new CalOesMarsReadinessResult + { + Data = new CalOesMarsReadinessData + { + AsOf = r.AsOf, AuthorityProfileCode = r.AuthorityProfileCode, AuthorityProfileCurrent = r.AuthorityProfileCurrent, IsReady = r.IsReady, HasAgencyProfile = r.Agency != null, MacsDesignator = r.Agency?.MacsDesignator, + HasFein = !string.IsNullOrWhiteSpace(r.Agency?.FeinReference), HasUei = !string.IsNullOrWhiteSpace(r.Agency?.UeiReference), HasFiscalSupplier = !string.IsNullOrWhiteSpace(r.Agency?.FiscalSupplierReference), AgencyVerifiedOn = r.Agency?.VerifiedOn, + ResourceProfiles = r.ResourceProfiles, ResourceMismatches = r.ResourceMismatches, CurrentRateProfiles = r.CurrentRateProfiles.Count, CurrentAgreements = r.CurrentAgreements.Count, + OpenWorkItems = r.OpenWorkItems, ReturnedWorkItems = r.ReturnedWorkItems, InvoicesAwaitingLocalApproval = r.InvoicesAwaitingLocalApproval, + Items = r.Items.Select(i => new CalOesMarsReadinessItemData { Key = i.Key, Severity = i.Severity, MessageKey = i.MessageKey, Detail = i.Detail, Area = i.Area }).ToList() + }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetQueue")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetQueue(int? recordType = null) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + var items = await _mars.GetActionQueueAsync(DepartmentId, UserId, CanView); + if (recordType.HasValue) items = items.Where(i => i.WorkItem.RecordType == recordType.Value).ToList(); + var result = new CalOesMarsQueueResult { Data = items.Select(MapQueue).ToList(), PageSize = items.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetWorkItem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWorkItem(string id) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (!await CanSeeAsync(id)) return Unauthorized(); + var item = await _mars.GetWorkItemAsync(id, DepartmentId); + if (item == null) return NotFound(); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("BuildF42")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BuildF42([FromBody] BuildF42Input input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return Failed("calmars_deployment_required"); + if (!CanManage && !await _deployments.IsRosteredAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); + try + { + var item = await _mars.BuildF42DraftAsync(input.DeploymentId, DepartmentId, input.RmsExternalOrderFillId, UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("BuildExpenseClaim")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BuildExpenseClaim([FromBody] BuildExpenseClaimInput input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return Failed("calmars_deployment_required"); + if (!CanManage && !await _deployments.IsRosteredAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); + try + { + var item = await _mars.BuildExpenseClaimDraftAsync(input.DeploymentId, DepartmentId, input.F42WorkItemId, UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("SaveF42")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveF42([FromBody] SaveF42Input input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.WorkItemId) || input.Snapshot == null) return Failed("calmars_work_item_not_found"); + if (!await CanTouchAsync(input.WorkItemId)) return Unauthorized(); + try + { + var item = await _mars.SaveF42SnapshotAsync(input.WorkItemId, DepartmentId, input.Snapshot, UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("SaveExpenseClaim")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveExpenseClaim([FromBody] SaveExpenseClaimInput input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.WorkItemId) || input.Snapshot == null) return Failed("calmars_work_item_not_found"); + if (!await CanTouchAsync(input.WorkItemId)) return Unauthorized(); + try + { + var item = await _mars.SaveExpenseSnapshotAsync(input.WorkItemId, DepartmentId, input.Snapshot, UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("Validate")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Validate(string id) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (!await CanTouchAsync(id)) return Unauthorized(); + try + { + var validation = await _mars.ValidateForPortalAsync(id, DepartmentId, UserId, Ip, Agent); + var result = new CalOesMarsValidationApiResult { Data = validation, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("CalculateExpectedReimbursement")] + [Authorize(Policy = ResgridResources.MutualAidReimbursement_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CalculateExpectedReimbursement(string id) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + try + { + var calc = await _mars.CalculateExpectedReimbursementAsync(id, DepartmentId, UserId, Ip, Agent); + var result = new CalOesMarsReimbursementApiResult + { + Data = new CalOesMarsReimbursementData { ExpectedTotal = calc.ExpectedTotal, UncertainTotal = calc.UncertainTotal, Lines = calc.Lines.Select(MapLine).ToList(), Exceptions = calc.Exceptions }, + PageSize = 1, Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("RecordExternalSubmission")] + [Authorize(Policy = ResgridResources.MutualAidReimbursement_Submit)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RecordExternalSubmission([FromBody] CalOesMarsObservationInput input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.WorkItemId)) return Failed("calmars_work_item_not_found"); + try + { + var item = await _mars.RecordExternalSubmissionAsync(input.WorkItemId, DepartmentId, ToObservation(input), UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + [HttpPost("RecordExternalStatus")] + [Authorize(Policy = ResgridResources.MutualAidReimbursement_Submit)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RecordExternalStatus([FromBody] CalOesMarsObservationInput input) + { + if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); + if (input == null || string.IsNullOrWhiteSpace(input.WorkItemId)) return Failed("calmars_work_item_not_found"); + try + { + var item = await _mars.RecordExternalStatusAsync(input.WorkItemId, DepartmentId, ToObservation(input), UserId, Ip, Agent); + var result = new CalOesMarsWorkItemResult { Data = Map(item), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + #region Mapping + + private static CalOesMarsExternalObservation ToObservation(CalOesMarsObservationInput input) => new CalOesMarsExternalObservation { ExternalId = input.ExternalId, ExternalStatus = input.ExternalStatus, ObservedOn = input.ObservedOn, Comment = input.Comment, ArtifactChecksum = input.ArtifactChecksum }; + + private static CalOesMarsQueueItemData MapQueue(CalOesMarsQueueItem q) => new CalOesMarsQueueItemData + { + Id = q.WorkItem.CalOesMarsWorkItemId, DeploymentId = q.WorkItem.DeploymentId, DeploymentName = q.DeploymentName, RecordType = q.WorkItem.RecordType, RecordTypeName = ((CalOesMarsRecordTypes)q.WorkItem.RecordType).ToString(), + LocalState = q.WorkItem.LocalState, LocalStateName = ((CalOesMarsLocalStates)q.WorkItem.LocalState).ToString(), IncidentNumber = q.IncidentNumber, RequestNumber = q.RequestNumber, MarsRecordId = q.WorkItem.MarsRecordId, + ObservedExternalStatus = q.WorkItem.ObservedExternalStatus, ObservedOn = q.WorkItem.ObservedOn, ErrorCount = q.ErrorCount, WarningCount = q.WarningCount, AgeDays = q.AgeDays, IsMine = q.IsMine, + ExpectedTotal = CanView ? q.WorkItem.ExpectedTotal : null, AddedOn = q.WorkItem.AddedOn, UpdatedOn = q.WorkItem.EditedOn ?? q.WorkItem.AddedOn + }; + + private static CalOesMarsWorkItemData Map(CalOesMarsWorkItem w) => new CalOesMarsWorkItemData + { + Id = w.CalOesMarsWorkItemId, DeploymentId = w.DeploymentId, DeploymentName = w.DeploymentName, RmsExternalOrderId = w.RmsExternalOrderId, RmsExternalOrderFillId = w.RmsExternalOrderFillId, + RecordType = w.RecordType, RecordTypeName = ((CalOesMarsRecordTypes)w.RecordType).ToString(), LocalState = w.LocalState, LocalStateName = ((CalOesMarsLocalStates)w.LocalState).ToString(), + MarsRecordId = w.MarsRecordId, ObservedExternalStatus = w.ObservedExternalStatus, ObservedOn = w.ObservedOn, CorrectionComment = w.CorrectionComment, AuthorityProfileCode = w.AuthorityProfileCode, + IsLocallyEditable = w.IsLocallyEditable, IsExternal = w.IsExternal, SupersedesWorkItemId = w.SupersedesWorkItemId, + F42 = w.RecordType == (int)CalOesMarsRecordTypes.F42 ? Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(w.SnapshotJson) : null, + ExpenseClaim = w.RecordType == (int)CalOesMarsRecordTypes.ExpenseClaim ? Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(w.SnapshotJson) : null, + Validation = Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(w.ValidationSummaryJson), + ExpectedTotal = CanView ? w.ExpectedTotal : null, Lines = CanView ? w.Lines.Select(MapLine).ToList() : new List(), + RowVersion = w.RowVersion, AddedOn = w.AddedOn, UpdatedOn = w.EditedOn ?? w.AddedOn + }; + + private static CalOesMarsLineData MapLine(CalOesMarsReimbursementLine l) => new CalOesMarsLineData + { + Id = l.CalOesMarsReimbursementLineId, LineKind = l.LineKind, LineKindName = ((CalOesMarsLineKinds)l.LineKind).ToString(), SubjectId = l.SubjectId, SubjectName = l.SubjectName, LineDate = l.LineDate, + Quantity = l.Quantity, Unit = l.Unit, Rate = l.Rate, ExpectedAmount = l.ExpectedAmount, ApprovedAmount = l.ApprovedAmount, PaidAmount = l.PaidAmount, EligibilityState = l.EligibilityState, EligibilityReason = l.EligibilityReason + }; + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs new file mode 100644 index 00000000..55536330 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Services; +using Resgrid.Model.Workforce; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4; +using Resgrid.Web.Services.Models.v4.Workforce; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Internal field costing (Workforce & Business Operations plan, Phase E / E6). Gated by the + /// Workforce.InternalCosting entitlement. InternalCosts_View (74) exposes aggregate cost summaries for a deployment + /// or call — categories, totals, revenue and margin only; rostered members file their own resource usage readings + /// for a deployment they are seated on. Compensation, cost lines, pay data and every protected value stay MVC-only. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [Authorize] + public class FieldCostController : V4AuthenticatedApiControllerbase + { + private readonly IFieldCostingService _costing; + private readonly IDeploymentService _deployments; + private readonly IBusinessOperationsAccessService _access; + + public FieldCostController(IFieldCostingService costing, IDeploymentService deployments, IBusinessOperationsAccessService access) + { + _costing = costing; + _deployments = deployments; + _access = access; + } + + private Task EnabledAsync() => _access.CanUseWorkforceAsync(DepartmentId); + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanViewInternalCosts => IsAdmin || ClaimsAuthorizationHelper.CanViewInternalCosts(); + 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("workforce_", StringComparison.Ordinal); + + private ActionResult Failed(string reason, int status = StatusCodes.Status400BadRequest) where T : StandardApiResponseV4Base, new() + { + var failed = new T { PageSize = 0, Status = ResponseHelper.Failure }; + ResponseHelper.PopulateV4ResponseData(failed); + Response.Headers["X-Resgrid-Reason"] = reason; + return StatusCode(status, failed); + } + + private async Task IsRosteredAsync(string deploymentId) + { + if (string.IsNullOrWhiteSpace(deploymentId)) return false; + var mine = await _deployments.GetDeploymentsForUserAsync(DepartmentId, UserId, false); + return mine?.Any(d => string.Equals(d.DeploymentId, deploymentId, StringComparison.OrdinalIgnoreCase)) == true; + } + + [HttpGet("GetAccess")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetAccess() + { + var enabled = await EnabledAsync(); + var result = new FieldCostAccessResult { Data = new FieldCostAccessData { Enabled = enabled, CanViewInternalCosts = enabled && CanViewInternalCosts, CanRecordUsage = enabled }, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Aggregate summaries of every run for a deployment or call (ViewInternalCosts). Never a line, rate or person. + [HttpGet("GetFieldCostSummaries")] + [Authorize(Policy = ResgridResources.InternalCosts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetFieldCostSummaries(string deploymentId = null, int? callId = null) + { + if (!await EnabledAsync()) return Failed("workforce_disabled", StatusCodes.Status403Forbidden); + if (string.IsNullOrWhiteSpace(deploymentId) && !callId.HasValue) return Failed("workforce_usage_context_required"); + var runs = !string.IsNullOrWhiteSpace(deploymentId) ? await _costing.GetRunsForDeploymentAsync(deploymentId, DepartmentId) : await _costing.GetRunsForCallAsync(callId.Value, DepartmentId); + var result = new FieldCostSummariesResult { Data = runs.Select(Map).ToList(), PageSize = runs.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpGet("GetFieldCostSummary")] + [Authorize(Policy = ResgridResources.InternalCosts_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetFieldCostSummary(string runId) + { + if (!await EnabledAsync()) return Failed("workforce_disabled", StatusCodes.Status403Forbidden); + var summary = await _costing.GetFieldCostSummaryAsync(runId, DepartmentId); + if (summary == null) return Failed("workforce_not_found", StatusCodes.Status404NotFound); + var run = await _costing.GetRunAsync(runId, DepartmentId); + var result = new FieldCostSummaryResult { Data = Map(run), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Usage entries for a deployment the caller is rostered on (or any, with ViewInternalCosts). + [HttpGet("GetResourceUsage")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetResourceUsage(string deploymentId) + { + if (!await EnabledAsync()) return Failed("workforce_disabled", StatusCodes.Status403Forbidden); + if (!CanViewInternalCosts && !await IsRosteredAsync(deploymentId)) return Failed("workforce_not_rostered", StatusCodes.Status403Forbidden); + var rows = await _costing.GetUsageForDeploymentAsync(deploymentId, DepartmentId); + var result = new ResourceUsagesResult { Data = rows.Select(Map).ToList(), PageSize = rows.Count, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// A rostered member's own reading for a unit on their deployment (or any, with ViewInternalCosts). Distance is canonicalised to miles; conflicting readings are queued for review. + [HttpPost("AddResourceUsage")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AddResourceUsage([FromBody] AddResourceUsageInput input) + { + if (!await EnabledAsync()) return Failed("workforce_disabled", StatusCodes.Status403Forbidden); + if (input == null) return Failed("workforce_usage_invalid"); + if (!CanViewInternalCosts && !await IsRosteredAsync(input.DeploymentId)) return Failed("workforce_not_rostered", StatusCodes.Status403Forbidden); + try + { + var entry = new ResourceUsageEntry + { + DepartmentId = DepartmentId, SubjectType = (int)ResourceSubjectTypes.Unit, UnitId = input.UnitId, DeploymentId = input.DeploymentId, CallId = input.CallId, UsageDate = input.UsageDate, Phase = input.Phase, + StartOdometer = input.StartOdometer, EndOdometer = input.EndOdometer, DistanceUnit = input.DistanceUnit, OriginalDistance = input.Distance, StartEngineMeter = input.StartEngineMeter, EndEngineMeter = input.EndEngineMeter, + EngineHours = input.EngineHours, OperatingHours = input.OperatingHours, IdleHours = input.IdleHours, FuelQuantity = input.FuelQuantity, FuelUnit = input.FuelUnit, FuelActualCost = input.FuelActualCost, + Source = (int)UsageSources.Manual, ExternalId = input.ExternalId, IsApproved = false + }; + var saved = await _costing.SaveUsageEntryAsync(entry, UserId, Ip, Agent); + var result = new ResourceUsageResult { Data = Map(saved), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } + } + + private static FieldCostSummaryData Map(FieldCostRun run) => new FieldCostSummaryData + { + RunId = run.FieldCostRunId, ContextType = run.ContextType, DeploymentId = run.DeploymentId, BidId = run.BidId, CallId = run.CallId, RunType = run.RunType, Status = run.Status, ThroughDate = run.ThroughDate, Currency = run.Currency, + PersonnelTotal = run.PersonnelTotal, ResourceTotal = run.ResourceTotal, ConsumableTotal = run.ConsumableTotal, ExpenseTotal = run.ExpenseTotal, OverheadTotal = run.OverheadTotal, TotalLoadedCost = run.TotalLoadedCost, + RevenueSource = run.RevenueSource, RevenueAmount = run.RevenueAmount, ContributionMargin = run.ContributionMargin, ContributionMarginPercent = run.ContributionMarginPercent, BreakEvenRevenue = run.BreakEvenRevenue, + MissingInputCount = run.MissingInputCount, FrozenOn = run.FrozenOn, CreatedOn = run.AddedOn + }; + + private static ResourceUsageData Map(ResourceUsageEntry u) => new ResourceUsageData + { + Id = u.ResourceUsageEntryId, DeploymentId = u.DeploymentId, CallId = u.CallId, UnitId = u.UnitId ?? 0, UsageDate = u.UsageDate, Phase = u.Phase, DistanceUnit = u.DistanceUnit, OriginalDistance = u.OriginalDistance, CanonicalDistanceMiles = u.CanonicalDistanceMiles, + EngineHours = u.EngineHours, OperatingHours = u.OperatingHours, IdleHours = u.IdleHours, FuelQuantity = u.FuelQuantity, FuelUnit = u.FuelUnit, FuelActualCost = u.FuelActualCost, Source = u.Source, NeedsReview = u.NeedsReview, ReviewReason = u.ReviewReason, IsApproved = u.IsApproved + }; + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs index a9fb93ef..1074d253 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ServiceContractsController.cs @@ -187,6 +187,8 @@ public async Task> SaveComplianceDocument byte[] data = null; if (!string.IsNullOrWhiteSpace(input.FileBase64)) { + // Base64 is 4 characters per 3 bytes: refuse on the encoded length before decoding allocates the oversized buffer. + if (input.FileBase64.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes / 3 * 4 + 4) return Failed("compliance_file_too_large"); try { data = Convert.FromBase64String(input.FileBase64); } catch (FormatException) { return Failed("compliance_file_invalid"); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs index 52ed4d39..947b78e6 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs @@ -223,6 +223,8 @@ public async Task> SaveExpense([FromBody] SaveExpens byte[] receipt = null; if (!string.IsNullOrWhiteSpace(input.ReceiptData)) { + // Base64 is 4 characters per 3 bytes: refuse on the encoded length before decoding allocates the oversized buffer. + if (input.ReceiptData.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes / 3 * 4 + 4) return Failed("deployments_attachment_too_large"); try { receipt = Convert.FromBase64String(input.ReceiptData); } catch (FormatException) { return Failed("expenses_receipt_invalid"); } if (receipt.Length > Resgrid.Services.Invoicing.DeploymentService.MaxAttachmentBytes) return Failed("deployments_attachment_too_large"); diff --git a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs index 4063d222..9c30439a 100644 --- a/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/ClaimsAuthorizationHelper.cs @@ -25,6 +25,18 @@ public static class ClaimsAuthorizationHelper 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 CanViewMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.View); + public static bool CanManageMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Update); + public static bool CanSubmitMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Submit); + public static bool CanReconcileMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Reconcile); + public static bool CanViewWorkforce() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View); + public static bool CanManageWorkforce() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.Update); + public static bool CanViewWorkforceCompensation() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.View); + public static bool CanManageWorkforceCompensation() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.Update); + public static bool CanViewInternalCosts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.InternalCosts, ResgridClaimTypes.Actions.View); + public static bool CanViewPayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.View); + public static bool CanManagePayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Update); + public static bool CanExportPayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Export); 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/CostRecovery/CalOesMars/CalOesMarsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/CostRecovery/CalOesMars/CalOesMarsApiModels.cs new file mode 100644 index 00000000..563a6fb7 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/CostRecovery/CalOesMars/CalOesMarsApiModels.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model.CostRecovery.CalOesMars; + +namespace Resgrid.Web.Services.Models.v4.CostRecovery.CalOesMars +{ + // Workforce & Business Operations plan, Phase C5 (Cal OES MARS): rostered personnel get / save / validate their own + // incident-bound F-42 and expense drafts; permission-79 holders see the department queue, readiness metadata, + // expected-versus-observed reimbursement and record manual external observations. Agency identifiers, rate inputs, + // the protected handoff and invoice decisions stay MVC-only in P0. Nothing here is under ADP (decision 44); the DTOs + // still omit the agency's FEIN / UEI / FI$Cal values (readiness reports presence, not the value). + + #region Access and readiness + + public class CalOesMarsAccessResult : StandardApiResponseV4Base + { + public CalOesMarsAccessData Data { get; set; } + } + + public class CalOesMarsAccessData + { + /// CostRecovery.CalOesMars entitlement (paid add-on + flag). + public bool Enabled { get; set; } + public bool CanView { get; set; } + public bool CanManage { get; set; } + public bool CanSubmit { get; set; } + public bool CanReconcile { get; set; } + public string AuthorityProfileCode { get; set; } + public string PortalUrl { get; set; } + } + + public class CalOesMarsReadinessResult : StandardApiResponseV4Base + { + public CalOesMarsReadinessData Data { get; set; } + } + + public class CalOesMarsReadinessData + { + public DateTime AsOf { get; set; } + public string AuthorityProfileCode { get; set; } + public bool AuthorityProfileCurrent { get; set; } + public bool IsReady { get; set; } + public bool HasAgencyProfile { get; set; } + public string MacsDesignator { get; set; } + public bool HasFein { get; set; } + public bool HasUei { get; set; } + public bool HasFiscalSupplier { get; set; } + public DateTime? AgencyVerifiedOn { get; set; } + public int ResourceProfiles { get; set; } + public int ResourceMismatches { get; set; } + public int CurrentRateProfiles { get; set; } + public int CurrentAgreements { get; set; } + public int OpenWorkItems { get; set; } + public int ReturnedWorkItems { get; set; } + public int InvoicesAwaitingLocalApproval { get; set; } + public List Items { get; set; } = new List(); + } + + public class CalOesMarsReadinessItemData + { + public string Key { get; set; } + public int Severity { get; set; } + public string MessageKey { get; set; } + public string Detail { get; set; } + public string Area { get; set; } + } + + #endregion + + #region Work items + + public class CalOesMarsQueueResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class CalOesMarsQueueItemData + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public string DeploymentName { get; set; } + public int RecordType { get; set; } + public string RecordTypeName { get; set; } + public int LocalState { get; set; } + public string LocalStateName { get; set; } + public string IncidentNumber { get; set; } + public string RequestNumber { get; set; } + public string MarsRecordId { get; set; } + public string ObservedExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public int ErrorCount { get; set; } + public int WarningCount { get; set; } + public int AgeDays { get; set; } + public bool IsMine { get; set; } + /// Managers only; null for rostered members. + public decimal? ExpectedTotal { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class CalOesMarsWorkItemResult : StandardApiResponseV4Base + { + public CalOesMarsWorkItemData Data { get; set; } + } + + public class CalOesMarsWorkItemData + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public string DeploymentName { get; set; } + public string RmsExternalOrderId { get; set; } + public string RmsExternalOrderFillId { get; set; } + public int RecordType { get; set; } + public string RecordTypeName { get; set; } + public int LocalState { get; set; } + public string LocalStateName { get; set; } + public string MarsRecordId { get; set; } + public string ObservedExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public string CorrectionComment { get; set; } + public string AuthorityProfileCode { get; set; } + public bool IsLocallyEditable { get; set; } + public bool IsExternal { get; set; } + public string SupersedesWorkItemId { get; set; } + public CalOesMarsF42Snapshot F42 { get; set; } + public CalOesMarsExpenseClaimSnapshot ExpenseClaim { get; set; } + public CalOesMarsValidationResult Validation { get; set; } + /// Managers only. + public decimal? ExpectedTotal { get; set; } + public List Lines { get; set; } = new List(); + public int RowVersion { get; set; } + public DateTime AddedOn { get; set; } + public DateTime? UpdatedOn { get; set; } + } + + public class CalOesMarsLineData + { + public string Id { get; set; } + public int LineKind { get; set; } + public string LineKindName { get; set; } + public string SubjectId { get; set; } + public string SubjectName { get; set; } + public DateTime? LineDate { get; set; } + public decimal Quantity { get; set; } + public string Unit { get; set; } + public decimal Rate { get; set; } + public decimal ExpectedAmount { get; set; } + public decimal? ApprovedAmount { get; set; } + public decimal? PaidAmount { get; set; } + public int EligibilityState { get; set; } + public string EligibilityReason { get; set; } + } + + public class CalOesMarsValidationApiResult : StandardApiResponseV4Base + { + public CalOesMarsValidationResult Data { get; set; } + } + + public class CalOesMarsReimbursementApiResult : StandardApiResponseV4Base + { + public CalOesMarsReimbursementData Data { get; set; } + } + + public class CalOesMarsReimbursementData + { + public decimal ExpectedTotal { get; set; } + public decimal UncertainTotal { get; set; } + public List Lines { get; set; } = new List(); + public List Exceptions { get; set; } = new List(); + } + + #endregion + + #region Inputs + + public class BuildF42Input + { + public string DeploymentId { get; set; } + public string RmsExternalOrderFillId { get; set; } + } + + public class BuildExpenseClaimInput + { + public string DeploymentId { get; set; } + public string F42WorkItemId { get; set; } + } + + public class SaveF42Input + { + public string WorkItemId { get; set; } + public CalOesMarsF42Snapshot Snapshot { get; set; } + } + + public class SaveExpenseClaimInput + { + public string WorkItemId { get; set; } + public CalOesMarsExpenseClaimSnapshot Snapshot { get; set; } + } + + public class CalOesMarsObservationInput + { + public string WorkItemId { get; set; } + public string ExternalId { get; set; } + public string ExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public string Comment { get; set; } + public string ArtifactChecksum { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Workforce/FieldCostApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Workforce/FieldCostApiModels.cs new file mode 100644 index 00000000..01ee3b2a --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Workforce/FieldCostApiModels.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Workforce +{ + // Workforce & Business Operations plan, Phase E (E6): the narrow mobile surface. ViewInternalCosts (74) sees + // aggregate cost summaries per deployment / call — categories, totals, revenue and margin, never a line, a rate or + // a person; rostered members file their own resource usage readings (odometer / engine meter / fuel) for a + // deployment they are seated on. Nothing here returns a protected value. + + public class FieldCostAccessResult : StandardApiResponseV4Base + { + public FieldCostAccessData Data { get; set; } + } + + public class FieldCostAccessData + { + /// Workforce.InternalCosting entitlement (paid add-on + flag). + public bool Enabled { get; set; } + public bool CanViewInternalCosts { get; set; } + public bool CanRecordUsage { get; set; } + } + + public class FieldCostSummaryResult : StandardApiResponseV4Base + { + public FieldCostSummaryData Data { get; set; } + } + + public class FieldCostSummariesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class FieldCostSummaryData + { + public string RunId { get; set; } + public int ContextType { get; set; } + public string DeploymentId { get; set; } + public string BidId { get; set; } + public int? CallId { get; set; } + public int RunType { get; set; } + public int Status { get; set; } + public DateTime? ThroughDate { get; set; } + public string Currency { get; set; } + public decimal PersonnelTotal { get; set; } + public decimal ResourceTotal { get; set; } + public decimal ConsumableTotal { get; set; } + public decimal ExpenseTotal { get; set; } + public decimal OverheadTotal { get; set; } + public decimal TotalLoadedCost { get; set; } + public int RevenueSource { get; set; } + public decimal? RevenueAmount { get; set; } + public decimal? ContributionMargin { get; set; } + public decimal? ContributionMarginPercent { get; set; } + public decimal BreakEvenRevenue { get; set; } + public int MissingInputCount { get; set; } + public DateTime? FrozenOn { get; set; } + public DateTime CreatedOn { get; set; } + } + + public class AddResourceUsageInput + { + public string DeploymentId { get; set; } + public int? CallId { get; set; } + public int UnitId { get; set; } + public DateTime UsageDate { get; set; } + /// UsagePhases. + public int Phase { get; set; } + public decimal? StartOdometer { get; set; } + public decimal? EndOdometer { get; set; } + /// "mi" or "km"; the server keeps the reading and stores canonical miles. + public string DistanceUnit { get; set; } + public decimal? Distance { get; set; } + public decimal? StartEngineMeter { get; set; } + public decimal? EndEngineMeter { get; set; } + public decimal? EngineHours { get; set; } + public decimal? OperatingHours { get; set; } + public decimal? IdleHours { get; set; } + public decimal? FuelQuantity { get; set; } + public string FuelUnit { get; set; } + public decimal? FuelActualCost { get; set; } + public string ExternalId { get; set; } + } + + public class ResourceUsageResult : StandardApiResponseV4Base + { + public ResourceUsageData Data { get; set; } + } + + public class ResourceUsagesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + public class ResourceUsageData + { + public string Id { get; set; } + public string DeploymentId { get; set; } + public int? CallId { get; set; } + public int UnitId { get; set; } + public DateTime UsageDate { get; set; } + public int Phase { get; set; } + public string DistanceUnit { get; set; } + public decimal? OriginalDistance { get; set; } + public decimal? CanonicalDistanceMiles { get; set; } + public decimal? EngineHours { get; set; } + public decimal? OperatingHours { get; set; } + public decimal? IdleHours { get; set; } + public decimal? FuelQuantity { get; set; } + public string FuelUnit { get; set; } + public decimal? FuelActualCost { get; set; } + public int Source { get; set; } + public bool NeedsReview { get; set; } + public string ReviewReason { get; set; } + public bool IsApproved { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 1240bbd8..3b3c981f 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -531,6 +531,15 @@ The cancellation token ActionResult. + + + Cal OES MARS cost recovery (Workforce & Business Operations plan, C5). Gated by the CostRecovery.CalOesMars + entitlement. Rostered personnel get / save / validate their own incident-bound F-42 and expense drafts and see + them in the queue; MutualAidReimbursement_View exposes the department queue and readiness metadata, + _Update the expected-reimbursement run, _Submit the manual external observations. Agency identifiers, annual + rate inputs, the attested handoff and invoice decisions stay MVC-only in P0. Nothing here writes to MARS. + + Certification catalog, personnel and unit records, role requirements and settings (Workforce & Business @@ -1854,6 +1863,23 @@ Gets calls and other data formatted for different feed formats, like RSS. + + + Internal field costing (Workforce & Business Operations plan, Phase E / E6). Gated by the + Workforce.InternalCosting entitlement. InternalCosts_View (74) exposes aggregate cost summaries for a deployment + or call — categories, totals, revenue and margin only; rostered members file their own resource usage readings + for a deployment they are seated on. Compensation, cost lines, pay data and every protected value stay MVC-only. + + + + Aggregate summaries of every run for a deployment or call (ViewInternalCosts). Never a line, rate or person. + + + Usage entries for a deployment the caller is rostered on (or any, with ViewInternalCosts). + + + A rostered member's own reading for a unit on their deployment (or any, with ViewInternalCosts). Distance is canonicalised to miles; conflicting readings are queued for review. + Field Records for the Responder, Unit, Incident Command and Dispatch apps (RMS plan RMS-1D): minimum-version @@ -11074,6 +11100,15 @@ ContractorChargeKinds value. + + CostRecovery.CalOesMars entitlement (paid add-on + flag). + + + Managers only; null for rostered members. + + + Managers only. + Custom defined Status for Personnel and Units @@ -15916,6 +15951,15 @@ Plaintext credential JSON — will be AES-encrypted server-side before storage. + + Workforce.InternalCosting entitlement (paid add-on + flag). + + + UsagePhases. + + + "mi" or "km"; the server keeps the reading and stores canonical miles. + A strongly-typed resource class, for looking up localized strings, etc. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index d8475de6..90076b55 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -337,6 +337,18 @@ public void ConfigureServices(IServiceCollection services) 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.MutualAidReimbursement_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Submit, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Submit)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Reconcile, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Reconcile)); + options.AddPolicy(ResgridResources.Workforce_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Workforce_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.WorkforceCompensation_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.WorkforceCompensation_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.InternalCosts_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.InternalCosts, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.PayDataReporting_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.PayDataReporting_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.PayDataReporting_Export, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Export)); 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 index f985a86c..ca6de31b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs @@ -27,6 +27,9 @@ namespace Resgrid.Web.Areas.User.Controllers [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public sealed class BidsController : SecureBaseController { + // Line descriptions, entry and premium names are user text rendered inside a " out of the page. + private static readonly JsonSerializerSettings ScriptJson = new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }; + private readonly IBidsService _bids; private readonly IServiceContractService _contracts; private readonly IRateScheduleService _rateSchedules; @@ -164,13 +167,13 @@ public async Task Edit(string id) 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.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 }), ScriptJson); 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 })); + }), ScriptJson); + view.PremiumsJson = JsonConvert.SerializeObject((view.Schedule?.Premiums ?? new List()).Select(p => new { id = p.RatePremiumId, name = p.Name, deploymentAdder = p.DeploymentAdder }), ScriptJson); return View(view); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs new file mode 100644 index 00000000..531f40f9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs @@ -0,0 +1,588 @@ +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.CostRecovery.CalOesMars; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.CostRecovery; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Cal OES MARS cost recovery (Workforce & Business Operations plan, Phase C-M3 / C6): the readiness dashboard, + /// the annual rate workspace (with agreements and the F-5 resource crosswalk), the incident action queue, the + /// F-42 / expense editor with validation and the no-store portal handoff, and invoice / payment reconciliation. + /// Needs the CostRecovery.CalOesMars entitlement. MutualAidReimbursement_View reads the department queue and + /// readiness; _Update edits agency / resources / rates / agreements; _Submit records what was observed in the portal; + /// _Reconcile decides and reconciles MARS invoices. Rostered members reach only their own incident-bound F-42 / + /// expense drafts. The UI says "Prepared for MARS" or "Observed in MARS", never "Submitted" from a download. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class CalOesMarsController : SecureBaseController + { + private readonly ICalOesMarsService _mars; + private readonly IDeploymentService _deployments; + private readonly IUnitsService _units; + private readonly IBusinessOperationsAccessService _access; + private readonly ICalOesMarsExternalGateway _gateway; + private readonly IStringLocalizer _strings; + + public CalOesMarsController(ICalOesMarsService mars, IDeploymentService deployments, IUnitsService units, IBusinessOperationsAccessService access, ICalOesMarsExternalGateway gateway, + IStringLocalizer strings) + { + _mars = mars; + _deployments = deployments; + _units = units; + _access = access; + _gateway = gateway; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool IsManager => IsAdmin || ClaimsAuthorizationHelper.CanManageMutualAidReimbursement(); + private static bool CanView => IsManager || ClaimsAuthorizationHelper.CanViewMutualAidReimbursement(); + private static bool CanSubmit => IsAdmin || ClaimsAuthorizationHelper.CanSubmitMutualAidReimbursement(); + private static bool CanReconcile => IsAdmin || ClaimsAuthorizationHelper.CanReconcileMutualAidReimbursement(); + private static readonly HashSet FieldActions = new HashSet(StringComparer.OrdinalIgnoreCase) { "Queue", "WorkItem", "BuildF42", "BuildExpense", "SaveF42", "SaveExpense", "Validate", "Print", "Packet" }; + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + if (!await _access.CanUseCostRecoveryAsync(DepartmentId)) + { + context.Result = Unauthorized(); + return; + } + var action = context.ActionDescriptor.RouteValues.TryGetValue("action", out var name) ? name : string.Empty; + // Field members reach the queue and their own drafts (roster-scoped inside the actions); everything else needs the view claim. + if (!CanView && !FieldActions.Contains(action)) + { + context.Result = Unauthorized(); + return; + } + await next(); + } + + private T Page(T view) where T : CalOesMarsPageView + { + view.IsManager = IsManager; + view.CanSubmit = CanSubmit; + view.CanReconcile = CanReconcile; + view.AuthorityProfileCode = CalOesMarsAuthorityProfile.Current.Code; + view.PortalUrl = _gateway.GetPortalUrl(null); + if (TempData["CalOesMarsMessage"] is string message) view.Message = message; + if (TempData["CalOesMarsSaved"] 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["CalOesMarsMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["CalOesMarsSaved"] = 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("calmars_", StringComparison.Ordinal); + + private async Task GuardedAsync(Func> action, string redirectAction, object routeValues = null) + { + try { return await action(); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, redirectAction, routeValues); } + catch (ArgumentException) { return Refused(400, "SaveFailed", redirectAction, routeValues); } + } + + #endregion + + #region 1. Readiness dashboard + + [HttpGet] + public async Task Index(DateTime? asOf = null) + { + var view = Page(new CalOesMarsDashboardView { AsOf = (asOf ?? DateTime.UtcNow).Date }); + view.Readiness = await _mars.GetAgencyReadinessAsync(DepartmentId, view.AsOf); + view.Authority = CalOesMarsAuthorityProfile.Get(view.Readiness.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current; + return View(view); + } + + [HttpGet] + public async Task Agency() + { + if (!IsManager) return Unauthorized(); + var view = Page(new CalOesMarsAgencyView { Agency = await _mars.GetAgencyProfileAsync(DepartmentId) ?? new CalOesMarsAgencyProfile { DepartmentId = DepartmentId } }); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveAgency(CalOesMarsAgencyProfile input) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _mars.SaveAgencyProfileAsync(input, UserId, Ip, Agent); + return Saved("Agency"); + }, "Agency"); + + [HttpPost, ValidateAntiForgeryToken] + public Task VerifyAgency() => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.MarkAgencyVerifiedAsync(DepartmentId, UserId, Ip, Agent); + return Saved("Index"); + }, "Index"); + + [HttpGet] + public async Task Resources(string edit = null) + { + if (!IsManager) return Unauthorized(); + var view = Page(new CalOesMarsResourcesView { Resources = await _mars.GetResourceProfilesAsync(DepartmentId), ResourceTypes = CalOesMarsAuthorityProfile.Current.ResourceTypes }); + view.Units = (await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(u => u.Name).Select(u => new SelectListItem(u.Name, u.UnitId.ToString())).ToList(); + if (!string.IsNullOrWhiteSpace(edit)) view.Editing = await _mars.GetResourceProfileAsync(edit, DepartmentId); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task BuildResources(int[] unitIds) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.BuildResourceInventoryF5DraftAsync(DepartmentId, unitIds ?? Array.Empty(), UserId, Ip, Agent); + return Saved("Resources"); + }, "Resources"); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveResource(CalOesMarsResourceProfile input) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _mars.SaveResourceProfileAsync(input, UserId, Ip, Agent); + return Saved("Resources"); + }, "Resources"); + + [HttpPost, ValidateAntiForgeryToken] + public Task ObserveResource(string id, CalOesMarsObservationInput input) => GuardedAsync(async () => + { + if (!CanSubmit) return Unauthorized(); + await _mars.RecordResourceObservationAsync(id, DepartmentId, ToObservation(input), UserId, Ip, Agent); + return Saved("Resources"); + }, "Resources"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteResource(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.DeleteResourceProfileAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Resources"); + }, "Resources"); + + #endregion + + #region 2. Annual rate workspace and agreements + + [HttpGet] + public async Task Rates(int? year = null) + { + var view = Page(new CalOesMarsRatesView { Profiles = await _mars.GetRateProfilesAsync(DepartmentId, year), Year = year }); + return View(view); + } + + [HttpGet] + public async Task Rate(string id = null, int? type = null) + { + if (!IsManager) return Unauthorized(); + var view = Page(new CalOesMarsRateEditView { Classifications = CalOesMarsAuthorityProfile.Current.SalaryClassifications, ResourceTypes = CalOesMarsAuthorityProfile.Current.ResourceTypes }); + if (!string.IsNullOrWhiteSpace(id)) + { + view.Profile = await _mars.GetRateProfileAsync(id, DepartmentId); + if (view.Profile == null) return NotFound(); + view.AdministrativeDraft = CostRecoveryDraft(view.Profile); + view.NextStatuses = Enum.GetValues().Where(s => Resgrid.Services.CostRecovery.CalOesMarsService.IsValidRateTransition((CalOesMarsRateProfileStatuses)view.Profile.Status, s)).ToList(); + } + else view.Profile = new CalOesMarsRateProfile { DepartmentId = DepartmentId, SubmissionYear = DateTime.UtcNow.Year, SubmissionType = type ?? (int)CalOesMarsSubmissionTypes.SalarySurvey, EffectiveOn = new DateTime(DateTime.UtcNow.Year, 1, 1) }; + view.LinesJson = JsonConvert.SerializeObject(view.Profile.Lines ?? new List()); + view.InputsJson = JsonConvert.SerializeObject((view.Profile.AdministrativeInputs ?? new List()).Select(i => new { i.CalOesMarsAdministrativeRateInputId, i.FiscalYear, i.FunctionCode, i.CategoryCode, i.Classification, Amount = i.Amount, i.SourceSystem, i.SourceLine, i.IncidentDirectExclusion, i.DoubleCountMarker, i.ReviewStatus, i.ReviewReason })); + return View(view); + } + + private static CalOesMarsAdministrativeRateDraft CostRecoveryDraft(CalOesMarsRateProfile profile) => + profile.SubmissionType == (int)CalOesMarsSubmissionTypes.AdministrativeRate || profile.AdministrativeInputs.Count > 0 + ? Resgrid.Services.CostRecovery.CalOesMarsService.BuildAdministrativeRateDraft(profile, CalOesMarsAuthorityProfile.Get(profile.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current) + : null; + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveRate(CalOesMarsRateProfile input) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + input.DepartmentId = DepartmentId; + var saved = await _mars.SaveRateProfileAsync(input, UserId, Ip, Agent); + return Saved("Rate", new { id = saved.CalOesMarsRateProfileId }); + }, string.IsNullOrWhiteSpace(input?.CalOesMarsRateProfileId) ? "Rates" : "Rate", string.IsNullOrWhiteSpace(input?.CalOesMarsRateProfileId) ? null : new { id = input.CalOesMarsRateProfileId }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveRateLines(string id, string linesJson) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + var lines = string.IsNullOrWhiteSpace(linesJson) ? new List() : JsonConvert.DeserializeObject>(linesJson) ?? new List(); + await _mars.SaveRateLinesAsync(id, DepartmentId, lines, UserId, Ip, Agent); + return Saved("Rate", new { id }); + }, "Rate", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveAdministrativeInputs(string id, string inputsJson) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + var rows = string.IsNullOrWhiteSpace(inputsJson) ? new List() : JsonConvert.DeserializeObject>(inputsJson) ?? new List(); + var inputs = rows.Select(r => new CalOesMarsAdministrativeRateInput + { + CalOesMarsAdministrativeRateInputId = r.CalOesMarsAdministrativeRateInputId, FiscalYear = r.FiscalYear, FunctionCode = r.FunctionCode, CategoryCode = r.CategoryCode, Classification = r.Classification, + ActualAmount = r.Amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), SourceSystem = r.SourceSystem, SourceLine = r.SourceLine, IncidentDirectExclusion = r.IncidentDirectExclusion, DoubleCountMarker = r.DoubleCountMarker, ReviewStatus = r.ReviewStatus, ReviewReason = r.ReviewReason + }).ToList(); + await _mars.SaveAdministrativeInputsAsync(id, DepartmentId, inputs, UserId, Ip, Agent); + return Saved("Rate", new { id }); + }, "Rate", new { id }); + + private sealed class AdminInputRow + { + public string CalOesMarsAdministrativeRateInputId { get; set; } + public int FiscalYear { get; set; } + public string FunctionCode { get; set; } + public string CategoryCode { get; set; } + public int Classification { get; set; } + public decimal Amount { get; set; } + public string SourceSystem { get; set; } + public string SourceLine { get; set; } + public bool IncidentDirectExclusion { get; set; } + public bool DoubleCountMarker { get; set; } + public int ReviewStatus { get; set; } + public string ReviewReason { get; set; } + } + + [HttpPost, ValidateAntiForgeryToken] + public Task BuildAdministrativeRate(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + var draft = await _mars.BuildAdministrativeRateDraftAsync(id, DepartmentId, UserId, Ip, Agent); + if (!draft.IsReady) { TempData["CalOesMarsMessage"] = string.Join(" ", draft.Blockers.Select(b => ErrorText("AdminBlocker_" + b))); return RedirectToAction("Rate", new { id }); } + return Saved("Rate", new { id }); + }, "Rate", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SetRateStatus(string id, int status, string signedByName) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + if (!Enum.IsDefined(typeof(CalOesMarsRateProfileStatuses), status)) return Refused(400, "calmars_rate_transition_invalid", "Rate", new { id }); + await _mars.SetRateProfileStatusAsync(id, DepartmentId, (CalOesMarsRateProfileStatuses)status, signedByName, UserId, Ip, Agent); + return Saved("Rate", new { id }); + }, "Rate", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task ObserveRate(string id, CalOesMarsObservationInput input) => GuardedAsync(async () => + { + if (!CanSubmit) return Unauthorized(); + await _mars.RecordRateProfileObservationAsync(id, DepartmentId, ToObservation(input), UserId, Ip, Agent); + return Saved("Rate", new { id }); + }, "Rate", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteRate(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.DeleteRateProfileAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Rates"); + }, "Rates"); + + [HttpGet] + public async Task Agreements(string edit = null) + { + var view = Page(new CalOesMarsAgreementsView { Agreements = await _mars.GetAgreementsAsync(DepartmentId), Classifications = CalOesMarsAuthorityProfile.Current.SalaryClassifications }); + if (!string.IsNullOrWhiteSpace(edit) && IsManager) view.Editing = await _mars.GetAgreementAsync(edit, DepartmentId); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveAgreement(CalOesMarsAgreementSnapshot input) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _mars.SaveAgreementAsync(input, UserId, Ip, Agent); + return Saved("Agreements"); + }, "Agreements"); + + [HttpPost, ValidateAntiForgeryToken] + public Task ObserveAgreement(string id, CalOesMarsObservationInput input) => GuardedAsync(async () => + { + if (!CanSubmit) return Unauthorized(); + await _mars.RecordAgreementObservationAsync(id, DepartmentId, ToObservation(input), UserId, Ip, Agent); + return Saved("Agreements"); + }, "Agreements"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteAgreement(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.DeleteAgreementAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Agreements"); + }, "Agreements"); + + #endregion + + #region 3. Incident action queue + + [HttpGet] + public async Task Queue(int? type = null) + { + var view = Page(new CalOesMarsQueueView { TypeFilter = type }); + view.Items = await _mars.GetActionQueueAsync(DepartmentId, UserId, IsManager); + if (type.HasValue) view.Items = view.Items.Where(i => i.WorkItem.RecordType == type.Value).ToList(); + var deployments = IsManager ? await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, openOnly: false, 0, 200) : await _deployments.GetDeploymentsForUserAsync(DepartmentId, UserId, openOnly: false); + view.CostRecoveryDeployments = deployments.Where(d => d.FinanceMode == (int)DeploymentFinanceModes.CostRecovery || !string.IsNullOrWhiteSpace(d.RmsExternalOrderId)).OrderByDescending(d => d.StartOn ?? d.AddedOn).ToList(); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task BuildF42(string deploymentId, string fillId) => GuardedAsync(async () => + { + if (!IsManager && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + var item = await _mars.BuildF42DraftAsync(deploymentId, DepartmentId, fillId, UserId, Ip, Agent); + return Saved("WorkItem", new { id = item.CalOesMarsWorkItemId }); + }, "Queue"); + + [HttpPost, ValidateAntiForgeryToken] + public Task BuildExpense(string deploymentId, string f42Id) => GuardedAsync(async () => + { + if (!IsManager && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + var item = await _mars.BuildExpenseClaimDraftAsync(deploymentId, DepartmentId, f42Id, UserId, Ip, Agent); + return Saved("WorkItem", new { id = item.CalOesMarsWorkItemId }); + }, "Queue"); + + #endregion + + #region 4. F-42 / expense editor and handoff + + private async Task<(CalOesMarsWorkItem Item, bool Rostered)> LoadItemAsync(string id) + { + var item = await _mars.GetWorkItemAsync(id, DepartmentId); + if (item == null) return (null, false); + var rostered = !IsManager && await _mars.IsRosteredForWorkItemAsync(id, DepartmentId, UserId); + return (item, rostered); + } + + [HttpGet] + public async Task WorkItem(string id) + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !CanView && !rostered) return Unauthorized(); + var view = Page(new CalOesMarsWorkItemView { Item = item, IsRostered = rostered, Authority = CalOesMarsAuthorityProfile.Get(item.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current, Classifications = CalOesMarsAuthorityProfile.Current.SalaryClassifications }); + view.CanEdit = (IsManager || rostered) && item.IsLocallyEditable; + view.Validation = Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(item.ValidationSummaryJson); + view.SnapshotJson = item.SnapshotJson ?? "{}"; + switch ((CalOesMarsRecordTypes)item.RecordType) + { + case CalOesMarsRecordTypes.F42: view.F42 = Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(item.SnapshotJson); break; + case CalOesMarsRecordTypes.ExpenseClaim: view.Expense = Resgrid.Services.CostRecovery.CalOesMarsService.Deserialize(item.SnapshotJson); break; + case CalOesMarsRecordTypes.GeneratedInvoice: return RedirectToAction("Invoice", new { id }); + } + if (!string.IsNullOrWhiteSpace(item.DeploymentId)) + { + view.Attachments = await _deployments.GetAttachmentsAsync(item.DeploymentId, DepartmentId) ?? new List(); + view.Related = (await _mars.GetWorkItemsForDeploymentAsync(item.DeploymentId, DepartmentId)).Where(w => w.CalOesMarsWorkItemId != item.CalOesMarsWorkItemId).ToList(); + } + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveF42(string id, string snapshotJson) => GuardedAsync(async () => + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !rostered) return Unauthorized(); + var snapshot = JsonConvert.DeserializeObject(snapshotJson ?? "{}") ?? new CalOesMarsF42Snapshot(); + await _mars.SaveF42SnapshotAsync(id, DepartmentId, snapshot, UserId, Ip, Agent); + return Saved("WorkItem", new { id }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveExpense(string id, CalOesMarsExpenseClaimSnapshot input) => GuardedAsync(async () => + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !rostered) return Unauthorized(); + await _mars.SaveExpenseSnapshotAsync(id, DepartmentId, input ?? new CalOesMarsExpenseClaimSnapshot(), UserId, Ip, Agent); + return Saved("WorkItem", new { id }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task Validate(string id) => GuardedAsync(async () => + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !rostered) return Unauthorized(); + var result = await _mars.ValidateForPortalAsync(id, DepartmentId, UserId, Ip, Agent); + if (IsAjax()) return Json(result); + TempData["CalOesMarsSaved"] = result.IsReadyForPortal; + if (!result.IsReadyForPortal) TempData["CalOesMarsMessage"] = string.Format(_strings["ValidationFailedSummary"].Value, result.Errors.Count, result.Warnings.Count); + return RedirectToAction("WorkItem", new { id }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task Calculate(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + var result = await _mars.CalculateExpectedReimbursementAsync(id, DepartmentId, UserId, Ip, Agent); + if (IsAjax()) return Json(result); + return Saved("WorkItem", new { id }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task Handoff(string id, bool attested = false) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + var item = await _mars.GetWorkItemAsync(id, DepartmentId); + if (item == null) return NotFound(); + var manifest = await _mars.OpenPortalHandoffAsync(id, DepartmentId, attested, UserId, Ip, Agent); + Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + Response.Headers["Pragma"] = "no-cache"; + return View("Handoff", Page(new CalOesMarsHandoffView { Item = item, Manifest = manifest })); + }, "WorkItem", new { id }); + + [HttpGet] + public async Task Packet(string id) + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !CanView && !rostered) return Unauthorized(); + var bytes = await _mars.BuildEvidencePacketAsync(id, DepartmentId, UserId); + return File(bytes, "application/zip", $"mars-evidence-{item.CalOesMarsWorkItemId}.zip"); + } + + [HttpGet] + public async Task Print(string id) + { + var (item, rostered) = await LoadItemAsync(id); + if (item == null) return NotFound(); + if (!IsManager && !CanView && !rostered) return Unauthorized(); + return Content(await _mars.RenderWorkItemHtmlAsync(id, DepartmentId), "text/html"); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task ObserveSubmission(string id, CalOesMarsObservationInput input) => GuardedAsync(async () => + { + if (!CanSubmit) return Unauthorized(); + await _mars.RecordExternalSubmissionAsync(id, DepartmentId, ToObservation(input), UserId, Ip, Agent); + return Saved("WorkItem", new { id }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task ObserveStatus(string id, CalOesMarsObservationInput input) => GuardedAsync(async () => + { + if (!CanSubmit) return Unauthorized(); + var item = await _mars.RecordExternalStatusAsync(id, DepartmentId, ToObservation(input), UserId, Ip, Agent); + return Saved("WorkItem", new { id = item.CalOesMarsWorkItemId }); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task Close(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.CloseWorkItemAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Queue"); + }, "WorkItem", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task Delete(string id) => GuardedAsync(async () => + { + if (!IsManager) return Unauthorized(); + await _mars.DeleteWorkItemAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Queue"); + }, "WorkItem", new { id }); + + #endregion + + #region 5. Invoice / payment reconciliation + + [HttpGet] + public async Task Reconciliation() + { + if (!CanReconcile && !IsManager) return Unauthorized(); + var queue = await _mars.GetActionQueueAsync(DepartmentId, UserId, true); + var view = Page(new CalOesMarsReconciliationView + { + Invoices = queue.Where(q => q.WorkItem.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice).Select(q => q.WorkItem).ToList(), + Coverable = queue.Where(q => q.WorkItem.RecordType != (int)CalOesMarsRecordTypes.GeneratedInvoice && q.WorkItem.IsExternal && q.WorkItem.LocalState != (int)CalOesMarsLocalStates.Paid).Select(q => q.WorkItem).ToList() + }); + foreach (var q in queue.Where(q => !string.IsNullOrWhiteSpace(q.DeploymentName) && !string.IsNullOrWhiteSpace(q.WorkItem.DeploymentId))) view.DeploymentNames[q.WorkItem.DeploymentId] = q.DeploymentName; + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task RecordInvoice(CalOesMarsInvoiceInput input) => GuardedAsync(async () => + { + if (!CanReconcile) return Unauthorized(); + var invoice = await _mars.RecordMarsInvoiceAsync(DepartmentId, input.DeploymentId, new CalOesMarsInvoiceObservation + { + MarsInvoiceId = input.MarsInvoiceId, InvoiceDate = input.InvoiceDate, InvoicedTotal = input.InvoicedTotal, PayingEntity = input.PayingEntity, ExternalStatus = input.ExternalStatus, + ObservedOn = input.ObservedOn, CoveredWorkItemIds = input.CoveredWorkItemIds ?? new List(), Comment = input.Comment + }, UserId, Ip, Agent); + return Saved("Invoice", new { id = invoice.CalOesMarsWorkItemId }); + }, "Reconciliation"); + + [HttpGet] + public async Task Invoice(string id) + { + if (!CanReconcile && !IsManager) return Unauthorized(); + var reconciliation = await _mars.GetInvoiceReconciliationAsync(id, DepartmentId); + if (reconciliation == null) return NotFound(); + return View(Page(new CalOesMarsInvoiceView { Reconciliation = reconciliation })); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task DecideInvoice(string id, bool approve, string decisionTitle, string comment) => GuardedAsync(async () => + { + if (!CanReconcile) return Unauthorized(); + await _mars.ApproveOrRejectObservedInvoiceAsync(id, DepartmentId, approve, decisionTitle, comment, UserId, Ip, Agent); + return Saved("Invoice", new { id }); + }, "Invoice", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task RecordPayment(string id, CalOesMarsPaymentInput input) => GuardedAsync(async () => + { + if (!CanReconcile) return Unauthorized(); + await _mars.RecordPaymentAsync(id, DepartmentId, new CalOesMarsPaymentObservation { PaidTotal = input.PaidTotal, PaidOn = input.PaidOn, PaymentReference = input.PaymentReference, PayingEntityStatus = input.PayingEntityStatus, Comment = input.Comment }, UserId, Ip, Agent); + return Saved("Invoice", new { id }); + }, "Invoice", new { id }); + + #endregion + + private static CalOesMarsExternalObservation ToObservation(CalOesMarsObservationInput input) => new CalOesMarsExternalObservation + { + ExternalId = input?.ExternalId, ExternalStatus = input?.ExternalStatus, ObservedOn = input?.ObservedOn, Comment = input?.Comment, ArtifactChecksum = input?.ArtifactChecksum, ArtifactAttachmentId = input?.ArtifactAttachmentId + }; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs index 0252dc41..5792f0d8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContractsController.cs @@ -31,6 +31,8 @@ namespace Resgrid.Web.Areas.User.Controllers public sealed class ContractsController : SecureBaseController { private static readonly string[] AllowedExtensions = { "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "csv", "heic" }; + // Requirement names are user text rendered inside a " out of the page. + private static readonly JsonSerializerSettings ScriptJson = new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }; private readonly IServiceContractService _contracts; private readonly IRateScheduleService _rateSchedules; @@ -149,7 +151,7 @@ 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 })) }); + 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 }), ScriptJson) }); await FillLookupsAsync(view); return View(view); } @@ -196,9 +198,10 @@ public async Task View(string id) 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(); } + // Contract-scoped queries: filtering a department-wide page after the 500/200 caps would drop this contract's rows once unrelated ones fill the page. + view.Bids = await _bids.GetBidsForContractAsync(id, DepartmentId); + view.Deployments = await _deployments.GetDeploymentsForContractAsync(id, DepartmentId); + try { view.Invoices = await _invoicing.GetInvoicesForDepartmentAsync(DepartmentId, new InvoiceListFilter { ContactId = contract.ContactId, ServiceContractId = id, Take = 200 }); } 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))) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs index 6bfe6cbe..a67f99de 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs @@ -28,6 +28,9 @@ namespace Resgrid.Web.Areas.User.Controllers [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public sealed class DeploymentWizardController : SecureBaseController { + // Bid, contract, schedule and roster names are user text rendered inside a " out of the page. + private static readonly JsonSerializerSettings ScriptJson = new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }; + private readonly IBidsService _bids; private readonly IDeploymentService _deployments; private readonly IDepartmentsService _departments; @@ -101,7 +104,7 @@ public async Task Index(string bidId) 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 - }); + }, ScriptJson); return View(view); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index 2898580e..e17675bf 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -482,7 +482,7 @@ SelectList AdpOptions(bool includeEveryone) // Rows come from RecordPermissionCatalog so this screen, ClaimsLogic.AddRecordClaims and the // activation-time row migration share one set of no-row defaults. A missing row preselects that // default, which for the Logs-parity types equals today's CreateLog/DeleteLog fall-through. - model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InvoicingPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, CertificationPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, DeploymentPermissionCatalog.All)).ToList(); + model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InvoicingPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, CertificationPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, DeploymentPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkforcePermissionCatalog.All)).ToList(); var recordsState = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); model.RecordsFlagEnabled = recordsState != null && recordsState.FlagEnabled; model.RecordsActivated = recordsState != null && recordsState.RecordsUsable; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs new file mode 100644 index 00000000..e60dda86 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs @@ -0,0 +1,825 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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.Model.Workforce; +using Resgrid.Web.Areas.User.Models.Workforce; +using Resgrid.Web.Helpers; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Workforce pay data, field costing and California pay data reporting (Workforce & Business Operations plan, + /// Phase E / E6). Needs the Workforce.InternalCosting entitlement for the workforce, compensation and costing + /// screens and Compliance.CaliforniaPayDataReporting (plus an Enabled ADP state) for the report wizard. + /// Workforce_View / _Update (75, 77) manage employer, establishments, workers and employments; + /// WorkforceCompensation_View / _Update (75, 76) the compensation profiles and annual facts; InternalCosts_View + /// (74) the aggregate cost runs; PayDataReporting_View / _Update / _Export (77, 78) the CRD wizard. Every member + /// reaches their own demographic response. Protected values render REDACTED without a current grant. + /// + [Area("User"), Authorize, ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public sealed class WorkforceController : SecureBaseController + { + private readonly IWorkforceService _workforce; + private readonly ICompensationCostService _compensation; + private readonly IFieldCostingService _costing; + private readonly IPayDataDemographicsService _demographics; + private readonly ICaPayDataReportingService _reporting; + private readonly IBusinessOperationsAccessService _access; + private readonly IPersonnelRolesService _roles; + private readonly IUserProfileService _profiles; + private readonly IUnitsService _units; + private readonly IDeploymentService _deployments; + private readonly IBidsService _bids; + private readonly ICallsService _calls; + private readonly IStringLocalizer _strings; + + public WorkforceController(IWorkforceService workforce, ICompensationCostService compensation, IFieldCostingService costing, IPayDataDemographicsService demographics, ICaPayDataReportingService reporting, + IBusinessOperationsAccessService access, IPersonnelRolesService roles, IUserProfileService profiles, IUnitsService units, IDeploymentService deployments, IBidsService bids, ICallsService calls, + IStringLocalizer strings) + { + _workforce = workforce; + _compensation = compensation; + _costing = costing; + _demographics = demographics; + _reporting = reporting; + _access = access; + _roles = roles; + _profiles = profiles; + _units = units; + _deployments = deployments; + _bids = bids; + _calls = calls; + _strings = strings; + } + + #region Plumbing + + private static bool IsAdmin => ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); + private static bool CanManage => IsAdmin || ClaimsAuthorizationHelper.CanManageWorkforce(); + private static bool CanView => CanManage || ClaimsAuthorizationHelper.CanViewWorkforce(); + private static bool CanManageCompensation => IsAdmin || ClaimsAuthorizationHelper.CanManageWorkforceCompensation(); + private static bool CanViewCompensation => CanManageCompensation || ClaimsAuthorizationHelper.CanViewWorkforceCompensation(); + private static bool CanViewInternalCosts => IsAdmin || ClaimsAuthorizationHelper.CanViewInternalCosts(); + private static bool CanManagePayData => IsAdmin || ClaimsAuthorizationHelper.CanManagePayDataReporting(); + private static bool CanViewPayData => CanManagePayData || ClaimsAuthorizationHelper.CanViewPayDataReporting(); + private static bool CanExportPayData => IsAdmin || ClaimsAuthorizationHelper.CanExportPayDataReporting(); + private static readonly HashSet PayDataActions = new HashSet(StringComparer.OrdinalIgnoreCase) { "PayData", "PayDataRun", "CreatePayDataRun", "BuildSnapshots", "OverrideSnapshot", "AggregateRows", "SaveRemarks", "ValidateRun", "FreezeAndExport", "DownloadArtifact", "Worksheet", "MarkCertified", "CreateCorrection", "VoidRun", "MyDemographics", "SaveMyDemographics", "WorkerDemographics", "SaveWorkerDemographics" }; + private static readonly HashSet SelfActions = new HashSet(StringComparer.OrdinalIgnoreCase) { "MyDemographics", "SaveMyDemographics" }; + + private bool _workforceEnabled; + private bool _payDataEnabled; + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Response.Headers["Cache-Control"] = "no-store"; + _workforceEnabled = await _access.CanUseWorkforceAsync(DepartmentId); + _payDataEnabled = await _access.CanUsePayDataReportingAsync(DepartmentId); + var action = context.ActionDescriptor.RouteValues.TryGetValue("action", out var name) ? name : string.Empty; + var payData = PayDataActions.Contains(action); + if (payData ? !_payDataEnabled : !_workforceEnabled && !(action == "Index" && _payDataEnabled)) { context.Result = Unauthorized(); return; } + // Every member answers their own demographic response; everything else needs a view claim of its family. + if (!SelfActions.Contains(action) && !CanView && !CanViewCompensation && !CanViewInternalCosts && !CanViewPayData) { context.Result = Unauthorized(); return; } + await next(); + } + + private T Page(T view) where T : WorkforcePageView + { + view.CanManage = CanManage; view.CanViewCompensation = CanViewCompensation; view.CanManageCompensation = CanManageCompensation; view.CanViewInternalCosts = CanViewInternalCosts; + view.CanViewPayData = CanViewPayData; view.CanManagePayData = CanManagePayData; view.CanExportPayData = CanExportPayData; + view.WorkforceEnabled = _workforceEnabled; view.PayDataEnabled = _payDataEnabled; + if (TempData["WorkforceMessage"] is string message) view.Message = message; + if (TempData["WorkforceSaved"] 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["WorkforceMessage"] = ErrorText(code); + return RedirectToAction(redirectAction, routeValues); + } + + private IActionResult Saved(string redirectAction, object routeValues = null) + { + if (IsAjax()) return Json(new { success = true }); + TempData["WorkforceSaved"] = 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("workforce_", StringComparison.Ordinal) || ex.Message.StartsWith("paydata_", StringComparison.Ordinal); + + private async Task GuardedAsync(Func> action, string redirectAction, object routeValues = null) + { + try { return await action(); } + catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, redirectAction, routeValues); } + catch (ArgumentException) { return Refused(400, "SaveFailed", redirectAction, routeValues); } + catch (JsonException) { return Refused(400, "SaveFailed", redirectAction, routeValues); } + } + + private async Task> MemberNamesAsync() + { + var profiles = await _profiles.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); + return profiles.ToDictionary(p => p.Key, p => p.Value?.FullName.AsFirstNameLastName ?? p.Key, StringComparer.OrdinalIgnoreCase); + } + + private async Task> RoleItemsAsync() => (await _roles.GetRolesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(r => r.Name).Select(r => new SelectListItem(r.Name, r.PersonnelRoleId.ToString())).ToList(); + private async Task> EstablishmentItemsAsync() => (await _workforce.GetEstablishmentsAsync(DepartmentId)).OrderBy(e => e.Code).Select(e => new SelectListItem($"{e.Code} — {e.Name}", e.WorkforceEstablishmentId)).ToList(); + private async Task> UnitItemsAsync() => (await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(u => u.Name).Select(u => new SelectListItem(u.Name, u.UnitId.ToString())).ToList(); + + private async Task WorkerNameAsync(WorkforceWorker worker) + { + if (worker == null) return null; + if (!string.IsNullOrWhiteSpace(worker.DisplayName)) return worker.DisplayName; + var names = await MemberNamesAsync(); + return worker.UserId != null && names.TryGetValue(worker.UserId, out var n) ? n : worker.WorkforceWorkerId; + } + + #endregion + + #region Dashboard + + [HttpGet] + public async Task Index() + { + var view = Page(new WorkforceDashboardView { ReportingYear = DateTime.UtcNow.Year - 1 }); + if (_workforceEnabled && CanView) + { + view.Employer = await _workforce.GetEmployerProfileAsync(DepartmentId); + view.EstablishmentCount = (await _workforce.GetEstablishmentsAsync(DepartmentId)).Count; + view.WorkerCount = (await _workforce.GetWorkersAsync(DepartmentId)).Count; + view.EmploymentCount = (await _workforce.GetEmploymentsAsync(DepartmentId)).Count; + } + if (_workforceEnabled && CanViewInternalCosts) + { + view.ResourceProfileCount = (await _costing.GetResourceProfilesAsync(DepartmentId)).Count; + view.CostRunCount = (await _costing.GetRunsAsync(DepartmentId, 0, 500)).Count; + } + if (_payDataEnabled && CanViewPayData) + { + view.Readiness = await _reporting.GetReadinessAsync(DepartmentId, view.ReportingYear); + view.Completeness = await _demographics.GetCompletenessAsync(DepartmentId, DateTime.UtcNow); + } + return View(view); + } + + #endregion + + #region Employer, establishments, contractors + + [HttpGet] + public async Task Employer(string affiliate = null) + { + if (!CanView) return Unauthorized(); + var view = Page(new WorkforceEmployerView { Employer = await _workforce.GetEmployerProfileAsync(DepartmentId) ?? new WorkforceEmployerProfile { DepartmentId = DepartmentId }, Affiliates = await _workforce.GetAffiliatesAsync(DepartmentId) }); + if (!string.IsNullOrWhiteSpace(affiliate)) view.EditingAffiliate = view.Affiliates.FirstOrDefault(a => a.WorkforceAffiliatedEntityId == affiliate); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveEmployer(WorkforceEmployerProfile input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _workforce.SaveEmployerProfileAsync(input, UserId, Ip, Agent); + return Saved("Employer"); + }, "Employer"); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveAffiliate(WorkforceAffiliatedEntity input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _workforce.SaveAffiliateAsync(input, UserId, Ip, Agent); + return Saved("Employer"); + }, "Employer"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteAffiliate(string id) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + await _workforce.DeleteAffiliateAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Employer"); + }, "Employer"); + + [HttpGet] + public async Task Establishments(string edit = null) + { + if (!CanView) return Unauthorized(); + var view = Page(new WorkforceEstablishmentsView { Establishments = await _workforce.GetEstablishmentsAsync(DepartmentId) }); + view.Affiliates = (await _workforce.GetAffiliatesAsync(DepartmentId)).Select(a => new SelectListItem(a.LegalName, a.WorkforceAffiliatedEntityId)).ToList(); + if (edit == "new") view.Editing = new WorkforceEstablishment { DepartmentId = DepartmentId, StateCode = "CA" }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = await _workforce.GetEstablishmentAsync(edit, DepartmentId); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveEstablishment(WorkforceEstablishment input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _workforce.SaveEstablishmentAsync(input, UserId, Ip, Agent); + return Saved("Establishments"); + }, "Establishments"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteEstablishment(string id) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + await _workforce.DeleteEstablishmentAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Establishments"); + }, "Establishments"); + + [HttpGet] + public async Task Contractors(string edit = null) + { + if (!CanView) return Unauthorized(); + var view = Page(new WorkforceContractorsView { Contractors = await _workforce.GetLaborContractorsAsync(DepartmentId) }); + if (edit == "new") view.Editing = new WorkforceLaborContractor { DepartmentId = DepartmentId }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Contractors.FirstOrDefault(c => c.WorkforceLaborContractorId == edit); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveContractor(WorkforceLaborContractor input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _workforce.SaveLaborContractorAsync(input, UserId, Ip, Agent); + return Saved("Contractors"); + }, "Contractors"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteContractor(string id) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + await _workforce.DeleteLaborContractorAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Contractors"); + }, "Contractors"); + + #endregion + + #region Workers, employments, assignments + + [HttpGet] + public async Task Workers() + { + if (!CanView) return Unauthorized(); + var view = Page(new WorkforceWorkersView { Workers = await _workforce.GetWorkersAsync(DepartmentId) }); + var employments = await _workforce.GetEmploymentsAsync(DepartmentId); + view.EmploymentCounts = employments.GroupBy(e => e.WorkforceWorkerId).ToDictionary(g => g.Key, g => g.Count()); + var names = await MemberNamesAsync(); + var linked = new HashSet(view.Workers.Where(w => w.UserId != null).Select(w => w.UserId), StringComparer.OrdinalIgnoreCase); + view.Members = names.Where(n => !linked.Contains(n.Key)).OrderBy(n => n.Value).Select(n => new SelectListItem(n.Value, n.Key)).ToList(); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task AddWorker(string userId, string externalWorkerKey, string displayLabel) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + WorkforceWorker worker; + if (!string.IsNullOrWhiteSpace(userId)) worker = await _workforce.GetOrCreateWorkerForUserAsync(DepartmentId, userId, UserId); + else worker = await _workforce.SaveWorkerAsync(new WorkforceWorker { DepartmentId = DepartmentId, ExternalWorkerKey = externalWorkerKey, DisplayLabel = displayLabel }, UserId, Ip, Agent); + return Saved("Worker", new { id = worker.WorkforceWorkerId }); + }, "Workers"); + + [HttpGet] + public async Task Worker(string id, string employment = null, string assignment = null) + { + if (!CanView) return Unauthorized(); + var worker = await _workforce.GetWorkerAsync(id, DepartmentId); + if (worker == null) return NotFound(); + var view = Page(new WorkforceWorkerView { Worker = worker, Employments = await _workforce.GetEmploymentsForWorkerAsync(worker.WorkforceWorkerId, DepartmentId) }); + view.Establishments = await EstablishmentItemsAsync(); + view.Contractors = (await _workforce.GetLaborContractorsAsync(DepartmentId)).Select(c => new SelectListItem(c.LegalName, c.WorkforceLaborContractorId)).ToList(); + view.Affiliates = (await _workforce.GetAffiliatesAsync(DepartmentId)).Select(a => new SelectListItem(a.LegalName, a.WorkforceAffiliatedEntityId)).ToList(); + view.Roles = await RoleItemsAsync(); + if (employment == "new") view.EditingEmployment = new WorkforceEmployment { DepartmentId = DepartmentId, WorkforceWorkerId = worker.WorkforceWorkerId, StartOn = DateTime.UtcNow.Date }; + else if (!string.IsNullOrWhiteSpace(employment)) view.EditingEmployment = view.Employments.FirstOrDefault(e => e.WorkforceEmploymentId == employment); + if (!string.IsNullOrWhiteSpace(assignment)) + { + var parts = assignment.Split(':'); + var owner = view.Employments.FirstOrDefault(e => e.WorkforceEmploymentId == parts[0]); + if (owner != null) view.EditingAssignment = parts.Length > 1 && parts[1] != "new" ? owner.Assignments.FirstOrDefault(a => a.WorkforceJobAssignmentId == parts[1]) : new WorkforceJobAssignment { DepartmentId = DepartmentId, WorkforceEmploymentId = owner.WorkforceEmploymentId, EffectiveOn = owner.StartOn, WorkforceEstablishmentId = owner.DefaultEstablishmentId, WorkCountry = "US", WorkSubdivision = "CA" }; + } + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveWorker(WorkforceWorker input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + var saved = await _workforce.SaveWorkerAsync(input, UserId, Ip, Agent); + return Saved("Worker", new { id = saved.WorkforceWorkerId }); + }, "Workers"); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveEmployment(WorkforceEmployment input) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + var saved = await _workforce.SaveEmploymentAsync(input, UserId, Ip, Agent); + return Saved("Worker", new { id = saved.WorkforceWorkerId }); + }, "Worker", new { id = input?.WorkforceWorkerId }); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteEmployment(string id, string workerId) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + await _workforce.DeleteEmploymentAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Worker", new { id = workerId }); + }, "Worker", new { id = workerId }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveJobAssignment(WorkforceJobAssignment input, string workerId) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _workforce.SaveJobAssignmentAsync(input, UserId, Ip, Agent); + return Saved("Worker", new { id = workerId }); + }, "Worker", new { id = workerId }); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteJobAssignment(string id, string workerId) => GuardedAsync(async () => + { + if (!CanManage) return Unauthorized(); + await _workforce.DeleteJobAssignmentAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Worker", new { id = workerId }); + }, "Worker", new { id = workerId }); + + #endregion + + #region Compensation + + [HttpGet] + public async Task Compensation(string employmentId = null) + { + if (!CanViewCompensation) return Unauthorized(); + var view = Page(new WorkforceCompensationView { Defaults = await _compensation.GetDefaultProfilesAsync(DepartmentId), EmploymentId = employmentId }); + view.RoleNames = (await _roles.GetRolesForDepartmentAsync(DepartmentId) ?? new List()).ToDictionary(r => r.PersonnelRoleId, r => r.Name); + if (!string.IsNullOrWhiteSpace(employmentId)) + { + view.Employee = await _compensation.GetProfilesForEmploymentAsync(employmentId, DepartmentId); + var employment = await _workforce.GetEmploymentAsync(employmentId, DepartmentId); + view.WorkerName = employment == null ? null : await WorkerNameAsync(await _workforce.GetWorkerAsync(employment.WorkforceWorkerId, DepartmentId)); + } + return View(view); + } + + [HttpGet] + public async Task CompensationProfile(string id = null, string employmentId = null, int scope = 2) + { + if (!CanViewCompensation) return Unauthorized(); + EmployeeCompensationProfile profile; + if (string.IsNullOrWhiteSpace(id)) profile = new EmployeeCompensationProfile { DepartmentId = DepartmentId, Scope = string.IsNullOrWhiteSpace(employmentId) ? scope : (int)CompensationScopes.Employee, WorkforceEmploymentId = employmentId, EffectiveOn = DateTime.UtcNow.Date, StandardHoursPerDay = 8, StandardHoursPerWeek = 40 }; + else { profile = await _compensation.GetProfileAsync(id, DepartmentId); if (profile == null) return NotFound(); } + var view = Page(new WorkforceCompensationProfileView { Profile = profile, PayComponentsJson = JsonConvert.SerializeObject(profile.PayComponents.Select(c => new { c.EmployeePayComponentId, c.Category, c.Name, c.Basis, Amount = c.Amount, c.EligiblePayCodesCsv, c.PaidForEachOvertimeHour, c.EffectiveOn, c.ExpiresOn, c.SourceAgreement })), CostComponentsJson = JsonConvert.SerializeObject(profile.CostComponents.Select(c => new { c.EmployeeCostComponentId, c.Category, c.Name, c.Basis, RateAmount = c.RateAmount, c.Cap, c.EligiblePayCodesCsv, c.EffectiveOn, c.ExpiresOn, c.Source })) }); + view.Roles = await RoleItemsAsync(); + if (!string.IsNullOrWhiteSpace(profile.WorkforceEmploymentId)) + { + var employment = await _workforce.GetEmploymentAsync(profile.WorkforceEmploymentId, DepartmentId); + view.WorkerName = employment == null ? null : await WorkerNameAsync(await _workforce.GetWorkerAsync(employment.WorkforceWorkerId, DepartmentId)); + } + if (!string.IsNullOrWhiteSpace(id) && !WorkforceProtectionSeamHelper.IsUnavailable(profile.BaseAmount)) + view.Preview = Resgrid.Services.Workforce.FieldCostCalculator.CalculateLabor(new LaborCostInput { Work = new LaborWorkQuantity { Hours = 8, PayCode = (int)PayCodes.Regular }, Profile = profile, AsOf = DateTime.UtcNow.Date, Currency = profile.Currency }); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveCompensationProfile(EmployeeCompensationProfile input, string payComponentsJson, string costComponentsJson) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + input.DepartmentId = DepartmentId; + var saved = await _compensation.SaveProfileAsync(input, UserId, Ip, Agent); + var pay = string.IsNullOrWhiteSpace(payComponentsJson) ? new List() : JsonConvert.DeserializeObject>(payComponentsJson) ?? new List(); + var cost = string.IsNullOrWhiteSpace(costComponentsJson) ? new List() : JsonConvert.DeserializeObject>(costComponentsJson) ?? new List(); + await _compensation.SaveComponentsAsync(saved.EmployeeCompensationProfileId, DepartmentId, pay, cost, UserId, Ip, Agent); + return Saved("CompensationProfile", new { id = saved.EmployeeCompensationProfileId }); + }, "Compensation"); + + [HttpPost, ValidateAntiForgeryToken] + public Task ApproveCompensationProfile(string id) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + await _compensation.ApproveProfileAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("CompensationProfile", new { id }); + }, "Compensation"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteCompensationProfile(string id, string employmentId) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + await _compensation.DeleteProfileAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Compensation", new { employmentId }); + }, "Compensation"); + + #endregion + + #region Annual facts and work entries + + [HttpGet] + public async Task AnnualFacts(int? year = null, int type = 0, string edit = null) + { + if (!CanViewCompensation) return Unauthorized(); + var view = Page(new WorkforceAnnualFactsView { ReportingYear = year ?? DateTime.UtcNow.Year - 1, ReportType = (PayDataReportTypes)type }); + view.Facts = await _workforce.GetAnnualPayFactsAsync(DepartmentId, view.ReportingYear, view.ReportType); + await LabelEmploymentsAsync(view); + if (edit == "new") view.Editing = new WorkforceAnnualPayFact { DepartmentId = DepartmentId, ReportingYear = view.ReportingYear, ReportType = type }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Facts.FirstOrDefault(f => f.WorkforceAnnualPayFactId == edit); + if (TempData["WorkforceImport"] is string import) { view.ImportResult = JsonConvert.DeserializeObject(import); view.Csv = TempData["WorkforceImportCsv"] as string; } + return View(view); + } + + private async Task LabelEmploymentsAsync(WorkforceAnnualFactsView view) + { + var employments = await _workforce.GetEmploymentsAsync(DepartmentId); + var workers = (await _workforce.GetWorkersAsync(DepartmentId)).ToDictionary(w => w.WorkforceWorkerId, w => w.DisplayName ?? w.WorkforceWorkerId); + foreach (var employment in employments) + { + var label = $"{(workers.TryGetValue(employment.WorkforceWorkerId, out var n) ? n : employment.WorkforceWorkerId)} ({employment.StartOn:yyyy-MM-dd}–{employment.EndOn?.ToString("yyyy-MM-dd") ?? "…"})"; + view.EmploymentLabels[employment.WorkforceEmploymentId] = label; + view.Employments.Add(new SelectListItem(label, employment.WorkforceEmploymentId)); + } + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveAnnualFact(WorkforceAnnualPayFact input, decimal? w2Box5, decimal? w2Box1, decimal? clientAllocatedEarnings) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + input.DepartmentId = DepartmentId; + input.W2Box5Value = w2Box5; input.W2Box1Value = w2Box1; input.ClientAllocatedEarningsValue = clientAllocatedEarnings; + await _workforce.SaveAnnualPayFactAsync(input, UserId, Ip, Agent); + return Saved("AnnualFacts", new { year = input.ReportingYear, type = input.ReportType }); + }, "AnnualFacts"); + + [HttpPost, ValidateAntiForgeryToken] + public Task ImportAnnualFacts(string csv, bool commit, int year, int type) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + var result = await _workforce.ImportAnnualPayFactsAsync(DepartmentId, csv, !commit, UserId, Ip, Agent); + TempData["WorkforceImport"] = JsonConvert.SerializeObject(result); + if (!commit || result.HasErrors) TempData["WorkforceImportCsv"] = csv; + return Saved("AnnualFacts", new { year, type }); + }, "AnnualFacts"); + + [HttpGet] + public async Task WorkEntries(DateTime? from = null, DateTime? to = null, string edit = null) + { + if (!CanViewCompensation) return Unauthorized(); + var view = Page(new WorkforceWorkEntriesView { From = (from ?? DateTime.UtcNow.AddDays(-30)).Date, To = (to ?? DateTime.UtcNow).Date }); + view.Entries = await _workforce.GetWorkEntriesAsync(DepartmentId, view.From, view.To); + var workers = await _workforce.GetWorkersAsync(DepartmentId); + view.WorkerNames = workers.ToDictionary(w => w.WorkforceWorkerId, w => w.DisplayName ?? w.WorkforceWorkerId); + view.Workers = workers.OrderBy(w => w.DisplayName).Select(w => new SelectListItem(w.DisplayName ?? w.WorkforceWorkerId, w.WorkforceWorkerId)).ToList(); + view.Establishments = await EstablishmentItemsAsync(); + if (edit == "new") view.Editing = new WorkforceWorkEntry { DepartmentId = DepartmentId, WorkDate = DateTime.UtcNow.Date, WorkCountry = "US", WorkSubdivision = "CA" }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Entries.FirstOrDefault(e => e.WorkforceWorkEntryId == edit); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveWorkEntry(WorkforceWorkEntry input, decimal? approvedPayrollCost) => GuardedAsync(async () => + { + if (!CanManageCompensation) return Unauthorized(); + input.DepartmentId = DepartmentId; + if (approvedPayrollCost.HasValue) input.ApprovedPayrollCostValue = approvedPayrollCost; + await _workforce.SaveWorkEntryAsync(input, UserId, Ip, Agent); + return Saved("WorkEntries", new { from = input.WorkDate.AddDays(-30).ToString("yyyy-MM-dd"), to = input.WorkDate.ToString("yyyy-MM-dd") }); + }, "WorkEntries"); + + #endregion + + #region Resource costs and usage + + [HttpGet] + public async Task ResourceCosts(string edit = null) + { + if (!CanViewInternalCosts) return Unauthorized(); + var view = Page(new WorkforceResourceCostsView { Profiles = await _costing.GetResourceProfilesAsync(DepartmentId), Units = await UnitItemsAsync() }); + if (edit == "new") view.Editing = new ResourceCostProfile { DepartmentId = DepartmentId, EffectiveOn = DateTime.UtcNow.Date }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = await _costing.GetResourceProfileAsync(edit, DepartmentId); + view.ComponentsJson = JsonConvert.SerializeObject((view.Editing?.Components ?? new List()).Select(c => new { c.ResourceCostComponentId, c.Category, c.Basis, c.Rate, c.ConsumptionQuantity, c.ConsumptionUnit, c.UnitPrice, c.Source, c.SourceWindowStart, c.SourceWindowEnd, c.SourceMeterStart, c.SourceMeterEnd, c.IsApproved, c.EffectiveOn, c.ExpiresOn })); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveResourceCost(ResourceCostProfile input, string componentsJson) => GuardedAsync(async () => + { + if (!CanViewInternalCosts || !CanManage) return Unauthorized(); + input.DepartmentId = DepartmentId; + var saved = await _costing.SaveResourceProfileAsync(input, UserId, Ip, Agent); + var components = string.IsNullOrWhiteSpace(componentsJson) ? new List() : JsonConvert.DeserializeObject>(componentsJson) ?? new List(); + await _costing.SaveResourceComponentsAsync(saved.ResourceCostProfileId, DepartmentId, components, UserId, Ip, Agent); + return Saved("ResourceCosts", new { edit = saved.ResourceCostProfileId }); + }, "ResourceCosts"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteResourceCost(string id) => GuardedAsync(async () => + { + if (!CanViewInternalCosts || !CanManage) return Unauthorized(); + await _costing.DeleteResourceProfileAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("ResourceCosts"); + }, "ResourceCosts"); + + [HttpGet] + public async Task Usage(string deploymentId = null, int? callId = null, string edit = null) + { + if (!CanViewInternalCosts) return Unauthorized(); + if (string.IsNullOrWhiteSpace(deploymentId) && !callId.HasValue) return RedirectToAction("CostRuns"); + var view = Page(new WorkforceUsageView { DeploymentId = deploymentId, CallId = callId, Units = await UnitItemsAsync() }); + view.UnitNames = view.Units.ToDictionary(u => int.Parse(u.Value), u => u.Text); + if (!string.IsNullOrWhiteSpace(deploymentId)) { view.ContextLabel = (await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId))?.Name ?? deploymentId; view.Entries = await _costing.GetUsageForDeploymentAsync(deploymentId, DepartmentId); } + else { view.ContextLabel = (await _calls.GetCallByIdAsync(callId.Value))?.Name ?? $"#{callId}"; view.Entries = await _costing.GetUsageForCallAsync(callId.Value, DepartmentId); } + if (edit == "new") view.Editing = new ResourceUsageEntry { DepartmentId = DepartmentId, DeploymentId = deploymentId, CallId = callId, UsageDate = DateTime.UtcNow.Date, Phase = (int)UsagePhases.Incident }; + else if (!string.IsNullOrWhiteSpace(edit)) view.Editing = view.Entries.FirstOrDefault(e => e.ResourceUsageEntryId == edit); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveUsage(ResourceUsageEntry input) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + input.DepartmentId = DepartmentId; + await _costing.SaveUsageEntryAsync(input, UserId, Ip, Agent); + return Saved("Usage", new { deploymentId = input.DeploymentId, callId = input.CallId }); + }, "CostRuns"); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteUsage(string id, string deploymentId, int? callId) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + await _costing.DeleteUsageEntryAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("Usage", new { deploymentId, callId }); + }, "CostRuns"); + + #endregion + + #region Cost runs + + [HttpGet] + public async Task CostRuns() + { + if (!CanViewInternalCosts) return Unauthorized(); + var view = Page(new WorkforceCostRunsView { Runs = await _costing.GetRunsAsync(DepartmentId, 0, 200) }); + var deployments = await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, false, 0, 200) ?? new List(); + view.Deployments = deployments.OrderByDescending(d => d.StartOn).Select(d => new SelectListItem(d.Name, d.DeploymentId)).ToList(); + var bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, null, 0, 200) ?? new List(); + view.Bids = bids.OrderByDescending(b => b.BidNumber).Select(b => new SelectListItem($"#{b.BidNumber} {b.Title}", b.BidId)).ToList(); + foreach (var d in deployments) view.ContextLabels["D:" + d.DeploymentId] = d.Name; + foreach (var b in bids) view.ContextLabels["B:" + b.BidId] = $"#{b.BidNumber} {b.Title}"; + return View(view); + } + + [HttpGet] + public async Task CostRun(string id) + { + if (!CanViewInternalCosts) return Unauthorized(); + var run = await _costing.GetRunAsync(id, DepartmentId); + if (run == null) return NotFound(); + var view = Page(new WorkforceCostRunView { Run = run, Summary = await _costing.GetFieldCostSummaryAsync(id, DepartmentId) }); + if (!string.IsNullOrWhiteSpace(run.DeploymentId)) { view.ContextLabel = (await _deployments.GetDeploymentByIdAsync(run.DeploymentId, DepartmentId))?.Name; view.Comparison = await _costing.CompareEstimateToActualAsync(run.DeploymentId, DepartmentId); } + else if (!string.IsNullOrWhiteSpace(run.BidId)) { var bid = await _bids.GetBidByIdAsync(run.BidId, DepartmentId); view.ContextLabel = bid == null ? run.BidId : $"#{bid.BidNumber} {bid.Title}"; } + else if (run.CallId.HasValue) view.ContextLabel = (await _calls.GetCallByIdAsync(run.CallId.Value))?.Name ?? $"#{run.CallId}"; + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task RunBidEstimate(string bidId) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + var run = await _costing.EstimateBidCostAsync(bidId, DepartmentId, UserId, Ip, Agent); + return Saved("CostRun", new { id = run.FieldCostRunId }); + }, "CostRuns"); + + [HttpPost, ValidateAntiForgeryToken] + public Task RunCallCost(int callId) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + var run = await _costing.CalculateCallCostAsync(callId, DepartmentId, UserId, Ip, Agent); + return Saved("CostRun", new { id = run.FieldCostRunId }); + }, "CostRuns"); + + [HttpPost, ValidateAntiForgeryToken] + public Task RunDeploymentCost(string deploymentId, DateTime? throughDate, int revenueSource) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + var run = await _costing.CalculateDeploymentCostAsync(deploymentId, DepartmentId, throughDate, Enum.IsDefined(typeof(RevenueSources), revenueSource) ? (RevenueSources)revenueSource : RevenueSources.None, UserId, Ip, Agent); + return Saved("CostRun", new { id = run.FieldCostRunId }); + }, "CostRuns"); + + [HttpPost, ValidateAntiForgeryToken] + public Task FreezeCostRun(string id) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + await _costing.FreezeCostRunAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("CostRun", new { id }); + }, "CostRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task DeleteCostRun(string id) => GuardedAsync(async () => + { + if (!CanViewInternalCosts) return Unauthorized(); + await _costing.DeleteRunAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("CostRuns"); + }, "CostRun", new { id }); + + #endregion + + #region Pay data reporting + + [HttpGet] + public async Task PayData(int? year = null) + { + if (!CanViewPayData) return Unauthorized(); + var view = Page(new WorkforcePayDataView { ReportingYear = year ?? DateTime.UtcNow.Year - 1 }); + view.Runs = await _reporting.GetRunsAsync(DepartmentId, null); + view.Readiness = await _reporting.GetReadinessAsync(DepartmentId, view.ReportingYear); + view.Completeness = await _demographics.GetCompletenessAsync(DepartmentId, new DateTime(view.ReportingYear, 12, 31)); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task CreatePayDataRun(int reportingYear, int reportType, DateTime snapshotStart, DateTime snapshotEnd) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + var run = await _reporting.CreateRunAsync(DepartmentId, reportingYear, (PayDataReportTypes)reportType, snapshotStart, snapshotEnd, UserId, Ip, Agent); + return Saved("PayDataRun", new { id = run.PayDataReportRunId }); + }, "PayData"); + + [HttpGet] + public async Task PayDataRun(string id, string tab = "snapshots") + { + if (!CanViewPayData) return Unauthorized(); + var run = await _reporting.GetRunAsync(id, DepartmentId); + if (run == null) return NotFound(); + var view = Page(new WorkforcePayDataRunView { Run = run, Profile = CaPayDataSchemaProfile.Get(run.SchemaProfileCode) ?? CaPayDataSchemaProfile.Current, Tab = tab }); + view.Snapshots = await _reporting.GetSnapshotsAsync(id, DepartmentId); + view.Rows = await _reporting.GetRowsAsync(id, DepartmentId); + view.Artifacts = await _reporting.GetArtifactsAsync(id, DepartmentId); + view.EstablishmentLabels = (await _workforce.GetEstablishmentsAsync(DepartmentId)).ToDictionary(e => e.WorkforceEstablishmentId, e => $"{e.Code} — {e.Name}"); + if (!string.IsNullOrWhiteSpace(run.ValidationSummaryJson)) { try { view.Validation = JsonConvert.DeserializeObject(run.ValidationSummaryJson); } catch (JsonException) { view.Validation = null; } } + if (TempData["WorkforceValidation"] is string validation) view.Validation = JsonConvert.DeserializeObject(validation); + return View(view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task BuildSnapshots(string id) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.BuildEmployeeSnapshotsAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "snapshots" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task OverrideSnapshot(string id, string snapshotId, bool include, string jobCategoryCode, int? workMode, string reason) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.OverrideSnapshotAsync(id, snapshotId, DepartmentId, include, jobCategoryCode, workMode, reason, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "snapshots" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task AggregateRows(string id) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.AggregateRowsAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "rows" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveRemarks(string id, string runRemarks) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.SaveRemarksAsync(id, DepartmentId, runRemarks, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "validation" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task ValidateRun(string id) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + var result = await _reporting.ValidateRunAsync(id, DepartmentId, UserId, Ip, Agent); + TempData["WorkforceValidation"] = JsonConvert.SerializeObject(result); + return Saved("PayDataRun", new { id, tab = "validation" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task FreezeAndExport(string id) => GuardedAsync(async () => + { + if (!CanExportPayData) return Unauthorized(); + await _reporting.FreezeAndExportAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "artifacts" }); + }, "PayDataRun", new { id }); + + [HttpGet] + public Task DownloadArtifact(string id, string runId) => GuardedAsync(async () => + { + if (!CanExportPayData) return Unauthorized(); + var artifact = await _reporting.DownloadArtifactAsync(id, DepartmentId, UserId, Ip, Agent); + Response.Headers["Cache-Control"] = "no-store"; + return File(artifact.Data, artifact.Format == (int)PayDataExportFormats.Xlsx ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv", artifact.FileName); + }, "PayDataRun", new { id = runId }); + + [HttpGet] + public async Task Worksheet(string id) + { + if (!CanExportPayData && !CanManagePayData) return Unauthorized(); + var worksheet = await _reporting.GetWorksheetAsync(id, DepartmentId); + return View(Page(new WorkforceWorksheetView { Worksheet = worksheet })); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task MarkCertified(string id, string certificationReference) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.MarkCertifiedExternallyAsync(id, DepartmentId, certificationReference, UserId, Ip, Agent); + return Saved("PayDataRun", new { id, tab = "artifacts" }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task CreateCorrection(string id) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + var correction = await _reporting.CreateCorrectionAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("PayDataRun", new { id = correction.PayDataReportRunId }); + }, "PayDataRun", new { id }); + + [HttpPost, ValidateAntiForgeryToken] + public Task VoidRun(string id) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _reporting.VoidRunAsync(id, DepartmentId, UserId, Ip, Agent); + return Saved("PayData"); + }, "PayDataRun", new { id }); + + #endregion + + #region Demographics + + [HttpGet] + public async Task MyDemographics() + { + var view = Page(new WorkforceDemographicsView { Response = await _demographics.GetOwnAsync(DepartmentId, UserId) ?? new PayDataReportingDemographic(), IsOwn = true }); + return View("Demographics", view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveMyDemographics(WorkforceDemographicInput input) => GuardedAsync(async () => + { + await _demographics.SaveOwnAsync(DepartmentId, UserId, ToResponse(input), Ip, Agent); + return Saved("MyDemographics"); + }, "MyDemographics"); + + [HttpGet] + public async Task WorkerDemographics(string workerId) + { + if (!CanManagePayData) return Unauthorized(); + var worker = await _workforce.GetWorkerAsync(workerId, DepartmentId); + if (worker == null) return NotFound(); + var view = Page(new WorkforceDemographicsView { Response = await _demographics.GetForWorkerAsync(DepartmentId, workerId, DateTime.UtcNow) ?? new PayDataReportingDemographic { CollectionSource = (int)DemographicCollectionSources.EmploymentRecord }, IsOwn = false, WorkerId = workerId, WorkerName = await WorkerNameAsync(worker) }); + return View("Demographics", view); + } + + [HttpPost, ValidateAntiForgeryToken] + public Task SaveWorkerDemographics(string workerId, WorkforceDemographicInput input) => GuardedAsync(async () => + { + if (!CanManagePayData) return Unauthorized(); + await _demographics.SaveForWorkerAsync(DepartmentId, workerId, ToResponse(input), input.Reason, UserId, Ip, Agent); + return Saved("WorkerDemographics", new { workerId }); + }, "WorkerDemographics", new { workerId }); + + private static PayDataReportingDemographic ToResponse(WorkforceDemographicInput input) => new PayDataReportingDemographic + { + HispanicLatino = input.HispanicLatino, RaceEthnicityCodes = input.RaceCodes == null ? null : string.Join(",", input.RaceCodes), SexCode = input.SexCode, + DeclinedRaceEthnicity = input.DeclinedRaceEthnicity, DeclinedSex = input.DeclinedSex, CollectionSource = input.CollectionSource + }; + + #endregion + } + + internal static class WorkforceProtectionSeamHelper + { + public static bool IsUnavailable(string value) => Resgrid.Services.Workforce.WorkforceProtectionSeam.IsUnavailable(value); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/CostRecovery/CalOesMarsViews.cs b/Web/Resgrid.Web/Areas/User/Models/CostRecovery/CalOesMarsViews.cs new file mode 100644 index 00000000..7fabdbf8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/CostRecovery/CalOesMarsViews.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.CostRecovery.CalOesMars; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Areas.User.Models.CostRecovery +{ + // Workforce & Business Operations plan, Phase C-M3 (C6): the five Cal OES MARS screens — readiness dashboard, annual + // rate workspace, incident action queue, F-42 / expense editor with handoff, and invoice / payment reconciliation — + // plus the agency, F-5 resource and agreement sub-pages the dashboard links to. + + public class CalOesMarsPageView + { + public bool IsManager { get; set; } + public bool CanSubmit { get; set; } + public bool CanReconcile { get; set; } + public string AuthorityProfileCode { get; set; } + public string PortalUrl { get; set; } + public string Message { get; set; } + public bool SaveSuccess { get; set; } + } + + public class CalOesMarsDashboardView : CalOesMarsPageView + { + public CalOesMarsReadiness Readiness { get; set; } + public CalOesMarsAuthorityProfile Authority { get; set; } + public DateTime AsOf { get; set; } + } + + public class CalOesMarsAgencyView : CalOesMarsPageView + { + public CalOesMarsAgencyProfile Agency { get; set; } = new CalOesMarsAgencyProfile(); + } + + public class CalOesMarsResourcesView : CalOesMarsPageView + { + public List Resources { get; set; } = new List(); + public List Units { get; set; } = new List(); + public CalOesMarsResourceProfile Editing { get; set; } + public IReadOnlyList ResourceTypes { get; set; } = Array.Empty(); + } + + public class CalOesMarsRatesView : CalOesMarsPageView + { + public List Profiles { get; set; } = new List(); + public int? Year { get; set; } + } + + public class CalOesMarsRateEditView : CalOesMarsPageView + { + public CalOesMarsRateProfile Profile { get; set; } = new CalOesMarsRateProfile(); + public bool IsNew => string.IsNullOrWhiteSpace(Profile?.CalOesMarsRateProfileId); + public string LinesJson { get; set; } + public string InputsJson { get; set; } + public CalOesMarsAdministrativeRateDraft AdministrativeDraft { get; set; } + public IReadOnlyList Classifications { get; set; } = Array.Empty(); + public IReadOnlyList ResourceTypes { get; set; } = Array.Empty(); + public List NextStatuses { get; set; } = new List(); + } + + public class CalOesMarsAgreementsView : CalOesMarsPageView + { + public List Agreements { get; set; } = new List(); + public CalOesMarsAgreementSnapshot Editing { get; set; } + public IReadOnlyList Classifications { get; set; } = Array.Empty(); + } + + public class CalOesMarsQueueView : CalOesMarsPageView + { + public List Items { get; set; } = new List(); + public int? TypeFilter { get; set; } + public List CostRecoveryDeployments { get; set; } = new List(); + } + + public class CalOesMarsWorkItemView : CalOesMarsPageView + { + public CalOesMarsWorkItem Item { get; set; } + public CalOesMarsF42Snapshot F42 { get; set; } + public CalOesMarsExpenseClaimSnapshot Expense { get; set; } + public CalOesMarsInvoiceSnapshot Invoice { get; set; } + public CalOesMarsValidationResult Validation { get; set; } + public CalOesMarsAuthorityProfile Authority { get; set; } + public List Attachments { get; set; } = new List(); + public List Related { get; set; } = new List(); + public bool IsRostered { get; set; } + public bool CanEdit { get; set; } + public string SnapshotJson { get; set; } + public IReadOnlyList Classifications { get; set; } = Array.Empty(); + } + + public class CalOesMarsHandoffView : CalOesMarsPageView + { + public CalOesMarsWorkItem Item { get; set; } + public CalOesMarsHandoffManifest Manifest { get; set; } + } + + public class CalOesMarsReconciliationView : CalOesMarsPageView + { + public List Invoices { get; set; } = new List(); + public List Coverable { get; set; } = new List(); + public Dictionary DeploymentNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public class CalOesMarsInvoiceView : CalOesMarsPageView + { + public CalOesMarsInvoiceReconciliation Reconciliation { get; set; } + } + + #region Form inputs + + public sealed class CalOesMarsObservationInput + { + public string ExternalId { get; set; } + public string ExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public string Comment { get; set; } + public string ArtifactChecksum { get; set; } + public int? ArtifactAttachmentId { get; set; } + } + + public sealed class CalOesMarsInvoiceInput + { + public string DeploymentId { get; set; } + public string MarsInvoiceId { get; set; } + public DateTime? InvoiceDate { get; set; } + public decimal InvoicedTotal { get; set; } + public string PayingEntity { get; set; } + public string ExternalStatus { get; set; } + public DateTime? ObservedOn { get; set; } + public List CoveredWorkItemIds { get; set; } = new List(); + public string Comment { get; set; } + } + + public sealed class CalOesMarsPaymentInput + { + public decimal PaidTotal { get; set; } + public DateTime PaidOn { get; set; } + public string PaymentReference { get; set; } + public string PayingEntityStatus { get; set; } + public string Comment { get; set; } + } + + #endregion +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Workforce/WorkforceViews.cs b/Web/Resgrid.Web/Areas/User/Models/Workforce/WorkforceViews.cs new file mode 100644 index 00000000..5af60115 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Workforce/WorkforceViews.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model.Workforce; + +namespace Resgrid.Web.Areas.User.Models.Workforce +{ + // Workforce & Business Operations plan, Phase E (E6): view models for the workforce workspace. Protected values + // arrive already resolved by the services (REDACTED without a grant); the views render them with SafeDisplay. + + public class WorkforcePageView + { + public bool CanManage { get; set; } + public bool CanViewCompensation { get; set; } + public bool CanManageCompensation { get; set; } + public bool CanViewInternalCosts { get; set; } + public bool CanViewPayData { get; set; } + public bool CanManagePayData { get; set; } + public bool CanExportPayData { get; set; } + public bool WorkforceEnabled { get; set; } + public bool PayDataEnabled { get; set; } + public string Message { get; set; } + public bool SaveSuccess { get; set; } + } + + public class WorkforceDashboardView : WorkforcePageView + { + public WorkforceEmployerProfile Employer { get; set; } + public int EstablishmentCount { get; set; } + public int WorkerCount { get; set; } + public int EmploymentCount { get; set; } + public int ResourceProfileCount { get; set; } + public int CostRunCount { get; set; } + public PayDataReadiness Readiness { get; set; } + public DemographicCompleteness Completeness { get; set; } + public int ReportingYear { get; set; } + } + + public class WorkforceEmployerView : WorkforcePageView + { + public WorkforceEmployerProfile Employer { get; set; } + public List Affiliates { get; set; } = new List(); + public WorkforceAffiliatedEntity EditingAffiliate { get; set; } + } + + public class WorkforceEstablishmentsView : WorkforcePageView + { + public List Establishments { get; set; } = new List(); + public WorkforceEstablishment Editing { get; set; } + public List Affiliates { get; set; } = new List(); + } + + public class WorkforceContractorsView : WorkforcePageView + { + public List Contractors { get; set; } = new List(); + public WorkforceLaborContractor Editing { get; set; } + } + + public class WorkforceWorkersView : WorkforcePageView + { + public List Workers { get; set; } = new List(); + public Dictionary EmploymentCounts { get; set; } = new Dictionary(); + public List Members { get; set; } = new List(); + } + + public class WorkforceWorkerView : WorkforcePageView + { + public WorkforceWorker Worker { get; set; } + public List Employments { get; set; } = new List(); + public WorkforceEmployment EditingEmployment { get; set; } + public WorkforceJobAssignment EditingAssignment { get; set; } + public List Establishments { get; set; } = new List(); + public List Contractors { get; set; } = new List(); + public List Affiliates { get; set; } = new List(); + public List Roles { get; set; } = new List(); + public IReadOnlyList JobCategories { get; set; } = CaPayDataSchemaProfile.Current.JobCategories; + public string ProfileCode { get; set; } = CaPayDataSchemaProfile.Current.Code; + } + + public class WorkforceCompensationView : WorkforcePageView + { + public List Defaults { get; set; } = new List(); + public List Employee { get; set; } = new List(); + public string EmploymentId { get; set; } + public string WorkerName { get; set; } + public Dictionary RoleNames { get; set; } = new Dictionary(); + } + + public class WorkforceCompensationProfileView : WorkforcePageView + { + public EmployeeCompensationProfile Profile { get; set; } + public string PayComponentsJson { get; set; } + public string CostComponentsJson { get; set; } + public List Roles { get; set; } = new List(); + public string WorkerName { get; set; } + public LaborCostResult Preview { get; set; } + } + + public class WorkforceAnnualFactsView : WorkforcePageView + { + public int ReportingYear { get; set; } + public PayDataReportTypes ReportType { get; set; } + public List Facts { get; set; } = new List(); + public Dictionary EmploymentLabels { get; set; } = new Dictionary(); + public WorkforceImportResult ImportResult { get; set; } + public string Csv { get; set; } + public WorkforceAnnualPayFact Editing { get; set; } + public List Employments { get; set; } = new List(); + } + + public class WorkforceWorkEntriesView : WorkforcePageView + { + public DateTime From { get; set; } + public DateTime To { get; set; } + public List Entries { get; set; } = new List(); + public Dictionary WorkerNames { get; set; } = new Dictionary(); + public WorkforceWorkEntry Editing { get; set; } + public List Workers { get; set; } = new List(); + public List Establishments { get; set; } = new List(); + } + + public class WorkforceResourceCostsView : WorkforcePageView + { + public List Profiles { get; set; } = new List(); + public ResourceCostProfile Editing { get; set; } + public string ComponentsJson { get; set; } + public List Units { get; set; } = new List(); + } + + public class WorkforceUsageView : WorkforcePageView + { + public string DeploymentId { get; set; } + public int? CallId { get; set; } + public string ContextLabel { get; set; } + public List Entries { get; set; } = new List(); + public ResourceUsageEntry Editing { get; set; } + public List Units { get; set; } = new List(); + public Dictionary UnitNames { get; set; } = new Dictionary(); + } + + public class WorkforceCostRunsView : WorkforcePageView + { + public List Runs { get; set; } = new List(); + public List Deployments { get; set; } = new List(); + public List Bids { get; set; } = new List(); + public Dictionary ContextLabels { get; set; } = new Dictionary(); + } + + public class WorkforceCostRunView : WorkforcePageView + { + public FieldCostRun Run { get; set; } + public FieldCostSummary Summary { get; set; } + public string ContextLabel { get; set; } + public FieldCostComparison Comparison { get; set; } + } + + public class WorkforcePayDataView : WorkforcePageView + { + public int ReportingYear { get; set; } + public List Runs { get; set; } = new List(); + public PayDataReadiness Readiness { get; set; } + public DemographicCompleteness Completeness { get; set; } + public IReadOnlyList Profiles { get; set; } = CaPayDataSchemaProfile.All; + } + + public class WorkforcePayDataRunView : WorkforcePageView + { + public PayDataReportRun Run { get; set; } + public CaPayDataSchemaProfile Profile { get; set; } + public List Snapshots { get; set; } = new List(); + public List Rows { get; set; } = new List(); + public List Artifacts { get; set; } = new List(); + public PayDataValidationResult Validation { get; set; } + public Dictionary EstablishmentLabels { get; set; } = new Dictionary(); + public string Tab { get; set; } = "snapshots"; + } + + public class WorkforceWorksheetView : WorkforcePageView + { + public PayDataPortalWorksheet Worksheet { get; set; } + } + + public class WorkforceDemographicsView : WorkforcePageView + { + public PayDataReportingDemographic Response { get; set; } + public bool IsOwn { get; set; } = true; + public string WorkerId { get; set; } + public string WorkerName { get; set; } + public string Reason { get; set; } + public IReadOnlyList Races { get; set; } = CaPayDataSchemaProfile.Current.RaceEthnicities; + public IReadOnlyList Sexes { get; set; } = CaPayDataSchemaProfile.Current.Sexes; + } + + public class WorkforceDemographicInput + { + public string HispanicLatino { get; set; } + public string[] RaceCodes { get; set; } + public string SexCode { get; set; } + public bool DeclinedRaceEthnicity { get; set; } + public bool DeclinedSex { get; set; } + public int CollectionSource { get; set; } + public string Reason { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml new file mode 100644 index 00000000..6a1c246e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agency.cshtml @@ -0,0 +1,60 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsAgencyView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AgencyProfile"]; + ViewData["Title"] = localizer["AgencyProfile"].Value; + ViewData["Subtitle"] = localizer["AgencyIntro"].Value; + ViewData["MarsTab"] = "agency"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var a = Model.Agency; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["AgencyProfile"]
+
+
@localizer["HelpAgency"]
+
+ @Html.AntiForgeryToken() + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@localizer["PortalReferenceHelp"]
+
+
+
+
@localizer["Cancel"]
+
+
+
+
+
+
+
@localizer["Verification"]
+
+

@localizer["VerifiedOn"]: @(a.VerifiedOn?.ToString("yyyy-MM-dd") ?? "—")

+

@localizer["VerificationHelp"]

+

@localizer["NoCredentialsStored"]

+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml new file mode 100644 index 00000000..78f78cb2 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml @@ -0,0 +1,93 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsAgreementsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Agreements"]; + ViewData["Title"] = localizer["Agreements"].Value; + ViewData["Subtitle"] = localizer["AgreementsIntro"].Value; + ViewData["MarsTab"] = "agreements"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var editing = Model.Editing ?? new Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAgreementSnapshot(); + var kinds = Enum.GetValues(); + var methods = Enum.GetValues(); + var overtime = Enum.GetValues(); + var today = DateTime.UtcNow; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["Agreements"]
+
+
@localizer["HelpAgreements"]
+ @if (Model.Agreements.Count == 0) {

@localizer["NoAgreements"]

} + else + { + + + + @foreach (var a in Model.Agreements) + { + + + + + + + + + + } + +
@localizer["Classification"]@localizer["DocumentKind"]@localizer["CompensationMethod"]@localizer["OvertimeMethod"]@localizer["Window"]@localizer["ObservedStatus"]
@(a.ClassificationTitle ?? a.ClassificationCode ?? localizer["AllClassifications"].Value)@localizer["DocumentKind" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsDocumentKinds)a.DocumentKind]@localizer["CompensationMethod" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsCompensationMethods)a.CompensationMethod]@localizer["OvertimeMethod" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsOvertimeMethods)a.OvertimeMethod]@(a.StartOn?.ToString("yyyy-MM-dd") ?? "—") → @(a.EndOn?.ToString("yyyy-MM-dd") ?? "—")@(a.ExternalApprovalStatus ?? "—") v@(a.RowVersion) + @if (Model.IsManager) + { + +
@Html.AntiForgeryToken()
+ } +
+ } +
+
+
+
+ @if (Model.IsManager) + { +
+
@(Model.Editing == null ? localizer["AddAgreement"] : localizer["EditAgreement"])
+
+
+ @Html.AntiForgeryToken() + +
@foreach (var c in Model.Classifications) { }
+
+
+
+
+
+
@localizer["AttachmentIdHelp"]
+
+ + @if (Model.Editing != null) { @localizer["Cancel"] } +
+ @if (Model.Editing != null && Model.CanSubmit) + { +
+
@localizer["ObserveInMars"]
+
+ @Html.AntiForgeryToken() +
+
+ +
+ } +
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml new file mode 100644 index 00000000..2b7a7c60 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Handoff.cshtml @@ -0,0 +1,78 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsHandoffView +@inject IStringLocalizer localizer +@{ + var w = Model.Item; + var m = Model.Manifest; + ViewBag.Title = "Resgrid | " + localizer["PortalHandoff"]; + ViewData["Title"] = localizer["PortalHandoff"].Value; + ViewData["Subtitle"] = localizer["HandoffIntro"].Value; + ViewData["MarsTab"] = "queue"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+
@localizer["HandoffWarning"]
+
+
+
+
+
@localizer["RecordType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)w.RecordType] @await Html.PartialAsync("_WorkItemStateBadge", w.LocalState)
+ +
+
+ + + + @foreach (var field in m.Fields) + { + + + + + + + } + +
@localizer["Box"]@localizer["Value"]@localizer["Source"]
@field.Label@(field.Value ?? "")@field.Source@if (!string.IsNullOrWhiteSpace(field.Value)) { }
+ @if (m.SupportingAttachmentNames.Count > 0) + { +
@localizer["SupportingDocuments"]
+
    @foreach (var name in m.SupportingAttachmentNames) {
  • @name
  • }
+ } +
+
+
+
+
+
@localizer["Provenance"]
+
+
+
@localizer["AuthorityProfile"]
@m.AuthorityProfileCode
+
@localizer["RateProfileVersion"]
@(m.RateProfileVersion ?? "—")
+
@localizer["Agreement"]
@(m.AgreementSnapshotId ?? "—")
+
@localizer["Checksum"]
@m.Checksum
+
@localizer["GeneratedOn"]
@m.GeneratedOn.ToString("yyyy-MM-dd HH:mm") UTC
+
+

@localizer["NotAnImportFile"]

+ @if (m.Validation != null && !m.Validation.IsReadyForPortal) {

@localizer["HandoffWithErrors"]

} + @localizer["EvidencePacket"] +
+
+
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml new file mode 100644 index 00000000..5a0d2cb4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Index.cshtml @@ -0,0 +1,139 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsDashboardView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CalOesMars"]; + ViewData["Title"] = localizer["ReadinessDashboard"].Value; + ViewData["Subtitle"] = localizer["ReadinessIntro"].Value; + ViewData["MarsTab"] = "readiness"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var r = Model.Readiness; + var blockers = r.Items.Where(i => i.Severity == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsReadinessSeverities.Blocker).ToList(); + var warnings = r.Items.Where(i => i.Severity == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsReadinessSeverities.Warning).ToList(); +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+ @localizer["HelpReadiness"] + @localizer["AuthorityProfile"]: @(r.AuthorityProfileCode ?? "—") (@localizer["ReviewedOn"] @Model.Authority.ReviewedOn.ToString("yyyy-MM-dd")) + @if (!r.AuthorityProfileCurrent) { @localizer["AuthorityStale"] } + · @localizer["OpenPortal"] +
+
+
+
+
+
@localizer["ReadinessAsOf"] @r.AsOf.ToString("yyyy-MM-dd") @if (r.IsReady) { @localizer["Ready"] } else { @localizer["Blocked"] }
+
+
+ + +
+
+
+
+ @if (r.Items.Count == 0) + { +

@localizer["NoReadinessIssues"]

+ } + else + { + + + + @foreach (var item in blockers.Concat(warnings)) + { + + + + + + + } + +
@localizer["Severity"]@localizer["Item"]@localizer["Detail"]
@if (item.Severity == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsReadinessSeverities.Blocker) { @localizer["Blocker"] } else { @localizer["Warning"] }@localizer[item.MessageKey]@item.Detail@if (Model.IsManager && !string.IsNullOrWhiteSpace(item.Area)) { @localizer["Fix"] }
+ } +
+
+
+
@localizer["AnnualSubmissions"]
+
+ @if (r.CurrentRateProfiles.Count == 0) {

@localizer["NoCurrentRateProfiles"]

} + else + { + + + + @foreach (var p in r.CurrentRateProfiles) + { + + + + + + + + } + +
@localizer["SubmissionType"]@localizer["Year"]@localizer["Status"]@localizer["Window"]@localizer["ObservedStatus"]
@if (Model.IsManager) { @localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType] } else { @localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType] }@p.SubmissionYear@localizer["RateStatus" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses)p.Status]@(p.EffectiveOn?.ToString("yyyy-MM-dd") ?? "—") → @(p.ExpiresOn?.ToString("yyyy-MM-dd") ?? "—")@(p.ObservedExternalStatus ?? "—")
+ } +
+
+
+
+
+
@localizer["Agency"]
+
+ @if (r.Agency == null) {

@localizer["NoAgencyProfile"]

} + else + { +
+
@localizer["MacsDesignator"]
@(r.Agency.MacsDesignator ?? "—")
+
@localizer["AgencyName"]
@r.Agency.AgencyName
+
@localizer["Fein"]
@(string.IsNullOrWhiteSpace(r.Agency.FeinReference) ? "—" : localizer["Recorded"].Value)
+
@localizer["Uei"]
@(string.IsNullOrWhiteSpace(r.Agency.UeiReference) ? "—" : localizer["Recorded"].Value)
+
@localizer["FiscalSupplier"]
@(string.IsNullOrWhiteSpace(r.Agency.FiscalSupplierReference) ? "—" : localizer["Recorded"].Value)
+
@localizer["PortalRole"]
@(r.Agency.PortalAccountRole ?? "—")
+
@localizer["VerifiedOn"]
@(r.Agency.VerifiedOn?.ToString("yyyy-MM-dd") ?? "—")
+
+ } + @if (Model.IsManager) + { + @localizer["EditAgency"] + @if (r.Agency != null) + { +
@Html.AntiForgeryToken()
+ } + } +
+
+
+
@localizer["Counts"]
+
+
+
@localizer["ResourceProfiles"]
@r.ResourceProfiles @if (r.ResourceMismatches > 0) { @r.ResourceMismatches @localizer["Mismatches"] }
+
@localizer["Agreements"]
@r.CurrentAgreements.Count
+
@localizer["OpenWorkItems"]
@r.OpenWorkItems
+
@localizer["ReturnedItems"]
@r.ReturnedWorkItems
+
@localizer["InvoicesAwaiting"]
@if (Model.IsManager || Model.CanReconcile) { @r.InvoicesAwaitingLocalApproval } else { @r.InvoicesAwaitingLocalApproval }
+
+
+
+
+
@localizer["OfficialSources"]
+
+
    + @foreach (var source in Model.Authority.Sources) + { +
  • @source.Title @source.PublishedOn.ToString("yyyy-MM-dd")
  • + } +
+

@localizer["NoCredentialsStored"]

+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml new file mode 100644 index 00000000..2b8d1c95 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Invoice.cshtml @@ -0,0 +1,94 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsInvoiceView +@inject IStringLocalizer localizer +@{ + var r = Model.Reconciliation; + var i = r.Invoice; + var s = r.Snapshot; + ViewBag.Title = "Resgrid | " + localizer["MarsInvoice"] + " " + i.MarsInvoiceId; + ViewData["Title"] = $"{localizer["MarsInvoice"].Value} {i.MarsInvoiceId}"; + ViewData["Subtitle"] = localizer["InvoiceIntro"].Value; + ViewData["MarsTab"] = "reconciliation"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var pendingLocal = i.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingLocalAgencyApproval; + var payable = i.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingPayingEntityApproval; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["MarsInvoice"] @await Html.PartialAsync("_WorkItemStateBadge", i.LocalState)
+ +
+
+

@localizer["MarsInvoiceNotPhaseB"]

+
+
@localizer["MarsInvoiceId"]
@i.MarsInvoiceId
+
@localizer["InvoiceDate"]
@(s.InvoiceDate?.ToString("yyyy-MM-dd") ?? "—")
+
@localizer["PayingEntity"]
@(s.PayingEntity ?? "—")
+
@localizer["ObservedStatus"]
@(i.ObservedExternalStatus ?? "—") @(i.ObservedOn?.ToString("yyyy-MM-dd"))
+
@localizer["LocalDecision"]
@(s.LocalDecisionTitle ?? "—") @(string.IsNullOrWhiteSpace(s.LocalDecisionComment) ? "" : "— " + s.LocalDecisionComment) @(i.ApprovedOn?.ToString("yyyy-MM-dd") ?? i.RejectedOn?.ToString("yyyy-MM-dd"))
+
@localizer["Paid"]
@(i.PaidTotal?.ToString("N2") ?? "—") @(i.PaidOn?.ToString("yyyy-MM-dd")) @i.PaymentReference
+
+

@localizer["ExpectedVsObserved"]

+ + + + @foreach (var c in r.CoveredItems) + { + + } + + + + + +
@localizer["RecordType"]@localizer["Deployment"]@localizer["MarsRecordId"]@localizer["State"]@localizer["Expected"]
@localizer["RecordType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)c.RecordType]@c.DeploymentName@(c.MarsRecordId ?? "—")@await Html.PartialAsync("_WorkItemStateBadge", c.LocalState)@((c.ExpectedTotal ?? 0).ToString("N2"))
@localizer["ExpectedTotal"]@r.ExpectedTotal.ToString("N2")
@localizer["Invoiced"]@(r.InvoicedTotal?.ToString("N2") ?? "—")
@localizer["Variance"]@(r.Variance?.ToString("N2") ?? "—")
@localizer["Paid"]@(r.PaidTotal?.ToString("N2") ?? "—")
+
+
+
+
+ @if (Model.CanReconcile && pendingLocal) + { +
+
@localizer["LocalDecision"]
+
+

@localizer["LocalDecisionHelp"]

+
+ @Html.AntiForgeryToken() +
+
+ + +
+
+
+ } + @if (Model.CanReconcile && payable) + { +
+
@localizer["RecordPayment"]
+
+

@localizer["RecordPaymentHelp"]

+
+ @Html.AntiForgeryToken() +
+
+
+
+ +
+
+
+ } + @if (Model.IsManager && i.LocalState is (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Paid or (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.LocalAgencyRejected or (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.DocumentationOnly) + { +
@Html.AntiForgeryToken()
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml new file mode 100644 index 00000000..6b11f0e5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Queue.cshtml @@ -0,0 +1,84 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsQueueView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ActionQueue"]; + ViewData["Title"] = localizer["ActionQueue"].Value; + ViewData["Subtitle"] = localizer["QueueIntro"].Value; + ViewData["MarsTab"] = "queue"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var types = new[] { Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes.F42, Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes.ExpenseClaim, Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes.GeneratedInvoice }; + var groups = Model.Items.GroupBy(i => i.WorkItem.DeploymentId ?? string.Empty).ToList(); +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["ActionQueue"]
+
+
@localizer["HelpQueue"]
+
+ @localizer["AllTypes"] + @foreach (var t in types) { @localizer["RecordType" + t] } +
+ @if (Model.Items.Count == 0) {

@localizer["NoQueueItems"]

} + else + { + foreach (var group in groups) + { + var first = group.First(); +

@(first.DeploymentName ?? localizer["NoDeployment"].Value) @first.IncidentNumber

+ + + + @foreach (var item in group) + { + var w = item.WorkItem; + + + + + + + + + + + } + +
@localizer["RecordType"]@localizer["Request"]@localizer["State"]@localizer["Checklist"]@localizer["ObservedStatus"]@localizer["Expected"]@localizer["Age"]
@localizer["RecordType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)w.RecordType] @if (item.IsMine) { }@(item.RequestNumber ?? w.MarsInvoiceId ?? "—")@await Html.PartialAsync("_WorkItemStateBadge", w.LocalState)@if (item.ErrorCount > 0) { @item.ErrorCount } @if (item.WarningCount > 0) { @item.WarningCount } @if (item.ErrorCount == 0 && item.WarningCount == 0 && w.LocalState >= (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReadyForPortal) { }@(w.ObservedExternalStatus ?? "—") @(w.ObservedOn?.ToString("yyyy-MM-dd"))@(Model.IsManager && w.ExpectedTotal.HasValue ? w.ExpectedTotal.Value.ToString("N2") : "—")@item.AgeDays d @localizer["Open"]
+ } + } +
+
+
+
+
+
@localizer["PrepareRecord"]
+
+

@localizer["PrepareRecordHelp"]

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

@localizer["NoCostRecoveryDeployments"]

} + else + { +
+ @Html.AntiForgeryToken() +
+
+ +
+
+ @Html.AntiForgeryToken() +
+
+ +
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml new file mode 100644 index 00000000..7b8eb6f3 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml @@ -0,0 +1,239 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsRateEditView +@inject IStringLocalizer localizer +@{ + var p = Model.Profile; + var typeName = localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType].Value; + ViewBag.Title = "Resgrid | " + typeName; + ViewData["Title"] = Model.IsNew ? localizer["NewSubmission"].Value : $"{typeName} {p.SubmissionYear}"; + ViewData["Subtitle"] = localizer["RateEditIntro"].Value; + ViewData["MarsTab"] = "rates"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var types = Enum.GetValues(); + var methods = Enum.GetValues(); + var lineKinds = Enum.GetValues(); + var bases = Enum.GetValues(); + var authorities = Enum.GetValues(); + var classifications = Enum.GetValues(); + var reviews = Enum.GetValues(); + var isAdmin = p.SubmissionType == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes.AdministrativeRate; + var editable = Model.IsNew || p.IsEditable; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) + @if (!editable) {
@localizer["RateLocked"]
} +
+
+
+
@localizer["Submission"] @if (!Model.IsNew) { @localizer["RateStatus" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses)p.Status] }
+
+
+ @Html.AntiForgeryToken() + +
+
+
+
+
@localizer["BaseRateAcceptedHelp"]
+
+
+
+
+
+ @if (editable) + { +
@localizer["Cancel"]
+ } +
+ @if (!Model.IsNew) + { +
+
+
@localizer["AuthorityProfile"]
@p.AuthorityProfileCode
+
@localizer["SignedOn"]
@(p.SignedOn?.ToString("yyyy-MM-dd") ?? "—") @p.SignedByName
+
@localizer["ObservedStatus"]
@(p.ObservedExternalStatus ?? "—") @(p.ObservedOn?.ToString("yyyy-MM-dd"))
+
@localizer["Version"]
@p.RowVersion
+
+ @foreach (var next in Model.NextStatuses) + { +
+ @Html.AntiForgeryToken() + @if (next == Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses.SignedLocally) { } + +
+ } + @if (p.IsEditable) + { +
@Html.AntiForgeryToken()
+ } + @if (Model.CanSubmit && p.Status >= (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses.SignedLocally) + { +
+
@localizer["ObserveInMars"]
+
+ @Html.AntiForgeryToken() + + + +
+ } + } +
+
+ @if (!Model.IsNew && (isAdmin || Model.AdministrativeDraft != null)) + { +
+
@localizer["AdministrativeWorksheet"]
+
+

@localizer["AdministrativeWorksheetHelp"]

+ @if (Model.AdministrativeDraft != null) + { + var d = Model.AdministrativeDraft; +
+
@localizer["AllowableDirect"]
@d.AllowableDirect.ToString("N2")
+
@localizer["AllowableIndirect"]
@d.AllowableIndirect.ToString("N2")
+
@localizer["ExcludedUnallowable"]
@d.ExcludedUnallowable.ToString("N2")
+
@localizer["ExcludedIncidentDirect"]
@d.ExcludedIncidentDirect.ToString("N2")
+
@localizer["CalculatedPercent"]
@(d.CalculatedPercent?.ToString("0.####") ?? "—") %
+
@localizer["DeMinimisPercent"]
@d.DeMinimisPercent.ToString("0.##") %
+
@localizer["MethodChosen"]
@localizer["AdminMethod" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAdministrativeRateMethods)d.MethodChosen] @(d.ChosenPercent?.ToString("0.####")) %
+
+ @foreach (var blocker in d.Blockers) {

@localizer["AdminBlocker_" + blocker]

} + } + @if (editable) + { +
@Html.AntiForgeryToken()
+ } +
+
+ } +
+
+ @if (!Model.IsNew) + { +
+
@localizer["RateLines"]
+
+

@localizer["RateLinesHelp"]

+
+ @Html.AntiForgeryToken() + +
+ + + +
@localizer["LineKind"]@localizer["Classification"]@localizer["ResourceCode"]FEMA@localizer["Basis"]@localizer["StraightRate"]@localizer["OvertimeRate"]P2POTWCUI@localizer["Authority"]
+
+ @if (editable) + { + + + } +
+
+
+ @if (isAdmin || p.AdministrativeInputs.Count > 0) + { +
+
@localizer["AdministrativeInputs"]
+
+

@localizer["AdministrativeInputsHelp"]

+
+ @Html.AntiForgeryToken() + +
+ + + +
FY@localizer["FunctionCode"]@localizer["CategoryCode"]@localizer["Classification"]@localizer["ActualAmount"]@localizer["SourceSystem"]@localizer["SourceLine"]Inc.2x@localizer["ReviewStatus"]@localizer["ReviewReason"]
+
+ @if (editable) + { + + + } +
+
+
+ } + } +
+
+
+ +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml new file mode 100644 index 00000000..03bbab6e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rates.cshtml @@ -0,0 +1,63 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsRatesView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AnnualRates"]; + ViewData["Title"] = localizer["AnnualRates"].Value; + ViewData["Subtitle"] = localizer["RatesIntro"].Value; + ViewData["MarsTab"] = "rates"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var types = Enum.GetValues(); + var years = Model.Profiles.Select(p => p.SubmissionYear).Distinct().OrderByDescending(y => y).ToList(); +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
@localizer["AnnualRates"]
+ @if (Model.IsManager) + { +
+
+ + +
+
+ } +
+
+
@localizer["HelpRates"]
+
+ @localizer["AllYears"] + @foreach (var y in years) { @y } +
+ @if (Model.Profiles.Count == 0) {

@localizer["NoRateProfiles"]

} + else + { + + + + @foreach (var p in Model.Profiles) + { + + + + + + + + + + + } + +
@localizer["Year"]@localizer["SubmissionType"]@localizer["Status"]@localizer["Window"]@localizer["AdministrativeRate"]@localizer["SignedOn"]@localizer["ObservedStatus"]
@p.SubmissionYear@if (Model.IsManager) { @localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType] } else { @localizer["SubmissionType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubmissionTypes)p.SubmissionType] } @if (p.BaseRateAccepted) { @localizer["BaseRateAccepted"] }@localizer["RateStatus" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses)p.Status]@(p.EffectiveOn?.ToString("yyyy-MM-dd") ?? "—") → @(p.ExpiresOn?.ToString("yyyy-MM-dd") ?? "—")@(p.AdministrativeRateMethod == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAdministrativeRateMethods.None ? "—" : $"{p.AdministrativeRateValue:0.##} % ({localizer["AdminMethod" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAdministrativeRateMethods)p.AdministrativeRateMethod]})")@(p.SignedOn?.ToString("yyyy-MM-dd") ?? "—")@(p.ObservedExternalStatus ?? "—")@if (Model.IsManager) { @localizer["Open"] }
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml new file mode 100644 index 00000000..5f3cb064 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Reconciliation.cshtml @@ -0,0 +1,71 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsReconciliationView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Reconciliation"]; + ViewData["Title"] = localizer["Reconciliation"].Value; + ViewData["Subtitle"] = localizer["ReconciliationIntro"].Value; + ViewData["MarsTab"] = "reconciliation"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var authority = Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsAuthorityProfile.Current; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["MarsInvoices"]
+
+
@localizer["HelpReconciliation"]
+ @if (Model.Invoices.Count == 0) {

@localizer["NoMarsInvoices"]

} + else + { + + + + @foreach (var i in Model.Invoices) + { + + + + + + + + + + + } + +
@localizer["MarsInvoiceId"]@localizer["Deployment"]@localizer["State"]@localizer["Expected"]@localizer["Invoiced"]@localizer["Paid"]@localizer["ObservedOn"]
@i.MarsInvoiceId@(Model.DeploymentNames.TryGetValue(i.DeploymentId ?? string.Empty, out var name) ? name : "—")@await Html.PartialAsync("_WorkItemStateBadge", i.LocalState)@((i.ExpectedTotal ?? 0).ToString("N2"))@(i.ApprovedTotal?.ToString("N2") ?? "—")@(i.PaidTotal?.ToString("N2") ?? "—")@(i.ObservedOn?.ToString("yyyy-MM-dd") ?? "—") @localizer["Open"]
+ } +
+
+
+
+ @if (Model.CanReconcile) + { +
+
@localizer["RecordMarsInvoice"]
+
+

@localizer["RecordMarsInvoiceHelp"]

+
+ @Html.AntiForgeryToken() +
+
+
+
+
+
@localizer["CoveredItemsHelp"]
+
+ +
+
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml new file mode 100644 index 00000000..af0936a5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml @@ -0,0 +1,97 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsResourcesView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ResourceInventory"]; + ViewData["Title"] = localizer["ResourceInventory"].Value; + ViewData["Subtitle"] = localizer["ResourcesIntro"].Value; + ViewData["MarsTab"] = "resources"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var editing = Model.Editing ?? new Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsResourceProfile { ResourceKind = "Apparatus" }; + var ownerships = Enum.GetValues(); + var subjectTypes = Enum.GetValues(); + var today = DateTime.UtcNow; +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
@localizer["ResourceInventory"]
+
+
@localizer["HelpResources"]
+
+ @Html.AntiForgeryToken() + + + @localizer["DraftFromUnitsHelp"] +
+ @if (Model.Resources.Count == 0) {

@localizer["NoResources"]

} + else + { + + + + @foreach (var r in Model.Resources) + { + + + + + + + + + + } + +
@localizer["Subject"]@localizer["ResourceType"]@localizer["Identifiers"]@localizer["Ownership"]@localizer["MarsResourceId"]@localizer["ReviewState"]
@r.SubjectName @localizer["SubjectType" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsSubjectTypes)r.SubjectType]@(r.ResourceType ?? "—") @r.ResourceKind@r.UnitDesignator @(string.IsNullOrWhiteSpace(r.LicensePlate) ? "" : "· " + r.LicensePlate) @(string.IsNullOrWhiteSpace(r.Vin) ? "" : "· VIN " + r.Vin)@localizer["Ownership" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsOwnerships)r.Ownership]@(r.MarsResourceId ?? "—")@localizer["ReviewState" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsReviewStates)r.ReviewState] @if (!r.IsCurrent(today)) { @localizer["NotCurrent"] } + +
@Html.AntiForgeryToken()
+
+ } +
+
+
+
+
+
@(Model.Editing == null ? localizer["AddResource"] : localizer["EditResource"])
+
+
+ @Html.AntiForgeryToken() + + +
+
+
+
@foreach (var t in Model.ResourceTypes) { }
+
+
+
+
+
+
+
+ + @if (Model.Editing != null) { @localizer["Cancel"] } +
+ @if (Model.Editing != null && Model.CanSubmit) + { +
+
@localizer["ObserveInMars"]
+
+ @Html.AntiForgeryToken() +
+
+
+ +
+ } +
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml new file mode 100644 index 00000000..0b8a8cd4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml @@ -0,0 +1,306 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsWorkItemView +@inject IStringLocalizer localizer +@{ + var w = Model.Item; + var recordType = (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRecordTypes)w.RecordType; + var typeName = localizer["RecordType" + recordType].Value; + ViewBag.Title = "Resgrid | " + typeName; + ViewData["Title"] = $"{typeName} — {w.DeploymentName ?? w.CalOesMarsWorkItemId}"; + ViewData["Subtitle"] = localizer["WorkItemIntro"].Value; + ViewData["MarsTab"] = "queue"; + ViewData["MarsIsManager"] = Model.IsManager; + ViewData["MarsCanReconcile"] = Model.CanReconcile; + var f = Model.F42; + var e = Model.Expense; + var v = Model.Validation; + var stateName = (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates)w.LocalState; + var recordStatuses = Model.Authority.RecordStatusMap.Keys.ToList(); +} + +@await Html.PartialAsync("_CalOesMarsShell") + +
+ @await Html.PartialAsync("_CalOesMarsMessage", (Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView)Model) +
+
+
+
+
@typeName @await Html.PartialAsync("_WorkItemStateBadge", w.LocalState)
+
+ @localizer["Printable"] + @localizer["EvidencePacket"] + @if (!string.IsNullOrWhiteSpace(w.DeploymentId)) { @localizer["OpenDeployment"] } +
+
+
+

@localizer["PreparedNotSubmitted"] · @localizer["AuthorityProfile"] @w.AuthorityProfileCode · @localizer["Version"] @w.RowVersion @if (!string.IsNullOrWhiteSpace(w.SupersedesWorkItemId)) { · @localizer["Supersedes"] @w.SupersedesWorkItemId.Substring(0, 8) }

+ @if (!string.IsNullOrWhiteSpace(w.CorrectionComment)) {
@localizer["CorrectionComment"]: @w.CorrectionComment
} + + @if (f != null) + { +
+ @Html.AntiForgeryToken() + +

@localizer["BoxAgency"]

+
+
@localizer["MacsDesignator"]
@(f.MacsDesignator ?? "—") @f.AgencyName
+
@localizer["BoxIncident"]
@f.IncidentName @(string.IsNullOrWhiteSpace(f.IncidentNumber) ? "" : "· " + f.IncidentNumber)
+
@localizer["BoxOrder"]
@(f.OrderNumber ?? "—")
+
@localizer["BoxRequest"]
@(f.RequestNumber ?? "—") @(string.IsNullOrWhiteSpace(f.ParentRequestNumber) ? "" : "(" + f.ParentRequestNumber + ")") @if (f.IsRedispatch) { @localizer["Redispatch"] @f.PreviousOrderNumber/@f.PreviousRequestNumber }
+
+
+
+
+
@localizer["BoxDispatch"]
@(f.DispatchedOn?.ToString("yyyy-MM-dd HH:mm") ?? "—") · @localizer["Committed"] @(f.CommittedOn?.ToString("yyyy-MM-dd HH:mm") ?? "—")
+
@localizer["Released"]
@(f.ReleasedOn?.ToString("yyyy-MM-dd HH:mm") ?? "—") @localizer["ReleaseIsNotReturn"]
+
+
+ +

@localizer["BoxApparatus"]

+ + + + @for (var i = 0; i < f.Vehicles.Count; i++) + { + var vh = f.Vehicles[i]; + + + + + + + + + + + } + +
@localizer["Kind"]@localizer["Designator"]@localizer["ResourceCode"]@localizer["Identifiers"]@localizer["Hours"]@localizer["Miles"]@localizer["Odometer"]FEMA
@vh.Designator @if (string.IsNullOrWhiteSpace(vh.ResourceProfileId) && vh.Kind != "Equipment") { }@vh.LicensePlate @(string.IsNullOrWhiteSpace(vh.Vin) ? "" : "· " + vh.Vin)
+ +

@localizer["BoxPersonnel"]

+ + + + @foreach (var p in f.Personnel) + { + + + + + + + + + + } + +
@localizer["Name"]@localizer["Rank"]@localizer["Classification"]@localizer["Committed"]@localizer["Released"]@localizer["CommittedHours"]@localizer["ActualHours"]
@p.Name@p.CommittedHours.ToString("0.##")@(p.ActualHours.Count == 0 ? "—" : p.ActualHours.Sum(h => h.Hours).ToString("0.##") + " (" + p.ActualHours.Count + " d)")
+ @foreach (var c in Model.Classifications) { } + +

@localizer["BoxRotation"]

+ + + + @foreach (var r in f.Rotations) + { + + + + + + + + } + +
@localizer["Date"]@localizer["Outgoing"]@localizer["Incoming"]@localizer["ApprovalAttachment"]
@if (Model.CanEdit) { }
+ @if (Model.CanEdit) { } + +

@localizer["BoxComments"]

+
+
+
+ +

@localizer["BoxSignatures"]

+
+
+
@localizer["DocumentationOnlyHelp"]
+ +

@localizer["BoxAttachments"]

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

@localizer["NoAttachments"]

} + else + { +
    + @foreach (var a in Model.Attachments) + { +
  • @if (f.AttachmentIds.Contains(a.DeploymentAttachmentId)) { } else { } @a.Name (@((Resgrid.Model.Invoicing.DeploymentAttachmentTypes)a.AttachmentType))
  • + } +
+ } + @if (Model.CanEdit) + { +
+ } +
+ } + else if (e != null) + { +
+ @Html.AntiForgeryToken() +
+
@localizer["LinkedF42"]
@if (e.TravelOnly) { @localizer["TravelOnly"] } else if (!string.IsNullOrWhiteSpace(e.F42WorkItemId)) { @e.RequestNumber · @e.IncidentNumber }
+
+ + + + @foreach (var line in e.Lines) + { + + } + +
@localizer["Date"]@localizer["City"]@localizer["Category"]@localizer["Amount"]@localizer["Description"]@localizer["Receipt"]@localizer["PreApproved"]
@line.Date.ToString("yyyy-MM-dd")@line.City@localizer["Category" + line.Category]@line.Amount.ToString("N2")@line.Description@if (line.ReceiptAttachmentId.HasValue) { } else { }@(line.PreApproved ? localizer["Yes"] : localizer["No"])
+
+
+
+ @if (Model.CanEdit) {
} +
+ } +
+
+
+
+
+
@localizer["Checklist"]
+
+ @if (v == null) {

@localizer["NotValidated"]

} + else + { +

@localizer["ValidatedOn"] @v.ValidatedOn.ToString("yyyy-MM-dd HH:mm")

+ @if (v.IsReadyForPortal) {

@localizer["ReadyForPortal"]

} + foreach (var issue in v.Errors) {

@localizer["F42Box_" + issue.Box] @localizer["Validation_" + issue.Code] @issue.Detail

} + foreach (var issue in v.Warnings) {

@localizer["F42Box_" + issue.Box] @localizer["Validation_" + issue.Code] @issue.Detail

} + } + @if (Model.CanEdit) + { +
@Html.AntiForgeryToken()
+ } +
+
+ @if (Model.IsManager) + { +
+
@localizer["ExpectedReimbursement"]
+
+

@localizer["ExpectedReimbursementHelp"]

+ @if (w.Lines.Count == 0) {

@localizer["NotCalculated"]

} + else + { + + + @foreach (var line in w.Lines) + { + + } + + +
@localizer["LineKind" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLineKinds)line.LineKind] @line.SubjectName@line.Quantity.ToString("0.##") @line.Unit × @line.Rate.ToString("N2")@line.ExpectedAmount.ToString("N2")@if (line.EligibilityState != (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsEligibilityStates.Eligible) { @localizer["Eligibility" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsEligibilityStates)line.EligibilityState] }
@localizer["ExpectedTotal"]@((w.ExpectedTotal ?? 0).ToString("N2"))
+ } + @if (w.LocalState < (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Paid) + { +
@Html.AntiForgeryToken()
+ } +
+
+
+
@localizer["PortalHandoff"]
+
+ @if (w.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReadyForPortal || w.IsExternal) + { +
+ @Html.AntiForgeryToken() +
+ +
+ } + else {

@localizer["HandoffNeedsReady"]

} + @if (Model.CanSubmit && w.LocalState == (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReadyForPortal) + { +
+
@localizer["ObservedSubmission"]
+
+ @Html.AntiForgeryToken() +
+
+ +
+ } + @if (Model.CanSubmit && w.IsExternal && w.LocalState < (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingLocalAgencyApproval) + { +
+
@localizer["ObservedStatus"]
+

@localizer["MarsRecordId"]: @(w.MarsRecordId ?? "—") · @(w.ObservedExternalStatus ?? "—") @(w.ObservedOn?.ToString("yyyy-MM-dd"))

+
+ @Html.AntiForgeryToken() +
+
+
+ +
+ } + @if (w.LocalState is (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Approved or (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.DocumentationOnly or (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Paid) + { +
@Html.AntiForgeryToken()
+ } + @if (!w.IsExternal) + { +
@Html.AntiForgeryToken()
+ } +
+
+ } + @if (Model.Related.Count > 0) + { +
+
@localizer["RelatedItems"]
+
+ +
+
+ } +
+
+
+ +@section Scripts { + @if (f != null) + { + + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/_WorkItemStateBadge.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/_WorkItemStateBadge.cshtml new file mode 100644 index 00000000..1304e4e0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/_WorkItemStateBadge.cshtml @@ -0,0 +1,21 @@ +@model int +@inject IStringLocalizer localizer +@{ + var state = (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates)Model; + var css = state switch + { + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Draft => "label-default", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.NeedsReview => "label-warning", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReadyForPortal => "label-info", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.SubmittedExternal => "label-primary", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.ReturnedForAgencyReview => "label-danger", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Approved => "label-success", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.DocumentationOnly => "label-default", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingLocalAgencyApproval => "label-warning", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.LocalAgencyRejected => "label-danger", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.PendingPayingEntityApproval => "label-primary", + Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsLocalStates.Paid => "label-success", + _ => "label-default" + }; +} +@localizer["State" + state] diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index 965d8314..5c402132 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -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],input.entry-id').forEach(function (i) { i.value = ''; i.removeAttribute('data-adp-field'); }); } + if (rows.length) { clone = rows[rows.length - 1].cloneNode(true); clone.querySelectorAll('input[type=text],input[type=number],input.entry-id,input[name$=".CertificationCode"]').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/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 5ff378bc..b1efff88 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -7,6 +7,8 @@ @inject IStringLocalizer certificationLocalizer @inject IStringLocalizer deploymentLocalizer @inject IStringLocalizer contractorLocalizer +@inject IStringLocalizer calOesLocalizer +@inject IStringLocalizer workforceLocalizer @inject IStringLocalizer checklistLocalizer @inject IStringLocalizer twoFactorLocalizer @{ @@ -32,6 +34,12 @@ case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type.ToString()].Value; case PermissionTypes.ManageBids: case PermissionTypes.ManageContracts: return contractorLocalizer[row.Type.ToString()].Value; + case PermissionTypes.ManageMutualAidReimbursement: return calOesLocalizer[row.Type.ToString()].Value; + case PermissionTypes.ViewInternalCosts: + case PermissionTypes.ManageWorkforceCompensation: + case PermissionTypes.ViewWorkforceCompensation: + case PermissionTypes.ManagePayDataReporting: + case PermissionTypes.ExportPayDataReporting: return workforceLocalizer[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; @@ -53,6 +61,12 @@ case PermissionTypes.ApproveTimeReports: return deploymentLocalizer[row.Type + "Note"].Value; case PermissionTypes.ManageBids: case PermissionTypes.ManageContracts: return contractorLocalizer[row.Type + "Note"].Value; + case PermissionTypes.ManageMutualAidReimbursement: return calOesLocalizer[row.Type + "Note"].Value; + case PermissionTypes.ViewInternalCosts: + case PermissionTypes.ManageWorkforceCompensation: + case PermissionTypes.ViewWorkforceCompensation: + case PermissionTypes.ManagePayDataReporting: + case PermissionTypes.ExportPayDataReporting: return workforceLocalizer[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; @@ -511,6 +525,8 @@ if (row.Type == PermissionTypes.ManageCertifications) { @certificationLocalizer["Certifications"] } if (row.Type == PermissionTypes.ManageDeployments) { @deploymentLocalizer["Deployments"] } if (row.Type == PermissionTypes.ManageBids) { @contractorLocalizer["ContractorBilling"] } + if (row.Type == PermissionTypes.ManageMutualAidReimbursement) { @calOesLocalizer["CalOesMars"] } + if (row.Type == PermissionTypes.ViewInternalCosts) { @workforceLocalizer["Workforce"] } @RecordsPermissionLabel(row) @RecordsPermissionNote(row) diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsMessage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsMessage.cshtml new file mode 100644 index 00000000..a315e050 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsMessage.cshtml @@ -0,0 +1,10 @@ +@model Resgrid.Web.Areas.User.Models.CostRecovery.CalOesMarsPageView +@inject IStringLocalizer calOesStrings +@if (Model.SaveSuccess) +{ +
@calOesStrings["Saved"]
+} +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ +
@Model.Message
+} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml new file mode 100644 index 00000000..0792b4e4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_CalOesMarsShell.cshtml @@ -0,0 +1,50 @@ +@inject IStringLocalizer calOesStrings +@* + Page heading + module tabs shared by the Cal OES MARS screens (Workforce & Business Operations plan, Phase C-M3 / C6). + ViewData["Title"] heading; ViewData["Subtitle"] optional; ViewData["MarsTab"] the active tab key. +*@ +@{ + var title = (string)ViewData["Title"] ?? calOesStrings["CalOesMars"].Value; + var subtitle = (string)ViewData["Subtitle"]; + var tab = (string)ViewData["MarsTab"] ?? "readiness"; + var isManager = ViewData["MarsIsManager"] as bool? ?? false; + var canReconcile = ViewData["MarsCanReconcile"] as bool? ?? false; +} +
+
+

@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 6d066b68..20b9c3ef 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -8,6 +8,8 @@ @inject IStringLocalizer certificationLocalizer @inject IStringLocalizer deploymentLocalizer @inject IStringLocalizer contractorLocalizer +@inject IStringLocalizer calOesLocalizer +@inject IStringLocalizer workforceLocalizer @inject Resgrid.Model.Services.IBusinessOperationsAccessService businessOperationsAccess @{ // Chat.System flag gates every chat surface (chat, assistant, moderation). When it is off @@ -109,6 +111,32 @@ } } + @if (SettingsHelper.IsBusinessOperationsEnabled() && await businessOperationsAccess.CanUseCostRecoveryAsync(ClaimsAuthorizationHelper.GetDepartmentId())) + { + @* Workforce & Business Operations plan, Phase C-M3: Cal OES MARS needs the CostRecovery.CalOesMars entitlement; managers see the department queue, rostered members their own drafts. *@ +
  • + @calOesLocalizer["CalOesMars"] +
  • + } + @if (SettingsHelper.IsBusinessOperationsEnabled()) + { + var workforceEnabled = await businessOperationsAccess.CanUseWorkforceAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var payDataEnabled = await businessOperationsAccess.CanUsePayDataReportingAsync(ClaimsAuthorizationHelper.GetDepartmentId()); + var workforceManager = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || ClaimsAuthorizationHelper.CanViewWorkforce() || ClaimsAuthorizationHelper.CanViewInternalCosts() || ClaimsAuthorizationHelper.CanViewPayDataReporting(); + @* Workforce & Business Operations plan, Phase E: managers reach the workforce workspace; every member reaches their own demographic response while pay data reporting is on. *@ + if ((workforceEnabled || payDataEnabled) && workforceManager) + { +
  • + @workforceLocalizer["Workforce"] +
  • + } + else if (payDataEnabled) + { +
  • + @workforceLocalizer["MyDemographics"] +
  • + } + } @if (SettingsHelper.IsMappingEnabled()) {
  • diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceMessage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceMessage.cshtml new file mode 100644 index 00000000..2c2cc45d --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceMessage.cshtml @@ -0,0 +1,10 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView +@inject IStringLocalizer workforceStrings +@if (Model.SaveSuccess) +{ +
    @workforceStrings["Saved"]
    +} +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ +
    @Model.Message
    +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceShell.cshtml new file mode 100644 index 00000000..05187cfb --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_WorkforceShell.cshtml @@ -0,0 +1,68 @@ +@inject IStringLocalizer workforceStrings +@* + Page heading + module tabs shared by the workforce screens (Workforce & Business Operations plan, Phase E / E6). + ViewData["Title"] heading; ViewData["Subtitle"] optional; ViewData["WorkforceTab"] the active tab key; the + WorkforcePageView flags decide which tabs render. +*@ +@{ + var title = (string)ViewData["Title"] ?? workforceStrings["Workforce"].Value; + var subtitle = (string)ViewData["Subtitle"]; + var tab = (string)ViewData["WorkforceTab"] ?? "dashboard"; + var page = ViewData["WorkforcePage"] as Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView ?? new Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView(); + var canView = page.CanManage || page.CanViewCompensation || page.CanViewInternalCosts || page.CanViewPayData; +} +
    +
    +

    @title

    + + @if (!string.IsNullOrEmpty(subtitle)) + { + @subtitle + } + @if (canView) + { +
    +
    + @workforceStrings["TabDashboard"] + @if (page.WorkforceEnabled && (page.CanManage || page.CanViewCompensation || page.CanViewPayData)) + { + @workforceStrings["TabEmployer"] + @workforceStrings["TabEstablishments"] + @workforceStrings["TabContractors"] + @workforceStrings["TabWorkers"] + } + @if (page.WorkforceEnabled && page.CanViewCompensation) + { + @workforceStrings["TabCompensation"] + @workforceStrings["TabWorkEntries"] + @workforceStrings["TabAnnualFacts"] + } + @if (page.WorkforceEnabled && page.CanViewInternalCosts) + { + @workforceStrings["TabResourceCosts"] + @workforceStrings["TabCostRuns"] + } + @if (page.PayDataEnabled && page.CanViewPayData) + { + @workforceStrings["TabPayData"] + } + @if (page.PayDataEnabled) + { + @workforceStrings["MyDemographics"] + } +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/AnnualFacts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/AnnualFacts.cshtml new file mode 100644 index 00000000..7eed2228 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/AnnualFacts.cshtml @@ -0,0 +1,126 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceAnnualFactsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["AnnualFacts"]; + ViewData["Title"] = localizer["AnnualFacts"].Value; + ViewData["Subtitle"] = localizer["AnnualFactsIntro"].Value; + ViewData["WorkforceTab"] = "annualfacts"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; + var isContractor = Model.ReportType == PayDataReportTypes.LaborContractorEmployee; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["AnnualFacts"] @Model.ReportingYear — @localizer["ReportType" + Model.ReportType]
    +
    +
    + + + +
    + @if (Model.CanManageCompensation) + { + @localizer["AddFact"] + } +
    +
    +
    + + + + @foreach (var f in Model.Facts) + { + + + + + + + + + } + @if (Model.Facts.Count == 0) + { + + } + +
    @localizer["Employment"]@localizer["EarningsUsed"] @localizer["EarningsSource"]@localizer["ReportableHours"]@localizer["WeeksWorked"]@localizer["Version"]@localizer["Approved"]@localizer["Source"]
    @(Model.EmploymentLabels.TryGetValue(f.WorkforceEmploymentId, out var label) ? label : f.WorkforceEmploymentId) @(string.IsNullOrWhiteSpace(f.ClientAllocationKey) ? "" : "[" + f.ClientAllocationKey + "]")@ProtectedDataEnvelope.SafeDisplay(f.EarningsUsed)@localizer["EarningsSource" + (EarningsSources)f.EarningsSource]@f.ReportableHours?.ToString("N2")@f.WeeksWorked?.ToString("N1")@f.Version@(f.IsApproved ? localizer["Yes"] : localizer["No"])@f.Source@if (Model.CanManageCompensation) { }
    @localizer["NoFacts"]
    +
    +
    + @if (Model.CanManageCompensation) + { +
    +
    @localizer["ImportFacts"]
    +
    +

    @localizer["ImportHelp"]

    +
    ExternalWorkerKey,UserId,ReportingYear,ReportType,ClientAllocationKey,W2Box5,W2Box1,ActualWorkedHours,PaidLeaveHours,DaysWorked,WeeksWorked,ExemptProxyMethod,ProxyAverageHoursPerDay,ClientAllocatedEarnings,ClientAllocatedHours,ClientAllocatedWeeks
    + @if (Model.ImportResult != null) + { +
    + @(Model.ImportResult.DryRun ? localizer["DryRunResult"] : localizer["ImportResult"]): + @localizer["Total"] @Model.ImportResult.Total · @localizer["Created"] @Model.ImportResult.Created · @localizer["Updated"] @Model.ImportResult.Updated · @localizer["Skipped"] @Model.ImportResult.Skipped + @if (Model.ImportResult.Issues.Count > 0) + { +
      + @foreach (var issue in Model.ImportResult.Issues.Take(50)) + { +
    • @localizer["Line"] @issue.Line: @localizer["Import_" + issue.Code] @issue.Detail
    • + } +
    + } +
    + } +
    + @Html.AntiForgeryToken() + +
    + + +
    +
    +
    + } +
    + @if (x != null && Model.CanManageCompensation) + { +
    +
    +
    @(string.IsNullOrWhiteSpace(x.WorkforceAnnualPayFactId) ? localizer["AddFact"] : localizer["CorrectFact"])
    +
    +

    @localizer["FactVersionHelp"]

    +
    + @Html.AntiForgeryToken() + +
    + @if (isContractor) + { +
    +
    +
    + } + else + { +
    +
    +
    +
    +
    + } +
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Compensation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Compensation.cshtml new file mode 100644 index 00000000..12fbbf1e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Compensation.cshtml @@ -0,0 +1,55 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceCompensationView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Compensation"]; + ViewData["Title"] = localizer["Compensation"].Value; + ViewData["Subtitle"] = localizer["CompensationIntro"].Value; + ViewData["WorkforceTab"] = "compensation"; + ViewData["WorkforcePage"] = Model; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    @localizer["HelpCompensation"]
    + @if (!string.IsNullOrWhiteSpace(Model.EmploymentId)) + { +
    +
    +
    +
    @localizer["EmployeeProfiles"] — @Model.WorkerName
    + @if (Model.CanManageCompensation) + { + + } +
    +
    + @await Html.PartialAsync("_CompensationTable", Model.Employee, new ViewDataDictionary(ViewData) { { "RoleNames", Model.RoleNames }, { "EmploymentId", Model.EmploymentId } }) +
    +
    +
    +
    + } +
    +
    +
    +
    @localizer["DefaultProfiles"]
    + @if (Model.CanManageCompensation) + { + + } +
    +
    +

    @localizer["DefaultProfilesHelp"]

    + @await Html.PartialAsync("_CompensationTable", Model.Defaults, new ViewDataDictionary(ViewData) { { "RoleNames", Model.RoleNames }, { "EmploymentId", (string)null } }) +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/CompensationProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/CompensationProfile.cshtml new file mode 100644 index 00000000..723a3408 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/CompensationProfile.cshtml @@ -0,0 +1,169 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceCompensationProfileView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CompensationProfile"]; + ViewData["Title"] = localizer["CompensationProfile"].Value; + ViewData["Subtitle"] = Model.WorkerName; + ViewData["WorkforceTab"] = "compensation"; + ViewData["WorkforcePage"] = Model; + var p = Model.Profile; + var isNew = string.IsNullOrWhiteSpace(p.EmployeeCompensationProfileId); + var readOnly = !Model.CanManageCompensation; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    + @Html.AntiForgeryToken() + + + + + +
    +
    +
    +
    @localizer["Scope" + (CompensationScopes)p.Scope] @(p.IsApproved ? "" : "— " + localizer["Unapproved"])
    +
    +
    + @if (p.Scope == (int)CompensationScopes.RoleDefault) + { +
    + } +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["BaseAmountHelp"]
    +
    @localizer["RegularHourlyEquivalentHelp"]
    +
    +
    +
    +
    +
    +
    +
    @localizer["RateMultipliersHelp"]
    +
    +
    + @if (Model.CanManageCompensation) + { + + @localizer["Cancel"] + } +
    +
    + @if (!isNew) + { +
    +
    @localizer["Approval"]
    +
    + @if (p.IsApproved) + { +

    @localizer["Approved"] @p.ApprovedOn?.ToString("yyyy-MM-dd")

    + } + else + { +

    @localizer["ApprovalHelp"]

    + } + @if (Model.Preview != null) + { +

    @localizer["PreviewEightHours"]: @Model.Preview.LoadedCost.ToString("N2") @p.Currency (@localizer["Pay"] @Model.Preview.PayAmount.ToString("N2") + @localizer["PayComponents"] @Model.Preview.PayComponentAmount.ToString("N2") + @localizer["EmployerCosts"] @Model.Preview.EmployerCostAmount.ToString("N2"))

    + } +
    +
    + } +
    +
    +
    +
    @localizer["PayComponents"]
    @if (Model.CanManageCompensation) {
    }
    +
    +

    @localizer["PayComponentsHelp"]

    + + + +
    @localizer["Category"]@localizer["Name"]@localizer["Basis"]@localizer["Amount"] @localizer["EligiblePayCodes"]@localizer["PerOvertimeHour"]
    +
    +
    +
    +
    @localizer["EmployerCostComponents"]
    @if (Model.CanManageCompensation) {
    }
    +
    +

    @localizer["CostComponentsHelp"]

    + + + +
    @localizer["Category"]@localizer["Name"]@localizer["Basis"]@localizer["Rate"] @localizer["Cap"] @localizer["EligiblePayCodes"]
    +
    +
    +
    +
    +
    + @if (!isNew && Model.CanManageCompensation && !p.IsApproved) + { +
    @Html.AntiForgeryToken()
    + } +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Contractors.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Contractors.cshtml new file mode 100644 index 00000000..9f7a9375 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Contractors.cshtml @@ -0,0 +1,85 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceContractorsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["LaborContractors"]; + ViewData["Title"] = localizer["LaborContractors"].Value; + ViewData["Subtitle"] = localizer["ContractorsIntro"].Value; + ViewData["WorkforceTab"] = "contractors"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["LaborContractors"]
    + @if (Model.CanManage) + { + + } +
    +
    + + + + @foreach (var row in Model.Contractors) + { + + + + + + + } + @if (Model.Contractors.Count == 0) + { + + } + +
    @localizer["LegalName"]@localizer["Dba"]@localizer["Fein"]@localizer["Relationship"]@localizer["Active"]
    @row.LegalName@row.Dba@ProtectedDataEnvelope.SafeDisplay(row.Fein)@row.RelationshipStartOn?.ToString("yyyy-MM-dd") – @(row.RelationshipEndOn?.ToString("yyyy-MM-dd") ?? "…")@(row.IsActive ? localizer["Yes"] : localizer["No"]) + @if (Model.CanManage) + { + +
    @Html.AntiForgeryToken()
    + } +
    @localizer["NoContractors"]
    +
    +
    +
    + @if (x != null) + { +
    +
    +
    @(string.IsNullOrWhiteSpace(x.WorkforceLaborContractorId) ? localizer["AddContractor"] : localizer["EditContractor"])
    +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["ProvenanceHelp"]
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRun.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRun.cshtml new file mode 100644 index 00000000..f328e7ee --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRun.cshtml @@ -0,0 +1,112 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceCostRunView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CostRun"]; + ViewData["Title"] = localizer["Context" + (FieldCostContextTypes)Model.Run.ContextType].Value + ": " + Model.ContextLabel; + ViewData["Subtitle"] = localizer["RunType" + (FieldCostRunTypes)Model.Run.RunType].Value + " · " + localizer["RunStatus" + (FieldCostRunStatuses)Model.Run.Status].Value; + ViewData["WorkforceTab"] = "costruns"; + ViewData["WorkforcePage"] = Model; + var r = Model.Run; + var s = Model.Summary; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["Summary"]
    +
    + + + + + + + + + + + + + +
    @localizer["Personnel"]@s.PersonnelTotal.ToString("N2")
    @localizer["Resources"]@s.ResourceTotal.ToString("N2")
    @localizer["Consumables"]@s.ConsumableTotal.ToString("N2")
    @localizer["Expenses"]@s.ExpenseTotal.ToString("N2")
    @localizer["Overhead"]@s.OverheadTotal.ToString("N2")
    @localizer["TotalLoadedCost"]@s.TotalLoadedCost.ToString("N2") @s.Currency
    @localizer["Revenue"] (@localizer["Revenue" + (RevenueSources)s.RevenueSource])@(s.RevenueAmount?.ToString("N2") ?? "—")
    @localizer["Margin"]@(s.ContributionMargin.HasValue ? s.ContributionMargin.Value.ToString("N2") + " (" + (s.ContributionMarginPercent ?? 0).ToString("N1") + "%)" : "—")
    @localizer["BreakEven"]@s.BreakEvenRevenue.ToString("N2")
    @localizer["MissingInputs"]@s.MissingInputCount
    @localizer["ThroughDate"]@(s.ThroughDate?.ToString("yyyy-MM-dd") ?? "—")
    @localizer["Frozen"]@(s.FrozenOn?.ToString("yyyy-MM-dd HH:mm") ?? "—")
    + @if (!r.IsFrozen) + { +
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    + } + @if (!string.IsNullOrWhiteSpace(r.DeploymentId)) + { + @localizer["ResourceUsage"] + } + else if (r.CallId.HasValue) + { + @localizer["ResourceUsage"] + } +
    +
    + @if (Model.Comparison?.Estimate != null && Model.Comparison.Actual != null) + { +
    +
    @localizer["EstimateVsActual"]
    +
    + + + + + + +
    @localizer["Estimate"]@localizer["Actual"]@localizer["Variance"]
    @localizer["Personnel"]@Model.Comparison.Estimate.PersonnelTotal.ToString("N2")@Model.Comparison.Actual.PersonnelTotal.ToString("N2")@Model.Comparison.PersonnelVariance.ToString("N2")
    @localizer["Resources"]@Model.Comparison.Estimate.ResourceTotal.ToString("N2")@Model.Comparison.Actual.ResourceTotal.ToString("N2")@Model.Comparison.ResourceVariance.ToString("N2")
    @localizer["Expenses"]@Model.Comparison.Estimate.ExpenseTotal.ToString("N2")@Model.Comparison.Actual.ExpenseTotal.ToString("N2")@Model.Comparison.ExpenseVariance.ToString("N2")
    @localizer["Total"]@Model.Comparison.Estimate.TotalLoadedCost.ToString("N2")@Model.Comparison.Actual.TotalLoadedCost.ToString("N2")@Model.Comparison.TotalVariance.ToString("N2")
    +
    +
    + } +
    +
    +
    +
    @localizer["Lines"]
    +
    + @if (!Model.CanViewCompensation) + { +
    @localizer["LinesRequireCompensationView"]
    + } + else + { +

    @localizer["LinesHelp"]

    + + + + @foreach (var l in r.Lines) + { + + + + + + + + @if (l.Category == (int)FieldCostCategories.Personnel && !string.IsNullOrWhiteSpace(l.ProtectedDetailJson) && !ProtectedDataEnvelope.HasEnvelopePrefix(l.ProtectedDetailJson) && l.ProtectedDetailJson != ProtectedDataEnvelope.RedactionValue) + { + + } + } + @if (r.Lines.Count == 0) + { + + } + +
    @localizer["Date"]@localizer["Category"]@localizer["Subject"]@localizer["Component"]@localizer["Quantity"]@localizer["Unit"]@localizer["Rate"]@localizer["Amount"]@localizer["Flags"]
    @l.LineDate?.ToString("yyyy-MM-dd")@localizer["Category" + (FieldCostCategories)l.Category]@l.SubjectLabel@l.Component@l.Quantity.ToString("N2")@l.Unit@(l.Rate.HasValue ? l.Rate.Value.ToString("N4") : l.Category == (int)FieldCostCategories.Personnel ? "🔒" : "—")@l.Amount.ToString("N2") + @if (l.IsEstimated) { @localizer["Estimated"] } + @if (l.IsFallback) { @localizer["Fallback"] } + @if (!string.IsNullOrWhiteSpace(l.ReviewReason)) { foreach (var reason in l.ReviewReason.Split(',')) { @localizer["Review_" + reason] } } +
    @l.ProtectedDetailJson
    @localizer["NoLines"]
    + } +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRuns.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRuns.cshtml new file mode 100644 index 00000000..56b781ad --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/CostRuns.cshtml @@ -0,0 +1,81 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceCostRunsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["CostRuns"]; + ViewData["Title"] = localizer["CostRuns"].Value; + ViewData["Subtitle"] = localizer["CostRunsIntro"].Value; + ViewData["WorkforceTab"] = "costruns"; + ViewData["WorkforcePage"] = Model; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["CostRuns"]
    +
    + + + + @foreach (var r in Model.Runs) + { + var label = r.ContextType == (int)FieldCostContextTypes.Deployment ? (Model.ContextLabels.TryGetValue("D:" + r.DeploymentId, out var d) ? d : r.DeploymentId) : r.ContextType == (int)FieldCostContextTypes.Bid ? (Model.ContextLabels.TryGetValue("B:" + r.BidId, out var b) ? b : r.BidId) : "Call #" + r.CallId; + + + + + + + + + + + + } + @if (Model.Runs.Count == 0) + { + + } + +
    @localizer["Created"]@localizer["Context"]@localizer["RunType"]@localizer["Status"]@localizer["TotalLoadedCost"]@localizer["Revenue"]@localizer["Margin"]@localizer["MissingInputs"]
    @r.AddedOn.ToString("yyyy-MM-dd")@localizer["Context" + (FieldCostContextTypes)r.ContextType]: @label@localizer["RunType" + (FieldCostRunTypes)r.RunType]@localizer["RunStatus" + (FieldCostRunStatuses)r.Status]@r.TotalLoadedCost.ToString("N2") @r.Currency@(r.RevenueAmount?.ToString("N2") ?? "—")@(r.ContributionMargin.HasValue ? r.ContributionMargin.Value.ToString("N2") + " (" + (r.ContributionMarginPercent ?? 0).ToString("N1") + "%)" : "—")@r.MissingInputCount
    @localizer["NoRuns"]
    +
    +
    +
    +
    +
    +
    @localizer["NewRun"]
    +
    +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    + + @localizer["RunDeploymentCostHelp"] +
    +
    +
    + @Html.AntiForgeryToken() +
    + + @localizer["RunBidEstimateHelp"] +
    +
    +
    + @Html.AntiForgeryToken() +
    + + @localizer["RunCallCostHelp"] +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Demographics.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Demographics.cshtml new file mode 100644 index 00000000..d9b598a1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Demographics.cshtml @@ -0,0 +1,85 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceDemographicsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["MyDemographics"]; + ViewData["Title"] = Model.IsOwn ? localizer["MyDemographics"].Value : localizer["DemographicRecord"].Value + " — " + Model.WorkerName; + ViewData["Subtitle"] = Model.IsOwn ? localizer["MyDemographicsIntro"].Value : localizer["DemographicRecordIntro"].Value; + ViewData["WorkforceTab"] = Model.IsOwn ? "mydemographics" : "workers"; + ViewData["WorkforcePage"] = Model; + var r = Model.Response; + var races = (ProtectedDataEnvelope.SafeDisplay(r.RaceEthnicityCodes) ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim()).ToList(); + var hispanic = ProtectedDataEnvelope.SafeDisplay(r.HispanicLatino); + var sex = ProtectedDataEnvelope.SafeDisplay(r.SexCode); + var hasResponse = !string.IsNullOrWhiteSpace(r.PayDataReportingDemographicId); +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @(Model.IsOwn ? localizer["SelfIdentification"] : localizer["DemographicRecord"])
    +
    +
    @(Model.IsOwn ? localizer["SelfIdentificationNotice"] : localizer["DemographicRecordNotice"])
    + @if (hasResponse) + { +

    @localizer["CurrentResponse"]: @localizer["CollectionSource" + (DemographicCollectionSources)r.CollectionSource] · @localizer["Version"] @r.Version · @r.CollectedOn?.ToString("yyyy-MM-dd")

    + } +
    + @Html.AntiForgeryToken() + @if (!Model.IsOwn) + { + +
    @localizer["CollectionSourceHelp"]
    +
    + } + else + { + + } +
    + + + +
    +
    + @foreach (var race in Model.Races.Where(x => x.Code != "A" && x.Code != "G")) + { +
    + } +
    + @localizer["RaceHelp"] +
    +
    + @foreach (var s in Model.Sexes) + { + + } +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["WhyWeAsk"]
    +
    +

    @localizer["WhyWeAskText"]

    +

    @localizer["DemographicsVoluntary"]

    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Employer.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Employer.cshtml new file mode 100644 index 00000000..8f349b35 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Employer.cshtml @@ -0,0 +1,100 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceEmployerView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Employer"]; + ViewData["Title"] = localizer["Employer"].Value; + ViewData["Subtitle"] = localizer["EmployerIntro"].Value; + ViewData["WorkforceTab"] = "employer"; + ViewData["WorkforcePage"] = Model; + var e = Model.Employer; + var a = Model.EditingAffiliate ?? new WorkforceAffiliatedEntity(); + var readOnly = !Model.CanManage; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["EmployerProfile"]
    +
    +
    @localizer["HelpEmployer"]
    +
    + @Html.AntiForgeryToken() +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["CoverageHelp"]
    +
    +
    +
    + @if (Model.CanManage) + { +
    @localizer["Cancel"]
    + } +
    +
    +
    +
    +
    +
    +
    +
    @localizer["Affiliates"]
    +
    +

    @localizer["AffiliatesHelp"]

    + + + + @foreach (var row in Model.Affiliates) + { + + } + @if (Model.Affiliates.Count == 0) + { + + } + +
    @localizer["LegalName"]@localizer["Fein"]
    @row.LegalName@ProtectedDataEnvelope.SafeDisplay(row.Fein) + @if (Model.CanManage) + { + +
    @Html.AntiForgeryToken()
    + } +
    @localizer["None"]
    + @if (Model.CanManage) + { +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    + +
    + } +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Establishments.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Establishments.cshtml new file mode 100644 index 00000000..82adaa48 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Establishments.cshtml @@ -0,0 +1,93 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceEstablishmentsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Establishments"]; + ViewData["Title"] = localizer["Establishments"].Value; + ViewData["Subtitle"] = localizer["EstablishmentsIntro"].Value; + ViewData["WorkforceTab"] = "establishments"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["Establishments"]
    + @if (Model.CanManage) + { + + } +
    +
    + + + + @foreach (var row in Model.Establishments) + { + + + + + + + } + @if (Model.Establishments.Count == 0) + { + + } + +
    @localizer["Code"]@localizer["Name"]@localizer["City"]@localizer["State"]@localizer["Naics"]@localizer["Headquarters"]@localizer["Active"]
    @row.Code@row.Name@row.City@row.StateCode@row.Naics@(row.IsHeadquarters ? localizer["Yes"] : "")@(row.IsActiveOn(DateTime.UtcNow) ? localizer["Yes"] : localizer["No"]) + @if (Model.CanManage) + { + +
    @Html.AntiForgeryToken()
    + } +
    @localizer["NoEstablishments"]
    +
    +
    +
    + @if (x != null) + { +
    +
    +
    @(string.IsNullOrWhiteSpace(x.WorkforceEstablishmentId) ? localizer["AddEstablishment"] : localizer["EditEstablishment"])
    +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Index.cshtml new file mode 100644 index 00000000..131c9a07 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Index.cshtml @@ -0,0 +1,82 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceDashboardView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Workforce"]; + ViewData["Title"] = localizer["Workforce"].Value; + ViewData["Subtitle"] = localizer["DashboardIntro"].Value; + ViewData["WorkforceTab"] = "dashboard"; + ViewData["WorkforcePage"] = Model; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    @localizer["ProtectedNotice"]
    +
    + @if (Model.WorkforceEnabled && (Model.CanManage || Model.CanViewCompensation || Model.CanViewPayData)) + { +
    +
    +
    @localizer["Employer"]
    +
    + @if (Model.Employer == null) + { +

    @localizer["EmployerMissing"]

    + } + else + { +

    @Model.Employer.LegalName

    +

    @localizer["CoverageStatus"]: @localizer["Coverage" + ((CaliforniaPayDataCoverageStatuses)Model.Employer.CoverageStatus)]

    + } +

    @localizer["Establishments"]: @Model.EstablishmentCount · @localizer["Workers"]: @Model.WorkerCount · @localizer["Employments"]: @Model.EmploymentCount

    + @localizer["TabEmployer"] + @localizer["TabWorkers"] +
    +
    +
    + } + @if (Model.WorkforceEnabled && Model.CanViewInternalCosts) + { +
    +
    +
    @localizer["FieldCosting"]
    +
    +

    @localizer["ResourceProfiles"]: @Model.ResourceProfileCount · @localizer["CostRuns"]: @Model.CostRunCount

    +

    @localizer["FieldCostingIntro"]

    + @localizer["TabCostRuns"] + @localizer["TabResourceCosts"] +
    +
    +
    + } + @if (Model.PayDataEnabled && Model.CanViewPayData && Model.Readiness != null) + { +
    +
    +
    @localizer["PayDataReporting"] @Model.ReportingYear
    +
    +

    @localizer["DueDate"]: @Model.Readiness.DueDate.ToString("yyyy-MM-dd") @(Model.Readiness.ProfileAvailable ? "" : "(" + localizer["ProfileUnavailable"] + ")")

    +

    @localizer["OpenRuns"]: @Model.Readiness.OpenRuns · @localizer["UnresolvedExceptions"]: @Model.Readiness.UnresolvedExceptions

    +

    @localizer["DemographicsMissing"]: @Model.Readiness.DemographicsMissing · @localizer["AnnualFactsMissing"]: @Model.Readiness.AnnualFactsMissing

    + @if (Model.Readiness.HasCertifiedRun) + { + @localizer["StatusCertifiedExternally"] + } + else if (Model.Readiness.HasFrozenRun) + { + @localizer["StatusExported"] + } + +
    +
    +
    + } +
    + @if (Model.PayDataEnabled && !(Model.CanManage || Model.CanViewCompensation || Model.CanViewInternalCosts || Model.CanViewPayData)) + { +

    @localizer["MyDemographicsIntro"]

    @localizer["MyDemographics"]
    + } +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/PayData.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/PayData.cshtml new file mode 100644 index 00000000..2e857907 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/PayData.cshtml @@ -0,0 +1,82 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforcePayDataView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["PayDataReporting"]; + ViewData["Title"] = localizer["PayDataReporting"].Value; + ViewData["Subtitle"] = localizer["PayDataIntro"].Value; + ViewData["WorkforceTab"] = "paydata"; + ViewData["WorkforcePage"] = Model; + var rd = Model.Readiness; + var profile = CaPayDataSchemaProfile.ForYear(Model.ReportingYear); +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    @localizer["PayDataDisclaimer"]
    +
    +
    +
    +
    @localizer["Readiness"] @Model.ReportingYear
    +
    +
    +
    +

    @localizer["DueDate"]: @rd.DueDate.ToString("yyyy-MM-dd")

    +

    @localizer["SchemaProfile"]: @(rd.ProfileCode ?? localizer["ProfileUnavailable"].Value)

    +

    @localizer["CoverageStatus"]: @localizer["Coverage" + (CaliforniaPayDataCoverageStatuses)rd.CoverageStatus]

    +

    @localizer["OpenRuns"]: @rd.OpenRuns · @localizer["UnresolvedExceptions"]: @rd.UnresolvedExceptions

    +

    @localizer["DemographicsMissing"]: @rd.DemographicsMissing · @localizer["AnnualFactsMissing"]: @rd.AnnualFactsMissing

    +

    @localizer["Completeness"]: @Model.Completeness.WithResponse / @Model.Completeness.ActiveWorkers (@localizer["SelfIdentified"] @Model.Completeness.SelfIdentified, @localizer["Declined"] @Model.Completeness.Declined, @localizer["ObserverPerception"] @Model.Completeness.ObserverPerception)

    +
    +
    + @if (Model.CanManagePayData && profile != null) + { +
    +
    @localizer["NewRun"]
    +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    + @localizer["SnapshotWindowHelp"] @profile.SnapshotWindowStart.ToString("yyyy-MM-dd") – @profile.SnapshotWindowEnd.ToString("yyyy-MM-dd") + +
    +
    +
    + } +
    +
    +
    +
    @localizer["Runs"]
    +
    + + + + @foreach (var run in Model.Runs) + { + + + + + + + + } + @if (Model.Runs.Count == 0) + { + + } + +
    @localizer["Year"]@localizer["ReportType"]@localizer["Snapshot"]@localizer["Status"]@localizer["Employees"]@localizer["Rows"]@localizer["Exceptions"]@localizer["Created"]
    @run.ReportingYear@localizer["ReportType" + (PayDataReportTypes)run.ReportType]@run.SnapshotStart.ToString("MM-dd") – @run.SnapshotEnd.ToString("MM-dd")@localizer["Status" + (PayDataReportRunStatuses)run.Status]@run.EmployeeCount@run.RowCount@run.ExceptionCount / @run.WarningCount@run.AddedOn.ToString("yyyy-MM-dd")
    @localizer["NoRuns"]
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/PayDataRun.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/PayDataRun.cshtml new file mode 100644 index 00000000..ed416368 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/PayDataRun.cshtml @@ -0,0 +1,184 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforcePayDataRunView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["PayDataRun"]; + ViewData["Title"] = localizer["PayDataRun"].Value + " " + Model.Run.ReportingYear + " — " + localizer["ReportType" + (PayDataReportTypes)Model.Run.ReportType].Value; + ViewData["Subtitle"] = localizer["Status" + (PayDataReportRunStatuses)Model.Run.Status].Value + " · " + Model.Run.SchemaProfileCode; + ViewData["WorkforceTab"] = "paydata"; + ViewData["WorkforcePage"] = Model; + var run = Model.Run; + var editable = run.IsEditable && Model.CanManagePayData; + string Tab(string key) => Model.Tab == key ? "active" : ""; + string Establishment(string id) => string.IsNullOrWhiteSpace(id) ? "—" : Model.EstablishmentLabels.TryGetValue(id, out var l) ? l : id; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    +
    +
    +

    @localizer["Snapshot"]: @run.SnapshotStart.ToString("yyyy-MM-dd") – @run.SnapshotEnd.ToString("yyyy-MM-dd") · @localizer["Employees"]: @run.EmployeeCount · @localizer["Rows"]: @run.RowCount · @localizer["Exceptions"]: @run.ExceptionCount · @localizer["Warnings"]: @run.WarningCount

    +

    @localizer["WizardSteps"]

    +
    +
    + @if (editable) + { +
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    + } + @if (run.Status == (int)PayDataReportRunStatuses.Validated && Model.CanExportPayData) + { +
    @Html.AntiForgeryToken()
    + } + @if (run.IsEditable && Model.CanManagePayData || run.Status == (int)PayDataReportRunStatuses.Exported && Model.CanManagePayData) + { +
    @Html.AntiForgeryToken()
    + } + @if ((run.Status == (int)PayDataReportRunStatuses.Exported || run.Status == (int)PayDataReportRunStatuses.CertifiedExternally) && Model.CanManagePayData) + { +
    @Html.AntiForgeryToken()
    + } +
    +
    + +
    + @if (Model.Tab == "snapshots") + { +

    @localizer["SnapshotsHelp"]

    + + + + @foreach (var s in Model.Snapshots) + { + 0 ? "warning" : "")"> + + + + + + + if (editable) + { + + } + } + @if (Model.Snapshots.Count == 0) + { + + } + +
    @localizer["Worker"]@localizer["Establishment"]@localizer["JobCategory"]@localizer["DemographicCode"]@localizer["PayBand"]@localizer["Hours"]@localizer["HourlyRate"]@localizer["WorkMode"]@localizer["Included"]@localizer["Exceptions"]
    @s.WorkerDisplayName@Establishment(s.WorkforceEstablishmentId)@s.JobCategoryCode@ProtectedDataEnvelope.SafeDisplay(s.DemographicCode)@s.PayBandCode@s.AnnualHours.ToString("N0")@ProtectedDataEnvelope.SafeDisplay(s.HourlyRate)@localizer["WorkMode" + (WorkModes)s.WorkMode]@(s.IsIncluded ? localizer["Yes"] : localizer["No"])@foreach (var code in s.ExceptionCodes) { @localizer["Validation_" + code] } + @if (editable) + { + + } +
    +
    + @Html.AntiForgeryToken() + + + + + + +
    +
    @localizer["NoSnapshots"]
    + } + else if (Model.Tab == "rows") + { + + + + @foreach (var r in Model.Rows) + { + + } + @if (Model.Rows.Count == 0) + { + + } + +
    @localizer["Establishment"]@localizer["JobCategory"]@localizer["DemographicCode"]@localizer["PayBand"]@localizer["Employees"]@localizer["Hours"]@localizer["MeanRate"]@localizer["MedianRate"]@localizer["NonRemote"]@localizer["RemoteInCa"]@localizer["RemoteOutsideCa"]
    @Establishment(r.WorkforceEstablishmentId)@r.JobCategoryCode@ProtectedDataEnvelope.SafeDisplay(r.DemographicCode)@r.PayBandCode@r.EmployeeCount@r.AnnualHours.ToString("N0")@ProtectedDataEnvelope.SafeDisplay(r.MeanHourlyRate)@ProtectedDataEnvelope.SafeDisplay(r.MedianHourlyRate)@r.NonRemoteCount@r.RemoteWithinCaliforniaCount@r.RemoteOutsideCaliforniaCount
    @localizer["NoRows"]
    + } + else if (Model.Tab == "validation") + { +
    +
    + @if (Model.Validation == null) + { +

    @localizer["NotValidated"]

    + } + else + { +

    @localizer["ValidatedOn"]: @Model.Validation.ValidatedOn.ToString("yyyy-MM-dd HH:mm") — @(Model.Validation.CanFreeze ? localizer["ReadyToFreeze"] : localizer["NotReadyToFreeze"])

    +

    @localizer["Errors"] (@Model.Validation.Errors.Count)

    +
      @foreach (var e in Model.Validation.Errors) {
    • @localizer[e.Code.StartsWith("required_") ? "Validation_required" : "Validation_" + e.Code] @e.Scope @e.Detail
    • }
    +

    @localizer["Warnings"] (@Model.Validation.Warnings.Count)

    +
      @foreach (var w in Model.Validation.Warnings) {
    • @localizer["Validation_" + w.Code] @w.Scope @w.Detail
    • }
    + } +
    +
    +
    + @Html.AntiForgeryToken() + +
    @localizer["RunRemarksHelp"]
    + @if (editable) { } +
    +
    +
    + } + else + { +

    @localizer["ArtifactsHelp"]

    + + + + @foreach (var a in Model.Artifacts) + { + + + } + @if (Model.Artifacts.Count == 0) + { + + } + +
    @localizer["File"]@localizer["Format"]@localizer["Size"]@localizer["Checksum"]@localizer["Created"]@localizer["Expires"]@localizer["Downloads"]
    @a.FileName@((PayDataExportFormats)a.Format)@a.Size@a.Checksum@a.CreatedOn.ToString("yyyy-MM-dd")@(a.PurgedOn.HasValue ? localizer["Purged"].Value : a.ExpiresOn.ToString("yyyy-MM-dd"))@a.DownloadCount@if (a.IsAvailable && Model.CanExportPayData) { }
    @localizer["NoArtifacts"]
    + @if (run.IsFrozen && (Model.CanExportPayData || Model.CanManagePayData)) + { + @localizer["PortalWorksheet"] + } + @if (run.Status == (int)PayDataReportRunStatuses.Exported && Model.CanManagePayData) + { +
    + @Html.AntiForgeryToken() + + + + @localizer["MarkCertifiedHelp"] +
    + } + @if (run.Status == (int)PayDataReportRunStatuses.CertifiedExternally) + { +

    @localizer["StatusCertifiedExternally"] @run.CertifiedOn?.ToString("yyyy-MM-dd") · @localizer["CertificationReference"]: @run.CertificationReference · @localizer["Checksum"]: @run.CertifiedArtifactChecksum

    + } + } +
    +
    +
    +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/ResourceCosts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/ResourceCosts.cshtml new file mode 100644 index 00000000..02be875e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/ResourceCosts.cshtml @@ -0,0 +1,161 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceResourceCostsView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ResourceCosts"]; + ViewData["Title"] = localizer["ResourceCosts"].Value; + ViewData["Subtitle"] = localizer["ResourceCostsIntro"].Value; + ViewData["WorkforceTab"] = "resourcecosts"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; + var canEdit = Model.CanViewInternalCosts && Model.CanManage; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["ResourceProfiles"]
    + @if (canEdit) + { + + } +
    +
    +

    @localizer["ResourceProfilesHelp"]

    + + + + @foreach (var p in Model.Profiles) + { + var rate = Resgrid.Services.Workforce.FieldCostCalculator.DepreciationRate(p); + + + + + + + + } + @if (Model.Profiles.Count == 0) + { + + } + +
    @localizer["Subject"]@localizer["Name"]@localizer["EffectiveOn"]@localizer["AllocationBasis"]@localizer["DepreciationRate"]@localizer["Components"]@localizer["Approved"]
    @localizer["SubjectType" + (ResourceSubjectTypes)p.SubjectType]: @p.SubjectName@p.Name@p.EffectiveOn.ToString("yyyy-MM-dd")@localizer["Allocation" + (AllocationBases)p.AllocationBasis]@(rate.HasValue ? rate.Value.ToString("N4") : "—")@p.Components.Count@(p.IsApproved ? localizer["Yes"] : localizer["No"]) + + @if (canEdit) + { +
    @Html.AntiForgeryToken()
    + } +
    @localizer["NoResourceProfiles"]
    +
    +
    +
    + @if (x != null) + { +
    +
    + @Html.AntiForgeryToken() + + +
    +
    @(string.IsNullOrWhiteSpace(x.ResourceCostProfileId) ? localizer["AddResourceProfile"] : localizer["EditResourceProfile"])
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["ExternalResourceKeyHelp"]
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    @localizer["Depreciation"]

    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["UsefulLifeQuantityHelp"]
    +
    +
    +
    @localizer["ExpectedAnnualUtilizationHelp"]
    + +
    +
    +
    +
    +
    +
    @localizer["CostComponents"]
    @if (canEdit) {
    }
    +
    +

    @localizer["ResourceComponentsHelp"]

    + + + +
    @localizer["Category"]@localizer["Basis"]@localizer["Rate"]@localizer["Consumption"]@localizer["UnitPrice"]@localizer["Source"]@localizer["Approved"]
    + @if (canEdit) + { + + @localizer["Cancel"] + } +
    +
    +
    +
    + } +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Usage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Usage.cshtml new file mode 100644 index 00000000..d03b122e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Usage.cshtml @@ -0,0 +1,109 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceUsageView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ResourceUsage"]; + ViewData["Title"] = localizer["ResourceUsage"].Value + " — " + Model.ContextLabel; + ViewData["Subtitle"] = localizer["UsageIntro"].Value; + ViewData["WorkforceTab"] = "costruns"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["UsageEntries"]
    + +
    +
    + + + + @foreach (var u in Model.Entries) + { + + + + + + + + + + + + } + @if (Model.Entries.Count == 0) + { + + } + +
    @localizer["Date"]@localizer["Unit"]@localizer["Phase"]@localizer["Miles"]@localizer["EngineHours"]@localizer["OperatingHours"]@localizer["Days"]@localizer["Fuel"]@localizer["Source"]@localizer["Review"]
    @u.UsageDate.ToString("yyyy-MM-dd")@(u.UnitId.HasValue && Model.UnitNames.TryGetValue(u.UnitId.Value, out var n) ? n : u.InventoryAssetId ?? u.ExternalResourceKey)@localizer["Phase" + (UsagePhases)u.Phase]@u.CanonicalDistanceMiles?.ToString("N1") @(u.DistanceUnit == "km" && u.OriginalDistance.HasValue ? "(" + u.OriginalDistance.Value.ToString("N1") + " km)" : "")@u.EngineHours?.ToString("N1")@u.OperatingHours?.ToString("N1")@((u.DeployedDays ?? 0) + (u.StandbyDays ?? 0))@(u.FuelActualCost.HasValue ? u.FuelActualCost.Value.ToString("N2") : u.FuelQuantity.HasValue ? u.FuelQuantity.Value.ToString("N1") + " " + u.FuelUnit : "")@localizer["UsageSource" + (UsageSources)u.Source]@(u.NeedsReview ? localizer["Review_" + u.ReviewReason] : "") + +
    @Html.AntiForgeryToken()
    +
    @localizer["NoUsage"]
    +
    +
    +
    + @if (x != null) + { +
    +
    +
    @(string.IsNullOrWhiteSpace(x.ResourceUsageEntryId) ? localizer["AddUsage"] : localizer["EditUsage"])
    +
    +
    + @Html.AntiForgeryToken() + + + +
    +
    +
    +
    +
    +
    +
    +
    +

    @localizer["Distance"]

    +
    +
    +
    +
    +
    +
    @localizer["DistanceHelp"]
    +

    @localizer["Hours"]

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    @localizer["Fuel"]

    +
    +
    +
    +
    +
    + +
    +
    @localizer["Cancel"]
    +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/WorkEntries.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/WorkEntries.cshtml new file mode 100644 index 00000000..31b2ec38 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/WorkEntries.cshtml @@ -0,0 +1,89 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceWorkEntriesView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["WorkEntries"]; + ViewData["Title"] = localizer["WorkEntries"].Value; + ViewData["Subtitle"] = localizer["WorkEntriesIntro"].Value; + ViewData["WorkforceTab"] = "workentries"; + ViewData["WorkforcePage"] = Model; + var x = Model.Editing; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["WorkEntries"]
    +
    +
    + + + +
    + @if (Model.CanManageCompensation) + { + @localizer["AddWorkEntry"] + } +
    +
    +
    + + + + @foreach (var e in Model.Entries) + { + + + + + + + + + + + } + @if (Model.Entries.Count == 0) + { + + } + +
    @localizer["Date"]@localizer["Worker"]@localizer["Hours"]@localizer["HoursType"]@localizer["Establishment"]@localizer["Context"]@localizer["ApprovedPayrollCost"] @localizer["Approved"]
    @e.WorkDate.ToString("yyyy-MM-dd")@(Model.WorkerNames.TryGetValue(e.WorkforceWorkerId, out var n) ? n : e.WorkforceWorkerId)@e.Hours.ToString("N2")@localizer["HoursType" + (WorkHoursTypes)e.HoursType]@(Model.Establishments.FirstOrDefault(s => s.Value == e.WorkforceEstablishmentId)?.Text)@(e.CallId.HasValue ? "Call #" + e.CallId : !string.IsNullOrWhiteSpace(e.DeploymentId) ? localizer["Deployment"].Value : e.ExternalSource)@ProtectedDataEnvelope.SafeDisplay(e.ApprovedPayrollCost)@(e.IsApproved ? localizer["Yes"] : localizer["No"])@if (Model.CanManageCompensation) { }
    @localizer["NoWorkEntries"]
    +
    +
    +
    + @if (x != null && Model.CanManageCompensation) + { +
    +
    +
    @(string.IsNullOrWhiteSpace(x.WorkforceWorkEntryId) ? localizer["AddWorkEntry"] : localizer["EditWorkEntry"])
    +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["ApprovedPayrollCostHelp"]
    +
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Worker.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Worker.cshtml new file mode 100644 index 00000000..403521a4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Worker.cshtml @@ -0,0 +1,164 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceWorkerView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Worker"]; + ViewData["Title"] = Model.Worker.DisplayName; + ViewData["Subtitle"] = localizer["WorkerIntro"].Value; + ViewData["WorkforceTab"] = "workers"; + ViewData["WorkforcePage"] = Model; + var w = Model.Worker; + var em = Model.EditingEmployment; + var ja = Model.EditingAssignment; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["Worker"]
    +
    +
    + @Html.AntiForgeryToken() + + +

    @localizer["Member"]: @(string.IsNullOrWhiteSpace(w.UserId) ? localizer["No"].Value : w.DisplayName)

    +
    +
    +
    +
    + @if (Model.CanManage) + { + + } +
    +
    + @if (Model.CanManagePayData && Model.PayDataEnabled) + { +
    @localizer["DemographicRecord"] + } +
    +
    +
    +
    +
    +
    @localizer["Employments"]
    + @if (Model.CanManage) + { + + } +
    +
    + @foreach (var e in Model.Employments) + { +
    +
    + @localizer["WorkerKind" + (WorkerKinds)e.WorkerKind] @e.StartOn.ToString("yyyy-MM-dd") – @(e.EndOn?.ToString("yyyy-MM-dd") ?? "…") + · @localizer["EmploymentType" + (EmploymentTypes)e.EmploymentType] · @localizer["Exemption" + (ExemptionStatuses)e.ExemptionStatus] · @localizer["CaBasis" + (CaliforniaEmployeeBases)e.CaliforniaEmployeeBasis] + + @if (Model.CanViewCompensation) + { + @localizer["TabCompensation"] + } + @if (Model.CanManage) + { + + @localizer["AddAssignment"] +
    @Html.AntiForgeryToken()
    + } +
    +
    +
    + + + + @foreach (var a in e.Assignments) + { + + + + + + + + + } + @if (e.Assignments.Count == 0) + { + + } + +
    @localizer["EffectiveOn"]@localizer["ExpiresOn"]@localizer["JobTitle"]@localizer["JobCategory"]@localizer["Establishment"]@localizer["WorkMode"]@localizer["MarsClassification"]
    @a.EffectiveOn.ToString("yyyy-MM-dd")@(a.ExpiresOn?.ToString("yyyy-MM-dd") ?? "…")@a.JobTitle@a.JobCategoryCode @(Model.JobCategories.FirstOrDefault(j => j.Code == a.JobCategoryCode)?.Label)@(Model.Establishments.FirstOrDefault(x => x.Value == a.WorkforceEstablishmentId)?.Text)@localizer["WorkMode" + (WorkModes)a.WorkMode]@a.CalOesMarsClassificationCode + @if (Model.CanManage) + { + +
    @Html.AntiForgeryToken()
    + } +
    @localizer["NoAssignments"]
    +
    +
    + } + @if (Model.Employments.Count == 0) + { +

    @localizer["NoEmployments"]

    + } +
    +
    + @if (em != null && Model.CanManage) + { +
    +
    @(string.IsNullOrWhiteSpace(em.WorkforceEmploymentId) ? localizer["AddEmployment"] : localizer["EditEmployment"])
    +
    +
    + @Html.AntiForgeryToken() + + +
    +
    +
    +
    +
    +
    +
    +
    @localizer["PersonnelRoleHelp"]
    +
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    + } + @if (ja != null && Model.CanManage) + { +
    +
    @(string.IsNullOrWhiteSpace(ja.WorkforceJobAssignmentId) ? localizer["AddAssignment"] : localizer["EditAssignment"])
    +
    +
    + @Html.AntiForgeryToken() + + + + +
    +
    +
    +
    @localizer["JobCategoryHelp"]
    +
    +
    +
    +
    +
    +
    +
    +
    @localizer["Cancel"]
    +
    +
    +
    + } +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Workers.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Workers.cshtml new file mode 100644 index 00000000..f2c880b0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Workers.cshtml @@ -0,0 +1,63 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceWorkersView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["Workers"]; + ViewData["Title"] = localizer["Workers"].Value; + ViewData["Subtitle"] = localizer["WorkersIntro"].Value; + ViewData["WorkforceTab"] = "workers"; + ViewData["WorkforcePage"] = Model; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    +
    +
    +
    @localizer["Workers"]
    +
    + + + + @foreach (var row in Model.Workers) + { + + + + + + + + + } + @if (Model.Workers.Count == 0) + { + + } + +
    @localizer["Name"]@localizer["Member"]@localizer["ExternalWorkerKey"]@localizer["Employments"]@localizer["Active"]
    @row.DisplayName@(string.IsNullOrWhiteSpace(row.UserId) ? localizer["No"] : localizer["Yes"])@ProtectedDataEnvelope.SafeDisplay(row.ExternalWorkerKey)@(Model.EmploymentCounts.TryGetValue(row.WorkforceWorkerId, out var n) ? n : 0)@(row.IsActive ? localizer["Yes"] : localizer["No"])
    @localizer["NoWorkers"]
    +
    +
    +
    + @if (Model.CanManage) + { +
    +
    +
    @localizer["AddWorker"]
    +
    +
    + @Html.AntiForgeryToken() +
    @localizer["AddWorkerMemberHelp"]
    +
    +
    @localizer["AddWorkerExternalHelp"]
    + +
    +
    +
    +
    + } +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/Worksheet.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/Worksheet.cshtml new file mode 100644 index 00000000..dce05667 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/Worksheet.cshtml @@ -0,0 +1,77 @@ +@model Resgrid.Web.Areas.User.Models.Workforce.WorkforceWorksheetView +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["PortalWorksheet"]; + ViewData["Title"] = localizer["PortalWorksheet"].Value; + ViewData["Subtitle"] = localizer["WorksheetIntro"].Value; + ViewData["WorkforceTab"] = "paydata"; + ViewData["WorkforcePage"] = Model; + var w = Model.Worksheet; + string D(string v) => ProtectedDataEnvelope.SafeDisplay(v) ?? "—"; +} + +@await Html.PartialAsync("_WorkforceShell") + +
    + @await Html.PartialAsync("_WorkforceMessage", (Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView)Model) +
    @localizer["WorksheetNoStore"]
    +
    +
    +
    +
    @localizer["EmployerSection"] — @w.ReportingYear @localizer["ReportType" + (PayDataReportTypes)w.ReportType]
    +
    + + + + + + + + + + + + + + + + + + +
    @localizer["LegalName"]@w.EmployerLegalName
    @localizer["Fein"]@D(w.EmployerFein)
    @localizer["Sein"]@D(w.EmployerSein)
    @localizer["SosNumber"]@D(w.EmployerSosNumber)
    @localizer["Naics"]@w.EmployerNaics
    @localizer["EddAddress"]@D(w.EddAddress)
    @localizer["HeadquartersAddress"]@D(w.HeadquartersAddress)
    @localizer["IsIntegratedEnterprise"]@(w.IsIntegratedEnterprise ? localizer["Yes"] : localizer["No"])
    @localizer["UsEmployeeCount"]@w.UsEmployeeCount
    @localizer["CaliforniaEmployeeCount"]@w.CaliforniaEmployeeCount
    @localizer["SnapshotEmployeeCount"]@w.SnapshotEmployeeCount
    @localizer["Snapshot"]@w.SnapshotStart.ToString("yyyy-MM-dd") – @w.SnapshotEnd.ToString("yyyy-MM-dd")
    @localizer["DueDate"]@w.DueDate.ToString("yyyy-MM-dd")
    @localizer["FilingContactName"]@D(w.FilingContactName)
    @localizer["FilingContactEmail"]@D(w.FilingContactEmail)
    @localizer["FilingContactPhone"]@D(w.FilingContactPhone)
    @localizer["RunRemarks"]@D(w.RunRemarks)
    +
    +
    +
    +
    +
    +
    @localizer["Establishments"]
    +
    + + + + @foreach (var e in w.Establishments) + { + + } + +
    @localizer["Code"]@localizer["Name"]@localizer["Address"]@localizer["Naics"]@localizer["Employees"]@localizer["Headquarters"]@localizer["WasFiledPriorYear"]
    @e.Code@e.Name@D(e.Address), @e.City @e.State @e.Zip@e.Naics@e.EmployeeCount@(e.IsHeadquarters ? localizer["Yes"] : localizer["No"])@(e.WasFiledPriorYear == true ? localizer["Yes"] : localizer["No"])
    +
    +
    + @if (w.Affiliates.Count > 0) + { +
    +
    @localizer["Affiliates"]
    +
    + + + @foreach (var a in w.Affiliates) { } +
    @localizer["LegalName"]@localizer["Fein"]@localizer["Sein"]@localizer["SosNumber"]
    @a.LegalName@D(a.Fein)@D(a.Sein)@D(a.SosNumber)
    +
    +
    + } + @localizer["Back"] +
    +
    +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Workforce/_CompensationTable.cshtml b/Web/Resgrid.Web/Areas/User/Views/Workforce/_CompensationTable.cshtml new file mode 100644 index 00000000..24c8e33a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Workforce/_CompensationTable.cshtml @@ -0,0 +1,37 @@ +@model List +@using Resgrid.Model +@using Resgrid.Model.Workforce +@inject IStringLocalizer localizer +@{ + var roleNames = ViewData["RoleNames"] as Dictionary ?? new Dictionary(); + var employmentId = ViewData["EmploymentId"] as string; + var page = ViewData["WorkforcePage"] as Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView ?? new Resgrid.Web.Areas.User.Models.Workforce.WorkforcePageView(); +} + + + + @foreach (var p in Model) + { + + + + + + + + + + + } + @if (Model.Count == 0) + { + + } + +
    @localizer["Scope"]@localizer["Subject"]@localizer["EffectiveOn"]@localizer["ExpiresOn"]@localizer["PayBasis"]@localizer["BaseAmount"] @localizer["Currency"]@localizer["Approved"]@localizer["Components"]
    @localizer["Scope" + (CompensationScopes)p.Scope]@(p.Scope == (int)CompensationScopes.RoleDefault ? (p.PersonnelRoleId.HasValue && roleNames.TryGetValue(p.PersonnelRoleId.Value, out var r) ? r : p.PersonnelRoleId?.ToString()) : p.Scope == (int)CompensationScopes.DepartmentDefault ? localizer["Department"].Value : "")@p.EffectiveOn.ToString("yyyy-MM-dd")@(p.ExpiresOn?.ToString("yyyy-MM-dd") ?? "…")@localizer["PayBasis" + (PayBases)p.PayBasis]@ProtectedDataEnvelope.SafeDisplay(p.BaseAmount)@p.Currency@(p.IsApproved ? localizer["Yes"] : localizer["No"])@p.PayComponents.Count / @p.CostComponents.Count + + @if (page.CanManageCompensation) + { +
    @Html.AntiForgeryToken()
    + } +
    @localizer["NoProfiles"]
    diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 9c76fd31..62134e5f 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -327,6 +327,18 @@ public static bool CanDeleteContacts() 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 CanViewMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.View); + public static bool CanManageMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Update); + public static bool CanSubmitMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Submit); + public static bool CanReconcileMutualAidReimbursement() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Reconcile); + public static bool CanViewWorkforce() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View); + public static bool CanManageWorkforce() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.Update); + public static bool CanViewWorkforceCompensation() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.View); + public static bool CanManageWorkforceCompensation() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.Update); + public static bool CanViewInternalCosts() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.InternalCosts, ResgridClaimTypes.Actions.View); + public static bool CanViewPayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.View); + public static bool CanManagePayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Update); + public static bool CanExportPayDataReporting() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Export); 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 63050796..61b35ae8 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -274,6 +274,18 @@ public void ConfigureServices(IServiceCollection services) 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.MutualAidReimbursement_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Submit, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Submit)); + options.AddPolicy(ResgridResources.MutualAidReimbursement_Reconcile, policy => policy.RequireClaim(ResgridClaimTypes.Resources.MutualAidReimbursement, ResgridClaimTypes.Actions.Reconcile)); + options.AddPolicy(ResgridResources.Workforce_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.Workforce_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.WorkforceCompensation_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.WorkforceCompensation_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.InternalCosts_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.InternalCosts, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.PayDataReporting_View, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.View)); + options.AddPolicy(ResgridResources.PayDataReporting_Update, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Update)); + options.AddPolicy(ResgridResources.PayDataReporting_Export, policy => policy.RequireClaim(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Export)); 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/PayDataReportingReadinessCommand.cs b/Workers/Resgrid.Workers.Console/Commands/PayDataReportingReadinessCommand.cs new file mode 100644 index 00000000..ce7226a7 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/PayDataReportingReadinessCommand.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + /// Worker ID 49 (Identifier Allocation Registry, Workforce & Business Operations plan E5): California pay data readiness sweep and export artifact purge. + public sealed class PayDataReportingReadinessCommand : IQuidjiboCommand + { + public PayDataReportingReadinessCommand(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 f7ab1e58..b52e40ab 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -534,6 +534,14 @@ await Client.ScheduleAsync("Compliance Expiry", Cron.Daily(4, 30), stoppingToken); + // Worker ID 49 (Identifier Allocation Registry, Workforce & Business Operations plan E5): California pay data + // readiness digest (filing season only, value-free) and export artifact purge. Daily tick. + _logger.Log(LogLevel.Information, "Scheduling Pay Data Reporting Readiness"); + await Client.ScheduleAsync("Pay Data Reporting Readiness", + new Commands.PayDataReportingReadinessCommand(49), + Cron.Daily(4, 45), + 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/PayDataReportingReadinessTask.cs b/Workers/Resgrid.Workers.Console/Tasks/PayDataReportingReadinessTask.cs new file mode 100644 index 00000000..6e87223e --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/PayDataReportingReadinessTask.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 PayDataReportingReadinessTask : IQuidjiboHandler + { + public string Name => "Pay Data Reporting Readiness"; + public int Priority => 1; + public async Task ProcessAsync(PayDataReportingReadinessCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + var result = await new PayDataReportingReadinessLogic().Process(cancellationToken); + if (!result.Item1) throw new InvalidOperationException(result.Item2); + progress?.Report(100, result.Item2); + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs index 7ea3bd5d..631b29ea 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/DeploymentFinanceReminderLogic.cs @@ -10,7 +10,10 @@ 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. + /// Cal OES MARS duties (C-M3): annual Salary Survey / Administrative Rate expiry, agreement expiry, released + /// resources without a ready or submitted F-42, expense claims missing evidence, returned records and MARS + /// invoices awaiting local approval → a value-minimized digest per department per day. Never auto-submits, + /// approves, chooses a rate or changes an observed MARS state. ///
  • public sealed class DeploymentFinanceReminderLogic { @@ -24,7 +27,9 @@ public async Task> Process(CancellationToken ct) 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}"); + var mars = scope.Resolve(); + var marsNotified = await mars.RunReminderSweepAsync(DateTime.UtcNow, access.CanUseCostRecoveryAsync, ct); + return Tuple.Create(true, $"Deployment finance reminder: departments notified={notified}; Cal OES MARS digests={marsNotified}"); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) diff --git a/Workers/Resgrid.Workers.Framework/Logic/PayDataReportingReadinessLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/PayDataReportingReadinessLogic.cs new file mode 100644 index 00000000..d0fa2ff6 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/PayDataReportingReadinessLogic.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Worker 49 (Workforce & Business Operations plan E5), daily. During the California filing season + /// (WorkforceConfig.FilingSeasonStartMonth–FilingSeasonEndMonth) every department with an active employer profile + /// or a report run for the prior year gets one value-free readiness digest per day to its administrators: due + /// date, run state, counts of unresolved exceptions, missing demographic responses and missing annual pay + /// facts. Never a name, code, earnings figure or rate; never files anything. All year it purges the bytes of + /// export artifacts past WorkforceConfig.ExportArtifactRetentionDays (the run, snapshots and rows stay). + /// + public sealed class PayDataReportingReadinessLogic + { + public async Task> Process(CancellationToken ct) + { + try + { + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var reporting = scope.Resolve(); + var access = scope.Resolve(); + var purged = await reporting.PurgeExpiredArtifactsAsync(DateTime.UtcNow, ct); + var notified = await reporting.RunReadinessSweepAsync(DateTime.UtcNow, access.CanUsePayDataReportingAsync, ct); + return Tuple.Create(true, $"Pay data reporting readiness: departments notified={notified}; artifacts purged={purged}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "Pay data reporting readiness worker failed."); + return Tuple.Create(false, "Pay data reporting readiness failed."); + } + } + } +}