diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8253c70..85139db1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.0.8-rc16] - unstable + +Flux de signature et de génération de devis : un devis peut désormais être +signé par les deux parties (fournisseur et client) puis téléchargé en PDF +ou TeX depuis PostIt, avec les signatures dessinées en vecteur. + +### Added + +* [PostIt] Pad de signature sur la page de validation d'un devis : on valide + ou on refuse en joignant une signature (côté fournisseur ou client), capturée + au format filaire PostIt (strokes) et envoyée sur l'acceptation. +* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` : + autorisation selon le rôle (fournisseur/client/admin), signature optionnelle + persistée sur l'estimate avec horodatage `ProviderValidationDate` / + `ClientValidationDate` selon le côté signataire. +* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted` + pour distinguer l'acceptation de chaque partie. +* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et + `estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est + rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`, + `BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs` + câble `AddControllersWithViews` + `SiteSettings`). +* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel + (`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client + côte à côte dans deux minipages ; plus de dépendance à un PNG de signature. +* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` / + `GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX » + dans PostIt sur la page de validation du devis et le détail de commande, + avec un helper `StorageProvider` d'enregistrement de fichier. +* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back), + de sorte qu'un devis validé quitte immédiatement la liste du signataire. +* [PostIt] Renommage des titres de liste : « Devis en attente de signature + fournisseur » / « Devis en attente de signature client ». +* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`. +* [CLI] Option `-c configuration-file.json` au lancement. +* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline + signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF + existe au chemin de sortie attendu et que seul le PDF persiste (aux/log + nettoyés) ; skip propre si `lualatex` est absent. +* [README] Dépendances système au runtime pour la génération PDF : + `texlive-binaries` (lualatex), `texlive-luatex` (luaotfload), + `texlive-pictures` (tikz), `texlive-fonts-extra` (bera). + +### Changed + +* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur + `ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste + « en attente ». +* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni + sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le + répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés + afin que seul le `.pdf` persiste. + +### Fixed + +* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument + fichier lit stdin en mode terminal et s'arrête après la première ligne d'un + document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier + d'entrée — stdin est alors traité comme un fichier régulier et le document + multi-lignes compile. C'était la cause racine de l'échec en production, pas + `bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets). +* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif + `Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de + `-output-directory` et de répertoire de travail ; résolu en chemin absolu. +* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model` + nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine` + résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine` + unique). +* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait + `null.cshtml` ; corrigé en `Layout = null` (null C#). +* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par + `lualatex`. +* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log` + qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message + d'erreur, et le `.log` est conservé en cas d'échec. + ## [1.0.8-rc15] - unstable ### Added @@ -369,6 +445,8 @@ the ACL now comes along with the article, the same user-visible switch without a schema change. [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD +[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 +[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/README.md b/README.md index 67713fc64..fefbdd48d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,37 @@ make test ### les services et l'API +#### Dépendances système au runtime + +La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`, +`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande +système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit +directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc +être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service +systemd `yavscApi`. + +Sous Debian/Ubuntu : + +```bash +sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra +``` + +- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic). +- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex, + notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur + `module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF. + (Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers + `texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) +- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures + du client et du fournisseur en bas du devis (vecteur, pas de PNG). +- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis. + Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found` + (`Emergency stop`, aucun PDF). + +Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF +generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet +de compiler le document hors-ligne. + ### La Prod `cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 05fe0e228..42d09cd55 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -22,6 +22,7 @@ + @@ -30,4 +31,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index a6d04cb33..46702b3a8 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,8 +11,8 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index ff2f3f2b8..bf967849b 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,8 +6,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 36ed1251e..12e9a8143 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,8 +7,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 app.manifest diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index 0de3bd69e..a2b8cd66f 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -12,6 +12,8 @@ sealed class Program [STAThread] public static void Main(string[] args) { + CLIOptionHandler.HandleArgs(args); + // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs index 6158b4132..eefd59f20 100644 --- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -11,12 +11,13 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); - Assert.Equal("Mes devis à valider", vm.Title); + Assert.Equal("Devis en attente de signature client", vm.Title); Assert.Equal(2, vm.Estimates.Count); // Most recent first Assert.Equal(2, vm.Estimates[0].Id); @@ -28,12 +29,13 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); - Assert.Equal("Mes devis en attente", vm.Title); + Assert.Equal("Devis en attente de signature fournisseur", vm.Title); Assert.Equal(2, vm.Estimates.Count); } @@ -42,7 +44,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); await vm.InitializeAsync(); @@ -63,7 +66,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi { Empty = true }; var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); await vm.InitializeAsync(); diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs new file mode 100644 index 000000000..02a6c64bd --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using PostIt.Controls; +using PostIt.ViewModels; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; + +namespace PostIt.Tests; + +/// +/// Verifies wires the two +/// signature pads (provider + client) into the right +/// call. The view (XAML, rendering) +/// is not tested here; the VM is driven through the same +/// test hooks the signature tests use, +/// and the API client through a recording fake +/// . The test host's +/// TestApp makes GoBackAsync a no-op, so the success paths +/// run to completion without navigation side effects. +/// +public class EstimateValidationPageViewModelTests +{ + private static EstimateDto SampleEstimate(long? commandId = 42) => new() + { + Id = 7, + CommandId = commandId, + CommandType = "Rdv", + Title = "Devis plomberie", + Description = "Réparation", + ClientId = "bob", + OwnerId = "alice", + }; + + private static void DrawOneStroke(SignaturePadControl pad) + { + pad.AppendPointForTest(100, 200); + pad.AppendPointForTest(300, 400); + pad.SealStrokeForTest(); + } + + [Fact] + public async Task Validate_calls_accept_with_strokes_and_command_id() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + var clientPad = new SignaturePadControl(); + vm.Attach(proPad, clientPad); + DrawOneStroke(proPad); // provider signs on their writable pad + + await vm.ValidateAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/accept", api.LastPath); + Assert.Contains("billingCode=Rdv", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + var body = Assert.IsType(api.LastBody); + Assert.NotNull(body.Strokes); + Assert.NotEmpty(body.Strokes); + Assert.Equal(10_000, body.CoordinateMax); + // The provider perspective declares the Pro side, so the + // server stores the signature under the provider's author + // regardless of role inference. + Assert.Equal(SignatureType.Pro, body.SignatureType); + } + + [Fact] + public async Task Validate_from_client_perspective_declares_client_side() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + var cliPad = new SignaturePadControl(); + vm.Attach(new SignaturePadControl(), cliPad); + DrawOneStroke(cliPad); // client signs on their writable pad + + await vm.ValidateAsync(); + + var body = Assert.IsType(api.LastBody); + Assert.Equal(SignatureType.Client, body.SignatureType); // Client side + } + + [Fact] + public async Task Reject_calls_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); + + await vm.RejectAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/reject", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Validate_without_signature_warns_and_does_not_call() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads + + await vm.ValidateAsync(); // no throw: returns before GoBack + + Assert.Null(api.LastMethod); + Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_command_gates_on_signature_and_command() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + vm.Attach(proPad, new SignaturePadControl()); + + // No CommandId ⇒ cannot validate even after signing. + DrawOneStroke(proPad); + Assert.False(vm.ValidateCommand.CanExecute(null)); + + var vm2 = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var proPad2 = new SignaturePadControl(); + vm2.Attach(proPad2, new SignaturePadControl()); + Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet + DrawOneStroke(proPad2); + Assert.True(vm2.ValidateCommand.CanExecute(null)); + } + + [Fact] + public void RoleLabel_reflects_perspective() + { + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var pro = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var cli = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase); + Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Only_the_author_pad_is_writable() + { + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + + var proVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + proVm.Attach(proPad, cliPad); + Assert.False(proPad.IsReadOnly); // provider writes the Pro pad + Assert.True(cliPad.IsReadOnly); + + var cliVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + var proPad2 = new SignaturePadControl(); + var cliPad2 = new SignaturePadControl(); + cliVm.Attach(proPad2, cliPad2); + Assert.True(proPad2.IsReadOnly); + Assert.False(cliPad2.IsReadOnly); // client writes the Client pad + } + + [Fact] + public void Load_existing_signatures_renders_both_sides_in_their_pads() + { + var proStrokes = new[] { 1, 1000, 1000 }; + var cliStrokes = new[] { 1, 9000, 9000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes }; + estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes }; + + // Client perspective: both pads show the stored signatures, but + // only the client pad is writable. + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + vm.Attach(proPad, cliPad); + + Assert.Equal(proStrokes, proPad.Snapshot().Strokes); + Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); + Assert.True(vm.HasProSignature); + Assert.True(vm.HasClientSignature); + } + + [Fact] + public void Load_existing_pro_signature_renders_in_pro_pad_only() + { + var strokes = new[] { 1, 5000, 5000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 }; + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + vm.Attach(proPad, cliPad); + + Assert.Equal(strokes, proPad.Snapshot().Strokes); + Assert.True(cliPad.Snapshot().IsEmpty); + Assert.True(vm.HasProSignature); + Assert.False(vm.HasClientSignature); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs new file mode 100644 index 000000000..02f6ae149 --- /dev/null +++ b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs @@ -0,0 +1,157 @@ +using System.Net.Http; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +/// +/// Verifies issues the right +/// api/v1/front/query/accept / .../reject requests with +/// the PostIt signature payload, using a recording fake +/// (no network). +/// +public class FrontOfficeApiClientTests +{ + private static QueryAcceptanceRequestDto SampleBody() => new() + { + Strokes = new[] { 2, 100, 200, 300, 400 }, + CoordinateMax = 10_000, + CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc), + }; + + [Fact] + public async Task Accept_posts_to_accept_endpoint_with_signature_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var body = SampleBody(); + await client.AcceptQueryAsync("Rdv", 42, body); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Same(body, api.LastBody); + } + + [Fact] + public async Task Reject_posts_to_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.RejectQueryAsync("Rdv", 42); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Accept_returns_deserialized_response() + { + var api = new RecordingApi + { + Response = new QueryAcceptanceResponseDto + { + QueryId = 42, + Status = "Accepted", + Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" }, + }, + }; + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody()); + + Assert.Equal(42, response.QueryId); + Assert.Equal("Accepted", response.Status); + Assert.NotNull(response.Signature); + Assert.Equal("Pro", response.Signature!.Type); + } + + [Fact] + public async Task Accept_rejects_invalid_arguments() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("", 42, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 0, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 42, null!)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("", 42)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("Rdv", 0)); + } + + [Fact] + public async Task GetEstimatePdf_gets_estimate_pdf_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimatePdfAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.pdf", + api.LastPath); + } + + [Fact] + public async Task GetEstimateTex_gets_estimate_tex_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimateTexAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.tex", + api.LastPath); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + public QueryAcceptanceResponseDto? Response { get; set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + + var response = Response ?? new QueryAcceptanceResponseDto(); + return Task.FromResult((T)(object)response); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = null; + return Task.FromResult(Array.Empty()); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index d2932665b..d4d16c29e 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -26,4 +26,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b80351e17..9a04ffd03 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -17,6 +17,7 @@ namespace PostIt; public partial class App : Application { private int _bootStarted; + public string? CliConfigFileSpecification {  get; private set; } /// /// DI container the platform entry points hand to ViewModels so @@ -203,4 +204,9 @@ public partial class App : Application { await View!.NavRoot.PopAsync(); } + + internal void UseConfigFileWhenLoading(string configFile) + { + this.CliConfigFileSpecification = configFile; + } } diff --git a/src/PostIt/PostIt/CLIOptions/CLIOptions.cs b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs new file mode 100644 index 000000000..c0bea85ff --- /dev/null +++ b/src/PostIt/PostIt/CLIOptions/CLIOptions.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using Mono.Options; +using PostIt; +using PostIt.ViewModels; + +public static class CLIOptionHandler +{ + public static void HandleArgs(string[] args) + { + bool showHelp = false; + string configFile = null; + + var options = new OptionSet { + { "c|configuration=", "Configuration file to load", (string v) => configFile = v }, + { "h|help", "Show this message and exit", v => showHelp = true } + }; + + List extra = options.Parse(args); + + if (showHelp) + { + Console.WriteLine("Usage: app [OPTIONS]+"); + options.WriteOptionDescriptions(Console.Out); + return; + } + if (configFile != null) + { + var app = App.Current as App; + app.UseConfigFileWhenLoading(configFile); + } + } +} diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs index dee8af36e..bacb52486 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadControl.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl set => SetValue(CaptureAreaProperty, value); } + /// + /// When true the control ignores pointer input — it only displays + /// strokes loaded into its buffer (e.g. the other party's + /// signature, which must be shown but not editable). + /// and still work. + /// + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + public bool IsReadOnly + { + get => GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + /// /// Captured strokes in wire form. Exposed as a read-only view /// over the internal buffer. The buffer only mutates on the UI @@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl private void OnCapturePressed(object? sender, PointerPressedEventArgs e) { + if (IsReadOnly) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; e.Pointer.Capture(CaptureArea); _capturing = true; @@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl RedrawRequested?.Invoke(this, EventArgs.Empty); } + /// + /// Replace the buffer with the given wire-format strokes — e.g. a + /// signature loaded back from the server — and raise + /// so the host view repaints the ink. + /// Loaded strokes are all sealed (there is no pending stroke). + /// Malformed input (a non-positive length prefix, or a stroke that + /// runs past the end of the array) is truncated at the offending + /// point so the buffer never holds an inconsistent state. + /// + public void Load(int[] strokes) + { + if (strokes is null) throw new ArgumentNullException(nameof(strokes)); + + _strokes.Clear(); + _pendingPoints = 0; + _capturing = false; + + int i = 0; + while (i < strokes.Length) + { + int k = strokes[i]; + if (k <= 0) break; + if (i + 1 + 2 * k > strokes.Length) break; + _strokes.Add(k); + for (int p = 0; p < 2 * k; p++) + _strokes.Add(strokes[i + 1 + p]); + i += 1 + 2 * k; + } + + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + /// /// Defensive copy of the current buffer wrapped in a /// . Cheap; call only when the diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml b/src/PostIt/PostIt/Controls/SignaturePadView.axaml new file mode 100644 index 000000000..cb0a99de1 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs new file mode 100644 index 000000000..ff75bf663 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using PostIt.Models; + +namespace PostIt.Controls; + +/// +/// Reusable signature-capture surface: wraps the render-agnostic +/// in a sized +/// (the hit-test region) with an overlay that +/// the code-behind repaints from the control's stroke buffer. Owns +/// the ink-rendering logic so every page that captures a signature +/// (the dev-only SignaturePage, the estimate validation page, +/// ...) shares one implementation. +/// +/// Hosts bind / +/// (DIPs, defaults 600×200) to size the surface. View models attach +/// to the inner control (exposed here) to +/// read snapshots, drive Clear, and subscribe to +/// / +/// — the same +/// contract the headless tests use, since a +/// needs its XAML loaded to be drivable. +/// +public partial class SignaturePadView : UserControl +{ + private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10)); + private const double StrokeThickness = 2.0; + private const double CoordinateMax = 10_000.0; + + // Last size the ink layer was painted at. LayoutUpdated repaints + // only when this changes, so adding/removing polyline children + // (which invalidates layout) does not loop. + private Size _lastPaintedSize; + + public static readonly StyledProperty CaptureWidthProperty = + AvaloniaProperty.Register(nameof(CaptureWidth), 600); + + public static readonly StyledProperty CaptureHeightProperty = + AvaloniaProperty.Register(nameof(CaptureHeight), 200); + + public double CaptureWidth + { + get => GetValue(CaptureWidthProperty); + set => SetValue(CaptureWidthProperty, value); + } + + public double CaptureHeight + { + get => GetValue(CaptureHeightProperty); + set => SetValue(CaptureHeightProperty, value); + } + + /// + /// The underlying capture control. View models attach to this to + /// read / call + /// and subscribe to its + /// events; this view repaints the ink layer from the same buffer. + /// + public SignaturePadControl SignaturePad => InnerPad; + + public SignaturePadView() + { + InitializeComponent(); + + // Wire the capture area: the Pad is the control, the + // surrounding Border (PadFrame) is the hit-test region. For an + // inline control with no template, capture wiring happens on + // first measure, which is guaranteed before the user can + // interact, so attaching here is safe. + InnerPad.CaptureArea = PadFrame; + ApplyFrameSize(); + + InnerPad.RedrawRequested += OnPadRedrawRequested; + // Repaint once layout completes so an ink buffer loaded before + // the first arrange (e.g. an existing signature on reopen) + // still renders without waiting for a pointer event. The + // handler repaints only on a size change to avoid a layout + // loop (child changes invalidate layout → LayoutUpdated). + LayoutUpdated += OnLayoutUpdated; + } + + private void OnLayoutUpdated(object? sender, EventArgs e) + { + var size = EffectivePadSize(); + if (size != _lastPaintedSize) + Repaint(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty) + { + ApplyFrameSize(); + Repaint(); + } + } + + private void ApplyFrameSize() + { + if (PadFrame is null) return; + PadFrame.Width = Math.Max(1, CaptureWidth); + PadFrame.Height = Math.Max(1, CaptureHeight); + } + + private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint(); + + /// Defensive copy of the current buffer. + public SignaturePadData Snapshot() => InnerPad.Snapshot(); + + /// Forget every captured stroke. + public void Clear() => InnerPad.Clear(); + + /// Wire-format strokes, forwarded from the inner control. + public IReadOnlyList Strokes => InnerPad.Strokes; + + /// + /// The effective ink surface size: the arranged bounds when + /// available, otherwise the explicitly sized + /// /. The + /// latter covers a repaint triggered before the first layout pass + /// (e.g. loading an existing signature on reopen), when Bounds is + /// still empty. + /// + private Size EffectivePadSize() + { + var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width; + var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height; + if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h)) + return default; + return new Size(w, h); + } + + private void Repaint() + { + if (InkLayer is null) return; + + var size = EffectivePadSize(); + if (size == default) return; + _lastPaintedSize = size; + + InkLayer.Children.Clear(); + var w = size.Width; + var h = size.Height; + + var pending = InnerPad.PendingStroke; + var strokes = InnerPad.Strokes; + int sealedCount = strokes.Count - pending.Count; + if (sealedCount < 0) + { + sealedCount = 0; + } + + int i = 0; + while (i < sealedCount) + { + int k = strokes[i]; + if (k <= 0) break; + i++; // skip the length prefix + + var poly = new Polyline + { + Stroke = StrokeBrush, + StrokeThickness = StrokeThickness, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + }; + var pts = new List(k); + for (int p = 0; p < k; p++) + { + int nx = strokes[i++]; + int ny = strokes[i++]; + pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); + } + poly.Points = pts; + InkLayer.Children.Add(poly); + } + + if (pending.Count > 0) + { + var poly = new Polyline + { + Stroke = StrokeBrush, + StrokeThickness = StrokeThickness, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + }; + + var pts = new List(pending.Count / 2); + for (int p = 0; p < pending.Count; p += 2) + { + int nx = pending[p]; + int ny = pending[p + 1]; + pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); + } + + poly.Points = pts; + InkLayer.Children.Add(poly); + } + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs b/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs new file mode 100644 index 000000000..b99a33c87 --- /dev/null +++ b/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +namespace PostIt.Helpers; + +/// +/// Saves a downloaded byte payload to a file chosen by the user through +/// the platform save picker. This is the first file-save (as opposed to +/// file-open) helper in PostIt; the open picker is already used for blog +/// attachment and avatar uploads +/// (, ). +/// +/// The TopLevel hosting the picker is resolved from the +/// application's root MainView (App.View), which works for +/// both the desktop and single-view lifetimes. +/// +public static class FileSaveHelpers +{ + /// + /// Prompt the user to choose a destination and write + /// to it. Returns false when the user + /// cancels the picker; throws on IO/platform errors so the caller + /// can surface them. + /// + public static async Task SaveAsync( + string suggestedName, + string defaultExtension, + byte[] content, + CancellationToken ct = default) + { + var view = (Application.Current as App)?.View; + var topLevel = view is null ? null : TopLevel.GetTopLevel(view); + if (topLevel is null) + throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier."); + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Enregistrer sous", + DefaultExtension = defaultExtension, + SuggestedFileName = suggestedName, + FileTypeChoices = new[] + { + new FilePickerFileType($"{defaultExtension.ToUpper()} file") + { + Patterns = new[] { $"*.{defaultExtension}" } + } + } + }).ConfigureAwait(true); + + if (file is null) + return false; // user cancelled + + await using var stream = await file.OpenWriteAsync().ConfigureAwait(true); + await stream.WriteAsync(content, ct).ConfigureAwait(true); + return true; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index bc5e7298f..2b243f2d5 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -31,6 +31,7 @@ public static class ServiceCollectionHelpers () => settings.Authentication?.Authority); var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); + var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); var reverseGeocoding = new NominatimReverseGeocodingService(); @@ -63,6 +64,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -74,6 +76,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(activityClient); services.AddSingleton(billingClient); services.AddSingleton(estimateClient); + services.AddSingleton(frontClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 3216cebb2..98dcbe480 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -5,8 +5,8 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -36,6 +36,7 @@ + diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 04dbce1fd..555a7c675 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable return "Avatar mis à jour."; } + /// + /// Download a binary resource (PDF, TeX, …) from the API. Unlike + /// , + /// which JSON-decodes the response, this returns the raw response + /// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once + /// contract as . + /// + public virtual async Task DownloadAsync( + HttpMethod method, + string path, + CancellationToken ct = default) + { + if (_tokens is null) + throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first."); + + await EnsureFreshTokenAsync(ct).ConfigureAwait(false); + + var sendOnce = async () => + { + using var request = new HttpRequestMessage(method, path); + return await Http.SendAsync(request, ct).ConfigureAwait(false); + }; + + var response = await sendOnce().ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + await ForceRefreshAsync(ct).ConfigureAwait(false); + response = await sendOnce().ConfigureAwait(false); + } + + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + } + public async Task LogoutAsync() { _store.Clear(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 11797b714..ca9a7ff65 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), + EstimateValidationPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index aab923d80..dbd84e5c2 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using Yavsc; using Yavsc.Api.Client; @@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV try { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); + var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( Activity, Performer, Form, _billingClient, + frontClient, details, IsReadOnly); await app.PushPageAsync(vm).ConfigureAwait(true); @@ -171,5 +174,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV } private static bool IsOngoingStatus(QueryStatus status) - => status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; + => status is QueryStatus.Inserted + or QueryStatus.Accepted + or QueryStatus.ProAccepted + or QueryStatus.ClientAccepted + or QueryStatus.InProgress; } diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index cd13daaf1..9ab1b42fd 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -14,6 +14,7 @@ namespace PostIt.ViewModels; public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly BillingApiClient _billingClient; + private readonly FrontOfficeApiClient _frontClient; private readonly BillingQueryDetailsDto _details; public ActivityInfo Activity { get; } @@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient, + FrontOfficeApiClient frontClient, BillingQueryDetailsDto details, bool isReadOnly) { @@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Form = form ?? throw new ArgumentNullException(nameof(form)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); _details = details ?? throw new ArgumentNullException(nameof(details)); IsReadOnly = isReadOnly; @@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt await app.GoBackAsync().ConfigureAwait(true); } + /// + /// Download the estimate linked to this command as a LaTeX source + /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is + /// resolved server-side from Estimate.CommandId == query Id. + /// + [RelayCommand] + public async Task DownloadTexAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimateTexAsync(_details.Id), + $"devis-{_details.Id}", "tex").ConfigureAwait(true); + } + + /// + /// Download the estimate linked to this command as a compiled PDF + /// (GET api/v1/front/query/{Id}/estimate.pdf). Requires + /// texi2pdf on the server host. + /// + [RelayCommand] + public async Task DownloadPdfAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimatePdfAsync(_details.Id), + $"devis-{_details.Id}", "pdf").ConfigureAwait(true); + } + + private async Task DownloadAsync(Func> fetch, string fileName, string ext) + { + IsBusy = true; + try + { + var bytes = await fetch().ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true); + this.SetInfoStatus(saved + ? $"Fichier .{ext} enregistré." + : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + private static string EmptyAsPlaceholder(string? value, string placeholder) => string.IsNullOrWhiteSpace(value) ? placeholder : value; @@ -166,6 +216,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#E6F7EC", + QueryStatus.ProAccepted => "#E6F7EC", + QueryStatus.ClientAccepted => "#E1F5FE", QueryStatus.InProgress => "#FFF4D6", QueryStatus.Rejected => "#FDECEA", QueryStatus.Failed => "#ECEFF1", @@ -177,6 +229,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#2E7D32", + QueryStatus.ProAccepted => "#2E7D32", + QueryStatus.ClientAccepted => "#1565C0", QueryStatus.InProgress => "#B26A00", QueryStatus.Rejected => "#C62828", QueryStatus.Failed => "#607D8B", @@ -188,6 +242,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "#1B5E20", + QueryStatus.ProAccepted => "#1B5E20", + QueryStatus.ClientAccepted => "#0D47A1", QueryStatus.InProgress => "#7A4A00", QueryStatus.Rejected => "#8E0000", QueryStatus.Failed => "#37474F", @@ -199,6 +255,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt => status switch { QueryStatus.Accepted => "OK", + QueryStatus.ProAccepted => "P", + QueryStatus.ClientAccepted => "C", QueryStatus.InProgress => "~", QueryStatus.Rejected => "!", QueryStatus.Failed => "X", diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs index e5c4d355f..c90a6d884 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Helpers; @@ -18,10 +19,10 @@ namespace PostIt.ViewModels; /// public enum EstimateListPerspective { - /// « Mes devis à valider » — l'utilisateur courant est le client. + /// « Devis en attente de signature client » — l'utilisateur courant est le client. Client, - /// « Mes devis en attente » — l'utilisateur courant est le fournisseur. + /// « Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur. Provider, } @@ -32,14 +33,15 @@ public enum EstimateListPerspective public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateApiClient _estimateClient; + private readonly FrontOfficeApiClient _frontClient; private readonly EstimateListPerspective _perspective; private List _allEstimates = new(); public EstimateListPerspective Perspective => _perspective; public string Title => _perspective == EstimateListPerspective.Client - ? "Mes devis à valider" - : "Mes devis en attente"; + ? "Devis en attente de signature client" + : "Devis en attente de signature fournisseur"; public string Subtitle => _perspective == EstimateListPerspective.Client ? "Point de vue client" @@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie protected set { _ = value; } } - public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) + public EstimateListPageViewModel( + EstimateApiClient estimateClient, + EstimateListPerspective perspective, + FrontOfficeApiClient frontClient) { _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _perspective = perspective; + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); } public Task InitializeAsync() => RefreshAsync(); @@ -153,4 +159,25 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie private static bool ContainsInsensitive(string? value, string query) => !string.IsNullOrWhiteSpace(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase); + + /// + /// Open the estimate validation (accept/reject with signature) + /// page for a specific estimate. The perspective the list was + /// opened in is forwarded as the actor-role UI hint; the server + /// independently enforces the real authorization. + /// + [RelayCommand] + private async Task OpenEstimateAsync(EstimateDto? estimate) + { + if (estimate is null) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient); + await app.PushPageAsync(vm).ConfigureAwait(true); + } } diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs new file mode 100644 index 000000000..87347c2ce --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Controls; +using PostIt.Helpers; +using PostIt.Models; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; + +namespace PostIt.ViewModels; + +/// +/// Validation (accept / reject) of a single estimate, with optional +/// signatures. Backs the estimate validation page opened from +/// for either perspective. +/// +/// Two signatures are shown, each in its own pad: the provider's +/// (Pro) and the client's (Client). Both are rendered from the estimate +/// payload when present, so reopening a partly-signed estimate shows +/// what was already captured. Only the pad matching the current +/// is writable — the server +/// (POST api/v1/front/query/accept) independently enforces the +/// same rule (it resolves the caller's role from the authenticated user +/// vs the query's PerformerId/ClientId), and the UI marks +/// the other pad read-only. The billing code sent to the server is the +/// estimate's and the query id is +/// its . +/// +public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly EstimateDto _estimate; + private readonly EstimateListPerspective _perspective; + private readonly FrontOfficeApiClient _frontClient; + private SignaturePadControl? _proPad; + private SignaturePadControl? _clientPad; + + public string Title => _estimate.Title; + public string Description => _estimate.Description; + public long EstimateId => _estimate.Id; + public string CommandType => _estimate.CommandType; + public IReadOnlyList Bill => _estimate.Bill; + public DateTime ProviderValidationDate => _estimate.ProviderValidationDate; + public DateTime ClientValidationDate => _estimate.ClientValidationDate; + + public string RoleLabel => _perspective == EstimateListPerspective.Provider + ? "Vous validez en tant que prestataire" + : "Vous validez en tant que client"; + + /// + /// True when the current user may sign the provider's signature + /// (i.e. they opened the list as the provider). The other party's + /// pad is read-only. + /// + public bool CanWritePro => _perspective == EstimateListPerspective.Provider; + + /// + /// True when the current user may sign the client's signature + /// (i.e. they opened the list as the client). + /// + public bool CanWriteClient => _perspective == EstimateListPerspective.Client; + + /// + /// Human-readable summary of who has already validated this + /// estimate, shown above the signature pads. + /// + public string ValidationStateText + { + get + { + var parts = new List(); + if (ProviderValidationDate != default) + parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}"); + if (ClientValidationDate != default) + parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}"); + return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts); + } + } + + /// + /// True when the estimate has a linked query to accept/reject. + /// Estimates without a are + /// templates and cannot be validated through the front-office + /// endpoint. + /// + public bool HasCommand => _estimate.CommandId is not null; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + + /// Stroke/point counts for the writable pad. + [ObservableProperty] + public partial int StrokeCount { get; set; } + + [ObservableProperty] + public partial int PointCount { get; set; } + + /// True when the writable pad holds a signature. + [ObservableProperty] + public partial bool HasSignature { get; set; } + + [ObservableProperty] + public partial bool HasProSignature { get; set; } + + [ObservableProperty] + public partial bool HasClientSignature { get; set; } + + [ObservableProperty] + public partial string ProSignatureHint { get; set; } = string.Empty; + + [ObservableProperty] + public partial string ClientSignatureHint { get; set; } = string.Empty; + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public EstimateValidationPageViewModel( + EstimateDto estimate, + EstimateListPerspective perspective, + FrontOfficeApiClient frontClient) + { + _estimate = estimate ?? throw new ArgumentNullException(nameof(estimate)); + _perspective = perspective; + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); + } + + /// + /// Bind the page's two signature pads (the inner controls of the + /// page's s): the provider's pad and + /// the client's pad. Called from the view's code-behind once the + /// view is in the visual tree. Marks the non-author pad read-only, + /// then renders any signatures already carried on the estimate + /// payload. + /// + public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad) + { + if (proPad is null) throw new ArgumentNullException(nameof(proPad)); + if (clientPad is null) throw new ArgumentNullException(nameof(clientPad)); + Detach(); + _proPad = proPad; + _clientPad = clientPad; + _proPad.IsReadOnly = !CanWritePro; + _clientPad.IsReadOnly = !CanWriteClient; + _proPad.RedrawRequested += OnPadRedraw; + _clientPad.RedrawRequested += OnPadRedraw; + LoadExistingSignatures(); + RefreshFromPad(); + } + + public void Detach() + { + if (_proPad is not null) + _proPad.RedrawRequested -= OnPadRedraw; + if (_clientPad is not null) + _clientPad.RedrawRequested -= OnPadRedraw; + _proPad = null; + _clientPad = null; + } + + private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad(); + + /// The pad the current user may sign in. + private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad; + + /// + /// Render the signatures already stored for this estimate back into + /// their pads. The strokes are carried on the estimate payload as + /// two dedicated slots — and + /// — so each pad loads + /// its own side directly, in the same wire format PostIt captured. + /// No network round-trip. + /// + public void LoadExistingSignatures() + { + LoadSide(_proPad, _estimate.SignaturePro); + LoadSide(_clientPad, _estimate.SignatureClient); + RefreshFromPad(); + } + + private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig) + { + if (pad is null || sig is null) return; + if (sig.Strokes is { Length: > 0 } strokes) + pad.Load(strokes); + } + + private void RefreshFromPad() + { + HasProSignature = _proPad?.Snapshot().IsEmpty == false; + HasClientSignature = _clientPad?.Snapshot().IsEmpty == false; + + ProSignatureHint = CanWritePro + ? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire") + : (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire"); + ClientSignatureHint = CanWriteClient + ? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client") + : (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client"); + + var pad = WritablePad; + if (pad is null) return; + var snap = pad.Snapshot(); + StrokeCount = snap.StrokeCount; + PointCount = snap.PointCount; + HasSignature = !snap.IsEmpty; + } + + partial void OnIsBusyChanged(bool value) + { + ValidateCommand.NotifyCanExecuteChanged(); + RejectCommand.NotifyCanExecuteChanged(); + } + + partial void OnHasSignatureChanged(bool value) + => ValidateCommand.NotifyCanExecuteChanged(); + + private bool CanValidate => !IsBusy && HasSignature && HasCommand; + + private bool CanReject => !IsBusy && HasCommand; + + [RelayCommand(CanExecute = nameof(CanValidate))] + public async Task ValidateAsync() + { + if (_estimate.CommandId is not long queryId) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé."); + return; + } + var pad = WritablePad; + if (pad is null || pad.Snapshot().IsEmpty) + { + this.SetWarningStatus("Veuillez signer avant de valider."); + return; + } + + var snap = pad.Snapshot(); + IsBusy = true; + try + { + var body = new QueryAcceptanceRequestDto + { + Strokes = snap.Strokes, + CoordinateMax = SignaturePadData.CoordinateMax, + CapturedAtUtc = DateTime.UtcNow, + // Declare which side the caller is signing as, so the + // server stores the signature under the right author + // (Pro/Client) even when one user is both parties. + SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client, + }; + + await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body) + .ConfigureAwait(true); + + this.SetInfoStatus("Devis validé."); + await GoBackAsync().ConfigureAwait(true); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) + { + this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest) + { + this.SetWarningStatus($"Validation refusée: {ex.Message}"); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanReject))] + public async Task RejectAsync() + { + if (_estimate.CommandId is not long queryId) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé."); + return; + } + + IsBusy = true; + try + { + await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId) + .ConfigureAwait(true); + + this.SetInfoStatus("Devis refusé."); + await GoBackAsync().ConfigureAwait(true); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) + { + this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void ClearSignature() + { + WritablePad?.Clear(); + RefreshFromPad(); + } + + /// + /// Download the estimate as a LaTeX source document. The server + /// renders the Estimate_tex template to a .tex byte stream + /// (GET api/v1/front/query/{CommandId}/estimate.tex); the + /// user picks a destination via the platform save picker. + /// + [RelayCommand] + public async Task DownloadTexAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value), + $"devis-{EstimateId}", "tex").ConfigureAwait(true); + } + + /// + /// Download the estimate as a compiled PDF + /// (GET api/v1/front/query/{CommandId}/estimate.pdf). + /// Requires texi2pdf on the server host; a failure is + /// surfaced as a status message. + /// + [RelayCommand] + public async Task DownloadPdfAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value), + $"devis-{EstimateId}", "pdf").ConfigureAwait(true); + } + + private async Task DownloadAsync(Func> fetch, string fileName, string ext) + { + if (_estimate.CommandId is not long) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande."); + return; + } + + IsBusy = true; + try + { + var bytes = await fetch().ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true); + this.SetInfoStatus(saved + ? $"Fichier .{ext} enregistré." + : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true); + + private static async Task GoBackAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + await app.GoBackAsync().ConfigureAwait(true); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 71e979e66..75a5bdc9a 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using Yavsc; using Yavsc.Abstract.Workflow; @@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); + var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( activity, performer, form, _billingClient, + frontClient, details, isReadOnly: false); @@ -344,11 +347,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi => status switch { QueryStatus.InProgress => 0, - QueryStatus.Accepted => 1, - QueryStatus.Inserted => 2, - QueryStatus.Success => 3, - QueryStatus.Rejected => 4, - QueryStatus.Failed => 5, + QueryStatus.ClientAccepted => 1, + QueryStatus.ProAccepted => 2, + QueryStatus.Accepted => 2, + QueryStatus.Inserted => 3, + QueryStatus.Success => 4, + QueryStatus.Rejected => 5, + QueryStatus.Failed => 6, _ => 99, }; diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index ea92f1d0a..9d466b26c 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -90,7 +90,13 @@ public class HomePageViewModel : ViewModelBase throw new InvalidOperationException("Client devis indisponible."); } - var vm = new EstimateListPageViewModel(estimateClient, perspective); + var frontClient = app.ServiceProvider?.GetRequiredService(); + if (frontClient is null) + { + throw new InvalidOperationException("Client front-office indisponible."); + } + + var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient); await vm.InitializeAsync(); await app.PushPageAsync(vm); } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 1c2802e33..5576f01cd 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -259,25 +259,13 @@ public partial class Settings : ViewModelBase return; } } - if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson - && !string.IsNullOrWhiteSpace(envJson)) + + var app = App.Current as App; + if (app!=null) + if (app.CliConfigFileSpecification!=null) { - Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable."); - FileInfo configByEnvFileInfo = new FileInfo(envJson); - if (!configByEnvFileInfo.Exists) - { - throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}"); - } - string json = File.ReadAllText(configByEnvFileInfo.FullName); - ApplyJson(json, "POSTIT_SETTINGS_JSON"); - SettingsFileFullName = configByEnvFileInfo.FullName; - Loaded = true; - return; + SettingsFileFullName = app.CliConfigFileSpecification; } - string configDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "PostIt" - ); if (SettingsFileFullName is not null) { @@ -287,10 +275,18 @@ public partial class Settings : ViewModelBase else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath && !string.IsNullOrWhiteSpace(envPath)) { - SettingsFileFullName = envPath; + FileInfo configByEnvFileInfo = new FileInfo(envPath); + if (!configByEnvFileInfo.Exists) + { + throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}"); + } + SettingsFileFullName = configByEnvFileInfo.FullName; } else { + string configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PostIt"); SettingsFileFullName = Path.Combine(configDir, "postit-settings.json"); } @@ -536,6 +532,7 @@ public partial class Settings : ViewModelBase /// partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); + public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } } diff --git a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs index 23ba70cf9..1e7df9c60 100644 --- a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs @@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo } /// - /// Bind a freshly-constructed (or re-templated) control to this - /// VM. Called from the view's code-behind once the control has - /// been added to the visual tree and its template applied (so - /// is wired). + /// Bind the inner of the page's + /// to this VM. Called from the + /// page's code-behind once the view is in the visual tree (so the + /// control's is + /// wired). Attaching to the control directly (rather than the + /// view) keeps the same contract the headless tests use. /// public void Attach(SignaturePadControl control) { diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 82231ad83..904f17350 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -14,7 +14,7 @@ - + public enum QueryStatus: int { - Inserted, - Rejected, - Accepted, - InProgress, + Inserted = 0, + Rejected = 1, + Accepted = 2, + InProgress = 3, // final states - Failed, - Success + Failed = 4, + Success = 5, + // party-specific acceptance (see summary) + ProAccepted = 6, + ClientAccepted = 7, } -} +} \ No newline at end of file diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index 4df6c53c2..0a9e481bf 100644 --- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj +++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj @@ -11,7 +11,7 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 \ No newline at end of file diff --git a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs index 43c09d94a..3e7a15052 100644 --- a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs +++ b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs @@ -39,6 +39,39 @@ public sealed class EstimateDto public string CommandType { get; set; } = string.Empty; public DateTime ProviderValidationDate { get; set; } public DateTime ClientValidationDate { get; set; } + + /// + /// The provider's signature on this estimate, or null when the + /// provider has not signed. Carries the PostIt wire-format + /// so the pad can + /// repaint a previously drawn signature on reopen without a second + /// round-trip. + /// + public EstimateSignatureDto? SignaturePro { get; set; } + + /// + /// The client's signature on this estimate, or null when the + /// client has not signed. + /// + public EstimateSignatureDto? SignatureClient { get; set; } +} + +/// +/// One stored signature on an estimate, mirroring the JSON shape of +/// the server-side Yavsc.Models.Billing.Signature entity. The +/// are the same PostIt wire-format payload that +/// was captured and are loaded back into the pad unchanged. The side +/// (Pro/Client) is implicit in which property of +/// carries this DTO, so no Type +/// field is needed. +/// +public sealed class EstimateSignatureDto +{ + public long Id { get; set; } + public long EstimateId { get; set; } + public int CoordinateMax { get; set; } + public DateTime CapturedAtUtc { get; set; } + public int[]? Strokes { get; set; } } /// diff --git a/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs b/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs new file mode 100644 index 000000000..dffbbf3ad --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs @@ -0,0 +1,56 @@ +using System; +using Yavsc.Models.Billing; + +namespace Yavsc.Api.Client; + +/// +/// Body of POST api/v1/front/query/accept and +/// .../query/reject, mirroring the server-side +/// Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest. +/// is optional: when non-empty on accept it is +/// the PostIt wire-format signature captured with the acceptance; null +/// or empty means no signature (the only valid value on reject). +/// +public sealed class QueryAcceptanceRequestDto +{ + public int[]? Strokes { get; set; } + + public int CoordinateMax { get; set; } = 10_000; + + public DateTime? CapturedAtUtc { get; set; } + + /// + /// Which signature is being submitted: + /// (the provider) or + /// . The PostIt client sets this + /// from the perspective the page was opened in (provider signs Pro, + /// client signs Client), so the server stores the signature under + /// the right author even when one user is both parties. Null lets + /// the server infer the role from the caller's identity. + /// + public SignatureType? SignatureType { get; set; } +} + +/// +/// Response of POST .../query/accept / .../query/reject +/// (Ok(new { queryId, status, signature = ... })). The +/// ref is non-null only when a signature was +/// staged on accept. +/// +public sealed class QueryAcceptanceResponseDto +{ + public long QueryId { get; set; } + + public string? Status { get; set; } + + public QuerySignatureRefDto? Signature { get; set; } +} + +public sealed class QuerySignatureRefDto +{ + public long Id { get; set; } + + public long EstimateId { get; set; } + + public string? Type { get; set; } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/FrontOfficeApiClient.cs b/src/Yavsc.Api.Client/FrontOfficeApiClient.cs new file mode 100644 index 000000000..84c7b8a0c --- /dev/null +++ b/src/Yavsc.Api.Client/FrontOfficeApiClient.cs @@ -0,0 +1,135 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for the front-office query routes +/// (api/v1/front/query/accept / .../reject, served by +/// ). Same +/// DTO↔path mapper shape as : transport +/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated +/// to . +/// +/// The server resolves the caller's role (provider vs client vs admin) +/// from the authenticated user compared to the query's +/// PerformerId/ClientId; the client does not declare a +/// role. is the estimate's +/// and +/// is its . +/// +public sealed class FrontOfficeApiClient +{ + private const string PathPrefix = "front"; + + private readonly IYavscApiClient _api; + private readonly Func _businessBaseAddress; + + public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress) + : this(api, () => businessBaseAddress) + { + } + + public FrontOfficeApiClient(IYavscApiClient api, Func businessBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress)); + _ = ResolveBusinessBaseAddress(); + } + + /// + /// Accept (validate) the query under + /// billing code . When + /// carries a non-empty signature, the + /// server stages it on the query's linked estimate and stamps the + /// matching validation date. + /// + public async Task AcceptQueryAsync( + string billingCode, + long queryId, + QueryAcceptanceRequestDto body, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(billingCode)) + throw new ArgumentException("billingCode is required.", nameof(billingCode)); + if (queryId <= 0) + throw new ArgumentOutOfRangeException(nameof(queryId)); + if (body is null) + throw new ArgumentNullException(nameof(body)); + + var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}"; + var response = await _api.CallAsync( + HttpMethod.Post, + Absolute(path), + body: body, + ct: ct).ConfigureAwait(false); + + return response ?? new QueryAcceptanceResponseDto { QueryId = queryId }; + } + + /// + /// Reject the query under billing code + /// . A signature is not meaningful on + /// a rejection; the server refuses one with 400, so this sends no + /// body. + /// + public async Task RejectQueryAsync( + string billingCode, + long queryId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(billingCode)) + throw new ArgumentException("billingCode is required.", nameof(billingCode)); + if (queryId <= 0) + throw new ArgumentOutOfRangeException(nameof(queryId)); + + var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}"; + var response = await _api.CallAsync( + HttpMethod.Post, + Absolute(path), + body: null, + ct: ct).ConfigureAwait(false); + + return response ?? new QueryAcceptanceResponseDto { QueryId = queryId }; + } + + /// + /// Download the estimate linked to query + /// as a LaTeX (.tex) source document + /// (GET api/v1/front/query/{queryId}/estimate.tex). Returns + /// the raw TeX bytes (UTF-8 text). + /// + public Task GetEstimateTexAsync(long queryId, CancellationToken ct = default) + { + if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId)); + var path = $"{PathPrefix}/query/{queryId}/estimate.tex"; + return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct); + } + + /// + /// Download the estimate linked to query + /// as a compiled PDF document + /// (GET api/v1/front/query/{queryId}/estimate.pdf). Returns + /// the raw PDF bytes. Requires texi2pdf on the server host; + /// a non-2xx response throws . + /// + public Task GetEstimatePdfAsync(long queryId, CancellationToken ct = default) + { + if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId)); + var path = $"{PathPrefix}/query/{queryId}/estimate.pdf"; + return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct); + } + + private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); + + private Uri ResolveBusinessBaseAddress() + { + var raw = _businessBaseAddress(); + if (string.IsNullOrWhiteSpace(raw)) + throw new InvalidOperationException("Business base address is required."); + + return new Uri(raw, UriKind.Absolute); + } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs index 1d470b682..e2dd56acd 100644 --- a/src/Yavsc.Api.Client/IYavscApiClient.cs +++ b/src/Yavsc.Api.Client/IYavscApiClient.cs @@ -73,4 +73,23 @@ public interface IYavscApiClient : IAsyncDisposable string path, Func contentFactory, CancellationToken ct = default); + + /// + /// Download a binary resource (PDF, TeX, image, …). Unlike + /// , + /// which JSON-decodes the response, this returns the raw response + /// bytes. The same Bearer-token attach + 401-refresh-retry-once + /// contract applies. + /// + /// + /// A default interface implementation is provided so that test + /// fakes that do not exercise binary download still compile without + /// declaring the member; the real + /// overrides it with the authenticated transport. + /// + Task DownloadAsync( + HttpMethod method, + string path, + CancellationToken ct = default) + => Task.FromResult(Array.Empty()); } diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index f0cdb55c9..c53f9e888 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -17,8 +17,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs index d8cae0265..568175610 100644 --- a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs +++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs @@ -284,11 +284,14 @@ public sealed class EstimateApiControllerTests : IClassFixture.pdf into DestDir. The +// TeX source mirrors what the Estimate_tex template emits for a devis +// signed by both parties: two side-by-side minipages, each with a +// tikzpicture bounded by \useasboundingbox and the strokes drawn by +// SignatureToTikz. +// +// The PDF test requires the system `lualatex` (and the TeX Live packages +// listed in the README runtime-deps section) on the host running the +// tests. On hosts without lualatex it skips cleanly rather than failing. +public sealed class EstimatePdfGenerationTests +{ + // A provider signature: a downstroke, a cross, and a tap (dot). + // PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in + // [0, 10000], y-down. + private static readonly int[] ProStrokes = + { + 4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900, + 3, 2000, 5000, 3500, 4500, 3500, 5100, + 1, 4000, 3000, + }; + + // A client signature: a wavy stroke and a shorter one. + private static readonly int[] CliStrokes = + { + 5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500, + 2, 3000, 6000, 4200, 5500, + }; + + [Fact] + public void SignatureToTikz_RendersBothSignaturesAsVectorDraws() + { + var pro = MakeSig(SignatureType.Pro, ProStrokes); + var cli = MakeSig(SignatureType.Client, CliStrokes); + + var proTex = pro.SignatureToTikz(5, 2.5).ToString(); + var cliTex = cli.SignatureToTikz(5, 2.5).ToString(); + + Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex); + Assert.Contains("\\fill", proTex); // the single-point tap → dot + Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped + Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex); + Assert.Contains("--", cliTex); + } + + [Fact] + public void SignatureToTikz_EmptySignatureIsEmpty() + { + var empty = MakeSig(SignatureType.Pro, Array.Empty()); + Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString()); + } + + [Fact] + public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir() + { + if (!IsLualatexAvailable()) + throw Xunit.Sdk.SkipException.ForSkip( + "lualatex is not installed on this host; skipping PDF generation test. " + + "Install the TeX Live runtime deps documented in the README."); + + var destDir = Path.Combine(Path.GetTempPath(), + "yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destDir); + + try + { + var pro = MakeSig(SignatureType.Pro, ProStrokes); + var cli = MakeSig(SignatureType.Client, CliStrokes); + var baseName = "estimate-test-both-signed"; + + var model = new PdfGenerationViewModel + { + TeXSource = BuildEstimateTeX(pro, cli), + BaseFileName = baseName, + DestDir = destDir, + }; + + var fo = model.GenerateEstimatePdf(); + + // The PDF must exist, at the expected path, and be non-empty. + var expectedPath = Path.Combine(destDir, baseName + ".pdf"); + Assert.True(fo is not null && fo.Exists, + "GenerateEstimatePdf returned a non-existent file. Error: " + + model.GenerationErrorMessage?.Value); + Assert.Equal(expectedPath, fo.FullName); + Assert.True(fo.Length > 0, "generated PDF is empty"); + + // Only the PDF should persist in DestDir — aux/log artifacts + // are cleaned up by GenerateEstimatePdf on success. + var leftovers = Directory.GetFiles(destDir) + .Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal)) + .ToArray(); + Assert.Empty(leftovers); + } + finally + { + if (Directory.Exists(destDir)) + Directory.Delete(destDir, recursive: true); + } + } + + private static Signature MakeSig(SignatureType type, int[] strokes) + => new() + { + Type = type, + CoordinateMax = 10_000, + Strokes = strokes, + CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), + }; + + // Builds the LaTeX source the Estimate_tex template would emit for a + // devis signed by both parties: same preamble (incl. tikz + bera) and + // the two-minipage signature foot, with the strokes drawn by + // SignatureToTikz. + private static string BuildEstimateTeX(Signature pro, Signature cli) + { + var fr = CultureInfo.CreateSpecificCulture("fr-FR"); + var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr); + var proTikz = pro.SignatureToTikz(5, 2.5).ToString(); + var cliTikz = cli.SignatureToTikz(5, 2.5).ToString(); + + return $@"\documentclass[french,11pt]{{article}} +\usepackage{{eurosym}} +\usepackage{{babel}} +\usepackage[T1]{{fontenc}} +\usepackage[utf8]{{inputenc}} +\usepackage[a4paper]{{geometry}} +\usepackage{{units}} +\usepackage{{bera}} +\usepackage{{graphicx}} +\usepackage{{fancyhdr}} +\usepackage{{fp}} +\usepackage{{tikz}} +\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}} +\setlength{{\parindent}}{{0pt}} +\begin{{document}} +Devis n°1 --- test signe par les deux parties + +\vspace{{2em}} +\noindent +\begin{{minipage}}[t]{{0.45\textwidth}} + \textbf{{Signature du fournisseur}}\\ +\small Valide le {date}\\ +\begin{{tikzpicture}} + \useasboundingbox (0,0) rectangle (5,2.5); + {proTikz} +\end{{tikzpicture}} +\end{{minipage}}\hfill +\begin{{minipage}}[t]{{0.45\textwidth}} + \textbf{{Signature du client}}\\ +\small Valide le {date}\\ +\begin{{tikzpicture}} + \useasboundingbox (0,0) rectangle (5,2.5); + {cliTikz} +\end{{tikzpicture}} +\end{{minipage}} +\end{{document}} +"; + } + + private static bool IsLualatexAvailable() + { + try + { + using var p = Process.Start(new ProcessStartInfo("lualatex", "--version") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + if (p is null) return false; + return p.WaitForExit(5000) && p.ExitCode == 0; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs index 55e830660..b2a8fdbee 100644 --- a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs +++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs @@ -1,6 +1,8 @@ +using System.Linq; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Api.Test.Fixtures; using Yavsc.Helpers; @@ -43,19 +45,26 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture JsonBody(new { }); - private static HttpContent WithSignature() => - JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 }); + private static HttpContent WithSignature(SignatureType? type = null) => + type is null + ? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 }) + : JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type }); private static JsonContent JsonBody(object value) => JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json")); /// - /// Seeds an Rdv query with distinct parties (provider alice, - /// client bob) and, when is true, - /// an estimate linked to it via CommandId. Returns the query id - /// and (when seeded) the estimate id. + /// Seeds an Rdv query and, when is + /// true, an estimate linked to it via CommandId. The provider is + /// (default alice) and the client is + /// (default bob) — pass the same value + /// for both to model a user who is both parties. Returns the query + /// id and (when seeded) the estimate id. /// - private (long queryId, long estimateId) SeedQuery(bool withEstimate) + private (long queryId, long estimateId) SeedQuery( + bool withEstimate, + string performer = "alice", + string client = "bob") { WorkflowHelpers.ConfigureBillingService(); _fixture.ResetAndSeedActivityGraph(); @@ -68,11 +77,11 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); var updated = assertDb.RdvQueries.Single(q => q.Id == queryId); - Assert.Equal(QueryStatus.Accepted, updated.Status); + // Default caller is "alice" (the provider) → ProAccepted. + Assert.Equal(QueryStatus.ProAccepted, updated.Status); } [Fact] @@ -146,7 +156,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); - Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); + Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); Assert.True(estimate.ProviderValidationDate != default); @@ -173,7 +183,7 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); - Assert.Equal(QueryStatus.Accepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); + Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); Assert.True(estimate.ClientValidationDate != default); @@ -243,4 +253,90 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + + // Signatures ride on the estimate payload as two dedicated + // slots, not a list: the provider signed here, so signaturePro + // is populated and signatureClient is absent/null. + var proSig = body.GetProperty("signaturePro"); + Assert.Equal(JsonValueKind.Object, proSig.ValueKind); + Assert.Equal(JsonValueKind.Array, proSig.GetProperty("strokes").ValueKind); + Assert.True(body.TryGetProperty("signatureClient", out var cli) + && cli.ValueKind == JsonValueKind.Null, + "signatureClient should be null when only the provider has signed"); + } + + // When one user is both the provider and the client of a query, the + // server cannot infer which side they are signing as from identity + // alone (it would always pick the provider). The caller declares the + // side (signatureType=1 here = Client); the server honors it because + // the caller is that party, and stores a Client signature. + [Fact] + public async Task Accept_declared_client_side_stores_client_signature_when_user_is_both_parties() + { + var (queryId, estimateId) = SeedQuery(withEstimate: true, performer: "alice", client: "alice"); + + using var http = NewClient(subject: "alice"); + var response = await http.PostAsync( + $"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", + WithSignature(SignatureType.Client), // signing as the client + TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + using var assertScope = _fixture.Services.CreateScope(); + var assertDb = assertScope.ServiceProvider.GetRequiredService(); + + Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status); + + var estimate = assertDb.Estimates.Single(e => e.Id == estimateId); + Assert.True(estimate.ClientValidationDate != default); + Assert.True(estimate.ProviderValidationDate == default); + + var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId); + Assert.Equal(SignatureType.Client, signature.Type); + Assert.Equal("alice", signature.SignerId); + } + + // A provider may not sign as the client (and vice versa): declaring + // the other party's side is refused even though the caller is a + // party to the query. Write is authorized only for the signature's + // author. + [Fact] + public async Task Accept_declaring_other_party_side_is_forbidden() + { + var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob + + using var http = NewClient(subject: "alice"); // alice is the provider + var response = await http.PostAsync( + $"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", + WithSignature(SignatureType.Client), // alice tries to sign as the client + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } } \ No newline at end of file diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj index 895b991fa..21cbdc32c 100644 --- a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj +++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -30,4 +30,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index ba43094a9..aa195712d 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -157,6 +157,8 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.ProAccepted + || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -169,6 +171,8 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.ProAccepted + || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -181,6 +185,8 @@ namespace Yavsc.ApiControllers .Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => q.Status == QueryStatus.Inserted || q.Status == QueryStatus.Accepted + || q.Status == QueryStatus.ProAccepted + || q.Status == QueryStatus.ClientAccepted || q.Status == QueryStatus.InProgress) .Cast() .ToList() @@ -329,9 +335,9 @@ namespace Yavsc.ApiControllers return new ChallengeResult(); } - var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id); + var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id); FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename)); - if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" }); + if (!fi.Exists) return NotFound(new { Error = "Client signature not found" }); return File(fi.OpenRead(), "application/x-pdf", filename); } diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs index 1ee88f953..c1e25cc27 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs @@ -40,7 +40,7 @@ namespace Yavsc.Controllers else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ; // or just do nothing return new StatusCodeResult(StatusCodes.Status403Forbidden); - return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId)); + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId)); } // GET: api/Estimate/5 [HttpGet("{id}", Name = "GetEstimate")] @@ -51,7 +51,7 @@ namespace Yavsc.Controllers return BadRequest(ModelState); } - Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id); + Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id); if (estimate == null) { @@ -209,21 +209,23 @@ namespace Yavsc.Controllers public IActionResult GetOngoingEstimatesAsClient() { var uid = User.GetUserId(); - return Ok(_context.Estimates.Include(e => e.Bill) + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) .Where(e => e.ClientId == uid && e.ProviderValidationDate != default && e.ClientValidationDate == default)); } // GET: api/estimate/asprovider - // Ongoing estimates (not yet validated by the client) established - // by the current user as the provider. + // Estimates awaiting the provider's signature, established by the + // current user as the provider. Once the provider validates + // (ProviderValidationDate stamped), the estimate leaves this list + // and appears in the client's "awaiting client signature" list. [HttpGet("asprovider")] public IActionResult GetOngoingEstimatesAsProvider() { var uid = User.GetUserId(); - return Ok(_context.Estimates.Include(e => e.Bill) - .Where(e => e.OwnerId == uid && e.ClientValidationDate == default)); + return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures) + .Where(e => e.OwnerId == uid && e.ProviderValidationDate == default)); } protected override void Dispose(bool disposing) diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index 5f328728c..eb7995f5e 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Yavsc.Abstract.Workflow; using Yavsc.Helpers; using Yavsc.Models; @@ -8,6 +11,7 @@ using Yavsc.Models.Billing; using Yavsc.Services; using Yavsc.Server.Helpers; using Yavsc.ViewModels.FrontOffice; +using Yavsc.ViewModels.Gen; namespace Yavsc.ApiControllers { @@ -18,15 +22,36 @@ namespace Yavsc.ApiControllers ApplicationDbContext dbContext; private readonly IBillingService billing; private readonly ILogger logger; + private readonly SiteSettings siteSettings; + private readonly IRazorViewEngine viewEngine; + // The view-rendering dependencies (siteSettings, viewEngine) are + // optional: only EstimateTex/EstimatePdf use them, and those run in + // the Api host where Program.cs wires AddControllersWithViews + + // SiteSettings. The accept/reject endpoints (and the Api integration + // test host, which composes with AddControllers alone) never touch + // them, so they must not be required for controller activation. + // + // .NET 10 does not register a single IViewEngine service (the view + // engine is registered as IRazorViewEngine and surfaced through + // MvcViewOptions.ViewEngines). IRazorViewEngine (RazorViewEngine) + // implements IViewEngine, so we inject that and hand it to + // TeXHelpers.RenderViewToString as an IViewEngine. RenderViewToString + // builds its ActionContext from the controller's own ControllerContext, + // so no IActionContextAccessor is needed (and that type is deprecated + // in .NET 10). public FrontOfficeApiController( ApplicationDbContext context, ILoggerFactory loggerFactory, + IOptions siteSettings = null, + IRazorViewEngine viewEngine = null, IBillingService billing = null) { dbContext = context; this.billing = billing ?? new BillingService(context); logger = loggerFactory.CreateLogger(); + this.siteSettings = siteSettings?.Value; + this.viewEngine = viewEngine; } [HttpGet("profiles/{actCode}")] @@ -35,6 +60,126 @@ namespace Yavsc.ApiControllers return await dbContext.ListPerformersAsync(billing, actCode); } + // GET: api/front/query/{queryId}/estimate.tex + // + // Renders the estimate linked to the query (Estimate.CommandId == + // queryId) as a LaTeX source document. The view is a generation + // template (Layout = "null"), not a web page: it is returned with + // a text/x-tex content type so the caller can save it as a .tex + // file. Either party (client or provider) or an admin may read it. + [HttpGet("query/{queryId}/estimate.tex")] + public async Task EstimateTex(long queryId, CancellationToken token) + { + if (viewEngine is null || siteSettings is null) + return Problem("TeX rendering is not configured on this host.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var estimate = await LoadEstimateForRenderAsync(queryId, token); + if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" }); + + var uid = User.GetUserId(); + if (!User.IsInRole(Constants.AdminGroupName) + && uid != estimate.ClientId && uid != estimate.OwnerId) + return Forbid(); + + PrepareEstimateViewBag(estimate); + Response.ContentType = "text/x-tex"; + return View("Estimate_tex", estimate); + } + + // GET: api/front/query/{queryId}/estimate.pdf + // + // Renders the same LaTeX template to a string, compiles it to PDF + // via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams + // the generated PDF bytes back. Requires /usr/bin/texi2pdf on the + // server host; on hosts without it the endpoint returns a problem. + [HttpGet("query/{queryId}/estimate.pdf")] + public async Task EstimatePdf(long queryId, CancellationToken token) + { + if (viewEngine is null || siteSettings is null) + { + string msg = "PDF rendering is not configured on this host."; + logger.LogError(msg); + return Problem(msg, + statusCode: StatusCodes.Status503ServiceUnavailable); + + } + + var estimate = await LoadEstimateForRenderAsync(queryId, token); + if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" }); + + var uid = User.GetUserId(); + if (!User.IsInRole(Constants.AdminGroupName) + && uid != estimate.ClientId && uid != estimate.OwnerId) + return Forbid(); + + PrepareEstimateViewBag(estimate); + + var baseFileName = $"estimate-{estimate.Id}"; + // Resolve the bills dir to an absolute path once: appsettings + // configures it as a relative path ("bills"), and both the + // generation (GenerateEstimatePdf) and the read-back below must + // agree on the same location regardless of the host's cwd. + var billsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName; + var model = new PdfGenerationViewModel + { + BaseFileName = baseFileName, + DestDir = billsDir, + Temp = siteSettings.TempDir, + }; + + try + { + // 1. Render the LaTeX template (Estimate_tex) to a TeX source + // string, using the estimate as the view model. + model.TeXSource = this.RenderViewToString(viewEngine, "Estimate_tex", estimate); + // 2. Render the generation view (Estimate_pdf). Its @{} block + // calls SiteSettings.GenerateEstimatePdf(Model), which + // compiles the TeX to PDF on disk as a side effect of the + // render. The HTML the view emits is not what we return — + // we stream the generated PDF bytes — but driving the + // generation through the view keeps the template as the + // output producer, per the original design. + if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists) + { + return File(fo.OpenRead(), "application/pdf"); + } + string msg = "TeX render failed: " + model.GenerationErrorMessage; + logger.LogError(msg); + return Problem(msg); + + } + catch (Exception ex) + { + logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId); + return Problem("TeX render failed: " + ex.Message); + } + } + + // Loads the estimate linked to a query with the navigation the + // Estimate_tex template reads (client + performer profile + bill + // + the captured signatures, drawn as tikz at the foot of the devis). + private Task LoadEstimateForRenderAsync(long queryId, CancellationToken token) + => dbContext.Estimates + .Where(e => e.CommandId == queryId) + .OrderByDescending(e => e.Id) + .Include(e => e.Bill) + .Include(e => e.Signatures) + .Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress) + .Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress) + .Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer) + .FirstOrDefaultAsync(token); + + // Sets the ViewBag keys the Estimate_tex template reads. A devis + // is never a bill (AsBill=false) and is never acquitted. + private void PrepareEstimateViewBag(Estimate estimate) + { + ViewBag.AsBill = false; + ViewBag.Acquitted = false; + ViewBag.BillsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName; + ViewBag.AvatarsDir = new System.IO.DirectoryInfo(siteSettings.Avatars).FullName; + } + // POST: api/front/query/accept // // Either party of the query may accept: the provider @@ -57,6 +202,15 @@ namespace Yavsc.ApiControllers if (query is null) return BadRequest(new { Error = "query not found" }); Signature? signature = null; + // actingRole is the role the caller is signing / accepting + // as. It defaults to the role inferred from the caller's + // identity, but when a signature is submitted the caller + // declares the side (Pro/Client) and the server honors it + // only if the caller is that party. This disambiguates a + // user who is both parties (where inference would always + // pick the provider) and makes "write only for its author" + // explicit. + ActorRole actingRole = role; if (HasSignature(body)) { // Admins bypass authorization but are neither party of @@ -71,20 +225,37 @@ namespace Yavsc.ApiControllers if (estimate is null) return BadRequest(new { Error = "no estimate linked to this query" }); - var type = role == ActorRole.Provider + var uid = User.GetUserId(); + bool isProvider = query.PerformerId == uid; + bool isClient = query.ClientId == uid; + + // The caller declares which side they sign as. Null + // falls back to the role inferred from identity. + var declared = body!.SignatureType; + if (declared.HasValue) + { + if (declared.Value == SignatureType.Pro && !isProvider) + return Forbid(); + if (declared.Value == SignatureType.Client && !isClient) + return Forbid(); + actingRole = declared.Value == SignatureType.Pro + ? ActorRole.Provider + : ActorRole.Client; + } + + var type = actingRole == ActorRole.Provider ? SignatureType.Pro : SignatureType.Client; var payload = new SignaturePadPayload { - CoordinateMax = body!.CoordinateMax, + CoordinateMax = body.CoordinateMax, CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow, Strokes = body.Strokes!, }; try { - var uid = User.GetUserId(); signature = await EstimateSignaturePersister.StageAsync( dbContext, estimate.Id, type, uid, payload, token); } @@ -94,13 +265,22 @@ namespace Yavsc.ApiControllers return BadRequest(new { Error = "signature stage failed", Detail = ex.Message }); } - if (role == ActorRole.Provider) + if (actingRole == ActorRole.Provider) estimate.ProviderValidationDate = DateTime.UtcNow; else estimate.ClientValidationDate = DateTime.UtcNow; } - query.Status = QueryStatus.Accepted; + // The party-specific status records who accepted: the + // provider (ProAccepted) or the client (ClientAccepted). + // An admin accepting (neither party) falls back to the + // legacy generic Accepted. + query.Status = actingRole switch + { + ActorRole.Provider => QueryStatus.ProAccepted, + ActorRole.Client => QueryStatus.ClientAccepted, + _ => QueryStatus.Accepted, + }; try { @@ -190,28 +370,3 @@ namespace Yavsc.ApiControllers internal enum ActorRole { None, Provider, Client, Admin } } - -namespace Yavsc.ViewModels.FrontOffice -{ - /// - /// Body of POST /api/v1/front/query/accept and - /// .../reject. The field is optional: - /// when present (non-empty) on accept, it is the PostIt wire-format - /// signature captured with the acceptance. All fields are optional - /// so a caller may post {} to accept/reject without a - /// signature. - /// - public class QueryAcceptanceRequest - { - /// - /// Wire-format strokes. See - /// PostIt.Models.SignaturePadData. Null or empty means - /// no signature. - /// - public int[]? Strokes { get; set; } - - public int CoordinateMax { get; set; } = 10_000; - - public DateTime? CapturedAtUtc { get; set; } - } -} \ No newline at end of file diff --git a/src/Yavsc.Api/Helpers/TeXHelpers.cs b/src/Yavsc.Api/Helpers/TeXHelpers.cs new file mode 100644 index 000000000..c35806bda --- /dev/null +++ b/src/Yavsc.Api/Helpers/TeXHelpers.cs @@ -0,0 +1,325 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Yavsc.Models.Billing; + +namespace Yavsc.Helpers +{ + using Microsoft.AspNetCore.Mvc.Razor; + using ViewModels.Gen; + public class TeXString : HtmlString + { + + public TeXString(TeXString teXString): base(teXString.ToString()) + { + } + + public TeXString(string str) : base(str) + { + + + } + + public static TeXString operator+ (TeXString a, TeXString b) { + return new TeXString(a.ToString()+b.ToString()); + } + + } + public class Replacement + { + string target; + string replacement; + public Replacement(string target, string replacement) + { + this.target = target; + this.replacement = replacement; + } + public string Execute(string source) + { + return source?.Replace(target, replacement) ?? null; + } + } + public static class TeXHelpers + { + public static readonly Replacement[] SpecialCharsDefaultRendering = + { + new Replacement("<","\\textless"), + new Replacement(">","\\textgreater"), + new Replacement("©","\\copyright"), + new Replacement("®","\\textregistered"), + new Replacement("\\","\\textbackslash"), + new Replacement("™","\\texttrademark"), + new Replacement("¶","\\P"), + new Replacement("|","\\textbar"), + new Replacement("%","\\%"), + new Replacement("{","\\{"), + new Replacement("}","\\}"), + new Replacement("_","\\_"), + new Replacement("#","\\#"), + new Replacement("$","\\$"), + new Replacement("_","\\_"), + new Replacement("¿","\\textquestiondown"), + new Replacement("§","\\S"), + new Replacement("£","\\pounds"), + new Replacement("&","\\&"), + new Replacement("¡","\\textexclamdown"), + new Replacement("†","\\dag"), + new Replacement("–","\\textendash"), + new Replacement("°","\\textdegree") + }; + + public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}") + { + if (source==null) return new TeXString(defaultValue); + string result=source; + foreach (var r in SpecialCharsDefaultRendering) + { + result = r.Execute(result); + } + return new TeXString(result); + } + + public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}") + { + if (source==null) return new TeXString(defaultValue); + string result=source; + foreach (var r in SpecialCharsDefaultRendering) + { + result = r.Execute(result); + } + result = result.Replace("\n","\\tabularnewline "); + return new TeXString(result); + } + + + public static string NewLinesWith(this string target, string separator) + { + var items = target.Split(new char[] { '\n' }).Where( + s => !string.IsNullOrWhiteSpace(s)); + + return string.Join(separator, items); + } + + public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\") + { + if (source == null) return new TeXString(defaultValue); + return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) ); + } + + public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}") + { + if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue); + var alines = source.Split(','); + var texlines = alines.Select(l=>l.ToTeX().ToString()); + return new TeXString(string.Join(lineSeparator,texlines)); + } + + /// + /// Renders a captured signature as TikZ \draw/\fill + /// paths, suitable for embedding in a tikzpicture at the + /// foot of a devis. The signature is the PostIt wire format — a + /// length-prefixed sequence of strokes, each stroke being + /// [k, x0, y0, x1, y1, …] with coordinates normalised to + /// [0, CoordinateMax] and y growing downward (screen + /// orientation). We denormalise to a × + /// canvas and flip y so the signature + /// appears upright on paper. A single-point stroke (a pen-down + /// tap) is rendered as a filled dot, mirroring the round-capped + /// Polyline the Avalonia pad draws for it. + /// + /// The persisted signature. Null/empty yields + /// , so the caller can gate the + /// tikzpicture on a non-empty result. + public static HtmlString SignatureToTikz( + this Signature sig, double widthCm, double heightCm) + { + if (sig is null || sig.Strokes is null || sig.Strokes.Length == 0) + return HtmlString.Empty; + + double max = sig.CoordinateMax > 0 ? sig.CoordinateMax : 10_000.0; + var strokes = sig.Strokes; + var sb = new StringBuilder(); + + int i = 0; + while (i < strokes.Length) + { + int k = strokes[i]; + if (k <= 0) break; + if (i + 1 + 2 * k > strokes.Length) break; + i++; // skip the length prefix + + // A single-point stroke: a tap. Draw a round dot so it + // shows instead of a zero-length, invisible \draw. + if (k == 1) + { + double px = strokes[i] / max * widthCm; + double py = heightCm - strokes[i + 1] / max * heightCm; + sb.Append(CultureInfo.InvariantCulture, + $"\\fill ({px:F3},{py:F3}) circle (0.8pt); "); + i += 2; + continue; + } + + sb.Append("\\draw[line cap=round, line join=round, line width=1.5pt] "); + for (int p = 0; p < k; p++) + { + double px = strokes[i + 2 * p] / max * widthCm; + double py = heightCm - strokes[i + 2 * p + 1] / max * heightCm; + if (p == 0) sb.Append(CultureInfo.InvariantCulture, + $"({px:F3},{py:F3})"); + else sb.Append(CultureInfo.InvariantCulture, + $" -- ({px:F3},{py:F3})"); + } + sb.Append("; "); + i += 2 * k; + } + + return new HtmlString(sb.ToString()); + } + + public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model) + { + string errorMsg = null; + // Resolve to an absolute path: SiteSettings.Bills may be a + // relative path (e.g. "bills"), and lualatex resolves + // -output-directory against its working directory — passing a + // relative path to both would nest them (/bills/bills) and + // the log/pdf write would fail. + var billdir = new DirectoryInfo(Model.DestDir).FullName; + string name = Model.BaseFileName; + FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf")); + System.IO.Directory.CreateDirectory(billdir); + + // Compile the LaTeX source with the system lualatex, feeding it + // the TeX on standard input (no intermediate .tex file) and + // directing the resulting .pdf straight into the bills + // directory. lualatex is a file-based engine — it cannot emit + // the PDF on stdout — so the pdf lands at its final destination + // and we only clean the transient aux/log artifacts beside it. + // + // We pass "/dev/stdin" as the input filename so lualatex treats + // stdin as a regular input *file* rather than the terminal: with + // no filename argument it reads stdin in terminal mode, which + // emergency-stops after the first line of a multi-line document. + // /dev/stdin makes the full multi-line TeX compile cleanly. (The + // API host is Linux — the systemd service and lualatex dep are + // Linux-only too.) + // + // We capture stdout/stderr so a LaTeX error (missing .sty, + // missing \includegraphics target, …) is surfaced in + // GenerationErrorMessage instead of being buried in the .log + // we then delete. + int exitCode; + string stdout = null, stderr = null; + try + { + using (Process p = new Process()) + { + p.StartInfo = new ProcessStartInfo + { + UseShellExecute = false, + WorkingDirectory = billdir, + FileName = "lualatex", + Arguments = $"-interaction=nonstopmode -halt-on-error" + + $" -jobname=\"{name}\" -output-directory=\"{billdir}\"" + + " /dev/stdin", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + p.Start(); + // Write the TeX source, then drain stdout/stderr. Read + // them after WaitForExit — lualatex's output is small + // (a few KB), well under the pipe buffer, so no deadlock. + using (var stdin = p.StandardInput) + { + stdin.Write(Model.TeXSource); + } + p.WaitForExit(); + exitCode = p.ExitCode; + stdout = p.StandardOutput.ReadToEnd(); + stderr = p.StandardError.ReadToEnd(); + } + } + catch (Exception ex) + { + errorMsg = "lualatex invocation failed: " + ex.Message; + exitCode = -1; + } + + if (exitCode != 0 && errorMsg == null) + { + // Surface the engine's own diagnostics (stderr carries the + // "! Emergency stop" / "! LaTeX Error: …" lines; stdout has + // the transcript banner) so the caller can see why it failed + // without having to find the (soon-deleted) .log file. + var diag = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr; + errorMsg = $"Pdf generation failed with exit code {exitCode}: {diag}"; + } + else if (!fo.Exists && errorMsg == null) + errorMsg = "Pdf generation produced no output"; + + // Remove the LaTeX build artifacts left beside the pdf so only + // .pdf persists in the bills directory. Keep the .log on + // failure so it can be inspected. + foreach (var ext in new[] { ".aux", ".log", ".out", ".fls", + ".fdb_latexmk", ".synctex.gz", ".toc" }) + { + if (ext == ".log" && !fo.Exists) continue; + var fileName = name + ext; + var f = new FileInfo(System.IO.Path.Combine(billdir, fileName)); + if (f.Exists) { try { f.Delete(); } catch { } } + } + + Model.Generated = fo.Exists; + Model.GenerationErrorMessage = new HtmlString(errorMsg); + return fo; + } + + public static string RenderViewToString( + this Controller controller, IRazorViewEngine engine, + string viewName, object model, bool isMainPage = true) + { + if (engine == null) + throw new InvalidOperationException("no engine"); + + // Build an ActionContext from the controller's own context — + // .NET 10 deprecated IActionContextAccessor, and the controller + // already holds the live request context on its ControllerContext. + var controllerContext = controller.ControllerContext; + var actionContext = new ActionContext( + controllerContext.HttpContext, + controllerContext.RouteData, + controllerContext.ActionDescriptor); + + ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage); + if (!viewResult.Success) + throw new InvalidOperationException( + $"View '{viewName}' not found. Searched locations: " + + string.Join(", ", viewResult.SearchedLocations)); + + // Hand the model to the view through ViewData so the template's + // @model directive resolves it (the previous implementation never + // set ViewData.Model, leaving Model null inside the template). + controller.ViewData.Model = model; + + using (var sw = new StringWriter()) + { + var viewContext = new ViewContext( + actionContext, + viewResult.View, + controller.ViewData, + controller.TempData, + sw, + new HtmlHelperOptions()); + viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult(); + return sw.GetStringBuilder().ToString(); + } + } + } +} diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index be4ae6579..8daff34d9 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -55,7 +55,11 @@ internal class Program }); }) .AddYavscCors(builder.Configuration) - .AddControllers(); + .AddControllersWithViews(); + + // Binds the "Site" section (Bills, TempDir, Avatars, …) used by + // the document-generation flow and BillingController. + services.Configure(builder.Configuration.GetSection("Site")); // accepts any access token issued by identity server services.AddAuthentication("Bearer") diff --git a/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs b/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs new file mode 100644 index 000000000..41e4dd650 --- /dev/null +++ b/src/Yavsc.Api/ViewModels/FrontOffice/QueryAcceptanceRequest.cs @@ -0,0 +1,40 @@ +using Yavsc.Models.Billing; + +namespace Yavsc.ViewModels.FrontOffice +{ + /// + /// Body of POST /api/v1/front/query/accept and + /// .../reject. The field is optional: + /// when present (non-empty) on accept, it is the PostIt wire-format + /// signature captured with the acceptance. All fields are optional + /// so a caller may post {} to accept/reject without a + /// signature. + /// + public class QueryAcceptanceRequest + { + /// + /// Wire-format strokes. See + /// PostIt.Models.SignaturePadData. Null or empty means + /// no signature. + /// + public int[]? Strokes { get; set; } + + public int CoordinateMax { get; set; } = 10_000; + + public DateTime? CapturedAtUtc { get; set; } + + /// + /// Which signature is being submitted: + /// (the provider) or + /// . When + /// is present this declares the side the caller is signing as; + /// the server honors it only if the caller is that party + /// (query.PerformerId for Pro, query.ClientId + /// for Client). This disambiguates the case where one user is + /// both parties, and makes "write only for its author" + /// explicit. When null, the server falls back to the role + /// inferred from the caller's identity. + /// + public SignatureType? SignatureType { get; set; } + } +} diff --git a/src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs b/src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs similarity index 98% rename from src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs rename to src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs index fe934e3ac..841e8d5dd 100644 --- a/src/Yavsc.Org/ViewModels/Gen/PdfGenerationViewModel.cs +++ b/src/Yavsc.Api/ViewModels/Gen/PdfGenerationViewModel.cs @@ -5,7 +5,7 @@ namespace Yavsc.ViewModels.Gen { public class PdfGenerationViewModel { - + [Required] public string TeXSource { get; set; } [Required] diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml b/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml similarity index 99% rename from src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml rename to src/Yavsc.Api/Views/Shared/Bill_tex.cshtml index 353d124e0..f0a3d2ad3 100644 --- a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml +++ b/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml @@ -3,7 +3,7 @@ @using System.Globalization @model IBillable @{ - Layout = "null"; + Layout = null; var pro = ViewBag.PerformerProfile; var from = ViewBag.Performer; var to = ViewBag.Client; diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml b/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml similarity index 72% rename from src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml rename to src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml index 4c35d4bdd..bbe4326b4 100644 --- a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml +++ b/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml @@ -3,7 +3,7 @@ @using System.Globalization @model Estimate @{ - Layout = "null"; + Layout = null; var pro = Model.Query.PerformerProfile; var from = Model.Query.PerformerProfile.Performer; var to = Model.Query.Client; @@ -11,7 +11,6 @@ var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var isestimate = !ViewBag.AsBill; - var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png"); } \documentclass[french,11pt]{article} @@ -25,6 +24,7 @@ \usepackage{graphicx} \usepackage{fancyhdr} \usepackage{fp} +\usepackage{tikz} \def\TVA{20} % Taux de la TVA @@ -49,7 +49,7 @@ \global\let\TotalHT\TotalHT \global\let\TotalTVA\TotalTVA \global\let\TotalTTC\TotalTTC - \cr + \cr \hline \textbf{Total} & & & \TotalHT } @@ -77,12 +77,12 @@ % Infos Client \def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client -\def\ClientAdresse{ +\def\ClientAdresse{ % Adresse du client @PostalAddress @if (PostalAddress!=null) {@PostalAddress} @if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {@TeXHelpers.ToTeX(to.PhoneNumber)\\ - } + } E-mail: @TeXHelpers.ToTeX(to.Email) } @@ -105,7 +105,7 @@ \renewcommand{\headrulewidth}{0pt} \cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { - @proaddrm } \newline - \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } + \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } } } @@ -113,10 +113,10 @@ % Logo de la société @if (from.Avatar != null) { -\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png} +\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png} -} else { -%\includegraphics{logo.png} +} else { +%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png} } % Nom et adresse de la société @@ -170,12 +170,12 @@ \begin{center} \begin{tabular}{|c c c c|} @if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode) - && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { + && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { \hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\ @bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\ } - @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { + @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { \hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\ \hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC } @@ -188,19 +188,46 @@ } -@if (!(Model.ProviderValidationDate==null)) { - -\begin{flushright} - @(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) -\end{flushright} -@if (prosign.Exists) { - -\begin{center} - \hspace{263pt} - \includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png} -\end{center} - +@{ + // Captured signatures, if any. The Estimate.Signatures collection + // is loaded by the controller; SignaturePro/SignatureClient project + // the row by type. We draw them as vector TikZ at the foot of the + // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to + // produce or \includegraphics to fail on. + var fr = CultureInfo.CreateSpecificCulture("fr-FR"); + var proSig = Model.SignaturePro; + var cliSig = Model.SignatureClient; } - + +\vspace{2em} +\noindent +\begin{minipage}[t]{0.45\textwidth} + \textbf{Signature du fournisseur}\\ +@if (Model.ProviderValidationDate != default(DateTime)) { +\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ } +@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) { +\begin{tikzpicture} + \useasboundingbox (0,0) rectangle (5,2.5); + @proSig.SignatureToTikz(5, 2.5) +\end{tikzpicture} +} else { +\rule[-2.2cm]{5cm}{0.4pt} +} +\end{minipage}\hfill +\begin{minipage}[t]{0.45\textwidth} + \textbf{Signature du client}\\ +@if (Model.ClientValidationDate != default(DateTime)) { +\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ +} +@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) { +\begin{tikzpicture} + \useasboundingbox (0,0) rectangle (5,2.5); + @cliSig.SignatureToTikz(5, 2.5) +\end{tikzpicture} +} else { +\rule[-2.2cm]{5cm}{0.4pt} +} +\end{minipage} + \end{document} diff --git a/src/Yavsc.Api/Views/_ViewImports.cshtml b/src/Yavsc.Api/Views/_ViewImports.cshtml new file mode 100644 index 000000000..788aed5ab --- /dev/null +++ b/src/Yavsc.Api/Views/_ViewImports.cshtml @@ -0,0 +1,12 @@ +@using Yavsc +@using Yavsc.Helpers +@using Yavsc.Billing +@using Yavsc.Models +@using Yavsc.Models.Billing +@using Yavsc.ViewModels.Gen +@using Microsoft.AspNetCore.Mvc +@using Microsoft.AspNetCore.Html + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +@inject Microsoft.Extensions.Options.IOptions SiteSettings \ No newline at end of file diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index 1a2accfc8..72231b0af 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -7,8 +7,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 14b41e9e7..4b3abb75c 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -9,8 +9,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -32,4 +32,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 28b5841c9..204ae6c73 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -8,8 +8,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 533333c3b..51d3660a9 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -11,8 +11,8 @@ $(MSBuildProjectDirectory)\test.runsettings 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -88,4 +88,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs index 549d80942..ac97193d0 100644 --- a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs +++ b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs @@ -8,7 +8,6 @@ namespace Yavsc.Controllers using Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; - using Microsoft.Extensions.Options; using Models; using ViewModels.FrontOffice; using Yavsc.Server.Helpers; @@ -21,22 +20,20 @@ namespace Yavsc.Controllers readonly ILogger _logger; readonly IStringLocalizer _SR; private readonly IBillingService _billing; - private readonly SiteSettings _siteSettings; public FrontOfficeController(ApplicationDbContext context, UserManager userManager, IBillingService billing, ILoggerFactory loggerFactory, - IStringLocalizer SR, - IOptions siteSettings) + IStringLocalizer SR) { _context = context; _userManager = userManager; _logger = loggerFactory.CreateLogger(); _SR = SR; _billing = billing; - _siteSettings = siteSettings.Value; } + public ActionResult Index() { var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -46,9 +43,9 @@ namespace Yavsc.Controllers { EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted && c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(), - EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted + EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && (c.Status == QueryStatus.ProAccepted || c.Status == QueryStatus.Accepted) && c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(), - EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(), + EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && (e.Query.Status == QueryStatus.ProAccepted || e.Query.Status == QueryStatus.Accepted)).Count(), BillToSignAsCliCount = 0, NewPayementsCount = 0 @@ -80,61 +77,5 @@ namespace Yavsc.Controllers return View(result); } - - [Produces("text/x-tex"), Authorize, Route("estimate-{id}.tex")] - [HttpGet] - public ViewResult EstimateTex(long id) - { - var estimate = _context.Estimates.Include(x => x.Query) - .Include(x => x.Query.Client) - .Include(x => x.Query.PerformerProfile) - .Include(x => x.Query.PerformerProfile.OrganizationAddress) - .Include(x => x.Query.PerformerProfile.Performer) - .Include(e => e.Bill).FirstOrDefault(x => x.Id == id); - Response.ContentType = "text/x-tex"; - return View("Estimate.tex", estimate); - } - - [Authorize, Route("Estimate-{id}.pdf")] - [HttpGet] - public IActionResult EstimatePdf(long id) - { - ViewBag.TempDir = _siteSettings.TempDir; - ViewBag.BillsDir = _siteSettings.Bills; - var estimate = _context.Estimates.Include(x => x.Query) - .Include(x => x.Query.Client) - .Include(x => x.Query.PerformerProfile) - .Include(x => x.Query.PerformerProfile.OrganizationAddress) - .Include(x => x.Query.PerformerProfile.Performer) - .Include(e => e.Bill).FirstOrDefault(x => x.Id == id); - if (estimate == null) - throw new Exception("No data"); - return View("Estimate.pdf", estimate); - } - - [Authorize] - public IActionResult EstimateProValidation() - { - throw new NotImplementedException(); - - } - - [Authorize] - public IActionResult EstimateClientValidation() - { - throw new NotImplementedException(); - - } - [Authorize] - public IActionResult BillValidation() - { - throw new NotImplementedException(); - - } - [Authorize] - public IActionResult BillAcquitment() - { - throw new NotImplementedException(); - } } } diff --git a/src/Yavsc.Org/Helpers/TeXHelpers.cs b/src/Yavsc.Org/Helpers/TeXHelpers.cs deleted file mode 100644 index f7cc812fb..000000000 --- a/src/Yavsc.Org/Helpers/TeXHelpers.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Html; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.ViewEngines; - -namespace Yavsc.Helpers -{ - using Microsoft.AspNetCore.Mvc.Infrastructure; - using ViewModels.Gen; - public class TeXString : HtmlString - { - - public TeXString(TeXString teXString): base(teXString.ToString()) - { - } - - public TeXString(string str) : base(str) - { - - - } - - public static TeXString operator+ (TeXString a, TeXString b) { - return new TeXString(a.ToString()+b.ToString()); - } - - } - public class Replacement - { - string target; - string replacement; - public Replacement(string target, string replacement) - { - this.target = target; - this.replacement = replacement; - } - public string Execute(string source) - { - return source?.Replace(target, replacement) ?? null; - } - } - public static class TeXHelpers - { - public static readonly Replacement[] SpecialCharsDefaultRendering = - { - new Replacement("<","\\textless"), - new Replacement(">","\\textgreater"), - new Replacement("©","\\copyright"), - new Replacement("®","\\textregistered"), - new Replacement("\\","\\textbackslash"), - new Replacement("™","\\texttrademark"), - new Replacement("¶","\\P"), - new Replacement("|","\\textbar"), - new Replacement("%","\\%"), - new Replacement("{","\\{"), - new Replacement("}","\\}"), - new Replacement("_","\\_"), - new Replacement("#","\\#"), - new Replacement("$","\\$"), - new Replacement("_","\\_"), - new Replacement("¿","\\textquestiondown"), - new Replacement("§","\\S"), - new Replacement("£","\\pounds"), - new Replacement("&","\\&"), - new Replacement("¡","\\textexclamdown"), - new Replacement("†","\\dag"), - new Replacement("–","\\textendash"), - new Replacement("°","\\textdegree") - }; - - public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}") - { - if (source==null) return new TeXString(defaultValue); - string result=source; - foreach (var r in SpecialCharsDefaultRendering) - { - result = r.Execute(result); - } - return new TeXString(result); - } - - public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}") - { - if (source==null) return new TeXString(defaultValue); - string result=source; - foreach (var r in SpecialCharsDefaultRendering) - { - result = r.Execute(result); - } - result = result.Replace("\n","\\tabularnewline "); - return new TeXString(result); - } - - - public static string NewLinesWith(this string target, string separator) - { - var items = target.Split(new char[] { '\n' }).Where( - s => !string.IsNullOrWhiteSpace(s)); - - return string.Join(separator, items); - } - - public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\") - { - if (source == null) return new TeXString(defaultValue); - return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) ); - } - - public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}") - { - if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue); - var alines = source.Split(','); - var texlines = alines.Select(l=>l.ToTeX().ToString()); - return new TeXString(string.Join(lineSeparator,texlines)); - } - - public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model) - { - string errorMsg = null; - var billdir = Model.DestDir; - var tempdir = settings.TempDir; - string name = Model.BaseFileName; - string fullname = new FileInfo( - System.IO.Path.Combine(tempdir, name)).FullName; - string ofullname = new FileInfo( - System.IO.Path.Combine(billdir, name)).FullName; - - FileInfo fi = new FileInfo(fullname + ".tex"); - FileInfo fo = new FileInfo(ofullname + ".pdf"); - using (StreamWriter sw = new StreamWriter(fi.FullName)) - { - sw.Write(Model.TeXSource); - } - if (!fi.Exists) - { - errorMsg = "Source write failed"; - } - else - { - using (Process p = new Process()) - { - p.StartInfo.WorkingDirectory = tempdir; - p.StartInfo = new ProcessStartInfo - { - UseShellExecute = false, - WorkingDirectory = tempdir, - FileName = "/usr/bin/texi2pdf", - Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}" - }; - p.Start(); - p.WaitForExit(); - if (p.ExitCode != 0) - { - errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}"; - } - else - { - fi.Delete(); - var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d")); - di.Delete(true); - - } - } - } - Model.Generated = fo.Exists; - Model.GenerationErrorMessage = new HtmlString(errorMsg); - return fo.Exists; - } - - public static string RenderViewToString( - this Controller controller, IViewEngine engine, - IActionContextAccessor contextAccessor, - string viewName, object model, bool isMainPage = true) - { - using (var sw = new StringWriter()) - { - if (engine == null) - throw new InvalidOperationException("no engine"); - - // try to find the specified view - controller.TryValidateModel(model); - ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage); - - // create the associated context - ViewContext viewContext = new ViewContext(); - viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor; - viewContext.HttpContext = contextAccessor.ActionContext.HttpContext; - viewContext.TempData = controller.TempData; - viewContext.View = viewResult.View; - viewContext.Writer = sw; - // write the render view with the given context to the stringwriter - viewResult.View.RenderAsync(viewContext).Wait(); - return sw.GetStringBuilder().ToString(); - } - } - } -} diff --git a/src/Yavsc.Org/ViewComponents/BillViewComponent.cs b/src/Yavsc.Org/ViewComponents/BillViewComponent.cs deleted file mode 100644 index 0217ebcf2..000000000 --- a/src/Yavsc.Org/ViewComponents/BillViewComponent.cs +++ /dev/null @@ -1,108 +0,0 @@ - -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Localization; -using Yavsc.Billing; -using Yavsc.Helpers; -using Yavsc.Models; -using Yavsc.ViewModels; -using Yavsc.ViewModels.Gen; -using Yavsc.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace Yavsc.ViewComponents -{ - public class BillViewComponent : ViewComponent - { - readonly ApplicationDbContext dbContext; - readonly IBillingService billing; - readonly IStringLocalizer localizer; - private readonly SiteSettings siteSettings; - - public BillViewComponent(ApplicationDbContext dbContext, - IStringLocalizer localizer, - IBillingService billing, - IOptions siteSettings) - { - this.billing = billing; - this.dbContext = dbContext; - this.localizer = localizer; - this.siteSettings = siteSettings.Value; - } - - public async Task InvokeAsync( - string code, - IBillable billable, - OutputFormat format, - bool asBill, - SiteSettings settings - ) - { - var di = new DirectoryInfo(settings.Bills); - var dia = new DirectoryInfo(settings.Avatars); - ViewBag.BillsDir = di.FullName; - ViewBag.AvatarsDir = dia.FullName; - ViewBag.AsBill = asBill; // vrai pour une facture, sinon, c'est un devis - ViewBag.Acquitted = billable.GetIsAcquitted(); - - ViewBag.BillingCode = code; - var client = await dbContext.Users - .Include(u=>u.PostalAddress) - .SingleAsync(u=>u.Id == billable.ClientId); - ViewBag.Client = client; - var performer = await dbContext.Users - .Include(u=>u.BankInfo) - .Include(u=>u.PostalAddress) - .SingleAsync(u=>u.Id == billable.PerformerId); - ViewBag.Performer = performer; - string clientAddress = client.PostalAddress?.Address ?? null; - ViewBag.ClientAddress = clientAddress.SplitAddressToTeX(); - - - var profile = await dbContext.Performers - .Include(p=>p.OrganizationAddress) - .SingleAsync(p=>p.PerformerId == billable.PerformerId); - ViewBag.PerformerProfile = profile; - ViewBag.ActivityLabel = (await dbContext.Activities.SingleAsync(a => a.Code == billable.ActivityCode)).Name; - - var proAddr = profile.OrganizationAddress.Address; - ViewBag.PerformerOrganizationAddress = proAddr.SplitAddressToTeX() ; - ViewBag.FooterPerformerOrganizationAddress = proAddr.SplitAddressToTeX(", "); - - ViewBag.PerformerAddress = performer.PostalAddress?.Address.SplitAddressToTeX() ; - switch (format) { - case OutputFormat.LaTeX : - return this.View("Bill_tex", billable); - case OutputFormat.Pdf : - string tex = null; - var oldWriter = ViewComponentContext.ViewContext.Writer; - using (var writer = new StringWriter()) - { - this.ViewComponentContext.ViewContext.Writer = writer; - var resultTex = View("Bill_tex", billable); - await resultTex.ExecuteAsync(this.ViewComponentContext); - tex = writer.ToString(); - } - ViewComponentContext.ViewContext.Writer = oldWriter; - - var pdfGenerationViewModel = new PdfGenerationViewModel - { - Temp = siteSettings.TempDir, - TeXSource = tex, - DestDir = siteSettings.Bills, - BaseFileName = billable.GetFileBaseName(billing) - }; - if (settings.GenerateEstimatePdf(pdfGenerationViewModel)) { - return this.View(new { Generated = pdfGenerationViewModel.BaseFileName+".pdf" }); - } else { - return View(new { Error = pdfGenerationViewModel.GenerationErrorMessage } ); - } - } - ViewBag.BillFileInfo = billable.GetBillInfo(billing, siteSettings); - ViewBag.Settings = settings; - return View("Default",billable); - - } - - } -} diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml deleted file mode 100644 index f49fad802..000000000 --- a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml +++ /dev/null @@ -1,21 +0,0 @@ -@using System.Reflection -@using System.IO -@using Microsoft.Extensions.WebEncoders -@using System.Diagnostics -@using System.Text -@using Yavsc.Helpers -@model Yavsc.ViewModels.Gen.PdfGenerationViewModel - -@{ - ViewBag.Settings.GenerateEstimatePdf(Model); -} -@if (Model.Generated) { -
- @(Model.BaseFileName).pdf was generated -
-} else { -
- @Model.GenerationErrorMessage -

Something went wrong ...

-
-} diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml deleted file mode 100644 index f2d0e0163..000000000 --- a/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml +++ /dev/null @@ -1,33 +0,0 @@ - -@model IBillable -
-@if (ViewBag.BillFileInfo.Exists) { -La facture au format Pdf -} else { - -
-
- - -
-
-
-
-} -
diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml deleted file mode 100644 index b9a1ab26d..000000000 --- a/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml +++ /dev/null @@ -1,13 +0,0 @@ - -@model Estimate - diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml deleted file mode 100644 index bef19b35a..000000000 --- a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml +++ /dev/null @@ -1,21 +0,0 @@ -@using System.Reflection -@using System.IO -@using Microsoft.Extensions.WebEncoders -@using System.Diagnostics -@using System.Text -@using Yavsc.Helpers -@model Yavsc.ViewModels.Gen.PdfGenerationViewModel - -@{ - SiteSettings.Value.GenerateEstimatePdf(Model); -} -@if (Model.Generated) { -
- @(Model.BaseFileName).pdf was generated -
-} else { -
- @Model.GenerationErrorMessage -

Something went wrong ...

-
-} diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 928faf7c8..99e0cf29c 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -9,8 +9,8 @@ https://github.com/pazof/yavsc 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 @@ -47,4 +47,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index bc7b9c81d..77e691ea5 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -34,7 +34,7 @@ namespace Yavsc.Server.Helpers { var item = new FileReceivedInfo( settings.Bills, - AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId)); + AbstractFileSystemHelpers.SignFileNameFormat(signType, billingCode, estimateId)); var fi = new FileInfo(item.FullName); if (fi.Exists) item.Overridden = true; diff --git a/src/Yavsc.Server/Models/Billing/Estimate.cs b/src/Yavsc.Server/Models/Billing/Estimate.cs index 74680e7b7..8a26faab9 100644 --- a/src/Yavsc.Server/Models/Billing/Estimate.cs +++ b/src/Yavsc.Server/Models/Billing/Estimate.cs @@ -8,6 +8,7 @@ namespace Yavsc.Models.Billing { using Models.Workflow; using Newtonsoft.Json; + using System.Linq; public partial class Estimate : IEstimate { @@ -78,14 +79,39 @@ namespace Yavsc.Models.Billing public DateTime ClientValidationDate { get; set; } /// - /// All signatures captured against this estimate, in - /// capture order. Multiple versions per (Type, SignerId) - /// are allowed; the controller reads the most recent - /// when asked. See . + /// All signatures captured against this estimate, keyed by + /// . The persister upserts by + /// (EstimateId, Type), so there is at most one row per type. + /// This collection is the persisted shape and stays + /// [JsonIgnore]: the wire payload exposes signatures + /// only through the dedicated and + /// projections, since an + /// estimate has exactly these two signature slots and never + /// more. See . /// [InverseProperty(nameof(Signature.Estimate))] + [JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual ICollection Signatures { get; set; } = new List(); + /// + /// The provider's signature on this estimate, or null when the + /// provider has not signed. Projected from + /// ; not mapped (the table is + /// ). + /// + [NotMapped] + public Signature? SignaturePro + => Signatures?.FirstOrDefault(s => s.Type == SignatureType.Pro); + + /// + /// The client's signature on this estimate, or null when the + /// client has not signed. Projected from + /// ; not mapped. + /// + [NotMapped] + public Signature? SignatureClient + => Signatures?.FirstOrDefault(s => s.Type == SignatureType.Client); + } } diff --git a/src/Yavsc.Server/Models/Billing/Signature.cs b/src/Yavsc.Server/Models/Billing/Signature.cs index 1aac0cd0e..7e568dbd1 100644 --- a/src/Yavsc.Server/Models/Billing/Signature.cs +++ b/src/Yavsc.Server/Models/Billing/Signature.cs @@ -31,7 +31,7 @@ public class Signature public long EstimateId { get; set; } - [ForeignKey(nameof(EstimateId)), JsonIgnore] + [ForeignKey(nameof(EstimateId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual Estimate Estimate { get; set; } /// @@ -48,7 +48,7 @@ public class Signature [Required] public string SignerId { get; set; } - [ForeignKey(nameof(SignerId)), JsonIgnore] + [ForeignKey(nameof(SignerId)), JsonIgnore, System.Text.Json.Serialization.JsonIgnore] public virtual ApplicationUser Signer { get; set; } public SignatureType Type { get; set; } diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index bb2022fc6..b291847c0 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -9,8 +9,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index dd93a8b34..830377ca2 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,8 +14,8 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 382dff670..39ef7660f 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,8 +7,8 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf - 1.1.0-feat-sign-estimate.1 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1