[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:
pqhuy1987
2026-04-21 12:01:11 +07:00
parent 54d6c9ba52
commit 5113e4c771
37 changed files with 2379 additions and 88 deletions

View File

@ -3,7 +3,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
@ -20,6 +22,8 @@ public static class DependencyInjection
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddScoped<AuditingInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>

View 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
}
}

View 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'."),
};
}
}

View 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");
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
@ -16,6 +17,8 @@ public class ApplicationDbContext
public DbSet<Department> Departments => Set<Department>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();
public DbSet<ContractClause> ContractClauses => Set<ContractClause>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Forms;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class ContractClauseConfiguration : IEntityTypeConfiguration<ContractClause>
{
public void Configure(EntityTypeBuilder<ContractClause> b)
{
b.ToTable("ContractClauses");
b.HasKey(x => x.Id);
b.Property(x => x.Code).HasMaxLength(50).IsRequired();
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Content).HasColumnType("nvarchar(max)").IsRequired();
b.HasIndex(x => x.Code).IsUnique();
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Forms;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class ContractTemplateConfiguration : IEntityTypeConfiguration<ContractTemplate>
{
public void Configure(EntityTypeBuilder<ContractTemplate> b)
{
b.ToTable("ContractTemplates");
b.HasKey(x => x.Id);
b.Property(x => x.FormCode).HasMaxLength(50).IsRequired();
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.ContractType).HasConversion<int?>();
b.Property(x => x.FileName).HasMaxLength(255).IsRequired();
b.Property(x => x.StoragePath).HasMaxLength(500).IsRequired();
b.Property(x => x.Format).HasMaxLength(10).IsRequired();
b.Property(x => x.FieldSpec).HasColumnType("nvarchar(max)");
b.Property(x => x.Description).HasMaxLength(500);
b.HasIndex(x => x.FormCode).IsUnique();
b.HasIndex(x => x.ContractType);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Persistence;
@ -27,6 +29,7 @@ public static class DbInitializer
await SeedAdminAsync(userManager, logger);
await SeedMenuTreeAsync(db, logger);
await SeedAdminPermissionsAsync(db, roleManager, logger);
await SeedContractTemplatesAsync(db, logger);
}
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger logger)
@ -68,7 +71,6 @@ public static class DbInitializer
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
{
// (key, label, parent, order, icon) — icon là lucide-react name
var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
{
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
@ -128,4 +130,67 @@ public static class DbInitializer
logger.LogInformation("Seeded {Count} admin permissions", added);
}
}
private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger)
{
// Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/.
// 3 file .doc (FO-002.02/03/06) chưa convert sẽ IsActive=false.
var templates = new (string FormCode, string Name, ContractType? Type, string FileName, string Format, string? Description)[]
{
("SOL-CCM-FO-002.01", "Bảng kiểm tra hợp đồng", null,
"SOL-CCM-FO-002.01.v01 Bang kiem tra hop dong.docx", "docx",
"Checklist duyệt HĐ theo 4 bộ phận: Đề xuất / Cung ứng / CCM / Giám đốc"),
("SOL-CCM-FO-002.02", "Hợp đồng trọn gói — Nhân công + Vật tư", ContractType.HopDongThauPhu,
"SOL-CCM-FO-002.02.v01 Hop dong tron goi nhan cong, vat tu.docx", "docx",
"Template HĐ thầu phụ trọn gói (cần convert .doc → .docx qua Word COM)"),
("SOL-CCM-FO-002.03", "Hợp đồng trọn gói — Nhân công + Thiết bị", ContractType.HopDongThauPhu,
"SOL-CCM-FO-002.03.v01 Hop dong trong goi nhan cong, thiet bi.docx", "docx",
"Template HĐ thầu phụ trọn gói (cần convert .doc → .docx qua Word COM)"),
("SOL-CCM-FO-002.04", "Điều kiện chung hợp đồng trọn gói", null,
"SOL-CCM-FO-002.04.v01 Dieu kien chung hop dong tron goi.docx", "docx",
"Appendix điều khoản chung đính kèm HĐ trọn gói"),
("SOL-CCM-FO-002.05", "Hợp đồng Giao khoán", ContractType.HopDongGiaoKhoan,
"SOL-CCM-FO-002.05.v01 Hop dong Giao khoan.docx", "docx",
"Template HĐ giao khoán với Tổ đội / Nhân công"),
("SOL-CCM-FO-002.06", "Hợp đồng mua bán", ContractType.HopDongMuaBan,
"SOL-CCM-FO-002.06.v01 Hop dong mua ban.docx", "docx",
"Template HĐ mua bán NCC (cần convert .doc → .docx qua Word COM)"),
("SOL-CCM-FO-002.07", "Đơn đặt hàng (PO)", null,
"SOL-CCM-FO-002.07.V01 Don dat hang.xlsx", "xlsx",
"Template PO Excel — 3 sheet: so sánh giá, PO chính, PO NLT"),
("SOL-CCM-RG-001", "Quy định mã số hợp đồng", null,
"SOL-CCM-RG-001.v02 QD ma so hop dong.docx", "docx",
"Regulation mã HĐ + PO — reference doc"),
};
var existingCodes = await db.ContractTemplates.Select(t => t.FormCode).ToListAsync();
var wwwroot = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
var added = 0;
foreach (var (formCode, name, type, fileName, format, desc) in templates)
{
if (existingCodes.Contains(formCode)) continue;
var templatePath = $"templates/{fileName}";
var absPath = Path.Combine(wwwroot, "templates", fileName);
var fileExists = File.Exists(absPath);
db.ContractTemplates.Add(new ContractTemplate
{
FormCode = formCode,
Name = name,
ContractType = type,
FileName = fileName,
StoragePath = templatePath,
Format = format,
Description = desc,
IsActive = fileExists,
});
added++;
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} contract templates (active file check)", added);
}
}
}

