[CLAUDE] Phase2: Form Engine MVP + docs (gotchas, skill, handoff)
Backend Forms:
- Domain/Forms: ContractTemplate (FormCode, Name, ContractType, FileName, StoragePath, Format, FieldSpec JSON, IsActive) + ContractClause
- EF config voi unique FormCode + query filter IsDeleted
- DbSets + IApplicationDbContext update
- Migration AddForms (bang 14 total)
- Packages: DocumentFormat.OpenXml 3.x + ClosedXML 0.105+
- Application/Forms:
- IFormRenderer interface + RenderResult record
- FormFeatures.cs: List/Get/Render CQRS
- IWebHostEnvironmentLocator (abstract IWebHostEnvironment)
- Infrastructure/Forms:
- DocxRenderer: OpenXml-based placeholder {{field}} replace, handle split runs (gom text tat ca <w:t> trong paragraph, replace, gan lai text dau + clear rest)
- XlsxRenderer: ClosedXML cell value replace
- FormRenderer router theo format docx/xlsx
- Api:
- FormsController: GET /templates (filter type, onlyActive), GET /templates/{id}, POST /templates/{id}/render (return file)
- WebHostEnvironmentLocator impl
- DbInitializer SeedContractTemplatesAsync: seed 8 template metadata, IsActive=true chi khi file ton tai
Templates vat ly:
- Copy 5 .docx/.xlsx tu FORM/ sang wwwroot/templates/
- 3 .doc (FO-002.02/03/06) chua convert: IsActive=false (Word COM bi stuck luc test, can retry voi DisplayAlerts=0 hoac LibreOffice)
- scripts/convert-doc-to-docx.ps1 (Word COM automation)
Frontend fe-admin:
- types/forms.ts: ContractTemplate + ContractTypeLabel
- pages/forms/FormsPage.tsx: list templates + Render dialog (paste JSON data → download .docx/.xlsx)
- Route /forms them vao App.tsx
Bug fix:
- SpaceProcessingModeValues namespace: wrap EnumValue<> full path
- SaveAs2($path, 16) thay vi SaveAs([ref], [ref]) — PowerShell type issue
- Word COM stuck: kill process, skip .doc cho MVP
Docs (theo yeu cau user):
- docs/gotchas.md MOI: 17 pitfalls nhom theo tech stack / EF Core / OpenXml / JSON / dev workflow
- .claude/skills/form-engine/SKILL.md: placeholder → full spec (algorithm + code pointers + API + limitations)
- .claude/skills/permission-matrix/SKILL.md: placeholder → full spec (BE policy + FE guard + seed + pitfalls)
- docs/HANDOFF.md MOI: brief 5 phut cho session sau (run quickstart + where we are + next steps + file tree + gotchas ref)
- docs/STATUS.md: update cumulative stats + next up Phase 3
- docs/changelog/migration-todos.md: tick Phase 2 iteration 1 items + add iteration 2 list
- docs/changelog/sessions/2026-04-21-1200-phase2-form-engine.md: session log
- CLAUDE.md root: them reference den gotchas + HANDOFF
E2E verified:
- GET /api/forms/templates (onlyActive=false) → 8 templates
- POST /api/forms/templates/{FO-002.05}/render voi data dict → HTTP 200 + file .docx 482KB (Microsoft Word 2007+ OK)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs
Normal file
77
src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
// Placeholder replace syntax: {{fieldName}}
|
||||
// Hạn chế Phase 2 MVP:
|
||||
// - Không support {{#loop}}...{{/loop}} (table lặp) — Phase 2 iteration 2
|
||||
// - Không split placeholder giữa 2 <w:t> (Word hay làm) — merge runs trước khi replace
|
||||
public class DocxRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderRegex = new(@"\{\{([a-zA-Z0-9_\.]+)\}\}", RegexOptions.Compiled);
|
||||
|
||||
public async Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
var bytes = File.ReadAllBytes(templateAbsolutePath);
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write(bytes, 0, bytes.Length);
|
||||
ms.Position = 0;
|
||||
|
||||
using (var doc = WordprocessingDocument.Open(ms, isEditable: true))
|
||||
{
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
if (body is null) throw new InvalidOperationException("Template .docx không có Body");
|
||||
|
||||
// Xử lý cả main document + headers + footers
|
||||
ReplaceInElement(body, data);
|
||||
foreach (var hp in doc.MainDocumentPart!.HeaderParts)
|
||||
if (hp.Header is not null) ReplaceInElement(hp.Header, data);
|
||||
foreach (var fp in doc.MainDocumentPart.FooterParts)
|
||||
if (fp.Footer is not null) ReplaceInElement(fp.Footer, data);
|
||||
|
||||
doc.MainDocumentPart.Document.Save();
|
||||
}
|
||||
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
outputFileName,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
}
|
||||
|
||||
private static void ReplaceInElement(DocumentFormat.OpenXml.OpenXmlElement root, IReadOnlyDictionary<string, string?> data)
|
||||
{
|
||||
// Iterate từng paragraph: gom text của mọi <w:t> trong cùng paragraph → replace → gán lại vào <w:t> đầu + clear rest
|
||||
foreach (var para in root.Descendants<Paragraph>().ToList())
|
||||
{
|
||||
var textElements = para.Descendants<Text>().ToList();
|
||||
if (textElements.Count == 0) continue;
|
||||
|
||||
var combined = string.Concat(textElements.Select(t => t.Text));
|
||||
if (!combined.Contains("{{")) continue;
|
||||
|
||||
var replaced = PlaceholderRegex.Replace(combined, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
return data.TryGetValue(key, out var value) ? (value ?? string.Empty) : match.Value;
|
||||
});
|
||||
|
||||
if (replaced == combined) continue;
|
||||
|
||||
// Gán vào <w:t> đầu, clear rest (preserve run style của <w:t> đầu)
|
||||
textElements[0].Text = replaced;
|
||||
textElements[0].Space = new DocumentFormat.OpenXml.EnumValue<DocumentFormat.OpenXml.SpaceProcessingModeValues>(DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
|
||||
for (var i = 1; i < textElements.Count; i++)
|
||||
textElements[i].Text = string.Empty;
|
||||
}
|
||||
|
||||
// Tables cũng có Paragraph lồng bên trong → đã được Descendants<Paragraph> bắt
|
||||
}
|
||||
}
|
||||
24
src/Backend/SolutionErp.Infrastructure/Forms/FormRenderer.cs
Normal file
24
src/Backend/SolutionErp.Infrastructure/Forms/FormRenderer.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
public class FormRenderer : IFormRenderer
|
||||
{
|
||||
private readonly DocxRenderer _docx = new();
|
||||
private readonly XlsxRenderer _xlsx = new();
|
||||
|
||||
public Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
string format,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"docx" => _docx.RenderAsync(templateAbsolutePath, data, outputFileName, ct),
|
||||
"xlsx" => _xlsx.RenderAsync(templateAbsolutePath, data, outputFileName, ct),
|
||||
_ => throw new NotSupportedException($"Format '{format}' không được hỗ trợ. Chỉ 'docx' hoặc 'xlsx'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs
Normal file
47
src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClosedXML.Excel;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
public class XlsxRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderRegex = new(@"\{\{([a-zA-Z0-9_\.]+)\}\}", RegexOptions.Compiled);
|
||||
|
||||
public async Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
using var wb = new XLWorkbook(templateAbsolutePath);
|
||||
|
||||
foreach (var ws in wb.Worksheets)
|
||||
{
|
||||
foreach (var cell in ws.CellsUsed())
|
||||
{
|
||||
if (cell.DataType != XLDataType.Text) continue;
|
||||
var text = cell.GetString();
|
||||
if (!text.Contains("{{")) continue;
|
||||
|
||||
var replaced = PlaceholderRegex.Replace(text, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
return data.TryGetValue(key, out var value) ? (value ?? string.Empty) : match.Value;
|
||||
});
|
||||
|
||||
if (replaced != text)
|
||||
cell.Value = replaced;
|
||||
}
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
outputFileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user