From 6bbd894d96d0a8c0cae990d7c2a0088186ae922d Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 21:28:31 +0700 Subject: [PATCH] [CLAUDE] App+Infra+Api+FE-Admin: PDF export (LibreOffice headless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline: template.docx → FormRenderer fill placeholders → LibreOffice soffice --headless --convert-to pdf → PDF byte[] → File() stream to browser. Clean-arch split: - Application: IPdfConverter abstraction (swap to QuestPDF/Aspose later without touching caller). - Infrastructure: LibreOfficePdfConverter — shells out to soffice.exe path from config (Pdf:SofficePath, default `C:\Program Files\LibreOffice\program\soffice.exe` on Windows). Per-request temp workDir để tránh filename collision + -env: UserInstallation isolate mỗi conversion (chống "soffice already running" khi concurrent). Timeout 60s (configurable). Best-effort cleanup. Kill entire process tree nếu timeout. - Application: ExportTemplatePdfCommand — reuses existing FormRenderer + pipes bytes through IPdfConverter. Same data dict signature as Render để UI code share. - Api: POST /api/forms/templates/{id}/export-pdf (same JSON body as /render, returns PDF stream). FE: - useExport hook chung cho 2 endpoints (DRY render + export-pdf mutations) - Render dialog thêm nút "Tải PDF" (outline variant) cạnh "Tải file gốc". Disabled khi mutation khác đang chạy. - Hướng dẫn dialog nâng cấp: "file gốc để edit Word/Excel, PDF để in/gửi không chỉnh sửa được". Ops: scripts/install-libreoffice.ps1 — silent MSI install 25.8.6 cho VPS (đã chạy trên prod). Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/pages/forms/FormsPage.tsx | 81 +++++++++++------ scripts/install-libreoffice.ps1 | 16 ++++ .../Controllers/FormsController.cs | 7 ++ .../Common/Interfaces/IPdfConverter.cs | 9 ++ .../Forms/FormFeatures.cs | 31 +++++++ .../DependencyInjection.cs | 1 + .../Forms/LibreOfficePdfConverter.cs | 91 +++++++++++++++++++ 7 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 scripts/install-libreoffice.ps1 create mode 100644 src/Backend/SolutionErp.Application/Common/Interfaces/IPdfConverter.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Forms/LibreOfficePdfConverter.cs diff --git a/fe-admin/src/pages/forms/FormsPage.tsx b/fe-admin/src/pages/forms/FormsPage.tsx index a0def56..b27903c 100644 --- a/fe-admin/src/pages/forms/FormsPage.tsx +++ b/fe-admin/src/pages/forms/FormsPage.tsx @@ -56,26 +56,32 @@ export function FormsPage() { queryFn: async () => (await api.get('/forms/templates', { params: { onlyActive: false } })).data, }) - const render = useMutation({ - mutationFn: async ({ id, data }: { id: string; data: Record }) => { - const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' }) - return { - blob: res.data as Blob, - filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx', - } - }, - onSuccess: ({ blob, filename }) => { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) - toast.success('Đã tải file render') - setRenderDialog(null) - }, - onError: err => toast.error(getErrorMessage(err)), - }) + function useExport(endpoint: 'render' | 'export-pdf', successMsg: string) { + return useMutation({ + mutationFn: async ({ id, data }: { id: string; data: Record }) => { + const res = await api.post(`/forms/templates/${id}/${endpoint}`, data, { responseType: 'blob' }) + const fallback = endpoint === 'export-pdf' ? 'contract.pdf' : 'render.docx' + return { + blob: res.data as Blob, + filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? fallback, + } + }, + onSuccess: ({ blob, filename }) => { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + toast.success(successMsg) + setRenderDialog(null) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + } + + const render = useExport('render', 'Đã tải file gốc') + const exportPdf = useExport('export-pdf', 'Đã tải PDF') const upload = useMutation({ mutationFn: async (params: { file: File; meta: EditState }) => { @@ -123,18 +129,28 @@ export function FormsPage() { onError: err => toast.error(getErrorMessage(err)), }) - function handleRender() { - if (!renderDialog) return - let data: Record + function parseJsonOrToast(): Record | null { + if (!renderDialog) return null try { - data = JSON.parse(dataJson) + return JSON.parse(dataJson) } catch { toast.error('JSON không hợp lệ') - return + return null } + } + + function handleRender() { + const data = parseJsonOrToast() + if (!data || !renderDialog) return render.mutate({ id: renderDialog.id, data }) } + function handleExportPdf() { + const data = parseJsonOrToast() + if (!data || !renderDialog) return + exportPdf.mutate({ id: renderDialog.id, data }) + } + function handleSaveEdit(e: FormEvent) { e.preventDefault() if (!edit) return @@ -256,8 +272,15 @@ export function FormsPage() { - + } @@ -265,8 +288,8 @@ export function FormsPage() {
Hướng dẫn: Template chứa placeholder dạng{' '} - {'{{fieldName}}'}. Điền key-value JSON dưới đây, backend sẽ - replace placeholder trong file gốc. + {'{{fieldName}}'}. Điền JSON bên dưới. Tải file gốc{' '} + để chỉnh sửa trong Word/Excel, Tải PDF để in/gửi không chỉnh sửa được.
diff --git a/scripts/install-libreoffice.ps1 b/scripts/install-libreoffice.ps1 new file mode 100644 index 0000000..f72cd85 --- /dev/null +++ b/scripts/install-libreoffice.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = 'Stop' +$url = 'https://download.documentfoundation.org/libreoffice/stable/25.8.6/win/x86_64/LibreOffice_25.8.6_Win_x86-64.msi' +$msi = 'C:\Windows\Temp\lo.msi' +Write-Host "Downloading LibreOffice 25.8.6..." +Invoke-WebRequest -Uri $url -OutFile $msi -UseBasicParsing +Write-Host ("Downloaded {0:N1} MB" -f ((Get-Item $msi).Length / 1MB)) +Write-Host "Installing silently (4-6 min)..." +$p = Start-Process msiexec.exe -ArgumentList '/i', $msi, '/quiet', '/norestart', 'ADDLOCAL=ALL', 'CREATEDESKTOPLINK=0' -Wait -PassThru +Write-Host "Exit code: $($p.ExitCode)" +Remove-Item $msi -ErrorAction SilentlyContinue +if (Test-Path 'C:\Program Files\LibreOffice\program\soffice.exe') { + Write-Host "OK: soffice.exe installed" + & 'C:\Program Files\LibreOffice\program\soffice.exe' --version +} else { + Write-Error "soffice.exe NOT found after install" +} diff --git a/src/Backend/SolutionErp.Api/Controllers/FormsController.cs b/src/Backend/SolutionErp.Api/Controllers/FormsController.cs index 43f8484..c9c6451 100644 --- a/src/Backend/SolutionErp.Api/Controllers/FormsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/FormsController.cs @@ -29,6 +29,13 @@ public class FormsController(IMediator mediator) : ControllerBase return File(result.Content, result.ContentType, result.FileName); } + [HttpPost("templates/{id:guid}/export-pdf")] + public async Task ExportPdf(Guid id, [FromBody] Dictionary data, CancellationToken ct) + { + var result = await mediator.Send(new ExportTemplatePdfCommand(id, data), ct); + return File(result.Content, result.ContentType, result.FileName); + } + // ==================== Admin CRUD ==================== // Upload new template — multipart form with file + metadata fields diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IPdfConverter.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IPdfConverter.cs new file mode 100644 index 0000000..9c63abf --- /dev/null +++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IPdfConverter.cs @@ -0,0 +1,9 @@ +namespace SolutionErp.Application.Common.Interfaces; + +// Convert .docx/.xlsx bytes to PDF. The Infrastructure impl shells out to +// LibreOffice headless (soffice.exe --headless --convert-to pdf). Future swap: +// QuestPDF re-render if we want zero external dep, or Aspose.Words for quality. +public interface IPdfConverter +{ + Task ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default); +} diff --git a/src/Backend/SolutionErp.Application/Forms/FormFeatures.cs b/src/Backend/SolutionErp.Application/Forms/FormFeatures.cs index 8153dc9..8e3ee00 100644 --- a/src/Backend/SolutionErp.Application/Forms/FormFeatures.cs +++ b/src/Backend/SolutionErp.Application/Forms/FormFeatures.cs @@ -95,6 +95,37 @@ public interface IWebHostEnvironmentLocator string ContentRootPath { get; } } +// ========== EXPORT rendered template as PDF ========== +// Renders the same docx/xlsx as RenderTemplateCommand, then pipes through +// IPdfConverter (LibreOffice headless) to get a PDF. Accepts the SAME data +// dictionary so callers can preview PDF without duplicating logic. + +public record ExportTemplatePdfCommand(Guid TemplateId, Dictionary Data) + : IRequest; + +public class ExportTemplatePdfCommandHandler( + IApplicationDbContext db, + IFormRenderer renderer, + IPdfConverter pdfConverter, + IWebHostEnvironmentLocator envLocator) : IRequestHandler +{ + public async Task Handle(ExportTemplatePdfCommand request, CancellationToken ct) + { + var tpl = await db.ContractTemplates.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == request.TemplateId, ct) + ?? throw new NotFoundException("ContractTemplate", request.TemplateId); + + var absPath = Path.Combine(envLocator.WebRootPath, tpl.StoragePath); + if (!File.Exists(absPath)) + throw new NotFoundException($"File template không tồn tại: {tpl.StoragePath}"); + + var rendered = await renderer.RenderAsync(absPath, tpl.Format, request.Data, "source", ct); + var pdfBytes = await pdfConverter.ConvertAsync(rendered.Content, tpl.Format, ct); + var outName = $"{tpl.FormCode}.pdf"; + return new RenderResult(pdfBytes, outName, "application/pdf"); + } +} + // ========== UPLOAD new template ========== public record UploadContractTemplateCommand( diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index ec6bc5e..51ddfd7 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -29,6 +29,7 @@ public static class DependencyInjection services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/SolutionErp.Infrastructure/Forms/LibreOfficePdfConverter.cs b/src/Backend/SolutionErp.Infrastructure/Forms/LibreOfficePdfConverter.cs new file mode 100644 index 0000000..41a1bf0 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Forms/LibreOfficePdfConverter.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SolutionErp.Application.Common.Interfaces; + +namespace SolutionErp.Infrastructure.Forms; + +// Shells out to LibreOffice headless to convert .docx/.xlsx → PDF. +// Requires soffice.exe installed on the host. Config Pdf:SofficePath overrides +// the default "C:\Program Files\LibreOffice\program\soffice.exe" / "soffice" +// so dev on macOS/Linux can point to a different binary. +public class LibreOfficePdfConverter(IConfiguration config, ILogger logger) : IPdfConverter +{ + private readonly string _sofficePath = config["Pdf:SofficePath"] + ?? (OperatingSystem.IsWindows() + ? @"C:\Program Files\LibreOffice\program\soffice.exe" + : "soffice"); + + private readonly TimeSpan _timeout = TimeSpan.FromSeconds( + config.GetValue("Pdf:TimeoutSeconds") ?? 60); + + public async Task ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default) + { + var ext = sourceExt.TrimStart('.').ToLowerInvariant(); + if (ext != "docx" && ext != "xlsx" && ext != "doc" && ext != "xls") + throw new InvalidOperationException($"Unsupported source extension: {ext}"); + + // Per-conversion temp directory — avoids filename collisions when multiple + // requests run concurrently and makes cleanup atomic. + var workDir = Path.Combine(Path.GetTempPath(), "solutionerp-pdf", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + var inputName = $"input.{ext}"; + var inputPath = Path.Combine(workDir, inputName); + var outputPath = Path.Combine(workDir, "input.pdf"); + + try + { + await File.WriteAllBytesAsync(inputPath, sourceBytes, ct); + + var psi = new ProcessStartInfo + { + FileName = _sofficePath, + // --headless: no GUI + // --norestore: don't try to resurrect prior session + // -env:UserInstallation: isolate user profile per worker (avoids + // "soffice already running" conflict when multiple requests hit) + Arguments = $"--headless --norestore --convert-to pdf " + + $"-env:UserInstallation=file:///{workDir.Replace('\\', '/')}/profile " + + $"--outdir \"{workDir}\" \"{inputPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = workDir, + }; + + using var proc = new Process { StartInfo = psi }; + proc.Start(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(_timeout); + + try + { + await proc.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + try { proc.Kill(entireProcessTree: true); } catch { /* best effort */ } + throw new TimeoutException($"LibreOffice conversion exceeded {_timeout.TotalSeconds}s."); + } + + if (proc.ExitCode != 0) + { + var stderr = await proc.StandardError.ReadToEndAsync(ct); + var stdout = await proc.StandardOutput.ReadToEndAsync(ct); + logger.LogError("soffice exit {Code}. stderr={Stderr} stdout={Stdout}", proc.ExitCode, stderr, stdout); + throw new InvalidOperationException($"LibreOffice conversion failed (exit {proc.ExitCode})."); + } + + if (!File.Exists(outputPath)) + throw new InvalidOperationException("LibreOffice did not produce a PDF output."); + + return await File.ReadAllBytesAsync(outputPath, ct); + } + finally + { + try { Directory.Delete(workDir, recursive: true); } catch { /* best effort */ } + } + } +}