[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:
@ -1,34 +1,130 @@
|
||||
---
|
||||
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:
|
||||
- "export contract to word"
|
||||
- "render template docx"
|
||||
- "xuất đơn đặt hàng excel"
|
||||
- "gen mã hợp đồng"
|
||||
- "placeholder không replace"
|
||||
- "upload template mới"
|
||||
- "gen mã hợp đồng"
|
||||
---
|
||||
|
||||
# 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):
|
||||
- 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ể
|
||||
- 3 file `.doc` cần convert qua Word COM / LibreOffice headless trước khi parse
|
||||
| Layer | Tech | Purpose |
|
||||
|---|---|---|
|
||||
| `.docx` render | **DocumentFormat.OpenXml 3.x** | Free, maintained by MS |
|
||||
| `.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ễ)
|
||||
- **.xlsx render:** EPPlus (free non-commercial) hoặc ClosedXML (free)
|
||||
- **PDF preview:** wkhtmltopdf hoặc LibreOffice `--convert-to pdf`
|
||||
Template (.docx hoặc .xlsx) chứa `{{fieldName}}` — regex `\{\{([a-zA-Z0-9_\.]+)\}\}`:
|
||||
|
||||
## 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`
|
||||
- `src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs`
|
||||
- `src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs`
|
||||
- `src/Backend/SolutionErp.Infrastructure/Services/ContractCodeGenerator.cs`
|
||||
**Data dictionary:**
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
@ -1,41 +1,143 @@
|
||||
---
|
||||
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:
|
||||
- "permission denied"
|
||||
- "access denied"
|
||||
- "menu không hiện"
|
||||
- "gán role cho user"
|
||||
- "reset password"
|
||||
- "seed permission"
|
||||
- "permission matrix edit"
|
||||
---
|
||||
|
||||
# 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
|
||||
- 1 Role có ma trận (MenuKey, CRUD flags) — `Permission` table
|
||||
- Không có per-user override (giữ đơn giản cho Phase 1)
|
||||
- Menu tree flat 2 cấp, hardcode `MenuKey`
|
||||
```
|
||||
User ────< UserRoles ────< Role ────< Permissions ────< MenuItem
|
||||
(RoleId, MenuKey, CRUD flags)
|
||||
```
|
||||
|
||||
## 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
|
||||
- FE: `<PermissionGuard menuKey="Contracts" action="Update">` + `usePermission().can("Contracts", "Update")`
|
||||
- Resolution: API `/api/menus/me` trả về tree + permissions đã resolved theo user's roles
|
||||
## Menu tree (seed)
|
||||
|
||||
## 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`
|
||||
- `fe-admin/src/components/PermissionGuard.tsx`
|
||||
- `fe-admin/src/hooks/usePermission.ts`
|
||||
```
|
||||
Dashboard
|
||||
Master
|
||||
├── 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
|
||||
- MenuKey hardcode dễ typo → tập trung vào file `src/lib/menuKeys.ts` (FE) + `MenuKeys.cs` (BE const)
|
||||
## Code pointers
|
||||
|
||||
**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)
|
||||
|
||||
@ -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/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/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
|
||||
|
||||
|
||||
155
docs/HANDOFF.md
Normal file
155
docs/HANDOFF.md
Normal 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
|
||||
@ -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`.
|
||||
|
||||
**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
|
||||
|
||||
_(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)
|
||||
|
||||
| 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 | **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 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
|
||||
| 2026-04-21 | Claude | **Phase 1 foundation** — BE Clean Arch + Identity + JWT + FE 2 app + login E2E | `702411f` |
|
||||
| 2026-04-21 | Claude | **Phase 0** — scaffold + parse FORM/QUY_TRINH + docs + git init | `25dad7f` |
|
||||
|
||||
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-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-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
|
||||
- [ ] 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%
|
||||
## 🎯 Next up
|
||||
|
||||
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
|
||||
- [ ] 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
|
||||
### Phase 3 — Workflow (sắp tới, item lớn)
|
||||
|
||||
## 📊 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)
|
||||
- **Migrations:** Init + AddMasterData + AddPermissions
|
||||
- **DB tables:** 7 Identity + 3 Master (Suppliers/Projects/Departments) + 2 Permission (MenuItems/Permissions)
|
||||
- **API endpoints:** 20+ (Auth 4 + Suppliers 5 + Projects 5 + Departments 5 + Menus 2 + Roles 1 + Permissions 2)
|
||||
- **Frontend routes:** 5 (Dashboard + 3 CRUD + Permission Matrix)
|
||||
- **FE LOC:** ~1700 (fe-admin; fe-user vẫn minimal)
|
||||
Summary:
|
||||
- [ ] Entity: Contract + ContractApproval + ContractComment + ContractAttachment
|
||||
- [ ] `IContractWorkflowService` với state guard + role guard
|
||||
- [ ] `IContractCodeGenerator` RG-001 + transaction SERIALIZABLE
|
||||
- [ ] `SlaExpiryJob` BackgroundService
|
||||
- [ ] 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
|
||||
|
||||
- ⏳ **Gitea remote** — URL chờ user cấp
|
||||
- ⚠️ **fe-user** chưa được update với menu động — Phase 2 sẽ sync
|
||||
- ⚠️ **Users CRUD** chưa có UI → khó test permission với non-admin role thật
|
||||
- ⚠️ **3 file `.doc`** Phase 2 cần convert COM
|
||||
- ⏳ **Gitea remote URL** — user sẽ cấp sau
|
||||
- ⚠️ **3 file .doc** chưa convert (IsActive=false) — retry Word COM với timeout/`DisplayAlerts=0` hoặc LibreOffice
|
||||
- ⚠️ **fe-user** chưa đồng bộ menu động (chỉ fe-admin đã chuyển) — quick fix lúc Phase 3
|
||||
|
||||
## Credentials mặc định
|
||||
## Credentials + URLs
|
||||
|
||||
```
|
||||
Email: admin@solutionerp.local
|
||||
Password: Admin@123456
|
||||
admin@solutionerp.local / Admin@123456
|
||||
```
|
||||
|
||||
URLs dev:
|
||||
- API: http://localhost:5443 — Swagger `/swagger`
|
||||
- Admin FE: http://localhost:8082
|
||||
- User FE: http://localhost:8080
|
||||
|
||||
@ -91,19 +91,34 @@
|
||||
|
||||
## 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)
|
||||
- [ ] 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
|
||||
- [ ] `Domain/Entities/ContractTemplate` (Id, FormCode, Name, TemplateFile path, FieldSpec JSON)
|
||||
- [ ] `Application/Forms/Services/IFormRenderer` — input: template + data dict → output: byte[] (.docx)
|
||||
- [ ] Implement `DocxRenderer` (OpenXml-based replace placeholder)
|
||||
- [ ] Implement `XlsxRenderer` cho FO-002.07 (dùng EPPlus/ClosedXML)
|
||||
- [ ] `Api/Controllers/FormsController` — GET /templates, POST /render
|
||||
- [ ] FE user: form builder — chọn template → dynamic form → preview → export
|
||||
- [ ] FE admin: upload template mới, edit field mapping
|
||||
### MVP xong (Phase 2 iteration 1)
|
||||
|
||||
- [x] Khảo sát: chọn **OpenXml + ClosedXML** (free, không cần license)
|
||||
- [x] `Domain/Forms/ContractTemplate` (Id, FormCode, Name, ContractType, FileName, StoragePath, Format, FieldSpec JSON, IsActive)
|
||||
- [x] `Domain/Forms/ContractClause` skeleton
|
||||
- [x] EF config + Migration `AddForms`
|
||||
- [x] `Application/Forms/Services/IFormRenderer` interface
|
||||
- [x] `Infrastructure/Forms/DocxRenderer` (OpenXml, handle placeholder split runs)
|
||||
- [x] `Infrastructure/Forms/XlsxRenderer` (ClosedXML)
|
||||
- [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
|
||||
- [ ] Import/export template (để backup)
|
||||
- [ ] Test: 1 HĐ Giao khoán filled → export .docx mở bằng Word y hệt mẫu
|
||||
- [ ] PDF convert via LibreOffice headless (`soffice --headless --convert-to pdf`)
|
||||
- [ ] 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)
|
||||
|
||||
|
||||
108
docs/changelog/sessions/2026-04-21-1200-phase2-form-engine.md
Normal file
108
docs/changelog/sessions/2026-04-21-1200-phase2-form-engine.md
Normal 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
187
docs/gotchas.md
Normal 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
|
||||
@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
|
||||
import { ProjectsPage } from '@/pages/master/ProjectsPage'
|
||||
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
|
||||
import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -28,6 +29,7 @@ function App() {
|
||||
<Route path="/master/projects" element={<ProjectsPage />} />
|
||||
<Route path="/master/departments" element={<DepartmentsPage />} />
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
142
fe-admin/src/pages/forms/FormsPage.tsx
Normal file
142
fe-admin/src/pages/forms/FormsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
fe-admin/src/types/forms.ts
Normal file
21
fe-admin/src/types/forms.ts
Normal 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ụ',
|
||||
}
|
||||
44
scripts/convert-doc-to-docx.ps1
Normal file
44
scripts/convert-doc-to-docx.ps1
Normal 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"
|
||||
31
src/Backend/SolutionErp.Api/Controllers/FormsController.cs
Normal file
31
src/Backend/SolutionErp.Api/Controllers/FormsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUserService>();
|
||||
builder.Services.AddSingleton<SolutionErp.Application.Forms.IWebHostEnvironmentLocator, SolutionErp.Api.Services.WebHostEnvironmentLocator>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
|
||||
@ -11,6 +12,8 @@ public interface IApplicationDbContext
|
||||
DbSet<Department> Departments { get; }
|
||||
DbSet<MenuItem> MenuItems { get; }
|
||||
DbSet<Permission> Permissions { get; }
|
||||
DbSet<ContractTemplate> ContractTemplates { get; }
|
||||
DbSet<ContractClause> ContractClauses { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
96
src/Backend/SolutionErp.Application/Forms/FormFeatures.cs
Normal file
96
src/Backend/SolutionErp.Application/Forms/FormFeatures.cs
Normal 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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
13
src/Backend/SolutionErp.Domain/Forms/ContractClause.cs
Normal file
13
src/Backend/SolutionErp.Domain/Forms/ContractClause.cs
Normal 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;
|
||||
}
|
||||
18
src/Backend/SolutionErp.Domain/Forms/ContractTemplate.cs
Normal file
18
src/Backend/SolutionErp.Domain/Forms/ContractTemplate.cs
Normal 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; }
|
||||
}
|
||||
@ -3,7 +3,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Forms;
|
||||
using SolutionErp.Infrastructure.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
@ -20,6 +22,8 @@ public static class DependencyInjection
|
||||
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
|
||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||
|
||||
services.AddScoped<AuditingInterceptor>();
|
||||
|
||||
services.AddDbContext<ApplicationDbContext>((sp, options) =>
|
||||
|
||||
77
src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs
Normal file
77
src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
// Placeholder replace syntax: {{fieldName}}
|
||||
// Hạn chế Phase 2 MVP:
|
||||
// - Không support {{#loop}}...{{/loop}} (table lặp) — Phase 2 iteration 2
|
||||
// - Không split placeholder giữa 2 <w:t> (Word hay làm) — merge runs trước khi replace
|
||||
public class DocxRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderRegex = new(@"\{\{([a-zA-Z0-9_\.]+)\}\}", RegexOptions.Compiled);
|
||||
|
||||
public async Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
var bytes = File.ReadAllBytes(templateAbsolutePath);
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write(bytes, 0, bytes.Length);
|
||||
ms.Position = 0;
|
||||
|
||||
using (var doc = WordprocessingDocument.Open(ms, isEditable: true))
|
||||
{
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
if (body is null) throw new InvalidOperationException("Template .docx không có Body");
|
||||
|
||||
// Xử lý cả main document + headers + footers
|
||||
ReplaceInElement(body, data);
|
||||
foreach (var hp in doc.MainDocumentPart!.HeaderParts)
|
||||
if (hp.Header is not null) ReplaceInElement(hp.Header, data);
|
||||
foreach (var fp in doc.MainDocumentPart.FooterParts)
|
||||
if (fp.Footer is not null) ReplaceInElement(fp.Footer, data);
|
||||
|
||||
doc.MainDocumentPart.Document.Save();
|
||||
}
|
||||
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
outputFileName,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
}
|
||||
|
||||
private static void ReplaceInElement(DocumentFormat.OpenXml.OpenXmlElement root, IReadOnlyDictionary<string, string?> data)
|
||||
{
|
||||
// Iterate từng paragraph: gom text của mọi <w:t> trong cùng paragraph → replace → gán lại vào <w:t> đầu + clear rest
|
||||
foreach (var para in root.Descendants<Paragraph>().ToList())
|
||||
{
|
||||
var textElements = para.Descendants<Text>().ToList();
|
||||
if (textElements.Count == 0) continue;
|
||||
|
||||
var combined = string.Concat(textElements.Select(t => t.Text));
|
||||
if (!combined.Contains("{{")) continue;
|
||||
|
||||
var replaced = PlaceholderRegex.Replace(combined, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
return data.TryGetValue(key, out var value) ? (value ?? string.Empty) : match.Value;
|
||||
});
|
||||
|
||||
if (replaced == combined) continue;
|
||||
|
||||
// Gán vào <w:t> đầu, clear rest (preserve run style của <w:t> đầu)
|
||||
textElements[0].Text = replaced;
|
||||
textElements[0].Space = new DocumentFormat.OpenXml.EnumValue<DocumentFormat.OpenXml.SpaceProcessingModeValues>(DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
|
||||
for (var i = 1; i < textElements.Count; i++)
|
||||
textElements[i].Text = string.Empty;
|
||||
}
|
||||
|
||||
// Tables cũng có Paragraph lồng bên trong → đã được Descendants<Paragraph> bắt
|
||||
}
|
||||
}
|
||||
24
src/Backend/SolutionErp.Infrastructure/Forms/FormRenderer.cs
Normal file
24
src/Backend/SolutionErp.Infrastructure/Forms/FormRenderer.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
public class FormRenderer : IFormRenderer
|
||||
{
|
||||
private readonly DocxRenderer _docx = new();
|
||||
private readonly XlsxRenderer _xlsx = new();
|
||||
|
||||
public Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
string format,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"docx" => _docx.RenderAsync(templateAbsolutePath, data, outputFileName, ct),
|
||||
"xlsx" => _xlsx.RenderAsync(templateAbsolutePath, data, outputFileName, ct),
|
||||
_ => throw new NotSupportedException($"Format '{format}' không được hỗ trợ. Chỉ 'docx' hoặc 'xlsx'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs
Normal file
47
src/Backend/SolutionErp.Infrastructure/Forms/XlsxRenderer.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClosedXML.Excel;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Forms;
|
||||
|
||||
public class XlsxRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderRegex = new(@"\{\{([a-zA-Z0-9_\.]+)\}\}", RegexOptions.Compiled);
|
||||
|
||||
public async Task<RenderResult> RenderAsync(
|
||||
string templateAbsolutePath,
|
||||
IReadOnlyDictionary<string, string?> data,
|
||||
string outputFileName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
using var wb = new XLWorkbook(templateAbsolutePath);
|
||||
|
||||
foreach (var ws in wb.Worksheets)
|
||||
{
|
||||
foreach (var cell in ws.CellsUsed())
|
||||
{
|
||||
if (cell.DataType != XLDataType.Text) continue;
|
||||
var text = cell.GetString();
|
||||
if (!text.Contains("{{")) continue;
|
||||
|
||||
var replaced = PlaceholderRegex.Replace(text, match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
return data.TryGetValue(key, out var value) ? (value ?? string.Empty) : match.Value;
|
||||
});
|
||||
|
||||
if (replaced != text)
|
||||
cell.Value = replaced;
|
||||
}
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
outputFileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
|
||||
@ -16,6 +17,8 @@ public class ApplicationDbContext
|
||||
public DbSet<Department> Departments => Set<Department>();
|
||||
public DbSet<MenuItem> MenuItems => Set<MenuItem>();
|
||||
public DbSet<Permission> Permissions => Set<Permission>();
|
||||
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();
|
||||
public DbSet<ContractClause> ContractClauses => Set<ContractClause>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Forms;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence;
|
||||
@ -27,6 +29,7 @@ public static class DbInitializer
|
||||
await SeedAdminAsync(userManager, logger);
|
||||
await SeedMenuTreeAsync(db, logger);
|
||||
await SeedAdminPermissionsAsync(db, roleManager, logger);
|
||||
await SeedContractTemplatesAsync(db, logger);
|
||||
}
|
||||
|
||||
private static async Task SeedRolesAsync(RoleManager<Role> roleManager, ILogger logger)
|
||||
@ -68,7 +71,6 @@ public static class DbInitializer
|
||||
|
||||
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
|
||||
{
|
||||
// (key, label, parent, order, icon) — icon là lucide-react name
|
||||
var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
|
||||
{
|
||||
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
|
||||
@ -128,4 +130,67 @@ public static class DbInitializer
|
||||
logger.LogInformation("Seeded {Count} admin permissions", added);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedContractTemplatesAsync(ApplicationDbContext db, ILogger logger)
|
||||
{
|
||||
// Chỉ IsActive=true nếu file thực tế tồn tại trong wwwroot/templates/.
|
||||
// 3 file .doc (FO-002.02/03/06) chưa convert sẽ IsActive=false.
|
||||
var templates = new (string FormCode, string Name, ContractType? Type, string FileName, string Format, string? Description)[]
|
||||
{
|
||||
("SOL-CCM-FO-002.01", "Bảng kiểm tra hợp đồng", null,
|
||||
"SOL-CCM-FO-002.01.v01 Bang kiem tra hop dong.docx", "docx",
|
||||
"Checklist duyệt HĐ theo 4 bộ phận: Đề xuất / Cung ứng / CCM / Giám đốc"),
|
||||
("SOL-CCM-FO-002.02", "Hợp đồng trọn gói — Nhân công + Vật tư", ContractType.HopDongThauPhu,
|
||||
"SOL-CCM-FO-002.02.v01 Hop dong tron goi nhan cong, vat tu.docx", "docx",
|
||||
"Template HĐ thầu phụ trọn gói (cần convert .doc → .docx qua Word COM)"),
|
||||
("SOL-CCM-FO-002.03", "Hợp đồng trọn gói — Nhân công + Thiết bị", ContractType.HopDongThauPhu,
|
||||
"SOL-CCM-FO-002.03.v01 Hop dong trong goi nhan cong, thiet bi.docx", "docx",
|
||||
"Template HĐ thầu phụ trọn gói (cần convert .doc → .docx qua Word COM)"),
|
||||
("SOL-CCM-FO-002.04", "Điều kiện chung hợp đồng trọn gói", null,
|
||||
"SOL-CCM-FO-002.04.v01 Dieu kien chung hop dong tron goi.docx", "docx",
|
||||
"Appendix điều khoản chung đính kèm HĐ trọn gói"),
|
||||
("SOL-CCM-FO-002.05", "Hợp đồng Giao khoán", ContractType.HopDongGiaoKhoan,
|
||||
"SOL-CCM-FO-002.05.v01 Hop dong Giao khoan.docx", "docx",
|
||||
"Template HĐ giao khoán với Tổ đội / Nhân công"),
|
||||
("SOL-CCM-FO-002.06", "Hợp đồng mua bán", ContractType.HopDongMuaBan,
|
||||
"SOL-CCM-FO-002.06.v01 Hop dong mua ban.docx", "docx",
|
||||
"Template HĐ mua bán NCC (cần convert .doc → .docx qua Word COM)"),
|
||||
("SOL-CCM-FO-002.07", "Đơn đặt hàng (PO)", null,
|
||||
"SOL-CCM-FO-002.07.V01 Don dat hang.xlsx", "xlsx",
|
||||
"Template PO Excel — 3 sheet: so sánh giá, PO chính, PO NLT"),
|
||||
("SOL-CCM-RG-001", "Quy định mã số hợp đồng", null,
|
||||
"SOL-CCM-RG-001.v02 QD ma so hop dong.docx", "docx",
|
||||
"Regulation mã HĐ + PO — reference doc"),
|
||||
};
|
||||
|
||||
var existingCodes = await db.ContractTemplates.Select(t => t.FormCode).ToListAsync();
|
||||
var wwwroot = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
|
||||
var added = 0;
|
||||
foreach (var (formCode, name, type, fileName, format, desc) in templates)
|
||||
{
|
||||
if (existingCodes.Contains(formCode)) continue;
|
||||
|
||||
var templatePath = $"templates/{fileName}";
|
||||
var absPath = Path.Combine(wwwroot, "templates", fileName);
|
||||
var fileExists = File.Exists(absPath);
|
||||
|
||||
db.ContractTemplates.Add(new ContractTemplate
|
||||
{
|
||||
FormCode = formCode,
|
||||
Name = name,
|
||||
ContractType = type,
|
||||
FileName = fileName,
|
||||
StoragePath = templatePath,
|
||||
Format = format,
|
||||
Description = desc,
|
||||
IsActive = fileExists,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
if (added > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Seeded {Count} contract templates (active file check)", added);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
725
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421043848_AddForms.Designer.cs
generated
Normal file
725
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421043848_AddForms.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,136 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("UserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ContractClauses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("ContractType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("FieldSpec")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("FormCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContractType");
|
||||
|
||||
b.HasIndex("FormCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ContractTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6">
|
||||
|
||||
Reference in New Issue
Block a user