[CLAUDE] App+Infra+FE-Admin: DynamicForm + .doc/.xls auto-convert on upload
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m17s

Tier 3 iter2 — form builder UI dùng FieldSpec thay raw JSON textarea.

FE:
- DynamicForm component — parse FieldSpec JSON (record of FieldDef với
  label/type/required/placeholder/hint/options) và render inputs
  dynamic theo type: text/textarea/number/date/currency/select.
- FormsPage render dialog thêm toggle Form ↔ JSON (segmented control).
  Mặc định Form mode khi template có FieldSpec, JSON mode khi không.
  Khi mở dialog cho row khác, reset formValues + chọn đúng default mode.
- parseFieldSpec helper trả { spec, error } — UI báo lỗi nếu JSON
  không parse được, fallback JSON textarea.

BE — generalize converter thành IDocumentConverter:
- IPdfConverter → IDocumentConverter (ConvertAsync(bytes, src, tgt, ct))
  — đủ gánh cả pdf, docx, xlsx targets.
- LibreOfficeDocumentConverter — 1 shell-out pattern cho mọi conversion
  (docx→pdf, doc→docx, xls→xlsx, xlsx→pdf), target arg truyền vào
  --convert-to.
- ExportTemplatePdfCommand update dùng "pdf" target.

Auto-convert .doc/.xls trên upload:
- Validator accept thêm .doc/.xls (thêm note "sẽ tự convert").
- UploadContractTemplateCommandHandler: nếu ext là doc/xls → read stream
  → converter.ConvertAsync → lưu file .docx/.xlsx thay vì format gốc.
  File rendering pipeline (DocxRenderer/XlsxRenderer) chỉ support docx/
  xlsx — convert đảm bảo consistent.
- Display FileName preserve original name nhưng đổi extension.

Unblock 3 file .doc legacy template — admin giờ upload .doc bình thường,
system tự convert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 21:35:05 +07:00
parent 6bbd894d96
commit e45909712b
6 changed files with 300 additions and 56 deletions

View File

@ -1,9 +1,14 @@
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
// Convert document bytes between office formats. Infrastructure impl shells out
// to LibreOffice headless (soffice.exe --headless --convert-to TARGET).
//
// Supported source/target combos are whatever LibreOffice supports, commonly:
// docx → pdf, doc → docx, xlsx → pdf, xls → xlsx
//
// Future swap: QuestPDF for PDF-only, Aspose.Words for quality, or cloud API —
// without touching callers.
public interface IDocumentConverter
{
Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default);
Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, string targetExt, CancellationToken ct = default);
}

View File

