[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:
150
fe-admin/src/components/DynamicForm.tsx
Normal file
150
fe-admin/src/components/DynamicForm.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
|
||||||
|
// FieldSpec schema — stored on ContractTemplate.FieldSpec as JSON string.
|
||||||
|
// Designed to be human-authorable by admin in JSON textarea but also machine-
|
||||||
|
// readable for the DynamicForm renderer below.
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "fieldKey": {
|
||||||
|
// "label": "Tên Cty bên A",
|
||||||
|
// "type": "text" | "textarea" | "number" | "date" | "currency" | "select",
|
||||||
|
// "required": false,
|
||||||
|
// "placeholder": "...",
|
||||||
|
// "hint": "...",
|
||||||
|
// "options": ["opt1", "opt2"] // only for type=select
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
export type FieldDef = {
|
||||||
|
label: string
|
||||||
|
type?: 'text' | 'textarea' | 'number' | 'date' | 'currency' | 'select'
|
||||||
|
required?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
hint?: string
|
||||||
|
options?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldSpec = Record<string, FieldDef>
|
||||||
|
|
||||||
|
export type DynamicFormValues = Record<string, string | null>
|
||||||
|
|
||||||
|
export function parseFieldSpec(json: string | null | undefined): { spec: FieldSpec | null; error: string | null } {
|
||||||
|
if (!json || !json.trim()) return { spec: null, error: null }
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(json)
|
||||||
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj))
|
||||||
|
return { spec: null, error: 'FieldSpec phải là object JSON' }
|
||||||
|
return { spec: obj as FieldSpec, error: null }
|
||||||
|
} catch (e) {
|
||||||
|
return { spec: null, error: `JSON lỗi: ${(e as Error).message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicForm({
|
||||||
|
spec,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
spec: FieldSpec
|
||||||
|
values: DynamicFormValues
|
||||||
|
onChange: (key: string, value: string | null) => void
|
||||||
|
}) {
|
||||||
|
const entries = useMemo(() => Object.entries(spec), [spec])
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50 p-6 text-center text-sm text-slate-400">
|
||||||
|
FieldSpec trống — thêm field vào spec JSON để hiện form.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{entries.map(([key, def]) => {
|
||||||
|
const type = def.type ?? 'text'
|
||||||
|
const val = values[key] ?? ''
|
||||||
|
const commonProps = {
|
||||||
|
id: key,
|
||||||
|
value: val,
|
||||||
|
placeholder: def.placeholder,
|
||||||
|
required: def.required,
|
||||||
|
}
|
||||||
|
const spanClass = type === 'textarea' ? 'md:col-span-2' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className={`space-y-1.5 ${spanClass}`}>
|
||||||
|
<Label htmlFor={key} className="flex items-center gap-1">
|
||||||
|
{def.label}
|
||||||
|
{def.required && <span className="text-red-500">*</span>}
|
||||||
|
<span className="ml-auto font-mono text-[10px] font-normal text-slate-400">{key}</span>
|
||||||
|
</Label>
|
||||||
|
{type === 'textarea' && (
|
||||||
|
<Textarea
|
||||||
|
{...commonProps}
|
||||||
|
rows={3}
|
||||||
|
onChange={e => onChange(key, e.target.value || null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'select' && (
|
||||||
|
<Select {...commonProps} onChange={e => onChange(key, e.target.value || null)}>
|
||||||
|
<option value="">— chọn —</option>
|
||||||
|
{(def.options ?? []).map(opt => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{type === 'number' && (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={e => onChange(key, e.target.value || null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'currency' && (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={e => onChange(key, e.target.value || null)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'date' && (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="date"
|
||||||
|
onChange={e => onChange(key, e.target.value || null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === 'text' && (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="text"
|
||||||
|
onChange={e => onChange(key, e.target.value || null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{def.hint && <div className="text-[11px] text-slate-400">{def.hint}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicFormPreviewError({ error }: { error: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<div>{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import { Input } from '@/components/ui/Input'
|
|||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { DynamicForm, DynamicFormPreviewError, parseFieldSpec, type DynamicFormValues } from '@/components/DynamicForm'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
|
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
|
||||||
@ -50,6 +51,8 @@ export function FormsPage() {
|
|||||||
"giaTri": "150,000,000 VND",
|
"giaTri": "150,000,000 VND",
|
||||||
"ngayKy": "21/04/2026"
|
"ngayKy": "21/04/2026"
|
||||||
}`)
|
}`)
|
||||||
|
const [dataMode, setDataMode] = useState<'form' | 'json'>('form')
|
||||||
|
const [formValues, setFormValues] = useState<DynamicFormValues>({})
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['contract-templates'],
|
queryKey: ['contract-templates'],
|
||||||
@ -131,6 +134,9 @@ export function FormsPage() {
|
|||||||
|
|
||||||
function parseJsonOrToast(): Record<string, string | null> | null {
|
function parseJsonOrToast(): Record<string, string | null> | null {
|
||||||
if (!renderDialog) return null
|
if (!renderDialog) return null
|
||||||
|
// Form mode uses the dynamic-form values directly; only parse JSON when
|
||||||
|
// the user is on the JSON tab.
|
||||||
|
if (dataMode === 'form') return formValues
|
||||||
try {
|
try {
|
||||||
return JSON.parse(dataJson)
|
return JSON.parse(dataJson)
|
||||||
} catch {
|
} catch {
|
||||||
@ -211,6 +217,10 @@ export function FormsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
// Reset form values + default to Form mode when FieldSpec present
|
||||||
|
const parsed = parseFieldSpec(t.fieldSpec)
|
||||||
|
setFormValues({})
|
||||||
|
setDataMode(parsed.spec && Object.keys(parsed.spec).length > 0 ? 'form' : 'json')
|
||||||
setRenderDialog(t)
|
setRenderDialog(t)
|
||||||
}}
|
}}
|
||||||
disabled={!t.isActive}
|
disabled={!t.isActive}
|
||||||
@ -285,26 +295,70 @@ export function FormsPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
{(() => {
|
||||||
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
const parsed = parseFieldSpec(renderDialog?.fieldSpec)
|
||||||
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
|
const hasSpec = parsed.spec && Object.keys(parsed.spec).length > 0
|
||||||
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền JSON bên dưới. <strong>Tải file gốc</strong>{' '}
|
return (
|
||||||
để chỉnh sửa trong Word/Excel, <strong>Tải PDF</strong> để in/gửi không chỉnh sửa được.
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
<div className="space-y-1.5">
|
<strong>Hướng dẫn:</strong> Placeholder dạng{' '}
|
||||||
<Label>Form Code</Label>
|
<code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. <strong>Tải file gốc</strong> để edit
|
||||||
<Input value={renderDialog?.formCode ?? ''} disabled />
|
Word/Excel, <strong>Tải PDF</strong> để in/gửi.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Data JSON (placeholder → value)</Label>
|
<div className="space-y-1.5 flex-1">
|
||||||
<Textarea
|
<Label>Form Code</Label>
|
||||||
rows={10}
|
<Input value={renderDialog?.formCode ?? ''} disabled />
|
||||||
value={dataJson}
|
</div>
|
||||||
onChange={e => setDataJson(e.target.value)}
|
{hasSpec && (
|
||||||
className="font-mono text-xs"
|
<div className="ml-4 flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-xs">
|
||||||
/>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
onClick={() => setDataMode('form')}
|
||||||
|
className={`rounded px-3 py-1.5 font-medium transition ${
|
||||||
|
dataMode === 'form' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Form
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDataMode('json')}
|
||||||
|
className={`rounded px-3 py-1.5 font-medium transition ${
|
||||||
|
dataMode === 'json' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parsed.error && <DynamicFormPreviewError error={parsed.error} />}
|
||||||
|
{hasSpec && dataMode === 'form' ? (
|
||||||
|
<DynamicForm
|
||||||
|
spec={parsed.spec!}
|
||||||
|
values={formValues}
|
||||||
|
onChange={(k, v) => setFormValues(prev => ({ ...prev, [k]: v }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Data JSON (placeholder → value)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={10}
|
||||||
|
value={dataJson}
|
||||||
|
onChange={e => setDataJson(e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
{!hasSpec && (
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
Template này chưa có FieldSpec — dùng JSON tự do. Thêm FieldSpec vào template để dùng form builder.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Upload / Edit dialog */}
|
{/* Upload / Edit dialog */}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
namespace SolutionErp.Application.Common.Interfaces;
|
namespace SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
// Convert .docx/.xlsx bytes to PDF. The Infrastructure impl shells out to
|
// Convert document bytes between office formats. Infrastructure impl shells out
|
||||||
// LibreOffice headless (soffice.exe --headless --convert-to pdf). Future swap:
|
// to LibreOffice headless (soffice.exe --headless --convert-to TARGET).
|
||||||
// QuestPDF re-render if we want zero external dep, or Aspose.Words for quality.
|
//
|
||||||
public interface IPdfConverter
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,7 @@ public record ExportTemplatePdfCommand(Guid TemplateId, Dictionary<string, strin
|
|||||||
public class ExportTemplatePdfCommandHandler(
|
public class ExportTemplatePdfCommandHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
IFormRenderer renderer,
|
IFormRenderer renderer,
|
||||||
IPdfConverter pdfConverter,
|
IDocumentConverter converter,
|
||||||
IWebHostEnvironmentLocator envLocator) : IRequestHandler<ExportTemplatePdfCommand, RenderResult>
|
IWebHostEnvironmentLocator envLocator) : IRequestHandler<ExportTemplatePdfCommand, RenderResult>
|
||||||
{
|
{
|
||||||
public async Task<RenderResult> Handle(ExportTemplatePdfCommand request, CancellationToken ct)
|
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}");
|
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 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";
|
var outName = $"{tpl.FormCode}.pdf";
|
||||||
return new RenderResult(pdfBytes, outName, "application/pdf");
|
return new RenderResult(pdfBytes, outName, "application/pdf");
|
||||||
}
|
}
|
||||||
@ -142,7 +142,11 @@ public record UploadContractTemplateCommand(
|
|||||||
public class UploadContractTemplateCommandValidator : AbstractValidator<UploadContractTemplateCommand>
|
public class UploadContractTemplateCommandValidator : AbstractValidator<UploadContractTemplateCommand>
|
||||||
{
|
{
|
||||||
private const long MaxBytes = 10L * 1024 * 1024;
|
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()
|
public UploadContractTemplateCommandValidator()
|
||||||
{
|
{
|
||||||
@ -155,7 +159,7 @@ public class UploadContractTemplateCommandValidator : AbstractValidator<UploadCo
|
|||||||
.WithMessage("Template tối đa 10 MB.");
|
.WithMessage("Template tối đa 10 MB.");
|
||||||
RuleFor(x => x.FileName).NotEmpty()
|
RuleFor(x => x.FileName).NotEmpty()
|
||||||
.Must(name => AllowedFormats.Contains(Path.GetExtension(name)))
|
.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)
|
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
|
||||||
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
|
.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(
|
public class UploadContractTemplateCommandHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
|
IDocumentConverter converter,
|
||||||
IWebHostEnvironmentLocator env) : IRequestHandler<UploadContractTemplateCommand, ContractTemplateDto>
|
IWebHostEnvironmentLocator env) : IRequestHandler<UploadContractTemplateCommand, ContractTemplateDto>
|
||||||
{
|
{
|
||||||
public async Task<ContractTemplateDto> Handle(UploadContractTemplateCommand request, CancellationToken ct)
|
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))
|
if (await db.ContractTemplates.AnyAsync(t => t.FormCode == request.FormCode, ct))
|
||||||
throw new ConflictException($"FormCode '{request.FormCode}' đã tồn tại.");
|
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();
|
var id = Guid.NewGuid();
|
||||||
// Store using a deterministic name — FormCode avoids path-traversal
|
var safeFile = $"{request.FormCode}_{id:N}.{finalExt}";
|
||||||
// (already regex-validated above) and disambiguates files.
|
|
||||||
var safeFile = $"{request.FormCode}_{id:N}.{ext}";
|
|
||||||
var templatesDir = Path.Combine(env.WebRootPath, "templates");
|
var templatesDir = Path.Combine(env.WebRootPath, "templates");
|
||||||
Directory.CreateDirectory(templatesDir);
|
Directory.CreateDirectory(templatesDir);
|
||||||
var absPath = Path.Combine(templatesDir, safeFile);
|
var absPath = Path.Combine(templatesDir, safeFile);
|
||||||
|
|
||||||
await using (var fs = File.Create(absPath))
|
await File.WriteAllBytesAsync(absPath, finalBytes, ct);
|
||||||
await request.Content.CopyToAsync(fs, 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
|
var entity = new Domain.Forms.ContractTemplate
|
||||||
{
|
{
|
||||||
@ -196,9 +211,9 @@ public class UploadContractTemplateCommandHandler(
|
|||||||
FormCode = request.FormCode,
|
FormCode = request.FormCode,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
ContractType = request.ContractType,
|
ContractType = request.ContractType,
|
||||||
FileName = request.FileName,
|
FileName = displayName,
|
||||||
StoragePath = $"templates/{safeFile}",
|
StoragePath = $"templates/{safeFile}",
|
||||||
Format = ext,
|
Format = finalExt,
|
||||||
FieldSpec = request.FieldSpec,
|
FieldSpec = request.FieldSpec,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@ -210,6 +225,21 @@ public class UploadContractTemplateCommandHandler(
|
|||||||
entity.Id, entity.FormCode, entity.Name, entity.ContractType,
|
entity.Id, entity.FormCode, entity.Name, entity.ContractType,
|
||||||
entity.FileName, entity.Format, entity.FieldSpec, entity.Description, entity.IsActive);
|
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 ==========
|
// ========== UPDATE metadata + FieldSpec ==========
|
||||||
|
|||||||
@ -29,7 +29,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||||
|
|
||||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||||
services.AddSingleton<IPdfConverter, LibreOfficePdfConverter>();
|
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
|
||||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
|
|||||||
@ -5,11 +5,12 @@ using SolutionErp.Application.Common.Interfaces;
|
|||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Forms;
|
namespace SolutionErp.Infrastructure.Forms;
|
||||||
|
|
||||||
// Shells out to LibreOffice headless to convert .docx/.xlsx → PDF.
|
// Shells out to LibreOffice headless for arbitrary .docx/.doc/.xlsx/.xls →
|
||||||
// Requires soffice.exe installed on the host. Config Pdf:SofficePath overrides
|
// pdf/docx conversion. Requires soffice.exe installed on the host. Config
|
||||||
// the default "C:\Program Files\LibreOffice\program\soffice.exe" / "soffice"
|
// Pdf:SofficePath overrides default path for cross-platform dev.
|
||||||
// so dev on macOS/Linux can point to a different binary.
|
public class LibreOfficeDocumentConverter(
|
||||||
public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficePdfConverter> logger) : IPdfConverter
|
IConfiguration config,
|
||||||
|
ILogger<LibreOfficeDocumentConverter> logger) : IDocumentConverter
|
||||||
{
|
{
|
||||||
private readonly string _sofficePath = config["Pdf:SofficePath"]
|
private readonly string _sofficePath = config["Pdf:SofficePath"]
|
||||||
?? (OperatingSystem.IsWindows()
|
?? (OperatingSystem.IsWindows()
|
||||||
@ -19,19 +20,22 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
|
|||||||
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(
|
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(
|
||||||
config.GetValue<int?>("Pdf:TimeoutSeconds") ?? 60);
|
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();
|
var srcExt = sourceExt.TrimStart('.').ToLowerInvariant();
|
||||||
if (ext != "docx" && ext != "xlsx" && ext != "doc" && ext != "xls")
|
var tgtExt = targetExt.TrimStart('.').ToLowerInvariant();
|
||||||
throw new InvalidOperationException($"Unsupported source extension: {ext}");
|
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
|
// Per-conversion temp directory — avoids filename collisions when
|
||||||
// requests run concurrently and makes cleanup atomic.
|
// multiple requests run concurrently and makes cleanup atomic.
|
||||||
var workDir = Path.Combine(Path.GetTempPath(), "solutionerp-pdf", Guid.NewGuid().ToString("N"));
|
var workDir = Path.Combine(Path.GetTempPath(), "solutionerp-conv", Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(workDir);
|
Directory.CreateDirectory(workDir);
|
||||||
var inputName = $"input.{ext}";
|
var inputName = $"input.{srcExt}";
|
||||||
var inputPath = Path.Combine(workDir, inputName);
|
var inputPath = Path.Combine(workDir, inputName);
|
||||||
var outputPath = Path.Combine(workDir, "input.pdf");
|
var outputPath = Path.Combine(workDir, $"input.{tgtExt}");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -43,8 +47,8 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
|
|||||||
// --headless: no GUI
|
// --headless: no GUI
|
||||||
// --norestore: don't try to resurrect prior session
|
// --norestore: don't try to resurrect prior session
|
||||||
// -env:UserInstallation: isolate user profile per worker (avoids
|
// -env:UserInstallation: isolate user profile per worker (avoids
|
||||||
// "soffice already running" conflict when multiple requests hit)
|
// "soffice already running" conflict when concurrent)
|
||||||
Arguments = $"--headless --norestore --convert-to pdf " +
|
Arguments = $"--headless --norestore --convert-to {tgtExt} " +
|
||||||
$"-env:UserInstallation=file:///{workDir.Replace('\\', '/')}/profile " +
|
$"-env:UserInstallation=file:///{workDir.Replace('\\', '/')}/profile " +
|
||||||
$"--outdir \"{workDir}\" \"{inputPath}\"",
|
$"--outdir \"{workDir}\" \"{inputPath}\"",
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
@ -74,12 +78,13 @@ public class LibreOfficePdfConverter(IConfiguration config, ILogger<LibreOfficeP
|
|||||||
{
|
{
|
||||||
var stderr = await proc.StandardError.ReadToEndAsync(ct);
|
var stderr = await proc.StandardError.ReadToEndAsync(ct);
|
||||||
var stdout = await proc.StandardOutput.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}).");
|
throw new InvalidOperationException($"LibreOffice conversion failed (exit {proc.ExitCode}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(outputPath))
|
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);
|
return await File.ReadAllBytesAsync(outputPath, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user