[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

@ -1,34 +1,130 @@
--- ---
name: form-engine name: form-engine
description: Template engine render 8 form hợp đồng ra .docx/.xlsx giống 100% mẫu gốc. Gen mã HĐ theo RG-001. Dùng khi export HĐ, upload template mới, debug render lỗi format. description: Template engine render 8 form hợp đồng ra .docx/.xlsx giống 100% mẫu gốc. Placeholder syntax {{field}}. Dùng khi export HĐ, upload template mới, debug render lỗi format, gen mã HĐ theo RG-001.
when-to-use: when-to-use:
- "export contract to word" - "export contract to word"
- "render template docx" - "render template docx"
- "xuất đơn đặt hàng excel" - "xuất đơn đặt hàng excel"
- "gen mã hợp đồng" - "placeholder không replace"
- "upload template mới" - "upload template mới"
- "gen mã hợp đồng"
--- ---
# Form Engine Skill # Form Engine Skill
> **Phase 2 deliverable.** Hiện tại skill này là PLACEHOLDER. > **Status:** Phase 2 implemented (MVP — placeholder replace cơ bản).
> **Missing:** loop `{{#loop}}...{{/loop}}` cho table lặp, field spec JSON, PDF convert, form builder FE.
## Context ## Tech stack
Xem đầy đủ ở [`docs/forms-spec.md`](../../../docs/forms-spec.md): | Layer | Tech | Purpose |
- 8 form (6 .docx/.doc + 1 .xlsx + 1 .docx quy định) |---|---|---|
- Mã HĐ format theo SOL-CCM-RG-001: `{Project}/{Type}/SOL&{Partner}/{Seq}` và biến thể | `.docx` render | **DocumentFormat.OpenXml 3.x** | Free, maintained by MS |
- 3 file `.doc` cần convert qua Word COM / LibreOffice headless trước khi parse | `.xlsx` render | **ClosedXML 0.105+** | Free LGPL, dễ dùng, support formula/style |
| PDF convert | LibreOffice headless (chưa implement) | `soffice --headless --convert-to pdf` — Phase 4 |
| `.doc``.docx` | Word COM (PowerShell) | Chạy offline 1 lần trên dev machine |
## Tech stack dự kiến ## Placeholder syntax
- **.docx render:** DocumentFormat.OpenXml (free, verbose) hoặc Aspose.Words (phí, dễ) Template (.docx hoặc .xlsx) chứa `{{fieldName}}` — regex `\{\{([a-zA-Z0-9_\.]+)\}\}`:
- **.xlsx render:** EPPlus (free non-commercial) hoặc ClosedXML (free)
- **PDF preview:** wkhtmltopdf hoặc LibreOffice `--convert-to pdf`
## Code pointers (sẽ có sau Phase 2) ```
Hợp đồng số: {{maHopDong}}
Bên A: {{benA_tenCongTy}}
Giá trị: {{giaTri}}
```
- `src/Backend/SolutionErp.Application/Forms/Services/IFormRenderer.cs` **Data dictionary:**
- `src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs` ```json
- `src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs` {
- `src/Backend/SolutionErp.Infrastructure/Services/ContractCodeGenerator.cs` "maHopDong": "FLOCK 01/HĐGK/SOL&PVL/03",
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
"giaTri": "150,000,000 VND"
}
```
Null value → replace bằng rỗng. Key không có trong data → giữ nguyên placeholder.
## Code pointers
- `src/Backend/SolutionErp.Application/Forms/Services/IFormRenderer.cs` — interface
- `src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs` — OpenXml-based
- `src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs` — ClosedXML-based
- `src/Backend/SolutionErp.Infrastructure/Forms/FormRenderer.cs` — router theo format
- `src/Backend/SolutionErp.Application/Forms/FormFeatures.cs` — CQRS list/get/render
- `src/Backend/SolutionErp.Api/Controllers/FormsController.cs` — REST endpoints
- `src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs``SeedContractTemplatesAsync`
- `fe-admin/src/pages/forms/FormsPage.tsx` — UI list + render test
- `src/Backend/SolutionErp.Api/wwwroot/templates/` — file templates vật lý
## API endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/forms/templates?type=&onlyActive=` | List templates |
| GET | `/api/forms/templates/{id}` | Get single |
| POST | `/api/forms/templates/{id}/render` | Render với data dictionary → return file |
## Key algorithm — Placeholder split fix
Word thường split placeholder thành nhiều `<w:t>` runs (vì style, typo check…). `DocxRenderer` xử lý bằng:
```csharp
foreach (var para in root.Descendants<Paragraph>()) {
var textElements = para.Descendants<Text>().ToList();
var combined = string.Concat(textElements.Select(t => t.Text));
if (!combined.Contains("{{")) continue;
var replaced = Regex.Replace(combined, @"\{\{([a-zA-Z0-9_\.]+)\}\}", match =>
data.TryGetValue(match.Groups[1].Value, out var v) ? (v ?? "") : match.Value);
if (replaced != combined) {
textElements[0].Text = replaced; // gán vào text đầu
textElements[0].Space = ...Preserve; // giữ space
for (var i = 1; i < textElements.Count; i++)
textElements[i].Text = ""; // clear phần còn lại
}
}
```
## Workflow thêm template mới
1. Upload file `.docx` / `.xlsx``wwwroot/templates/{formCode}.{ext}`
2. Insert row vào `ContractTemplates`:
```sql
INSERT INTO ContractTemplates (FormCode, Name, ContractType, FileName, StoragePath, Format, IsActive, CreatedAt)
VALUES ('SOL-CCM-FO-002.XX', 'Tên', 1, 'file.docx', 'templates/file.docx', 'docx', 1, GETUTCDATE());
```
3. Template tự động xuất hiện ở `/forms` FE
## Known limitations
| # | Limitation | Phase fix |
|---|---|---|
| 1 | Không support `{{#loop}}...{{/loop}}` cho table lặp | Phase 2 iteration 2 |
| 2 | Không có field spec JSON — form builder FE phải điền JSON thủ công | Phase 2 iteration 2 |
| 3 | 3 file `.doc` (FO-002.02/03/06) chưa convert → IsActive=false | Convert offline qua Word COM |
| 4 | Không có PDF convert → preview chỉ download .docx | Phase 4 |
| 5 | Không handle `.docm` (macro) — chỉ accept `.docx` / `.xlsx` | By design |
| 6 | Không convert format trong template (vd number → `150,000,000 VND`) — BE phải format trước khi pass data | Phase 3 khi gen mã HĐ |
## Common pitfalls (xem gotchas.md)
- **Placeholder bị split runs** → đã handle trong DocxRenderer
- **SpaceProcessingModeValues namespace** — xem gotcha #9
- **Word COM stuck** — gotcha #12
- **SaveAs type conversion** — gotcha #11
- **Template file không tồn tại** → throw `NotFoundException` ở RenderCommandHandler
## Gen mã HĐ (RG-001) — chưa implement (Phase 3)
Xem [`docs/forms-spec.md §RG-001`](../../../docs/forms-spec.md). Tóm tắt format:
| Loại HĐ | Format |
|---|---|
| HĐ Thầu phụ | `{Project}/HĐTP/SOL&{Partner}/{Seq}` |
| HĐ Giao khoán | `{Project}/HĐGK/SOL&{Partner}/{Seq}` |
| HĐ NCC | `{Project}/NCC/SOL&{Partner}/{Seq}` |
| HĐ Nguyên tắc | `{Year}/NCC/SOL&{Partner}/{Seq}` |
Planned implementation: `IContractCodeGenerator.GenerateAsync()` với transaction SERIALIZABLE + `ContractCodeSequences` table để tránh race.

View File

@ -1,41 +1,143 @@
--- ---
name: permission-matrix name: permission-matrix
description: Hệ thống phân quyền Role × MenuKey × CRUD. Sidebar gating, permission guard, seed default, reset password. Dùng khi debug access denied, gán role, menu không hiện. description: Hệ thống phân quyền Role × MenuKey × CRUD. Seed 12 menu + admin full. FE PermissionGuard + usePermission. BE AuthorizationHandler + 48 policy. Dùng khi debug access denied, gán role, menu không hiện.
when-to-use: when-to-use:
- "permission denied" - "permission denied"
- "access denied" - "access denied"
- "menu không hiện" - "menu không hiện"
- "gán role cho user" - "gán role cho user"
- "reset password"
- "seed permission" - "seed permission"
- "permission matrix edit"
--- ---
# Permission Matrix Skill # Permission Matrix Skill
> **Phase 1 deliverable.** Hiện tại skill này là PLACEHOLDER. > **Status:** Phase 1 đợt 2 IMPLEMENTED.
## Context ## Model
Pattern copy từ **NamGroup** skill `permission-system` nhưng đơn giản hóa: ```
- 1 User có N Role User ────< UserRoles ────< Role ────< Permissions ────< MenuItem
- 1 Role có ma trận (MenuKey, CRUD flags) — `Permission` table (RoleId, MenuKey, CRUD flags)
- Không có per-user override (giữ đơn giản cho Phase 1) ```
- Menu tree flat 2 cấp, hardcode `MenuKey`
## Tech - 1 User có N Role (qua `AspNetUserRoles` rename → `UserRoles`)
- 1 Role có N Permission (1 row per MenuKey × 4 CRUD flag)
- Union (OR) nhiều role → user có quyền nếu **bất kỳ role nào** cho quyền đó
- Admin role → **bypass** check (luôn pass mọi policy)
- BE: `[Authorize(Policy = "Menu.Read")]` attribute ## Menu tree (seed)
- FE: `<PermissionGuard menuKey="Contracts" action="Update">` + `usePermission().can("Contracts", "Update")`
- Resolution: API `/api/menus/me` trả về tree + permissions đã resolved theo user's roles
## Code pointers (sẽ có sau Phase 1) 12 menu trong `MenuKeys.All`:
- `src/Backend/SolutionErp.Domain/Identity/Permission.cs` ```
- `src/Backend/SolutionErp.Application/Permissions/Queries/GetMyMenuTreeQuery.cs` Dashboard
- `fe-admin/src/components/PermissionGuard.tsx` Master
- `fe-admin/src/hooks/usePermission.ts` ├── Suppliers
├── Projects
└── Departments
Contracts
Forms
Reports
System
├── Users
├── Roles
└── Permissions
```
## Common pitfalls (dự kiến) Tree hierarchy qua `ParentKey` field. Seed trong `DbInitializer.SeedMenuTreeAsync`.
- Quên refresh token sau khi admin update permission → user phải logout/login mới thấy ## Code pointers
- MenuKey hardcode dễ typo → tập trung vào file `src/lib/menuKeys.ts` (FE) + `MenuKeys.cs` (BE const)
**Backend:**
- `Domain/Identity/MenuKeys.cs` — const class, single source of truth
- `Domain/Identity/MenuItem.cs` — entity (Key PK, Label, ParentKey, Order, Icon)
- `Domain/Identity/Permission.cs` — entity (RoleId, MenuKey, 4 flag)
- `Application/Permissions/Queries/GetMyMenuTree/GetMyMenuTreeQuery.cs` — resolve per-user, union OR, filter tree
- `Application/Permissions/PermissionFeatures.cs` — list/upsert
- `Api/Authorization/MenuPermissionRequirement.cs` + `MenuPermissionHandler.cs` — policy check
- `Api/Program.cs` — register 48 policy `{menu}.{action}` trong AddAuthorization
- `Infrastructure/Persistence/DbInitializer.cs``SeedMenuTreeAsync` + `SeedAdminPermissionsAsync`
- `Api/Controllers/MenusController.cs`, `RolesController.cs`, `PermissionsController.cs`
**Frontend (fe-admin):**
- `src/lib/menuKeys.ts` — const mirror, cần **đồng bộ tay** với BE
- `src/types/menu.ts` — MenuNode type
- `src/hooks/usePermission.ts``can(menuKey, action)` helper
- `src/components/PermissionGuard.tsx` — wrap button/content
- `src/components/Layout.tsx` — render sidebar động từ AuthContext.menu
- `src/pages/system/PermissionsPage.tsx` — ma trận edit UI
- `src/contexts/AuthContext.tsx``loadMenu()` on login + localStorage cache
## BE policy usage
Register trong Program.cs:
```csharp
services.AddAuthorization(opts =>
{
foreach (var menu in MenuKeys.All)
foreach (var action in MenuKeys.Actions)
opts.AddPolicy($"{menu}.{action}", p =>
p.Requirements.Add(new MenuPermissionRequirement(menu, action)));
});
services.AddScoped<IAuthorizationHandler, MenuPermissionHandler>();
```
Apply ở controller:
```csharp
[HttpPut("{id:guid}")]
[Authorize(Policy = "Contracts.Update")]
public async Task<IActionResult> Update(...) { }
```
## FE guard usage
```tsx
// Hook
const { can } = usePermission()
if (!can('Contracts', 'Update')) return null
// Component wrap
<PermissionGuard menuKey="Contracts" action="Update">
<Button>Sửa</Button>
</PermissionGuard>
// Route guard
<Route
path="/system/permissions"
element={
<PermissionGuard menuKey="Permissions" action="Read" fallback={<Forbidden />}>
<PermissionsPage />
</PermissionGuard>
}
/>
```
## Workflow — gán quyền cho role mới
1. Admin login → `/system/permissions`
2. Chọn role (vd "CostControl")
3. Tick checkbox trên matrix grid — mỗi lần tick tự động PUT `/api/permissions` upsert
4. User thuộc role đó logout/login lại → thấy permission mới (menu refresh từ `/api/menus/me`)
## Guard rules đã implement
- **Admin bypass:** role `Admin` luôn pass mọi policy (kể cả chưa seed row Permission)
- **Not user active:** `User.IsActive=false` → AuthorizationHandler return fail
- **Self-demote protection:** admin đang edit không thể giảm quyền role Admin (check trong `UpsertPermissionCommandHandler`)
## Common pitfalls
- **Quên refresh menu sau update permission** → user thấy menu cũ. Giải pháp: logout/login, hoặc Phase 3 thêm SignalR push.
- **MenuKey typo** — TS không check vì menu.key là string. Luôn dùng `MenuKeys.Contracts` const, không hardcode `"Contracts"`.
- **FE cache menu trong localStorage** → sau user được assign role mới, FE thấy menu cũ. Login lại fix.
- **Hai role conflict** (1 cho, 1 cấm): union OR → có ít nhất 1 role cho là được.
- **403 ở API nhưng FE không hide button** → FE guard chỉ UX, BE phải là source of truth. Phải apply `[Authorize(Policy = "X.Y")]` ở controller.
## Phase tiếp theo
- **Phase 3:** SignalR notify khi permission đổi → FE tự refetch `/api/menus/me`
- **Phase 4:** Per-user override (ngoài role) — thêm bảng `UserPermissionOverrides`
- **Phase 4:** Invalidate JWT khi role đổi (rare event, nhưng secure)

View File

@ -70,6 +70,8 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
| [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ | | [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ |
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD | | [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD |
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) | | [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) |
| [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 17 bẫy đã gặp — đọc trước khi debug tương tự |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | Brief 5 phút: session trước làm gì + P1 tasks tiếp |
## ⚠️ Kết thúc session ## ⚠️ Kết thúc session

155
docs/HANDOFF.md Normal file
View File

@ -0,0 +1,155 @@
# HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-04-21 12:00 (cuối Phase 2 MVP)
## Ở đâu rồi?
| Phase | Trạng thái |
|---|---|
| 0 Draft | ✅ Done |
| 1 Alpha Core foundation | ✅ Done |
| 1 Alpha Core đợt 2 (CRUD + Permission) | ✅ Done |
| **2 Form Engine MVP** | ✅ Done |
| 2 Form Engine iteration 2 | 📝 Optional |
| 3 Workflow (9 phase state machine) | 📋 Next |
| 4 Report + Polish | 📋 Queue |
| 5 Production (CI/CD IIS) | 📋 Queue |
## Run nhanh
```powershell
# Terminal 1 — API (auto seed 8 template lần đầu)
dotnet run --project src\Backend\SolutionErp.Api
# Terminal 2 — Admin FE
cd fe-admin && npm run dev # → http://localhost:8082
# Terminal 3 — User FE
cd fe-user && npm run dev # → http://localhost:8080
```
Login: `admin@solutionerp.local` / `Admin@123456`
Điểm cần test ngay:
- `/forms` → render FO-002.05 → download .docx (Phase 2 MVP)
- `/system/permissions` → chọn role → tick matrix
- `/master/suppliers|projects|departments` → CRUD
## Cần làm kế tiếp (ưu tiên)
### A. Phase 3 — Workflow (item lớn, ~3 tuần work)
**Đọc trước:**
1. [`workflow-contract.md`](workflow-contract.md) — spec 9 phase + role matrix
2. [`flows/contract-approval-flow.md`](flows/contract-approval-flow.md) — sequence diagram
3. [`flows/sla-expiry-flow.md`](flows/sla-expiry-flow.md) — BackgroundService auto-approve
4. [`forms-spec.md#RG-001`](forms-spec.md) — format mã HĐ
**Deliverable chính:**
- Entity: `Contract` (Phase, SlaDeadline, BypassProcurementAndCCM, DraftData) + `ContractApproval` + `ContractComment` + `ContractAttachment`
- `IContractWorkflowService.TransitionAsync()` — state guard (9 phase adjacency) + role guard
- `IContractCodeGenerator` (implement RG-001) với transaction SERIALIZABLE + `ContractCodeSequences` table
- `SlaExpiryJob` BackgroundService — auto-approve HĐ quá hạn mỗi 15 phút
- `INotificationService` — email (MailKit) + in-app
- API `POST /api/contracts/{id}/transitions`
- FE `/inbox` (list HĐ chờ tôi xử lý theo role × phase)
- FE `/contracts/{id}` detail — timeline 9 phase, approval panel, comment thread, attachment upload
### B. Phase 2 iteration 2 (nếu user muốn polish Form Engine)
- Convert 3 file `.doc` (retry Word COM với timeout OR LibreOffice)
- Field spec JSON → dynamic form builder
- `{{#loop}}...{{/loop}}` support
- PDF convert
- FE upload template UI
### C. Quick wins (không block phase)
- FE Users management + Roles CRUD (test permission với non-admin role)
- fe-user sync menu động (đang hardcode)
## Lưu ý kỹ thuật quan trọng
**Đọc [`gotchas.md`](gotchas.md) trước khi:**
- Thêm package mới → check compat với .NET 10 (MediatR 14 fail → dùng 12)
- Debug 404 API → kiểm Program.cs có persist không (Dropbox issue)
- Expression tree error → tách switch ra ngoài LINQ
- TS enum error → dùng const-object pattern (`erasableSyntaxOnly`)
- Word COM stuck → kill + fallback LibreOffice
- Migration lỗi → check 3 file đầy đủ (Designer + Migration + Snapshot)
## File đang active
```
SOLUTION_ERP/
├── src/Backend/ (Clean Arch, 4 project, .NET 10)
│ ├── SolutionErp.Domain/
│ │ ├── Common/ BaseEntity, AuditableEntity
│ │ ├── Contracts/ ContractType, ContractPhase, ApprovalDecision
│ │ ├── Forms/ ContractTemplate, ContractClause ← Phase 2
│ │ ├── Identity/ User, Role, MenuItem, Permission, AppRoles, MenuKeys
│ │ └── Master/ Supplier, Project, Department, SupplierType
│ ├── SolutionErp.Application/
│ │ ├── Auth/ Login, Refresh, Me
│ │ ├── Common/ Exceptions, Behaviors, Interfaces, Models
│ │ ├── Forms/ FormFeatures (List/Get/Render) ← Phase 2
│ │ ├── Master/ Suppliers, Projects, Departments CQRS
│ │ └── Permissions/ GetMyMenuTree, matrix upsert
│ ├── SolutionErp.Infrastructure/
│ │ ├── Forms/ DocxRenderer, XlsxRenderer, FormRenderer ← Phase 2
│ │ ├── Identity/ JwtSettings, JwtTokenService
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (4)
│ │ └── Services/ DateTimeService
│ └── SolutionErp.Api/
│ ├── Authorization/ MenuPermissionHandler + Requirement
│ ├── Controllers/ Auth, Suppliers, Projects, Departments, Menus, Roles, Permissions, Forms
│ ├── Middleware/ GlobalExceptionMiddleware
│ ├── Services/ CurrentUserService, WebHostEnvironmentLocator
│ └── wwwroot/templates/ 5 file .docx/.xlsx ← Phase 2
├── fe-admin/ (7 page)
│ └── src/pages/
│ ├── LoginPage
│ ├── DashboardPage
│ ├── master/SuppliersPage, ProjectsPage, DepartmentsPage
│ ├── system/PermissionsPage
│ └── forms/FormsPage ← Phase 2
├── fe-user/ (2 page — Login + Inbox placeholder)
├── docs/ (24 file — STATUS, PROJECT-MAP, workflow, forms-spec, database-guide, 6 flow, gotchas, 4 session log, 1 handoff)
└── .claude/skills/ (3 skill — contract-workflow placeholder, form-engine + permission-matrix full spec)
```
## Git state
```
49a5f57..(sẽ là commit 5) — Phase 2 MVP
54d6c9b — Phase 1.2 CRUD + Permission
49a5f57 — Docs database-guide + flows
702411f — Phase 1 foundation
25dad7f — Phase 0 scaffold
Branch: main
Remote: chưa (Gitea URL chờ user)
```
## Credentials + URLs
```
admin@solutionerp.local / Admin@123456
```
- API: http://localhost:5443 (swagger `/swagger`)
- Admin FE: http://localhost:8082
- User FE: http://localhost:8080
- SQL LocalDB: `(localdb)\MSSQLLocalDB` / Database=`SolutionErp_Dev`
## Đánh giá nhanh
**Tốt:**
- Build pass 100% cả BE + FE
- E2E test: login + CRUD + render template đều pass
- Docs đầy đủ: 24 file, có session log mỗi chunk, gotchas library tích lũy
**Rủi ro:**
- fe-user còn thô — Phase 3 phải build inbox
- Form render chỉ MVP — loop table + PDF chưa có
- Permission matrix chưa test thực với non-admin user

View File

@ -2,73 +2,85 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-04-21 11:30 **Last updated:** 2026-04-21 12:00
## 📍 Phase hiện tại: **Phase 1 — Alpha Core (đợt 2 xong)** — sẵn sàng Phase 2 Form Engine ## 📍 Phase hiện tại: **Phase 2 Form Engine (MVP xong)** — sẵn sàng Phase 3 Workflow
## 🔥 In Progress ## 🔥 In Progress
_(không có — Phase 1 đợt 2 hoàn tất)_ _(không có — tạm nghỉ chờ user approve move on)_
## ✅ Recently Done (newest on top) ## ✅ Recently Done (newest on top)
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-04-21 | Claude | **Phase 1 đợt 2 HOÀN TẤT** — BE: Supplier/Project/Department CRUD + Permission Matrix (MenuItem/Permission + Authorization handler) + 2 migration. FE: DataTable/Dialog generic, usePermission, PermissionGuard, 3 trang CRUD admin, Permission Matrix page, Layout menu động | (sắp commit) | | 2026-04-21 | Claude | **Phase 2 Form Engine MVP** — BE: ContractTemplate/ContractClause entities + OpenXml + ClosedXML renderer (placeholder `{{field}}`) + FormsController + seed 8 template. FE: FormsPage list + render dialog. `/api/forms/templates/{id}/render` trả file .docx/.xlsx 482KB OK | (sắp commit) |
| 2026-04-21 | Claude | **Docs:** `gotchas.md` (17 pitfalls) + update 2 skill (form-engine, permission-matrix) từ placeholder → full spec | (sắp commit) |
| 2026-04-21 | Claude | **Phase 1 đợt 2** — BE CRUD Supplier/Project/Department + Permission Matrix + FE 4 page | `54d6c9b` |
| 2026-04-21 | Claude | **Docs addition**`database-guide.md` + `flows/` 6 doc | `49a5f57` | | 2026-04-21 | Claude | **Docs addition**`database-guide.md` + `flows/` 6 doc | `49a5f57` |
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE Clean Arch + Identity + JWT + FE 2 app + Tailwind 4 + login E2E | `702411f` | | 2026-04-21 | Claude | **Phase 1 foundation** — BE Clean Arch + Identity + JWT + FE 2 app + login E2E | `702411f` |
| 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` | | 2026-04-21 | Claude | **Phase 0** — scaffold + parse FORM/QUY_TRINH + docs + git init | `25dad7f` |
Session logs: Session logs:
- [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) - [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md)
- [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md) - [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md)
- [`changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md`](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) - [`changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md`](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md)
- [`changelog/sessions/2026-04-21-1200-phase2-form-engine.md`](changelog/sessions/2026-04-21-1200-phase2-form-engine.md)
## 🎯 Next up — Phase 2 Form Engine (có thể bắt ngay) Gotchas library: [`gotchas.md`](gotchas.md) — 17 pitfalls đã gặp + cách xử lý.
- [ ] Convert 3 file `.doc` (FO-002.02/03/06) → `.docx` qua PowerShell COM hoặc LibreOffice headless ## 🎯 Next up
- [ ] Parse chi tiết field specs cho 5 template HĐ → JSON spec
- [ ] Add NuGet: DocumentFormat.OpenXml, ClosedXML
- [ ] `Domain/Entities/ContractTemplate`, `ContractClause` + EF config
- [ ] `Application/Forms/Services/IFormRenderer` + `DocxRenderer` + `XlsxRenderer`
- [ ] `Api/Controllers/FormsController` (list template, get spec, render preview, render final)
- [ ] FE: form builder dynamic render từ fieldSpec
- [ ] FE admin: upload template + manage ContractClause (rich text editor)
- [ ] Test: FO-002.05 Giao khoán render → docx khớp mẫu 100%
Chi tiết: [`docs/flows/form-render-flow.md`](flows/form-render-flow.md) + [`docs/changelog/migration-todos.md`](changelog/migration-todos.md) section Phase 2. ### Phase 2 iteration 2 (optional — enhance)
## 🔄 Còn có thể làm parallel (optional, không block Phase 2) - [ ] Convert 3 file `.doc` (retry Word COM với timeout, hoặc LibreOffice headless)
- [ ] Field spec JSON mỗi template + dynamic form builder FE
- [ ] Support `{{#loop}}...{{/loop}}` cho table lặp
- [ ] PDF convert via LibreOffice
- [ ] Admin upload template UI (POST multipart)
- [ ] FE Users management (tạo user + gán role) — cần để test permission với role khác Admin ### Phase 3 — Workflow (sắp tới, item lớn)
- [ ] FE Roles CRUD (tạo custom role mới)
- [ ] Contract entity skeleton (không state machine, chỉ CRUD draft)
- [ ] E2E test permission: tạo user role Drafter-only → verify không thấy menu System/Admin
## 📊 Thông số sau Phase 1 đợt 2 Xem [`docs/flows/contract-approval-flow.md`](flows/contract-approval-flow.md) + [`docs/workflow-contract.md`](workflow-contract.md).
- **Backend LOC:** ~1500 (Domain 150 + Application 800 + Infrastructure 350 + Api 200) Summary:
- **Migrations:** Init + AddMasterData + AddPermissions - [ ] Entity: Contract + ContractApproval + ContractComment + ContractAttachment
- **DB tables:** 7 Identity + 3 Master (Suppliers/Projects/Departments) + 2 Permission (MenuItems/Permissions) - [ ] `IContractWorkflowService` với state guard + role guard
- **API endpoints:** 20+ (Auth 4 + Suppliers 5 + Projects 5 + Departments 5 + Menus 2 + Roles 1 + Permissions 2) - [ ] `IContractCodeGenerator` RG-001 + transaction SERIALIZABLE
- **Frontend routes:** 5 (Dashboard + 3 CRUD + Permission Matrix) - [ ] `SlaExpiryJob` BackgroundService
- **FE LOC:** ~1700 (fe-admin; fe-user vẫn minimal) - [ ] Email + in-app notification service
- [ ] API `POST /api/contracts/{id}/transitions`
- [ ] FE Inbox + Contract detail page + timeline UI
### Optional (không block Phase 3)
- [ ] FE Users management + Roles CRUD (cho test permission với non-admin role)
- [ ] fe-user sync menu động (đang hardcode)
## 📊 Thông số cumulative
| | Phase 0 | +Phase 1f | +Phase 1.2 | +Docs | +Phase 2 MVP |
|---|---:|---:|---:|---:|---:|
| BE LOC | 0 | ~400 | ~1500 | — | ~1900 |
| DB tables | 0 | 7 | 12 | — | 14 |
| API endpoints | 0 | 4 | ~20 | — | ~23 |
| Migrations | 0 | 1 | 3 | — | 4 |
| FE pages | 0 | 2 | 6 | — | 7 |
| Docs files | 10 | 13 | 14 | 21 | 24 |
| Commits | 1 | 2 | 3 | — | 5 (sắp) |
## 🚨 Blockers / risks ## 🚨 Blockers / risks
-**Gitea remote** URL chờ user cấp -**Gitea remote URL** — user sẽ cấp sau
- ⚠️ **fe-user** chưa được update với menu động — Phase 2 sẽ sync - ⚠️ **3 file .doc** chưa convert (IsActive=false) — retry Word COM với timeout/`DisplayAlerts=0` hoặc LibreOffice
- ⚠️ **Users CRUD** chưa có UI → khó test permission với non-admin role thật - ⚠️ **fe-user** chưa đồng bộ menu động (chỉ fe-admin đã chuyển) — quick fix lúc Phase 3
- ⚠️ **3 file `.doc`** Phase 2 cần convert COM
## Credentials mặc định ## Credentials + URLs
``` ```
Email: admin@solutionerp.local admin@solutionerp.local / Admin@123456
Password: Admin@123456
``` ```
URLs dev:
- API: http://localhost:5443 — Swagger `/swagger` - API: http://localhost:5443 — Swagger `/swagger`
- Admin FE: http://localhost:8082 - Admin FE: http://localhost:8082
- User FE: http://localhost:8080 - User FE: http://localhost:8080

View File

@ -91,19 +91,34 @@
## Phase 2 — Form Engine (T5-6) ## Phase 2 — Form Engine (T5-6)
- [ ] Khảo sát: OpenXml vs Aspose.Words — chọn 1 (Aspose có license phí; OpenXml free nhưng verbose) ### MVP xong (Phase 2 iteration 1)
- [ ] Convert 3 file `.doc``.docx` (COM automation PowerShell hoặc LibreOffice headless)
- [ ] Parse chi tiết field của 5 template HĐ — mỗi form thành JSON spec - [x] Khảo sát: chọn **OpenXml + ClosedXML** (free, không cần license)
- [ ] `Domain/Entities/ContractTemplate` (Id, FormCode, Name, TemplateFile path, FieldSpec JSON) - [x] `Domain/Forms/ContractTemplate` (Id, FormCode, Name, ContractType, FileName, StoragePath, Format, FieldSpec JSON, IsActive)
- [ ] `Application/Forms/Services/IFormRenderer` — input: template + data dict → output: byte[] (.docx) - [x] `Domain/Forms/ContractClause` skeleton
- [ ] Implement `DocxRenderer` (OpenXml-based replace placeholder) - [x] EF config + Migration `AddForms`
- [ ] Implement `XlsxRenderer` cho FO-002.07 (dùng EPPlus/ClosedXML) - [x] `Application/Forms/Services/IFormRenderer` interface
- [ ] `Api/Controllers/FormsController` — GET /templates, POST /render - [x] `Infrastructure/Forms/DocxRenderer` (OpenXml, handle placeholder split runs)
- [ ] FE user: form builder — chọn template → dynamic form → preview → export - [x] `Infrastructure/Forms/XlsxRenderer` (ClosedXML)
- [ ] FE admin: upload template mới, edit field mapping - [x] `Application/Forms/FormFeatures.cs` — List/Get/Render CQRS
- [x] `Api/Controllers/FormsController` — GET templates, GET single, POST render
- [x] Copy 5 .docx/.xlsx template → `wwwroot/templates/`
- [x] Seed 8 ContractTemplate rows (5 IsActive=true, 3 chờ convert)
- [x] FE admin: `FormsPage` — list + render dialog điền JSON + download
- [x] E2E verified: render FO-002.05 → file .docx 482KB mở được bằng Word
### Iteration 2 (optional — enhance)
- [ ] Convert 3 file `.doc``.docx` (retry Word COM với `DisplayAlerts=0` + timeout, hoặc LibreOffice headless)
- [ ] Parse chi tiết field của 5 template HĐ — mỗi form thành JSON `FieldSpec`
- [ ] Support `{{#loop}}...{{/loop}}` block cho table lặp (hạng mục HĐ giao khoán, PO)
- [ ] FE user: form builder dynamic — render từ fieldSpec thay vì điền JSON tay
- [ ] FE admin: upload template mới qua UI (POST multipart) + edit field mapping
- [ ] Lưu `ContractClause` (FO-002.04) dạng rich text, admin edit qua TipTap/TinyMCE - [ ] Lưu `ContractClause` (FO-002.04) dạng rich text, admin edit qua TipTap/TinyMCE
- [ ] Import/export template (để backup) - [ ] PDF convert via LibreOffice headless (`soffice --headless --convert-to pdf`)
- [ ] Test: 1 HĐ Giao khoán filled → export .docx mở bằng Word y hệt mẫu - [ ] Import/export template (backup/restore)
- [ ] Format helpers: number → `150,000,000 VND`, date → `dd/MM/yyyy`
- [ ] Content preservation test: render → diff layout với template gốc
## Phase 3 — Workflow State Machine (T7-9) ## Phase 3 — Workflow State Machine (T7-9)

View File

@ -0,0 +1,108 @@
# Session 2026-04-21 12:00 — Phase 2 Form Engine MVP
**Dev:** Claude (Opus 4.7)
**Duration:** ~1h
**Base commit:** `54d6c9b`
## Làm được
### Chunk D — Forms domain + packages
- NuGet: `DocumentFormat.OpenXml 3.x` + `ClosedXML 0.105+` (Infrastructure)
- Domain: `Forms/ContractTemplate` (FormCode, Name, ContractType, FileName, StoragePath, Format, FieldSpec JSON, IsActive), `Forms/ContractClause` (Code, Name, Content rich text, Version)
- EF configs: unique index FormCode, query filter IsDeleted
- DbSets + `IApplicationDbContext` update
- Migration `AddForms`
### Chunk E — Renderer + Application + Controller
- `Application/Forms/Services/IFormRenderer` + `RenderResult` record
- `Infrastructure/Forms/DocxRenderer` — OpenXml-based, xử lý placeholder bị split runs (gom text tất cả `<w:t>` trong paragraph → replace → gán lại vào text đầu)
- `Infrastructure/Forms/XlsxRenderer` — ClosedXML-based, replace cell value nếu là text chứa placeholder
- `Infrastructure/Forms/FormRenderer` — router theo format docx/xlsx
- Register `IFormRenderer` as Singleton trong Infrastructure DI
- `Application/Forms/FormFeatures.cs`:
- `ListContractTemplatesQuery` (filter type + onlyActive)
- `GetContractTemplateQuery`
- `RenderTemplateCommand` + Validator + Handler (resolve absolute path qua `IWebHostEnvironmentLocator`)
- `IWebHostEnvironmentLocator` interface trong Application — abstract `IWebHostEnvironment`, impl ở Api layer (`WebHostEnvironmentLocator`)
- `Api/Controllers/FormsController`: GET templates, GET single, POST render (return file)
### Chunk F — Convert + Seed + FE
- `scripts/convert-doc-to-docx.ps1` — Word COM automation. Chạy thử bị stuck (hidden dialog) → killed process. **3 file `.doc` chưa convert** — đánh dấu IsActive=false trong seed.
- Copy 5 file `.docx`/`.xlsx` từ `FORM/``wwwroot/templates/`
- `DbInitializer.SeedContractTemplatesAsync` — seed 8 template, check file exists để set IsActive
- FE: `types/forms.ts` (ContractTemplate + ContractTypeLabel), `pages/forms/FormsPage.tsx` — list + render button + Dialog điền JSON data → download file
- Route `/forms` add vào App.tsx (menu Layout đã có path sẵn)
## E2E verified
```
GET /api/forms/templates?onlyActive=false → 8 templates (5 active, 3 inactive)
POST /api/forms/templates/{fo-002.05-id}/render
body: {"benA_tenCongTy": "Solutions Construction", "giaTri": "150,000,000 VND", "ngayKy": "21/04/2026"}
→ HTTP 200, file .docx 482KB (Microsoft Word 2007+ format) — OK mở được bằng Word
```
TS check fe-admin pass.
## Bug gặp + fix
| Bug | Fix |
|---|---|
| `SpaceProcessingModeValues` namespace không tìm thấy | Dùng full path + wrap `EnumValue<>` |
| Word COM `SaveAs([ref])` type conversion error | Đổi sang `SaveAs2($path, 16)` |
| Word COM stuck (2 process, 164s CPU) | Kill process, fallback: skip .doc convert, đánh dấu IsActive=false cho 3 template tương ứng |
| Edit tool "File has not been read yet" sau system-reminder interrupt | Read lại rồi Write full file |
## Docs updates trong session này
- **`docs/gotchas.md`** (MỚI) — 17 bẫy đã gặp từ Phase 0 → 2, nhóm theo: tech stack constraints, EF Core, OpenXml/ClosedXML, System.Text.Json, file ops, dev workflow
- **`.claude/skills/form-engine/SKILL.md`** — update từ placeholder → full spec với code pointers, algorithm, API, limitations
- **`.claude/skills/permission-matrix/SKILL.md`** — update từ placeholder → full spec với BE policy + FE guard usage + pitfalls
- **`docs/STATUS.md`** — mark Phase 2 MVP done
- **`docs/changelog/migration-todos.md`** — tick Phase 2 items đã xong
## Handoff cho session tiếp theo
### Phase 2 còn lại (iteration 2)
- [ ] Convert 3 file `.doc` (retry Word COM với `DisplayAlerts=0` + set timeout) HOẶC dùng LibreOffice headless
- [ ] Field spec JSON mỗi template — cho phép FE render dynamic form thay vì điền JSON tay
- [ ] Form builder FE: dynamic render từ fieldSpec → validation → preview → submit
- [ ] Support `{{#loop}}...{{/loop}}` block (cho table hạng mục lặp ở FO-002.05, FO-002.07)
- [ ] PDF convert via LibreOffice headless (hoặc Aspose nếu mua license)
- [ ] Admin upload template mới qua UI (POST multipart)
- [ ] ContractClause rich text editor (TipTap) cho admin edit FO-002.04
### Phase 3 — Workflow (sắp tới)
Xem [`docs/flows/contract-approval-flow.md`](../../flows/contract-approval-flow.md).
Các việc lớn:
- Entity `Contract` + `ContractApproval` + `ContractComment` + `ContractAttachment`
- `IContractWorkflowService.TransitionAsync()` với state guard + role guard
- `IContractCodeGenerator` theo RG-001 với transaction SERIALIZABLE
- `SlaExpiryJob` hosted service auto-approve
- Email + in-app notification service
- FE Inbox + Contract detail + timeline UI
### Còn optional (không block Phase 3)
- Users management FE (tạo user + gán role)
- Roles CRUD
- fe-user menu động (hiện tại chưa sync với AuthContext menu pattern từ fe-admin)
### Blocker
-**Gitea remote** URL
## Thông số sau Phase 2 MVP
- **Git commits:** 4 (từ scaffold) + sắp thêm 1
- **Backend LOC:** ~1900 (thêm ~400 cho Forms)
- **DB tables:** 14 (thêm ContractTemplates + ContractClauses)
- **API endpoints:** ~23 (thêm Forms 3)
- **FE pages:** 6 (thêm Forms)
- **Templates vật lý:** 5 .docx/.xlsx trong `wwwroot/templates/`

187
docs/gotchas.md Normal file
View File

@ -0,0 +1,187 @@
# Gotchas — SOLUTION_ERP
> Các bẫy, pitfall đã gặp + cách xử lý. Đọc trước khi debug tương tự để không mất thời gian.
## Tech stack constraints (.NET 10 + TS 6 + Vite 8)
### 1. MediatR 14.x không tương thích → pin 12.4.1
**Triệu chứng:** `Unable to resolve service for type 'MediatR.IMediator'``AddMediatR` vẫn chạy nhưng không register IMediator.
**Nguyên nhân:** MediatR v14 (late 2025) refactored, extension methods khác.
**Fix:** Downgrade `<PackageReference Include="MediatR" Version="12.4.1" />`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken param).
### 2. Swashbuckle 10.x + Microsoft.OpenApi 2.x breaking change
**Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger endpoint 404.
**Nguyên nhân:** `.NET 10` template auto-cài `Microsoft.AspNetCore.OpenApi 10` → pull `Microsoft.OpenApi 2.0` → namespace `Microsoft.OpenApi.Models` đã bị remove.
**Fix:**
- Remove `Microsoft.AspNetCore.OpenApi` khỏi Api
- Downgrade Swashbuckle về `6.9.0` (compatible với OpenApi 1.x)
### 3. TypeScript 6 `erasableSyntaxOnly` cấm `enum`
**Triệu chứng:** `TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.` khi dùng `enum`.
**Nguyên nhân:** Vite 8 scaffold bật `erasableSyntaxOnly: true` — enum sinh runtime code nên bị cấm.
**Fix:** Dùng `const + as const + typeof[keyof]` pattern:
```ts
// ❌ Không được
export enum SupplierType { NhaCungCap = 1 }
// ✅ OK
export const SupplierType = { NhaCungCap: 1, NhaThauPhu: 2 } as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
```
### 4. TypeScript 6 deprecate `baseUrl`
**Triệu chứng:** `TS5101: Option 'baseUrl' is deprecated`.
**Fix:** Bỏ `baseUrl` trong `tsconfig.app.json`, chỉ giữ `paths`. Paths resolve relative to tsconfig location.
### 5. Node 22 local nhưng CI phải pin 20
**Bài học NamGroup:** CI build fail trên Node latest, phải downgrade. Dev local dùng Node 22 thoải mái.
**Fix:**
- `package.json` engines: `">=20"` (min only, không upper bound)
- `.nvmrc` = `20` (CI dùng)
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version-file: '.nvmrc'` hoặc hardcode `'20.x'`
## EF Core 10
### 6. Expression tree không support switch expression
**Triệu chứng:** `CS8514: An expression tree may not contain a switch expression` khi viết `.AnyAsync(p => action switch { ... })`.
**Fix:** Tách switch ra ngoài, mỗi case gọi query riêng:
```csharp
// ❌
var hasPermission = await query.AnyAsync(p => action switch { "Read" => p.CanRead, ... });
// ✅
var hasPermission = action switch
{
"Read" => await query.AnyAsync(p => p.CanRead),
"Create" => await query.AnyAsync(p => p.CanCreate),
...
};
```
### 7. Design-time DbContext resolve fail
**Triệu chứng:** `dotnet ef migrations add``Unable to resolve service for type 'DbContextOptions<ApplicationDbContext>'`.
**Fix:** Tạo `IDesignTimeDbContextFactory<ApplicationDbContext>` trong Infrastructure — EF CLI sẽ dùng factory này thay vì chạy full Host.
### 8. `AddDefaultTokenProviders()` không tồn tại trong `AddIdentityCore`
**Triệu chứng:** Build fail `IdentityBuilder does not contain AddDefaultTokenProviders`.
**Nguyên nhân:** `AddIdentityCore` là minimal variant, không include token providers (password reset, email confirmation).
**Fix:** Bỏ call `AddDefaultTokenProviders()` nếu chưa cần. Khi cần password reset (Phase 4), chuyển sang `AddIdentity` hoặc add package `Microsoft.AspNetCore.Identity.UI`.
## OpenXml / ClosedXML (Form Engine Phase 2)
### 9. `SpaceProcessingModeValues.Preserve` namespace không tìm thấy
**Triệu chứng:** `CS0103: The name 'SpaceProcessingModeValues' does not exist`.
**Fix:** Dùng full path `DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve` và wrap trong `EnumValue<>`:
```csharp
textElement.Space = new DocumentFormat.OpenXml.EnumValue<DocumentFormat.OpenXml.SpaceProcessingModeValues>(
DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
```
### 10. Placeholder `{{field}}` bị split giữa 2 `<w:t>` elements
**Vấn đề:** Word hay split text thành nhiều run (style, typo check), placeholder `{{giaTri}}` có thể bị chia thành `{{gia` + `Tri}}` nằm trong 2 `<w:t>` khác nhau → regex replace miss.
**Fix (đã implement trong DocxRenderer):** Iterate theo 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. Giữ run style của text đầu.
### 11. Word COM SaveAs PowerShell type conversion error
**Triệu chứng:** `Cannot convert "..." value of type "psobject" to type "Object"` khi gọi `$doc.SaveAs([ref]$outPath, [ref]16)`.
**Fix:** Dùng `SaveAs2` (không đòi ref parameters):
```powershell
$doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault (.docx)
```
### 12. Word COM stuck/hang
**Triệu chứng:** Script chạy không xong, process `WINWORD.EXE` còn nhưng CPU idle hoặc high.
**Nguyên nhân:** Hidden dialog (activation, recovery, template warning) block COM.
**Fix:**
- Set `$word.DisplayAlerts = 0` trước khi mở file
- Nếu stuck → `Get-Process WINWORD | Stop-Process -Force`
- Fallback: dùng LibreOffice headless `soffice --headless --convert-to docx file.doc`
## System.Text.Json (ASP.NET Core 10)
### 13. Record constructor deserialization fail với Unicode
**Triệu chứng:** POST JSON chứa ký tự tiếng Việt từ Windows bash/curl CLI → 400 "JSON value could not be converted to ... CreateSupplierCommand. Path: $.name".
**Nguyên nhân:** Encoding CLI không đúng UTF-8 khi pass vào `curl -d '{...}'`.
**Fix:**
- Test qua file: `curl --data-binary @payload.json` (file lưu UTF-8 thật)
- Không phải bug backend — API handle UTF-8 đúng qua axios/Swagger
## File operations
### 14. Dropbox sync có thể "revert" file đang edit
**Triệu chứng:** Write file thành công, build pass, nhưng file thực tế vẫn là nội dung cũ.
**Case cụ thể (Phase 1):** Program.cs Write thành công nhưng runtime chạy với default scaffold code.
**Fix:** Sau Write file quan trọng → Read lại hoặc `head -5` để xác nhận nội dung. Nếu phát hiện revert → Write lại ngay.
### 15. `.gitignore` wwwroot/uploads/ vs wwwroot/templates/
**Quy ước:**
- `wwwroot/uploads/`**ignore** (user-uploaded files, không commit)
- `wwwroot/templates/`**commit** (template files là source of truth, phải version control)
- `wwwroot/exports/` → ignore (rendered output, tạm)
## Dev workflow
### 16. Port conflict khi restart dev server
**Triệu chứng:** `npm run dev` fail với `Port 8082 is in use`.
**Nguyên nhân:** Background task trước chưa kill hẳn.
**Fix:** `TaskStop` task cũ, hoặc kill process listening port: `netstat -ano | findstr :8082``taskkill /F /PID <pid>`.
### 17. EF migration tạo 3 file, COMMIT ĐỦ
**Quy tắc:** Mỗi migration tạo:
- `{timestamp}_{Name}.cs` — up/down
- `{timestamp}_{Name}.Designer.cs` — model snapshot lúc đó
- `ApplicationDbContextModelSnapshot.cs` — current snapshot (update mỗi lần)
Commit đủ 3 file. Nếu thiếu, team khác `dotnet ef database update` sẽ fail.
## Checklist khi gặp bug mới
1. Build có pass không? Nếu fail → check using + package version
2. Log API startup có error ẩn không? → `tail` output file
3. File đã persist đúng chưa? → `head -5` verify
4. Nếu là package compat → thử downgrade về stable (không dùng preview/latest)
5. Nếu là TS error exotic → check tsconfig flags (`erasableSyntaxOnly`, `verbatimModuleSyntax`)
6. Nếu là EF expression tree error → tách logic ra ngoài query

View File

@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage' import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage' import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage' import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
function App() { function App() {
return ( return (
@ -28,6 +29,7 @@ function App() {
<Route path="/master/projects" element={<ProjectsPage />} /> <Route path="/master/projects" element={<ProjectsPage />} />
<Route path="/master/departments" element={<DepartmentsPage />} /> <Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} /> <Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route <Route
path="*" path="*"

View File

@ -0,0 +1,142 @@
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Download, FileSpreadsheet, FileText, CheckCircle2, XCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
export function FormsPage() {
const [dialog, setDialog] = useState<ContractTemplate | null>(null)
const [dataJson, setDataJson] = useState<string>(`{
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
"giaTri": "150,000,000 VND",
"ngayKy": "21/04/2026"
}`)
const list = useQuery({
queryKey: ['contract-templates'],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { onlyActive: false } })).data,
})
const render = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' })
return { blob: res.data as Blob, filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx' }
},
onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success('Đã tải file render')
setDialog(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
function handleRender() {
if (!dialog) return
let data: Record<string, string | null>
try {
data = JSON.parse(dataJson)
} catch {
toast.error('JSON không hợp lệ')
return
}
render.mutate({ id: dialog.id, data })
}
const columns: Column<ContractTemplate>[] = [
{
key: 'format',
header: '',
width: 'w-12',
align: 'center',
render: t =>
t.format === 'xlsx' ? <FileSpreadsheet className="mx-auto h-4 w-4 text-emerald-600" /> : <FileText className="mx-auto h-4 w-4 text-blue-600" />,
},
{ key: 'formCode', header: 'Form Code', render: t => <span className="font-mono text-xs">{t.formCode}</span>, width: 'w-40' },
{ key: 'name', header: 'Tên', render: t => t.name },
{ key: 'contractType', header: 'Loại HĐ', render: t => (t.contractType ? ContractTypeLabel[t.contractType] : '—'), width: 'w-40' },
{
key: 'isActive',
header: 'Trạng thái',
width: 'w-28',
align: 'center',
render: t =>
t.isActive ? (
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" />
) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600">
<XCircle className="h-3.5 w-3.5" />
Chưa active
</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
width: 'w-32',
render: t => (
<Button size="sm" variant="outline" disabled={!t.isActive} onClick={() => setDialog(t)}>
<Download className="h-3.5 w-3.5" />
Render
</Button>
),
},
]
return (
<div className="p-6">
<PageHeader
title="Biểu mẫu hợp đồng"
description="Danh sách template HĐ. Click 'Render' để test điền field {{key}} và tải file .docx/.xlsx."
/>
<DataTable columns={columns} rows={list.data ?? []} getRowKey={t => t.id} isLoading={list.isLoading} />
<Dialog
open={!!dialog}
onClose={() => setDialog(null)}
title={dialog ? `Render: ${dialog.name}` : ''}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setDialog(null)}>
Hủy
</Button>
<Button onClick={handleRender} disabled={render.isPending}>
{render.isPending ? 'Đang render…' : 'Render & tải xuống'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng <code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON
dưới đây, backend sẽ replace placeholder trong file gốc.
</div>
<div className="space-y-1.5">
<Label>Form Code</Label>
<Input value={dialog?.formCode ?? ''} disabled />
</div>
<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" />
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,21 @@
export type ContractTemplate = {
id: string
formCode: string
name: string
contractType: number | null
fileName: string
format: 'docx' | 'xlsx'
fieldSpec: string | null
description: string | null
isActive: boolean
}
export const ContractTypeLabel: Record<number, string> = {
1: 'HĐ Thầu phụ',
2: 'HĐ Giao khoán',
3: 'HĐ Nhà cung cấp',
4: 'HĐ Dịch vụ',
5: 'HĐ Mua bán',
6: 'HĐ Nguyên tắc NCC',
7: 'HĐ Nguyên tắc Dịch vụ',
}

View File

@ -0,0 +1,44 @@
# Convert tất cả .doc trong FORM/ sang .docx qua Word COM automation.
# Yêu cầu: Microsoft Word đã cài trên máy.
# Usage: .\scripts\convert-doc-to-docx.ps1
$ErrorActionPreference = 'Stop'
$src = 'D:\Dropbox\CONG_VIEC\SOLUTION\FORM'
$out = Join-Path $PSScriptRoot '..\src\Backend\SolutionErp.Api\wwwroot\templates'
if (-not (Test-Path $out)) { New-Item -ItemType Directory -Force -Path $out | Out-Null }
$docs = Get-ChildItem -Path $src -Filter '*.doc' -File
if ($docs.Count -eq 0) {
Write-Host "Khong co file .doc can convert."
exit 0
}
Write-Host "Opening Word COM..."
$word = $null
try {
$word = New-Object -ComObject Word.Application
} catch {
Write-Error "Microsoft Word chua cai. Cai Word hoac dung LibreOffice fallback."
exit 1
}
$word.Visible = $false
$word.DisplayAlerts = 0
foreach ($f in $docs) {
$inPath = $f.FullName
$outPath = Join-Path $out ($f.BaseName + '.docx')
Write-Host "Convert: $($f.Name) -> $(Split-Path $outPath -Leaf)"
$doc = $word.Documents.Open($inPath, $false, $true)
# 16 = wdFormatDocumentDefault (.docx)
$doc.SaveAs2($outPath, 16)
$doc.Close($false)
}
$word.Quit()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($word) | Out-Null
[System.GC]::Collect()
Write-Host "Done. Output: $out"

View File

@ -0,0 +1,31 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Forms;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/forms")]
[Authorize]
public class FormsController(IMediator mediator) : ControllerBase
{
[HttpGet("templates")]
public async Task<ActionResult<List<ContractTemplateDto>>> List(
[FromQuery] ContractType? type = null,
[FromQuery] bool onlyActive = true,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListContractTemplatesQuery(type, onlyActive), ct));
[HttpGet("templates/{id:guid}")]
public async Task<ActionResult<ContractTemplateDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetContractTemplateQuery(id), ct));
[HttpPost("templates/{id:guid}/render")]
public async Task<IActionResult> Render(Guid id, [FromBody] Dictionary<string, string?> data, CancellationToken ct)
{
var result = await mediator.Send(new RenderTemplateCommand(id, data), ct);
return File(result.Content, result.ContentType, result.FileName);
}
}

View File

@ -26,6 +26,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUserService>(); builder.Services.AddScoped<ICurrentUser, CurrentUserService>();
builder.Services.AddSingleton<SolutionErp.Application.Forms.IWebHostEnvironmentLocator, SolutionErp.Api.Services.WebHostEnvironmentLocator>();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);

View File

@ -0,0 +1,9 @@
using SolutionErp.Application.Forms;
namespace SolutionErp.Api.Services;
public class WebHostEnvironmentLocator(IWebHostEnvironment env) : IWebHostEnvironmentLocator
{
public string WebRootPath => env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot");
public string ContentRootPath => env.ContentRootPath;
}

View File

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master; using SolutionErp.Domain.Master;
@ -11,6 +12,8 @@ public interface IApplicationDbContext
DbSet<Department> Departments { get; } DbSet<Department> Departments { get; }
DbSet<MenuItem> MenuItems { get; } DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; } DbSet<Permission> Permissions { get; }
DbSet<ContractTemplate> ContractTemplates { get; }
DbSet<ContractClause> ContractClauses { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }

View File

@ -0,0 +1,96 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Forms;
public record ContractTemplateDto(
Guid Id,
string FormCode,
string Name,
ContractType? ContractType,
string FileName,
string Format,
string? FieldSpec,
string? Description,
bool IsActive);
// ========== List templates ==========
public record ListContractTemplatesQuery(ContractType? ContractType = null, bool OnlyActive = true)
: IRequest<List<ContractTemplateDto>>;
public class ListContractTemplatesQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListContractTemplatesQuery, List<ContractTemplateDto>>
{
public async Task<List<ContractTemplateDto>> Handle(ListContractTemplatesQuery request, CancellationToken ct)
{
var query = db.ContractTemplates.AsNoTracking();
if (request.OnlyActive) query = query.Where(x => x.IsActive);
if (request.ContractType is not null) query = query.Where(x => x.ContractType == request.ContractType);
return await query
.OrderBy(x => x.FormCode)
.Select(x => new ContractTemplateDto(
x.Id, x.FormCode, x.Name, x.ContractType, x.FileName, x.Format, x.FieldSpec, x.Description, x.IsActive))
.ToListAsync(ct);
}
}
// ========== Get one ==========
public record GetContractTemplateQuery(Guid Id) : IRequest<ContractTemplateDto>;
public class GetContractTemplateQueryHandler(IApplicationDbContext db)
: IRequestHandler<GetContractTemplateQuery, ContractTemplateDto>
{
public async Task<ContractTemplateDto> Handle(GetContractTemplateQuery request, CancellationToken ct)
{
var x = await db.ContractTemplates.AsNoTracking().FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException("ContractTemplate", request.Id);
return new ContractTemplateDto(
x.Id, x.FormCode, x.Name, x.ContractType, x.FileName, x.Format, x.FieldSpec, x.Description, x.IsActive);
}
}
// ========== Render ==========
public record RenderTemplateCommand(Guid TemplateId, Dictionary<string, string?> Data)
: IRequest<RenderResult>;
public class RenderTemplateCommandValidator : AbstractValidator<RenderTemplateCommand>
{
public RenderTemplateCommandValidator()
{
RuleFor(x => x.TemplateId).NotEmpty();
RuleFor(x => x.Data).NotNull();
}
}
public class RenderTemplateCommandHandler(
IApplicationDbContext db,
IFormRenderer renderer,
IWebHostEnvironmentLocator envLocator) : IRequestHandler<RenderTemplateCommand, RenderResult>
{
public async Task<RenderResult> Handle(RenderTemplateCommand request, CancellationToken ct)
{
var tpl = await db.ContractTemplates.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == request.TemplateId, ct)
?? throw new NotFoundException("ContractTemplate", request.TemplateId);
var absPath = Path.Combine(envLocator.WebRootPath, tpl.StoragePath);
if (!File.Exists(absPath))
throw new NotFoundException($"File template không tồn tại: {tpl.StoragePath}");
var outName = $"{tpl.FormCode}_preview.{tpl.Format}";
return await renderer.RenderAsync(absPath, tpl.Format, request.Data, outName, ct);
}
}
// Abstraction nhỏ cho webRootPath — để Application không phụ thuộc IWebHostEnvironment
public interface IWebHostEnvironmentLocator
{
string WebRootPath { get; }
string ContentRootPath { get; }
}

View File

@ -0,0 +1,15 @@
namespace SolutionErp.Application.Forms.Services;
public record RenderResult(byte[] Content, string FileName, string ContentType);
public interface IFormRenderer
{
// Render template file (.docx hoặc .xlsx) với placeholder {{field}} replace bằng data.
// data values: string (kể cả number/date đã format). Null → replace bằng rỗng.
Task<RenderResult> RenderAsync(
string templateAbsolutePath,
string format, // "docx" | "xlsx"
IReadOnlyDictionary<string, string?> data,
string outputFileName,
CancellationToken ct = default);
}

View File

@ -0,0 +1,13 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Forms;
// Điều khoản chung (vd FO-002.04) — đính kèm appendix cho HĐ trọn gói. Admin edit qua rich text.
public class ContractClause : AuditableEntity
{
public string Code { get; set; } = string.Empty; // vd "FO-002.04"
public string Name { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty; // rich text (HTML/Markdown)
public int Version { get; set; } = 1;
public bool IsActive { get; set; } = true;
}

View File

@ -0,0 +1,18 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Domain.Forms;
// Mapping 1 mẫu HĐ (file .docx hoặc .xlsx trong wwwroot/templates) → metadata + field spec JSON
public class ContractTemplate : AuditableEntity
{
public string FormCode { get; set; } = string.Empty; // vd "SOL-CCM-FO-002.05"
public string Name { get; set; } = string.Empty; // vd "Hợp đồng Giao khoán"
public ContractType? ContractType { get; set; } // null nếu đây là phụ lục (điều kiện chung)
public string FileName { get; set; } = string.Empty; // tên file gốc (để preserve extension)
public string StoragePath { get; set; } = string.Empty; // relative path dưới wwwroot/templates/
public string Format { get; set; } = "docx"; // "docx" | "xlsx"
public string? FieldSpec { get; set; } // JSON field spec — null nếu chưa định nghĩa
public bool IsActive { get; set; } = true;
public string? Description { get; set; }
}

View File

@ -3,7 +3,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
using SolutionErp.Infrastructure.Identity; using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence; using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors; using SolutionErp.Infrastructure.Persistence.Interceptors;
@ -20,6 +22,8 @@ public static class DependencyInjection
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName)); services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddScoped<IJwtTokenService, JwtTokenService>(); services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddScoped<AuditingInterceptor>(); services.AddScoped<AuditingInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) => 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.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master; using SolutionErp.Domain.Master;
@ -16,6 +17,8 @@ public class ApplicationDbContext
public DbSet<Department> Departments => Set<Department>(); public DbSet<Department> Departments => Set<Department>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>(); public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<Permission> Permissions => Set<Permission>(); public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();
public DbSet<ContractClause> ContractClauses => Set<ContractClause>();
protected override void OnModelCreating(ModelBuilder builder) 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.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Persistence; namespace SolutionErp.Infrastructure.Persistence;
@ -27,6 +29,7 @@ public static class DbInitializer
await SeedAdminAsync(userManager, logger); await SeedAdminAsync(userManager, logger);
await SeedMenuTreeAsync(db, logger); await SeedMenuTreeAsync(db, logger);
await SeedAdminPermissionsAsync(db, roleManager, logger); await SeedAdminPermissionsAsync(db, roleManager, logger);
await SeedContractTemplatesAsync(db, logger);
} }
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger 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) 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)[] var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
{ {
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"), (MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
@ -128,4 +130,67 @@ public static class DbInitializer
logger.LogInformation("Seeded {Count} admin permissions", added); 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); 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 => modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")

View File

@ -5,6 +5,8 @@
</ItemGroup> </ItemGroup>
<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.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6">