[CLAUDE] App+Infra+FE-Admin: DynamicForm + .doc/.xls auto-convert on upload
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m17s
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:
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user