first commit
23
.editorconfig
Normal file
@@ -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
|
||||
25
.gitignore
vendored
Normal file
@@ -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
|
||||
12
Directory.Build.props
Normal file
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<Authors>Pablo Larade</Authors>
|
||||
<Product>GMAO</Product>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
101
GMAO.sln
Normal file
@@ -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
|
||||
110
README.md
Normal file
@@ -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
|
||||
34
docs/ARCHITECTURE.md
Normal file
@@ -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<TEntity, TKey>` (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.
|
||||
85
docs/DATABASE_SCHEMA.md
Normal file
@@ -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`).
|
||||
16
src/App.Core/App.Core.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>App.Core</RootNamespace>
|
||||
<AssemblyName>App.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- .NET 8 n'a pas de support natif Ed25519 dans System.Security.Cryptography. BouncyCastle
|
||||
est managé pur (pas de DLL native) : compatible sandbox MSIX/Store, contrairement à
|
||||
NSec/libsodium. Version a revalider via 'dotnet list package outdated' apres restore. -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
17
src/App.Core/Dtos/Sync/MachineDto.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using App.Core.Entities.Machines;
|
||||
|
||||
namespace App.Core.Dtos.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record MachineDto(
|
||||
int Id,
|
||||
string Reference,
|
||||
string Designation,
|
||||
string? Location,
|
||||
MachineCriticality Criticality,
|
||||
DateOnly? CommissioningDate,
|
||||
bool IsActive);
|
||||
24
src/App.Core/Dtos/Sync/MaintenancePlanDto.cs
Normal file
@@ -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<string> Tools,
|
||||
List<MaintenanceTaskPartDto> RequiredParts);
|
||||
|
||||
/// <summary>Gamme telle que transmise à l'app Android pour affichage de la checklist d'exécution.</summary>
|
||||
public sealed record MaintenancePlanDto(
|
||||
int Id,
|
||||
int MachineId,
|
||||
string Name,
|
||||
string? Description,
|
||||
FrequencyType FrequencyType,
|
||||
int? RequiredQualificationTypeId,
|
||||
List<MaintenanceTaskDto> Tasks);
|
||||
6
src/App.Core/Dtos/Sync/PairingDto.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace App.Core.Dtos.Sync;
|
||||
|
||||
/// <summary>Demande d'appairage envoyée par l'app Android après saisie du PIN affiché sur le PC.</summary>
|
||||
public sealed record PairingRequestDto(string DeviceName, string DeviceIdentifier, string Pin);
|
||||
|
||||
public sealed record PairingResponseDto(bool Success, string? SyncToken, string? ErrorMessage);
|
||||
8
src/App.Core/Dtos/Sync/SyncPullResponseDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace App.Core.Dtos.Sync;
|
||||
|
||||
/// <summary>Réponse du PC à l'app Android : gammes/OT à jour à télécharger (sens "pull").</summary>
|
||||
public sealed record SyncPullResponseDto(
|
||||
List<MachineDto> Machines,
|
||||
List<MaintenancePlanDto> MaintenancePlans,
|
||||
List<WorkOrderDto> WorkOrders,
|
||||
DateTime ServerTimeUtc);
|
||||
24
src/App.Core/Dtos/Sync/SyncPushRequestDto.cs
Normal file
@@ -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);
|
||||
|
||||
/// <summary>Payload envoyé par l'app Android au PC lors d'une synchronisation (sens "push").</summary>
|
||||
public sealed record SyncPushRequestDto(
|
||||
string DeviceIdentifier,
|
||||
string SyncToken,
|
||||
List<WorkOrderCompletionPushDto> CompletedWorkOrders,
|
||||
List<TimeEntryPushDto> TimeEntries,
|
||||
List<MachineCounterReadingPushDto> CounterReadings);
|
||||
|
||||
public sealed record SyncPushResultDto(bool Success, int WorkOrdersAccepted, List<string> Errors);
|
||||
35
src/App.Core/Dtos/Sync/WorkOrderDto.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Dtos.Sync;
|
||||
|
||||
/// <summary>OT transmis à l'app Android (sens "pull") pour exécution sur le terrain.</summary>
|
||||
public sealed record WorkOrderDto(
|
||||
int Id,
|
||||
int MachineId,
|
||||
int? MaintenancePlanId,
|
||||
WorkOrderType Type,
|
||||
WorkOrderStatus Status,
|
||||
WorkOrderPriority Priority,
|
||||
DateOnly ScheduledDate,
|
||||
DateOnly? DueDate,
|
||||
int? AssignedTechnicianUserId,
|
||||
string? Comment);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="PhotoUploadId"/> (id temporaire côté
|
||||
/// client, résolu côté serveur après l'upload du binaire) — à affiner avec l'app Android réelle.
|
||||
/// </summary>
|
||||
public sealed record WorkOrderTaskResultPushDto(
|
||||
int? MaintenanceTaskId,
|
||||
bool IsCompleted,
|
||||
string? Comment,
|
||||
string? PhotoUploadId);
|
||||
|
||||
public sealed record WorkOrderCompletionPushDto(
|
||||
int WorkOrderId,
|
||||
DateTime CompletedAtUtc,
|
||||
List<WorkOrderTaskResultPushDto> TaskResults,
|
||||
double? CounterValueAtCompletion);
|
||||
15
src/App.Core/Entities/Inventory/MachinePart.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using App.Core.Entities.Machines;
|
||||
|
||||
namespace App.Core.Entities.Inventory;
|
||||
|
||||
/// <summary>Association nomenclature : pièces habituellement utilisées sur une machine.</summary>
|
||||
public class MachinePart
|
||||
{
|
||||
public int MachineId { get; set; }
|
||||
|
||||
public Machine? Machine { get; set; }
|
||||
|
||||
public int PartId { get; set; }
|
||||
|
||||
public Part? Part { get; set; }
|
||||
}
|
||||
31
src/App.Core/Entities/Inventory/Part.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace App.Core.Entities.Inventory;
|
||||
|
||||
/// <summary>Pièce détachée du magasin.</summary>
|
||||
public class Part
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public required string Reference { get; set; }
|
||||
|
||||
public required string Designation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quantité en stock, dénormalisée pour un accès rapide côté UI/dashboard.
|
||||
/// Source de vérité recalculable à tout moment depuis <see cref="StockMovement"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
31
src/App.Core/Entities/Inventory/StockMovement.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace App.Core.Entities.Inventory;
|
||||
|
||||
public enum StockMovementType
|
||||
{
|
||||
In = 0,
|
||||
Out = 1,
|
||||
}
|
||||
|
||||
/// <summary>Mouvement d'entrée ou de sortie de stock, tracé et éventuellement lié à une intervention.</summary>
|
||||
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; }
|
||||
}
|
||||
17
src/App.Core/Entities/Inventory/Supplier.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace App.Core.Entities.Inventory;
|
||||
|
||||
/// <summary>Fournisseur, utilisé aussi bien pour le magasin de pièces que pour les factures.</summary>
|
||||
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; }
|
||||
}
|
||||
69
src/App.Core/Entities/Invoicing/Invoice.cs
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>Facture ou devis interne généré (PDF via QuestPDF).</summary>
|
||||
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<InvoiceLine> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Ligne de facture/devis, support du copier-coller façon tableur côté UI.</summary>
|
||||
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; }
|
||||
}
|
||||
21
src/App.Core/Entities/Invoicing/SupplierInvoice.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace App.Core.Entities.Invoicing;
|
||||
|
||||
/// <summary>Facture fournisseur importée par drag & drop (PDF), classée et éventuellement associée à un OT.</summary>
|
||||
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; }
|
||||
}
|
||||
20
src/App.Core/Entities/Licensing/LicenseActivationCache.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace App.Core.Entities.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>docs/DATABASE_SCHEMA.md</c> §2.9).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
39
src/App.Core/Entities/Machines/Machine.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using App.Core.Entities.Inventory;
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Entities.Machines;
|
||||
|
||||
/// <summary>
|
||||
/// Équipement industriel soumis à maintenance préventive et/ou curative.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Désactivation logique (soft delete) : une machine liée à un historique n'est jamais supprimée physiquement.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
|
||||
public List<MaintenancePlan> MaintenancePlans { get; set; } = [];
|
||||
|
||||
public List<WorkOrder> WorkOrders { get; set; } = [];
|
||||
|
||||
public List<MachinePart> MachineParts { get; set; } = [];
|
||||
|
||||
public List<MachineCounterReading> CounterReadings { get; set; } = [];
|
||||
}
|
||||
29
src/App.Core/Entities/Machines/MachineCounterReading.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace App.Core.Entities.Machines;
|
||||
|
||||
public enum CounterReadingSource
|
||||
{
|
||||
Pc = 0,
|
||||
AndroidSync = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class MachineCounterReading
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int MachineId { get; set; }
|
||||
|
||||
public Machine? Machine { get; set; }
|
||||
|
||||
/// <summary>Libellé libre du type de compteur (doit correspondre à <c>MaintenancePlan.CounterType</c>).</summary>
|
||||
public required string CounterType { get; set; }
|
||||
|
||||
public double Value { get; set; }
|
||||
|
||||
public DateTime RecordedAtUtc { get; set; }
|
||||
|
||||
public CounterReadingSource Source { get; set; }
|
||||
}
|
||||
9
src/App.Core/Entities/Machines/MachineCriticality.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace App.Core.Entities.Machines;
|
||||
|
||||
public enum MachineCriticality
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
Critical = 3,
|
||||
}
|
||||
26
src/App.Core/Entities/Maintenance/FrequencyType.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace App.Core.Entities.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Type de fréquence d'une gamme de maintenance préventive. Détermine quels champs
|
||||
/// de <see cref="MaintenancePlan"/> sont utilisés par <c>FrequencyCalculator</c>
|
||||
/// pour calculer la prochaine échéance.
|
||||
/// </summary>
|
||||
public enum FrequencyType
|
||||
{
|
||||
Daily = 0,
|
||||
Weekly = 1,
|
||||
Monthly = 2,
|
||||
|
||||
/// <summary>Règle récurrente type "premier lundi du mois" (voir <see cref="MaintenancePlan.MonthlyRuleOccurrence"/>).</summary>
|
||||
MonthlyRule = 3,
|
||||
Bimonthly = 4,
|
||||
Quarterly = 5,
|
||||
Semiannual = 6,
|
||||
Annual = 7,
|
||||
|
||||
/// <summary>Déclenchement sur seuil de compteur d'usage plutôt que sur date calendaire.</summary>
|
||||
CounterBased = 8,
|
||||
|
||||
/// <summary>Fréquence personnalisée exprimée en nombre de jours.</summary>
|
||||
CustomDays = 9,
|
||||
}
|
||||
76
src/App.Core/Entities/Maintenance/MaintenancePlan.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using App.Core.Entities.Machines;
|
||||
using App.Core.Entities.Team;
|
||||
|
||||
namespace App.Core.Entities.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>FrequencyCalculator</c> à partir de
|
||||
/// <see cref="LastExecutionDateUtc"/> / <see cref="LastExecutionCounterValue"/>,
|
||||
/// ce qui absorbe naturellement les préventifs faits en avance ou en retard.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Date d'ancrage du calcul tant qu'aucune exécution réelle n'a encore eu lieu
|
||||
/// (<see cref="LastExecutionDateUtc"/> est null). Une fois le premier OT clôturé,
|
||||
/// c'est <see cref="LastExecutionDateUtc"/> qui prend le relais.
|
||||
/// </summary>
|
||||
public DateOnly StartDate { get; set; }
|
||||
|
||||
/// <summary>Nombre de jours entre deux occurrences, utilisé uniquement pour <see cref="FrequencyType.CustomDays"/>.</summary>
|
||||
public int? CustomIntervalDays { get; set; }
|
||||
|
||||
/// <summary>Jour de la semaine (0=dimanche..6=samedi), utilisé pour <see cref="FrequencyType.Weekly"/>.</summary>
|
||||
public int? DayOfWeek { get; set; }
|
||||
|
||||
/// <summary>Jour du mois (1-31), utilisé pour <see cref="FrequencyType.Monthly"/>.</summary>
|
||||
public int? DayOfMonth { get; set; }
|
||||
|
||||
/// <summary>Occurrence dans le mois pour <see cref="FrequencyType.MonthlyRule"/> : 1=premier, 2=deuxième, -1=dernier.</summary>
|
||||
public int? MonthlyRuleOccurrence { get; set; }
|
||||
|
||||
/// <summary>Jour de la semaine visé par la règle mensuelle (0=dimanche..6=samedi).</summary>
|
||||
public int? MonthlyRuleWeekday { get; set; }
|
||||
|
||||
/// <summary>Type de compteur suivi (doit correspondre à <see cref="MachineCounterReading.CounterType"/>), pour <see cref="FrequencyType.CounterBased"/>.</summary>
|
||||
public string? CounterType { get; set; }
|
||||
|
||||
/// <summary>Seuil de déclenchement du compteur.</summary>
|
||||
public double? CounterThreshold { get; set; }
|
||||
|
||||
/// <summary>Préavis en jours avant l'échéance calendaire ; si null, la valeur globale des Paramètres s'applique.</summary>
|
||||
public int? LeadTimeDays { get; set; }
|
||||
|
||||
/// <summary>Préavis exprimé en pourcentage du seuil restant pour les gammes à compteur (ex: 90 = dès 90% atteint).</summary>
|
||||
public double? LeadTimePercentForCounter { get; set; }
|
||||
|
||||
public int? RequiredQualificationTypeId { get; set; }
|
||||
|
||||
/// <summary>Date de la dernière exécution réelle (clôture d'OT), base du calcul de la prochaine échéance.</summary>
|
||||
public DateOnly? LastExecutionDateUtc { get; set; }
|
||||
|
||||
/// <summary>Valeur du compteur à la dernière exécution réelle, pour les gammes à compteur.</summary>
|
||||
public double? LastExecutionCounterValue { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
public List<MaintenanceTask> Tasks { get; set; } = [];
|
||||
|
||||
public List<WorkOrder> WorkOrders { get; set; } = [];
|
||||
}
|
||||
45
src/App.Core/Entities/Maintenance/MaintenanceTask.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using App.Core.Entities.Inventory;
|
||||
|
||||
namespace App.Core.Entities.Maintenance;
|
||||
|
||||
/// <summary>Tâche unitaire ordonnée au sein d'une gamme de maintenance.</summary>
|
||||
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<Tool> Tools { get; set; } = [];
|
||||
|
||||
public List<MaintenanceTaskPart> RequiredParts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Référentiel d'outils réutilisables entre gammes (autocomplétion, cohérence des libellés).</summary>
|
||||
public class Tool
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Pièce nécessaire pour réaliser une tâche de gamme, avec la quantité requise.</summary>
|
||||
public class MaintenanceTaskPart
|
||||
{
|
||||
public int MaintenanceTaskId { get; set; }
|
||||
|
||||
public int PartId { get; set; }
|
||||
|
||||
public Part? Part { get; set; }
|
||||
|
||||
public double QuantityRequired { get; set; }
|
||||
}
|
||||
84
src/App.Core/Entities/Maintenance/WorkOrder.cs
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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é
|
||||
/// (<see cref="Status"/> != Done/Cancelled) ; le retard se déduit de
|
||||
/// <see cref="ScheduledDate"/> < aujourd'hui, sans champ dédié.
|
||||
/// </summary>
|
||||
public class WorkOrder
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int MachineId { get; set; }
|
||||
|
||||
public Machine? Machine { get; set; }
|
||||
|
||||
/// <summary>Null si l'OT est curatif (pas issu d'une gamme).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'occurrence unique par gamme (date ISO pour une échéance calendaire, valeur de seuil
|
||||
/// pour une échéance à compteur). Combinée à <see cref="MaintenancePlanId"/> dans un index unique
|
||||
/// filtré, elle empêche le moteur de planification de générer deux fois le même OT préventif.
|
||||
/// </summary>
|
||||
public string? GeneratedForOccurrenceKey { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
|
||||
public WorkOrderSource Source { get; set; }
|
||||
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
public List<WorkOrderTaskResult> TaskResults { get; set; } = [];
|
||||
|
||||
public List<WorkOrderPartUsed> PartsUsed { get; set; } = [];
|
||||
}
|
||||
41
src/App.Core/Entities/Maintenance/WorkOrderTaskResult.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace App.Core.Entities.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'exécution d'une tâche de gamme pour un OT donné (coché par le technicien,
|
||||
/// éventuellement côté Android). Null <see cref="MaintenanceTaskId"/> = tâche ad-hoc
|
||||
/// ajoutée pour un OT curatif, sans lien avec une gamme.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Pièce effectivement consommée pour réaliser un OT, tracée jusqu'au mouvement de stock.</summary>
|
||||
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; }
|
||||
}
|
||||
25
src/App.Core/Entities/Shared/AppSetting.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace App.Core.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 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é <c>App.Core</c>,
|
||||
/// volontairement pas en base : évite une table à colonnes fixes qui devrait être migrée
|
||||
/// à chaque nouveau réglage.
|
||||
/// </summary>
|
||||
public class AppSetting
|
||||
{
|
||||
public required string Key { get; set; }
|
||||
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Clés connues de <see cref="AppSetting"/>, centralisées pour éviter les chaînes magiques éparpillées.</summary>
|
||||
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";
|
||||
}
|
||||
49
src/App.Core/Entities/Shared/Attachment.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace App.Core.Entities.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Type d'entité propriétaire d'une pièce jointe. Utilisé comme discriminant
|
||||
/// de la clé polymorphe (<see cref="Attachment.EntityType"/>, <see cref="Attachment.EntityId"/>).
|
||||
/// </summary>
|
||||
public enum AttachmentOwnerType
|
||||
{
|
||||
Machine = 0,
|
||||
TechnicianQualification = 1,
|
||||
WorkOrderTaskResult = 2,
|
||||
SupplierInvoice = 3,
|
||||
Invoice = 4,
|
||||
Part = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fichier importé par l'utilisateur (photo, PDF, documentation technique) et stocké
|
||||
/// physiquement sous <c>ApplicationData.Current.LocalFolder/Attachments</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le lien vers son entité propriétaire (<see cref="EntityType"/> + <see cref="EntityId"/>) 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 <c>IAttachmentRepository</c>, pas par le SGBD.
|
||||
/// C'est un choix délibéré pour éviter de dupliquer une table "XxxAttachments" par entité.
|
||||
/// </remarks>
|
||||
public class Attachment
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Nom de fichier original tel qu'importé par l'utilisateur.</summary>
|
||||
public required string FileName { get; set; }
|
||||
|
||||
/// <summary>Type MIME (ex: "application/pdf", "image/jpeg").</summary>
|
||||
public required string ContentType { get; set; }
|
||||
|
||||
/// <summary>Chemin relatif au dossier sandboxé de l'application (jamais un chemin absolu).</summary>
|
||||
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; }
|
||||
}
|
||||
51
src/App.Core/Entities/Sync/SyncDevice.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
namespace App.Core.Entities.Sync;
|
||||
|
||||
/// <summary>Appareil Android appairé avec le PC après validation du PIN affiché à l'écran.</summary>
|
||||
public class SyncDevice
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public required string DeviceName { get; set; }
|
||||
|
||||
/// <summary>Identifiant stable de l'appareil Android (ex: Android ID), unique.</summary>
|
||||
public required string DeviceIdentifier { get; set; }
|
||||
|
||||
public DateTime PairedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Hash du PIN utilisé lors de l'appairage (jamais le PIN en clair).</summary>
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>Journal technique des échanges de synchronisation, utile au diagnostic.</summary>
|
||||
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; }
|
||||
}
|
||||
24
src/App.Core/Entities/Team/QualificationType.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
public enum QualificationCategory
|
||||
{
|
||||
Electrique = 0,
|
||||
Mecanique = 1,
|
||||
Autre = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
18
src/App.Core/Entities/Team/TechnicianProfile.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
/// <summary>
|
||||
/// Informations complémentaires propres à un technicien de terrain, en extension
|
||||
/// de <see cref="User"/> (relation 1:1, clé partagée sur <see cref="UserId"/>).
|
||||
/// </summary>
|
||||
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<TechnicianQualification> Qualifications { get; set; } = [];
|
||||
}
|
||||
26
src/App.Core/Entities/Team/TechnicianQualification.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
/// <summary>
|
||||
/// 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é.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Null si l'habilitation n'expire jamais.</summary>
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
|
||||
public int? CertificateAttachmentId { get; set; }
|
||||
}
|
||||
36
src/App.Core/Entities/Team/TimeEntry.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
public enum TimeEntrySource
|
||||
{
|
||||
Pc = 0,
|
||||
AndroidSync = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
28
src/App.Core/Entities/Team/User.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
/// <summary>
|
||||
/// Compte utilisateur local (authentification par mot de passe haché, Argon2id).
|
||||
/// </summary>
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public required string Username { get; set; }
|
||||
|
||||
/// <summary>Hash Argon2id du mot de passe. Jamais le mot de passe en clair.</summary>
|
||||
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; }
|
||||
}
|
||||
13
src/App.Core/Entities/Team/UserRole.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace App.Core.Entities.Team;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum UserRole
|
||||
{
|
||||
Admin = 0,
|
||||
ResponsableMaintenance = 1,
|
||||
Technicien = 2,
|
||||
}
|
||||
10
src/App.Core/Interfaces/IPdfGenerator.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using App.Core.Entities.Invoicing;
|
||||
|
||||
namespace App.Core.Interfaces;
|
||||
|
||||
/// <summary>Génération de PDF pour les factures/devis internes. Implémenté via QuestPDF côté App.Infrastructure.</summary>
|
||||
public interface IPdfGenerator
|
||||
{
|
||||
/// <summary>Génère le PDF d'une facture ou d'un devis et retourne son contenu binaire.</summary>
|
||||
byte[] GenerateInvoicePdf(Invoice invoice);
|
||||
}
|
||||
11
src/App.Core/Interfaces/ISettingsProvider.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace App.Core.Interfaces;
|
||||
|
||||
/// <summary>Accès typé aux paramètres globaux stockés en clé-valeur (voir <c>AppSetting</c>).</summary>
|
||||
public interface ISettingsProvider
|
||||
{
|
||||
/// <summary>Préavis par défaut (en jours) avant l'échéance calendaire d'une gamme, si celle-ci ne le surcharge pas.</summary>
|
||||
Task<int> GetDefaultLeadTimeDaysAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Préavis par défaut (en % du seuil) pour les gammes à compteur, si celles-ci ne le surchargent pas.</summary>
|
||||
Task<double> GetDefaultLeadTimePercentForCounterAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
7
src/App.Core/Interfaces/IUnitOfWork.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace App.Core.Interfaces;
|
||||
|
||||
/// <summary>Persiste en une transaction les changements effectués via les repositories. Implémenté par <c>GmaoDbContext</c>.</summary>
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
9
src/App.Core/Interfaces/Licensing/IHardwareIdProvider.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Interfaces.Licensing;
|
||||
|
||||
/// <summary>Fournit l'identifiant matériel du poste. Implémenté côté App.Infrastructure via WMI.</summary>
|
||||
public interface IHardwareIdProvider
|
||||
{
|
||||
HardwareId GetCurrent();
|
||||
}
|
||||
16
src/App.Core/Interfaces/Licensing/ILicenseFileRepository.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Interfaces.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Lit/écrit le fichier de licence activée (.lic) dans le dossier sandboxé de l'application.
|
||||
/// Implémenté côté App.Infrastructure.
|
||||
/// </summary>
|
||||
public interface ILicenseFileRepository
|
||||
{
|
||||
/// <summary>Charge la licence actuellement installée, ou null si aucune n'a été importée.</summary>
|
||||
LicenseFile? LoadInstalled();
|
||||
|
||||
/// <summary>Copie et enregistre le fichier .lic importé par l'utilisateur comme licence active.</summary>
|
||||
void Install(LicenseFile license);
|
||||
}
|
||||
23
src/App.Core/Interfaces/Licensing/ILicenseService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Interfaces.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public interface ILicenseService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
LicenseCheckResult CheckLicenseStatus(DateTime nowUtc);
|
||||
|
||||
/// <summary>Installe une licence fournie par l'utilisateur (import du fichier .lic) et la revérifie immédiatement.</summary>
|
||||
LicenseCheckResult ActivateLicense(LicenseFile license);
|
||||
|
||||
/// <summary>Identifiant matériel du poste courant, affiché sur l'écran d'activation.</summary>
|
||||
HardwareId GetCurrentHardwareId();
|
||||
}
|
||||
14
src/App.Core/Interfaces/Licensing/ITrialStateStore.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Core.Interfaces.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Persiste l'état de l'essai gratuit hors base SQLite. Implémenté côté App.Infrastructure
|
||||
/// via DPAPI (<c>ProtectedData.Protect</c>) dans un fichier du dossier sandboxé MSIX.
|
||||
/// </summary>
|
||||
public interface ITrialStateStore
|
||||
{
|
||||
TrialState? Load();
|
||||
|
||||
void Save(TrialState state);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using App.Core.Entities.Shared;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IAttachmentRepository : IRepository<Attachment, int>
|
||||
{
|
||||
Task<List<Attachment>> GetForOwnerAsync(AttachmentOwnerType ownerType, int ownerId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
12
src/App.Core/Interfaces/Repositories/IInvoiceRepository.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using App.Core.Entities.Invoicing;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IInvoiceRepository : IRepository<Invoice, int>
|
||||
{
|
||||
Task<string> GenerateNextNumberAsync(InvoiceDocumentType documentType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISupplierInvoiceRepository : IRepository<SupplierInvoice, int>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMachineCounterReadingRepository
|
||||
{
|
||||
/// <summary>Dernier relevé enregistré pour ce compteur sur cette machine, ou null si aucun.</summary>
|
||||
Task<double?> GetLatestValueAsync(int machineId, string counterType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
11
src/App.Core/Interfaces/Repositories/IMachineRepository.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using App.Core.Entities.Machines;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMachineRepository : IRepository<Machine, int>
|
||||
{
|
||||
/// <summary>Machines actives, paginées, avec filtre optionnel sur référence/désignation/localisation.</summary>
|
||||
Task<List<Machine>> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(string? searchText, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMaintenancePlanRepository : IRepository<MaintenancePlan, int>
|
||||
{
|
||||
/// <summary>Gammes actives, avec leur machine chargée (nécessaire au moteur de planification et aux gammes à compteur).</summary>
|
||||
Task<List<MaintenancePlan>> GetActivePlansAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
11
src/App.Core/Interfaces/Repositories/IPartRepository.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using App.Core.Entities.Inventory;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IPartRepository : IRepository<Part, int>
|
||||
{
|
||||
/// <summary>Pièces dont le stock est passé sous leur seuil d'alerte (pour la notification de réapprovisionnement).</summary>
|
||||
Task<List<Part>> GetBelowAlertThresholdAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<Part>> SearchAsync(string? searchText, int skip, int take, CancellationToken cancellationToken = default);
|
||||
}
|
||||
20
src/App.Core/Interfaces/Repositories/IRepository.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IRepository<TEntity, in TKey> where TEntity : class
|
||||
{
|
||||
Task<TEntity?> GetByIdAsync(TKey id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(TEntity entity);
|
||||
|
||||
void Remove(TEntity entity);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using App.Core.Entities.Inventory;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IStockMovementRepository : IRepository<StockMovement, int>
|
||||
{
|
||||
Task<List<StockMovement>> GetForPartAsync(int partId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using App.Core.Entities.Inventory;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface ISupplierRepository : IRepository<Supplier, int>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using App.Core.Entities.Sync;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface ISyncDeviceRepository : IRepository<SyncDevice, int>
|
||||
{
|
||||
Task<SyncDevice?> GetByDeviceIdentifierAsync(string deviceIdentifier, CancellationToken cancellationToken = default);
|
||||
}
|
||||
10
src/App.Core/Interfaces/Repositories/IUserRepository.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using App.Core.Entities.Team;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IUserRepository : IRepository<User, int>
|
||||
{
|
||||
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<TechnicianQualification>> GetExpiringQualificationsAsync(int withinDays, CancellationToken cancellationToken = default);
|
||||
}
|
||||
16
src/App.Core/Interfaces/Repositories/IWorkOrderRepository.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IWorkOrderRepository : IRepository<WorkOrder, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<bool> ExistsForOccurrenceAsync(int maintenancePlanId, string occurrenceKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>OT dans une plage de dates (vue calendrier), toutes machines confondues.</summary>
|
||||
Task<List<WorkOrder>> GetScheduledBetweenAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
|
||||
}
|
||||
86
src/App.Core/Services/Kpi/KpiCalculator.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Services.Kpi;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class KpiCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static double? ComputeMtbfHours(IReadOnlyList<DateTime> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static double? ComputeMttrHours(IReadOnlyList<WorkOrder> 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();
|
||||
}
|
||||
|
||||
/// <summary>Taux de disponibilité (%) = MTBF / (MTBF + MTTR). Null si l'un des deux est indisponible.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Coût de maintenance = coût des pièces consommées + coût main d'œuvre optionnel.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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%).
|
||||
/// </summary>
|
||||
public static double? ComputePreventiveComplianceRatePercent(IReadOnlyList<WorkOrder> 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;
|
||||
}
|
||||
}
|
||||
23
src/App.Core/Services/Licensing/DateOnlyJsonConverter.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Convertisseur JSON pour <see cref="DateOnly"/> (format "yyyy-MM-dd"). Nécessaire tant que
|
||||
/// <c>System.Text.Json</c> n'a pas de support natif fiable pour ce type sur .NET 8.
|
||||
/// Utilisé via <see cref="LicenseJsonOptions.Default"/>, jamais instancié directement ailleurs,
|
||||
/// pour garantir que <c>LicenseGenerator</c> (écriture) et l'app (lecture) produisent/attendent
|
||||
/// exactement le même format.
|
||||
/// </summary>
|
||||
public sealed class DateOnlyJsonConverter : JsonConverter<DateOnly>
|
||||
{
|
||||
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));
|
||||
}
|
||||
92
src/App.Core/Services/Licensing/Ed25519PublicKeyProvider.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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 <see cref="GetPublicKeyBytes"/> lève une exception tant que ce n'est pas fait,
|
||||
/// pour ne jamais valider silencieusement une licence contre une clé factice.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Réassemble et retourne les 32 octets de la clé publique Ed25519.</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 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.
|
||||
/// </exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Découpe et XORe une clé publique Ed25519 de 32 octets en 4 fragments, avec les mêmes
|
||||
/// masques que <see cref="GetPublicKeyBytes"/>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
src/App.Core/Services/Licensing/HardwareId.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant matériel du poste, composé de l'UUID de la carte mère
|
||||
/// (WMI <c>Win32_ComputerSystemProduct.UUID</c>) et du numéro de série du disque
|
||||
/// système (WMI <c>Win32_DiskDrive</c>, index 0). Les deux valeurs doivent
|
||||
/// correspondre pour qu'une licence soit considérée valide sur ce poste.
|
||||
/// </summary>
|
||||
public sealed record HardwareId(string MotherboardUuid, string DiskSerial)
|
||||
{
|
||||
/// <summary>Représentation canonique utilisée pour le hash et la comparaison.</summary>
|
||||
public string ToCanonicalString() => $"{MotherboardUuid.Trim().ToUpperInvariant()}|{DiskSerial.Trim().ToUpperInvariant()}";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
35
src/App.Core/Services/Licensing/LicenseCheckResult.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
public enum LicenseStatus
|
||||
{
|
||||
/// <summary>Aucune licence, essai en cours (<see cref="LicenseCheckResult.TrialDaysRemaining"/> > 0).</summary>
|
||||
TrialActive,
|
||||
|
||||
/// <summary>Aucune licence, essai de 30 jours écoulé : écran d'activation bloquant.</summary>
|
||||
TrialExpired,
|
||||
|
||||
/// <summary>Licence présente, signature valide et matériel correspondant.</summary>
|
||||
Licensed,
|
||||
|
||||
/// <summary>Fichier de licence présent mais signature invalide (fichier modifié/corrompu).</summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>Signature valide mais motherboardUuid et/ou diskSerial ne correspond pas au poste actuel.</summary>
|
||||
HardwareMismatch,
|
||||
|
||||
/// <summary>L'horloge système a été reculée par rapport au dernier lancement enregistré.</summary>
|
||||
ClockTamperDetected,
|
||||
}
|
||||
|
||||
/// <summary>Résultat de la vérification de licence effectuée à chaque démarrage de l'application.</summary>
|
||||
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);
|
||||
}
|
||||
20
src/App.Core/Services/Licensing/LicenseFile.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Contenu déserialisé d'un fichier de licence (.lic) signé par le <c>LicenseGenerator</c>.
|
||||
/// Correspond exactement au JSON produit par ce dernier — toute modification de forme doit
|
||||
/// être faite des deux côtés à la fois (voir <see cref="LicenseFileCanonicalSerializer"/>).
|
||||
/// </summary>
|
||||
public sealed record LicenseFile(
|
||||
string CompanyName,
|
||||
string MotherboardUuid,
|
||||
string DiskSerial,
|
||||
DateOnly IssuedDate,
|
||||
DateOnly? ExpiryDate,
|
||||
int MaxUsers,
|
||||
int MaxMachines,
|
||||
string Signature)
|
||||
{
|
||||
/// <summary>Licence sans champ <see cref="Signature"/>, utilisée pour produire les octets canoniques à signer/vérifier.</summary>
|
||||
public LicenseFile WithoutSignature() => this with { Signature = string.Empty };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Produit la représentation binaire canonique d'une <see cref="LicenseFile"/> 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 <c>LicenseGenerator</c>
|
||||
/// (signature) et <c>App.Core</c> (vérification) — ne jamais le modifier sans regénérer les
|
||||
/// licences existantes.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
18
src/App.Core/Services/Licensing/LicenseJsonOptions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Options JSON partagées pour lire/écrire un fichier .lic. Centralisées ici pour que
|
||||
/// <c>LicenseGenerator</c> (écriture) et <c>App.Infrastructure</c> (lecture) restent
|
||||
/// obligatoirement synchronisés sur le même format.
|
||||
/// </summary>
|
||||
public static class LicenseJsonOptions
|
||||
{
|
||||
public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
Converters = { new DateOnlyJsonConverter() },
|
||||
};
|
||||
}
|
||||
95
src/App.Core/Services/Licensing/LicenseService.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using App.Core.Interfaces.Licensing;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <inheritdoc cref="ILicenseService"/>
|
||||
/// <remarks>
|
||||
/// Orchestration pure : ne fait aucune I/O directement, délègue tout au trio
|
||||
/// <see cref="IHardwareIdProvider"/> / <see cref="ITrialStateStore"/> / <see cref="ILicenseFileRepository"/>,
|
||||
/// ce qui rend cette classe entièrement testable avec des doubles de test (voir App.Core.Tests/Licensing).
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
}
|
||||
49
src/App.Core/Services/Licensing/LicenseValidator.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Vérification cryptographique pure d'une <see cref="LicenseFile"/> : signature Ed25519
|
||||
/// puis correspondance matérielle. Ne fait aucune I/O (fichier, DB, WMI) — entièrement
|
||||
/// unitaire-testable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// .NET 8 n'a pas de support natif d'Ed25519 dans <c>System.Security.Cryptography</c>
|
||||
/// (contrairement à RSA/ECDSA). On utilise donc BouncyCastle (<c>BouncyCastle.Cryptography</c>,
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class LicenseValidator
|
||||
{
|
||||
/// <param name="license">Licence à vérifier.</param>
|
||||
/// <param name="publicKeyBytes">
|
||||
/// Clé publique Ed25519 (32 octets). Passée en paramètre plutôt que lue directement depuis
|
||||
/// <see cref="Ed25519PublicKeyProvider"/> pour que cette méthode reste testable avec une
|
||||
/// paire de clés de test, indépendamment de la clé de production embarquée.
|
||||
/// </param>
|
||||
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);
|
||||
}
|
||||
24
src/App.Core/Services/Licensing/TrialState.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace App.Core.Services.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// État de l'essai gratuit, stocké chiffré (DPAPI) dans un fichier séparé de la base
|
||||
/// SQLite — voir <c>docs/DATABASE_SCHEMA.md</c> §2.9 pour la justification. Ne contient
|
||||
/// jamais les identifiants matériels bruts, seulement leur hash.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
153
src/App.Core/Services/Maintenance/FrequencyCalculator.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
|
||||
namespace App.Core.Services.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="MaintenancePlan.LastExecutionDateUtc"/>
|
||||
/// si une exécution a déjà eu lieu, sinon <see cref="MaintenancePlan.StartDate"/>).
|
||||
/// 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 <see cref="MaintenanceSchedulingEngine"/>).
|
||||
/// </summary>
|
||||
public static class FrequencyCalculator
|
||||
{
|
||||
/// <summary>Calcule la prochaine date d'échéance pour les gammes basées sur une fréquence calendaire.</summary>
|
||||
/// <exception cref="InvalidOperationException">Un champ requis par le type de fréquence est absent (gamme mal configurée).</exception>
|
||||
/// <exception cref="NotSupportedException"><paramref name="plan"/> est basée sur compteur : utiliser <see cref="ComputeNextCounterThreshold"/>.</exception>
|
||||
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é."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Valeur de compteur à laquelle la prochaine occurrence doit se déclencher.</summary>
|
||||
public static double ComputeNextCounterThreshold(MaintenancePlan plan)
|
||||
{
|
||||
var threshold = Require(plan.CounterThreshold, nameof(plan.CounterThreshold));
|
||||
return (plan.LastExecutionCounterValue ?? 0) + threshold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Indique si l'échéance à compteur est entrée dans sa fenêtre de préavis (ex: 90% du seuil atteint).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trouve la Nième occurrence d'un jour de semaine dans un mois (1=premier, -1=dernier).
|
||||
/// </summary>
|
||||
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>(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.");
|
||||
}
|
||||
126
src/App.Core/Services/Maintenance/MaintenanceSchedulingEngine.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class MaintenanceSchedulingEngine(
|
||||
IMaintenancePlanRepository planRepository,
|
||||
IWorkOrderRepository workOrderRepository,
|
||||
IMachineCounterReadingRepository counterReadingRepository,
|
||||
ISettingsProvider settingsProvider,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<List<WorkOrder>> RunAsync(DateOnly today, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var created = new List<WorkOrder>();
|
||||
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<WorkOrder?> 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<WorkOrder?> 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using App.Core.Entities.Maintenance;
|
||||
using App.Core.Interfaces;
|
||||
using App.Core.Interfaces.Repositories;
|
||||
|
||||
namespace App.Core.Services.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Clôture un OT et fait avancer l'ancre de sa gamme (<see cref="MaintenancePlan.LastExecutionDateUtc"/> /
|
||||
/// <see cref="MaintenancePlan.LastExecutionCounterValue"/>). C'est cette mise à jour qui permet au
|
||||
/// <see cref="MaintenanceSchedulingEngine"/> 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.
|
||||
/// </summary>
|
||||
public sealed class WorkOrderCompletionService(
|
||||
IWorkOrderRepository workOrderRepository,
|
||||
IMaintenancePlanRepository planRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
/// <param name="workOrderId">OT à clôturer.</param>
|
||||
/// <param name="completedAtUtc">Date/heure d'exécution réelle, utilisée comme nouvelle ancre de la gamme.</param>
|
||||
/// <param name="counterValueAtCompletion">
|
||||
/// Valeur du compteur au moment de la clôture, pour les gammes basées sur compteur. Ignoré pour les gammes calendaires.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Jeton d'annulation.</param>
|
||||
/// <exception cref="InvalidOperationException">L'OT n'existe pas.</exception>
|
||||
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);
|
||||
}
|
||||
}
|
||||
28
src/App.Infrastructure/App.Infrastructure.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App.Core\App.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="QuestPDF" Version="2024.10.3" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- net8.0-windows (sans suffixe 10.0.xxxxx.0) : suffit pour WMI/DPAPI (Win32) et evite le
|
||||
runtime pack de projection WinRT, qui casse design-time 'dotnet ef migrations'. Le chemin
|
||||
Windows.Storage.ApplicationData.Current.LocalFolder est resolu par App.UI (qui cible bien
|
||||
le TFM WinRT complet) et injecte juste une string de chemin ici. -->
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<Supplier>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Supplier> builder)
|
||||
{
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
|
||||
}
|
||||
}
|
||||
|
||||
public class PartConfiguration : IEntityTypeConfiguration<Part>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Part> 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<StockMovement>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockMovement> builder)
|
||||
{
|
||||
builder.HasIndex(m => new { m.PartId, m.CreatedAtUtc });
|
||||
|
||||
builder.HasOne(m => m.Part)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.PartId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -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<SupplierInvoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SupplierInvoice> builder)
|
||||
{
|
||||
builder.Property(i => i.InvoiceNumber).HasMaxLength(100);
|
||||
}
|
||||
}
|
||||
|
||||
public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Invoice> 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<InvoiceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InvoiceLine> builder)
|
||||
{
|
||||
builder.Property(l => l.Designation).IsRequired().HasMaxLength(300);
|
||||
}
|
||||
}
|
||||
@@ -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<Machine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Machine> 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<MachinePart>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MachinePart> 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<MachineCounterReading>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MachineCounterReading> builder)
|
||||
{
|
||||
builder.Property(c => c.CounterType).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(c => new { c.MachineId, c.CounterType, c.RecordedAtUtc });
|
||||
}
|
||||
}
|
||||
@@ -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<MaintenancePlan>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MaintenancePlan> 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<MaintenanceTask>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MaintenanceTask> 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<Tool>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Tool> builder)
|
||||
{
|
||||
builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class MaintenanceTaskPartConfiguration : IEntityTypeConfiguration<MaintenanceTaskPart>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MaintenanceTaskPart> builder)
|
||||
{
|
||||
builder.HasKey(tp => new { tp.MaintenanceTaskId, tp.PartId });
|
||||
|
||||
builder.HasOne(tp => tp.Part)
|
||||
.WithMany()
|
||||
.HasForeignKey(tp => tp.PartId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -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<Attachment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Attachment> 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<AppSetting>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AppSetting> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Key);
|
||||
builder.Property(s => s.Key).HasMaxLength(100);
|
||||
builder.Property(s => s.Value).IsRequired();
|
||||
}
|
||||
}
|
||||
|
||||
public class LicenseActivationCacheConfiguration : IEntityTypeConfiguration<LicenseActivationCache>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LicenseActivationCache> builder)
|
||||
{
|
||||
builder.Property(l => l.CompanyName).IsRequired().HasMaxLength(200);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncDevice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SyncDevice> 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<SyncLogEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SyncLogEntry> builder)
|
||||
{
|
||||
builder.Property(l => l.EntityType).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasOne<SyncDevice>()
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.SyncDeviceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -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<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> 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<TechnicianProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TechnicianProfile> builder)
|
||||
{
|
||||
builder.HasKey(t => t.UserId);
|
||||
|
||||
builder.HasOne(t => t.User)
|
||||
.WithOne(u => u.TechnicianProfile)
|
||||
.HasForeignKey<TechnicianProfile>(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<QualificationType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<QualificationType> 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<TechnicianQualification>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TechnicianQualification> 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<TimeEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TimeEntry> builder)
|
||||
{
|
||||
builder.HasOne(t => t.WorkOrder)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.WorkOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -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<WorkOrder>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkOrder> 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<App.Core.Entities.Team.User>()
|
||||
.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<WorkOrderTaskResult>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkOrderTaskResult> builder)
|
||||
{
|
||||
builder.HasOne<MaintenanceTask>()
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.MaintenanceTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderPartUsedConfiguration : IEntityTypeConfiguration<WorkOrderPartUsed>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkOrderPartUsed> builder)
|
||||
{
|
||||
builder.HasOne<App.Core.Entities.Inventory.Part>()
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.PartId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
18
src/App.Infrastructure/Data/DbInitializer.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace App.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>app.db</c> 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.
|
||||
/// </summary>
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static void MigrateAndConfigure(GmaoDbContext context)
|
||||
{
|
||||
context.Database.Migrate();
|
||||
context.Database.ExecuteSqlRaw("PRAGMA journal_mode='WAL';");
|
||||
}
|
||||
}
|
||||
78
src/App.Infrastructure/Data/GmaoDbContext.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Contexte EF Core unique de l'application, adossé à SQLite. Le mode WAL est activé au
|
||||
/// démarrage (voir <c>DbInitializer</c>), 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.
|
||||
/// </summary>
|
||||
public class GmaoDbContext(DbContextOptions<GmaoDbContext> options) : DbContext(options), IUnitOfWork
|
||||
{
|
||||
public DbSet<Machine> Machines => Set<Machine>();
|
||||
|
||||
public DbSet<MachinePart> MachineParts => Set<MachinePart>();
|
||||
|
||||
public DbSet<MachineCounterReading> MachineCounterReadings => Set<MachineCounterReading>();
|
||||
|
||||
public DbSet<MaintenancePlan> MaintenancePlans => Set<MaintenancePlan>();
|
||||
|
||||
public DbSet<MaintenanceTask> MaintenanceTasks => Set<MaintenanceTask>();
|
||||
|
||||
public DbSet<Tool> Tools => Set<Tool>();
|
||||
|
||||
public DbSet<MaintenanceTaskPart> MaintenanceTaskParts => Set<MaintenanceTaskPart>();
|
||||
|
||||
public DbSet<WorkOrder> WorkOrders => Set<WorkOrder>();
|
||||
|
||||
public DbSet<WorkOrderTaskResult> WorkOrderTaskResults => Set<WorkOrderTaskResult>();
|
||||
|
||||
public DbSet<WorkOrderPartUsed> WorkOrderPartsUsed => Set<WorkOrderPartUsed>();
|
||||
|
||||
public DbSet<Supplier> Suppliers => Set<Supplier>();
|
||||
|
||||
public DbSet<Part> Parts => Set<Part>();
|
||||
|
||||
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||||
|
||||
public DbSet<SupplierInvoice> SupplierInvoices => Set<SupplierInvoice>();
|
||||
|
||||
public DbSet<Invoice> Invoices => Set<Invoice>();
|
||||
|
||||
public DbSet<InvoiceLine> InvoiceLines => Set<InvoiceLine>();
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
public DbSet<TechnicianProfile> TechnicianProfiles => Set<TechnicianProfile>();
|
||||
|
||||
public DbSet<QualificationType> QualificationTypes => Set<QualificationType>();
|
||||
|
||||
public DbSet<TechnicianQualification> TechnicianQualifications => Set<TechnicianQualification>();
|
||||
|
||||
public DbSet<TimeEntry> TimeEntries => Set<TimeEntry>();
|
||||
|
||||
public DbSet<SyncDevice> SyncDevices => Set<SyncDevice>();
|
||||
|
||||
public DbSet<SyncLogEntry> SyncLogEntries => Set<SyncLogEntry>();
|
||||
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
|
||||
public DbSet<LicenseActivationCache> LicenseActivationCaches => Set<LicenseActivationCache>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
}
|
||||
}
|
||||
20
src/App.Infrastructure/Data/GmaoDbContextFactory.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace App.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Factory utilisée uniquement par les outils EF Core (<c>dotnet ef migrations</c>) en design-time,
|
||||
/// pour générer les migrations sans dépendre du runtime WinUI. L'application réelle configure
|
||||
/// <see cref="GmaoDbContext"/> via DI dans App.UI, avec le vrai chemin
|
||||
/// <c>ApplicationData.Current.LocalFolder</c>.
|
||||
/// </summary>
|
||||
public class GmaoDbContextFactory : IDesignTimeDbContextFactory<GmaoDbContext>
|
||||
{
|
||||
public GmaoDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<GmaoDbContext>();
|
||||
optionsBuilder.UseSqlite("Data Source=design-time.db");
|
||||
return new GmaoDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
1201
src/App.Infrastructure/Data/Migrations/20260910115542_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,884 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace App.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Attachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
FileName = table.Column<string>(type: "TEXT", maxLength: 260, nullable: false),
|
||||
ContentType = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
StoredRelativePath = table.Column<string>(type: "TEXT", maxLength: 400, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
EntityType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EntityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
UploadedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UploadedByUserId = table.Column<int>(type: "INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Attachments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Invoices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
DocumentType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Number = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
ClientName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
ClientAddress = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IssueDate = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
DueDate = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
VatRate = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TotalHT = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
TotalTTC = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
PdfAttachmentId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
LinkedWorkOrderId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedByUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Invoices", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LicenseActivationCaches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
CompanyName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
MaxUsers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MaxMachines = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
IssuedDate = table.Column<DateOnly>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LicenseActivationCaches", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Machines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Reference = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
Designation = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Criticality = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CommissioningDate = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
PhotoAttachmentId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Machines", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QualificationTypes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
Label = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Category = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QualificationTypes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SupplierInvoices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SupplierId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
InvoiceNumber = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
InvoiceDate = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
AttachmentId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LinkedWorkOrderId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
ImportedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SupplierInvoices", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Suppliers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
ContactName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Phone = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Address = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Suppliers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SyncDevices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
DeviceName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
DeviceIdentifier = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
PairedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
PairingPinHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
LastSyncAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SyncDevices", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tools",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
FullName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Role = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastLoginAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InvoiceLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
InvoiceId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Order = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Designation = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
Quantity = table.Column<double>(type: "REAL", nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
VatRateOverride = table.Column<decimal>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
MachineId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CounterType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
Value = table.Column<double>(type: "REAL", nullable: false),
|
||||
RecordedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Source = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
MachineId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FrequencyType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
CustomIntervalDays = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
DayOfWeek = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
DayOfMonth = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
MonthlyRuleOccurrence = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
MonthlyRuleWeekday = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CounterType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
CounterThreshold = table.Column<double>(type: "REAL", nullable: true),
|
||||
LeadTimeDays = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
LeadTimePercentForCounter = table.Column<double>(type: "REAL", nullable: true),
|
||||
RequiredQualificationTypeId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
LastExecutionDateUtc = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
LastExecutionCounterValue = table.Column<double>(type: "REAL", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Reference = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
Designation = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
QuantityInStock = table.Column<double>(type: "REAL", nullable: false),
|
||||
AlertThreshold = table.Column<double>(type: "REAL", nullable: false),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PrimarySupplierId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
UnitPrice = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
PhotoAttachmentId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
IsActive = table.Column<bool>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SyncDeviceId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Direction = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EntityType = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
|
||||
OccurredAtUtc = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false),
|
||||
Skills = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhoneNumber = table.Column<string>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
MaintenancePlanId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Order = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Label = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EstimatedDurationMinutes = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
MachineId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MaintenancePlanId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Priority = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
DueDate = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
CompletedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
AssignedTechnicianUserId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
GeneratedForOccurrenceKey = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Source = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false),
|
||||
PartId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PartId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MovementType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<double>(type: "REAL", nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", nullable: true),
|
||||
WorkOrderId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
SupplierInvoiceId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CreatedByUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TechnicianUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
QualificationTypeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ObtainedDate = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
ExpiryDate = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
CertificateAttachmentId = table.Column<int>(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<int>(type: "INTEGER", nullable: false),
|
||||
PartId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
QuantityRequired = table.Column<double>(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<int>(type: "INTEGER", nullable: false),
|
||||
ToolsId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
WorkOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TechnicianUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StartedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
EndedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
DurationMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Source = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SyncDeviceId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
WorkOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PartId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
QuantityUsed = table.Column<double>(type: "REAL", nullable: false),
|
||||
StockMovementId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
WorkOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
MaintenanceTaskId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhotoAttachmentId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CompletedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CompletedByTechnicianUserId = table.Column<int>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1198
src/App.Infrastructure/Data/Migrations/GmaoDbContextModelSnapshot.cs
Normal file
57
src/App.Infrastructure/Licensing/DpapiTrialStateStore.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope <see cref="DataProtectionScope.LocalMachine"/> plutôt que <c>CurrentUser</c> : 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).
|
||||
/// </remarks>
|
||||
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<TrialState>(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);
|
||||
}
|
||||
}
|
||||
41
src/App.Infrastructure/Licensing/LicenseFileRepository.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using App.Core.Interfaces.Licensing;
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Infrastructure.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Lit/écrit le fichier de licence (.lic) activée dans le dossier sandboxé de l'application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="LoadInstalled"/> ne retourne <c>null</c> QUE si aucun fichier n'existe. Si le
|
||||
/// fichier existe mais est illisible/corrompu (JSON invalide), une <see cref="JsonException"/>
|
||||
/// 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 <c>LicenseService</c>.
|
||||
/// </remarks>
|
||||
public class LicenseFileRepository(string filePath) : ILicenseFileRepository
|
||||
{
|
||||
public LicenseFile? LoadInstalled()
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonSerializer.Deserialize<LicenseFile>(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);
|
||||
}
|
||||
}
|
||||
41
src/App.Infrastructure/Licensing/WmiHardwareIdProvider.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Management;
|
||||
using App.Core.Interfaces.Licensing;
|
||||
using App.Core.Services.Licensing;
|
||||
|
||||
namespace App.Infrastructure.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
54
src/App.UI/.gitignore
vendored
Normal file
@@ -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
|
||||
79
src/App.UI/App.UI.csproj
Normal file
@@ -0,0 +1,79 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||
<RootNamespace>App_UI</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
|
||||
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<WinUISDKReferences>false</WinUISDKReferences>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
<Content Include="Assets\LockScreenLogo.scale-200.png" />
|
||||
<Content Include="Assets\Square150x150Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
|
||||
<Content Include="Assets\StoreLogo.png" />
|
||||
<Content Include="Assets\AppIcon.ico" />
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||
package has not yet been restored.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<ProjectCapability Include="Msix" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Microsoft.Windows.SDK.BuildTools.WinApp adds first-class support for
|
||||
`dotnet run` on packaged WinUI apps: it hooks the .NET CLI Run target to
|
||||
register a debug identity via the winapp CLI and launch the app with
|
||||
package identity (AUMID).
|
||||
Set <EnableWinAppRunSupport>false</EnableWinAppRunSupport> in a
|
||||
<PropertyGroup> above to opt out.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.4.0" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App.Core\App.Core.csproj" />
|
||||
<ProjectReference Include="..\App.Infrastructure\App.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
|
||||
Explorer "Package and Publish" context menu entry to be enabled for this project even if
|
||||
the Windows App SDK Nuget package has not yet been restored.
|
||||
-->
|
||||
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Publish Properties -->
|
||||
<PropertyGroup>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
||||
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
|
||||
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
43
src/App.UI/App.xaml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Application
|
||||
x:Class="App_UI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:App_UI">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Couleur d'accent ARGOS (logo du PaneHeader). -->
|
||||
<Color x:Key="ArgosAccentColor">#4C6FFF</Color>
|
||||
<SolidColorBrush x:Key="ArgosAccentBrush" Color="{StaticResource ArgosAccentColor}" />
|
||||
|
||||
<!--
|
||||
"Pastilles" colorées du tableau de bord : dégradé clair -> foncé + fine bordure
|
||||
blanche translucide pour un effet biseauté/bombé. Couleurs fixes (pas de
|
||||
ThemeResource) : le sens (vert=ok, rouge=alerte...) doit rester identique quel que
|
||||
soit le thème clair/sombre de l'utilisateur.
|
||||
-->
|
||||
<LinearGradientBrush x:Key="ArgosPastilleGreenBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#66BB6A" Offset="0" />
|
||||
<GradientStop Color="#2E7D32" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
<LinearGradientBrush x:Key="ArgosPastilleRedBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#EF5350" Offset="0" />
|
||||
<GradientStop Color="#C62828" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
<LinearGradientBrush x:Key="ArgosPastilleOrangeBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FFB74D" Offset="0" />
|
||||
<GradientStop Color="#EF6C00" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
<LinearGradientBrush x:Key="ArgosPastilleBrownBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#A1887F" Offset="0" />
|
||||
<GradientStop Color="#5D4037" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
<SolidColorBrush x:Key="ArgosPastilleRimBrush" Color="#40FFFFFF" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
43
src/App.UI/App.xaml.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private Window? _window;
|
||||
|
||||
/// <summary>Conteneur DI de l'application, résolu par chaque page pour obtenir son ViewModel.</summary>
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 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().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
Services = ServiceRegistration.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// </summary>
|
||||
/// <param name="args">Details about the launch request and process.</param>
|
||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||
{
|
||||
_window = new MainWindow();
|
||||
_window.Activate();
|
||||
}
|
||||
}
|
||||
BIN
src/App.UI/Assets/AppIcon.ico
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
src/App.UI/Assets/LockScreenLogo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/App.UI/Assets/SplashScreen.scale-200.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/App.UI/Assets/Square150x150Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/App.UI/Assets/Square44x44Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 1.1 KiB |