View File

@ -0,0 +1,725 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SolutionErp.Infrastructure.Persistence;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260421043848_AddForms")]
partial class AddForms
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ContractClauses", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int?>("ContractType")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("FieldSpec")
.HasColumnType("nvarchar(max)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FormCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Format")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractType");
b.HasIndex("FormCode")
.IsUnique();
b.ToTable("ContractTemplates", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Property<string>("Key")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Icon")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<string>("ParentKey")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Key");
b.HasIndex("ParentKey");
b.ToTable("MenuItems", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("CanCreate")
.HasColumnType("bit");
b.Property<bool>("CanDelete")
.HasColumnType("bit");
b.Property<bool>("CanRead")
.HasColumnType("bit");
b.Property<bool>("CanUpdate")
.HasColumnType("bit");
b.Property<string>("MenuKey")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MenuKey");
b.HasIndex("RoleId", "MenuKey")
.IsUnique();
b.ToTable("Permissions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateTime?>("RefreshTokenExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Department", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid?>("ManagerUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Departments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("BudgetTotal")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid?>("ManagerUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Projects", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ContactPerson")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Note")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<string>("TaxCode")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("Type");
b.ToTable("Suppliers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
.WithMany("Children")
.HasForeignKey("ParentKey")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu")
.WithMany("Permissions")
.HasForeignKey("MenuKey")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Menu");
b.Navigation("Role");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Navigation("Children");
b.Navigation("Permissions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddForms : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractClauses",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "nvarchar(max)", nullable: false),
Version = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractClauses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContractTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FormCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
ContractType = table.Column<int>(type: "int", nullable: true),
FileName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Format = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
FieldSpec = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractTemplates", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ContractClauses_Code",
table: "ContractClauses",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ContractTemplates_ContractType",
table: "ContractTemplates",
column: "ContractType");
migrationBuilder.CreateIndex(
name: "IX_ContractTemplates_FormCode",
table: "ContractTemplates",
column: "FormCode",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractClauses");
migrationBuilder.DropTable(
name: "ContractTemplates");
}
}
}

View File

@ -125,6 +125,136 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ContractClauses", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int?>("ContractType")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("FieldSpec")
.HasColumnType("nvarchar(max)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("FormCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Format")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractType");
b.HasIndex("FormCode")
.IsUnique();
b.ToTable("ContractTemplates", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Property<string>("Key")

View File

@ -5,6 +5,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6">