first commit

This commit is contained in:
2026-09-10 16:43:09 +02:00
commit 79cf35f690
144 changed files with 8822 additions and 0 deletions

23
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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`).

View 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>

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@@ -0,0 +1,21 @@
namespace App.Core.Entities.Invoicing;
/// <summary>Facture fournisseur importée par drag &amp; 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; }
}

View 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; }
}

View 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; } = [];
}

View 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; }
}

View File

@@ -0,0 +1,9 @@
namespace App.Core.Entities.Machines;
public enum MachineCriticality
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3,
}

View 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,
}

View 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; } = [];
}

View 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; }
}

View 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"/> &lt; 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; } = [];
}

View 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; }
}

View 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";
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; } = [];
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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,
}

View 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);
}

View 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);
}

View 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);
}

View 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();
}

View 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);
}

View 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();
}

View 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);
}

View File

@@ -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);
}

View 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>
{
}

View File

@@ -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);
}

View 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);
}

View File

@@ -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);
}

View 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);
}

View 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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
using App.Core.Entities.Inventory;
namespace App.Core.Interfaces.Repositories;
public interface ISupplierRepository : IRepository<Supplier, int>
{
}

View File

@@ -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);
}

View 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);
}

View 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);
}

View 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;
}
}

View 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));
}

View 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;
}
}

View 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);
}

View 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);
}

View 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 };
}

View File

@@ -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());
}
}

View 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() },
};
}

View 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));
}
}

View 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);
}

View 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;
}

View 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 &gt;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.");
}

View 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,
};
}

View File

@@ -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);
}
}

View 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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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';");
}
}

View 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());
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More