From 79cf35f6907e5e20faca7a46dc25cb03e7baed54 Mon Sep 17 00:00:00 2001 From: Poloman Date: Thu, 10 Sep 2026 16:43:09 +0200 Subject: [PATCH] first commit --- .editorconfig | 23 + .gitignore | 25 + Directory.Build.props | 12 + GMAO.sln | 101 ++ README.md | 110 ++ docs/ARCHITECTURE.md | 34 + docs/DATABASE_SCHEMA.md | 85 ++ src/App.Core/App.Core.csproj | 16 + src/App.Core/Dtos/Sync/MachineDto.cs | 17 + src/App.Core/Dtos/Sync/MaintenancePlanDto.cs | 24 + src/App.Core/Dtos/Sync/PairingDto.cs | 6 + src/App.Core/Dtos/Sync/SyncPullResponseDto.cs | 8 + src/App.Core/Dtos/Sync/SyncPushRequestDto.cs | 24 + src/App.Core/Dtos/Sync/WorkOrderDto.cs | 35 + .../Entities/Inventory/MachinePart.cs | 15 + src/App.Core/Entities/Inventory/Part.cs | 31 + .../Entities/Inventory/StockMovement.cs | 31 + src/App.Core/Entities/Inventory/Supplier.cs | 17 + src/App.Core/Entities/Invoicing/Invoice.cs | 69 + .../Entities/Invoicing/SupplierInvoice.cs | 21 + .../Licensing/LicenseActivationCache.cs | 20 + src/App.Core/Entities/Machines/Machine.cs | 39 + .../Machines/MachineCounterReading.cs | 29 + .../Entities/Machines/MachineCriticality.cs | 9 + .../Entities/Maintenance/FrequencyType.cs | 26 + .../Entities/Maintenance/MaintenancePlan.cs | 76 ++ .../Entities/Maintenance/MaintenanceTask.cs | 45 + .../Entities/Maintenance/WorkOrder.cs | 84 ++ .../Maintenance/WorkOrderTaskResult.cs | 41 + src/App.Core/Entities/Shared/AppSetting.cs | 25 + src/App.Core/Entities/Shared/Attachment.cs | 49 + src/App.Core/Entities/Sync/SyncDevice.cs | 51 + .../Entities/Team/QualificationType.cs | 24 + .../Entities/Team/TechnicianProfile.cs | 18 + .../Entities/Team/TechnicianQualification.cs | 26 + src/App.Core/Entities/Team/TimeEntry.cs | 36 + src/App.Core/Entities/Team/User.cs | 28 + src/App.Core/Entities/Team/UserRole.cs | 13 + src/App.Core/Interfaces/IPdfGenerator.cs | 10 + src/App.Core/Interfaces/ISettingsProvider.cs | 11 + src/App.Core/Interfaces/IUnitOfWork.cs | 7 + .../Licensing/IHardwareIdProvider.cs | 9 + .../Licensing/ILicenseFileRepository.cs | 16 + .../Interfaces/Licensing/ILicenseService.cs | 23 + .../Interfaces/Licensing/ITrialStateStore.cs | 14 + .../Repositories/IAttachmentRepository.cs | 8 + .../Repositories/IInvoiceRepository.cs | 12 + .../IMachineCounterReadingRepository.cs | 7 + .../Repositories/IMachineRepository.cs | 11 + .../IMaintenancePlanRepository.cs | 9 + .../Repositories/IPartRepository.cs | 11 + .../Interfaces/Repositories/IRepository.cs | 20 + .../Repositories/IStockMovementRepository.cs | 8 + .../Repositories/ISupplierRepository.cs | 7 + .../Repositories/ISyncDeviceRepository.cs | 8 + .../Repositories/IUserRepository.cs | 10 + .../Repositories/IWorkOrderRepository.cs | 16 + src/App.Core/Services/Kpi/KpiCalculator.cs | 86 ++ .../Licensing/DateOnlyJsonConverter.cs | 23 + .../Licensing/Ed25519PublicKeyProvider.cs | 92 ++ src/App.Core/Services/Licensing/HardwareId.cs | 31 + .../Services/Licensing/LicenseCheckResult.cs | 35 + .../Services/Licensing/LicenseFile.cs | 20 + .../LicenseFileCanonicalSerializer.cs | 29 + .../Services/Licensing/LicenseJsonOptions.cs | 18 + .../Services/Licensing/LicenseService.cs | 95 ++ .../Services/Licensing/LicenseValidator.cs | 49 + src/App.Core/Services/Licensing/TrialState.cs | 24 + .../Maintenance/FrequencyCalculator.cs | 153 +++ .../MaintenanceSchedulingEngine.cs | 126 ++ .../Maintenance/WorkOrderCompletionService.cs | 56 + .../App.Infrastructure.csproj | 28 + .../Configurations/InventoryConfigurations.cs | 41 + .../Configurations/InvoicingConfigurations.cs | 36 + .../Configurations/MachineConfiguration.cs | 60 + .../MaintenancePlanConfiguration.cs | 65 + .../Configurations/SharedConfigurations.cs | 38 + .../Data/Configurations/SyncConfigurations.cs | 28 + .../Data/Configurations/TeamConfigurations.cs | 67 + .../Configurations/WorkOrderConfiguration.cs | 59 + src/App.Infrastructure/Data/DbInitializer.cs | 18 + src/App.Infrastructure/Data/GmaoDbContext.cs | 78 ++ .../Data/GmaoDbContextFactory.cs | 20 + .../20260910115542_InitialCreate.Designer.cs | 1201 +++++++++++++++++ .../20260910115542_InitialCreate.cs | 884 ++++++++++++ .../Migrations/GmaoDbContextModelSnapshot.cs | 1198 ++++++++++++++++ .../Licensing/DpapiTrialStateStore.cs | 57 + .../Licensing/LicenseFileRepository.cs | 41 + .../Licensing/WmiHardwareIdProvider.cs | 41 + src/App.UI/.gitignore | 54 + src/App.UI/App.UI.csproj | 79 ++ src/App.UI/App.xaml | 43 + src/App.UI/App.xaml.cs | 43 + src/App.UI/Assets/AppIcon.ico | Bin 0 -> 370070 bytes .../Assets/LockScreenLogo.scale-200.png | Bin 0 -> 1090 bytes src/App.UI/Assets/SplashScreen.scale-200.png | Bin 0 -> 12625 bytes .../Assets/Square150x150Logo.scale-200.png | Bin 0 -> 6357 bytes .../Assets/Square44x44Logo.scale-200.png | Bin 0 -> 1910 bytes ...x44Logo.targetsize-24_altform-unplated.png | Bin 0 -> 574 bytes ...go.targetsize-48_altform-lightunplated.png | Bin 0 -> 1091 bytes src/App.UI/Assets/StoreLogo.png | Bin 0 -> 1106 bytes .../Assets/Wide310x150Logo.scale-200.png | Bin 0 -> 5843 bytes src/App.UI/MainWindow.xaml | 96 ++ src/App.UI/MainWindow.xaml.cs | 69 + src/App.UI/Package.appxmanifest | 73 + src/App.UI/Pages/DashboardPage.xaml | 115 ++ src/App.UI/Pages/DashboardPage.xaml.cs | 16 + src/App.UI/Pages/InventoryPage.xaml | 58 + src/App.UI/Pages/InventoryPage.xaml.cs | 16 + src/App.UI/Pages/InvoicingPage.xaml | 62 + src/App.UI/Pages/InvoicingPage.xaml.cs | 16 + src/App.UI/Pages/MachinesPage.xaml | 54 + src/App.UI/Pages/MachinesPage.xaml.cs | 16 + src/App.UI/Pages/MaintenanceCalendarPage.xaml | 56 + .../Pages/MaintenanceCalendarPage.xaml.cs | 16 + src/App.UI/Pages/SettingsPage.xaml | 19 + src/App.UI/Pages/SettingsPage.xaml.cs | 17 + src/App.UI/Pages/TeamPage.xaml | 50 + src/App.UI/Pages/TeamPage.xaml.cs | 16 + src/App.UI/Pages/WorkOrdersPage.xaml | 62 + src/App.UI/Pages/WorkOrdersPage.xaml.cs | 16 + src/App.UI/Properties/launchSettings.json | 10 + src/App.UI/Services/InMemoryRepository.cs | 30 + src/App.UI/Services/MockData.cs | 126 ++ src/App.UI/Services/MockRepositories.cs | 78 ++ src/App.UI/Services/ServiceRegistration.cs | 35 + src/App.UI/ViewModels/DashboardViewModel.cs | 75 + src/App.UI/ViewModels/InventoryViewModel.cs | 31 + src/App.UI/ViewModels/InvoicingViewModel.cs | 31 + src/App.UI/ViewModels/MachinesViewModel.cs | 31 + .../MaintenanceCalendarViewModel.cs | 45 + src/App.UI/ViewModels/TeamViewModel.cs | 31 + src/App.UI/ViewModels/WorkOrdersViewModel.cs | 42 + src/App.UI/app.manifest | 19 + .../KeyManagement/LicenseKeyPairGenerator.cs | 21 + .../KeyManagement/PrivateKeyStore.cs | 37 + src/LicenseGenerator/LicenseGenerator.csproj | 21 + src/LicenseGenerator/Program.cs | 117 ++ tests/App.Core.Tests/App.Core.Tests.csproj | 28 + .../App.Core.Tests/Kpi/KpiCalculatorTests.cs | 146 ++ .../Licensing/LicenseServiceTests.cs | 131 ++ .../Licensing/LicenseValidatorTests.cs | 89 ++ .../Licensing/TrialStateTests.cs | 55 + .../Maintenance/FrequencyCalculatorTests.cs | 174 +++ 144 files changed, 8822 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 GMAO.sln create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 src/App.Core/App.Core.csproj create mode 100644 src/App.Core/Dtos/Sync/MachineDto.cs create mode 100644 src/App.Core/Dtos/Sync/MaintenancePlanDto.cs create mode 100644 src/App.Core/Dtos/Sync/PairingDto.cs create mode 100644 src/App.Core/Dtos/Sync/SyncPullResponseDto.cs create mode 100644 src/App.Core/Dtos/Sync/SyncPushRequestDto.cs create mode 100644 src/App.Core/Dtos/Sync/WorkOrderDto.cs create mode 100644 src/App.Core/Entities/Inventory/MachinePart.cs create mode 100644 src/App.Core/Entities/Inventory/Part.cs create mode 100644 src/App.Core/Entities/Inventory/StockMovement.cs create mode 100644 src/App.Core/Entities/Inventory/Supplier.cs create mode 100644 src/App.Core/Entities/Invoicing/Invoice.cs create mode 100644 src/App.Core/Entities/Invoicing/SupplierInvoice.cs create mode 100644 src/App.Core/Entities/Licensing/LicenseActivationCache.cs create mode 100644 src/App.Core/Entities/Machines/Machine.cs create mode 100644 src/App.Core/Entities/Machines/MachineCounterReading.cs create mode 100644 src/App.Core/Entities/Machines/MachineCriticality.cs create mode 100644 src/App.Core/Entities/Maintenance/FrequencyType.cs create mode 100644 src/App.Core/Entities/Maintenance/MaintenancePlan.cs create mode 100644 src/App.Core/Entities/Maintenance/MaintenanceTask.cs create mode 100644 src/App.Core/Entities/Maintenance/WorkOrder.cs create mode 100644 src/App.Core/Entities/Maintenance/WorkOrderTaskResult.cs create mode 100644 src/App.Core/Entities/Shared/AppSetting.cs create mode 100644 src/App.Core/Entities/Shared/Attachment.cs create mode 100644 src/App.Core/Entities/Sync/SyncDevice.cs create mode 100644 src/App.Core/Entities/Team/QualificationType.cs create mode 100644 src/App.Core/Entities/Team/TechnicianProfile.cs create mode 100644 src/App.Core/Entities/Team/TechnicianQualification.cs create mode 100644 src/App.Core/Entities/Team/TimeEntry.cs create mode 100644 src/App.Core/Entities/Team/User.cs create mode 100644 src/App.Core/Entities/Team/UserRole.cs create mode 100644 src/App.Core/Interfaces/IPdfGenerator.cs create mode 100644 src/App.Core/Interfaces/ISettingsProvider.cs create mode 100644 src/App.Core/Interfaces/IUnitOfWork.cs create mode 100644 src/App.Core/Interfaces/Licensing/IHardwareIdProvider.cs create mode 100644 src/App.Core/Interfaces/Licensing/ILicenseFileRepository.cs create mode 100644 src/App.Core/Interfaces/Licensing/ILicenseService.cs create mode 100644 src/App.Core/Interfaces/Licensing/ITrialStateStore.cs create mode 100644 src/App.Core/Interfaces/Repositories/IAttachmentRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IInvoiceRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IMachineCounterReadingRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IMachineRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IMaintenancePlanRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IPartRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IStockMovementRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/ISupplierRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/ISyncDeviceRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IUserRepository.cs create mode 100644 src/App.Core/Interfaces/Repositories/IWorkOrderRepository.cs create mode 100644 src/App.Core/Services/Kpi/KpiCalculator.cs create mode 100644 src/App.Core/Services/Licensing/DateOnlyJsonConverter.cs create mode 100644 src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs create mode 100644 src/App.Core/Services/Licensing/HardwareId.cs create mode 100644 src/App.Core/Services/Licensing/LicenseCheckResult.cs create mode 100644 src/App.Core/Services/Licensing/LicenseFile.cs create mode 100644 src/App.Core/Services/Licensing/LicenseFileCanonicalSerializer.cs create mode 100644 src/App.Core/Services/Licensing/LicenseJsonOptions.cs create mode 100644 src/App.Core/Services/Licensing/LicenseService.cs create mode 100644 src/App.Core/Services/Licensing/LicenseValidator.cs create mode 100644 src/App.Core/Services/Licensing/TrialState.cs create mode 100644 src/App.Core/Services/Maintenance/FrequencyCalculator.cs create mode 100644 src/App.Core/Services/Maintenance/MaintenanceSchedulingEngine.cs create mode 100644 src/App.Core/Services/Maintenance/WorkOrderCompletionService.cs create mode 100644 src/App.Infrastructure/App.Infrastructure.csproj create mode 100644 src/App.Infrastructure/Data/Configurations/InventoryConfigurations.cs create mode 100644 src/App.Infrastructure/Data/Configurations/InvoicingConfigurations.cs create mode 100644 src/App.Infrastructure/Data/Configurations/MachineConfiguration.cs create mode 100644 src/App.Infrastructure/Data/Configurations/MaintenancePlanConfiguration.cs create mode 100644 src/App.Infrastructure/Data/Configurations/SharedConfigurations.cs create mode 100644 src/App.Infrastructure/Data/Configurations/SyncConfigurations.cs create mode 100644 src/App.Infrastructure/Data/Configurations/TeamConfigurations.cs create mode 100644 src/App.Infrastructure/Data/Configurations/WorkOrderConfiguration.cs create mode 100644 src/App.Infrastructure/Data/DbInitializer.cs create mode 100644 src/App.Infrastructure/Data/GmaoDbContext.cs create mode 100644 src/App.Infrastructure/Data/GmaoDbContextFactory.cs create mode 100644 src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.Designer.cs create mode 100644 src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.cs create mode 100644 src/App.Infrastructure/Data/Migrations/GmaoDbContextModelSnapshot.cs create mode 100644 src/App.Infrastructure/Licensing/DpapiTrialStateStore.cs create mode 100644 src/App.Infrastructure/Licensing/LicenseFileRepository.cs create mode 100644 src/App.Infrastructure/Licensing/WmiHardwareIdProvider.cs create mode 100644 src/App.UI/.gitignore create mode 100644 src/App.UI/App.UI.csproj create mode 100644 src/App.UI/App.xaml create mode 100644 src/App.UI/App.xaml.cs create mode 100644 src/App.UI/Assets/AppIcon.ico create mode 100644 src/App.UI/Assets/LockScreenLogo.scale-200.png create mode 100644 src/App.UI/Assets/SplashScreen.scale-200.png create mode 100644 src/App.UI/Assets/Square150x150Logo.scale-200.png create mode 100644 src/App.UI/Assets/Square44x44Logo.scale-200.png create mode 100644 src/App.UI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png create mode 100644 src/App.UI/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png create mode 100644 src/App.UI/Assets/StoreLogo.png create mode 100644 src/App.UI/Assets/Wide310x150Logo.scale-200.png create mode 100644 src/App.UI/MainWindow.xaml create mode 100644 src/App.UI/MainWindow.xaml.cs create mode 100644 src/App.UI/Package.appxmanifest create mode 100644 src/App.UI/Pages/DashboardPage.xaml create mode 100644 src/App.UI/Pages/DashboardPage.xaml.cs create mode 100644 src/App.UI/Pages/InventoryPage.xaml create mode 100644 src/App.UI/Pages/InventoryPage.xaml.cs create mode 100644 src/App.UI/Pages/InvoicingPage.xaml create mode 100644 src/App.UI/Pages/InvoicingPage.xaml.cs create mode 100644 src/App.UI/Pages/MachinesPage.xaml create mode 100644 src/App.UI/Pages/MachinesPage.xaml.cs create mode 100644 src/App.UI/Pages/MaintenanceCalendarPage.xaml create mode 100644 src/App.UI/Pages/MaintenanceCalendarPage.xaml.cs create mode 100644 src/App.UI/Pages/SettingsPage.xaml create mode 100644 src/App.UI/Pages/SettingsPage.xaml.cs create mode 100644 src/App.UI/Pages/TeamPage.xaml create mode 100644 src/App.UI/Pages/TeamPage.xaml.cs create mode 100644 src/App.UI/Pages/WorkOrdersPage.xaml create mode 100644 src/App.UI/Pages/WorkOrdersPage.xaml.cs create mode 100644 src/App.UI/Properties/launchSettings.json create mode 100644 src/App.UI/Services/InMemoryRepository.cs create mode 100644 src/App.UI/Services/MockData.cs create mode 100644 src/App.UI/Services/MockRepositories.cs create mode 100644 src/App.UI/Services/ServiceRegistration.cs create mode 100644 src/App.UI/ViewModels/DashboardViewModel.cs create mode 100644 src/App.UI/ViewModels/InventoryViewModel.cs create mode 100644 src/App.UI/ViewModels/InvoicingViewModel.cs create mode 100644 src/App.UI/ViewModels/MachinesViewModel.cs create mode 100644 src/App.UI/ViewModels/MaintenanceCalendarViewModel.cs create mode 100644 src/App.UI/ViewModels/TeamViewModel.cs create mode 100644 src/App.UI/ViewModels/WorkOrdersViewModel.cs create mode 100644 src/App.UI/app.manifest create mode 100644 src/LicenseGenerator/KeyManagement/LicenseKeyPairGenerator.cs create mode 100644 src/LicenseGenerator/KeyManagement/PrivateKeyStore.cs create mode 100644 src/LicenseGenerator/LicenseGenerator.csproj create mode 100644 src/LicenseGenerator/Program.cs create mode 100644 tests/App.Core.Tests/App.Core.Tests.csproj create mode 100644 tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs create mode 100644 tests/App.Core.Tests/Licensing/LicenseServiceTests.cs create mode 100644 tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs create mode 100644 tests/App.Core.Tests/Licensing/TrialStateTests.cs create mode 100644 tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ce828cd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +indent_style = space +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +indent_size = 4 +csharp_new_line_before_open_brace = all +dotnet_sort_system_directives_first = true +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_property = false +dotnet_style_qualification_for_method = false +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_diagnostic.CS8618.severity = error +dotnet_diagnostic.CS8625.severity = error + +[*.{xaml,csproj,props,targets}] +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c0d34a --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +## Build outputs +[Bb]in/ +[Oo]bj/ +*.user + +## Visual Studio +.vs/ + +## Rider +.idea/ + +## SQLite databases (dev/test artifacts, jamais commit la base) +*.db +*.db-shm +*.db-wal + +## MSIX packaging output +AppPackages/ +BundleArtifacts/ + +## Licences generees localement (jamais commit une vraie licence client) +*.lic + +## Cle privee Ed25519 (ne doit JAMAIS quitter le poste LicenseGenerator) +*.key.protected diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f18a0ed --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + latest + true + $(NoWarn);CS1591 + Pablo Larade + GMAO + + diff --git a/GMAO.sln b/GMAO.sln new file mode 100644 index 0000000..bcd5488 --- /dev/null +++ b/GMAO.sln @@ -0,0 +1,101 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{55E12BBD-F3A7-4DF4-8E97-D8EC8EB77606}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Core", "src\App.Core\App.Core.csproj", "{20682746-2014-449C-A618-6AD15C60D1A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LicenseGenerator", "src\LicenseGenerator\LicenseGenerator.csproj", "{4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B4BDDA9F-787B-49EF-A83B-A3F0DD511515}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Core.Tests", "tests\App.Core.Tests\App.Core.Tests.csproj", "{DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Infrastructure", "src\App.Infrastructure\App.Infrastructure.csproj", "{C8AF8124-2556-4659-880F-0C6DA32ADC17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.UI", "src\App.UI\App.UI.csproj", "{5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|x64.Build.0 = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Debug|x86.Build.0 = Debug|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|Any CPU.Build.0 = Release|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|x64.ActiveCfg = Release|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|x64.Build.0 = Release|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|x86.ActiveCfg = Release|Any CPU + {20682746-2014-449C-A618-6AD15C60D1A9}.Release|x86.Build.0 = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|x64.Build.0 = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Debug|x86.Build.0 = Debug|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|Any CPU.Build.0 = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|x64.ActiveCfg = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|x64.Build.0 = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|x86.ActiveCfg = Release|Any CPU + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54}.Release|x86.Build.0 = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|x64.Build.0 = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Debug|x86.Build.0 = Debug|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|Any CPU.Build.0 = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|x64.ActiveCfg = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|x64.Build.0 = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|x86.ActiveCfg = Release|Any CPU + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D}.Release|x86.Build.0 = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|x64.Build.0 = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Debug|x86.Build.0 = Debug|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|Any CPU.Build.0 = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|x64.ActiveCfg = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|x64.Build.0 = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|x86.ActiveCfg = Release|Any CPU + {C8AF8124-2556-4659-880F-0C6DA32ADC17}.Release|x86.Build.0 = Release|Any CPU + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|Any CPU.ActiveCfg = Debug|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|Any CPU.Build.0 = Debug|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|x64.ActiveCfg = Debug|x64 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|x64.Build.0 = Debug|x64 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|x86.ActiveCfg = Debug|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Debug|x86.Build.0 = Debug|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|Any CPU.ActiveCfg = Release|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|Any CPU.Build.0 = Release|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|x64.ActiveCfg = Release|x64 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|x64.Build.0 = Release|x64 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|x86.ActiveCfg = Release|x86 + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {20682746-2014-449C-A618-6AD15C60D1A9} = {55E12BBD-F3A7-4DF4-8E97-D8EC8EB77606} + {4B140CEB-B3F3-4D84-8D35-29E8DDCB9F54} = {55E12BBD-F3A7-4DF4-8E97-D8EC8EB77606} + {DC8A3E58-D65E-4FF5-9B7C-DAE9EF10108D} = {B4BDDA9F-787B-49EF-A83B-A3F0DD511515} + {C8AF8124-2556-4659-880F-0C6DA32ADC17} = {55E12BBD-F3A7-4DF4-8E97-D8EC8EB77606} + {5D4E3109-1ECB-4A0F-8C78-EE9044841FEC} = {55E12BBD-F3A7-4DF4-8E97-D8EC8EB77606} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..af6a863 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# ARGOS — GMAO pour PME industrielles + +Logiciel de Gestion de Maintenance Assistée par Ordinateur (GMAO) pour Windows 10/11, +destiné aux PME industrielles sans GMAO existante (maintenance électrotechnique et mécanique). +Desktop WinUI 3, base SQLite locale, licence perpétuelle liée au poste, synchronisation future +avec une app Android terrain via le réseau local (zéro cloud, zéro coût récurrent). + +> Nom de marque : **ARGOS**. "GMAO" reste le terme générique utilisé dans le code/les docs. + +## Stack technique + +- **C# / .NET 8** (LTS) +- **WinUI 3** (Windows App SDK) + **CommunityToolkit.Mvvm** (MVVM) +- **EF Core 8 + SQLite** (`Microsoft.Data.Sqlite`) +- **BouncyCastle.Cryptography** (Ed25519 — .NET n'a pas de support natif) +- **QuestPDF** (génération PDF factures/devis — pas encore intégré) +- **xUnit** (tests unitaires sur `App.Core`) +- Packaging **MSIX** (Windows App SDK templates officiels) + +## Structure de la solution + +``` +GMAO.sln +├── src/ +│ ├── App.Core # Domaine pur (entités, interfaces, logique métier). Zéro dépendance EF/UI. +│ ├── App.Infrastructure # EF Core (GmaoDbContext + migrations), WMI, DPAPI, fichiers licence +│ ├── App.UI # WinUI 3 — sidebar, pages, ViewModels, packaging MSIX +│ ├── App.SyncServer # Squelette de dossiers uniquement — API HTTP locale + mDNS (pas commencé) +│ └── LicenseGenerator # Console interne (keygen/issue) — jamais packagée dans le client +├── tests/ +│ └── App.Core.Tests # xUnit — licence, calcul d'échéances, KPI +└── docs/ + ├── ARCHITECTURE.md # Décisions structurantes détaillées + └── DATABASE_SCHEMA.md # Schéma complet table par table +``` + +## Démarrer + +Prérequis : SDK .NET 8, Visual Studio 2022 (workloads *.NET Desktop Development* + +*Windows application development*), Mode développeur Windows activé. + +```powershell +# Build de toute la solution +dotnet build + +# Tests unitaires App.Core +dotnet test tests/App.Core.Tests + +# Lancer l'app WinUI3 (packaging MSIX debug via winapp CLI) +cd src/App.UI +dotnet run +``` + +## État d'avancement + +Légende : ✅ fait et testé · 🚧 en cours / partiel · ⬜ pas commencé + +### Socle technique +- ✅ Solution scaffoldée, 5 projets, tout compile (0 erreur/warning) +- ✅ `Directory.Build.props`, `.editorconfig`, `.gitignore` +- ✅ Environnement de build fonctionnel (SDK .NET 8, VS 2022, templates WinUI3, Mode développeur) + +### App.Core (domaine) +- ✅ Toutes les entités du schéma (Machines, Maintenance, Stock, Facturation, Équipe, Sync, pièces jointes, paramètres) +- ✅ Interfaces de repository (CRUD générique + requêtes métier spécifiques) +- ✅ **Licence** : essai 30 jours, anti-recul d'horloge, signature Ed25519, activation — logique complète et testée. Un bug de sécurité trouvé en cours de route (fichier `.lic` corrompu → repli silencieux en essai gratuit) a été corrigé et couvert par un test. +- ✅ **Calendrier préventif** : `FrequencyCalculator` (9 types de fréquence dont compteur et règles type "1er lundi du mois"), `MaintenanceSchedulingEngine` (génération au fur et à mesure, dédoublonnage par clé d'occurrence unique), `WorkOrderCompletionService` +- ✅ **KPI** : MTBF, MTTR, disponibilité, coût, taux de respect du planning +- ✅ DTOs de synchronisation Android (contrats prêts, app pas développée) +- ✅ 45 tests xUnit, tous verts +- ⬜ Clé publique Ed25519 réelle (placeholder tant que `LicenseGenerator keygen` n'a pas été exécuté pour de vrai) + +### App.Infrastructure +- ✅ `GmaoDbContext` + configurations EF Core (index unique filtré anti-doublon d'OT vérifié sur SQLite réel) +- ✅ Migration initiale générée et appliquée avec succès +- ✅ `WmiHardwareIdProvider`, `DpapiTrialStateStore`, `LicenseFileRepository` +- ⬜ Repositories EF Core concrets pour les autres entités (Machines, Stock, Équipe, Factures — seules les interfaces existent) +- ⬜ `AttachmentStorageService`, `InvoicePdfGenerator` (QuestPDF) + +### App.UI (WinUI 3) +- ✅ Scaffold officiel (template Windows App SDK), sidebar avec les 8 sections du cahier des charges +- ✅ `Package.appxmanifest` avec capabilities commentées (`runFullTrust`, `privateNetworkClientServer`) +- ✅ Branding ARGOS (nom, identité MSIX, logo simple dans la sidebar) +- ✅ DI (`Microsoft.Extensions.DependencyInjection`) + ViewModels MVVM pour les 7 pages +- ✅ Tableau de bord avec pastilles KPI colorées (OT ouverts/en retard, stock sous seuil, respect planning) +- 🚧 Toutes les pages tournent sur des **repositories factices en mémoire** (`Services/MockData.cs`) — à remplacer par les implémentations EF Core d'App.Infrastructure (interfaces Core déjà en place, donc zéro changement côté ViewModel prévu) +- ⬜ Écran d'activation de licence + bandeau "essai : X jours restants" +- ⬜ Vraie vue calendrier (mensuelle/hebdomadaire, drag & drop) — actuellement une simple liste +- ⬜ Drag & drop fichiers (pièces jointes, factures), copier-coller façon tableur +- ⬜ Authentification locale (login/mot de passe) + +### LicenseGenerator +- ✅ `keygen` (génère la paire Ed25519 + fragments obfusqués à coller) et `issue` (émet un `.lic` signé) — compilent, jamais exécutés pour de vrai (clé placeholder encore en place dans `Ed25519PublicKeyProvider`) + +### App.SyncServer +- ⬜ Pas commencé (dossiers vides). Décision ouverte : hébergé en process dans App.UI (Kestrel embarqué) vs. exécutable séparé — voir `docs/ARCHITECTURE.md`. + +### Autres modules du cahier des charges (Machines, Stock, Équipe, Factures) +- ⬜ Logique métier et écrans réels — actuellement uniquement des ViewModels de démonstration sur données factices. + +### Packaging / Store +- ⬜ Build MSIX de production, checklist de soumission Microsoft Store + +## Prochaines étapes suggérées + +1. Repositories EF Core réels (remplacer les Mock\* dans `App.UI/Services`) +2. Écran d'activation de licence + bandeau d'essai +3. Génération réelle de la paire de clés Ed25519 (`LicenseGenerator keygen`) +4. Module Machines complet (fiches, pièces jointes, historique) +5. Vraie vue calendrier préventif diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a8d00c2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,34 @@ +# Architecture — ARGOS (GMAO) + +> Nom de marque : **ARGOS**. "GMAO" reste utilisé comme terme générique (Gestion de Maintenance +> Assistée par Ordinateur) dans le code/les noms de dossiers existants — pas de renommage du repo. + +## Projets + +- **App.Core** (`net8.0`, zéro dépendance EF/WinUI/ASP.NET) : entités du domaine, interfaces de repository, services métier purs (licence, planification préventive, KPI), DTOs de synchronisation. Dépend uniquement de `BouncyCastle.Cryptography` (Ed25519, .NET n'a pas de support natif). +- **App.Infrastructure** : EF Core (SQLite), implémentations des repositories, WMI (hardware id), DPAPI (état d'essai), QuestPDF, stockage des pièces jointes. +- **App.UI** : WinUI 3, MVVM (CommunityToolkit.Mvvm), packaging MSIX. +- **App.SyncServer** : API HTTP locale (ASP.NET Core Minimal API) + découverte mDNS pour la future app Android. +- **LicenseGenerator** : console séparée, reste sur le poste de développement, jamais packagée dans le client. +- **tests/App.Core.Tests** : xUnit, couvre licence / calcul d'échéances / KPI (logique pure, sans I/O). + +## Décisions structurantes + +- **Rôles utilisateurs** : enum `UserRole` sur `User`, pas de table `Roles` séparée (3 rôles fixes en V1). +- **Pièces jointes** : table unique `Attachment` avec clé polymorphe (`EntityType` + `EntityId`), pas de FK SQLite dessus — intégrité gérée par `IAttachmentRepository`. +- **Paramètres globaux** : table clé-valeur `AppSetting`, typée côté `App.Core` via `ISettingsProvider`. +- **Licence & essai** : stockés **hors SQLite** (fichier `.lic` signé + état DPAPI séparé) pour qu'un reset de la base ne réinitialise jamais l'essai. `LicenseActivationCache` en base n'est qu'un cache d'affichage, jamais une source d'autorité. +- **Repositories** : interface générique `IRepository` (CRUD minimal) + interfaces spécifiques par entité pour les requêtes métier (recherche, filtres, dédoublonnage d'occurrence). +- **Unité de travail** : `IUnitOfWork.SaveChangesAsync()` — les repositories modifient le graphe en mémoire, la persistance est commitée explicitement par le service appelant (`MaintenanceSchedulingEngine`, `WorkOrderCompletionService`, etc.). +- **Ed25519** : `BouncyCastle.Cryptography` (managé pur), pas NSec/libsodium (dépendance native incompatible avec la contrainte "pas de DLL native à embarquer dans le MSIX"). +- **Moteur de planification préventive** : ne calcule jamais qu'UNE seule prochaine occurrence par gamme (`FrequencyCalculator.ComputeNextDueDate`), à partir de la dernière exécution réelle. La génération d'OT est dédupliquée par un index unique `(MaintenancePlanId, GeneratedForOccurrenceKey)` côté base, pas par une logique applicative fragile. + +## Points ouverts / à trancher au fil de l'implémentation + +- **Hébergement d'App.SyncServer** : en cours d'exécution intégrée au process App.UI (un `WebApplication`/Kestrel démarré en tâche de fond dans l'app WinUI, partageant le même `DbContext`) plutôt qu'un `.exe` séparé à lancer/superviser — à confirmer à l'étape 3i. +- **Clé publique Ed25519** : `Ed25519PublicKeyProvider` contient des fragments placeholder (zéros) qui lèvent une exception explicite tant que la vraie paire de clés n'a pas été générée par `LicenseGenerator`. + +## Environnement de build + +- SDK .NET 8 + Visual Studio 2022 Community (workloads `.NET Desktop Development` + `Windows application development`) installés via winget sur ce poste. +- Aucune build/test n'a encore été exécutée au moment de la rédaction de ce document — à faire dès l'installation terminée, avant d'ajouter du code supplémentaire. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..2db987b --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,85 @@ +# Schéma de base de données — ARGOS (SQLite / EF Core 8) + +Référence tenue à jour au fil de l'implémentation des entités `App.Core/Entities/**`. +Les types EF Core réels sont fixés dans `App.Infrastructure/Data/Configurations/*`. + +## Équipe & authentification + +**Users** — Id PK · Username (UNIQUE) · PasswordHash (Argon2id) · FullName · Email? · Role (enum) · IsActive · CreatedAtUtc · LastLoginAtUtc? + +**TechnicianProfiles** (1:1 avec Users) — UserId PK/FK → Users · Skills? · PhoneNumber? + +**QualificationTypes** — Id PK · Code (ex "B1","BR") · Label · Category (enum Electrique/Mecanique/Autre) + +**TechnicianQualifications** — Id PK · TechnicianUserId FK → TechnicianProfiles · QualificationTypeId FK · ObtainedDate · ExpiryDate? · CertificateAttachmentId FK → Attachments? +Index (TechnicianUserId, ExpiryDate) pour l'alerte habilitations expirantes. + +**TimeEntries** — Id PK · WorkOrderId FK → WorkOrders · TechnicianUserId FK → Users · StartedAtUtc · EndedAtUtc? · DurationMinutes · Source (enum Pc/AndroidSync) · Comment? · SyncDeviceId FK → SyncDevices? + +## Machines + +**Machines** — Id PK · Reference (UNIQUE) · Designation · Location? · Criticality (enum) · CommissioningDate? · PhotoAttachmentId FK → Attachments? · IsActive (soft delete) · CreatedAtUtc · UpdatedAtUtc + +**MachineParts** — MachineId FK, PartId FK (PK composite) — nomenclature pièces/machine + +**MachineCounterReadings** — Id PK · MachineId FK · CounterType (texte libre) · Value (REAL) · RecordedAtUtc · Source (enum Pc/AndroidSync) +Index (MachineId, CounterType, RecordedAtUtc DESC) + +## Maintenance préventive + +**MaintenancePlans** (gammes) — Id PK · MachineId FK · Name · Description? · FrequencyType (enum) · **StartDate** (ancre avant 1ère exécution) · CustomIntervalDays? · DayOfWeek? · DayOfMonth? · MonthlyRuleOccurrence? · MonthlyRuleWeekday? · CounterType? · CounterThreshold? · LeadTimeDays? · LeadTimePercentForCounter? · RequiredQualificationTypeId FK? · LastExecutionDateUtc? · LastExecutionCounterValue? · IsActive · CreatedAtUtc + +**MaintenanceTasks** — Id PK · MaintenancePlanId FK · Order · Label · Description? · EstimatedDurationMinutes? + +**Tools** — Id PK · Name (référentiel réutilisable) +**MaintenanceTaskTools** — MaintenanceTaskId FK, ToolId FK (PK composite) +**MaintenanceTaskParts** — MaintenanceTaskId FK, PartId FK (PK composite) · QuantityRequired + +**WorkOrders** (OT) — Id PK · MachineId FK · MaintenancePlanId FK? (null = curatif) · Type (enum) · Status (enum) · Priority (enum) · ScheduledDate · DueDate? · CompletedAtUtc? · AssignedTechnicianUserId FK → Users? · **GeneratedForOccurrenceKey?** · Comment? · Source (enum) · CreatedAtUtc +**Index unique filtré** : `(MaintenancePlanId, GeneratedForOccurrenceKey) WHERE MaintenancePlanId IS NOT NULL` — anti-doublon de génération, voir `MaintenanceSchedulingEngine`. + +**WorkOrderTaskResults** — Id PK · WorkOrderId FK · MaintenanceTaskId? (null = tâche ad-hoc curative) · IsCompleted · Comment? · PhotoAttachmentId FK → Attachments? · CompletedAtUtc? · CompletedByTechnicianUserId FK → Users? + +**WorkOrderPartsUsed** — Id PK · WorkOrderId FK · PartId FK · QuantityUsed · StockMovementId FK → StockMovements? + +## Magasin / stock + +**Suppliers** — Id PK · Name · ContactName? · Phone? · Email? · Address? + +**Parts** — Id PK · Reference (UNIQUE) · Designation · QuantityInStock (dénormalisé) · AlertThreshold · Location? · PrimarySupplierId FK? · UnitPrice · PhotoAttachmentId FK? · IsActive + +**StockMovements** — Id PK · PartId FK · MovementType (enum In/Out) · Quantity · Reason? · WorkOrderId FK? · SupplierInvoiceId FK? · CreatedByUserId FK → Users · CreatedAtUtc +Index (PartId, CreatedAtUtc) + +## Facturation + +**SupplierInvoices** — Id PK · SupplierId FK? · InvoiceNumber? · InvoiceDate · Amount · AttachmentId FK → Attachments (NOT NULL) · LinkedWorkOrderId FK? · ImportedAtUtc + +**Invoices** — Id PK · DocumentType (enum Invoice/Quote) · Number (UNIQUE) · ClientName · ClientAddress? · IssueDate · DueDate? · VatRate · Status (enum) · TotalHT · TotalTTC · PdfAttachmentId FK? · LinkedWorkOrderId FK? · CreatedByUserId FK → Users · CreatedAtUtc + +**InvoiceLines** — Id PK · InvoiceId FK · Order · Designation · Quantity · UnitPrice · VatRateOverride? + +## Sync réseau local (Android futur) + +**SyncDevices** — Id PK · DeviceName · DeviceIdentifier (UNIQUE) · PairedAtUtc · PairingPinHash · LastSyncAtUtc? · IsActive + +**SyncLogEntries** — Id PK · SyncDeviceId FK · Direction (enum Push/Pull) · EntityType · Status (enum) · ErrorMessage? · OccurredAtUtc + +## Transverse + +**Attachments** — Id PK · FileName · ContentType · StoredRelativePath (relatif à LocalFolder) · SizeBytes · EntityType (enum `AttachmentOwnerType`) · EntityId (int) · UploadedAtUtc · UploadedByUserId FK → Users? +⚠️ Clé polymorphe (EntityType+EntityId) : pas de FK SQLite possible, intégrité gérée par `IAttachmentRepository` (choix assumé, voir `docs/ARCHITECTURE.md`). + +**AppSettings** — Key PK (texte) · Value (texte) — clé-valeur générique, typé côté `App.Core` via `ISettingsProvider`. Clés connues : `App.Core.Entities.Shared.AppSettingKeys`. + +**LicenseActivationCache** — Id PK · CompanyName · MaxUsers · MaxMachines · IssuedDate — cache d'affichage uniquement, jamais utilisé pour la vérification (voir §Licence). + +## Licence & essai — hors SQLite + +- État d'essai (`InstallDateUtc`, `HardwareIdHash`, `LastRunTimestampUtc`) : fichier séparé chiffré DPAPI (`DataProtectionScope.LocalMachine`) dans `LocalFolder`, PAS en base — un reset de `app.db` ne doit jamais relancer un essai. +- Licence activée : fichier `.lic` JSON signé Ed25519, copié tel quel dans `LocalFolder`, revérifié (signature + hardware match) à chaque démarrage. Modèles : `App.Core.Services.Licensing.{LicenseFile, TrialState, LicenseCheckResult}`. + +## Notes d'implémentation + +- Mode SQLite **WAL** à activer dès la première migration (`PRAGMA journal_mode=WAL`) : App.UI (thread UI) et App.SyncServer (Kestrel local) écrivent potentiellement en même temps dans `app.db`. +- Toutes les dates métier "pures" (échéances, dates de naissance de document) utilisent `DateOnly` ; les horodatages système utilisent `DateTime` en UTC (`...AtUtc`). diff --git a/src/App.Core/App.Core.csproj b/src/App.Core/App.Core.csproj new file mode 100644 index 0000000..85bdc32 --- /dev/null +++ b/src/App.Core/App.Core.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + App.Core + App.Core + + + + + + + + diff --git a/src/App.Core/Dtos/Sync/MachineDto.cs b/src/App.Core/Dtos/Sync/MachineDto.cs new file mode 100644 index 0000000..87bcb6f --- /dev/null +++ b/src/App.Core/Dtos/Sync/MachineDto.cs @@ -0,0 +1,17 @@ +using App.Core.Entities.Machines; + +namespace App.Core.Dtos.Sync; + +/// +/// Contrat de synchronisation pour une machine (PC → Android, sens "pull"). Pas de +/// dépendance vers l'entité EF : ce DTO est le contrat public de l'API locale, il doit +/// pouvoir évoluer indépendamment du schéma de base. +/// +public sealed record MachineDto( + int Id, + string Reference, + string Designation, + string? Location, + MachineCriticality Criticality, + DateOnly? CommissioningDate, + bool IsActive); diff --git a/src/App.Core/Dtos/Sync/MaintenancePlanDto.cs b/src/App.Core/Dtos/Sync/MaintenancePlanDto.cs new file mode 100644 index 0000000..72b4ea8 --- /dev/null +++ b/src/App.Core/Dtos/Sync/MaintenancePlanDto.cs @@ -0,0 +1,24 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Dtos.Sync; + +public sealed record MaintenanceTaskPartDto(int PartId, string PartDesignation, double QuantityRequired); + +public sealed record MaintenanceTaskDto( + int Id, + int Order, + string Label, + string? Description, + int? EstimatedDurationMinutes, + List Tools, + List RequiredParts); + +/// Gamme telle que transmise à l'app Android pour affichage de la checklist d'exécution. +public sealed record MaintenancePlanDto( + int Id, + int MachineId, + string Name, + string? Description, + FrequencyType FrequencyType, + int? RequiredQualificationTypeId, + List Tasks); diff --git a/src/App.Core/Dtos/Sync/PairingDto.cs b/src/App.Core/Dtos/Sync/PairingDto.cs new file mode 100644 index 0000000..54994e3 --- /dev/null +++ b/src/App.Core/Dtos/Sync/PairingDto.cs @@ -0,0 +1,6 @@ +namespace App.Core.Dtos.Sync; + +/// Demande d'appairage envoyée par l'app Android après saisie du PIN affiché sur le PC. +public sealed record PairingRequestDto(string DeviceName, string DeviceIdentifier, string Pin); + +public sealed record PairingResponseDto(bool Success, string? SyncToken, string? ErrorMessage); diff --git a/src/App.Core/Dtos/Sync/SyncPullResponseDto.cs b/src/App.Core/Dtos/Sync/SyncPullResponseDto.cs new file mode 100644 index 0000000..e1dcab3 --- /dev/null +++ b/src/App.Core/Dtos/Sync/SyncPullResponseDto.cs @@ -0,0 +1,8 @@ +namespace App.Core.Dtos.Sync; + +/// Réponse du PC à l'app Android : gammes/OT à jour à télécharger (sens "pull"). +public sealed record SyncPullResponseDto( + List Machines, + List MaintenancePlans, + List WorkOrders, + DateTime ServerTimeUtc); diff --git a/src/App.Core/Dtos/Sync/SyncPushRequestDto.cs b/src/App.Core/Dtos/Sync/SyncPushRequestDto.cs new file mode 100644 index 0000000..7d0d280 --- /dev/null +++ b/src/App.Core/Dtos/Sync/SyncPushRequestDto.cs @@ -0,0 +1,24 @@ +namespace App.Core.Dtos.Sync; + +public sealed record TimeEntryPushDto( + int WorkOrderId, + int TechnicianUserId, + DateTime StartedAtUtc, + DateTime? EndedAtUtc, + string? Comment); + +public sealed record MachineCounterReadingPushDto( + int MachineId, + string CounterType, + double Value, + DateTime RecordedAtUtc); + +/// Payload envoyé par l'app Android au PC lors d'une synchronisation (sens "push"). +public sealed record SyncPushRequestDto( + string DeviceIdentifier, + string SyncToken, + List CompletedWorkOrders, + List TimeEntries, + List CounterReadings); + +public sealed record SyncPushResultDto(bool Success, int WorkOrdersAccepted, List Errors); diff --git a/src/App.Core/Dtos/Sync/WorkOrderDto.cs b/src/App.Core/Dtos/Sync/WorkOrderDto.cs new file mode 100644 index 0000000..6be7921 --- /dev/null +++ b/src/App.Core/Dtos/Sync/WorkOrderDto.cs @@ -0,0 +1,35 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Dtos.Sync; + +/// OT transmis à l'app Android (sens "pull") pour exécution sur le terrain. +public sealed record WorkOrderDto( + int Id, + int MachineId, + int? MaintenancePlanId, + WorkOrderType Type, + WorkOrderStatus Status, + WorkOrderPriority Priority, + DateOnly ScheduledDate, + DateOnly? DueDate, + int? AssignedTechnicianUserId, + string? Comment); + +/// +/// Résultat d'une tâche de gamme remonté par le technicien (sens "push"). La photo n'est +/// volontairement PAS transportée en base64 dans ce DTO : pour des interventions avec +/// plusieurs photos HD, ça ferait exploser la taille du JSON échangé. Le protocole prévu est +/// un upload multipart séparé référencé par (id temporaire côté +/// client, résolu côté serveur après l'upload du binaire) — à affiner avec l'app Android réelle. +/// +public sealed record WorkOrderTaskResultPushDto( + int? MaintenanceTaskId, + bool IsCompleted, + string? Comment, + string? PhotoUploadId); + +public sealed record WorkOrderCompletionPushDto( + int WorkOrderId, + DateTime CompletedAtUtc, + List TaskResults, + double? CounterValueAtCompletion); diff --git a/src/App.Core/Entities/Inventory/MachinePart.cs b/src/App.Core/Entities/Inventory/MachinePart.cs new file mode 100644 index 0000000..1a150b1 --- /dev/null +++ b/src/App.Core/Entities/Inventory/MachinePart.cs @@ -0,0 +1,15 @@ +using App.Core.Entities.Machines; + +namespace App.Core.Entities.Inventory; + +/// Association nomenclature : pièces habituellement utilisées sur une machine. +public class MachinePart +{ + public int MachineId { get; set; } + + public Machine? Machine { get; set; } + + public int PartId { get; set; } + + public Part? Part { get; set; } +} diff --git a/src/App.Core/Entities/Inventory/Part.cs b/src/App.Core/Entities/Inventory/Part.cs new file mode 100644 index 0000000..e961551 --- /dev/null +++ b/src/App.Core/Entities/Inventory/Part.cs @@ -0,0 +1,31 @@ +namespace App.Core.Entities.Inventory; + +/// Pièce détachée du magasin. +public class Part +{ + public int Id { get; set; } + + public required string Reference { get; set; } + + public required string Designation { get; set; } + + /// + /// Quantité en stock, dénormalisée pour un accès rapide côté UI/dashboard. + /// Source de vérité recalculable à tout moment depuis . + /// + public double QuantityInStock { get; set; } + + public double AlertThreshold { get; set; } + + public string? Location { get; set; } + + public int? PrimarySupplierId { get; set; } + + public Supplier? PrimarySupplier { get; set; } + + public decimal UnitPrice { get; set; } + + public int? PhotoAttachmentId { get; set; } + + public bool IsActive { get; set; } = true; +} diff --git a/src/App.Core/Entities/Inventory/StockMovement.cs b/src/App.Core/Entities/Inventory/StockMovement.cs new file mode 100644 index 0000000..36b7df7 --- /dev/null +++ b/src/App.Core/Entities/Inventory/StockMovement.cs @@ -0,0 +1,31 @@ +namespace App.Core.Entities.Inventory; + +public enum StockMovementType +{ + In = 0, + Out = 1, +} + +/// Mouvement d'entrée ou de sortie de stock, tracé et éventuellement lié à une intervention. +public class StockMovement +{ + public int Id { get; set; } + + public int PartId { get; set; } + + public Part? Part { get; set; } + + public StockMovementType MovementType { get; set; } + + public double Quantity { get; set; } + + public string? Reason { get; set; } + + public int? WorkOrderId { get; set; } + + public int? SupplierInvoiceId { get; set; } + + public int CreatedByUserId { get; set; } + + public DateTime CreatedAtUtc { get; set; } +} diff --git a/src/App.Core/Entities/Inventory/Supplier.cs b/src/App.Core/Entities/Inventory/Supplier.cs new file mode 100644 index 0000000..1fe72b9 --- /dev/null +++ b/src/App.Core/Entities/Inventory/Supplier.cs @@ -0,0 +1,17 @@ +namespace App.Core.Entities.Inventory; + +/// Fournisseur, utilisé aussi bien pour le magasin de pièces que pour les factures. +public class Supplier +{ + public int Id { get; set; } + + public required string Name { get; set; } + + public string? ContactName { get; set; } + + public string? Phone { get; set; } + + public string? Email { get; set; } + + public string? Address { get; set; } +} diff --git a/src/App.Core/Entities/Invoicing/Invoice.cs b/src/App.Core/Entities/Invoicing/Invoice.cs new file mode 100644 index 0000000..f155202 --- /dev/null +++ b/src/App.Core/Entities/Invoicing/Invoice.cs @@ -0,0 +1,69 @@ +namespace App.Core.Entities.Invoicing; + +public enum InvoiceDocumentType +{ + Invoice = 0, + Quote = 1, +} + +public enum InvoiceStatus +{ + Draft = 0, + Sent = 1, + Paid = 2, + Cancelled = 3, +} + +/// Facture ou devis interne généré (PDF via QuestPDF). +public class Invoice +{ + public int Id { get; set; } + + public InvoiceDocumentType DocumentType { get; set; } + + public required string Number { get; set; } + + public required string ClientName { get; set; } + + public string? ClientAddress { get; set; } + + public DateOnly IssueDate { get; set; } + + public DateOnly? DueDate { get; set; } + + public decimal VatRate { get; set; } + + public InvoiceStatus Status { get; set; } + + public decimal TotalHT { get; set; } + + public decimal TotalTTC { get; set; } + + public int? PdfAttachmentId { get; set; } + + public int? LinkedWorkOrderId { get; set; } + + public int CreatedByUserId { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public List Lines { get; set; } = []; +} + +/// Ligne de facture/devis, support du copier-coller façon tableur côté UI. +public class InvoiceLine +{ + public int Id { get; set; } + + public int InvoiceId { get; set; } + + public int Order { get; set; } + + public required string Designation { get; set; } + + public double Quantity { get; set; } + + public decimal UnitPrice { get; set; } + + public decimal? VatRateOverride { get; set; } +} diff --git a/src/App.Core/Entities/Invoicing/SupplierInvoice.cs b/src/App.Core/Entities/Invoicing/SupplierInvoice.cs new file mode 100644 index 0000000..5ff4264 --- /dev/null +++ b/src/App.Core/Entities/Invoicing/SupplierInvoice.cs @@ -0,0 +1,21 @@ +namespace App.Core.Entities.Invoicing; + +/// Facture fournisseur importée par drag & drop (PDF), classée et éventuellement associée à un OT. +public class SupplierInvoice +{ + public int Id { get; set; } + + public int? SupplierId { get; set; } + + public string? InvoiceNumber { get; set; } + + public DateOnly InvoiceDate { get; set; } + + public decimal Amount { get; set; } + + public int AttachmentId { get; set; } + + public int? LinkedWorkOrderId { get; set; } + + public DateTime ImportedAtUtc { get; set; } +} diff --git a/src/App.Core/Entities/Licensing/LicenseActivationCache.cs b/src/App.Core/Entities/Licensing/LicenseActivationCache.cs new file mode 100644 index 0000000..691f740 --- /dev/null +++ b/src/App.Core/Entities/Licensing/LicenseActivationCache.cs @@ -0,0 +1,20 @@ +namespace App.Core.Entities.Licensing; + +/// +/// Cache en base des informations de la licence activée, à but d'affichage uniquement +/// (écran Paramètres). N'a AUCUNE valeur d'autorité pour la vérification de licence : +/// celle-ci se refait systématiquement depuis le fichier .lic signé + l'état DPAPI, +/// tous deux stockés hors base (voir docs/DATABASE_SCHEMA.md §2.9). +/// +public class LicenseActivationCache +{ + public int Id { get; set; } + + public required string CompanyName { get; set; } + + public int MaxUsers { get; set; } + + public int MaxMachines { get; set; } + + public DateOnly IssuedDate { get; set; } +} diff --git a/src/App.Core/Entities/Machines/Machine.cs b/src/App.Core/Entities/Machines/Machine.cs new file mode 100644 index 0000000..4f093f3 --- /dev/null +++ b/src/App.Core/Entities/Machines/Machine.cs @@ -0,0 +1,39 @@ +using App.Core.Entities.Inventory; +using App.Core.Entities.Maintenance; + +namespace App.Core.Entities.Machines; + +/// +/// Équipement industriel soumis à maintenance préventive et/ou curative. +/// +public class Machine +{ + public int Id { get; set; } + + public required string Reference { get; set; } + + public required string Designation { get; set; } + + public string? Location { get; set; } + + public MachineCriticality Criticality { get; set; } + + public DateOnly? CommissioningDate { get; set; } + + public int? PhotoAttachmentId { get; set; } + + /// Désactivation logique (soft delete) : une machine liée à un historique n'est jamais supprimée physiquement. + public bool IsActive { get; set; } = true; + + public DateTime CreatedAtUtc { get; set; } + + public DateTime UpdatedAtUtc { get; set; } + + public List MaintenancePlans { get; set; } = []; + + public List WorkOrders { get; set; } = []; + + public List MachineParts { get; set; } = []; + + public List CounterReadings { get; set; } = []; +} diff --git a/src/App.Core/Entities/Machines/MachineCounterReading.cs b/src/App.Core/Entities/Machines/MachineCounterReading.cs new file mode 100644 index 0000000..0513d4a --- /dev/null +++ b/src/App.Core/Entities/Machines/MachineCounterReading.cs @@ -0,0 +1,29 @@ +namespace App.Core.Entities.Machines; + +public enum CounterReadingSource +{ + Pc = 0, + AndroidSync = 1, +} + +/// +/// Relevé d'un compteur d'usage (heures machine, cycles, kilométrage) utilisé pour +/// déclencher les gammes de maintenance basées sur compteur plutôt que sur date. +/// +public class MachineCounterReading +{ + public int Id { get; set; } + + public int MachineId { get; set; } + + public Machine? Machine { get; set; } + + /// Libellé libre du type de compteur (doit correspondre à MaintenancePlan.CounterType). + public required string CounterType { get; set; } + + public double Value { get; set; } + + public DateTime RecordedAtUtc { get; set; } + + public CounterReadingSource Source { get; set; } +} diff --git a/src/App.Core/Entities/Machines/MachineCriticality.cs b/src/App.Core/Entities/Machines/MachineCriticality.cs new file mode 100644 index 0000000..0ab070c --- /dev/null +++ b/src/App.Core/Entities/Machines/MachineCriticality.cs @@ -0,0 +1,9 @@ +namespace App.Core.Entities.Machines; + +public enum MachineCriticality +{ + Low = 0, + Medium = 1, + High = 2, + Critical = 3, +} diff --git a/src/App.Core/Entities/Maintenance/FrequencyType.cs b/src/App.Core/Entities/Maintenance/FrequencyType.cs new file mode 100644 index 0000000..9120d39 --- /dev/null +++ b/src/App.Core/Entities/Maintenance/FrequencyType.cs @@ -0,0 +1,26 @@ +namespace App.Core.Entities.Maintenance; + +/// +/// Type de fréquence d'une gamme de maintenance préventive. Détermine quels champs +/// de sont utilisés par FrequencyCalculator +/// pour calculer la prochaine échéance. +/// +public enum FrequencyType +{ + Daily = 0, + Weekly = 1, + Monthly = 2, + + /// Règle récurrente type "premier lundi du mois" (voir ). + MonthlyRule = 3, + Bimonthly = 4, + Quarterly = 5, + Semiannual = 6, + Annual = 7, + + /// Déclenchement sur seuil de compteur d'usage plutôt que sur date calendaire. + CounterBased = 8, + + /// Fréquence personnalisée exprimée en nombre de jours. + CustomDays = 9, +} diff --git a/src/App.Core/Entities/Maintenance/MaintenancePlan.cs b/src/App.Core/Entities/Maintenance/MaintenancePlan.cs new file mode 100644 index 0000000..04f720d --- /dev/null +++ b/src/App.Core/Entities/Maintenance/MaintenancePlan.cs @@ -0,0 +1,76 @@ +using App.Core.Entities.Machines; +using App.Core.Entities.Team; + +namespace App.Core.Entities.Maintenance; + +/// +/// Gamme de maintenance préventive : liste de tâches ordonnées à exécuter sur une +/// machine selon une fréquence donnée. La prochaine échéance n'est jamais stockée +/// telle quelle ; elle est recalculée par FrequencyCalculator à partir de +/// / , +/// ce qui absorbe naturellement les préventifs faits en avance ou en retard. +/// +public class MaintenancePlan +{ + public int Id { get; set; } + + public int MachineId { get; set; } + + public Machine? Machine { get; set; } + + public required string Name { get; set; } + + public string? Description { get; set; } + + public FrequencyType FrequencyType { get; set; } + + /// + /// Date d'ancrage du calcul tant qu'aucune exécution réelle n'a encore eu lieu + /// ( est null). Une fois le premier OT clôturé, + /// c'est qui prend le relais. + /// + public DateOnly StartDate { get; set; } + + /// Nombre de jours entre deux occurrences, utilisé uniquement pour . + public int? CustomIntervalDays { get; set; } + + /// Jour de la semaine (0=dimanche..6=samedi), utilisé pour . + public int? DayOfWeek { get; set; } + + /// Jour du mois (1-31), utilisé pour . + public int? DayOfMonth { get; set; } + + /// Occurrence dans le mois pour : 1=premier, 2=deuxième, -1=dernier. + public int? MonthlyRuleOccurrence { get; set; } + + /// Jour de la semaine visé par la règle mensuelle (0=dimanche..6=samedi). + public int? MonthlyRuleWeekday { get; set; } + + /// Type de compteur suivi (doit correspondre à ), pour . + public string? CounterType { get; set; } + + /// Seuil de déclenchement du compteur. + public double? CounterThreshold { get; set; } + + /// Préavis en jours avant l'échéance calendaire ; si null, la valeur globale des Paramètres s'applique. + public int? LeadTimeDays { get; set; } + + /// Préavis exprimé en pourcentage du seuil restant pour les gammes à compteur (ex: 90 = dès 90% atteint). + public double? LeadTimePercentForCounter { get; set; } + + public int? RequiredQualificationTypeId { get; set; } + + /// Date de la dernière exécution réelle (clôture d'OT), base du calcul de la prochaine échéance. + public DateOnly? LastExecutionDateUtc { get; set; } + + /// Valeur du compteur à la dernière exécution réelle, pour les gammes à compteur. + public double? LastExecutionCounterValue { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAtUtc { get; set; } + + public List Tasks { get; set; } = []; + + public List WorkOrders { get; set; } = []; +} diff --git a/src/App.Core/Entities/Maintenance/MaintenanceTask.cs b/src/App.Core/Entities/Maintenance/MaintenanceTask.cs new file mode 100644 index 0000000..d71324b --- /dev/null +++ b/src/App.Core/Entities/Maintenance/MaintenanceTask.cs @@ -0,0 +1,45 @@ +using App.Core.Entities.Inventory; + +namespace App.Core.Entities.Maintenance; + +/// Tâche unitaire ordonnée au sein d'une gamme de maintenance. +public class MaintenanceTask +{ + public int Id { get; set; } + + public int MaintenancePlanId { get; set; } + + public MaintenancePlan? MaintenancePlan { get; set; } + + public int Order { get; set; } + + public required string Label { get; set; } + + public string? Description { get; set; } + + public int? EstimatedDurationMinutes { get; set; } + + public List Tools { get; set; } = []; + + public List RequiredParts { get; set; } = []; +} + +/// Référentiel d'outils réutilisables entre gammes (autocomplétion, cohérence des libellés). +public class Tool +{ + public int Id { get; set; } + + public required string Name { get; set; } +} + +/// Pièce nécessaire pour réaliser une tâche de gamme, avec la quantité requise. +public class MaintenanceTaskPart +{ + public int MaintenanceTaskId { get; set; } + + public int PartId { get; set; } + + public Part? Part { get; set; } + + public double QuantityRequired { get; set; } +} diff --git a/src/App.Core/Entities/Maintenance/WorkOrder.cs b/src/App.Core/Entities/Maintenance/WorkOrder.cs new file mode 100644 index 0000000..4e6fea1 --- /dev/null +++ b/src/App.Core/Entities/Maintenance/WorkOrder.cs @@ -0,0 +1,84 @@ +using App.Core.Entities.Inventory; +using App.Core.Entities.Machines; + +namespace App.Core.Entities.Maintenance; + +public enum WorkOrderType +{ + Preventive = 0, + Corrective = 1, +} + +public enum WorkOrderStatus +{ + ToDo = 0, + InProgress = 1, + Done = 2, + Cancelled = 3, +} + +public enum WorkOrderPriority +{ + Low = 0, + Normal = 1, + High = 2, + Critical = 3, +} + +public enum WorkOrderSource +{ + Pc = 0, + AndroidSync = 1, +} + +/// +/// Ordre de travail (OT) : préventif généré automatiquement par le moteur de +/// planification, ou curatif créé manuellement. Un OT en retard n'est jamais +/// supprimé ni remplacé — il reste visible tant qu'il n'est pas clôturé +/// ( != Done/Cancelled) ; le retard se déduit de +/// < aujourd'hui, sans champ dédié. +/// +public class WorkOrder +{ + public int Id { get; set; } + + public int MachineId { get; set; } + + public Machine? Machine { get; set; } + + /// Null si l'OT est curatif (pas issu d'une gamme). + public int? MaintenancePlanId { get; set; } + + public MaintenancePlan? MaintenancePlan { get; set; } + + public WorkOrderType Type { get; set; } + + public WorkOrderStatus Status { get; set; } + + public WorkOrderPriority Priority { get; set; } + + public DateOnly ScheduledDate { get; set; } + + public DateOnly? DueDate { get; set; } + + public DateTime? CompletedAtUtc { get; set; } + + public int? AssignedTechnicianUserId { get; set; } + + /// + /// Clé d'occurrence unique par gamme (date ISO pour une échéance calendaire, valeur de seuil + /// pour une échéance à compteur). Combinée à dans un index unique + /// filtré, elle empêche le moteur de planification de générer deux fois le même OT préventif. + /// + public string? GeneratedForOccurrenceKey { get; set; } + + public string? Comment { get; set; } + + public WorkOrderSource Source { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public List TaskResults { get; set; } = []; + + public List PartsUsed { get; set; } = []; +} diff --git a/src/App.Core/Entities/Maintenance/WorkOrderTaskResult.cs b/src/App.Core/Entities/Maintenance/WorkOrderTaskResult.cs new file mode 100644 index 0000000..1459b3a --- /dev/null +++ b/src/App.Core/Entities/Maintenance/WorkOrderTaskResult.cs @@ -0,0 +1,41 @@ +namespace App.Core.Entities.Maintenance; + +/// +/// Résultat d'exécution d'une tâche de gamme pour un OT donné (coché par le technicien, +/// éventuellement côté Android). Null = tâche ad-hoc +/// ajoutée pour un OT curatif, sans lien avec une gamme. +/// +public class WorkOrderTaskResult +{ + public int Id { get; set; } + + public int WorkOrderId { get; set; } + + public WorkOrder? WorkOrder { get; set; } + + public int? MaintenanceTaskId { get; set; } + + public bool IsCompleted { get; set; } + + public string? Comment { get; set; } + + public int? PhotoAttachmentId { get; set; } + + public DateTime? CompletedAtUtc { get; set; } + + public int? CompletedByTechnicianUserId { get; set; } +} + +/// Pièce effectivement consommée pour réaliser un OT, tracée jusqu'au mouvement de stock. +public class WorkOrderPartUsed +{ + public int Id { get; set; } + + public int WorkOrderId { get; set; } + + public int PartId { get; set; } + + public double QuantityUsed { get; set; } + + public int? StockMovementId { get; set; } +} diff --git a/src/App.Core/Entities/Shared/AppSetting.cs b/src/App.Core/Entities/Shared/AppSetting.cs new file mode 100644 index 0000000..0aab772 --- /dev/null +++ b/src/App.Core/Entities/Shared/AppSetting.cs @@ -0,0 +1,25 @@ +namespace App.Core.Entities.Shared; + +/// +/// Paramètre global clé-valeur (préavis par défaut, coordonnées entreprise pour les PDF, +/// thème, port du serveur de sync...). Typage et validation faits côté App.Core, +/// volontairement pas en base : évite une table à colonnes fixes qui devrait être migrée +/// à chaque nouveau réglage. +/// +public class AppSetting +{ + public required string Key { get; set; } + + public required string Value { get; set; } +} + +/// Clés connues de , centralisées pour éviter les chaînes magiques éparpillées. +public static class AppSettingKeys +{ + public const string DefaultLeadTimeDays = "Maintenance.DefaultLeadTimeDays"; + public const string CompanyName = "Company.Name"; + public const string CompanyAddress = "Company.Address"; + public const string CompanyVatNumber = "Company.VatNumber"; + public const string Theme = "Ui.Theme"; + public const string SyncServerPort = "Sync.ServerPort"; +} diff --git a/src/App.Core/Entities/Shared/Attachment.cs b/src/App.Core/Entities/Shared/Attachment.cs new file mode 100644 index 0000000..c4c34ef --- /dev/null +++ b/src/App.Core/Entities/Shared/Attachment.cs @@ -0,0 +1,49 @@ +namespace App.Core.Entities.Shared; + +/// +/// Type d'entité propriétaire d'une pièce jointe. Utilisé comme discriminant +/// de la clé polymorphe (, ). +/// +public enum AttachmentOwnerType +{ + Machine = 0, + TechnicianQualification = 1, + WorkOrderTaskResult = 2, + SupplierInvoice = 3, + Invoice = 4, + Part = 5, +} + +/// +/// Fichier importé par l'utilisateur (photo, PDF, documentation technique) et stocké +/// physiquement sous ApplicationData.Current.LocalFolder/Attachments. +/// +/// +/// Le lien vers son entité propriétaire ( + ) est +/// une clé polymorphe : SQLite/EF Core ne peuvent pas poser de contrainte de clé étrangère dessus. +/// L'intégrité référentielle est garantie par IAttachmentRepository, pas par le SGBD. +/// C'est un choix délibéré pour éviter de dupliquer une table "XxxAttachments" par entité. +/// +public class Attachment +{ + public int Id { get; set; } + + /// Nom de fichier original tel qu'importé par l'utilisateur. + public required string FileName { get; set; } + + /// Type MIME (ex: "application/pdf", "image/jpeg"). + public required string ContentType { get; set; } + + /// Chemin relatif au dossier sandboxé de l'application (jamais un chemin absolu). + public required string StoredRelativePath { get; set; } + + public long SizeBytes { get; set; } + + public AttachmentOwnerType EntityType { get; set; } + + public int EntityId { get; set; } + + public DateTime UploadedAtUtc { get; set; } + + public int? UploadedByUserId { get; set; } +} diff --git a/src/App.Core/Entities/Sync/SyncDevice.cs b/src/App.Core/Entities/Sync/SyncDevice.cs new file mode 100644 index 0000000..b60c5cb --- /dev/null +++ b/src/App.Core/Entities/Sync/SyncDevice.cs @@ -0,0 +1,51 @@ +namespace App.Core.Entities.Sync; + +/// Appareil Android appairé avec le PC après validation du PIN affiché à l'écran. +public class SyncDevice +{ + public int Id { get; set; } + + public required string DeviceName { get; set; } + + /// Identifiant stable de l'appareil Android (ex: Android ID), unique. + public required string DeviceIdentifier { get; set; } + + public DateTime PairedAtUtc { get; set; } + + /// Hash du PIN utilisé lors de l'appairage (jamais le PIN en clair). + public required string PairingPinHash { get; set; } + + public DateTime? LastSyncAtUtc { get; set; } + + public bool IsActive { get; set; } = true; +} + +public enum SyncDirection +{ + Push = 0, + Pull = 1, +} + +public enum SyncStatus +{ + Success = 0, + Error = 1, +} + +/// Journal technique des échanges de synchronisation, utile au diagnostic. +public class SyncLogEntry +{ + public int Id { get; set; } + + public int SyncDeviceId { get; set; } + + public SyncDirection Direction { get; set; } + + public required string EntityType { get; set; } + + public SyncStatus Status { get; set; } + + public string? ErrorMessage { get; set; } + + public DateTime OccurredAtUtc { get; set; } +} diff --git a/src/App.Core/Entities/Team/QualificationType.cs b/src/App.Core/Entities/Team/QualificationType.cs new file mode 100644 index 0000000..1357075 --- /dev/null +++ b/src/App.Core/Entities/Team/QualificationType.cs @@ -0,0 +1,24 @@ +namespace App.Core.Entities.Team; + +public enum QualificationCategory +{ + Electrique = 0, + Mecanique = 1, + Autre = 2, +} + +/// +/// Référentiel des habilitations possibles (ex: B1, B2, BR, BC, CACES-3). +/// Table de référence plutôt qu'un enum figé : les habilitations varient +/// fortement d'une entreprise à l'autre et doivent rester éditables. +/// +public class QualificationType +{ + public int Id { get; set; } + + public required string Code { get; set; } + + public required string Label { get; set; } + + public QualificationCategory Category { get; set; } +} diff --git a/src/App.Core/Entities/Team/TechnicianProfile.cs b/src/App.Core/Entities/Team/TechnicianProfile.cs new file mode 100644 index 0000000..514693e --- /dev/null +++ b/src/App.Core/Entities/Team/TechnicianProfile.cs @@ -0,0 +1,18 @@ +namespace App.Core.Entities.Team; + +/// +/// Informations complémentaires propres à un technicien de terrain, en extension +/// de (relation 1:1, clé partagée sur ). +/// +public class TechnicianProfile +{ + public int UserId { get; set; } + + public User? User { get; set; } + + public string? Skills { get; set; } + + public string? PhoneNumber { get; set; } + + public List Qualifications { get; set; } = []; +} diff --git a/src/App.Core/Entities/Team/TechnicianQualification.cs b/src/App.Core/Entities/Team/TechnicianQualification.cs new file mode 100644 index 0000000..49f30c1 --- /dev/null +++ b/src/App.Core/Entities/Team/TechnicianQualification.cs @@ -0,0 +1,26 @@ +namespace App.Core.Entities.Team; + +/// +/// Habilitation détenue par un technicien, avec sa date de validité. Utilisée par +/// le dashboard pour l'alerte "habilitations bientôt expirées" et par les gammes +/// de maintenance pour vérifier qu'un technicien affecté est habilité. +/// +public class TechnicianQualification +{ + public int Id { get; set; } + + public int TechnicianUserId { get; set; } + + public TechnicianProfile? Technician { get; set; } + + public int QualificationTypeId { get; set; } + + public QualificationType? QualificationType { get; set; } + + public DateOnly ObtainedDate { get; set; } + + /// Null si l'habilitation n'expire jamais. + public DateOnly? ExpiryDate { get; set; } + + public int? CertificateAttachmentId { get; set; } +} diff --git a/src/App.Core/Entities/Team/TimeEntry.cs b/src/App.Core/Entities/Team/TimeEntry.cs new file mode 100644 index 0000000..95431c9 --- /dev/null +++ b/src/App.Core/Entities/Team/TimeEntry.cs @@ -0,0 +1,36 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Entities.Team; + +public enum TimeEntrySource +{ + Pc = 0, + AndroidSync = 1, +} + +/// +/// Temps déclaré par un technicien sur une intervention. Saisi sur le PC ou +/// remonté depuis l'app Android via la synchronisation réseau locale. +/// +public class TimeEntry +{ + public int Id { get; set; } + + public int WorkOrderId { get; set; } + + public WorkOrder? WorkOrder { get; set; } + + public int TechnicianUserId { get; set; } + + public DateTime StartedAtUtc { get; set; } + + public DateTime? EndedAtUtc { get; set; } + + public int DurationMinutes { get; set; } + + public TimeEntrySource Source { get; set; } + + public string? Comment { get; set; } + + public int? SyncDeviceId { get; set; } +} diff --git a/src/App.Core/Entities/Team/User.cs b/src/App.Core/Entities/Team/User.cs new file mode 100644 index 0000000..9517baf --- /dev/null +++ b/src/App.Core/Entities/Team/User.cs @@ -0,0 +1,28 @@ +namespace App.Core.Entities.Team; + +/// +/// Compte utilisateur local (authentification par mot de passe haché, Argon2id). +/// +public class User +{ + public int Id { get; set; } + + public required string Username { get; set; } + + /// Hash Argon2id du mot de passe. Jamais le mot de passe en clair. + public required string PasswordHash { get; set; } + + public required string FullName { get; set; } + + public string? Email { get; set; } + + public UserRole Role { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAtUtc { get; set; } + + public DateTime? LastLoginAtUtc { get; set; } + + public TechnicianProfile? TechnicianProfile { get; set; } +} diff --git a/src/App.Core/Entities/Team/UserRole.cs b/src/App.Core/Entities/Team/UserRole.cs new file mode 100644 index 0000000..ea34288 --- /dev/null +++ b/src/App.Core/Entities/Team/UserRole.cs @@ -0,0 +1,13 @@ +namespace App.Core.Entities.Team; + +/// +/// Rôle applicatif d'un utilisateur local. Fixe en V1 (pas de permissions +/// data-driven) : si des permissions granulaires deviennent nécessaires, +/// migrer vers une table Roles/Permissions sans impact sur le reste du modèle. +/// +public enum UserRole +{ + Admin = 0, + ResponsableMaintenance = 1, + Technicien = 2, +} diff --git a/src/App.Core/Interfaces/IPdfGenerator.cs b/src/App.Core/Interfaces/IPdfGenerator.cs new file mode 100644 index 0000000..120317a --- /dev/null +++ b/src/App.Core/Interfaces/IPdfGenerator.cs @@ -0,0 +1,10 @@ +using App.Core.Entities.Invoicing; + +namespace App.Core.Interfaces; + +/// Génération de PDF pour les factures/devis internes. Implémenté via QuestPDF côté App.Infrastructure. +public interface IPdfGenerator +{ + /// Génère le PDF d'une facture ou d'un devis et retourne son contenu binaire. + byte[] GenerateInvoicePdf(Invoice invoice); +} diff --git a/src/App.Core/Interfaces/ISettingsProvider.cs b/src/App.Core/Interfaces/ISettingsProvider.cs new file mode 100644 index 0000000..db4e4bc --- /dev/null +++ b/src/App.Core/Interfaces/ISettingsProvider.cs @@ -0,0 +1,11 @@ +namespace App.Core.Interfaces; + +/// Accès typé aux paramètres globaux stockés en clé-valeur (voir AppSetting). +public interface ISettingsProvider +{ + /// Préavis par défaut (en jours) avant l'échéance calendaire d'une gamme, si celle-ci ne le surcharge pas. + Task GetDefaultLeadTimeDaysAsync(CancellationToken cancellationToken = default); + + /// Préavis par défaut (en % du seuil) pour les gammes à compteur, si celles-ci ne le surchargent pas. + Task GetDefaultLeadTimePercentForCounterAsync(CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/IUnitOfWork.cs b/src/App.Core/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..0131260 --- /dev/null +++ b/src/App.Core/Interfaces/IUnitOfWork.cs @@ -0,0 +1,7 @@ +namespace App.Core.Interfaces; + +/// Persiste en une transaction les changements effectués via les repositories. Implémenté par GmaoDbContext. +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Licensing/IHardwareIdProvider.cs b/src/App.Core/Interfaces/Licensing/IHardwareIdProvider.cs new file mode 100644 index 0000000..ea8687c --- /dev/null +++ b/src/App.Core/Interfaces/Licensing/IHardwareIdProvider.cs @@ -0,0 +1,9 @@ +using App.Core.Services.Licensing; + +namespace App.Core.Interfaces.Licensing; + +/// Fournit l'identifiant matériel du poste. Implémenté côté App.Infrastructure via WMI. +public interface IHardwareIdProvider +{ + HardwareId GetCurrent(); +} diff --git a/src/App.Core/Interfaces/Licensing/ILicenseFileRepository.cs b/src/App.Core/Interfaces/Licensing/ILicenseFileRepository.cs new file mode 100644 index 0000000..e68f1ab --- /dev/null +++ b/src/App.Core/Interfaces/Licensing/ILicenseFileRepository.cs @@ -0,0 +1,16 @@ +using App.Core.Services.Licensing; + +namespace App.Core.Interfaces.Licensing; + +/// +/// Lit/écrit le fichier de licence activée (.lic) dans le dossier sandboxé de l'application. +/// Implémenté côté App.Infrastructure. +/// +public interface ILicenseFileRepository +{ + /// Charge la licence actuellement installée, ou null si aucune n'a été importée. + LicenseFile? LoadInstalled(); + + /// Copie et enregistre le fichier .lic importé par l'utilisateur comme licence active. + void Install(LicenseFile license); +} diff --git a/src/App.Core/Interfaces/Licensing/ILicenseService.cs b/src/App.Core/Interfaces/Licensing/ILicenseService.cs new file mode 100644 index 0000000..27e5c10 --- /dev/null +++ b/src/App.Core/Interfaces/Licensing/ILicenseService.cs @@ -0,0 +1,23 @@ +using App.Core.Services.Licensing; + +namespace App.Core.Interfaces.Licensing; + +/// +/// Point d'entrée unique pour la vérification de licence, appelé au démarrage de +/// l'application (App.UI) et par le job d'application des limites (maxUsers/maxMachines). +/// +public interface ILicenseService +{ + /// + /// Vérifie l'état de la licence : initialise l'essai au tout premier lancement, + /// détecte le recul d'horloge, valide la signature et le matériel si une licence + /// est installée. À appeler une fois au démarrage. + /// + LicenseCheckResult CheckLicenseStatus(DateTime nowUtc); + + /// Installe une licence fournie par l'utilisateur (import du fichier .lic) et la revérifie immédiatement. + LicenseCheckResult ActivateLicense(LicenseFile license); + + /// Identifiant matériel du poste courant, affiché sur l'écran d'activation. + HardwareId GetCurrentHardwareId(); +} diff --git a/src/App.Core/Interfaces/Licensing/ITrialStateStore.cs b/src/App.Core/Interfaces/Licensing/ITrialStateStore.cs new file mode 100644 index 0000000..aa867a5 --- /dev/null +++ b/src/App.Core/Interfaces/Licensing/ITrialStateStore.cs @@ -0,0 +1,14 @@ +using App.Core.Services.Licensing; + +namespace App.Core.Interfaces.Licensing; + +/// +/// Persiste l'état de l'essai gratuit hors base SQLite. Implémenté côté App.Infrastructure +/// via DPAPI (ProtectedData.Protect) dans un fichier du dossier sandboxé MSIX. +/// +public interface ITrialStateStore +{ + TrialState? Load(); + + void Save(TrialState state); +} diff --git a/src/App.Core/Interfaces/Repositories/IAttachmentRepository.cs b/src/App.Core/Interfaces/Repositories/IAttachmentRepository.cs new file mode 100644 index 0000000..07eaa83 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IAttachmentRepository.cs @@ -0,0 +1,8 @@ +using App.Core.Entities.Shared; + +namespace App.Core.Interfaces.Repositories; + +public interface IAttachmentRepository : IRepository +{ + Task> GetForOwnerAsync(AttachmentOwnerType ownerType, int ownerId, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IInvoiceRepository.cs b/src/App.Core/Interfaces/Repositories/IInvoiceRepository.cs new file mode 100644 index 0000000..d8678f9 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IInvoiceRepository.cs @@ -0,0 +1,12 @@ +using App.Core.Entities.Invoicing; + +namespace App.Core.Interfaces.Repositories; + +public interface IInvoiceRepository : IRepository +{ + Task GenerateNextNumberAsync(InvoiceDocumentType documentType, CancellationToken cancellationToken = default); +} + +public interface ISupplierInvoiceRepository : IRepository +{ +} diff --git a/src/App.Core/Interfaces/Repositories/IMachineCounterReadingRepository.cs b/src/App.Core/Interfaces/Repositories/IMachineCounterReadingRepository.cs new file mode 100644 index 0000000..500d8ca --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IMachineCounterReadingRepository.cs @@ -0,0 +1,7 @@ +namespace App.Core.Interfaces.Repositories; + +public interface IMachineCounterReadingRepository +{ + /// Dernier relevé enregistré pour ce compteur sur cette machine, ou null si aucun. + Task GetLatestValueAsync(int machineId, string counterType, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IMachineRepository.cs b/src/App.Core/Interfaces/Repositories/IMachineRepository.cs new file mode 100644 index 0000000..9d0f847 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IMachineRepository.cs @@ -0,0 +1,11 @@ +using App.Core.Entities.Machines; + +namespace App.Core.Interfaces.Repositories; + +public interface IMachineRepository : IRepository +{ + /// Machines actives, paginées, avec filtre optionnel sur référence/désignation/localisation. + Task> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default); + + Task CountAsync(string? searchText, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IMaintenancePlanRepository.cs b/src/App.Core/Interfaces/Repositories/IMaintenancePlanRepository.cs new file mode 100644 index 0000000..24c7dd1 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IMaintenancePlanRepository.cs @@ -0,0 +1,9 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Interfaces.Repositories; + +public interface IMaintenancePlanRepository : IRepository +{ + /// Gammes actives, avec leur machine chargée (nécessaire au moteur de planification et aux gammes à compteur). + Task> GetActivePlansAsync(CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IPartRepository.cs b/src/App.Core/Interfaces/Repositories/IPartRepository.cs new file mode 100644 index 0000000..5133b17 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IPartRepository.cs @@ -0,0 +1,11 @@ +using App.Core.Entities.Inventory; + +namespace App.Core.Interfaces.Repositories; + +public interface IPartRepository : IRepository +{ + /// Pièces dont le stock est passé sous leur seuil d'alerte (pour la notification de réapprovisionnement). + Task> GetBelowAlertThresholdAsync(CancellationToken cancellationToken = default); + + Task> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IRepository.cs b/src/App.Core/Interfaces/Repositories/IRepository.cs new file mode 100644 index 0000000..cbbe4fa --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IRepository.cs @@ -0,0 +1,20 @@ +namespace App.Core.Interfaces.Repositories; + +/// +/// CRUD générique minimal. Les repositories spécifiques n'en héritent que pour éviter de +/// réécrire GetById/Add/Update partout ; toute requête un peu spécifique (filtre, recherche, +/// pagination) est déclarée explicitement sur l'interface concrète plutôt que rajoutée ici, +/// pour ne pas transformer ce contrat générique en fourre-tout. +/// +public interface IRepository where TEntity : class +{ + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task AddAsync(TEntity entity, CancellationToken cancellationToken = default); + + void Update(TEntity entity); + + void Remove(TEntity entity); +} diff --git a/src/App.Core/Interfaces/Repositories/IStockMovementRepository.cs b/src/App.Core/Interfaces/Repositories/IStockMovementRepository.cs new file mode 100644 index 0000000..c4ff412 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IStockMovementRepository.cs @@ -0,0 +1,8 @@ +using App.Core.Entities.Inventory; + +namespace App.Core.Interfaces.Repositories; + +public interface IStockMovementRepository : IRepository +{ + Task> GetForPartAsync(int partId, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/ISupplierRepository.cs b/src/App.Core/Interfaces/Repositories/ISupplierRepository.cs new file mode 100644 index 0000000..4185869 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/ISupplierRepository.cs @@ -0,0 +1,7 @@ +using App.Core.Entities.Inventory; + +namespace App.Core.Interfaces.Repositories; + +public interface ISupplierRepository : IRepository +{ +} diff --git a/src/App.Core/Interfaces/Repositories/ISyncDeviceRepository.cs b/src/App.Core/Interfaces/Repositories/ISyncDeviceRepository.cs new file mode 100644 index 0000000..2969af7 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/ISyncDeviceRepository.cs @@ -0,0 +1,8 @@ +using App.Core.Entities.Sync; + +namespace App.Core.Interfaces.Repositories; + +public interface ISyncDeviceRepository : IRepository +{ + Task GetByDeviceIdentifierAsync(string deviceIdentifier, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IUserRepository.cs b/src/App.Core/Interfaces/Repositories/IUserRepository.cs new file mode 100644 index 0000000..4ba3759 --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IUserRepository.cs @@ -0,0 +1,10 @@ +using App.Core.Entities.Team; + +namespace App.Core.Interfaces.Repositories; + +public interface IUserRepository : IRepository +{ + Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default); + + Task> GetExpiringQualificationsAsync(int withinDays, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Interfaces/Repositories/IWorkOrderRepository.cs b/src/App.Core/Interfaces/Repositories/IWorkOrderRepository.cs new file mode 100644 index 0000000..d5c3c9b --- /dev/null +++ b/src/App.Core/Interfaces/Repositories/IWorkOrderRepository.cs @@ -0,0 +1,16 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Interfaces.Repositories; + +public interface IWorkOrderRepository : IRepository +{ + /// + /// Vérifie si un OT a déjà été généré pour cette occurrence précise d'une gamme + /// (quel que soit son statut, y compris Done/Cancelled) — c'est le garde-fou anti-doublon + /// du moteur de planification. + /// + Task ExistsForOccurrenceAsync(int maintenancePlanId, string occurrenceKey, CancellationToken cancellationToken = default); + + /// OT dans une plage de dates (vue calendrier), toutes machines confondues. + Task> GetScheduledBetweenAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default); +} diff --git a/src/App.Core/Services/Kpi/KpiCalculator.cs b/src/App.Core/Services/Kpi/KpiCalculator.cs new file mode 100644 index 0000000..5897d3f --- /dev/null +++ b/src/App.Core/Services/Kpi/KpiCalculator.cs @@ -0,0 +1,86 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Services.Kpi; + +/// +/// Calcul pur des indicateurs du tableau de bord. Ne fait aucune requête : les appelants +/// (repositories côté App.Infrastructure) sont responsables de fournir les données déjà +/// filtrées sur la période/machine voulue. +/// +public static class KpiCalculator +{ + /// + /// MTBF (temps moyen entre pannes), en heures : moyenne des intervalles entre pannes + /// consécutives. Null si moins de deux pannes sur la période (pas assez de données). + /// + public static double? ComputeMtbfHours(IReadOnlyList failureTimestampsSortedAscending) + { + if (failureTimestampsSortedAscending.Count < 2) + { + return null; + } + + var totalHours = 0.0; + for (var i = 1; i < failureTimestampsSortedAscending.Count; i++) + { + totalHours += (failureTimestampsSortedAscending[i] - failureTimestampsSortedAscending[i - 1]).TotalHours; + } + + return totalHours / (failureTimestampsSortedAscending.Count - 1); + } + + /// + /// MTTR (temps moyen de réparation), en heures : moyenne des durées entre création et + /// clôture des OT curatifs. Null si aucun OT curatif clôturé sur la période. + /// + public static double? ComputeMttrHours(IReadOnlyList correctiveWorkOrders) + { + var durations = correctiveWorkOrders + .Where(w => w.Type == WorkOrderType.Corrective && w.CompletedAtUtc.HasValue) + .Select(w => (w.CompletedAtUtc!.Value - w.CreatedAtUtc).TotalHours) + .ToList(); + + return durations.Count == 0 ? null : durations.Average(); + } + + /// Taux de disponibilité (%) = MTBF / (MTBF + MTTR). Null si l'un des deux est indisponible. + public static double? ComputeAvailabilityPercent(double? mtbfHours, double? mttrHours) + { + if (mtbfHours is null || mttrHours is null) + { + return null; + } + + var total = mtbfHours.Value + mttrHours.Value; + return total <= 0 ? null : mtbfHours.Value / total * 100.0; + } + + /// Coût de maintenance = coût des pièces consommées + coût main d'œuvre optionnel. + public static decimal ComputeMaintenanceCost(IEnumerable<(decimal UnitPrice, double Quantity)> partsUsed, decimal laborCost = 0m) + { + var partsCost = partsUsed.Sum(p => p.UnitPrice * (decimal)p.Quantity); + return partsCost + laborCost; + } + + /// + /// Taux de respect du planning préventif (%) : proportion des OT préventifs clôturés + /// dans les temps (avant leur échéance) parmi ceux clôturés sur la période. Null si + /// aucun OT préventif n'a été clôturé sur la période (pas de données, pas 0%). + /// + public static double? ComputePreventiveComplianceRatePercent(IReadOnlyList preventiveWorkOrdersInPeriod) + { + var closed = preventiveWorkOrdersInPeriod + .Where(w => w.Type == WorkOrderType.Preventive && w.Status == WorkOrderStatus.Done && w.CompletedAtUtc.HasValue) + .ToList(); + + if (closed.Count == 0) + { + return null; + } + + var onTime = closed.Count(w => + DateOnly.FromDateTime(w.CompletedAtUtc!.Value) <= (w.DueDate ?? w.ScheduledDate)); + + return (double)onTime / closed.Count * 100.0; + } +} diff --git a/src/App.Core/Services/Licensing/DateOnlyJsonConverter.cs b/src/App.Core/Services/Licensing/DateOnlyJsonConverter.cs new file mode 100644 index 0000000..2da6a0c --- /dev/null +++ b/src/App.Core/Services/Licensing/DateOnlyJsonConverter.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace App.Core.Services.Licensing; + +/// +/// Convertisseur JSON pour (format "yyyy-MM-dd"). Nécessaire tant que +/// System.Text.Json n'a pas de support natif fiable pour ce type sur .NET 8. +/// Utilisé via , jamais instancié directement ailleurs, +/// pour garantir que LicenseGenerator (écriture) et l'app (lecture) produisent/attendent +/// exactement le même format. +/// +public sealed class DateOnlyJsonConverter : JsonConverter +{ + private const string Format = "yyyy-MM-dd"; + + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + DateOnly.ParseExact(reader.GetString()!, Format, CultureInfo.InvariantCulture); + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture)); +} diff --git a/src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs b/src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs new file mode 100644 index 0000000..9b78d97 --- /dev/null +++ b/src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs @@ -0,0 +1,92 @@ +namespace App.Core.Services.Licensing; + +/// +/// Réassemble la clé publique Ed25519 utilisée pour vérifier les licences. La clé est +/// splittée en fragments XORés avec des masques distincts plutôt que stockée en une seule +/// chaîne base64 évidente, pour ralentir une extraction triviale par recherche de chaîne +/// dans le binaire. +/// +/// +/// ⚠️ Ceci est de l'obfuscation, pas une protection cryptographique : la clé doit être +/// lisible par le programme pour fonctionner, donc récupérable par rétro-ingénierie d'un +/// attaquant motivé (dump mémoire, débogueur). Elle n'a besoin d'être que PUBLIQUE de toute +/// façon — sa divulgation ne permet pas de forger une licence (il faut la clé PRIVÉE, qui ne +/// quitte jamais le poste où tourne LicenseGenerator). Le vrai enjeu ici est seulement +/// d'éviter qu'un utilisateur curieux remplace trivialement la clé pour s'auto-délivrer une +/// licence — ce que cette obfuscation légère suffit à décourager en V1. +/// +/// ⚠️ PLACEHOLDER : les fragments ci-dessous sont des zéros. Ils DOIVENT être remplacés par +/// la vraie clé publique générée au premier lancement de LicenseGenerator (voir +/// LicenseGenerator/KeyManagement/Ed25519KeyPairGenerator.cs) avant toute mise en production. +/// Le garde-fou lève une exception tant que ce n'est pas fait, +/// pour ne jamais valider silencieusement une licence contre une clé factice. +/// +public static class Ed25519PublicKeyProvider +{ + // Fragments de la clé publique (32 octets Ed25519), chacun XORé avec un masque différent. + // TODO(licensing-bootstrap): remplacer ces 4 fragments après la génération de la paire de + // clés définitive par LicenseGenerator. Ne jamais committer la clé PRIVÉE ici. + private static readonly byte[] Fragment1 = new byte[8]; + private static readonly byte[] Fragment2 = new byte[8]; + private static readonly byte[] Fragment3 = new byte[8]; + private static readonly byte[] Fragment4 = new byte[8]; + + private static readonly byte[] Mask1 = { 0x5A, 0x3C, 0x91, 0x0F, 0xE2, 0x77, 0xB4, 0x1D }; + private static readonly byte[] Mask2 = { 0x8B, 0x44, 0x2E, 0xC7, 0x19, 0x9A, 0x63, 0xF0 }; + private static readonly byte[] Mask3 = { 0x21, 0xD6, 0x58, 0xA9, 0x3F, 0x7C, 0x0B, 0xE4 }; + private static readonly byte[] Mask4 = { 0xF7, 0x12, 0xA3, 0x6D, 0x89, 0x24, 0xDE, 0x50 }; + + private static bool IsPlaceholder(byte[] fragment) => Array.TrueForAll(fragment, b => b == 0); + + /// Réassemble et retourne les 32 octets de la clé publique Ed25519. + /// + /// La clé n'a pas encore été générée/embarquée (fragments encore à zéro). Ne doit jamais + /// se produire en dehors du bootstrap initial du projet. + /// + public static byte[] GetPublicKeyBytes() + { + if (IsPlaceholder(Fragment1) && IsPlaceholder(Fragment2) && IsPlaceholder(Fragment3) && IsPlaceholder(Fragment4)) + { + throw new InvalidOperationException( + "Clé publique Ed25519 non initialisée : Ed25519PublicKeyProvider contient encore les fragments " + + "placeholder. Génère la paire de clés avec LicenseGenerator et reporte les fragments obfusqués ici."); + } + + var result = new byte[32]; + Xor(Fragment1, Mask1).CopyTo(result, 0); + Xor(Fragment2, Mask2).CopyTo(result, 8); + Xor(Fragment3, Mask3).CopyTo(result, 16); + Xor(Fragment4, Mask4).CopyTo(result, 24); + return result; + } + + /// + /// Découpe et XORe une clé publique Ed25519 de 32 octets en 4 fragments, avec les mêmes + /// masques que . Utilisé uniquement par LicenseGenerator + /// (bootstrap ponctuel) pour produire les fragments à coller dans ce fichier après + /// génération de la vraie paire de clés — jamais appelé par l'application cliente. + /// + public static (byte[] Fragment1, byte[] Fragment2, byte[] Fragment3, byte[] Fragment4) EncodeFragments(byte[] publicKeyBytes) + { + if (publicKeyBytes.Length != 32) + { + throw new ArgumentException("La clé publique Ed25519 doit faire exactement 32 octets.", nameof(publicKeyBytes)); + } + + return ( + Xor(publicKeyBytes[0..8], Mask1), + Xor(publicKeyBytes[8..16], Mask2), + Xor(publicKeyBytes[16..24], Mask3), + Xor(publicKeyBytes[24..32], Mask4)); + } + + private static byte[] Xor(byte[] data, byte[] mask) + { + var result = new byte[data.Length]; + for (var i = 0; i < data.Length; i++) + { + result[i] = (byte)(data[i] ^ mask[i]); + } + return result; + } +} diff --git a/src/App.Core/Services/Licensing/HardwareId.cs b/src/App.Core/Services/Licensing/HardwareId.cs new file mode 100644 index 0000000..77aad9b --- /dev/null +++ b/src/App.Core/Services/Licensing/HardwareId.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using System.Text; + +namespace App.Core.Services.Licensing; + +/// +/// Identifiant matériel du poste, composé de l'UUID de la carte mère +/// (WMI Win32_ComputerSystemProduct.UUID) et du numéro de série du disque +/// système (WMI Win32_DiskDrive, index 0). Les deux valeurs doivent +/// correspondre pour qu'une licence soit considérée valide sur ce poste. +/// +public sealed record HardwareId(string MotherboardUuid, string DiskSerial) +{ + /// Représentation canonique utilisée pour le hash et la comparaison. + public string ToCanonicalString() => $"{MotherboardUuid.Trim().ToUpperInvariant()}|{DiskSerial.Trim().ToUpperInvariant()}"; + + /// + /// Hash SHA-256 (base64) de l'identifiant matériel. Utilisé pour le stockage de l'état + /// d'essai (DPAPI) afin de ne jamais persister les identifiants matériels bruts. + /// + public string ComputeHash() + { + var bytes = Encoding.UTF8.GetBytes(ToCanonicalString()); + var hash = SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } + + public bool Matches(string motherboardUuid, string diskSerial) => + string.Equals(MotherboardUuid.Trim(), motherboardUuid.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(DiskSerial.Trim(), diskSerial.Trim(), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/App.Core/Services/Licensing/LicenseCheckResult.cs b/src/App.Core/Services/Licensing/LicenseCheckResult.cs new file mode 100644 index 0000000..728abf9 --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseCheckResult.cs @@ -0,0 +1,35 @@ +namespace App.Core.Services.Licensing; + +public enum LicenseStatus +{ + /// Aucune licence, essai en cours ( > 0). + TrialActive, + + /// Aucune licence, essai de 30 jours écoulé : écran d'activation bloquant. + TrialExpired, + + /// Licence présente, signature valide et matériel correspondant. + Licensed, + + /// Fichier de licence présent mais signature invalide (fichier modifié/corrompu). + InvalidSignature, + + /// Signature valide mais motherboardUuid et/ou diskSerial ne correspond pas au poste actuel. + HardwareMismatch, + + /// L'horloge système a été reculée par rapport au dernier lancement enregistré. + ClockTamperDetected, +} + +/// Résultat de la vérification de licence effectuée à chaque démarrage de l'application. +public sealed record LicenseCheckResult(LicenseStatus Status, int? TrialDaysRemaining, LicenseFile? License) +{ + public static LicenseCheckResult Trial(int daysRemaining) => + new(daysRemaining > 0 ? LicenseStatus.TrialActive : LicenseStatus.TrialExpired, daysRemaining, null); + + public static LicenseCheckResult Licensed(LicenseFile license) => + new(LicenseStatus.Licensed, null, license); + + public static LicenseCheckResult Invalid(LicenseStatus status) => + new(status, null, null); +} diff --git a/src/App.Core/Services/Licensing/LicenseFile.cs b/src/App.Core/Services/Licensing/LicenseFile.cs new file mode 100644 index 0000000..473a394 --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseFile.cs @@ -0,0 +1,20 @@ +namespace App.Core.Services.Licensing; + +/// +/// Contenu déserialisé d'un fichier de licence (.lic) signé par le LicenseGenerator. +/// Correspond exactement au JSON produit par ce dernier — toute modification de forme doit +/// être faite des deux côtés à la fois (voir ). +/// +public sealed record LicenseFile( + string CompanyName, + string MotherboardUuid, + string DiskSerial, + DateOnly IssuedDate, + DateOnly? ExpiryDate, + int MaxUsers, + int MaxMachines, + string Signature) +{ + /// Licence sans champ , utilisée pour produire les octets canoniques à signer/vérifier. + public LicenseFile WithoutSignature() => this with { Signature = string.Empty }; +} diff --git a/src/App.Core/Services/Licensing/LicenseFileCanonicalSerializer.cs b/src/App.Core/Services/Licensing/LicenseFileCanonicalSerializer.cs new file mode 100644 index 0000000..1b249b0 --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseFileCanonicalSerializer.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Text; + +namespace App.Core.Services.Licensing; + +/// +/// Produit la représentation binaire canonique d'une pour la +/// signature/vérification Ed25519. Volontairement un format texte fait à la main plutôt que +/// "JSON sans le champ signature" : la sérialisation JSON générique ne garantit pas un ordre +/// de propriétés stable entre deux versions/plateformes, ce qui casserait la vérification de +/// signature de façon silencieuse. Ce format doit rester identique entre LicenseGenerator +/// (signature) et App.Core (vérification) — ne jamais le modifier sans regénérer les +/// licences existantes. +/// +public static class LicenseFileCanonicalSerializer +{ + public static byte[] GetCanonicalBytes(LicenseFile license) + { + var sb = new StringBuilder(); + sb.Append("companyName=").Append(license.CompanyName).Append('\n'); + sb.Append("motherboardUuid=").Append(license.MotherboardUuid).Append('\n'); + sb.Append("diskSerial=").Append(license.DiskSerial).Append('\n'); + sb.Append("issuedDate=").Append(license.IssuedDate.ToString("O", CultureInfo.InvariantCulture)).Append('\n'); + sb.Append("expiryDate=").Append(license.ExpiryDate?.ToString("O", CultureInfo.InvariantCulture) ?? "null").Append('\n'); + sb.Append("maxUsers=").Append(license.MaxUsers.ToString(CultureInfo.InvariantCulture)).Append('\n'); + sb.Append("maxMachines=").Append(license.MaxMachines.ToString(CultureInfo.InvariantCulture)); + return Encoding.UTF8.GetBytes(sb.ToString()); + } +} diff --git a/src/App.Core/Services/Licensing/LicenseJsonOptions.cs b/src/App.Core/Services/Licensing/LicenseJsonOptions.cs new file mode 100644 index 0000000..2be6aa2 --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseJsonOptions.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace App.Core.Services.Licensing; + +/// +/// Options JSON partagées pour lire/écrire un fichier .lic. Centralisées ici pour que +/// LicenseGenerator (écriture) et App.Infrastructure (lecture) restent +/// obligatoirement synchronisés sur le même format. +/// +public static class LicenseJsonOptions +{ + public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.General) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + Converters = { new DateOnlyJsonConverter() }, + }; +} diff --git a/src/App.Core/Services/Licensing/LicenseService.cs b/src/App.Core/Services/Licensing/LicenseService.cs new file mode 100644 index 0000000..abe5334 --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseService.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using App.Core.Interfaces.Licensing; + +namespace App.Core.Services.Licensing; + +/// +/// +/// Orchestration pure : ne fait aucune I/O directement, délègue tout au trio +/// / / , +/// ce qui rend cette classe entièrement testable avec des doubles de test (voir App.Core.Tests/Licensing). +/// +public sealed class LicenseService( + IHardwareIdProvider hardwareIdProvider, + ITrialStateStore trialStateStore, + ILicenseFileRepository licenseFileRepository) : ILicenseService +{ + public LicenseCheckResult CheckLicenseStatus(DateTime nowUtc) + { + LicenseFile? installedLicense; + try + { + installedLicense = licenseFileRepository.LoadInstalled(); + } + catch (JsonException) + { + // Fichier .lic présent mais illisible (corrompu/modifié) : traité comme une licence + // invalide, JAMAIS comme "pas de licence" — sinon corrompre le fichier suffirait à + // relancer un essai gratuit. Voir LicenseFileRepository.LoadInstalled. + return LicenseCheckResult.Invalid(LicenseStatus.InvalidSignature); + } + + if (installedLicense is not null) + { + return ValidateInstalledLicense(installedLicense); + } + + return EvaluateTrial(nowUtc); + } + + public LicenseCheckResult ActivateLicense(LicenseFile license) + { + if (!LicenseValidator.VerifySignature(license, Ed25519PublicKeyProvider.GetPublicKeyBytes())) + { + return LicenseCheckResult.Invalid(LicenseStatus.InvalidSignature); + } + + var currentHardware = hardwareIdProvider.GetCurrent(); + if (!LicenseValidator.MatchesHardware(license, currentHardware)) + { + return LicenseCheckResult.Invalid(LicenseStatus.HardwareMismatch); + } + + licenseFileRepository.Install(license); + return LicenseCheckResult.Licensed(license); + } + + public HardwareId GetCurrentHardwareId() => hardwareIdProvider.GetCurrent(); + + private LicenseCheckResult ValidateInstalledLicense(LicenseFile license) + { + if (!LicenseValidator.VerifySignature(license, Ed25519PublicKeyProvider.GetPublicKeyBytes())) + { + return LicenseCheckResult.Invalid(LicenseStatus.InvalidSignature); + } + + var currentHardware = hardwareIdProvider.GetCurrent(); + if (!LicenseValidator.MatchesHardware(license, currentHardware)) + { + return LicenseCheckResult.Invalid(LicenseStatus.HardwareMismatch); + } + + return LicenseCheckResult.Licensed(license); + } + + private LicenseCheckResult EvaluateTrial(DateTime nowUtc) + { + var currentHardware = hardwareIdProvider.GetCurrent(); + var state = trialStateStore.Load(); + + if (state is null) + { + var newState = new TrialState(nowUtc, currentHardware.ComputeHash(), nowUtc); + trialStateStore.Save(newState); + return LicenseCheckResult.Trial(TrialState.TrialDurationDays); + } + + if (nowUtc < state.LastRunTimestampUtc) + { + return LicenseCheckResult.Invalid(LicenseStatus.ClockTamperDetected); + } + + trialStateStore.Save(state.WithUpdatedLastRun(nowUtc)); + return LicenseCheckResult.Trial(state.GetDaysRemaining(nowUtc)); + } +} diff --git a/src/App.Core/Services/Licensing/LicenseValidator.cs b/src/App.Core/Services/Licensing/LicenseValidator.cs new file mode 100644 index 0000000..b1a76fa --- /dev/null +++ b/src/App.Core/Services/Licensing/LicenseValidator.cs @@ -0,0 +1,49 @@ +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; + +namespace App.Core.Services.Licensing; + +/// +/// Vérification cryptographique pure d'une : signature Ed25519 +/// puis correspondance matérielle. Ne fait aucune I/O (fichier, DB, WMI) — entièrement +/// unitaire-testable. +/// +/// +/// .NET 8 n'a pas de support natif d'Ed25519 dans System.Security.Cryptography +/// (contrairement à RSA/ECDSA). On utilise donc BouncyCastle (BouncyCastle.Cryptography, +/// implémentation managée pure, sans dépendance native — important pour rester compatible +/// MSIX/Store) plutôt que NSec/libsodium qui nécessiterait d'embarquer une DLL native. +/// +public static class LicenseValidator +{ + /// Licence à vérifier. + /// + /// Clé publique Ed25519 (32 octets). Passée en paramètre plutôt que lue directement depuis + /// pour que cette méthode reste testable avec une + /// paire de clés de test, indépendamment de la clé de production embarquée. + /// + public static bool VerifySignature(LicenseFile license, byte[] publicKeyBytes) + { + byte[] signatureBytes; + try + { + signatureBytes = Convert.FromBase64String(license.Signature); + } + catch (FormatException) + { + return false; + } + + var publicKey = new Ed25519PublicKeyParameters(publicKeyBytes, 0); + var signer = new Ed25519Signer(); + signer.Init(forSigning: false, publicKey); + + var canonicalBytes = LicenseFileCanonicalSerializer.GetCanonicalBytes(license.WithoutSignature()); + signer.BlockUpdate(canonicalBytes, 0, canonicalBytes.Length); + + return signer.VerifySignature(signatureBytes); + } + + public static bool MatchesHardware(LicenseFile license, HardwareId currentHardware) => + currentHardware.Matches(license.MotherboardUuid, license.DiskSerial); +} diff --git a/src/App.Core/Services/Licensing/TrialState.cs b/src/App.Core/Services/Licensing/TrialState.cs new file mode 100644 index 0000000..1652175 --- /dev/null +++ b/src/App.Core/Services/Licensing/TrialState.cs @@ -0,0 +1,24 @@ +namespace App.Core.Services.Licensing; + +/// +/// État de l'essai gratuit, stocké chiffré (DPAPI) dans un fichier séparé de la base +/// SQLite — voir docs/DATABASE_SCHEMA.md §2.9 pour la justification. Ne contient +/// jamais les identifiants matériels bruts, seulement leur hash. +/// +public sealed record TrialState( + DateTime InstallDateUtc, + string HardwareIdHash, + DateTime LastRunTimestampUtc) +{ + public const int TrialDurationDays = 30; + + public TrialState WithUpdatedLastRun(DateTime nowUtc) => this with { LastRunTimestampUtc = nowUtc }; + + public int GetDaysRemaining(DateTime nowUtc) + { + var elapsed = (nowUtc.Date - InstallDateUtc.Date).Days; + return Math.Max(0, TrialDurationDays - elapsed); + } + + public bool IsExpired(DateTime nowUtc) => GetDaysRemaining(nowUtc) <= 0; +} diff --git a/src/App.Core/Services/Maintenance/FrequencyCalculator.cs b/src/App.Core/Services/Maintenance/FrequencyCalculator.cs new file mode 100644 index 0000000..c570fbd --- /dev/null +++ b/src/App.Core/Services/Maintenance/FrequencyCalculator.cs @@ -0,0 +1,153 @@ +using App.Core.Entities.Maintenance; + +namespace App.Core.Services.Maintenance; + +/// +/// Calcule la prochaine échéance théorique d'une gamme de maintenance. Ne connaît rien +/// des OT déjà générés ni de l'historique complet : il calcule toujours UNE seule +/// occurrence, à partir de l'ancre ( +/// si une exécution a déjà eu lieu, sinon ). +/// C'est ce qui permet à "prochaine échéance = dernière exécution + fréquence" d'absorber +/// naturellement les préventifs faits en avance ou en retard, sans logique de rattrapage +/// complexe : tant qu'un OT généré n'est pas clôturé, cette méthode n'est pas rappelée +/// pour cette gamme (voir ). +/// +public static class FrequencyCalculator +{ + /// Calcule la prochaine date d'échéance pour les gammes basées sur une fréquence calendaire. + /// Un champ requis par le type de fréquence est absent (gamme mal configurée). + /// est basée sur compteur : utiliser . + public static DateOnly ComputeNextDueDate(MaintenancePlan plan) + { + var anchor = plan.LastExecutionDateUtc ?? plan.StartDate; + + return plan.FrequencyType switch + { + FrequencyType.Daily => anchor.AddDays(1), + FrequencyType.Weekly => NextWeekday(anchor, Require(plan.DayOfWeek, nameof(plan.DayOfWeek))), + FrequencyType.Monthly => NextMonthlyDate(anchor, Require(plan.DayOfMonth, nameof(plan.DayOfMonth))), + FrequencyType.MonthlyRule => NextMonthlyRuleDate( + anchor, + Require(plan.MonthlyRuleOccurrence, nameof(plan.MonthlyRuleOccurrence)), + Require(plan.MonthlyRuleWeekday, nameof(plan.MonthlyRuleWeekday))), + FrequencyType.Bimonthly => anchor.AddMonths(2), + FrequencyType.Quarterly => anchor.AddMonths(3), + FrequencyType.Semiannual => anchor.AddMonths(6), + FrequencyType.Annual => anchor.AddMonths(12), + FrequencyType.CustomDays => anchor.AddDays(Require(plan.CustomIntervalDays, nameof(plan.CustomIntervalDays))), + FrequencyType.CounterBased => throw new NotSupportedException( + "Gamme basée sur compteur : utiliser ComputeNextCounterThreshold, pas ComputeNextDueDate."), + _ => throw new ArgumentOutOfRangeException(nameof(plan), plan.FrequencyType, "Type de fréquence non géré."), + }; + } + + /// Valeur de compteur à laquelle la prochaine occurrence doit se déclencher. + public static double ComputeNextCounterThreshold(MaintenancePlan plan) + { + var threshold = Require(plan.CounterThreshold, nameof(plan.CounterThreshold)); + return (plan.LastExecutionCounterValue ?? 0) + threshold; + } + + /// + /// Progression entre 0 et 1 (potentiellement >1 si en retard) vers la prochaine + /// échéance à compteur, utilisée pour la fenêtre de préavis en pourcentage. + /// + public static double ComputeCounterProgressRatio(MaintenancePlan plan, double currentCounterValue) + { + var threshold = Require(plan.CounterThreshold, nameof(plan.CounterThreshold)); + if (threshold <= 0) + { + throw new InvalidOperationException("CounterThreshold doit être strictement positif."); + } + + var lastValue = plan.LastExecutionCounterValue ?? 0; + return (currentCounterValue - lastValue) / threshold; + } + + /// Indique si l'échéance à compteur est entrée dans sa fenêtre de préavis (ex: 90% du seuil atteint). + public static bool IsCounterWithinLeadWindow(MaintenancePlan plan, double currentCounterValue, double defaultLeadTimePercent) + { + var leadPercent = plan.LeadTimePercentForCounter ?? defaultLeadTimePercent; + return ComputeCounterProgressRatio(plan, currentCounterValue) >= leadPercent / 100.0; + } + + private static DateOnly NextWeekday(DateOnly anchor, int targetDayOfWeek) + { + var candidate = anchor.AddDays(1); + for (var i = 0; i < 7; i++) + { + if ((int)candidate.DayOfWeek == targetDayOfWeek) + { + return candidate; + } + + candidate = candidate.AddDays(1); + } + + throw new InvalidOperationException($"Jour de semaine invalide: {targetDayOfWeek}."); + } + + private static DateOnly NextMonthlyDate(DateOnly anchor, int dayOfMonth) + { + var target = anchor.AddMonths(1); + var clampedDay = Math.Min(dayOfMonth, DateTime.DaysInMonth(target.Year, target.Month)); + return new DateOnly(target.Year, target.Month, clampedDay); + } + + private static DateOnly NextMonthlyRuleDate(DateOnly anchor, int occurrence, int weekday) + { + var target = anchor.AddMonths(1); + return GetNthWeekdayOfMonth(target.Year, target.Month, (DayOfWeek)weekday, occurrence); + } + + /// + /// Trouve la Nième occurrence d'un jour de semaine dans un mois (1=premier, -1=dernier). + /// + private static DateOnly GetNthWeekdayOfMonth(int year, int month, DayOfWeek weekday, int occurrence) + { + var daysInMonth = DateTime.DaysInMonth(year, month); + + if (occurrence == -1) + { + for (var day = daysInMonth; day >= 1; day--) + { + var date = new DateOnly(year, month, day); + if (date.DayOfWeek == weekday) + { + return date; + } + } + } + else if (occurrence >= 1) + { + var matchesFound = 0; + for (var day = 1; day <= daysInMonth; day++) + { + var date = new DateOnly(year, month, day); + if (date.DayOfWeek == weekday) + { + matchesFound++; + if (matchesFound == occurrence) + { + return date; + } + } + } + + throw new InvalidOperationException( + $"Le mois {year}-{month:D2} ne contient pas {occurrence} occurrence(s) de {weekday}. " + + "Gamme mal configurée (ex: '5ème lundi' qui n'existe pas tous les mois)."); + } + + throw new InvalidOperationException($"MonthlyRuleOccurrence invalide: {occurrence}."); + } + + private static T Require(T? value, string fieldName) where T : struct + { + return value ?? throw new InvalidOperationException( + $"Champ '{fieldName}' requis pour ce type de fréquence mais absent sur la gamme."); + } + + private static int Require(int? value, string fieldName) => value ?? throw new InvalidOperationException( + $"Champ '{fieldName}' requis pour ce type de fréquence mais absent sur la gamme."); +} diff --git a/src/App.Core/Services/Maintenance/MaintenanceSchedulingEngine.cs b/src/App.Core/Services/Maintenance/MaintenanceSchedulingEngine.cs new file mode 100644 index 0000000..c1dcefd --- /dev/null +++ b/src/App.Core/Services/Maintenance/MaintenanceSchedulingEngine.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using App.Core.Entities.Maintenance; +using App.Core.Interfaces; +using App.Core.Interfaces.Repositories; + +namespace App.Core.Services.Maintenance; + +/// +/// Génère les OT préventifs "au fur et à mesure" : pour chaque gamme active, calcule la +/// prochaine échéance et crée l'OT correspondant dès qu'elle entre dans sa fenêtre de +/// préavis — jamais à l'avance sur toute l'année. Exécuté au démarrage de l'app puis +/// périodiquement (timer/BackgroundService) tant qu'elle tourne. +/// +/// +/// La déduplication ne repose PAS sur un flag "déjà généré" en mémoire mais sur l'index +/// unique (MaintenancePlanId, GeneratedForOccurrenceKey) côté base : même si ce moteur +/// tourne deux fois avant que la première génération soit commitée, la seconde échoue +/// proprement sur la contrainte plutôt que de créer un doublon. +/// +public sealed class MaintenanceSchedulingEngine( + IMaintenancePlanRepository planRepository, + IWorkOrderRepository workOrderRepository, + IMachineCounterReadingRepository counterReadingRepository, + ISettingsProvider settingsProvider, + IUnitOfWork unitOfWork) +{ + /// + /// Passe en revue toutes les gammes actives et génère les OT dont l'échéance est + /// entrée dans la fenêtre de préavis. Retourne les OT nouvellement créés. + /// + public async Task> RunAsync(DateOnly today, CancellationToken cancellationToken = default) + { + var created = new List(); + var plans = await planRepository.GetActivePlansAsync(cancellationToken); + var defaultLeadTimeDays = await settingsProvider.GetDefaultLeadTimeDaysAsync(cancellationToken); + var defaultLeadTimePercent = await settingsProvider.GetDefaultLeadTimePercentForCounterAsync(cancellationToken); + + foreach (var plan in plans) + { + var workOrder = plan.FrequencyType == FrequencyType.CounterBased + ? await TryGenerateCounterBasedAsync(plan, defaultLeadTimePercent, cancellationToken) + : await TryGenerateCalendarBasedAsync(plan, today, defaultLeadTimeDays, cancellationToken); + + if (workOrder is not null) + { + created.Add(workOrder); + } + } + + if (created.Count > 0) + { + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + return created; + } + + private async Task TryGenerateCalendarBasedAsync( + MaintenancePlan plan, DateOnly today, int defaultLeadTimeDays, CancellationToken cancellationToken) + { + var nextDueDate = FrequencyCalculator.ComputeNextDueDate(plan); + var occurrenceKey = nextDueDate.ToString("O", CultureInfo.InvariantCulture); + + if (await workOrderRepository.ExistsForOccurrenceAsync(plan.Id, occurrenceKey, cancellationToken)) + { + return null; + } + + var leadTimeDays = plan.LeadTimeDays ?? defaultLeadTimeDays; + var windowOpensOn = nextDueDate.AddDays(-leadTimeDays); + if (today < windowOpensOn) + { + return null; + } + + var workOrder = BuildWorkOrder(plan, nextDueDate, occurrenceKey); + await workOrderRepository.AddAsync(workOrder, cancellationToken); + return workOrder; + } + + private async Task TryGenerateCounterBasedAsync( + MaintenancePlan plan, double defaultLeadTimePercent, CancellationToken cancellationToken) + { + if (plan.CounterType is null) + { + return null; + } + + var currentValue = await counterReadingRepository.GetLatestValueAsync(plan.MachineId, plan.CounterType, cancellationToken); + if (currentValue is null) + { + return null; + } + + var nextThreshold = FrequencyCalculator.ComputeNextCounterThreshold(plan); + var occurrenceKey = nextThreshold.ToString(CultureInfo.InvariantCulture); + + if (await workOrderRepository.ExistsForOccurrenceAsync(plan.Id, occurrenceKey, cancellationToken)) + { + return null; + } + + if (!FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentValue.Value, defaultLeadTimePercent)) + { + return null; + } + + // Échéance théorique = aujourd'hui : une gamme à compteur n'a pas de date calendaire prévisible à l'avance. + var workOrder = BuildWorkOrder(plan, DateOnly.FromDateTime(DateTime.UtcNow), occurrenceKey); + await workOrderRepository.AddAsync(workOrder, cancellationToken); + return workOrder; + } + + private static WorkOrder BuildWorkOrder(MaintenancePlan plan, DateOnly scheduledDate, string occurrenceKey) => new() + { + MachineId = plan.MachineId, + MaintenancePlanId = plan.Id, + Type = WorkOrderType.Preventive, + Status = WorkOrderStatus.ToDo, + Priority = WorkOrderPriority.Normal, + ScheduledDate = scheduledDate, + GeneratedForOccurrenceKey = occurrenceKey, + Source = WorkOrderSource.Pc, + CreatedAtUtc = DateTime.UtcNow, + }; +} diff --git a/src/App.Core/Services/Maintenance/WorkOrderCompletionService.cs b/src/App.Core/Services/Maintenance/WorkOrderCompletionService.cs new file mode 100644 index 0000000..56c0831 --- /dev/null +++ b/src/App.Core/Services/Maintenance/WorkOrderCompletionService.cs @@ -0,0 +1,56 @@ +using App.Core.Entities.Maintenance; +using App.Core.Interfaces; +using App.Core.Interfaces.Repositories; + +namespace App.Core.Services.Maintenance; + +/// +/// Clôture un OT et fait avancer l'ancre de sa gamme ( / +/// ). C'est cette mise à jour qui permet au +/// de calculer la prochaine échéance à partir de la date +/// d'exécution RÉELLE plutôt que de la date théorique — un préventif fait en avance ou en retard +/// décale donc naturellement le suivant, sans logique de rattrapage séparée. +/// +public sealed class WorkOrderCompletionService( + IWorkOrderRepository workOrderRepository, + IMaintenancePlanRepository planRepository, + IUnitOfWork unitOfWork) +{ + /// OT à clôturer. + /// Date/heure d'exécution réelle, utilisée comme nouvelle ancre de la gamme. + /// + /// Valeur du compteur au moment de la clôture, pour les gammes basées sur compteur. Ignoré pour les gammes calendaires. + /// + /// Jeton d'annulation. + /// L'OT n'existe pas. + public async Task CompleteAsync( + int workOrderId, + DateTime completedAtUtc, + double? counterValueAtCompletion = null, + CancellationToken cancellationToken = default) + { + var workOrder = await workOrderRepository.GetByIdAsync(workOrderId, cancellationToken) + ?? throw new InvalidOperationException($"OT {workOrderId} introuvable."); + + workOrder.Status = WorkOrderStatus.Done; + workOrder.CompletedAtUtc = completedAtUtc; + workOrderRepository.Update(workOrder); + + if (workOrder.MaintenancePlanId is { } planId) + { + var plan = await planRepository.GetByIdAsync(planId, cancellationToken) + ?? throw new InvalidOperationException($"Gamme {planId} introuvable pour l'OT {workOrderId}."); + + plan.LastExecutionDateUtc = DateOnly.FromDateTime(completedAtUtc); + + if (plan.FrequencyType == FrequencyType.CounterBased && counterValueAtCompletion is not null) + { + plan.LastExecutionCounterValue = counterValueAtCompletion; + } + + planRepository.Update(plan); + } + + await unitOfWork.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/App.Infrastructure/App.Infrastructure.csproj b/src/App.Infrastructure/App.Infrastructure.csproj new file mode 100644 index 0000000..1700816 --- /dev/null +++ b/src/App.Infrastructure/App.Infrastructure.csproj @@ -0,0 +1,28 @@ + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + net8.0-windows + enable + enable + + + diff --git a/src/App.Infrastructure/Data/Configurations/InventoryConfigurations.cs b/src/App.Infrastructure/Data/Configurations/InventoryConfigurations.cs new file mode 100644 index 0000000..6c8aacf --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/InventoryConfigurations.cs @@ -0,0 +1,41 @@ +using App.Core.Entities.Inventory; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class SupplierConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(s => s.Name).IsRequired().HasMaxLength(200); + } +} + +public class PartConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(p => p.Reference).IsRequired().HasMaxLength(50); + builder.Property(p => p.Designation).IsRequired().HasMaxLength(200); + builder.HasIndex(p => p.Reference).IsUnique(); + + builder.HasOne(p => p.PrimarySupplier) + .WithMany() + .HasForeignKey(p => p.PrimarySupplierId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class StockMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(m => new { m.PartId, m.CreatedAtUtc }); + + builder.HasOne(m => m.Part) + .WithMany() + .HasForeignKey(m => m.PartId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/InvoicingConfigurations.cs b/src/App.Infrastructure/Data/Configurations/InvoicingConfigurations.cs new file mode 100644 index 0000000..4fc2dda --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/InvoicingConfigurations.cs @@ -0,0 +1,36 @@ +using App.Core.Entities.Invoicing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class SupplierInvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(i => i.InvoiceNumber).HasMaxLength(100); + } +} + +public class InvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(i => i.Number).IsRequired().HasMaxLength(50); + builder.Property(i => i.ClientName).IsRequired().HasMaxLength(200); + builder.HasIndex(i => i.Number).IsUnique(); + + builder.HasMany(i => i.Lines) + .WithOne() + .HasForeignKey(l => l.InvoiceId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class InvoiceLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(l => l.Designation).IsRequired().HasMaxLength(300); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/MachineConfiguration.cs b/src/App.Infrastructure/Data/Configurations/MachineConfiguration.cs new file mode 100644 index 0000000..003fed0 --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/MachineConfiguration.cs @@ -0,0 +1,60 @@ +using App.Core.Entities.Inventory; +using App.Core.Entities.Machines; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class MachineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(m => m.Reference).IsRequired().HasMaxLength(50); + builder.Property(m => m.Designation).IsRequired().HasMaxLength(200); + builder.HasIndex(m => m.Reference).IsUnique(); + + // Historique jamais perdu : on ne supprime jamais une machine en cascade avec ses OT/gammes, + // elle est désactivée (IsActive=false) à la place. + builder.HasMany(m => m.MaintenancePlans) + .WithOne(p => p.Machine) + .HasForeignKey(p => p.MachineId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(m => m.WorkOrders) + .WithOne(w => w.Machine) + .HasForeignKey(w => w.MachineId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(m => m.CounterReadings) + .WithOne(c => c.Machine) + .HasForeignKey(c => c.MachineId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class MachinePartConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(mp => new { mp.MachineId, mp.PartId }); + + builder.HasOne(mp => mp.Machine) + .WithMany(m => m.MachineParts) + .HasForeignKey(mp => mp.MachineId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(mp => mp.Part) + .WithMany() + .HasForeignKey(mp => mp.PartId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class MachineCounterReadingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(c => c.CounterType).IsRequired().HasMaxLength(50); + builder.HasIndex(c => new { c.MachineId, c.CounterType, c.RecordedAtUtc }); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/MaintenancePlanConfiguration.cs b/src/App.Infrastructure/Data/Configurations/MaintenancePlanConfiguration.cs new file mode 100644 index 0000000..8560a8f --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/MaintenancePlanConfiguration.cs @@ -0,0 +1,65 @@ +using App.Core.Entities.Maintenance; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class MaintenancePlanConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(p => p.Name).IsRequired().HasMaxLength(200); + builder.Property(p => p.CounterType).HasMaxLength(50); + builder.HasIndex(p => new { p.MachineId, p.IsActive }); + + builder.HasMany(p => p.Tasks) + .WithOne(t => t.MaintenancePlan) + .HasForeignKey(t => t.MaintenancePlanId) + .OnDelete(DeleteBehavior.Cascade); // les tâches n'existent pas sans leur gamme + + builder.HasMany(p => p.WorkOrders) + .WithOne(w => w.MaintenancePlan) + .HasForeignKey(w => w.MaintenancePlanId) + .OnDelete(DeleteBehavior.Restrict); // un OT déjà généré doit survivre même si la gamme est désactivée + } +} + +public class MaintenanceTaskConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Label).IsRequired().HasMaxLength(200); + + // Many-to-many explicite (nommage clair de la table de jonction plutôt que le nom généré par défaut). + builder.HasMany(t => t.Tools) + .WithMany() + .UsingEntity(j => j.ToTable("MaintenanceTaskTools")); + + builder.HasMany(t => t.RequiredParts) + .WithOne() + .HasForeignKey(rp => rp.MaintenanceTaskId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class ToolConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Name).IsRequired().HasMaxLength(100); + builder.HasIndex(t => t.Name).IsUnique(); + } +} + +public class MaintenanceTaskPartConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(tp => new { tp.MaintenanceTaskId, tp.PartId }); + + builder.HasOne(tp => tp.Part) + .WithMany() + .HasForeignKey(tp => tp.PartId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/SharedConfigurations.cs b/src/App.Infrastructure/Data/Configurations/SharedConfigurations.cs new file mode 100644 index 0000000..bb4d6c5 --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/SharedConfigurations.cs @@ -0,0 +1,38 @@ +using App.Core.Entities.Licensing; +using App.Core.Entities.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class AttachmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(a => a.FileName).IsRequired().HasMaxLength(260); + builder.Property(a => a.ContentType).IsRequired().HasMaxLength(100); + builder.Property(a => a.StoredRelativePath).IsRequired().HasMaxLength(400); + + // Clé polymorphe (EntityType + EntityId) : volontairement PAS de relation EF/FK ici, + // voir App.Core.Entities.Shared.Attachment pour la justification. + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + } +} + +public class AppSettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Key); + builder.Property(s => s.Key).HasMaxLength(100); + builder.Property(s => s.Value).IsRequired(); + } +} + +public class LicenseActivationCacheConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(l => l.CompanyName).IsRequired().HasMaxLength(200); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/SyncConfigurations.cs b/src/App.Infrastructure/Data/Configurations/SyncConfigurations.cs new file mode 100644 index 0000000..856479d --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/SyncConfigurations.cs @@ -0,0 +1,28 @@ +using App.Core.Entities.Sync; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class SyncDeviceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(d => d.DeviceName).IsRequired().HasMaxLength(100); + builder.Property(d => d.DeviceIdentifier).IsRequired().HasMaxLength(200); + builder.HasIndex(d => d.DeviceIdentifier).IsUnique(); + } +} + +public class SyncLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(l => l.EntityType).IsRequired().HasMaxLength(100); + + builder.HasOne() + .WithMany() + .HasForeignKey(l => l.SyncDeviceId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/TeamConfigurations.cs b/src/App.Infrastructure/Data/Configurations/TeamConfigurations.cs new file mode 100644 index 0000000..64e6b1c --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/TeamConfigurations.cs @@ -0,0 +1,67 @@ +using App.Core.Entities.Team; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(u => u.Username).IsRequired().HasMaxLength(100); + builder.Property(u => u.FullName).IsRequired().HasMaxLength(200); + builder.HasIndex(u => u.Username).IsUnique(); + } +} + +public class TechnicianProfileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.UserId); + + builder.HasOne(t => t.User) + .WithOne(u => u.TechnicianProfile) + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(t => t.Qualifications) + .WithOne(q => q.Technician) + .HasForeignKey(q => q.TechnicianUserId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class QualificationTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(q => q.Code).IsRequired().HasMaxLength(20); + builder.Property(q => q.Label).IsRequired().HasMaxLength(200); + builder.HasIndex(q => q.Code).IsUnique(); + } +} + +public class TechnicianQualificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(q => new { q.TechnicianUserId, q.ExpiryDate }); + + builder.HasOne(q => q.QualificationType) + .WithMany() + .HasForeignKey(q => q.QualificationTypeId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class TimeEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(t => t.WorkOrder) + .WithMany() + .HasForeignKey(t => t.WorkOrderId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/App.Infrastructure/Data/Configurations/WorkOrderConfiguration.cs b/src/App.Infrastructure/Data/Configurations/WorkOrderConfiguration.cs new file mode 100644 index 0000000..6a1c9c7 --- /dev/null +++ b/src/App.Infrastructure/Data/Configurations/WorkOrderConfiguration.cs @@ -0,0 +1,59 @@ +using App.Core.Entities.Maintenance; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace App.Infrastructure.Data.Configurations; + +public class WorkOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(w => w.GeneratedForOccurrenceKey).HasMaxLength(50); + + // Anti-doublon de génération du MaintenanceSchedulingEngine : un seul OT par occurrence + // d'une gamme donnée, quel que soit son statut. Filtré (pas d'index sur les OT curatifs, + // qui ont MaintenancePlanId = null). + builder.HasIndex(w => new { w.MaintenancePlanId, w.GeneratedForOccurrenceKey }) + .IsUnique() + .HasFilter("MaintenancePlanId IS NOT NULL"); + + builder.HasIndex(w => new { w.Status, w.ScheduledDate }); // vue calendrier / dashboard + + builder.HasOne() + .WithMany() + .HasForeignKey(w => w.AssignedTechnicianUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(w => w.TaskResults) + .WithOne(r => r.WorkOrder) + .HasForeignKey(r => r.WorkOrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(w => w.PartsUsed) + .WithOne() + .HasForeignKey(p => p.WorkOrderId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class WorkOrderTaskResultConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne() + .WithMany() + .HasForeignKey(r => r.MaintenanceTaskId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public class WorkOrderPartUsedConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.PartId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/App.Infrastructure/Data/DbInitializer.cs b/src/App.Infrastructure/Data/DbInitializer.cs new file mode 100644 index 0000000..3f180f2 --- /dev/null +++ b/src/App.Infrastructure/Data/DbInitializer.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; + +namespace App.Infrastructure.Data; + +/// +/// Applique les migrations en attente et active le mode WAL de SQLite, nécessaire car App.UI +/// (thread UI) et App.SyncServer (Kestrel local) peuvent écrire dans app.db en même temps +/// une fois l'application lancée. Réglage persisté dans le fichier de base : suffisant de le +/// (re)poser à chaque démarrage, l'opération est immédiate si déjà actif. +/// +public static class DbInitializer +{ + public static void MigrateAndConfigure(GmaoDbContext context) + { + context.Database.Migrate(); + context.Database.ExecuteSqlRaw("PRAGMA journal_mode='WAL';"); + } +} diff --git a/src/App.Infrastructure/Data/GmaoDbContext.cs b/src/App.Infrastructure/Data/GmaoDbContext.cs new file mode 100644 index 0000000..50d6d42 --- /dev/null +++ b/src/App.Infrastructure/Data/GmaoDbContext.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using App.Core.Entities.Invoicing; +using App.Core.Entities.Inventory; +using App.Core.Entities.Licensing; +using App.Core.Entities.Machines; +using App.Core.Entities.Maintenance; +using App.Core.Entities.Shared; +using App.Core.Entities.Sync; +using App.Core.Entities.Team; +using App.Core.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace App.Infrastructure.Data; + +/// +/// Contexte EF Core unique de l'application, adossé à SQLite. Le mode WAL est activé au +/// démarrage (voir DbInitializer), pas ici : App.UI (thread UI) et App.SyncServer +/// (Kestrel local) peuvent écrire dans la même base en même temps une fois l'app lancée. +/// +public class GmaoDbContext(DbContextOptions options) : DbContext(options), IUnitOfWork +{ + public DbSet Machines => Set(); + + public DbSet MachineParts => Set(); + + public DbSet MachineCounterReadings => Set(); + + public DbSet MaintenancePlans => Set(); + + public DbSet MaintenanceTasks => Set(); + + public DbSet Tools => Set(); + + public DbSet MaintenanceTaskParts => Set(); + + public DbSet WorkOrders => Set(); + + public DbSet WorkOrderTaskResults => Set(); + + public DbSet WorkOrderPartsUsed => Set(); + + public DbSet Suppliers => Set(); + + public DbSet Parts => Set(); + + public DbSet StockMovements => Set(); + + public DbSet SupplierInvoices => Set(); + + public DbSet Invoices => Set(); + + public DbSet InvoiceLines => Set(); + + public DbSet Users => Set(); + + public DbSet TechnicianProfiles => Set(); + + public DbSet QualificationTypes => Set(); + + public DbSet TechnicianQualifications => Set(); + + public DbSet TimeEntries => Set(); + + public DbSet SyncDevices => Set(); + + public DbSet SyncLogEntries => Set(); + + public DbSet Attachments => Set(); + + public DbSet AppSettings => Set(); + + public DbSet LicenseActivationCaches => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } +} diff --git a/src/App.Infrastructure/Data/GmaoDbContextFactory.cs b/src/App.Infrastructure/Data/GmaoDbContextFactory.cs new file mode 100644 index 0000000..5992f2c --- /dev/null +++ b/src/App.Infrastructure/Data/GmaoDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace App.Infrastructure.Data; + +/// +/// Factory utilisée uniquement par les outils EF Core (dotnet ef migrations) en design-time, +/// pour générer les migrations sans dépendre du runtime WinUI. L'application réelle configure +/// via DI dans App.UI, avec le vrai chemin +/// ApplicationData.Current.LocalFolder. +/// +public class GmaoDbContextFactory : IDesignTimeDbContextFactory +{ + public GmaoDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=design-time.db"); + return new GmaoDbContext(optionsBuilder.Options); + } +} diff --git a/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.Designer.cs b/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.Designer.cs new file mode 100644 index 0000000..f77927c --- /dev/null +++ b/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.Designer.cs @@ -0,0 +1,1201 @@ +// +using System; +using App.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace App.Infrastructure.Data.Migrations +{ + [DbContext(typeof(GmaoDbContext))] + [Migration("20260910115542_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("App.Core.Entities.Inventory.MachinePart", b => + { + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.HasKey("MachineId", "PartId"); + + b.HasIndex("PartId"); + + b.ToTable("MachineParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Part", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertThreshold") + .HasColumnType("REAL"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("PrimarySupplierId") + .HasColumnType("INTEGER"); + + b.Property("QuantityInStock") + .HasColumnType("REAL"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrimarySupplierId"); + + b.HasIndex("Reference") + .IsUnique(); + + b.ToTable("Parts"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("INTEGER"); + + b.Property("MovementType") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("REAL"); + + b.Property("Reason") + .HasColumnType("TEXT"); + + b.Property("SupplierInvoiceId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PartId", "CreatedAtUtc"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientAddress") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("INTEGER"); + + b.Property("DocumentType") + .HasColumnType("INTEGER"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LinkedWorkOrderId") + .HasColumnType("INTEGER"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PdfAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalHT") + .HasColumnType("TEXT"); + + b.Property("TotalTTC") + .HasColumnType("TEXT"); + + b.Property("VatRate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.InvoiceLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("REAL"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.Property("VatRateOverride") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLines"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.SupplierInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("AttachmentId") + .HasColumnType("INTEGER"); + + b.Property("ImportedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceDate") + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LinkedWorkOrderId") + .HasColumnType("INTEGER"); + + b.Property("SupplierId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SupplierInvoices"); + }); + + modelBuilder.Entity("App.Core.Entities.Licensing.LicenseActivationCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IssuedDate") + .HasColumnType("TEXT"); + + b.Property("MaxMachines") + .HasColumnType("INTEGER"); + + b.Property("MaxUsers") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("LicenseActivationCaches"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.Machine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommissioningDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Criticality") + .HasColumnType("INTEGER"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Reference") + .IsUnique(); + + b.ToTable("Machines"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.MachineCounterReading", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "CounterType", "RecordedAtUtc"); + + b.ToTable("MachineCounterReadings"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterThreshold") + .HasColumnType("REAL"); + + b.Property("CounterType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CustomIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FrequencyType") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionCounterValue") + .HasColumnType("REAL"); + + b.Property("LastExecutionDateUtc") + .HasColumnType("TEXT"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("LeadTimePercentForCounter") + .HasColumnType("REAL"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MonthlyRuleOccurrence") + .HasColumnType("INTEGER"); + + b.Property("MonthlyRuleWeekday") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequiredQualificationTypeId") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "IsActive"); + + b.ToTable("MaintenancePlans"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaintenancePlanId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MaintenancePlanId"); + + b.ToTable("MaintenanceTasks"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTaskPart", b => + { + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("QuantityRequired") + .HasColumnType("REAL"); + + b.HasKey("MaintenanceTaskId", "PartId"); + + b.HasIndex("PartId"); + + b.ToTable("MaintenanceTaskParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.Tool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tools"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedTechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("GeneratedForOccurrenceKey") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MaintenancePlanId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("ScheduledDate") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AssignedTechnicianUserId"); + + b.HasIndex("MachineId"); + + b.HasIndex("MaintenancePlanId", "GeneratedForOccurrenceKey") + .IsUnique() + .HasFilter("MaintenancePlanId IS NOT NULL"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderPartUsed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("QuantityUsed") + .HasColumnType("REAL"); + + b.Property("StockMovementId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PartId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderPartsUsed"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderTaskResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedByTechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MaintenanceTaskId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderTaskResults"); + }); + + modelBuilder.Entity("App.Core.Entities.Shared.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("App.Core.Entities.Shared.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StoredRelativePath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedByUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAtUtc") + .HasColumnType("TEXT"); + + b.Property("PairedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PairingPinHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceIdentifier") + .IsUnique(); + + b.ToTable("SyncDevices"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SyncDeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SyncDeviceId"); + + b.ToTable("SyncLogEntries"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.QualificationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("QualificationTypes"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("Skills") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("TechnicianProfiles"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianQualification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CertificateAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT"); + + b.Property("ObtainedDate") + .HasColumnType("TEXT"); + + b.Property("QualificationTypeId") + .HasColumnType("INTEGER"); + + b.Property("TechnicianUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("QualificationTypeId"); + + b.HasIndex("TechnicianUserId", "ExpiryDate"); + + b.ToTable("TechnicianQualifications"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EndedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SyncDeviceId") + .HasColumnType("INTEGER"); + + b.Property("TechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("TimeEntries"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MaintenanceTaskTool", b => + { + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("ToolsId") + .HasColumnType("INTEGER"); + + b.HasKey("MaintenanceTaskId", "ToolsId"); + + b.HasIndex("ToolsId"); + + b.ToTable("MaintenanceTaskTools", (string)null); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.MachinePart", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("MachineParts") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Part", b => + { + b.HasOne("App.Core.Entities.Inventory.Supplier", "PrimarySupplier") + .WithMany() + .HasForeignKey("PrimarySupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PrimarySupplier"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.StockMovement", b => + { + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.InvoiceLine", b => + { + b.HasOne("App.Core.Entities.Invoicing.Invoice", null) + .WithMany("Lines") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.MachineCounterReading", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("CounterReadings") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("MaintenancePlans") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenancePlan", "MaintenancePlan") + .WithMany("Tasks") + .HasForeignKey("MaintenancePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MaintenancePlan"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTaskPart", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany("RequiredParts") + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.HasOne("App.Core.Entities.Team.User", null) + .WithMany() + .HasForeignKey("AssignedTechnicianUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("WorkOrders") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.MaintenancePlan", "MaintenancePlan") + .WithMany("WorkOrders") + .HasForeignKey("MaintenancePlanId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Machine"); + + b.Navigation("MaintenancePlan"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderPartUsed", b => + { + b.HasOne("App.Core.Entities.Inventory.Part", null) + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", null) + .WithMany("PartsUsed") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderTaskResult", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany() + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", "WorkOrder") + .WithMany("TaskResults") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncLogEntry", b => + { + b.HasOne("App.Core.Entities.Sync.SyncDevice", null) + .WithMany() + .HasForeignKey("SyncDeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.HasOne("App.Core.Entities.Team.User", "User") + .WithOne("TechnicianProfile") + .HasForeignKey("App.Core.Entities.Team.TechnicianProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianQualification", b => + { + b.HasOne("App.Core.Entities.Team.QualificationType", "QualificationType") + .WithMany() + .HasForeignKey("QualificationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Team.TechnicianProfile", "Technician") + .WithMany("Qualifications") + .HasForeignKey("TechnicianUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QualificationType"); + + b.Navigation("Technician"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TimeEntry", b => + { + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("MaintenanceTaskTool", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany() + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.Tool", null) + .WithMany() + .HasForeignKey("ToolsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.Invoice", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.Machine", b => + { + b.Navigation("CounterReadings"); + + b.Navigation("MachineParts"); + + b.Navigation("MaintenancePlans"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.Navigation("Tasks"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.Navigation("RequiredParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.Navigation("PartsUsed"); + + b.Navigation("TaskResults"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.User", b => + { + b.Navigation("TechnicianProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.cs b/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.cs new file mode 100644 index 0000000..e08ea3c --- /dev/null +++ b/src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.cs @@ -0,0 +1,884 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace App.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Key = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Value = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "Attachments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FileName = table.Column(type: "TEXT", maxLength: 260, nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + StoredRelativePath = table.Column(type: "TEXT", maxLength: 400, nullable: false), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + EntityType = table.Column(type: "INTEGER", nullable: false), + EntityId = table.Column(type: "INTEGER", nullable: false), + UploadedAtUtc = table.Column(type: "TEXT", nullable: false), + UploadedByUserId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Attachments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Invoices", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DocumentType = table.Column(type: "INTEGER", nullable: false), + Number = table.Column(type: "TEXT", maxLength: 50, nullable: false), + ClientName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ClientAddress = table.Column(type: "TEXT", nullable: true), + IssueDate = table.Column(type: "TEXT", nullable: false), + DueDate = table.Column(type: "TEXT", nullable: true), + VatRate = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + TotalHT = table.Column(type: "TEXT", nullable: false), + TotalTTC = table.Column(type: "TEXT", nullable: false), + PdfAttachmentId = table.Column(type: "INTEGER", nullable: true), + LinkedWorkOrderId = table.Column(type: "INTEGER", nullable: true), + CreatedByUserId = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Invoices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "LicenseActivationCaches", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CompanyName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + MaxUsers = table.Column(type: "INTEGER", nullable: false), + MaxMachines = table.Column(type: "INTEGER", nullable: false), + IssuedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LicenseActivationCaches", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Machines", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Reference = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Designation = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Location = table.Column(type: "TEXT", nullable: true), + Criticality = table.Column(type: "INTEGER", nullable: false), + CommissioningDate = table.Column(type: "TEXT", nullable: true), + PhotoAttachmentId = table.Column(type: "INTEGER", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Machines", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "QualificationTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Code = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Label = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Category = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QualificationTypes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupplierInvoices", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SupplierId = table.Column(type: "INTEGER", nullable: true), + InvoiceNumber = table.Column(type: "TEXT", maxLength: 100, nullable: true), + InvoiceDate = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "TEXT", nullable: false), + AttachmentId = table.Column(type: "INTEGER", nullable: false), + LinkedWorkOrderId = table.Column(type: "INTEGER", nullable: true), + ImportedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SupplierInvoices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Suppliers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ContactName = table.Column(type: "TEXT", nullable: true), + Phone = table.Column(type: "TEXT", nullable: true), + Email = table.Column(type: "TEXT", nullable: true), + Address = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Suppliers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SyncDevices", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DeviceName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + DeviceIdentifier = table.Column(type: "TEXT", maxLength: 200, nullable: false), + PairedAtUtc = table.Column(type: "TEXT", nullable: false), + PairingPinHash = table.Column(type: "TEXT", nullable: false), + LastSyncAtUtc = table.Column(type: "TEXT", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncDevices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tools", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tools", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + FullName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Email = table.Column(type: "TEXT", nullable: true), + Role = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + LastLoginAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "InvoiceLines", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + InvoiceId = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + Designation = table.Column(type: "TEXT", maxLength: 300, nullable: false), + Quantity = table.Column(type: "REAL", nullable: false), + UnitPrice = table.Column(type: "TEXT", nullable: false), + VatRateOverride = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoiceLines", x => x.Id); + table.ForeignKey( + name: "FK_InvoiceLines_Invoices_InvoiceId", + column: x => x.InvoiceId, + principalTable: "Invoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MachineCounterReadings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MachineId = table.Column(type: "INTEGER", nullable: false), + CounterType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Value = table.Column(type: "REAL", nullable: false), + RecordedAtUtc = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MachineCounterReadings", x => x.Id); + table.ForeignKey( + name: "FK_MachineCounterReadings_Machines_MachineId", + column: x => x.MachineId, + principalTable: "Machines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "MaintenancePlans", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MachineId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + FrequencyType = table.Column(type: "INTEGER", nullable: false), + StartDate = table.Column(type: "TEXT", nullable: false), + CustomIntervalDays = table.Column(type: "INTEGER", nullable: true), + DayOfWeek = table.Column(type: "INTEGER", nullable: true), + DayOfMonth = table.Column(type: "INTEGER", nullable: true), + MonthlyRuleOccurrence = table.Column(type: "INTEGER", nullable: true), + MonthlyRuleWeekday = table.Column(type: "INTEGER", nullable: true), + CounterType = table.Column(type: "TEXT", maxLength: 50, nullable: true), + CounterThreshold = table.Column(type: "REAL", nullable: true), + LeadTimeDays = table.Column(type: "INTEGER", nullable: true), + LeadTimePercentForCounter = table.Column(type: "REAL", nullable: true), + RequiredQualificationTypeId = table.Column(type: "INTEGER", nullable: true), + LastExecutionDateUtc = table.Column(type: "TEXT", nullable: true), + LastExecutionCounterValue = table.Column(type: "REAL", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MaintenancePlans", x => x.Id); + table.ForeignKey( + name: "FK_MaintenancePlans_Machines_MachineId", + column: x => x.MachineId, + principalTable: "Machines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Parts", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Reference = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Designation = table.Column(type: "TEXT", maxLength: 200, nullable: false), + QuantityInStock = table.Column(type: "REAL", nullable: false), + AlertThreshold = table.Column(type: "REAL", nullable: false), + Location = table.Column(type: "TEXT", nullable: true), + PrimarySupplierId = table.Column(type: "INTEGER", nullable: true), + UnitPrice = table.Column(type: "TEXT", nullable: false), + PhotoAttachmentId = table.Column(type: "INTEGER", nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Parts", x => x.Id); + table.ForeignKey( + name: "FK_Parts_Suppliers_PrimarySupplierId", + column: x => x.PrimarySupplierId, + principalTable: "Suppliers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "SyncLogEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SyncDeviceId = table.Column(type: "INTEGER", nullable: false), + Direction = table.Column(type: "INTEGER", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + OccurredAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncLogEntries", x => x.Id); + table.ForeignKey( + name: "FK_SyncLogEntries_SyncDevices_SyncDeviceId", + column: x => x.SyncDeviceId, + principalTable: "SyncDevices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TechnicianProfiles", + columns: table => new + { + UserId = table.Column(type: "INTEGER", nullable: false), + Skills = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TechnicianProfiles", x => x.UserId); + table.ForeignKey( + name: "FK_TechnicianProfiles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MaintenanceTasks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MaintenancePlanId = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + Label = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + EstimatedDurationMinutes = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MaintenanceTasks", x => x.Id); + table.ForeignKey( + name: "FK_MaintenanceTasks_MaintenancePlans_MaintenancePlanId", + column: x => x.MaintenancePlanId, + principalTable: "MaintenancePlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WorkOrders", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MachineId = table.Column(type: "INTEGER", nullable: false), + MaintenancePlanId = table.Column(type: "INTEGER", nullable: true), + Type = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + Priority = table.Column(type: "INTEGER", nullable: false), + ScheduledDate = table.Column(type: "TEXT", nullable: false), + DueDate = table.Column(type: "TEXT", nullable: true), + CompletedAtUtc = table.Column(type: "TEXT", nullable: true), + AssignedTechnicianUserId = table.Column(type: "INTEGER", nullable: true), + GeneratedForOccurrenceKey = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Comment = table.Column(type: "TEXT", nullable: true), + Source = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrders", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrders_Machines_MachineId", + column: x => x.MachineId, + principalTable: "Machines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WorkOrders_MaintenancePlans_MaintenancePlanId", + column: x => x.MaintenancePlanId, + principalTable: "MaintenancePlans", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WorkOrders_Users_AssignedTechnicianUserId", + column: x => x.AssignedTechnicianUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "MachineParts", + columns: table => new + { + MachineId = table.Column(type: "INTEGER", nullable: false), + PartId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MachineParts", x => new { x.MachineId, x.PartId }); + table.ForeignKey( + name: "FK_MachineParts_Machines_MachineId", + column: x => x.MachineId, + principalTable: "Machines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_MachineParts_Parts_PartId", + column: x => x.PartId, + principalTable: "Parts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "StockMovements", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PartId = table.Column(type: "INTEGER", nullable: false), + MovementType = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "REAL", nullable: false), + Reason = table.Column(type: "TEXT", nullable: true), + WorkOrderId = table.Column(type: "INTEGER", nullable: true), + SupplierInvoiceId = table.Column(type: "INTEGER", nullable: true), + CreatedByUserId = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StockMovements", x => x.Id); + table.ForeignKey( + name: "FK_StockMovements_Parts_PartId", + column: x => x.PartId, + principalTable: "Parts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TechnicianQualifications", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TechnicianUserId = table.Column(type: "INTEGER", nullable: false), + QualificationTypeId = table.Column(type: "INTEGER", nullable: false), + ObtainedDate = table.Column(type: "TEXT", nullable: false), + ExpiryDate = table.Column(type: "TEXT", nullable: true), + CertificateAttachmentId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TechnicianQualifications", x => x.Id); + table.ForeignKey( + name: "FK_TechnicianQualifications_QualificationTypes_QualificationTypeId", + column: x => x.QualificationTypeId, + principalTable: "QualificationTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TechnicianQualifications_TechnicianProfiles_TechnicianUserId", + column: x => x.TechnicianUserId, + principalTable: "TechnicianProfiles", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MaintenanceTaskParts", + columns: table => new + { + MaintenanceTaskId = table.Column(type: "INTEGER", nullable: false), + PartId = table.Column(type: "INTEGER", nullable: false), + QuantityRequired = table.Column(type: "REAL", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MaintenanceTaskParts", x => new { x.MaintenanceTaskId, x.PartId }); + table.ForeignKey( + name: "FK_MaintenanceTaskParts_MaintenanceTasks_MaintenanceTaskId", + column: x => x.MaintenanceTaskId, + principalTable: "MaintenanceTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MaintenanceTaskParts_Parts_PartId", + column: x => x.PartId, + principalTable: "Parts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "MaintenanceTaskTools", + columns: table => new + { + MaintenanceTaskId = table.Column(type: "INTEGER", nullable: false), + ToolsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MaintenanceTaskTools", x => new { x.MaintenanceTaskId, x.ToolsId }); + table.ForeignKey( + name: "FK_MaintenanceTaskTools_MaintenanceTasks_MaintenanceTaskId", + column: x => x.MaintenanceTaskId, + principalTable: "MaintenanceTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MaintenanceTaskTools_Tools_ToolsId", + column: x => x.ToolsId, + principalTable: "Tools", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TimeEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + WorkOrderId = table.Column(type: "INTEGER", nullable: false), + TechnicianUserId = table.Column(type: "INTEGER", nullable: false), + StartedAtUtc = table.Column(type: "TEXT", nullable: false), + EndedAtUtc = table.Column(type: "TEXT", nullable: true), + DurationMinutes = table.Column(type: "INTEGER", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false), + Comment = table.Column(type: "TEXT", nullable: true), + SyncDeviceId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TimeEntries", x => x.Id); + table.ForeignKey( + name: "FK_TimeEntries_WorkOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "WorkOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "WorkOrderPartsUsed", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + WorkOrderId = table.Column(type: "INTEGER", nullable: false), + PartId = table.Column(type: "INTEGER", nullable: false), + QuantityUsed = table.Column(type: "REAL", nullable: false), + StockMovementId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderPartsUsed", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrderPartsUsed_Parts_PartId", + column: x => x.PartId, + principalTable: "Parts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WorkOrderPartsUsed_WorkOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "WorkOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WorkOrderTaskResults", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + WorkOrderId = table.Column(type: "INTEGER", nullable: false), + MaintenanceTaskId = table.Column(type: "INTEGER", nullable: true), + IsCompleted = table.Column(type: "INTEGER", nullable: false), + Comment = table.Column(type: "TEXT", nullable: true), + PhotoAttachmentId = table.Column(type: "INTEGER", nullable: true), + CompletedAtUtc = table.Column(type: "TEXT", nullable: true), + CompletedByTechnicianUserId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderTaskResults", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrderTaskResults_MaintenanceTasks_MaintenanceTaskId", + column: x => x.MaintenanceTaskId, + principalTable: "MaintenanceTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WorkOrderTaskResults_WorkOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "WorkOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Attachments_EntityType_EntityId", + table: "Attachments", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_InvoiceLines_InvoiceId", + table: "InvoiceLines", + column: "InvoiceId"); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_Number", + table: "Invoices", + column: "Number", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MachineCounterReadings_MachineId_CounterType_RecordedAtUtc", + table: "MachineCounterReadings", + columns: new[] { "MachineId", "CounterType", "RecordedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_MachineParts_PartId", + table: "MachineParts", + column: "PartId"); + + migrationBuilder.CreateIndex( + name: "IX_Machines_Reference", + table: "Machines", + column: "Reference", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MaintenancePlans_MachineId_IsActive", + table: "MaintenancePlans", + columns: new[] { "MachineId", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_MaintenanceTaskParts_PartId", + table: "MaintenanceTaskParts", + column: "PartId"); + + migrationBuilder.CreateIndex( + name: "IX_MaintenanceTasks_MaintenancePlanId", + table: "MaintenanceTasks", + column: "MaintenancePlanId"); + + migrationBuilder.CreateIndex( + name: "IX_MaintenanceTaskTools_ToolsId", + table: "MaintenanceTaskTools", + column: "ToolsId"); + + migrationBuilder.CreateIndex( + name: "IX_Parts_PrimarySupplierId", + table: "Parts", + column: "PrimarySupplierId"); + + migrationBuilder.CreateIndex( + name: "IX_Parts_Reference", + table: "Parts", + column: "Reference", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QualificationTypes_Code", + table: "QualificationTypes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StockMovements_PartId_CreatedAtUtc", + table: "StockMovements", + columns: new[] { "PartId", "CreatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_SyncDevices_DeviceIdentifier", + table: "SyncDevices", + column: "DeviceIdentifier", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SyncLogEntries_SyncDeviceId", + table: "SyncLogEntries", + column: "SyncDeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_TechnicianQualifications_QualificationTypeId", + table: "TechnicianQualifications", + column: "QualificationTypeId"); + + migrationBuilder.CreateIndex( + name: "IX_TechnicianQualifications_TechnicianUserId_ExpiryDate", + table: "TechnicianQualifications", + columns: new[] { "TechnicianUserId", "ExpiryDate" }); + + migrationBuilder.CreateIndex( + name: "IX_TimeEntries_WorkOrderId", + table: "TimeEntries", + column: "WorkOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_Tools_Name", + table: "Tools", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderPartsUsed_PartId", + table: "WorkOrderPartsUsed", + column: "PartId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderPartsUsed_WorkOrderId", + table: "WorkOrderPartsUsed", + column: "WorkOrderId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrders_AssignedTechnicianUserId", + table: "WorkOrders", + column: "AssignedTechnicianUserId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrders_MachineId", + table: "WorkOrders", + column: "MachineId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrders_MaintenancePlanId_GeneratedForOccurrenceKey", + table: "WorkOrders", + columns: new[] { "MaintenancePlanId", "GeneratedForOccurrenceKey" }, + unique: true, + filter: "MaintenancePlanId IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrders_Status_ScheduledDate", + table: "WorkOrders", + columns: new[] { "Status", "ScheduledDate" }); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderTaskResults_MaintenanceTaskId", + table: "WorkOrderTaskResults", + column: "MaintenanceTaskId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderTaskResults_WorkOrderId", + table: "WorkOrderTaskResults", + column: "WorkOrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + + migrationBuilder.DropTable( + name: "Attachments"); + + migrationBuilder.DropTable( + name: "InvoiceLines"); + + migrationBuilder.DropTable( + name: "LicenseActivationCaches"); + + migrationBuilder.DropTable( + name: "MachineCounterReadings"); + + migrationBuilder.DropTable( + name: "MachineParts"); + + migrationBuilder.DropTable( + name: "MaintenanceTaskParts"); + + migrationBuilder.DropTable( + name: "MaintenanceTaskTools"); + + migrationBuilder.DropTable( + name: "StockMovements"); + + migrationBuilder.DropTable( + name: "SupplierInvoices"); + + migrationBuilder.DropTable( + name: "SyncLogEntries"); + + migrationBuilder.DropTable( + name: "TechnicianQualifications"); + + migrationBuilder.DropTable( + name: "TimeEntries"); + + migrationBuilder.DropTable( + name: "WorkOrderPartsUsed"); + + migrationBuilder.DropTable( + name: "WorkOrderTaskResults"); + + migrationBuilder.DropTable( + name: "Invoices"); + + migrationBuilder.DropTable( + name: "Tools"); + + migrationBuilder.DropTable( + name: "SyncDevices"); + + migrationBuilder.DropTable( + name: "QualificationTypes"); + + migrationBuilder.DropTable( + name: "TechnicianProfiles"); + + migrationBuilder.DropTable( + name: "Parts"); + + migrationBuilder.DropTable( + name: "MaintenanceTasks"); + + migrationBuilder.DropTable( + name: "WorkOrders"); + + migrationBuilder.DropTable( + name: "Suppliers"); + + migrationBuilder.DropTable( + name: "MaintenancePlans"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Machines"); + } + } +} diff --git a/src/App.Infrastructure/Data/Migrations/GmaoDbContextModelSnapshot.cs b/src/App.Infrastructure/Data/Migrations/GmaoDbContextModelSnapshot.cs new file mode 100644 index 0000000..4e3ee75 --- /dev/null +++ b/src/App.Infrastructure/Data/Migrations/GmaoDbContextModelSnapshot.cs @@ -0,0 +1,1198 @@ +// +using System; +using App.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace App.Infrastructure.Data.Migrations +{ + [DbContext(typeof(GmaoDbContext))] + partial class GmaoDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("App.Core.Entities.Inventory.MachinePart", b => + { + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.HasKey("MachineId", "PartId"); + + b.HasIndex("PartId"); + + b.ToTable("MachineParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Part", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertThreshold") + .HasColumnType("REAL"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("PrimarySupplierId") + .HasColumnType("INTEGER"); + + b.Property("QuantityInStock") + .HasColumnType("REAL"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrimarySupplierId"); + + b.HasIndex("Reference") + .IsUnique(); + + b.ToTable("Parts"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("INTEGER"); + + b.Property("MovementType") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("REAL"); + + b.Property("Reason") + .HasColumnType("TEXT"); + + b.Property("SupplierInvoiceId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PartId", "CreatedAtUtc"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("ContactName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientAddress") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("INTEGER"); + + b.Property("DocumentType") + .HasColumnType("INTEGER"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LinkedWorkOrderId") + .HasColumnType("INTEGER"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PdfAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalHT") + .HasColumnType("TEXT"); + + b.Property("TotalTTC") + .HasColumnType("TEXT"); + + b.Property("VatRate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.InvoiceLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("REAL"); + + b.Property("UnitPrice") + .HasColumnType("TEXT"); + + b.Property("VatRateOverride") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLines"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.SupplierInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("AttachmentId") + .HasColumnType("INTEGER"); + + b.Property("ImportedAtUtc") + .HasColumnType("TEXT"); + + b.Property("InvoiceDate") + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LinkedWorkOrderId") + .HasColumnType("INTEGER"); + + b.Property("SupplierId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SupplierInvoices"); + }); + + modelBuilder.Entity("App.Core.Entities.Licensing.LicenseActivationCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IssuedDate") + .HasColumnType("TEXT"); + + b.Property("MaxMachines") + .HasColumnType("INTEGER"); + + b.Property("MaxUsers") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("LicenseActivationCaches"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.Machine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommissioningDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Criticality") + .HasColumnType("INTEGER"); + + b.Property("Designation") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("Reference") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Reference") + .IsUnique(); + + b.ToTable("Machines"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.MachineCounterReading", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("RecordedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "CounterType", "RecordedAtUtc"); + + b.ToTable("MachineCounterReadings"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CounterThreshold") + .HasColumnType("REAL"); + + b.Property("CounterType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CustomIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FrequencyType") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionCounterValue") + .HasColumnType("REAL"); + + b.Property("LastExecutionDateUtc") + .HasColumnType("TEXT"); + + b.Property("LeadTimeDays") + .HasColumnType("INTEGER"); + + b.Property("LeadTimePercentForCounter") + .HasColumnType("REAL"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MonthlyRuleOccurrence") + .HasColumnType("INTEGER"); + + b.Property("MonthlyRuleWeekday") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequiredQualificationTypeId") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MachineId", "IsActive"); + + b.ToTable("MaintenancePlans"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaintenancePlanId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MaintenancePlanId"); + + b.ToTable("MaintenanceTasks"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTaskPart", b => + { + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("QuantityRequired") + .HasColumnType("REAL"); + + b.HasKey("MaintenanceTaskId", "PartId"); + + b.HasIndex("PartId"); + + b.ToTable("MaintenanceTaskParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.Tool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tools"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedTechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("GeneratedForOccurrenceKey") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MachineId") + .HasColumnType("INTEGER"); + + b.Property("MaintenancePlanId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("ScheduledDate") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AssignedTechnicianUserId"); + + b.HasIndex("MachineId"); + + b.HasIndex("MaintenancePlanId", "GeneratedForOccurrenceKey") + .IsUnique() + .HasFilter("MaintenancePlanId IS NOT NULL"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderPartUsed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PartId") + .HasColumnType("INTEGER"); + + b.Property("QuantityUsed") + .HasColumnType("REAL"); + + b.Property("StockMovementId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PartId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderPartsUsed"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderTaskResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedByTechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("PhotoAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MaintenanceTaskId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderTaskResults"); + }); + + modelBuilder.Entity("App.Core.Entities.Shared.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("App.Core.Entities.Shared.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StoredRelativePath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UploadedByUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAtUtc") + .HasColumnType("TEXT"); + + b.Property("PairedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PairingPinHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceIdentifier") + .IsUnique(); + + b.ToTable("SyncDevices"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SyncDeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SyncDeviceId"); + + b.ToTable("SyncLogEntries"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.QualificationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("QualificationTypes"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("Skills") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("TechnicianProfiles"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianQualification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CertificateAttachmentId") + .HasColumnType("INTEGER"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT"); + + b.Property("ObtainedDate") + .HasColumnType("TEXT"); + + b.Property("QualificationTypeId") + .HasColumnType("INTEGER"); + + b.Property("TechnicianUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("QualificationTypeId"); + + b.HasIndex("TechnicianUserId", "ExpiryDate"); + + b.ToTable("TechnicianQualifications"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EndedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("SyncDeviceId") + .HasColumnType("INTEGER"); + + b.Property("TechnicianUserId") + .HasColumnType("INTEGER"); + + b.Property("WorkOrderId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("TimeEntries"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MaintenanceTaskTool", b => + { + b.Property("MaintenanceTaskId") + .HasColumnType("INTEGER"); + + b.Property("ToolsId") + .HasColumnType("INTEGER"); + + b.HasKey("MaintenanceTaskId", "ToolsId"); + + b.HasIndex("ToolsId"); + + b.ToTable("MaintenanceTaskTools", (string)null); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.MachinePart", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("MachineParts") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.Part", b => + { + b.HasOne("App.Core.Entities.Inventory.Supplier", "PrimarySupplier") + .WithMany() + .HasForeignKey("PrimarySupplierId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PrimarySupplier"); + }); + + modelBuilder.Entity("App.Core.Entities.Inventory.StockMovement", b => + { + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.InvoiceLine", b => + { + b.HasOne("App.Core.Entities.Invoicing.Invoice", null) + .WithMany("Lines") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.MachineCounterReading", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("CounterReadings") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("MaintenancePlans") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Machine"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenancePlan", "MaintenancePlan") + .WithMany("Tasks") + .HasForeignKey("MaintenancePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MaintenancePlan"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTaskPart", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany("RequiredParts") + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("App.Core.Entities.Inventory.Part", "Part") + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Part"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.HasOne("App.Core.Entities.Team.User", null) + .WithMany() + .HasForeignKey("AssignedTechnicianUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("App.Core.Entities.Machines.Machine", "Machine") + .WithMany("WorkOrders") + .HasForeignKey("MachineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.MaintenancePlan", "MaintenancePlan") + .WithMany("WorkOrders") + .HasForeignKey("MaintenancePlanId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Machine"); + + b.Navigation("MaintenancePlan"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderPartUsed", b => + { + b.HasOne("App.Core.Entities.Inventory.Part", null) + .WithMany() + .HasForeignKey("PartId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", null) + .WithMany("PartsUsed") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrderTaskResult", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany() + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", "WorkOrder") + .WithMany("TaskResults") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("App.Core.Entities.Sync.SyncLogEntry", b => + { + b.HasOne("App.Core.Entities.Sync.SyncDevice", null) + .WithMany() + .HasForeignKey("SyncDeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.HasOne("App.Core.Entities.Team.User", "User") + .WithOne("TechnicianProfile") + .HasForeignKey("App.Core.Entities.Team.TechnicianProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianQualification", b => + { + b.HasOne("App.Core.Entities.Team.QualificationType", "QualificationType") + .WithMany() + .HasForeignKey("QualificationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("App.Core.Entities.Team.TechnicianProfile", "Technician") + .WithMany("Qualifications") + .HasForeignKey("TechnicianUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QualificationType"); + + b.Navigation("Technician"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TimeEntry", b => + { + b.HasOne("App.Core.Entities.Maintenance.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("MaintenanceTaskTool", b => + { + b.HasOne("App.Core.Entities.Maintenance.MaintenanceTask", null) + .WithMany() + .HasForeignKey("MaintenanceTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("App.Core.Entities.Maintenance.Tool", null) + .WithMany() + .HasForeignKey("ToolsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("App.Core.Entities.Invoicing.Invoice", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("App.Core.Entities.Machines.Machine", b => + { + b.Navigation("CounterReadings"); + + b.Navigation("MachineParts"); + + b.Navigation("MaintenancePlans"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenancePlan", b => + { + b.Navigation("Tasks"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.MaintenanceTask", b => + { + b.Navigation("RequiredParts"); + }); + + modelBuilder.Entity("App.Core.Entities.Maintenance.WorkOrder", b => + { + b.Navigation("PartsUsed"); + + b.Navigation("TaskResults"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.TechnicianProfile", b => + { + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("App.Core.Entities.Team.User", b => + { + b.Navigation("TechnicianProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/App.Infrastructure/Licensing/DpapiTrialStateStore.cs b/src/App.Infrastructure/Licensing/DpapiTrialStateStore.cs new file mode 100644 index 0000000..78efef1 --- /dev/null +++ b/src/App.Infrastructure/Licensing/DpapiTrialStateStore.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Text.Json; +using App.Core.Interfaces.Licensing; +using App.Core.Services.Licensing; + +namespace App.Infrastructure.Licensing; + +/// +/// Stocke l'état de l'essai gratuit chiffré par DPAPI dans un fichier séparé de la base SQLite +/// (voir docs/DATABASE_SCHEMA.md §Licence) : supprimer/remplacer app.db ne doit jamais +/// réinitialiser l'essai. +/// +/// +/// Scope plutôt que CurrentUser : la +/// protection est liée au POSTE, pas au compte Windows qui exécute l'app — cohérent avec le +/// modèle de licence "à vie, liée au poste". Contrepartie assumée : sur un poste multi-comptes, +/// n'importe quel compte Windows local peut déchiffrer ce fichier (mais pas le copier utilement +/// sur un autre poste, ce qui est le seul contournement qu'on cherche à bloquer ici). +/// +public class DpapiTrialStateStore(string filePath) : ITrialStateStore +{ + public TrialState? Load() + { + if (!File.Exists(filePath)) + { + return null; + } + + try + { + var protectedBytes = File.ReadAllBytes(filePath); + var jsonBytes = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, DataProtectionScope.LocalMachine); + return JsonSerializer.Deserialize(jsonBytes); + } + catch (Exception ex) when (ex is CryptographicException or JsonException) + { + // Fichier corrompu ou déplacé depuis un autre poste (DPAPI ne peut pas le déchiffrer) : + // traité comme "pas d'essai enregistré" -> LicenseService en recréera un. Sans risque + // d'abus significatif : au pire ça redonne un essai, ça ne contourne pas une licence + // payante déjà activée (qui, elle, est vérifiée par signature Ed25519 indépendamment). + return null; + } + } + + public void Save(TrialState state) + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(state); + var protectedBytes = ProtectedData.Protect(jsonBytes, optionalEntropy: null, DataProtectionScope.LocalMachine); + File.WriteAllBytes(filePath, protectedBytes); + } +} diff --git a/src/App.Infrastructure/Licensing/LicenseFileRepository.cs b/src/App.Infrastructure/Licensing/LicenseFileRepository.cs new file mode 100644 index 0000000..3296dfc --- /dev/null +++ b/src/App.Infrastructure/Licensing/LicenseFileRepository.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using App.Core.Interfaces.Licensing; +using App.Core.Services.Licensing; + +namespace App.Infrastructure.Licensing; + +/// +/// Lit/écrit le fichier de licence (.lic) activée dans le dossier sandboxé de l'application. +/// +/// +/// ne retourne null QUE si aucun fichier n'existe. Si le +/// fichier existe mais est illisible/corrompu (JSON invalide), une +/// est volontairement laissée remonter plutôt que d'être avalée : un fichier corrompu doit être +/// traité comme une licence invalide (écran d'activation), jamais comme "pas de licence" — sinon +/// il suffirait de corrompre le fichier pour relancer un essai gratuit. Voir LicenseService. +/// +public class LicenseFileRepository(string filePath) : ILicenseFileRepository +{ + public LicenseFile? LoadInstalled() + { + if (!File.Exists(filePath)) + { + return null; + } + + var json = File.ReadAllText(filePath); + return JsonSerializer.Deserialize(json, LicenseJsonOptions.Default); + } + + public void Install(LicenseFile license) + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(license, LicenseJsonOptions.Default); + File.WriteAllText(filePath, json); + } +} diff --git a/src/App.Infrastructure/Licensing/WmiHardwareIdProvider.cs b/src/App.Infrastructure/Licensing/WmiHardwareIdProvider.cs new file mode 100644 index 0000000..3433391 --- /dev/null +++ b/src/App.Infrastructure/Licensing/WmiHardwareIdProvider.cs @@ -0,0 +1,41 @@ +using System.Management; +using App.Core.Interfaces.Licensing; +using App.Core.Services.Licensing; + +namespace App.Infrastructure.Licensing; + +/// +/// Lit l'UUID de la carte mère et le numéro de série du disque système via WMI, tels que +/// requis par le format de licence (voir docs/DATABASE_SCHEMA.md §Licence). +/// +public class WmiHardwareIdProvider : IHardwareIdProvider +{ + public HardwareId GetCurrent() + { + var motherboardUuid = QuerySingleProperty("SELECT UUID FROM Win32_ComputerSystemProduct", "UUID") + ?? throw new InvalidOperationException("Impossible de lire Win32_ComputerSystemProduct.UUID via WMI."); + + // Win32_DiskDrive.Index=0 : le disque système (celui sur lequel Windows est installé), + // conformément au cahier des charges ("disque système index 0"). + var diskSerial = QuerySingleProperty("SELECT SerialNumber FROM Win32_DiskDrive WHERE Index = 0", "SerialNumber") + ?? throw new InvalidOperationException("Impossible de lire Win32_DiskDrive (Index=0).SerialNumber via WMI."); + + return new HardwareId(motherboardUuid.Trim(), diskSerial.Trim()); + } + + private static string? QuerySingleProperty(string query, string propertyName) + { + using var searcher = new ManagementObjectSearcher(query); + using var results = searcher.Get(); + + foreach (ManagementBaseObject item in results) + { + using (item) + { + return item[propertyName]?.ToString(); + } + } + + return null; + } +} diff --git a/src/App.UI/.gitignore b/src/App.UI/.gitignore new file mode 100644 index 0000000..dd39356 --- /dev/null +++ b/src/App.UI/.gitignore @@ -0,0 +1,54 @@ +## .NET / Visual Studio +[Bb]in/ +[Oo]bj/ +[Dd]ebug/ +[Rr]elease/ +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates +artifacts/ + +# Build logs +[Ll]og/ +[Ll]ogs/ +*.log +*.binlog + +# Test results +[Tt]est[Rr]esult*/ +*.trx +*.coverage +*.coveragexml + +# NuGet +*.nupkg +*.snupkg +*.nuget.props +*.nuget.targets +project.lock.json + +# MSIX packaging output +AppPackages/ +BundleArtifacts/ +*.msix +*.msixupload +*.appx +*.appxbundle +*.appxupload + +# Publish output +publish/ +*.pubxml +PublishScripts/ + +# Code analysis and tooling +_ReSharper*/ +*.DotSettings.user +*.dotCover +.idea/ + +# Generated files +Generated\ Files/ +*_wpftmp.csproj diff --git a/src/App.UI/App.UI.csproj b/src/App.UI/App.UI.csproj new file mode 100644 index 0000000..2f0776f --- /dev/null +++ b/src/App.UI/App.UI.csproj @@ -0,0 +1,79 @@ + + + WinExe + net8.0-windows10.0.26100.0 + 10.0.17763.0 + App_UI + app.manifest + x86;x64;ARM64 + win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant()) + win-$(Platform).pubxml + true + false + true + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + + + False + True + False + True + + diff --git a/src/App.UI/App.xaml b/src/App.UI/App.xaml new file mode 100644 index 0000000..fd551ec --- /dev/null +++ b/src/App.UI/App.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + #4C6FFF + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/App.xaml.cs b/src/App.UI/App.xaml.cs new file mode 100644 index 0000000..3d82ed4 --- /dev/null +++ b/src/App.UI/App.xaml.cs @@ -0,0 +1,43 @@ +using App_UI.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace App_UI; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : Application +{ + private Window? _window; + + /// Conteneur DI de l'application, résolu par chaque page pour obtenir son ViewModel. + public static IServiceProvider Services { get; private set; } = null!; + + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + InitializeComponent(); + Services = ServiceRegistration.BuildServiceProvider(); + } + + /// + /// Invoked when the application is launched. + /// + /// Details about the launch request and process. + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + _window = new MainWindow(); + _window.Activate(); + } +} diff --git a/src/App.UI/Assets/AppIcon.ico b/src/App.UI/Assets/AppIcon.ico new file mode 100644 index 0000000000000000000000000000000000000000..235dad2c569272e9a3c7158ce4f93042044ea5d1 GIT binary patch literal 370070 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 literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/LockScreenLogo.scale-200.png b/src/App.UI/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..33f889e106984ab9d522a6fca51b036fe85d0ffb GIT binary patch literal 1090 zcmV-I1ikx-P)&b zxMup`R-qg+6mnXG?R5g=!vhZ{B%T$C3{J*#jNh1hW8OF6FkFY~o^r2s_a+uf+(H0QDRr*g(#0 zlNu*NgVilaJ32($r5Ipc0eTGq>2FnqRYZ%onM&!&F2sOn0qO#)x4-h+VnwvMO7&nI zW2AFM!LkCcUPp=kgDw$~e*VUvD+U$;2mI4^3}_Nz2vYZYjrMGoIB@7q(18`) zQrt#(_7TsHBodYtpqqP+>FBS77>@@azm)ewU?)=IKome^?cJ{BKfxk^USdC{qrc}9 z;>4cL6$Oicm6R^7b$LUBO$c_hwFilWML^+AP>UvSV~7(@e|9>H3KjvaKDx_Q#)v?B z7R=R&XaVZ~S19M}X%r+t0;-lsIsP@&xj^B#^HV1Us$-UFTSgFbmL==)$X7 zNFuNTbAO!hlpBMTl=3x)#pLu9d}}k-S<*o|0hI6fnd=xF>og^^vu0#(2J4*TRyvZ> zGqG-|LYy>{cKC+$gMCq$d2#O_;mx0=rl-+HT<2|c$-ii-d8O@@VKNw1M}mueyr4q^Y?K99ZS~GvB;sw9V6)LLn(_)H8ti+qJx5d;4Q9hrD=uhL6ovL*69u$^)}Bjr*$0tZfSyz5qg33VntAWsAl z+lB3+5ih(>`*SZV?-Bm`_ireG7N5)e|780HlFwv^9X<|!09gBj2F|(qf&c&j07*qo IM6N<$f=X5F2LJ#7 literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/SplashScreen.scale-200.png b/src/App.UI/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..802c79d6d7f4a181514f1cac16931cbfd654ea5d GIT binary patch literal 12625 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 Rd16UVXrIz5IA(YCe*qp{r`G@g literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/Square150x150Logo.scale-200.png b/src/App.UI/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..ddba42adbb45821d1123a424657ea248ad7a411e GIT binary patch literal 6357 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 literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/App.UI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000000000000000000000000000000000..e51416a8e42d483453d06da68f57aaa2c5db4671 GIT binary patch literal 574 zcmV-E0>S->P)D!2S5_J7ldulh3Kr$xyeFkPI7_d zdA`B>2VVEkh3Fo(61wVh&N8th;R#sKfm9ez&?)S-6|usQVq9a4$Wy({3^Vw zphDr%m8mNO1$s);V1kfmw{TFTYjzf3jMPH_D)6sNgGmedoAHL%o&}K9nd``Q&i`D6 zm!43A`v9_oZhakUgCOHn*3cjTIN%0i=LLB>)Bq{4DQ@wd4F-_N6=_b_LV-PpDSRM( zltjK$*w+UgEq55-w7HchQO=CFGWW|p^_;?CkMpjH_$->@HG#egU;^8N1GNI%hF6LE-sc{=N^MNf+#(=^ZVR_`JM)0Zh{x&phK0(*OVf M07*qoM6N<$f{>Q)_y7O^ literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/src/App.UI/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png new file mode 100644 index 0000000000000000000000000000000000000000..bf063eb4d6c4d14763d5d8ccca84c0f39fc6c350 GIT binary patch literal 1091 zcmV-J1ibr+P)%;e)Mb+oQkl#NdTRmo~A<(;gjdxkT%sBw>93j2n~5)+||jpL}EC7HA9`KV42*9;xWPn@+QXxK< zzDg^3Ky?Ou$jm!0>xAOrw>^};O)v{!2@uPX@EORkZDRdI zcwl7<;v5}u*(DiZT>)4PV$$F046BF^Ycr$Dl3kDiY5`&b>)BuWWm*wkrcx{z`bcCf z6)Y?8Vs(V*e^59(jh(;o$I`$e;1GYht^sW#3{L7^o@&cx@dHQR0Idi;wjd>ilfpLQ zwvXI4Atfv;05kWbspzi+8LtOSenD@Sz+S}o0Tr0Y+IwBo2SF`>XB0Qe15I6j&qw$P z%>GJQS1*8P^bb`gu5)?A0~_J&aMm733Ck8Ly#X50W^D|9!pYBGV^PB*pwmY8nM#ca zE>DB8dZ88|_J7IgJlsZFJKrS&H&NcEh!2)szJ_822yh1i`73W=QzX?Ug7aqH7;2aT z8r&LrP+Yby%d$j}?kFM*Y;blsU-j%~WI&7`_`+$$O zI;r=9n_rcpKlRHvIl_7A<>6LfW3Lv0PX#nJb zz+=0xJUqmOSLq=4qVfUZpMU>`0&wCpdi$U3&;ar&?Xbi9;V;i{h8m1}(AWR~002ov JPDHLkV1ke!=|TVi literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/StoreLogo.png b/src/App.UI/Assets/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..be865f0c378b74ecc9dc740f45452b9763e59917 GIT binary patch literal 1106 zcmV-Y1g-mtP)LZf|FOXf6EvNm$pnQ7pc6nR046AJ zg2DvQ0XXs|D4l?Gg0>4lC9D)bV<(oO7w#V4oz96ZEB>%7*#_W_JN^q=`pagsiLv;K zlEUp`h4MMevO=$XFqFQ)iNp_--*7v4L`hJ(C~cHXZ#0Q?wdVu9BlE_|pr_ElH(FI{ z>O^KmtJT_KL6RsNM;*< zxf+@zBPBt2sA{CnXf)G|5}8V)*q-k9`)(9PPxu;xiZBHzm!L?PWB(kkNTQaAFB$&$ z5M@LIM-rx)n9DB881)M*=O_z3AB2)1L_SJYZnj>pY4AIg39CP&^iVq3uT=`Zzvub| zs9Bn@ox!Cf2xexd-0^}2Qk$|P6b?YS4H206e4KX>)Njb7W}tFIEeT>yy8Pq(L#=D+ zu((%xrBm|rkfiB~PP}d?U{JewR-{A* zpnkP^Ss@|g5n^X-e+WW&6QPWaMU1=Sl^LOY$oS+5em~|J{@(S80G|PHcl^!PSVcyd z>~b!X!B#`i9;nH!Ci9wdJw_L^`fHx6zsYf2{wo1=JW(R_8hvv3VAb?~>I=;F* z*q%|{6DB)R2$syqFJE0=6HB9dDa;Q)1-hacIT~i4cfzu;J0Qz5&r!=7c1<9^1X;Q-dh>t~9 zaU8yg2;*!oBuRE2WcqA>0YXTL%z*bo^y>*T_7N^J97HCd67C#?5E0=)OhNr>6kdbQ z*fuh7x)PmAPMak{PKYyHkPCQy)b%Ez*)hq)D9>0whX90}5ZkyQ-)U8C5N2VcuZ|BP zhbn^fglMh_TKqA}4%^0c$x^pN`uO;rZ7tx!8%^(96xYJWdU~coO;ASozQU5W`h~v1 zWReNqLZ)zvPUK$nr_Ic;Uwflz!s}kEE;?!VROL~)4%GMT9?T~S2H$IWCc6UEj7<1s zm$H7FiIIN6vT`N6kh^9tB7u1#2jTX@=pRJJx!6NUTniz$Ggcx^k|-f1VgXID}tkx!s@xO65+;5P9B0avPb-0{}& Y7w}?(qQZ`_82|tP07*qoM6N<$g5HGdP5=M^ literal 0 HcmV?d00001 diff --git a/src/App.UI/Assets/Wide310x150Logo.scale-200.png b/src/App.UI/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..314d297f20b765ee16ca15c4d3a066e0b7233c62 GIT binary patch literal 5843 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 yOHd7d7^t+2t&o=9fIe%#|H + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/MainWindow.xaml.cs b/src/App.UI/MainWindow.xaml.cs new file mode 100644 index 0000000..1a690c5 --- /dev/null +++ b/src/App.UI/MainWindow.xaml.cs @@ -0,0 +1,69 @@ +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using App_UI.Pages; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace App_UI; + +public sealed partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + + ExtendsContentIntoTitleBar = true; + SetTitleBar(AppTitleBar); + AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; + AppWindow.SetIcon("Assets/AppIcon.ico"); + } + + private void TitleBar_PaneToggleRequested(TitleBar sender, object args) + { + NavView.IsPaneOpen = !NavView.IsPaneOpen; + } + + private void TitleBar_BackRequested(TitleBar sender, object args) + { + NavFrame.GoBack(); + } + + private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (args.IsSettingsSelected) + { + NavFrame.Navigate(typeof(SettingsPage)); + } + else if (args.SelectedItem is NavigationViewItem item) + { + switch (item.Tag) + { + case "dashboard": + NavFrame.Navigate(typeof(DashboardPage)); + break; + case "machines": + NavFrame.Navigate(typeof(MachinesPage)); + break; + case "calendar": + NavFrame.Navigate(typeof(MaintenanceCalendarPage)); + break; + case "workorders": + NavFrame.Navigate(typeof(WorkOrdersPage)); + break; + case "inventory": + NavFrame.Navigate(typeof(InventoryPage)); + break; + case "team": + NavFrame.Navigate(typeof(TeamPage)); + break; + case "invoicing": + NavFrame.Navigate(typeof(InvoicingPage)); + break; + default: + throw new InvalidOperationException($"Unknown navigation item tag: {item.Tag}"); + } + } + } +} diff --git a/src/App.UI/Package.appxmanifest b/src/App.UI/Package.appxmanifest new file mode 100644 index 0000000..c35aa0f --- /dev/null +++ b/src/App.UI/Package.appxmanifest @@ -0,0 +1,73 @@ + + + + + + + + + + + ARGOS + Pablo Larade + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/DashboardPage.xaml b/src/App.UI/Pages/DashboardPage.xaml new file mode 100644 index 0000000..3e92b2f --- /dev/null +++ b/src/App.UI/Pages/DashboardPage.xaml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/DashboardPage.xaml.cs b/src/App.UI/Pages/DashboardPage.xaml.cs new file mode 100644 index 0000000..76be2a2 --- /dev/null +++ b/src/App.UI/Pages/DashboardPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class DashboardPage : Page +{ + public DashboardViewModel ViewModel { get; } + + public DashboardPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/InventoryPage.xaml b/src/App.UI/Pages/InventoryPage.xaml new file mode 100644 index 0000000..5093b88 --- /dev/null +++ b/src/App.UI/Pages/InventoryPage.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/InventoryPage.xaml.cs b/src/App.UI/Pages/InventoryPage.xaml.cs new file mode 100644 index 0000000..fa71d09 --- /dev/null +++ b/src/App.UI/Pages/InventoryPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class InventoryPage : Page +{ + public InventoryViewModel ViewModel { get; } + + public InventoryPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/InvoicingPage.xaml b/src/App.UI/Pages/InvoicingPage.xaml new file mode 100644 index 0000000..207e40a --- /dev/null +++ b/src/App.UI/Pages/InvoicingPage.xaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/InvoicingPage.xaml.cs b/src/App.UI/Pages/InvoicingPage.xaml.cs new file mode 100644 index 0000000..d546f87 --- /dev/null +++ b/src/App.UI/Pages/InvoicingPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class InvoicingPage : Page +{ + public InvoicingViewModel ViewModel { get; } + + public InvoicingPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/MachinesPage.xaml b/src/App.UI/Pages/MachinesPage.xaml new file mode 100644 index 0000000..4396306 --- /dev/null +++ b/src/App.UI/Pages/MachinesPage.xaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/MachinesPage.xaml.cs b/src/App.UI/Pages/MachinesPage.xaml.cs new file mode 100644 index 0000000..8e36607 --- /dev/null +++ b/src/App.UI/Pages/MachinesPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class MachinesPage : Page +{ + public MachinesViewModel ViewModel { get; } + + public MachinesPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/MaintenanceCalendarPage.xaml b/src/App.UI/Pages/MaintenanceCalendarPage.xaml new file mode 100644 index 0000000..6e62582 --- /dev/null +++ b/src/App.UI/Pages/MaintenanceCalendarPage.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/MaintenanceCalendarPage.xaml.cs b/src/App.UI/Pages/MaintenanceCalendarPage.xaml.cs new file mode 100644 index 0000000..a3c5ff0 --- /dev/null +++ b/src/App.UI/Pages/MaintenanceCalendarPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class MaintenanceCalendarPage : Page +{ + public MaintenanceCalendarViewModel ViewModel { get; } + + public MaintenanceCalendarPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/SettingsPage.xaml b/src/App.UI/Pages/SettingsPage.xaml new file mode 100644 index 0000000..28401d8 --- /dev/null +++ b/src/App.UI/Pages/SettingsPage.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/src/App.UI/Pages/SettingsPage.xaml.cs b/src/App.UI/Pages/SettingsPage.xaml.cs new file mode 100644 index 0000000..72ed43c --- /dev/null +++ b/src/App.UI/Pages/SettingsPage.xaml.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.UI.Xaml.Controls; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace App_UI.Pages; + +public sealed partial class SettingsPage : Page +{ + public SettingsPage() + { + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/TeamPage.xaml b/src/App.UI/Pages/TeamPage.xaml new file mode 100644 index 0000000..01cf7ca --- /dev/null +++ b/src/App.UI/Pages/TeamPage.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/TeamPage.xaml.cs b/src/App.UI/Pages/TeamPage.xaml.cs new file mode 100644 index 0000000..7ad66b1 --- /dev/null +++ b/src/App.UI/Pages/TeamPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class TeamPage : Page +{ + public TeamViewModel ViewModel { get; } + + public TeamPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Pages/WorkOrdersPage.xaml b/src/App.UI/Pages/WorkOrdersPage.xaml new file mode 100644 index 0000000..b13d180 --- /dev/null +++ b/src/App.UI/Pages/WorkOrdersPage.xaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.UI/Pages/WorkOrdersPage.xaml.cs b/src/App.UI/Pages/WorkOrdersPage.xaml.cs new file mode 100644 index 0000000..6150373 --- /dev/null +++ b/src/App.UI/Pages/WorkOrdersPage.xaml.cs @@ -0,0 +1,16 @@ +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace App_UI.Pages; + +public sealed partial class WorkOrdersPage : Page +{ + public WorkOrdersViewModel ViewModel { get; } + + public WorkOrdersPage() + { + ViewModel = App.Services.GetRequiredService(); + InitializeComponent(); + } +} diff --git a/src/App.UI/Properties/launchSettings.json b/src/App.UI/Properties/launchSettings.json new file mode 100644 index 0000000..6e8338e --- /dev/null +++ b/src/App.UI/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "App.UI (Package)": { + "commandName": "MsixPackage" + }, + "App.UI (Unpackaged)": { + "commandName": "Project" + } + } +} \ No newline at end of file diff --git a/src/App.UI/Services/InMemoryRepository.cs b/src/App.UI/Services/InMemoryRepository.cs new file mode 100644 index 0000000..146a91f --- /dev/null +++ b/src/App.UI/Services/InMemoryRepository.cs @@ -0,0 +1,30 @@ +namespace App_UI.Services; + +/// +/// Base CRUD minimale pour les repositories factices (voir ). Retirée en +/// même temps que les Mock*Repository une fois App.Infrastructure branchée sur EF Core. +/// +internal abstract class InMemoryRepository(List items, Func keySelector) + where TEntity : class +{ + protected List Items { get; } = items; + + public Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) => + Task.FromResult(Items.FirstOrDefault(i => EqualityComparer.Default.Equals(keySelector(i), id))); + + public Task> GetAllAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Items.ToList()); + + public Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) + { + Items.Add(entity); + return Task.CompletedTask; + } + + public void Update(TEntity entity) + { + // Liste en mémoire : l'entité modifiée est déjà la même référence, rien à faire. + } + + public void Remove(TEntity entity) => Items.Remove(entity); +} diff --git a/src/App.UI/Services/MockData.cs b/src/App.UI/Services/MockData.cs new file mode 100644 index 0000000..4b7ae4f --- /dev/null +++ b/src/App.UI/Services/MockData.cs @@ -0,0 +1,126 @@ +using App.Core.Entities.Invoicing; +using App.Core.Entities.Inventory; +using App.Core.Entities.Machines; +using App.Core.Entities.Maintenance; +using App.Core.Entities.Team; + +namespace App_UI.Services; + +/// +/// Données factices en mémoire pour valider visuellement les pages avant le branchement sur +/// App.Infrastructure/EF Core. À supprimer dès que les repositories réels sont câblés dans +/// — chaque Mock*Repository ne fait que lire/écrire ces listes. +/// +internal static class MockData +{ + public static readonly List Machines = + [ + new() + { + Id = 1, Reference = "CMP-001", Designation = "Compresseur à vis Atlas Copco", + Location = "Atelier A - Zone air comprimé", Criticality = MachineCriticality.Critical, + CommissioningDate = new DateOnly(2019, 3, 12), IsActive = true, + CreatedAtUtc = DateTime.UtcNow.AddYears(-2), UpdatedAtUtc = DateTime.UtcNow, + }, + new() + { + Id = 2, Reference = "CNC-014", Designation = "Centre d'usinage Haas VF-2", + Location = "Atelier B - Ligne usinage", Criticality = MachineCriticality.High, + CommissioningDate = new DateOnly(2021, 6, 1), IsActive = true, + CreatedAtUtc = DateTime.UtcNow.AddYears(-1), UpdatedAtUtc = DateTime.UtcNow, + }, + new() + { + Id = 3, Reference = "CONV-007", Designation = "Convoyeur à bande ligne 3", + Location = "Atelier B - Ligne conditionnement", Criticality = MachineCriticality.Medium, + CommissioningDate = new DateOnly(2018, 11, 20), IsActive = true, + CreatedAtUtc = DateTime.UtcNow.AddYears(-3), UpdatedAtUtc = DateTime.UtcNow, + }, + new() + { + Id = 4, Reference = "PONT-002", Designation = "Pont roulant 5T", + Location = "Atelier A - Zone stockage", Criticality = MachineCriticality.High, + CommissioningDate = new DateOnly(2015, 4, 3), IsActive = true, + CreatedAtUtc = DateTime.UtcNow.AddYears(-4), UpdatedAtUtc = DateTime.UtcNow, + }, + new() + { + Id = 5, Reference = "GRP-003", Designation = "Groupe électrogène de secours", + Location = "Local technique", Criticality = MachineCriticality.Low, + CommissioningDate = new DateOnly(2020, 9, 15), IsActive = true, + CreatedAtUtc = DateTime.UtcNow.AddYears(-1), UpdatedAtUtc = DateTime.UtcNow, + }, + ]; + + public static readonly List Users = + [ + new() { Id = 1, Username = "j.durand", PasswordHash = "-", FullName = "Julien Durand", Role = UserRole.Admin, CreatedAtUtc = DateTime.UtcNow.AddYears(-2) }, + new() { Id = 2, Username = "m.leroy", PasswordHash = "-", FullName = "Marc Leroy", Role = UserRole.ResponsableMaintenance, CreatedAtUtc = DateTime.UtcNow.AddYears(-1) }, + new() { Id = 3, Username = "s.martin", PasswordHash = "-", FullName = "Sophie Martin", Role = UserRole.Technicien, CreatedAtUtc = DateTime.UtcNow.AddMonths(-8) }, + new() { Id = 4, Username = "k.benali", PasswordHash = "-", FullName = "Karim Benali", Role = UserRole.Technicien, CreatedAtUtc = DateTime.UtcNow.AddMonths(-5) }, + ]; + + public static readonly List Parts = + [ + new() { Id = 1, Reference = "RLT-6205", Designation = "Roulement à billes 6205-2RS", QuantityInStock = 14, AlertThreshold = 5, Location = "Magasin A3", UnitPrice = 8.90m, IsActive = true }, + new() { Id = 2, Reference = "FLT-HYD-12", Designation = "Filtre hydraulique 12 microns", QuantityInStock = 3, AlertThreshold = 4, Location = "Magasin B1", UnitPrice = 24.50m, IsActive = true }, + new() { Id = 3, Reference = "COUR-XPZ", Designation = "Courroie trapézoïdale XPZ 1250", QuantityInStock = 8, AlertThreshold = 3, Location = "Magasin A1", UnitPrice = 15.20m, IsActive = true }, + new() { Id = 4, Reference = "JNT-TOR-40", Designation = "Joint torique NBR 40x3", QuantityInStock = 2, AlertThreshold = 10, Location = "Magasin C2", UnitPrice = 1.10m, IsActive = true }, + ]; + + public static readonly List WorkOrders = + [ + new() + { + Id = 1, MachineId = 1, Type = WorkOrderType.Preventive, Status = WorkOrderStatus.ToDo, + Priority = WorkOrderPriority.High, ScheduledDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-3)), + AssignedTechnicianUserId = 3, Comment = "Vidange + contrôle pression", + CreatedAtUtc = DateTime.UtcNow.AddDays(-10), + }, + new() + { + Id = 2, MachineId = 2, Type = WorkOrderType.Corrective, Status = WorkOrderStatus.InProgress, + Priority = WorkOrderPriority.Critical, ScheduledDate = DateOnly.FromDateTime(DateTime.Today), + AssignedTechnicianUserId = 4, Comment = "Broche bloquée, intervention en cours", + CreatedAtUtc = DateTime.UtcNow.AddHours(-4), + }, + new() + { + Id = 3, MachineId = 3, Type = WorkOrderType.Preventive, Status = WorkOrderStatus.ToDo, + Priority = WorkOrderPriority.Normal, ScheduledDate = DateOnly.FromDateTime(DateTime.Today.AddDays(4)), + Comment = "Contrôle tension courroie", + CreatedAtUtc = DateTime.UtcNow.AddDays(-2), + }, + new() + { + Id = 4, MachineId = 4, Type = WorkOrderType.Preventive, Status = WorkOrderStatus.Done, + Priority = WorkOrderPriority.Normal, ScheduledDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-15)), + CompletedAtUtc = DateTime.UtcNow.AddDays(-14), AssignedTechnicianUserId = 3, + Comment = "Contrôle annuel organes de sécurité", CreatedAtUtc = DateTime.UtcNow.AddDays(-20), + }, + new() + { + Id = 5, MachineId = 5, Type = WorkOrderType.Preventive, Status = WorkOrderStatus.ToDo, + Priority = WorkOrderPriority.Low, ScheduledDate = DateOnly.FromDateTime(DateTime.Today.AddDays(10)), + Comment = "Essai mensuel démarrage automatique", CreatedAtUtc = DateTime.UtcNow.AddDays(-1), + }, + ]; + + public static readonly List Invoices = + [ + new() + { + Id = 1, DocumentType = InvoiceDocumentType.Invoice, Number = "FA-2026-014", + ClientName = "Client interne - Atelier A", IssueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-5)), + VatRate = 20m, Status = InvoiceStatus.Sent, TotalHT = 450.00m, TotalTTC = 540.00m, + CreatedByUserId = 1, CreatedAtUtc = DateTime.UtcNow.AddDays(-5), + }, + new() + { + Id = 2, DocumentType = InvoiceDocumentType.Quote, Number = "DV-2026-007", + ClientName = "Sous-traitance maintenance", IssueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-1)), + VatRate = 20m, Status = InvoiceStatus.Draft, TotalHT = 1200.00m, TotalTTC = 1440.00m, + CreatedByUserId = 2, CreatedAtUtc = DateTime.UtcNow.AddDays(-1), + }, + ]; +} diff --git a/src/App.UI/Services/MockRepositories.cs b/src/App.UI/Services/MockRepositories.cs new file mode 100644 index 0000000..4f02db8 --- /dev/null +++ b/src/App.UI/Services/MockRepositories.cs @@ -0,0 +1,78 @@ +using App.Core.Entities.Invoicing; +using App.Core.Entities.Inventory; +using App.Core.Entities.Machines; +using App.Core.Entities.Maintenance; +using App.Core.Entities.Team; +using App.Core.Interfaces.Repositories; + +namespace App_UI.Services; + +internal sealed class MockMachineRepository() + : InMemoryRepository(MockData.Machines, m => m.Id), IMachineRepository +{ + public Task> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default) + { + var query = Items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(searchText)) + { + query = query.Where(m => + m.Reference.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + m.Designation.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult(query.Skip(skip).Take(take).ToList()); + } + + public Task CountAsync(string? searchText, CancellationToken cancellationToken = default) => + Task.FromResult(Items.Count); +} + +internal sealed class MockWorkOrderRepository() + : InMemoryRepository(MockData.WorkOrders, w => w.Id), IWorkOrderRepository +{ + public Task ExistsForOccurrenceAsync(int maintenancePlanId, string occurrenceKey, CancellationToken cancellationToken = default) => + Task.FromResult(Items.Any(w => w.MaintenancePlanId == maintenancePlanId && w.GeneratedForOccurrenceKey == occurrenceKey)); + + public Task> GetScheduledBetweenAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) => + Task.FromResult(Items.Where(w => w.ScheduledDate >= from && w.ScheduledDate <= to).ToList()); +} + +internal sealed class MockPartRepository() + : InMemoryRepository(MockData.Parts, p => p.Id), IPartRepository +{ + public Task> GetBelowAlertThresholdAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Items.Where(p => p.QuantityInStock <= p.AlertThreshold).ToList()); + + public Task> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default) + { + var query = Items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(searchText)) + { + query = query.Where(p => + p.Reference.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + p.Designation.Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult(query.Skip(skip).Take(take).ToList()); + } +} + +internal sealed class MockUserRepository() + : InMemoryRepository(MockData.Users, u => u.Id), IUserRepository +{ + public Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) => + Task.FromResult(Items.FirstOrDefault(u => u.Username == username)); + + public Task> GetExpiringQualificationsAsync(int withinDays, CancellationToken cancellationToken = default) => + Task.FromResult(new List()); +} + +internal sealed class MockInvoiceRepository() + : InMemoryRepository(MockData.Invoices, i => i.Id), IInvoiceRepository +{ + public Task GenerateNextNumberAsync(InvoiceDocumentType documentType, CancellationToken cancellationToken = default) + { + var prefix = documentType == InvoiceDocumentType.Invoice ? "FA" : "DV"; + return Task.FromResult($"{prefix}-{DateTime.Now.Year}-{Items.Count + 1:000}"); + } +} diff --git a/src/App.UI/Services/ServiceRegistration.cs b/src/App.UI/Services/ServiceRegistration.cs new file mode 100644 index 0000000..a4f2097 --- /dev/null +++ b/src/App.UI/Services/ServiceRegistration.cs @@ -0,0 +1,35 @@ +using App.Core.Interfaces.Repositories; +using App_UI.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace App_UI.Services; + +/// +/// Racine de composition DI de l'application. Les repositories sont pour l'instant les +/// implémentations factices de ; les remplacer par les implémentations +/// EF Core d'App.Infrastructure (+ GmaoDbContext) est le seul changement nécessaire ici pour +/// brancher les vraies données — aucun ViewModel n'a à changer. +/// +internal static class ServiceRegistration +{ + public static IServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services.BuildServiceProvider(); + } +} diff --git a/src/App.UI/ViewModels/DashboardViewModel.cs b/src/App.UI/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..409e9af --- /dev/null +++ b/src/App.UI/ViewModels/DashboardViewModel.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using App.Core.Entities.Maintenance; +using App.Core.Interfaces.Repositories; +using App.Core.Services.Kpi; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record OverdueWorkOrderRow(int Id, string Machine, string Priority, DateOnly ScheduledDate, int DaysLate); + +/// +/// ViewModel du tableau de bord. Ne dépend que des interfaces App.Core (repositories, KPI) : +/// aujourd'hui branché sur les repositories factices (voir Services/MockData), demain sur les +/// implémentations EF Core, sans aucun changement ici. +/// +public sealed partial class DashboardViewModel : ObservableObject +{ + private readonly IWorkOrderRepository _workOrderRepository; + private readonly IMachineRepository _machineRepository; + private readonly IPartRepository _partRepository; + + [ObservableProperty] + private int _openWorkOrdersCount; + + [ObservableProperty] + private int _overdueWorkOrdersCount; + + [ObservableProperty] + private int _lowStockPartsCount; + + [ObservableProperty] + private string _preventiveComplianceLabel = "—"; + + public ObservableCollection OverdueWorkOrders { get; } = []; + + public DashboardViewModel(IWorkOrderRepository workOrderRepository, IMachineRepository machineRepository, IPartRepository partRepository) + { + _workOrderRepository = workOrderRepository; + _machineRepository = machineRepository; + _partRepository = partRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var workOrders = await _workOrderRepository.GetAllAsync(); + var machines = await _machineRepository.GetAllAsync(); + var lowStockParts = await _partRepository.GetBelowAlertThresholdAsync(); + var today = DateOnly.FromDateTime(DateTime.Today); + + OpenWorkOrdersCount = workOrders.Count(w => w.Status is WorkOrderStatus.ToDo or WorkOrderStatus.InProgress); + LowStockPartsCount = lowStockParts.Count; + + var overdue = workOrders + .Where(w => w.Status is WorkOrderStatus.ToDo or WorkOrderStatus.InProgress && w.ScheduledDate < today) + .OrderBy(w => w.ScheduledDate) + .Select(w => new OverdueWorkOrderRow( + w.Id, + machines.FirstOrDefault(m => m.Id == w.MachineId)?.Designation ?? $"Machine #{w.MachineId}", + w.Priority.ToString(), + w.ScheduledDate, + today.DayNumber - w.ScheduledDate.DayNumber)) + .ToList(); + + OverdueWorkOrdersCount = overdue.Count; + OverdueWorkOrders.Clear(); + foreach (var row in overdue) + { + OverdueWorkOrders.Add(row); + } + + var complianceRate = KpiCalculator.ComputePreventiveComplianceRatePercent(workOrders); + PreventiveComplianceLabel = complianceRate is null ? "N/A" : $"{complianceRate:0}%"; + } +} diff --git a/src/App.UI/ViewModels/InventoryViewModel.cs b/src/App.UI/ViewModels/InventoryViewModel.cs new file mode 100644 index 0000000..47ac3ed --- /dev/null +++ b/src/App.UI/ViewModels/InventoryViewModel.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record PartRow(int Id, string Reference, string Designation, double QuantityInStock, double AlertThreshold, bool IsLowStock, string Location); + +public sealed partial class InventoryViewModel : ObservableObject +{ + private readonly IPartRepository _partRepository; + + public ObservableCollection Parts { get; } = []; + + public InventoryViewModel(IPartRepository partRepository) + { + _partRepository = partRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var parts = await _partRepository.GetAllAsync(); + + Parts.Clear(); + foreach (var part in parts.OrderBy(p => p.Reference)) + { + Parts.Add(new PartRow(part.Id, part.Reference, part.Designation, part.QuantityInStock, part.AlertThreshold, part.QuantityInStock <= part.AlertThreshold, part.Location ?? "—")); + } + } +} diff --git a/src/App.UI/ViewModels/InvoicingViewModel.cs b/src/App.UI/ViewModels/InvoicingViewModel.cs new file mode 100644 index 0000000..e37045e --- /dev/null +++ b/src/App.UI/ViewModels/InvoicingViewModel.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record InvoiceRow(int Id, string Number, string DocumentType, string ClientName, DateOnly IssueDate, string Status, decimal TotalTTC); + +public sealed partial class InvoicingViewModel : ObservableObject +{ + private readonly IInvoiceRepository _invoiceRepository; + + public ObservableCollection Invoices { get; } = []; + + public InvoicingViewModel(IInvoiceRepository invoiceRepository) + { + _invoiceRepository = invoiceRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var invoices = await _invoiceRepository.GetAllAsync(); + + Invoices.Clear(); + foreach (var invoice in invoices.OrderByDescending(i => i.IssueDate)) + { + Invoices.Add(new InvoiceRow(invoice.Id, invoice.Number, invoice.DocumentType.ToString(), invoice.ClientName, invoice.IssueDate, invoice.Status.ToString(), invoice.TotalTTC)); + } + } +} diff --git a/src/App.UI/ViewModels/MachinesViewModel.cs b/src/App.UI/ViewModels/MachinesViewModel.cs new file mode 100644 index 0000000..9620251 --- /dev/null +++ b/src/App.UI/ViewModels/MachinesViewModel.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record MachineRow(int Id, string Reference, string Designation, string Location, string Criticality); + +public sealed partial class MachinesViewModel : ObservableObject +{ + private readonly IMachineRepository _machineRepository; + + public ObservableCollection Machines { get; } = []; + + public MachinesViewModel(IMachineRepository machineRepository) + { + _machineRepository = machineRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var machines = await _machineRepository.GetAllAsync(); + + Machines.Clear(); + foreach (var machine in machines.OrderBy(m => m.Reference)) + { + Machines.Add(new MachineRow(machine.Id, machine.Reference, machine.Designation, machine.Location ?? "—", machine.Criticality.ToString())); + } + } +} diff --git a/src/App.UI/ViewModels/MaintenanceCalendarViewModel.cs b/src/App.UI/ViewModels/MaintenanceCalendarViewModel.cs new file mode 100644 index 0000000..9672d16 --- /dev/null +++ b/src/App.UI/ViewModels/MaintenanceCalendarViewModel.cs @@ -0,0 +1,45 @@ +using System.Collections.ObjectModel; +using App.Core.Entities.Maintenance; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record UpcomingPreventiveRow(string Machine, DateOnly ScheduledDate, string Status, string Comment); + +/// +/// Liste simple des préventifs à venir/en retard, en attendant la vraie vue calendrier +/// (LiveCharts2/grille mensuelle) prévue dans le cahier des charges. +/// +public sealed partial class MaintenanceCalendarViewModel : ObservableObject +{ + private readonly IWorkOrderRepository _workOrderRepository; + private readonly IMachineRepository _machineRepository; + + public ObservableCollection UpcomingPreventives { get; } = []; + + public MaintenanceCalendarViewModel(IWorkOrderRepository workOrderRepository, IMachineRepository machineRepository) + { + _workOrderRepository = workOrderRepository; + _machineRepository = machineRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var workOrders = await _workOrderRepository.GetAllAsync(); + var machines = await _machineRepository.GetAllAsync(); + var today = DateOnly.FromDateTime(DateTime.Today); + + UpcomingPreventives.Clear(); + foreach (var wo in workOrders + .Where(w => w.Type == WorkOrderType.Preventive && w.Status != WorkOrderStatus.Done && w.Status != WorkOrderStatus.Cancelled) + .OrderBy(w => w.ScheduledDate)) + { + var machineLabel = machines.FirstOrDefault(m => m.Id == wo.MachineId)?.Designation ?? $"Machine #{wo.MachineId}"; + var status = wo.ScheduledDate < today ? "En retard" : "À venir"; + + UpcomingPreventives.Add(new UpcomingPreventiveRow(machineLabel, wo.ScheduledDate, status, wo.Comment ?? "—")); + } + } +} diff --git a/src/App.UI/ViewModels/TeamViewModel.cs b/src/App.UI/ViewModels/TeamViewModel.cs new file mode 100644 index 0000000..0fb82bf --- /dev/null +++ b/src/App.UI/ViewModels/TeamViewModel.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record UserRow(int Id, string FullName, string Username, string Role); + +public sealed partial class TeamViewModel : ObservableObject +{ + private readonly IUserRepository _userRepository; + + public ObservableCollection Users { get; } = []; + + public TeamViewModel(IUserRepository userRepository) + { + _userRepository = userRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var users = await _userRepository.GetAllAsync(); + + Users.Clear(); + foreach (var user in users.OrderBy(u => u.FullName)) + { + Users.Add(new UserRow(user.Id, user.FullName, user.Username, user.Role.ToString())); + } + } +} diff --git a/src/App.UI/ViewModels/WorkOrdersViewModel.cs b/src/App.UI/ViewModels/WorkOrdersViewModel.cs new file mode 100644 index 0000000..503b1e0 --- /dev/null +++ b/src/App.UI/ViewModels/WorkOrdersViewModel.cs @@ -0,0 +1,42 @@ +using System.Collections.ObjectModel; +using App.Core.Interfaces.Repositories; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace App_UI.ViewModels; + +public sealed record WorkOrderRow(int Id, string Machine, string Type, string Status, string Priority, DateOnly ScheduledDate, string Technician); + +public sealed partial class WorkOrdersViewModel : ObservableObject +{ + private readonly IWorkOrderRepository _workOrderRepository; + private readonly IMachineRepository _machineRepository; + private readonly IUserRepository _userRepository; + + public ObservableCollection WorkOrders { get; } = []; + + public WorkOrdersViewModel(IWorkOrderRepository workOrderRepository, IMachineRepository machineRepository, IUserRepository userRepository) + { + _workOrderRepository = workOrderRepository; + _machineRepository = machineRepository; + _userRepository = userRepository; + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + var workOrders = await _workOrderRepository.GetAllAsync(); + var machines = await _machineRepository.GetAllAsync(); + var users = await _userRepository.GetAllAsync(); + + WorkOrders.Clear(); + foreach (var wo in workOrders.OrderBy(w => w.ScheduledDate)) + { + var machineLabel = machines.FirstOrDefault(m => m.Id == wo.MachineId)?.Designation ?? $"Machine #{wo.MachineId}"; + var technicianLabel = wo.AssignedTechnicianUserId is { } techId + ? users.FirstOrDefault(u => u.Id == techId)?.FullName ?? "—" + : "Non affecté"; + + WorkOrders.Add(new WorkOrderRow(wo.Id, machineLabel, wo.Type.ToString(), wo.Status.ToString(), wo.Priority.ToString(), wo.ScheduledDate, technicianLabel)); + } + } +} diff --git a/src/App.UI/app.manifest b/src/App.UI/app.manifest new file mode 100644 index 0000000..f9437e7 --- /dev/null +++ b/src/App.UI/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/LicenseGenerator/KeyManagement/LicenseKeyPairGenerator.cs b/src/LicenseGenerator/KeyManagement/LicenseKeyPairGenerator.cs new file mode 100644 index 0000000..cf0f2ca --- /dev/null +++ b/src/LicenseGenerator/KeyManagement/LicenseKeyPairGenerator.cs @@ -0,0 +1,21 @@ +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace LicenseGenerator.KeyManagement; + +/// Génère une nouvelle paire de clés Ed25519 pour signer les licences. +public static class LicenseKeyPairGenerator +{ + public static (byte[] PrivateKey, byte[] PublicKey) Generate() + { + var random = new SecureRandom(); + var generator = new Ed25519KeyPairGenerator(); + generator.Init(new Ed25519KeyGenerationParameters(random)); + var keyPair = generator.GenerateKeyPair(); + + var privateKey = ((Ed25519PrivateKeyParameters)keyPair.Private).GetEncoded(); + var publicKey = ((Ed25519PublicKeyParameters)keyPair.Public).GetEncoded(); + return (privateKey, publicKey); + } +} diff --git a/src/LicenseGenerator/KeyManagement/PrivateKeyStore.cs b/src/LicenseGenerator/KeyManagement/PrivateKeyStore.cs new file mode 100644 index 0000000..c6e95e3 --- /dev/null +++ b/src/LicenseGenerator/KeyManagement/PrivateKeyStore.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; + +namespace LicenseGenerator.KeyManagement; + +/// +/// Persiste la clé privée Ed25519 chiffrée par DPAPI (scope utilisateur courant) dans le +/// profil de CE poste. Ne doit jamais être committée, copiée sur un autre poste, ni +/// partagée : quiconque y accède peut émettre des licences valides pour n'importe quel client. +/// +public static class PrivateKeyStore +{ + private static readonly string StorePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "GmaoLicenseGenerator", + "private.key.protected"); + + public static bool Exists() => File.Exists(StorePath); + + public static void Save(byte[] privateKeyBytes) + { + Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); + var protectedBytes = ProtectedData.Protect(privateKeyBytes, optionalEntropy: null, DataProtectionScope.CurrentUser); + File.WriteAllBytes(StorePath, protectedBytes); + } + + public static byte[] Load() + { + if (!Exists()) + { + throw new InvalidOperationException( + $"Aucune clé privée trouvée dans '{StorePath}'. Lance d'abord : licensegen keygen"); + } + + var protectedBytes = File.ReadAllBytes(StorePath); + return ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, DataProtectionScope.CurrentUser); + } +} diff --git a/src/LicenseGenerator/LicenseGenerator.csproj b/src/LicenseGenerator/LicenseGenerator.csproj new file mode 100644 index 0000000..362f98e --- /dev/null +++ b/src/LicenseGenerator/LicenseGenerator.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0-windows + LicenseGenerator + licensegen + true + + + + + + + + + + + + + diff --git a/src/LicenseGenerator/Program.cs b/src/LicenseGenerator/Program.cs new file mode 100644 index 0000000..4f6d6e5 --- /dev/null +++ b/src/LicenseGenerator/Program.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Text.Json; +using App.Core.Services.Licensing; +using LicenseGenerator.KeyManagement; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; + +if (args.Length == 0) +{ + PrintUsage(); + return 1; +} + +switch (args[0]) +{ + case "keygen": + return RunKeygen(args); + case "issue": + return RunIssue(args); + default: + PrintUsage(); + return 1; +} + +static int RunKeygen(string[] args) +{ + var force = args.Contains("--force"); + + if (PrivateKeyStore.Exists() && !force) + { + Console.Error.WriteLine( + "Une clé privée existe déjà. Toute nouvelle génération invaliderait TOUTES les licences déjà " + + "émises avec l'ancienne clé (la vérification côté client échouerait). Relance avec --force si " + + "tu es certain de vouloir régénérer (et republier une nouvelle version de l'app avec la nouvelle " + + "clé publique)."); + return 1; + } + + var (privateKey, publicKey) = LicenseKeyPairGenerator.Generate(); + PrivateKeyStore.Save(privateKey); + + var (f1, f2, f3, f4) = Ed25519PublicKeyProvider.EncodeFragments(publicKey); + + Console.WriteLine("Paire de clés Ed25519 générée. Clé privée sauvegardée (chiffrée DPAPI) sur ce poste."); + Console.WriteLine(); + Console.WriteLine("Colle ce qui suit dans src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs"); + Console.WriteLine("en remplacement des 4 champs Fragment1..4 (garde les masques Mask1..4 tels quels) :"); + Console.WriteLine(); + Console.WriteLine($" private static readonly byte[] Fragment1 = {{ {FormatBytes(f1)} }};"); + Console.WriteLine($" private static readonly byte[] Fragment2 = {{ {FormatBytes(f2)} }};"); + Console.WriteLine($" private static readonly byte[] Fragment3 = {{ {FormatBytes(f3)} }};"); + Console.WriteLine($" private static readonly byte[] Fragment4 = {{ {FormatBytes(f4)} }};"); + Console.WriteLine(); + Console.WriteLine("La clé publique complète (référence, ne pas committer ailleurs que dans les fragments ci-dessus) :"); + Console.WriteLine(Convert.ToBase64String(publicKey)); + + return 0; +} + +static int RunIssue(string[] args) +{ + var companyName = GetOption(args, "--company"); + var motherboardUuid = GetOption(args, "--motherboard-uuid"); + var diskSerial = GetOption(args, "--disk-serial"); + var maxUsersRaw = GetOption(args, "--max-users") ?? "5"; + var maxMachinesRaw = GetOption(args, "--max-machines") ?? "50"; + var outPath = GetOption(args, "--out"); + var expiryRaw = GetOption(args, "--expiry"); + + if (companyName is null || motherboardUuid is null || diskSerial is null || outPath is null) + { + Console.Error.WriteLine( + "Usage: licensegen issue --company \"Nom\" --motherboard-uuid --disk-serial " + + "--out chemin.lic [--max-users 5] [--max-machines 50] [--expiry yyyy-MM-dd]"); + return 1; + } + + var privateKeyBytes = PrivateKeyStore.Load(); + var privateKey = new Ed25519PrivateKeyParameters(privateKeyBytes, 0); + + var license = new LicenseFile( + CompanyName: companyName, + MotherboardUuid: motherboardUuid, + DiskSerial: diskSerial, + IssuedDate: DateOnly.FromDateTime(DateTime.UtcNow), + ExpiryDate: expiryRaw is null ? null : DateOnly.ParseExact(expiryRaw, "yyyy-MM-dd", CultureInfo.InvariantCulture), + MaxUsers: int.Parse(maxUsersRaw, CultureInfo.InvariantCulture), + MaxMachines: int.Parse(maxMachinesRaw, CultureInfo.InvariantCulture), + Signature: string.Empty); + + var canonicalBytes = LicenseFileCanonicalSerializer.GetCanonicalBytes(license); + var signer = new Ed25519Signer(); + signer.Init(forSigning: true, privateKey); + signer.BlockUpdate(canonicalBytes, 0, canonicalBytes.Length); + var signature = Convert.ToBase64String(signer.GenerateSignature()); + + var signedLicense = license with { Signature = signature }; + var json = JsonSerializer.Serialize(signedLicense, LicenseJsonOptions.Default); + File.WriteAllText(outPath, json); + + Console.WriteLine($"Licence émise pour '{companyName}' -> {outPath}"); + return 0; +} + +static string? GetOption(string[] args, string name) +{ + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; +} + +static string FormatBytes(byte[] bytes) => string.Join(", ", bytes.Select(b => $"0x{b:X2}")); + +static void PrintUsage() +{ + Console.WriteLine("licensegen keygen [--force]"); + Console.WriteLine("licensegen issue --company --motherboard-uuid --disk-serial --out [--max-users N] [--max-machines N] [--expiry yyyy-MM-dd]"); +} diff --git a/tests/App.Core.Tests/App.Core.Tests.csproj b/tests/App.Core.Tests/App.Core.Tests.csproj new file mode 100644 index 0000000..6b3e359 --- /dev/null +++ b/tests/App.Core.Tests/App.Core.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs b/tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs new file mode 100644 index 0000000..921f602 --- /dev/null +++ b/tests/App.Core.Tests/Kpi/KpiCalculatorTests.cs @@ -0,0 +1,146 @@ +using App.Core.Entities.Maintenance; +using App.Core.Services.Kpi; + +namespace App.Core.Tests.Kpi; + +public class KpiCalculatorTests +{ + [Fact] + public void ComputeMtbfHours_LessThanTwoFailures_ReturnsNull() + { + var timestamps = new List { new(2026, 1, 1) }; + + Assert.Null(KpiCalculator.ComputeMtbfHours(timestamps)); + Assert.Null(KpiCalculator.ComputeMtbfHours(new List())); + } + + [Fact] + public void ComputeMtbfHours_AveragesIntervalsBetweenFailures() + { + // Pannes le 1er, le 3 (48h après) et le 8 (120h après) -> moyenne (48+120)/2 = 84h. + var timestamps = new List + { + new(2026, 1, 1), + new(2026, 1, 3), + new(2026, 1, 8), + }; + + var mtbf = KpiCalculator.ComputeMtbfHours(timestamps); + + Assert.Equal(84, mtbf); + } + + [Fact] + public void ComputeMttrHours_NoCompletedCorrective_ReturnsNull() + { + var workOrders = new List + { + new() { Type = WorkOrderType.Corrective, Status = WorkOrderStatus.InProgress, CreatedAtUtc = DateTime.UtcNow, MachineId = 1, ScheduledDate = DateOnly.FromDateTime(DateTime.UtcNow) }, + }; + + Assert.Null(KpiCalculator.ComputeMttrHours(workOrders)); + } + + [Fact] + public void ComputeMttrHours_AveragesRepairDuration() + { + var created = new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc); + var workOrders = new List + { + new() + { + Type = WorkOrderType.Corrective, + Status = WorkOrderStatus.Done, + MachineId = 1, + ScheduledDate = DateOnly.FromDateTime(created), + CreatedAtUtc = created, + CompletedAtUtc = created.AddHours(4), + }, + new() + { + Type = WorkOrderType.Corrective, + Status = WorkOrderStatus.Done, + MachineId = 1, + ScheduledDate = DateOnly.FromDateTime(created), + CreatedAtUtc = created, + CompletedAtUtc = created.AddHours(8), + }, + }; + + Assert.Equal(6, KpiCalculator.ComputeMttrHours(workOrders)); + } + + [Fact] + public void ComputeAvailabilityPercent_NullInputs_ReturnsNull() + { + Assert.Null(KpiCalculator.ComputeAvailabilityPercent(null, 5)); + Assert.Null(KpiCalculator.ComputeAvailabilityPercent(5, null)); + } + + [Fact] + public void ComputeAvailabilityPercent_ComputesClassicFormula() + { + // MTBF=90, MTTR=10 -> 90/(90+10) = 90% + var availability = KpiCalculator.ComputeAvailabilityPercent(90, 10); + + Assert.Equal(90.0, availability); + } + + [Fact] + public void ComputeMaintenanceCost_SumsPartsAndLabor() + { + var parts = new List<(decimal UnitPrice, double Quantity)> + { + (10.00m, 3), // 30 + (5.50m, 2), // 11 + }; + + var cost = KpiCalculator.ComputeMaintenanceCost(parts, laborCost: 100m); + + Assert.Equal(141.00m, cost); + } + + [Fact] + public void ComputePreventiveComplianceRatePercent_NoClosedOrders_ReturnsNull() + { + var workOrders = new List + { + new() { Type = WorkOrderType.Preventive, Status = WorkOrderStatus.ToDo, MachineId = 1, ScheduledDate = new DateOnly(2026, 1, 1), CreatedAtUtc = DateTime.UtcNow }, + }; + + Assert.Null(KpiCalculator.ComputePreventiveComplianceRatePercent(workOrders)); + } + + [Fact] + public void ComputePreventiveComplianceRatePercent_CountsOnTimeVsLate() + { + var workOrders = new List + { + // Clôturé avant échéance -> dans les temps + new() + { + Type = WorkOrderType.Preventive, Status = WorkOrderStatus.Done, MachineId = 1, + ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow, + CompletedAtUtc = new DateTime(2026, 1, 9), + }, + // Clôturé après échéance -> en retard + new() + { + Type = WorkOrderType.Preventive, Status = WorkOrderStatus.Done, MachineId = 1, + ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow, + CompletedAtUtc = new DateTime(2026, 1, 15), + }, + // Curatif : ignoré du calcul + new() + { + Type = WorkOrderType.Corrective, Status = WorkOrderStatus.Done, MachineId = 1, + ScheduledDate = new DateOnly(2026, 1, 10), CreatedAtUtc = DateTime.UtcNow, + CompletedAtUtc = new DateTime(2026, 1, 20), + }, + }; + + var rate = KpiCalculator.ComputePreventiveComplianceRatePercent(workOrders); + + Assert.Equal(50.0, rate); + } +} diff --git a/tests/App.Core.Tests/Licensing/LicenseServiceTests.cs b/tests/App.Core.Tests/Licensing/LicenseServiceTests.cs new file mode 100644 index 0000000..ca126de --- /dev/null +++ b/tests/App.Core.Tests/Licensing/LicenseServiceTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using App.Core.Interfaces.Licensing; +using App.Core.Services.Licensing; + +namespace App.Core.Tests.Licensing; + +/// +/// Couvre uniquement le flux "essai" de LicenseService (pas de licence installée) : c'est la +/// partie testable sans dépendre de la vraie clé publique Ed25519, qui n'est pas encore générée +/// (voir Ed25519PublicKeyProvider). Le flux "licence installée" (ActivateLicense / licence déjà +/// présente) sera couvert une fois LicenseGenerator exécuté pour de vrai et la clé embarquée. +/// +public class LicenseServiceTests +{ + private sealed class FakeHardwareIdProvider(HardwareId hardwareId) : IHardwareIdProvider + { + public HardwareId GetCurrent() => hardwareId; + } + + private sealed class FakeTrialStateStore : ITrialStateStore + { + public TrialState? Stored { get; private set; } + + public TrialState? Load() => Stored; + + public void Save(TrialState state) => Stored = state; + } + + private sealed class FakeLicenseFileRepository : ILicenseFileRepository + { + public LicenseFile? Installed { get; set; } + + public LicenseFile? LoadInstalled() => Installed; + + public void Install(LicenseFile license) => Installed = license; + } + + private sealed class CorruptedLicenseFileRepository : ILicenseFileRepository + { + public LicenseFile? LoadInstalled() => throw new JsonException("fichier .lic corrompu (test)"); + + public void Install(LicenseFile license) => throw new NotSupportedException(); + } + + private static readonly HardwareId TestHardware = new("MB-TEST", "DISK-TEST"); + + private static LicenseService CreateService(FakeTrialStateStore trialStore, ILicenseFileRepository? licenseRepo = null) => + new(new FakeHardwareIdProvider(TestHardware), trialStore, licenseRepo ?? new FakeLicenseFileRepository()); + + [Fact] + public void CheckLicenseStatus_FirstLaunch_CreatesTrialAndReturnsFullDuration() + { + var trialStore = new FakeTrialStateStore(); + var service = CreateService(trialStore); + var now = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc); + + var result = service.CheckLicenseStatus(now); + + Assert.Equal(LicenseStatus.TrialActive, result.Status); + Assert.Equal(TrialState.TrialDurationDays, result.TrialDaysRemaining); + Assert.NotNull(trialStore.Stored); + Assert.Equal(now, trialStore.Stored!.InstallDateUtc); + Assert.Equal(TestHardware.ComputeHash(), trialStore.Stored.HardwareIdHash); + } + + [Fact] + public void CheckLicenseStatus_SubsequentLaunch_ReturnsRemainingDaysAndUpdatesLastRun() + { + var trialStore = new FakeTrialStateStore(); + var service = CreateService(trialStore); + var installDate = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc); + service.CheckLicenseStatus(installDate); + + var result = service.CheckLicenseStatus(installDate.AddDays(10)); + + Assert.Equal(LicenseStatus.TrialActive, result.Status); + Assert.Equal(20, result.TrialDaysRemaining); + Assert.Equal(installDate.AddDays(10), trialStore.Stored!.LastRunTimestampUtc); + Assert.Equal(installDate, trialStore.Stored.InstallDateUtc); // date d'installation jamais modifiée + } + + [Fact] + public void CheckLicenseStatus_After30Days_ReturnsTrialExpired() + { + var trialStore = new FakeTrialStateStore(); + var service = CreateService(trialStore); + var installDate = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc); + service.CheckLicenseStatus(installDate); + + var result = service.CheckLicenseStatus(installDate.AddDays(31)); + + Assert.Equal(LicenseStatus.TrialExpired, result.Status); + Assert.Equal(0, result.TrialDaysRemaining); + } + + [Fact] + public void CheckLicenseStatus_ClockRolledBack_ReturnsClockTamperDetected() + { + var trialStore = new FakeTrialStateStore(); + var service = CreateService(trialStore); + var installDate = new DateTime(2026, 1, 15, 9, 0, 0, DateTimeKind.Utc); + service.CheckLicenseStatus(installDate); + + // L'utilisateur recule l'horloge système de 5 jours pour tenter de prolonger l'essai. + var result = service.CheckLicenseStatus(installDate.AddDays(-5)); + + Assert.Equal(LicenseStatus.ClockTamperDetected, result.Status); + } + + [Fact] + public void GetCurrentHardwareId_DelegatesToHardwareIdProvider() + { + var service = CreateService(new FakeTrialStateStore()); + + Assert.Equal(TestHardware, service.GetCurrentHardwareId()); + } + + [Fact] + public void CheckLicenseStatus_CorruptedLicenseFile_ReturnsInvalidRatherThanFallingBackToTrial() + { + var trialStore = new FakeTrialStateStore(); + var service = CreateService(trialStore, new CorruptedLicenseFileRepository()); + + var result = service.CheckLicenseStatus(DateTime.UtcNow); + + Assert.Equal(LicenseStatus.InvalidSignature, result.Status); + // Ne doit surtout pas avoir créé un nouvel essai : corrompre le fichier ne doit jamais + // redonner 30 jours gratuits. + Assert.Null(trialStore.Stored); + } +} diff --git a/tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs b/tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs new file mode 100644 index 0000000..21e61ec --- /dev/null +++ b/tests/App.Core.Tests/Licensing/LicenseValidatorTests.cs @@ -0,0 +1,89 @@ +using App.Core.Services.Licensing; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; + +namespace App.Core.Tests.Licensing; + +public class LicenseValidatorTests +{ + /// + /// Génère une paire de clés de TEST (jamais la clé de production) et signe une licence + /// avec, exactement comme le fait LicenseGenerator. Permet de tester LicenseValidator sans + /// dépendre de la clé publique embarquée dans Ed25519PublicKeyProvider. + /// + private static (byte[] PublicKey, LicenseFile SignedLicense) CreateSignedLicense( + string companyName = "ACME Industries", + string motherboardUuid = "MB-1234", + string diskSerial = "DISK-5678", + int maxUsers = 5, + int maxMachines = 50) + { + var generator = new Ed25519KeyPairGenerator(); + generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom())); + var keyPair = generator.GenerateKeyPair(); + var privateKey = (Ed25519PrivateKeyParameters)keyPair.Private; + var publicKey = (Ed25519PublicKeyParameters)keyPair.Public; + + var unsigned = new LicenseFile( + companyName, motherboardUuid, diskSerial, + new DateOnly(2026, 1, 1), null, maxUsers, maxMachines, Signature: string.Empty); + + var canonicalBytes = LicenseFileCanonicalSerializer.GetCanonicalBytes(unsigned); + var signer = new Ed25519Signer(); + signer.Init(forSigning: true, privateKey); + signer.BlockUpdate(canonicalBytes, 0, canonicalBytes.Length); + var signature = Convert.ToBase64String(signer.GenerateSignature()); + + return (publicKey.GetEncoded(), unsigned with { Signature = signature }); + } + + [Fact] + public void VerifySignature_ValidLicense_ReturnsTrue() + { + var (publicKey, license) = CreateSignedLicense(); + + Assert.True(LicenseValidator.VerifySignature(license, publicKey)); + } + + [Fact] + public void VerifySignature_TamperedField_ReturnsFalse() + { + var (publicKey, license) = CreateSignedLicense(); + var tampered = license with { MaxUsers = 9999 }; + + Assert.False(LicenseValidator.VerifySignature(tampered, publicKey)); + } + + [Fact] + public void VerifySignature_WrongPublicKey_ReturnsFalse() + { + var (_, license) = CreateSignedLicense(); + var (otherPublicKey, _) = CreateSignedLicense(); + + Assert.False(LicenseValidator.VerifySignature(license, otherPublicKey)); + } + + [Fact] + public void VerifySignature_CorruptedBase64Signature_ReturnsFalseWithoutThrowing() + { + var (publicKey, license) = CreateSignedLicense(); + var corrupted = license with { Signature = "pas-du-base64-valide!!" }; + + Assert.False(LicenseValidator.VerifySignature(corrupted, publicKey)); + } + + [Theory] + [InlineData("MB-1234", "DISK-5678", true)] + [InlineData("mb-1234", "disk-5678", true)] // comparaison insensible à la casse + [InlineData("MB-9999", "DISK-5678", false)] + [InlineData("MB-1234", "DISK-0000", false)] + public void MatchesHardware_RequiresBothIdentifiersToMatch(string motherboard, string disk, bool expected) + { + var (_, license) = CreateSignedLicense(motherboardUuid: "MB-1234", diskSerial: "DISK-5678"); + var current = new HardwareId(motherboard, disk); + + Assert.Equal(expected, LicenseValidator.MatchesHardware(license, current)); + } +} diff --git a/tests/App.Core.Tests/Licensing/TrialStateTests.cs b/tests/App.Core.Tests/Licensing/TrialStateTests.cs new file mode 100644 index 0000000..bdac3cc --- /dev/null +++ b/tests/App.Core.Tests/Licensing/TrialStateTests.cs @@ -0,0 +1,55 @@ +using App.Core.Services.Licensing; + +namespace App.Core.Tests.Licensing; + +public class TrialStateTests +{ + [Fact] + public void GetDaysRemaining_OnInstallDay_Returns30() + { + var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var state = new TrialState(installDate, "hash", installDate); + + Assert.Equal(30, state.GetDaysRemaining(installDate)); + } + + [Fact] + public void GetDaysRemaining_AfterFifteenDays_Returns15() + { + var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var state = new TrialState(installDate, "hash", installDate); + + Assert.Equal(15, state.GetDaysRemaining(installDate.AddDays(15))); + } + + [Fact] + public void GetDaysRemaining_NeverGoesNegative() + { + var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var state = new TrialState(installDate, "hash", installDate); + + Assert.Equal(0, state.GetDaysRemaining(installDate.AddDays(90))); + } + + [Fact] + public void IsExpired_ExactlyOnDay30_IsTrue() + { + var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var state = new TrialState(installDate, "hash", installDate); + + Assert.True(state.IsExpired(installDate.AddDays(30))); + Assert.False(state.IsExpired(installDate.AddDays(29))); + } + + [Fact] + public void WithUpdatedLastRun_DoesNotChangeInstallDate() + { + var installDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var state = new TrialState(installDate, "hash", installDate); + + var updated = state.WithUpdatedLastRun(installDate.AddDays(5)); + + Assert.Equal(installDate, updated.InstallDateUtc); + Assert.Equal(installDate.AddDays(5), updated.LastRunTimestampUtc); + } +} diff --git a/tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs b/tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs new file mode 100644 index 0000000..1eead1e --- /dev/null +++ b/tests/App.Core.Tests/Maintenance/FrequencyCalculatorTests.cs @@ -0,0 +1,174 @@ +using App.Core.Entities.Maintenance; +using App.Core.Services.Maintenance; + +namespace App.Core.Tests.Maintenance; + +public class FrequencyCalculatorTests +{ + private static MaintenancePlan CreatePlan(FrequencyType type, DateOnly startDate) => new() + { + MachineId = 1, + Name = "Test", + FrequencyType = type, + StartDate = startDate, + }; + + [Fact] + public void ComputeNextDueDate_Daily_ReturnsAnchorPlusOneDay() + { + var plan = CreatePlan(FrequencyType.Daily, new DateOnly(2026, 3, 10)); + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2026, 3, 11), next); + } + + [Fact] + public void ComputeNextDueDate_UsesLastExecutionDate_WhenPlanAlreadyRanOnce() + { + var plan = CreatePlan(FrequencyType.Daily, new DateOnly(2026, 1, 1)); + plan.LastExecutionDateUtc = new DateOnly(2026, 3, 10); + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2026, 3, 11), next); + } + + [Fact] + public void ComputeNextDueDate_Weekly_ReturnsNextConfiguredWeekday() + { + // 2026-03-10 est un mardi. Gamme hebdomadaire calée sur le lundi (1). + var plan = CreatePlan(FrequencyType.Weekly, new DateOnly(2026, 3, 10)); + plan.DayOfWeek = (int)DayOfWeek.Monday; + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2026, 3, 16), next); // premier lundi après le mardi 10/03 + Assert.Equal(DayOfWeek.Monday, next.DayOfWeek); + } + + [Fact] + public void ComputeNextDueDate_Weekly_MissingDayOfWeek_Throws() + { + var plan = CreatePlan(FrequencyType.Weekly, new DateOnly(2026, 3, 10)); + + Assert.Throws(() => FrequencyCalculator.ComputeNextDueDate(plan)); + } + + [Fact] + public void ComputeNextDueDate_Monthly_ClampsToLastDayOfShorterMonth() + { + // Ancre en janvier, jour du mois = 31 : février n'a pas 31 jours -> clampé au 28/29. + var plan = CreatePlan(FrequencyType.Monthly, new DateOnly(2026, 1, 15)); + plan.DayOfMonth = 31; + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2026, 2, 28), next); // 2026 n'est pas bissextile + } + + [Fact] + public void ComputeNextDueDate_MonthlyRule_FirstMonday() + { + // Ancre en janvier -> cible février 2024. Le 1er lundi de février 2024 est le 05/02. + var plan = CreatePlan(FrequencyType.MonthlyRule, new DateOnly(2024, 1, 10)); + plan.MonthlyRuleOccurrence = 1; + plan.MonthlyRuleWeekday = (int)DayOfWeek.Monday; + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2024, 2, 5), next); + } + + [Fact] + public void ComputeNextDueDate_MonthlyRule_LastFriday() + { + // Cible février 2024 (bissextile). Le dernier vendredi de février 2024 est le 23/02. + var plan = CreatePlan(FrequencyType.MonthlyRule, new DateOnly(2024, 1, 10)); + plan.MonthlyRuleOccurrence = -1; + plan.MonthlyRuleWeekday = (int)DayOfWeek.Friday; + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2024, 2, 23), next); + } + + [Theory] + [InlineData(FrequencyType.Bimonthly, 2)] + [InlineData(FrequencyType.Quarterly, 3)] + [InlineData(FrequencyType.Semiannual, 6)] + [InlineData(FrequencyType.Annual, 12)] + public void ComputeNextDueDate_FixedMonthIntervals_AddsExpectedMonths(FrequencyType type, int months) + { + var anchor = new DateOnly(2026, 3, 10); + var plan = CreatePlan(type, anchor); + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(anchor.AddMonths(months), next); + } + + [Fact] + public void ComputeNextDueDate_CustomDays_AddsConfiguredInterval() + { + var plan = CreatePlan(FrequencyType.CustomDays, new DateOnly(2026, 3, 10)); + plan.CustomIntervalDays = 45; + + var next = FrequencyCalculator.ComputeNextDueDate(plan); + + Assert.Equal(new DateOnly(2026, 4, 24), next); + } + + [Fact] + public void ComputeNextDueDate_CounterBased_ThrowsNotSupported() + { + var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10)); + + Assert.Throws(() => FrequencyCalculator.ComputeNextDueDate(plan)); + } + + [Fact] + public void ComputeNextCounterThreshold_NoPriorExecution_StartsFromZero() + { + var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10)); + plan.CounterThreshold = 500; + + var threshold = FrequencyCalculator.ComputeNextCounterThreshold(plan); + + Assert.Equal(500, threshold); + } + + [Fact] + public void ComputeNextCounterThreshold_AddsToLastExecutionValue() + { + var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10)); + plan.CounterThreshold = 500; + plan.LastExecutionCounterValue = 1200; + + var threshold = FrequencyCalculator.ComputeNextCounterThreshold(plan); + + Assert.Equal(1700, threshold); + } + + [Fact] + public void IsCounterWithinLeadWindow_TrueOncePercentReached() + { + var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10)); + plan.CounterThreshold = 100; + plan.LastExecutionCounterValue = 1000; + plan.LeadTimePercentForCounter = 90; + + Assert.False(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 1085, defaultLeadTimePercent: 80)); + Assert.True(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 1090, defaultLeadTimePercent: 80)); + } + + [Fact] + public void IsCounterWithinLeadWindow_UsesDefaultPercent_WhenPlanDoesNotOverride() + { + var plan = CreatePlan(FrequencyType.CounterBased, new DateOnly(2026, 3, 10)); + plan.CounterThreshold = 100; + + Assert.True(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 81, defaultLeadTimePercent: 80)); + Assert.False(FrequencyCalculator.IsCounterWithinLeadWindow(plan, currentCounterValue: 79, defaultLeadTimePercent: 80)); + } +}