@ -106,7 +106,7 @@ public record ExportTemplatePdfCommand(Guid TemplateId, Dictionary<string, strin
public class ExportTemplatePdfCommandHandler(
IApplicationDbContext db,
IFormRenderer renderer,
IPdfConverter pdfConverter,
IDocumentConverter converter,
IWebHostEnvironmentLocator envLocator) : IRequestHandler<ExportTemplatePdfCommand, RenderResult>
{
public async Task<RenderResult> Handle(ExportTemplatePdfCommand request, CancellationToken ct)
@ -120,7 +120,7 @@ public class ExportTemplatePdfCommandHandler(
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 pdfBytes = await converter.ConvertAsync(rendered.Content, tpl.Format, "pdf", ct);
var outName = $"{tpl.FormCode}.pdf";
return new RenderResult(pdfBytes, outName, "application/pdf");
}
@ -142,7 +142,11 @@ public record UploadContractTemplateCommand(
public class UploadContractTemplateCommandValidator : AbstractValidator<UploadContractTemplateCommand>
{
private const long MaxBytes = 10L * 1024 * 1024;
private static readonly HashSet<string> AllowedFormats = new(StringComparer.OrdinalIgnoreCase) { ".docx", ".xlsx" };
// .doc/.xls accepted — auto-converted to .docx/.xlsx during upload handling.
private static readonly HashSet<string> AllowedFormats = new(StringComparer.OrdinalIgnoreCase)
{
".docx", ".xlsx", ".doc", ".xls",
};
public UploadContractTemplateCommandValidator()
{
@ -155,7 +159,7 @@ public class UploadContractTemplateCommandValidator : AbstractValidator<UploadCo
.WithMessage("Template tối đa 10 MB.");
RuleFor(x => x.FileName).NotEmpty()
.Must(name => AllowedFormats.Contains(Path.GetExtension(name)))
.WithMessage("Chỉ chấp nhận file .docx hoặc .xlsx.");
.WithMessage("Chỉ chấp nhận file .docx/.xlsx (.doc/.xls sẽ tự convert).");
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
}
@ -170,6 +174,7 @@ public class UploadContractTemplateCommandValidator : AbstractValidator<UploadCo
public class UploadContractTemplateCommandHandler(
IApplicationDbContext db,
IDocumentConverter converter,
IWebHostEnvironmentLocator env) : IRequestHandler<UploadContractTemplateCommand, ContractTemplateDto>
{
public async Task<ContractTemplateDto> Handle(UploadContractTemplateCommand request, CancellationToken ct)
@ -178,17 +183,27 @@ public class UploadContractTemplateCommandHandler(
if (await db.ContractTemplates.AnyAsync(t => t.FormCode == request.FormCode, ct))
throw new ConflictException($"FormCode '{request.FormCode}' đã tồn tại.");
var ext = Path.GetExtension(request.FileName).TrimStart('.').ToLowerInvariant();
var srcExt = Path.GetExtension(request.FileName).TrimStart('.').ToLowerInvariant();
// Auto-convert legacy .doc/.xls → .docx/.xlsx so rendering pipeline
// (DocxRenderer / XlsxRenderer) works uniformly. Admin sees the upload
// succeed with the new format without extra steps.
var (finalBytes, finalExt) = (srcExt is "doc" or "xls")
? await ReadAndConvert(request.Content, srcExt, ct)
: (await ReadAllBytes(request.Content, ct), srcExt);
var id = Guid.NewGuid();
// Store using a deterministic name — FormCode avoids path-traversal
// (already regex-validated above) and disambiguates files.
var safeFile = $"{request.FormCode}_{id:N}.{ext}";
var safeFile = $"{request.FormCode}_{id:N}.{finalExt}";
var templatesDir = Path.Combine(env.WebRootPath, "templates");
Directory.CreateDirectory(templatesDir);
var absPath = Path.Combine(templatesDir, safeFile);
await using (var fs = File.Create(absPath))
await request.Content.CopyToAsync(fs, ct);
await File.WriteAllBytesAsync(absPath, finalBytes, ct);
// Preserve original filename for user display but reflect final format.
var displayName = srcExt != finalExt
? Path.ChangeExtension(request.FileName, finalExt)
: request.FileName;
var entity = new Domain.Forms.ContractTemplate
{
@ -196,9 +211,9 @@ public class UploadContractTemplateCommandHandler(
FormCode = request.FormCode,
Name = request.Name,
ContractType = request.ContractType,
FileName = request.FileName,
FileName = displayName,
StoragePath = $"templates/{safeFile}",
Format = ext,
Format = finalExt,
FieldSpec = request.FieldSpec,
Description = request.Description,
IsActive = true,
@ -210,6 +225,21 @@ public class UploadContractTemplateCommandHandler(
entity.Id, entity.FormCode, entity.Name, entity.ContractType,
entity.FileName, entity.Format, entity.FieldSpec, entity.Description, entity.IsActive);
}
private static async Task<byte[]> ReadAllBytes(Stream s, CancellationToken ct)
{
using var ms = new MemoryStream();
await s.CopyToAsync(ms, ct);
return ms.ToArray();
}
private async Task<(byte[] Bytes, string Ext)> ReadAndConvert(Stream s, string srcExt, CancellationToken ct)
{
var srcBytes = await ReadAllBytes(s, ct);
var targetExt = srcExt switch { "doc" => "docx", "xls" => "xlsx", _ => srcExt };
var converted = await converter.ConvertAsync(srcBytes, srcExt, targetExt, ct);
return (converted, targetExt);
}
}
// ========== UPDATE metadata + FieldSpec ==========

View File

@ -29,7 +29,7 @@ public static class DependencyInjection
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddSingleton<IPdfConverter, LibreOfficePdfConverter>();
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();

View File

@ -5,11 +5,12 @@ 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<LibreOfficePdfConverter> logger) : IPdfConverter
// Shells out to LibreOffice headless for arbitrary .docx/.doc/.xlsx/.xls →
// pdf/docx conversion. Requires soffice.exe installed on the host. Config
// Pdf:SofficePath overrides default path for cross-platform dev.
public class LibreOfficeDocumentConverter(
IConfiguration config,
ILogger<LibreOfficeDocumentConverter> logger) : IDocumentConverter
{
private readonly string _sofficePath = config["Pdf:SofficePath"]
?? (OperatingSystem.IsWindows()
@ -19,19 +20,22 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(
config.GetValue<int?>("Pdf:TimeoutSeconds") ?? 60);
public async Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, CancellationToken ct = default)
public async Task<byte[]> ConvertAsync(byte[] sourceBytes, string sourceExt, string targetExt, CancellationToken ct = default)
{
var ext = sourceExt.TrimStart('.').ToLowerInvariant();
if (ext != "docx" && ext != "xlsx" && ext != "doc" && ext != "xls")
throw new InvalidOperationException($"Unsupported source extension: {ext}");
var srcExt = sourceExt.TrimStart('.').ToLowerInvariant();
var tgtExt = targetExt.TrimStart('.').ToLowerInvariant();
if (srcExt is not ("docx" or "xlsx" or "doc" or "xls"))
throw new InvalidOperationException($"Unsupported source extension: {srcExt}");
if (tgtExt is not ("pdf" or "docx" or "xlsx"))
throw new InvalidOperationException($"Unsupported target extension: {tgtExt}");
// 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"));
// Per-conversion temp directory — avoids filename collisions when
// multiple requests run concurrently and makes cleanup atomic.
var workDir = Path.Combine(Path.GetTempPath(), "solutionerp-conv", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
var inputName = $"input.{ext}";
var inputName = $"input.{srcExt}";
var inputPath = Path.Combine(workDir, inputName);
var outputPath = Path.Combine(workDir, "input.pdf");
var outputPath = Path.Combine(workDir, $"input.{tgtExt}");
try
{
@ -43,8 +47,8 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
// --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 " +
// "soffice already running" conflict when concurrent)
Arguments = $"--headless --norestore --convert-to {tgtExt} " +
$"-env:UserInstallation=file:///{workDir.Replace('\\', '/')}/profile " +
$"--outdir \"{workDir}\" \"{inputPath}\"",
UseShellExecute = false,
@ -74,12 +78,13 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
{
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);
logger.LogError("soffice {Src}→{Tgt} exit {Code}. stderr={Stderr} stdout={Stdout}",
srcExt, tgtExt, 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.");
throw new InvalidOperationException($"LibreOffice did not produce {tgtExt} output.");
return await File.ReadAllBytesAsync(outputPath, ct);
}