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