[CLAUDE] Phase4: Report MVP + Docs Consolidation (rules, architecture, schema-diagram)

Backend Report:
- Application/Reports/Dtos/DashboardStatsDto: 5 KPI + PhaseCount + SupplierCount + ProjectCount + MonthlyValue
- Application/Reports/Queries/GetDashboardStats handler: total/active/overdue/published this month/totalValueActive + byPhase + top 5 NCC/du an + 12 thang monthly (fill zero khi thang empty)
- Application/Reports/Services/IContractExcelExporter interface
- Infrastructure/Reports/ContractExcelExporter: ClosedXML workbook 10 cot, header style bold+blue, number format #,##0, formula SUM, auto-fit, freeze header
- Application/Reports/Commands/ExportContractsToExcelCommand: filter phase/supplier/project/date range
- Api/Controllers/ReportsController: GET /reports/dashboard, GET /reports/contracts/export
- DI register IContractExcelExporter (Scoped)

Frontend fe-admin:
- types/reports.ts: DashboardStats type
- components/BarChart.tsx: generic horizontal bar chart — chi Tailwind, khong thu vien ngoai
- pages/DashboardPage.tsx REWRITE: 5 KPI card (FileText/TrendingUp/AlertTriangle/CheckCircle2/Coins) + by-phase bar + monthly 12-month chart + top 5 NCC + top 5 du an + skeleton loader
- pages/ReportsPage.tsx MOI: filter phase/fromDate/toDate → export Excel button
- Route /reports vao App.tsx

E2E verified:
- GET /api/reports/dashboard → 200 voi day du KPI + monthly fill 12 thang
- GET /api/reports/contracts/export → 200 xlsx 7229 bytes (Microsoft Excel 2007+)

Docs consolidation (theo yeu cau user):
- docs/rules.md MOI: 9 section coding conventions (ngon ngu UI/code/DB/docs, BE Clean Arch, CQRS+MediatR, Validation FluentValidation, Error handling, Async, Entity rules, DI, Package pinning, FE React/TS erasableSyntaxOnly, path alias, TanStack Query, Permission guard, Toast+error, DB convention, Git commit format, Docs structure, Testing, Security)
- docs/architecture.md MOI: layered overview ASCII art, request lifecycle (1 POST/api/contracts qua 10 step), workflow state machine 9 phase, permission model, data flow sequence diagram 4 actor (Drafter/Manager/CCM/BOD/HRA), deployment architecture Phase 5, skill library, non-functional table
- docs/database/schema-diagram.md MOI: full ERD 19 table mermaid + data flow diagram + vong doi 1 HD (create → 7 transition → gen ma → publish) + index strategy table + relationship cardinality + soft delete behavior + SQL queries (inbox/dashboard/gen ma) + migration history
- docs/gotchas.md UPDATE: 17 → 26 pitfalls, them section "Claude Code harness quirks" (Edit File not read, DI build pass nhung runtime fail) + "Contract workflow" (ma HD gen 2 lan, BE-FE NEXT_PHASES sync, race condition) + "Permission matrix" (cache real-time, MenuKey typo)
- docs/STATUS.md: Phase 4 MVP done, docs entry points section liet ke het, next Phase 5 Production
- docs/HANDOFF.md: phase table them Phase 4 row, file tree update voi Reports, test points day du, git state commit 7
- docs/changelog/migration-todos.md: tick Phase 4 MVP items + them iteration 2 list
- docs/changelog/sessions/2026-04-21-1430-phase4-report.md: session log voi thong so cumulative (BE 3100 LOC, 30 docs)
- CLAUDE.md root: update Tai lieu quan trong section them rules.md, architecture.md, schema-diagram.md, .claude/skills (13 links now)

Bug fix:
- TS unused import ContractPhaseLabel trong DashboardPage
- DI thieu register IContractExcelExporter — build pass but runtime would fail (added)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 12:42:46 +07:00
parent 7e957a7654
commit fe7ad8e4a3
21 changed files with 1817 additions and 212 deletions

View File

@ -63,15 +63,19 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
| File | Nội dung | | File | Nội dung |
|---|---| |---|---|
| [`docs/STATUS.md`](docs/STATUS.md) | **🔥 Current state** — đọc đầu tiên | | [`docs/STATUS.md`](docs/STATUS.md) | **🔥 Current state** — đọc đầu tiên |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | Brief 5 phút: session trước làm gì + next tasks |
| [`docs/rules.md`](docs/rules.md) | ⭐ Coding conventions (BE Clean Arch, FE React, DB, Git, Docs) |
| [`docs/architecture.md`](docs/architecture.md) | ⭐ Layered architecture + request lifecycle + deployment |
| [`docs/PROJECT-MAP.md`](docs/PROJECT-MAP.md) | Bản đồ tổng quan | | [`docs/PROJECT-MAP.md`](docs/PROJECT-MAP.md) | Bản đồ tổng quan |
| [`docs/changelog/migration-todos.md`](docs/changelog/migration-todos.md) | Roadmap 5 phase + atomic tasks | | [`docs/changelog/migration-todos.md`](docs/changelog/migration-todos.md) | Roadmap 5 phase + atomic tasks |
| [`docs/CLAUDE.md`](docs/CLAUDE.md) | Full context — tech stack, architecture | | [`docs/CLAUDE.md`](docs/CLAUDE.md) | Full context — tech stack chi tiết |
| [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase + role matrix | | [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase + role matrix |
| [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ | | [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ RG-001 |
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD | | [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + migration workflow + cheatsheet |
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) | | [`docs/database/schema-diagram.md`](docs/database/schema-diagram.md) | ⭐ ERD + luồng DB + data flow 19 table |
| [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 17 bẫy đã gặp — đọc trước khi debug tương tự | | [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract, form, SLA) |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | Brief 5 phút: session trước làm gì + P1 tasks tiếp | | [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 26 bẫy đã gặp — đọc trước khi debug tương tự |
| [`.claude/skills/`](.claude/skills/README.md) | 3 skill: contract-workflow, form-engine, permission-matrix |
## ⚠️ Kết thúc session ## ⚠️ Kết thúc session

View File

@ -1,6 +1,6 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-04-21 13:30 (cuối Phase 3 MVP) **Last updated:** 2026-04-21 14:30 (cuối Phase 4 MVP + docs consolidation)
## Ở đâu rồi? ## Ở đâu rồi?
@ -11,10 +11,11 @@
| 1 Alpha Core đợt 2 (CRUD + Permission) | ✅ Done | | 1 Alpha Core đợt 2 (CRUD + Permission) | ✅ Done |
| 2 Form Engine MVP | ✅ Done | | 2 Form Engine MVP | ✅ Done |
| 2 Form Engine iteration 2 | 📝 Optional | | 2 Form Engine iteration 2 | 📝 Optional |
| **3 Workflow MVP (9 phase + code gen)** | ✅ Done | | 3 Workflow MVP (9 phase + code gen) | ✅ Done |
| 3 Workflow iteration 2 (SLA job + notify + attachment) | 📝 Optional | | 3 Workflow iteration 2 (SLA job + notify + attachment) | 📝 Optional |
| 4 Report + Polish | 📋 Next | | **4 Report + Polish MVP (Dashboard + Excel)** | ✅ Done |
| 5 Production (CI/CD IIS) | 📋 Queue | | 4 Report iteration 2 (SLA report, PDF export) | 📝 Optional |
| 5 Production (CI/CD IIS) | 📋 Next |
## Run nhanh ## Run nhanh
@ -31,34 +32,43 @@ cd fe-user && npm run dev # → http://localhost:8080
Login: `admin@solutionerp.local` / `Admin@123456` Login: `admin@solutionerp.local` / `Admin@123456`
Điểm cần test ngay (Phase 3 MVP): Điểm cần test ngay (Phase 4 MVP):
- **Admin `/dashboard`** → 5 KPI card + By Phase bar + Monthly chart + Top NCC/dự án
- **Admin `/reports`** → filter phase/date → Export Excel .xlsx 10 cột có formula SUM
- **fe-user `/contracts/new`** → tạo HĐ draft (Phase 2 DangSoanThao, SLA +7d) - **fe-user `/contracts/new`** → tạo HĐ draft (Phase 2 DangSoanThao, SLA +7d)
- **fe-user `/inbox`** → xem HĐ chờ role mình xử lý - **fe-user `/inbox`** → xem HĐ chờ role mình xử lý
- **`/contracts/{id}`** (cả 2 FE) → click "Duyệt → tiếp" chạy state machine. Đến phase 8 DangDongDau → xem `MaHopDong` tự gen theo RG-001 - **`/contracts/{id}`** → click "Duyệt → tiếp" chạy state machine. Phase 8 gen `MaHopDong` RG-001
- **`/forms`** (admin) → render template .docx với JSON data - **`/forms`** → render template .docx
- **`/system/permissions`** → ma trận Role × MenuKey - **`/system/permissions`** → ma trận Role × MenuKey
- **`/master/suppliers|projects|departments`** → CRUD - **`/master/suppliers|projects|departments`** → CRUD
## Cần làm kế tiếp (ưu tiên) ## Cần làm kế tiếp (ưu tiên)
### A. Phase 4Report + Polish (tuần 10-11) ### A. Phase 5Production (tuần 12-13, item lớn nhất còn lại)
- Dashboard admin: HĐ theo phase, top NCC, top dự án, tổng giá trị tháng **Đọc trước:** `docs/changelog/migration-todos.md` section Phase 5.
- Excel export list HĐ (dùng ClosedXML đã có)
- Report quá hạn SLA theo phase/role
- UX polish: skeleton loader, empty state có action, error boundary recovery
- Accessibility: keyboard nav, aria labels
- Index DB hot query (SupplierId, ProjectId, Phase combo)
- User guide docs
### B. Phase 3 iteration 2 (polish workflow) - [ ] `.gitea/workflows/deploy.yml` CI/CD build + deploy IIS
- [ ] `scripts/deploy-iis.ps1` stop app pool → xcopy → start
- [ ] Windows Server IIS + URL Rewrite + ARR (reverse proxy FE → .NET)
- [ ] HTTPS cert via win-acme hoặc mua
- [ ] `appsettings.Production.json` + user secrets + JWT secret rotation
- [ ] Rate limiting middleware (auth endpoint 5 req/min/IP)
- [ ] Security audit OWASP top 10
- [ ] Health check endpoint `/health`
- [ ] Serilog → file rolling daily retention 30d
- [ ] SQL backup: daily full + 15min log
- [ ] Runbook: restart, rollback migration, restore backup
- [ ] UAT production 1 tuần với 2-3 user thật
- [ ] Go-live checklist
- [ ] `SlaExpiryJob` BackgroundService auto-approve (xem `flows/sla-expiry-flow.md`) ### B. Polish iterations (optional — khi rảnh)
- [ ] Email notification (MailKit) — pick up phase + SMTP config
- [ ] In-app notification (SignalR + badge counter) **Phase 2 iter 2:** convert 3 .doc qua Word COM `DisplayAlerts=0` hoặc LibreOffice, field spec JSON + form builder FE dynamic, `{{#loop}}` block support, PDF convert, FE upload template multipart.
- [ ] Upload attachment endpoint + FE multipart (store vào `wwwroot/uploads/contracts/{id}/`)
- [ ] RowVersion optimistic concurrency (2 user race → 409) **Phase 3 iter 2:** `SlaExpiryJob` BackgroundService auto-approve, email (MailKit) + in-app (SignalR) notify, upload attachment endpoint + FE multipart (`wwwroot/uploads/contracts/{id}/`), RowVersion concurrency, render HĐ docx khi tạo (merge TemplateId + DraftData + ContractClause).
- [ ] Render HĐ docx lúc tạo (merge TemplateId + DraftData + ContractClause appendix)
**Phase 4 iter 2:** SLA overdue report by role/phase, PDF HĐ export (LibreOffice), dashboard user-specific (role tôi).
### C. Phase 2 iteration 2 (form engine polish) ### C. Phase 2 iteration 2 (form engine polish)
@ -98,42 +108,53 @@ SOLUTION_ERP/
│ ├── SolutionErp.Application/ │ ├── SolutionErp.Application/
│ │ ├── Auth/ Login, Refresh, Me │ │ ├── Auth/ Login, Refresh, Me
│ │ ├── Common/ Exceptions, Behaviors, Interfaces, Models │ │ ├── Common/ Exceptions, Behaviors, Interfaces, Models
│ │ ├── Contracts/ **ContractFeatures (8 CQRS), IContractWorkflowService, IContractCodeGenerator, DTOs** ← Phase 3 │ │ ├── Contracts/ ContractFeatures (8 CQRS), IContractWorkflowService, IContractCodeGenerator, DTOs ← Phase 3
│ │ ├── Forms/ FormFeatures (List/Get/Render) ← Phase 2 │ │ ├── Forms/ FormFeatures (List/Get/Render) ← Phase 2
│ │ ├── Master/ Suppliers, Projects, Departments CQRS │ │ ├── Master/ Suppliers, Projects, Departments CQRS
│ │ ── Permissions/ GetMyMenuTree, matrix upsert │ │ ── Permissions/ GetMyMenuTree, matrix upsert
│ │ └── Reports/ **DashboardStats, ExportContractsToExcel, IContractExcelExporter** ← Phase 4
│ ├── SolutionErp.Infrastructure/ │ ├── SolutionErp.Infrastructure/
│ │ ├── Forms/ DocxRenderer, XlsxRenderer, FormRenderer ← Phase 2 │ │ ├── Forms/ DocxRenderer, XlsxRenderer, FormRenderer ← Phase 2
│ │ ├── Identity/ JwtSettings, JwtTokenService │ │ ├── Identity/ JwtSettings, JwtTokenService
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (**5** now) │ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (**5**)
│ │ ── Services/ DateTimeService, **ContractWorkflowService, ContractCodeGenerator** ← Phase 3 │ │ ── Reports/ **ContractExcelExporter** ← Phase 4
│ │ └── Services/ DateTimeService, ContractWorkflowService, ContractCodeGenerator ← Phase 3
│ └── SolutionErp.Api/ │ └── SolutionErp.Api/
│ ├── Authorization/ MenuPermissionHandler + Requirement │ ├── Authorization/ MenuPermissionHandler + Requirement
│ ├── Controllers/ Auth, Suppliers, Projects, Departments, Menus, Roles, Permissions, Forms, **Contracts** ← Phase 3 │ ├── Controllers/ Auth, Suppliers, Projects, Departments, Menus, Roles, Permissions, Forms, Contracts, **Reports** (10 controller)
│ ├── Middleware/ GlobalExceptionMiddleware │ ├── Middleware/ GlobalExceptionMiddleware
│ ├── Services/ CurrentUserService, WebHostEnvironmentLocator │ ├── Services/ CurrentUserService, WebHostEnvironmentLocator
│ └── wwwroot/templates/ 5 file .docx/.xlsx ← Phase 2 │ └── wwwroot/templates/ 5 file .docx/.xlsx ← Phase 2
├── fe-admin/ (9 page) ├── fe-admin/ (11 page)
│ └── src/pages/ │ └── src/pages/
│ ├── LoginPage │ ├── LoginPage
│ ├── DashboardPage │ ├── DashboardPage ← Phase 4 rewrite (KPI cards + BarChart)
│ ├── master/SuppliersPage, ProjectsPage, DepartmentsPage │ ├── master/SuppliersPage, ProjectsPage, DepartmentsPage
│ ├── system/PermissionsPage │ ├── system/PermissionsPage
│ ├── forms/FormsPage ← Phase 2 │ ├── forms/FormsPage ← Phase 2
── contracts/ContractsListPage, ContractDetailPage ← Phase 3 ── contracts/ContractsListPage, ContractDetailPage ← Phase 3
│ └── ReportsPage ← Phase 4
├── fe-user/ (5 page) ├── fe-user/ (5 page)
│ └── src/pages/ │ └── src/pages/
│ ├── LoginPage │ ├── LoginPage
│ ├── InboxPage ← Phase 3 │ ├── InboxPage ← Phase 3
│ └── contracts/ContractCreatePage, ContractDetailPage, MyContractsPage ← Phase 3 │ └── contracts/ContractCreatePage, ContractDetailPage, MyContractsPage ← Phase 3
├── docs/ (26 file — STATUS, PROJECT-MAP, workflow, forms-spec, database-guide, 6 flow, gotchas, HANDOFF, 5 session log) ├── docs/ (30 file)
└── .claude/skills/ (3 skill — all full spec: contract-workflow, form-engine, permission-matrix) │ ├── STATUS.md, HANDOFF.md, rules.md, architecture.md
│ ├── CLAUDE.md, PROJECT-MAP.md
│ ├── workflow-contract.md, forms-spec.md
│ ├── database/{database-guide, schema-diagram}.md
│ ├── flows/ (7 file — README + 6 flow)
│ ├── changelog/migration-todos.md + sessions/ (6 session log)
│ └── gotchas.md
└── .claude/skills/ (3 skill — all full spec)
``` ```
## Git state ## Git state
``` ```
(sẽ là commit 6) — Phase 3 Workflow MVP (sẽ là commit 7) — Phase 4 Report MVP + docs consolidation
7e957a7 — Phase 3 Workflow MVP
5113e4c — Phase 2 Form Engine MVP 5113e4c — Phase 2 Form Engine MVP
54d6c9b — Phase 1.2 CRUD + Permission 54d6c9b — Phase 1.2 CRUD + Permission
49a5f57 — Docs database-guide + flows 49a5f57 — Docs database-guide + flows
@ -141,7 +162,7 @@ SOLUTION_ERP/
25dad7f — Phase 0 scaffold 25dad7f — Phase 0 scaffold
Branch: main Branch: main
Remote: chưa (Gitea URL chờ user) Remote: chưa (Gitea URL chờ user — cần cho Phase 5)
``` ```
## Credentials + URLs ## Credentials + URLs

View File

@ -2,9 +2,9 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-04-21 13:30 **Last updated:** 2026-04-21 14:30
## 📍 Phase hiện tại: **Phase 3 Workflow (MVP xong)** — sẵn sàng Phase 4 Report hoặc polish Phase 3 iteration 2 ## 📍 Phase hiện tại: **Phase 4 Report MVP (xong)** — sẵn sàng Phase 5 Production hoặc polish iterations
## 🔥 In Progress ## 🔥 In Progress
@ -14,72 +14,73 @@ _(không có)_
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-04-21 | Claude | **Phase 3 Workflow MVP** — Contract+Approval+Comment+Attachment+CodeSequence entities + IContractWorkflowService (9 phase adjacency + role guard + bypass CĐT) + IContractCodeGenerator (RG-001 transactional) + CQRS 8 command/query + ContractsController. FE: fe-admin ContractsList + ContractDetail, fe-user Inbox+Create+Detail+MyContracts. **E2E 9 phase end-to-end pass với mã HĐ `FLOCK 01/HĐGK/SOL&PVL2026/01`** | (sắp commit) | | 2026-04-21 | Claude | **Phase 4 Report MVP + Docs Consolidation** — BE Dashboard stats (5 KPI + by phase + top 5 NCC/dự án + 12 tháng) + Excel export qua ClosedXML. FE DashboardPage rewrite với BarChart tự build (không thư viện ngoài) + ReportsPage filter export. Docs: rules.md (coding conventions), architecture.md (layered + sequence), database/schema-diagram.md (ERD + data flow 19 table), gotchas.md update 26 pitfalls | (sắp commit) |
| 2026-04-21 | Claude | **Phase 2 Form Engine MVP** — OpenXml + ClosedXML renderer + seed 8 template + FE FormsPage | `5113e4c` | | 2026-04-21 | Claude | **Phase 3 Workflow MVP** — 9 phase state machine + code gen RG-001 + Inbox/Detail FE — E2E pass mã `FLOCK 01/HĐGK/SOL&PVL2026/01` | `7e957a7` |
| 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix + FE 4 page | `54d6c9b` | | 2026-04-21 | Claude | **Phase 2 Form Engine MVP** | `5113e4c` |
| 2026-04-21 | Claude | **Docs + flows** | `49a5f57` | | 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix | `54d6c9b` |
| 2026-04-21 | Claude | **Phase 1 foundation** — Clean Arch + Identity + JWT + 2 FE + login E2E | `702411f` | | 2026-04-21 | Claude | **Docs addition** — database-guide + flows | `49a5f57` |
| 2026-04-21 | Claude | **Phase 1 foundation** — Clean Arch + Identity + JWT + 2 FE | `702411f` |
| 2026-04-21 | Claude | **Phase 0** — scaffold + docs | `25dad7f` | | 2026-04-21 | Claude | **Phase 0** — scaffold + docs | `25dad7f` |
Session logs: [Phase 0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [Phase 1f](changelog/sessions/2026-04-21-1100-phase1-foundation.md) · [Phase 1.2](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) · [Phase 2](changelog/sessions/2026-04-21-1200-phase2-form-engine.md) · [Phase 3](changelog/sessions/2026-04-21-1330-phase3-workflow.md) Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1f](changelog/sessions/2026-04-21-1100-phase1-foundation.md) · [P1.2](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) · [P2](changelog/sessions/2026-04-21-1200-phase2-form-engine.md) · [P3](changelog/sessions/2026-04-21-1330-phase3-workflow.md) · [P4](changelog/sessions/2026-04-21-1430-phase4-report.md)
Gotchas: [`gotchas.md`](gotchas.md) · Handoff: [`HANDOFF.md`](HANDOFF.md) **Docs entry points:**
- [`rules.md`](rules.md) — coding conventions
- [`architecture.md`](architecture.md) — layered + data flow + deployment
- [`database/database-guide.md`](database/database-guide.md) + [`database/schema-diagram.md`](database/schema-diagram.md) — DB spec
- [`gotchas.md`](gotchas.md) — 26 pitfalls
- [`HANDOFF.md`](HANDOFF.md) — brief 5 phút
- [`workflow-contract.md`](workflow-contract.md) — state machine
- [`forms-spec.md`](forms-spec.md) — 8 form catalog
- [`flows/`](flows/) — 6 flow diagram
## 🎯 Next up ## 🎯 Next up
### Phase 3 iteration 2 (polish — optional) ### Phase 5 — Production (T12-13, item lớn nhất còn lại)
- [ ] `SlaExpiryJob` BackgroundService auto-approve quá hạn - [ ] CI/CD Gitea Actions (`.gitea/workflows/deploy.yml`) deploy IIS
- [ ] Email notification (MailKit) khi chuyển phase - [ ] `scripts/deploy-iis.ps1` stop app pool → xcopy → start
- [ ] In-app notification (SignalR + badge) - [ ] Windows Server setup: IIS + URL Rewrite + ARR
- [ ] Upload attachment endpoint + FE multipart - [ ] HTTPS cert via win-acme
- [ ] RowVersion optimistic concurrency - [ ] `appsettings.Production.json` + user secrets
- [ ] Render HĐ docx khi tạo (merge ContractClause appendix) - [ ] Rate limiting middleware
- [ ] Security audit OWASP top 10
- [ ] Health check endpoint `/health`
- [ ] Serilog → file rolling daily retention 30d
- [ ] Runbook: restart, rollback, backup/restore
- [ ] UAT production 1 tuần
### Phase 4 — Report + Polish (tuần 10-11) ### Polish iterations (optional — làm khi rảnh)
- [ ] Dashboard admin: HĐ theo phase / top NCC / top dự án / tổng giá trị tháng **Phase 2 iter 2:** convert 3 .doc, field spec JSON + form builder, {{#loop}}, PDF convert, upload template UI
- [ ] Excel export list HĐ (EPPlus/ClosedXML) **Phase 3 iter 2:** SLA auto-approve job, email/in-app notification, attachment upload, RowVersion, render HĐ khi tạo
- [ ] Report quá hạn SLA theo phase/role **Phase 4 iter 2:** SLA overdue report, PDF HĐ export, dashboard user-specific
- [ ] UX polish: skeleton, empty state, error boundary
- [ ] Accessibility pass
- [ ] Index DB hot query
- [ ] User guide docs
- [ ] UAT với data thật
### Phase 5 — Production (tuần 12-13) ### Quick wins
- [ ] CI/CD Gitea Actions → IIS deploy - FE Users management + Roles CRUD (test permission non-admin)
- [ ] HTTPS cert + appsettings Production secrets - Filter Inbox theo phase FE
- [ ] Rate limiting + Security audit - FE refresh token auto interceptor
- [ ] Backup/restore runbook
### Quick wins (không block)
- [ ] FE Users management + Roles CRUD
- [ ] Filter Inbox theo phase FE
- [ ] Refresh token auto (FE axios interceptor)
## 📊 Thông số cumulative ## 📊 Thông số cumulative
| | Phase 0 | 1f | 1.2 | 2 | **Phase 3 MVP** | | | P0 | P1f | P1.2 | P2 | P3 | **P4** |
|---|---:|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|---:|
| BE LOC | 0 | ~400 | ~1500 | ~1900 | **~2700** | | BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | **~3100** |
| DB tables | 0 | 7 | 12 | 14 | **19** | | DB tables | 0 | 7 | 12 | 14 | 19 | **19** |
| API endpoints | 0 | 4 | ~20 | ~23 | **~31** | | API endpoints | 0 | 4 | ~20 | ~23 | ~31 | **~33** |
| Migrations | 0 | 1 | 3 | 4 | **5** | | FE pages | 0 | 2 | 6 | 7 | 14 | **16** |
| FE pages | 0 | 2 | 6 | 7 | **14** (9 admin + 5 user) | | Docs | 10 | 13 | 14 | 24 | 26 | **30** |
| Docs | 10 | 13 | 14 | 24 | **26** | | Commits | 1 | 2 | 3 | 5 | 6 | **7** (sắp) |
| Commits | 1 | 2 | 3 | 5 | **6** (sắp) |
## 🚨 Blockers / risks ## 🚨 Blockers / risks
-**Gitea remote URL**vẫn chờ -**Gitea remote URL**user sẽ cấp khi vào Phase 5
- ⚠️ **SLA hiện chỉ set deadline** — không có job auto-approve (Phase 3.2)
- ⚠️ **Không có notification** (email/in-app) — user phải F5 inbox manual
- ⚠️ **Không có RowVersion** — 2 user cùng transition race → last-write-wins
- ⚠️ **3 file .doc chưa convert** (Phase 2 carryover) - ⚠️ **3 file .doc chưa convert** (Phase 2 carryover)
- ⚠️ **Permission chưa test với non-admin user**tất cả E2E đều dùng admin - ⚠️ **SLA chỉ set deadline**không auto-approve (Phase 3.2)
- ⚠️ **Không notification** email/in-app (Phase 3.2)
- ⚠️ **Permission chưa test non-admin user thật** — cần FE Users mgmt
- ⚠️ **FE refresh token** — 401 chỉ redirect logout, chưa auto-refresh
## Credentials + URLs ## Credentials + URLs
@ -88,5 +89,5 @@ admin@solutionerp.local / Admin@123456
``` ```
- API: http://localhost:5443 — Swagger `/swagger` - API: http://localhost:5443 — Swagger `/swagger`
- Admin FE: http://localhost:8082 - Admin FE: http://localhost:8082 — Dashboard → **`/dashboard`** (KPI mới), **`/contracts`** (list), **`/reports`** (export), **`/master/*`** (NCC/DA/PB), **`/forms`**, **`/system/permissions`**
- User FE: http://localhost:8080 - User FE: http://localhost:8080 — Inbox → **`/inbox`**, **`/contracts/new`**, **`/my-contracts`**

250
docs/architecture.md Normal file
View File

@ -0,0 +1,250 @@
# Architecture — SOLUTION_ERP
> Kiến trúc tổng thể + trách nhiệm từng layer + diagram luồng dữ liệu.
## 1. Layered overview
```
┌──────────────────────────────────────────────────────────────┐
│ CLIENT TIER │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ fe-admin :8082 │ │ fe-user :8080 │ │
│ │ React 19 + Vite │ │ React 19 + Vite │ │
│ │ Tailwind 4 │ │ Tailwind 4 │ │
│ │ TanStack Query │ │ TanStack Query │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼──────────────────────────┼───────────────────────┘
│ Vite dev proxy /api │
│ IIS URL Rewrite prod │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ API LAYER (:5443) │
│ SolutionErp.Api — ASP.NET Core 10 Web API │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Controllers: Auth, Menus, Roles, Permissions, │ │
│ │ Suppliers, Projects, Departments, │ │
│ │ Forms, Contracts, Reports │ │
│ │ Middleware: GlobalException, Serilog, CORS, JWT │ │
│ │ Authorization: MenuPermissionHandler (policy-based) │ │
│ │ Services: CurrentUserService, WebHostEnvLocator │ │
│ │ wwwroot/templates/ (5 .docx/.xlsx) │ │
│ └────────────────────┬───────────────────────────────────┘ │
└───────────────────────┼──────────────────────────────────────┘
│ MediatR ISender.Send(cmd)
┌──────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ SolutionErp.Application │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth │ │ Master │ │ Permissions │ │
│ │ (Login/Me) │ │ (CRUD 3 │ │ (Menu tree + │ │
│ │ │ │ entity) │ │ matrix) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Forms │ │ Contracts │ ┌──────────────┐ │
│ │ (Render │ │ (Workflow 9 │ │ Reports │ │
│ │ engine) │ │ phase, Inbox) │ │ (Dashboard + │ │
│ └──────────────┘ └──────────────────┘ │ Excel exp) │ │
│ └──────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Common: Exceptions, Behaviors (ValidationPipeline), │ │
│ │ Interfaces (IDbContext, ICurrentUser, ...), │ │
│ │ Models (PagedResult) │ │
│ └──────────────────────────────────────────────────────┘ │
└───────┬──────────────────────────────────────────┬───────────┘
│ depends on interface │ runs on
▼ ▼
┌──────────────────────┐ ┌───────────────────────────┐
│ DOMAIN LAYER │ │ INFRASTRUCTURE LAYER │
│ SolutionErp.Domain │ │ SolutionErp.Infrastructure│
│ │ │ │
│ Common/BaseEntity │ │ Persistence/ApplicationD │
│ Contracts/ │ │ bContext + Migrations │
│ Forms/ │ │ Identity/JwtTokenService │
│ Identity/ │ │ Forms/Docx+XlsxRenderer │
│ Master/ │ │ Services/ContractWorkflow│
│ │ │ + ContractCodeGenerator│
│ Enum + value object │ │ Reports/ExcelExporter │
└──────────────────────┘ │ Services/DateTimeService │
↑ └────────────┬──────────────┘
│ references │
└───────────────────────────────────────┘
┌───────────────────────┐
│ DATA TIER │
│ SQL Server 2022 │
│ (dbo schema, 19 bảng)│
└───────────────────────┘
```
## 2. Request lifecycle (1 POST/api/contracts)
```
1. Browser → POST /api/contracts { type, supplierId, ... }
2. Vite → proxy tới :5443
3. JwtBearerMiddleware → validate token, set ClaimsPrincipal
4. Routing → ContractsController.Create(cmd)
5. MediatR.Send(CreateContractCommand)
6. ValidationBehavior (pipeline) → FluentValidation run
7. CreateContractCommandHandler
├─ check Supplier/Project exists (IApplicationDbContext)
├─ new Contract entity + set DrafterUserId (ICurrentUser)
├─ set SlaDeadline (IDateTime + IContractWorkflowService.GetPhaseSla)
├─ db.Contracts.Add(...)
└─ SaveChangesAsync
├─ AuditingInterceptor sets CreatedAt/CreatedBy
└─ EF Core INSERT → SQL Server
8. Return Guid id
9. Controller → 201 Created + Location header
10. Axios interceptor (FE) → TanStack Query invalidate + UI update
```
## 3. Workflow state machine (Phase 3)
Xem full ở [`workflow-contract.md`](workflow-contract.md).
```
DangChon → DangSoanThao → DangGopY → DangDamPhan → DangInKy →
→ DangKiemTraCCM → DangTrinhKy → DangDongDau → DaPhatHanh
Alternates: → TuChoi (từ DangSoanThao)
→ DangSoanThao (revise từ bất kỳ phase duyệt)
Bypass CĐT (BypassProcurementAndCCM=true):
DangInKy → DangTrinhKy (skip CCM)
```
**Code generator trigger:** khi `targetPhase = DangDongDau` + `MaHopDong IS NULL` → gen format RG-001 với transaction SERIALIZABLE.
## 4. Permission model
```
User ──(AspNetUserRoles)── Role ──(Permissions)── MenuItem
├── CanRead
├── CanCreate
├── CanUpdate
└── CanDelete
```
**Resolution:**
- Login → JWT chứa claims (sub, email, roles)
- `/api/menus/me` → query Permissions theo roleIds, **union OR** CRUD flags, filter tree theo CanRead
- FE cache menu trong `AuthContext` + localStorage
- Mỗi API action: `[Authorize(Policy = "{MenuKey}.{Action}")]``MenuPermissionHandler` check
**Admin bypass:** role `Admin` luôn pass mọi policy (seed default full CRUD).
## 5. Data flow — "tạo HĐ và chạy hết workflow"
```mermaid
sequenceDiagram
actor D as Drafter
actor M as Manager (PD/PM)
actor C as CCM
actor B as BOD
actor H as HRA
participant FE as fe-user
participant API
participant WF as WorkflowService
participant CG as CodeGenerator
participant DB
D->>FE: POST /contracts/new
FE->>API: POST /api/contracts
API->>DB: INSERT Contracts (Phase=DangSoanThao, SLA=+7d)
API-->>FE: 201 {id}
D->>FE: Click "Submit → góp ý"
FE->>API: POST /contracts/{id}/transitions {target:3}
API->>WF: Transition(contract, 3, roles=[Drafter])
WF->>WF: Check adjacency + role
WF->>DB: INSERT ContractApproval + UPDATE Contract Phase=3
API-->>FE: 204
M->>FE: Inbox → click HĐ → góp ý + "Chuyển tiếp"
FE->>API: POST /transitions {target:4}
API->>WF: Transition → Phase 4
Note over WF: Chạy tương tự qua 5,6,7
B->>FE: Duyệt → target:8 DangDongDau
FE->>API: POST /transitions {target:8}
API->>WF: Transition
WF->>CG: GenerateAsync(contract, project, supplier)
CG->>DB: BEGIN TRAN SERIALIZABLE
CG->>DB: SELECT/UPDATE ContractCodeSequences
CG->>DB: COMMIT
CG-->>WF: "FLOCK 01/HĐGK/SOL&PVL/03"
WF->>DB: UPDATE Contract SET MaHopDong, Phase=8
API-->>FE: 204
H->>FE: Click đóng dấu → target:9
FE->>API: POST /transitions {target:9}
API->>WF: Transition (role HrAdmin)
WF->>DB: UPDATE Phase=9 (DaPhatHanh)
API-->>FE: 204
```
## 6. Deployment architecture (Phase 5 — planned)
```
┌─────────────────────────────┐
│ Internet / Corp LAN │
└──────────────┬──────────────┘
┌──────────▼──────────┐
│ IIS (Win Server) │
│ URL Rewrite / ARR │
└──────────┬──────────┘
┌─────────────┼─────────────┐
│ │ │
┌──────▼──────┐ ┌───▼────┐ ┌──────▼──────┐
│ fe-admin │ │ Api │ │ fe-user │
│ static files│ │ Kestrel│ │ static files│
│ (dist/) │ │ :5443 │ │ (dist/) │
└─────────────┘ └───┬────┘ └─────────────┘
┌────────▼────────┐
│ SQL Server │
│ (same host OR │
│ separate VM) │
└─────────────────┘
```
- IIS app pool riêng cho Api, Integrated Managed Pipeline, .NET CLR disabled (hosting .NET 10 OOP)
- Static files 2 FE deploy vào `C:\inetpub\wwwroot\solution-erp-admin\` + `...user\`
- HTTPS: Let's Encrypt qua win-acme (hoặc cert mua)
- Backup SQL: daily full + 15min log → D:\Backups
## 7. Skill library (AI agent support)
`.claude/skills/` có 3 skill chuyên biệt:
| Skill | Dùng khi |
|---|---|
| [`contract-workflow`](../.claude/skills/contract-workflow/SKILL.md) | Debug chuyển phase, 403, mã HĐ, bypass CĐT |
| [`form-engine`](../.claude/skills/form-engine/SKILL.md) | Render template, upload, placeholder không replace |
| [`permission-matrix`](../.claude/skills/permission-matrix/SKILL.md) | Access denied, menu không hiện, gán role |
Claude auto-invoke theo description matching.
## 8. Non-functional
| Aspect | Current | Phase 5 target |
|---|---|---|
| Availability | dev-only | 99.5% (IIS restart, SQL HA optional) |
| Latency | <200ms P95 local | <500ms P95 prod |
| Concurrency | unrestricted | rate limit 100 req/min/IP |
| Observability | Serilog console | + file rolling daily + Seq/ELK |
| Security | JWT + HTTPS dev | + rate limit + audit log + CSP |
## 9. Liên quan
- [`rules.md`](rules.md) coding conventions
- [`database/database-guide.md`](database/database-guide.md) DB schema chi tiết
- [`flows/`](flows/) per-feature sequence diagrams
- [`workflow-contract.md`](workflow-contract.md) state machine spec
- [`forms-spec.md`](forms-spec.md) 8 form catalog

View File

@ -158,13 +158,25 @@
## Phase 4 — Reporting + Polish (T10-11) ## Phase 4 — Reporting + Polish (T10-11)
- [ ] Dashboard admin: số HĐ theo phase, top NCC, top dự án, tổng giá trị theo tháng ### MVP xong (iteration 1)
- [ ] Excel export theo bộ lọc (dùng EPPlus)
- [ ] Report: HĐ quá hạn SLA bao nhiêu lần theo phase/role - [x] Dashboard admin: 5 KPI (total/active/overdue/published this month/total value) + by phase + top 5 NCC + top 5 dự án + 12 tháng
- [ ] UX polish: skeleton loader, empty state, error boundary có recovery button - [x] Excel export HĐ theo filter (phase/supplier/project/date range) qua ClosedXML
- [x] BE `GetDashboardStatsQuery` + `ExportContractsToExcelCommand` + ReportsController
- [x] FE `DashboardPage` rewrite với `BarChart` tự build (Tailwind only, không thư viện ngoài)
- [x] FE `ReportsPage` filter + export
- [x] Docs consolidation: `rules.md` + `architecture.md` + `database/schema-diagram.md` + gotchas update
### Iteration 2 (polish — optional)
- [ ] SLA overdue report (by role / phase, export Excel)
- [ ] Contract audit log export (từng HĐ ra PDF)
- [ ] Dashboard user-specific (HĐ của tôi / role của tôi)
- [ ] Chart library recharts (nếu cần chart phức tạp)
- [ ] UX polish: skeleton loader cho mọi list, empty state có action, error boundary recovery
- [ ] Accessibility: keyboard nav, focus trap modal, aria labels - [ ] Accessibility: keyboard nav, focus trap modal, aria labels
- [ ] Dark mode (optional, nếu rảnh) - [ ] Dark mode
- [ ] Performance: index DB cho query hot (SupplierId, ProjectId, Phase) - [ ] Performance: explicit index DB cho query hot đã identify
- [ ] Tài liệu user guide: quy trình tạo HĐ + duyệt - [ ] Tài liệu user guide: quy trình tạo HĐ + duyệt
- [ ] UAT với 5-10 HĐ dữ liệu thật từ bộ phận Cung ứng - [ ] UAT với 5-10 HĐ dữ liệu thật từ bộ phận Cung ứng

View File

@ -0,0 +1,122 @@
# Session 2026-04-21 14:30 — Phase 4 Report MVP + Docs Consolidation
**Dev:** Claude (Opus 4.7)
**Duration:** ~1h
**Base commit:** `7e957a7`
## Làm được
### Chunk K — BE Dashboard + Excel export
- `Application/Reports/Dtos/DashboardStatsDto.cs`: DashboardStats + PhaseCount + SupplierCount + ProjectCount + MonthlyValue
- `Application/Reports/Queries/GetDashboardStats` Handler:
- Counts: Total / Active (not final) / Overdue (SlaDeadline < now) / PublishedThisMonth
- TotalValueActive: SUM GiaTri của active contracts
- ByPhase: group theo phase
- TopSuppliers, TopProjects: top 5 theo count + sum value
- MonthlyValue: fill 12 tháng liên tục (kể cả tháng rỗng)
- `Application/Reports/Services/IContractExcelExporter`
- `Infrastructure/Reports/ContractExcelExporter`:
- ClosedXML workbook 10 cột (STT, , Tên, Loại, Phase, NCC, Dự án, Giá trị, SLA, Ngày tạo)
- Header style (bold + background blue)
- Number format `#,##0` cho cột giá trị
- Formula SUM tổng cuối bảng
- Auto-fit columns + freeze header row
- `Application/Reports/Commands/ExportContractsToExcelCommand` (filter phase/supplier/project/date range)
- `Api/Controllers/ReportsController`: GET `/api/reports/dashboard`, GET `/api/reports/contracts/export`
- DI register IContractExcelExporter (Scoped)
### Chunk L — FE admin Dashboard + Reports
- `types/reports.ts`: DashboardStats type
- `components/BarChart.tsx`: generic horizontal bar chart chỉ Tailwind, không thư viện ngoài (tránh bloat bundle)
- `pages/DashboardPage.tsx` rewrite:
- 5 StatCard KPI: Tổng , Đang xử , Quá hạn SLA, Phát hành tháng, Tổng giá trị
- Section By Phase (bar với PhaseBadge)
- Section Monthly Value 12 tháng (BarChart)
- Section Top 5 NCC + Top 5 dự án
- Skeleton loader khi loading
- Money formatter (tỷ / tr / số thường)
- `pages/ReportsPage.tsx` MỚI: form filter (phase / from / to) export Excel button
- Route `/reports` added vào App.tsx
### Docs Consolidation (theo yêu cầu user)
- `docs/rules.md` MỚI: 9 section coding conventions (ngôn ngữ, BE Clean Arch, CQRS, validation, error, async, entity, DI, packages, FE React/TS, database, git, docs)
- `docs/architecture.md` MỚI: layered diagram, request lifecycle, workflow state machine, permission model, data flow sequence, deployment, non-functional
- `docs/database/schema-diagram.md` MỚI: ERD full 19 tables với mermaid, data flow diagram, lifecycle 1 , index strategy, relationship cardinality, soft delete behavior, SQL queries điển hình, migration history
- `docs/gotchas.md` UPDATE: thêm 7 pitfalls mới (20-26: Claude Code harness, DI register, Contract workflow, Permission)
- Session log này
## E2E verified
```bash
GET /api/reports/dashboard → 200
totalContracts: 1, activeContracts: 0, overdueContracts: 0,
publishedThisMonth: 1, totalValueActive: 0,
byPhase: [{phase:9, count:1}],
topSuppliers: [{supplierName:"Cong ty PVL Test", count:1, totalValue:150M}],
topProjects: [...],
monthlyValue: [12 rows, fill 0 if empty month]
GET /api/reports/contracts/export → HTTP 200, file xlsx 7229 bytes
Microsoft Excel 2007+ OK
TS check fe-admin: pass
```
## Bug gặp + fix
| Bug | Fix |
|---|---|
| Edit tool "File not read" sau system-reminder | Read lại + Write full |
| TS unused import ContractPhaseLabel | Remove unused import |
| DI thiếu register IContractExcelExporter | Add `services.AddScoped<IContractExcelExporter, ContractExcelExporter>()` |
## Handoff session sau
### Phase 4 iteration 2 (polish)
- [ ] SLA overdue report (by role / phase, export Excel)
- [ ] Contract audit log export (từng ra PDF)
- [ ] Dashboard user-specific ( tôi / role tôi)
- [ ] Chart library đơn giản (recharts hoặc tiếp tục Tailwind)
### Phase 5 — Production (T12-13)
- [ ] CI/CD Gitea Actions `.gitea/workflows/deploy.yml`
- [ ] IIS setup script + runbook
- [ ] HTTPS cert (win-acme)
- [ ] Rate limit middleware
- [ ] Security audit OWASP top 10
- [ ] Backup SQL runbook
### Phase 3 iteration 2 (vẫn còn thiếu)
- [ ] SlaExpiryJob BackgroundService auto-approve
- [ ] Email + in-app notification
- [ ] Upload attachment endpoint + FE
- [ ] RowVersion concurrency
- [ ] Render docx khi tạo
### Phase 2 iteration 2 (vẫn còn)
- [ ] Convert 3 file .doc
- [ ] Field spec JSON + form builder
- [ ] {{#loop}} block
- [ ] PDF convert
### Blocker
- Gitea remote URL (vẫn chờ sẽ quyết Phase 5)
## Thông số cumulative
| | Phase 2 | Phase 3 | **Phase 4 MVP** |
|---|---:|---:|---:|
| BE LOC | ~1900 | ~2700 | **~3100** |
| DB tables | 14 | 19 | 19 (không thêm table mới) |
| API endpoints | ~23 | ~31 | **~33** |
| FE pages | 9+5 | 14 | **16** (+Reports, +Dashboard rewrite) |
| Docs files | 24 | 26 | **30** (+rules, architecture, schema-diagram) |
| Commits | 5 | 6 | **7** (sắp) |

View File

@ -0,0 +1,358 @@
# Schema Diagram — Luồng DB SOLUTION_ERP
> ERD đầy đủ + mối quan hệ 19 table sau Phase 3. Mermaid render ở VS Code / GitHub / Gitea.
## 1. Full ERD
```mermaid
erDiagram
Users ||--o{ UserRoles : "has"
Roles ||--o{ UserRoles : "assigned to"
Users ||--o{ UserClaims : "has"
Users ||--o{ UserLogins : "has"
Users ||--o{ UserTokens : "has"
Roles ||--o{ RoleClaims : "has"
Roles ||--o{ Permissions : "grants"
MenuItems ||--o{ Permissions : "controlled by"
MenuItems ||--o{ MenuItems : "parent-of"
Suppliers ||--o{ Contracts : "party-B"
Projects ||--o{ Contracts : "belongs-to"
Departments ||--o{ Contracts : "drafted-in"
Users ||--o{ Contracts : "drafter"
ContractTemplates ||--o{ Contracts : "uses"
Contracts ||--o{ ContractApprovals : "history"
Contracts ||--o{ ContractComments : "thread"
Contracts ||--o{ ContractAttachments : "files"
Users ||--o{ ContractApprovals : "approved-by"
Users ||--o{ ContractComments : "author"
Users {
uniqueidentifier Id PK
nvarchar FullName "200"
nvarchar Email "UK"
nvarchar PasswordHash
nvarchar RefreshToken "512"
datetime2 RefreshTokenExpiresAt
bit IsActive
datetime2 CreatedAt
}
Roles {
uniqueidentifier Id PK
nvarchar Name "UK"
nvarchar Description "500"
datetime2 CreatedAt
}
MenuItems {
nvarchar Key PK "50"
nvarchar Label "200"
nvarchar ParentKey FK "NULL if root"
int Order
nvarchar Icon "50"
}
Permissions {
uniqueidentifier Id PK
uniqueidentifier RoleId FK
nvarchar MenuKey FK "50"
bit CanRead
bit CanCreate
bit CanUpdate
bit CanDelete
}
Suppliers {
uniqueidentifier Id PK
nvarchar Code "UK 50"
nvarchar Name "200"
int Type "NCC/NTP/TD/DVDV/CDT"
nvarchar TaxCode "20"
nvarchar Phone "30"
nvarchar Email "100"
nvarchar Address "500"
bit IsDeleted
}
Projects {
uniqueidentifier Id PK
nvarchar Code "UK 50"
nvarchar Name "200"
date StartDate
date EndDate
uniqueidentifier ManagerUserId FK
decimal BudgetTotal "18,2"
}
Departments {
uniqueidentifier Id PK
nvarchar Code "UK 50"
nvarchar Name "200"
uniqueidentifier ManagerUserId FK
}
ContractTemplates {
uniqueidentifier Id PK
nvarchar FormCode "UK 50"
nvarchar Name "200"
int ContractType "nullable"
nvarchar FileName "255"
nvarchar StoragePath "500"
nvarchar Format "docx/xlsx"
nvarchar FieldSpec "max JSON"
bit IsActive
}
ContractClauses {
uniqueidentifier Id PK
nvarchar Code "UK 50"
nvarchar Name "200"
nvarchar Content "max rich text"
int Version
bit IsActive
}
Contracts {
uniqueidentifier Id PK
nvarchar MaHopDong "UK filtered 100"
nvarchar TenHopDong "500"
int Type "ContractType enum"
int Phase "9 state"
uniqueidentifier SupplierId FK
uniqueidentifier ProjectId FK
uniqueidentifier DepartmentId FK
uniqueidentifier DrafterUserId FK
uniqueidentifier TemplateId FK
decimal GiaTri "18,2"
bit BypassProcurementAndCCM
datetime2 SlaDeadline
bit SlaWarningSent
nvarchar DraftData "max JSON"
bit IsDeleted
}
ContractApprovals {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
int FromPhase
int ToPhase
uniqueidentifier ApproverUserId FK "NULL=system"
int Decision "Pending/Approve/Reject/AutoApprove"
nvarchar Comment "1000"
datetime2 ApprovedAt
}
ContractComments {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
uniqueidentifier UserId FK
int Phase "phase luc comment"
nvarchar Content "2000"
datetime2 CreatedAt
}
ContractAttachments {
uniqueidentifier Id PK
uniqueidentifier ContractId FK
nvarchar FileName "255"
nvarchar StoragePath "500"
bigint FileSize
nvarchar ContentType "100"
int Purpose "DraftExport/ScannedSigned/SealedCopy"
nvarchar Note "500"
}
ContractCodeSequences {
nvarchar Prefix PK "200"
int LastSeq
datetime2 UpdatedAt
}
```
## 2. Luồng dữ liệu chính (data flow diagram)
```mermaid
flowchart TB
subgraph IDENTITY ["🔐 Identity domain (seed lần đầu)"]
U[Users] -->|N-M| R[Roles]
R --> P[Permissions]
P --> MI[MenuItems]
end
subgraph MASTER ["📋 Master data (admin CRUD)"]
S[Suppliers]
PR[Projects]
DE[Departments]
end
subgraph FORMS ["📄 Form templates (seed)"]
CT[ContractTemplates]
CC[ContractClauses]
end
subgraph CONTRACT ["📝 Contract workflow (Phase 3 core)"]
C[Contracts]
CA[ContractApprovals]
CCM[ContractComments]
CAT[ContractAttachments]
CCS[ContractCodeSequences]
end
U -.Drafter.-> C
S --> C
PR --> C
DE --> C
CT --> C
C --> CA
C --> CCM
C --> CAT
C -.gen when DangDongDau.-> CCS
```
## 3. Vòng đời 1 HĐ — data changes
```mermaid
flowchart LR
Create[POST /contracts]
Create --> C1["Contracts INSERT<br/>Phase=2, SLA=+7d"]
Transition1[Transition 2→3]
Transition1 --> C2["UPDATE Phase=3<br/>INSERT ContractApprovals"]
Comment[POST /comments]
Comment --> C3[INSERT ContractComments]
Transition2[Transition 3→4→5→6→7]
Transition2 --> C4["UPDATE Phase + SlaDeadline<br/>INSERT ContractApprovals"]
Transition3[Transition 7→8 BOD ký]
Transition3 --> CG["ContractCodeGenerator<br/>SERIALIZABLE tran<br/>UPSERT ContractCodeSequences"]
CG --> C5["UPDATE Contract<br/>SET MaHopDong='FLOCK 01/HĐGK/SOL&PVL/01',<br/>Phase=8"]
Transition4[Transition 8→9 HRA phát hành]
Transition4 --> C6["UPDATE Phase=9, SlaDeadline=NULL<br/>INSERT ContractApprovals"]
```
## 4. Index strategy
| Table | Index | Purpose |
|---|---|---|
| Contracts | `UX_Contracts_MaHopDong` filtered `WHERE [MaHopDong] IS NOT NULL` | Unique mã HĐ |
| Contracts | `IX_Contracts_Phase_IsDeleted` | Inbox query theo phase |
| Contracts | `IX_Contracts_SupplierId` | Filter HĐ theo NCC |
| Contracts | `IX_Contracts_ProjectId` | Filter HĐ theo dự án |
| Contracts | `IX_Contracts_SlaDeadline` | SLA expiry job query |
| ContractApprovals | `IX_ContractApprovals_ContractId_ApprovedAt` | Timeline query theo HĐ |
| ContractComments | `IX_ContractComments_ContractId_CreatedAt` | Thread load |
| ContractAttachments | `IX_ContractAttachments_ContractId` | Attachments load |
| Suppliers/Projects/Departments | `UX_{Table}_Code` | Unique business code |
| Permissions | `UX_Permissions_RoleId_MenuKey` | 1 row / role / menu |
| MenuItems | `IX_MenuItems_ParentKey` | Tree query |
Chi tiết + cheatsheet SQL: [`database-guide.md`](database-guide.md).
## 5. Relationship cardinality
| Parent → Child | Cardinality | OnDelete | Ghi chú |
|---|---|---|---|
| Contract → ContractApproval | 1 - N | Cascade | Xóa HĐ → xóa lịch sử (nhưng mức delete bị chặn sau DangInKy) |
| Contract → ContractComment | 1 - N | Cascade | — |
| Contract → ContractAttachment | 1 - N | Cascade | File vật lý vẫn còn trong `wwwroot/uploads/`, cleanup riêng |
| Supplier → Contract | 1 - N | Restrict | Không xóa Supplier nếu còn HĐ tham chiếu |
| Project → Contract | 1 - N | Restrict | Tương tự |
| Role → Permission | 1 - N | Cascade | Xóa role → clear permissions |
| MenuItem → Permission | 1 - N | Cascade | — |
| MenuItem → MenuItem (self-ref) | 1 - N | Restrict | Parent-child menu |
## 6. Soft delete behavior
Mọi entity extend `AuditableEntity`:
- Delete qua `db.X.Remove(entity)``AuditingInterceptor` chuyển `EntityState.Deleted``Modified`, set `IsDeleted=true, DeletedAt, DeletedBy`
- Query filter `HasQueryFilter(x => !x.IsDeleted)` tự động filter ra
- Để query bao gồm soft-deleted: `.IgnoreQueryFilters()`
Entity list áp dụng:
- Supplier, Project, Department
- Contract
- ContractTemplate, ContractClause
KHÔNG soft delete (cascade hoặc keep):
- ContractApproval, ContractComment, ContractAttachment — cascade khi Contract xóa
- Permission, MenuItem — cascade khi Role xóa
- ContractCodeSequence — không bao giờ xóa (giữ history seq)
- Identity tables (Users, Roles, ...) — Identity không support soft delete built-in
## 7. Truy vấn tiêu biểu
### Inbox HĐ chờ role của tôi
```sql
-- Tương đương GetMyInboxQuery
SELECT c.Id, c.MaHopDong, c.TenHopDong, c.Phase, c.SlaDeadline, s.Name AS SupplierName, p.Name AS ProjectName
FROM Contracts c
INNER JOIN Suppliers s ON c.SupplierId = s.Id
INNER JOIN Projects p ON c.ProjectId = p.Id
WHERE c.IsDeleted = 0
AND c.Phase IN (/* phase eligible cho role hiện tại */)
ORDER BY c.SlaDeadline ASC;
```
### Dashboard stats
```sql
-- Tổng + active + overdue
SELECT
(SELECT COUNT(*) FROM Contracts WHERE IsDeleted = 0) AS Total,
(SELECT COUNT(*) FROM Contracts WHERE IsDeleted = 0 AND Phase NOT IN (9, 99)) AS Active,
(SELECT COUNT(*) FROM Contracts WHERE IsDeleted = 0 AND Phase NOT IN (9, 99) AND SlaDeadline < GETUTCDATE()) AS Overdue;
-- By phase
SELECT Phase, COUNT(*) FROM Contracts WHERE IsDeleted = 0 GROUP BY Phase;
-- Top 5 NCC
SELECT TOP 5 c.SupplierId, s.Name, COUNT(*) AS Cnt, SUM(c.GiaTri) AS TotalValue
FROM Contracts c
INNER JOIN Suppliers s ON c.SupplierId = s.Id
WHERE c.IsDeleted = 0
GROUP BY c.SupplierId, s.Name
ORDER BY COUNT(*) DESC;
```
### Gen mã HĐ atomic
```sql
BEGIN TRAN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
MERGE ContractCodeSequences AS tgt
USING (SELECT @Prefix AS Prefix) AS src ON tgt.Prefix = src.Prefix
WHEN MATCHED THEN UPDATE SET LastSeq = LastSeq + 1, UpdatedAt = GETUTCDATE()
WHEN NOT MATCHED THEN INSERT (Prefix, LastSeq, UpdatedAt) VALUES (@Prefix, 1, GETUTCDATE());
SELECT LastSeq FROM ContractCodeSequences WHERE Prefix = @Prefix;
COMMIT;
```
(EF Core impl dùng `UPDATE + IF ROWCOUNT = 0 INSERT` thay MERGE — tương đương nhưng an toàn hơn với SERIALIZABLE).
## 8. Migration lịch sử
| # | Migration | Tables added |
|---|---|---|
| 1 | `Init` | 7 Identity tables |
| 2 | `AddMasterData` | Suppliers, Projects, Departments |
| 3 | `AddPermissions` | MenuItems, Permissions |
| 4 | `AddForms` | ContractTemplates, ContractClauses |
| 5 | `AddContractsWorkflow` | Contracts, ContractApprovals, ContractComments, ContractAttachments, ContractCodeSequences |
Tổng: **19 bảng** (+ `__EFMigrationsHistory` hệ thống).
## 9. Liên quan
- [`database-guide.md`](database-guide.md) — conventions + migration workflow + cheatsheet đầy đủ
- [`../architecture.md`](../architecture.md) — layered architecture + data flow
- [`../workflow-contract.md`](../workflow-contract.md) — state machine spec
- [`../flows/`](../flows/) — sequence diagrams

View File

@ -1,6 +1,6 @@
# Gotchas — SOLUTION_ERP # 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. > Bẫy/pitfall đã gặp + cách xử lý. Đọc trước khi debug tương tự để không mất thời gian. Cập nhật liên tục khi gặp bug mới.
## Tech stack constraints (.NET 10 + TS 6 + Vite 8) ## Tech stack constraints (.NET 10 + TS 6 + Vite 8)
@ -8,180 +8,188 @@
**Triệu chứng:** `Unable to resolve service for type 'MediatR.IMediator'``AddMediatR` vẫn chạy nhưng không register IMediator. **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:** Pin `MediatR 12.4.1`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken).
**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 ### 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. **Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger 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:** **Fix:**
- Remove `Microsoft.AspNetCore.OpenApi` khỏi Api - Remove `Microsoft.AspNetCore.OpenApi` khỏi Api
- Downgrade Swashbuckle về `6.9.0` (compatible với OpenApi 1.x) - Downgrade Swashbuckle về `6.9.0`
### 3. TypeScript 6 `erasableSyntaxOnly` cấm `enum` ### 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: **Fix:** Dùng `const + as const + typeof[keyof]` pattern:
```ts ```ts
// ❌ Không được export const SupplierType = { NhaCungCap: 1 } as const
export enum SupplierType { NhaCungCap = 1 }
// ✅ OK
export const SupplierType = { NhaCungCap: 1, NhaThauPhu: 2 } as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType] export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
``` ```
### 4. TypeScript 6 deprecate `baseUrl` ### 4. TypeScript 6 deprecate `baseUrl`
**Triệu chứng:** `TS5101: Option 'baseUrl' is deprecated`. **Fix:** Bỏ `baseUrl` trong tsconfig, chỉ giữ `paths`. Paths resolve relative tsconfig location.
**Fix:** Bỏ `baseUrl` trong `tsconfig.app.json`, chỉ giữ `paths`. Paths resolve relative to tsconfig location. ### 5. Node 22 local vs CI pin 20
### 5. Node 22 local nhưng CI phải pin 20 **Bài học NamGroup:** CI build fail trên Node latest.
**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:** **Fix:**
- `package.json` engines: `">=20"` (min only, không upper bound) - `package.json` engines: `">=20"` (min, không upper)
- `.nvmrc` = `20` (CI dùng) - `.nvmrc` = `20` cho CI
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version-file: '.nvmrc'` hoặc hardcode `'20.x'` - GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version: '20.x'`
## EF Core 10 ## EF Core 10
### 6. Expression tree không support switch expression ### 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 { ... })`. **Triệu chứng:** `CS8514: An expression tree may not contain a switch expression`.
**Fix:** Tách switch ra ngoài, mỗi case gọi query riêng: **Fix:** Tách switch ra ngoài LINQ:
```csharp ```csharp
// ❌
var hasPermission = await query.AnyAsync(p => action switch { "Read" => p.CanRead, ... });
// ✅
var hasPermission = action switch var hasPermission = action switch
{ {
"Read" => await query.AnyAsync(p => p.CanRead), "Read" => await query.AnyAsync(p => p.CanRead),
"Create" => await query.AnyAsync(p => p.CanCreate), "Create" => await query.AnyAsync(p => p.CanCreate),
... _ => false,
}; };
``` ```
### 7. Design-time DbContext resolve fail ### 7. Design-time DbContext resolve fail
**Triệu chứng:** `dotnet ef migrations add``Unable to resolve service for type 'DbContextOptions<ApplicationDbContext>'`. **Triệu chứng:** `dotnet ef migrations add``Unable to resolve service for type 'DbContextOptions<T>'`.
**Fix:** Tạo `IDesignTimeDbContextFactory<ApplicationDbContext>` trong Infrastructure — EF CLI sẽ dùng factory này thay vì chạy full Host. **Fix:** Tạo `IDesignTimeDbContextFactory<ApplicationDbContext>` trong Infrastructure.
### 8. `AddDefaultTokenProviders()` không tồn tại trong `AddIdentityCore` ### 8. `AddDefaultTokenProviders()` không trong `AddIdentityCore`
**Triệu chứng:** Build fail `IdentityBuilder does not contain AddDefaultTokenProviders`. **Fix:** Bỏ call nếu chưa cần password reset. Khi cần, chuyển `AddIdentity` hoặc add package `Microsoft.AspNetCore.Identity.UI`.
**Nguyên nhân:** `AddIdentityCore` là minimal variant, không include token providers (password reset, email confirmation). ## OpenXml / ClosedXML
**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`. ### 9. `SpaceProcessingModeValues` namespace
## OpenXml / ClosedXML (Form Engine Phase 2) **Fix:** Full path + wrap `EnumValue<>`:
### 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 ```csharp
textElement.Space = new DocumentFormat.OpenXml.EnumValue<DocumentFormat.OpenXml.SpaceProcessingModeValues>( textElement.Space = new DocumentFormat.OpenXml.EnumValue<
DocumentFormat.OpenXml.SpaceProcessingModeValues>(
DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve); DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
``` ```
### 10. Placeholder `{{field}}` bị split giữa 2 `<w:t>` elements ### 10. Placeholder `{{field}}` bị split runs
**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. **Vấn đề:** Word hay split text thành nhiều `<w:t>` — placeholder miss khi regex replace.
**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. **Fix:** Iterate Paragraph, gom text tất cả `<w:t>` → replace → gán lại text đầu + clear rest. Đã implement trong `DocxRenderer`.
### 11. Word COM SaveAs PowerShell type conversion error ### 11. Word COM `SaveAs` PowerShell type conversion
**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`:
**Fix:** Dùng `SaveAs2` (không đòi ref parameters):
```powershell ```powershell
$doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault (.docx) $doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault
``` ```
### 12. Word COM stuck/hang ### 12. Word COM stuck
**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:** **Fix:**
- Set `$word.DisplayAlerts = 0` trước khi mở file - `$word.DisplayAlerts = 0`
- Nếu stuck → `Get-Process WINWORD | Stop-Process -Force` - Nếu stuck → `Get-Process WINWORD | Stop-Process -Force`
- Fallback: dùng LibreOffice headless `soffice --headless --convert-to docx file.doc` - Fallback: LibreOffice headless `soffice --headless --convert-to docx`
## System.Text.Json (ASP.NET Core 10) ## System.Text.Json
### 13. Record constructor deserialization fail với Unicode ### 13. Record deserialization fail với Unicode qua CLI
**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". **Triệu chứng:** POST JSON tiếng Việt từ Windows bash/curl → 400 "JSON value could not be converted".
**Nguyên nhân:** Encoding CLI không đúng UTF-8 khi pass vào `curl -d '{...}'`. **Fix:** Dùng `curl --data-binary @file.json` (file UTF-8). API handle đúng qua axios/Swagger.
**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 ## File operations
### 14. Dropbox sync có thể "revert" file đang edit ### 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ũ. **Triệu chứng:** Write thành công, build pass, runtime chạy code 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 quan trọng → Read lại verify. Nếu revert → Write lại.
**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 rules
### 15. `.gitignore` wwwroot/uploads/ vs wwwroot/templates/ - `wwwroot/uploads/`**ignore** (user files)
- `wwwroot/templates/`**commit** (source of truth)
**Quy ước:** - `wwwroot/exports/` → ignore (temp)
- `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 ## Dev workflow
### 16. Port conflict khi restart dev server ### 16. Port conflict khi restart dev server
**Triệu chứng:** `npm run dev` fail với `Port 8082 is in use`. **Fix:** `TaskStop` task cũ, hoặc `netstat -ano | findstr :8082``taskkill /F /PID <pid>`.
**Nguyên nhân:** Background task trước chưa kill hẳn. ### 17. EF migration 3-file rule
**Fix:** `TaskStop` task cũ, hoặc kill process listening port: `netstat -ano | findstr :8082``taskkill /F /PID <pid>`. Mỗi migration tạo: `{name}.cs` + `{name}.Designer.cs` + `ApplicationDbContextModelSnapshot.cs`. Commit đủ 3.
### 17. EF migration tạo 3 file, COMMIT ĐỦ ## Claude Code harness quirks
**Quy tắc:** Mỗi migration tạo: ### 18. Edit tool "File not read" sau system-reminder
- `{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. **Triệu chứng:** Edit file vừa Read, lỗi "File has not been read yet".
## Checklist khi gặp bug mới **Nguyên nhân:** System reminder interrupt reset read-cache.
1. Build có pass không? Nếu fail → check using + package version **Fix:** Read lại file rồi Write/Edit. Hoặc dùng Write (ghi đè full) thay Edit.
2. Log API startup có error ẩn không? → `tail` output file
3. File đã persist đúng chưa? → `head -5` verify ### 19. Build pass nhưng DI thiếu registration
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`) **Triệu chứng:** `dotnet build` → 0 errors nhưng runtime throw `Unable to resolve service`.
6. Nếu là EF expression tree error → tách logic ra ngoài query
**Nguyên nhân:** C# compiler chỉ check type, không check DI graph.
**Fix:** Sau thêm interface mới + impl → luôn add `services.AddScoped<IX, X>()` trong `DependencyInjection.cs`. Test API start up là OK check.
## Contract workflow
### 20. Mã HĐ gen 2 lần sau reject → approve lại
**Fix:** Check `if (contract.MaHopDong is null)` trước khi gen. Đã implement trong `ContractWorkflowService.TransitionAsync`.
### 21. BE adjacency vs FE NEXT_PHASES sync
**Triệu chứng:** FE hiển thị nút chuyển phase, click → BE 403.
**Nguyên nhân:** FE `NEXT_PHASES` map phải khớp BE `Transitions` dict.
**Fix:** Khi đổi adjacency BE → sync FE `src/pages/contracts/ContractDetailPage.tsx` ngay lập tức (cả 2 app).
### 22. Race condition gen mã HĐ khi 2 user cùng transition tới DangDongDau
**Fix:** `IsolationLevel.Serializable` transaction trong `ContractCodeGenerator`. Không skip.
## Permission matrix
### 23. Permission update không real-time
**Triệu chứng:** Admin tick permission cho role X → user X vẫn thấy menu cũ.
**Nguyên nhân:** FE cache menu trong `localStorage`, không auto refetch.
**Fix:** User phải logout/login. Phase 3 iteration 2 có thể thêm SignalR push "permission-changed" → FE tự refetch `/menus/me`.
### 24. MenuKey typo — không check type
**Fix:** Luôn dùng `MenuKeys.Contracts` const (BE) + `MenuKeys.Contracts` (FE `menuKeys.ts`). Không hardcode string.
## Checklist debug bug mới
1. Build pass không? → fail → check using + package version compat
2. DI register đủ? → runtime error "Unable to resolve" → add `AddScoped/Singleton`
3. API log startup có error ẩn? → `tail` output file
4. File đã persist đúng chưa? → `head -5` verify sau Write
5. Nếu package exotic → thử downgrade về stable trước
6. Nếu TS error → check `erasableSyntaxOnly`, `verbatimModuleSyntax`
7. Nếu EF expression tree → tách logic ra ngoài query
8. Nếu Unicode CLI → dùng file payload
9. Nếu workflow 403 → check FE NEXT_PHASES sync BE

300
docs/rules.md Normal file
View File

@ -0,0 +1,300 @@
# Rules — Coding Conventions SOLUTION_ERP
> Nguồn duy nhất cho rules. Bất cứ commit nào vi phạm cần justify trong message.
## 1. Ngôn ngữ
| Thành phần | Ngôn ngữ |
|---|---|
| UI FE | **100% tiếng Việt** |
| Code (.cs/.ts) | **English** (tên biến/hàm/class) |
| Comment code | Tiếng Anh hoặc Việt — miễn rõ |
| Tên DB table + column | **English PascalCase** (Contracts, SupplierId) |
| Business label | Tiếng Việt (vd `"Đang soạn thảo"` cho phase 2) |
| Commit message | English hoặc Việt — preferred English |
| Doc MD (docs/) | **Tiếng Việt** (audience là người Việt) |
## 2. Backend — .NET 10
### 2.1 Clean Architecture dependency rule
```
Api → Application ← Domain
Infrastructure ──┘
```
- **Domain:** pure, chỉ dùng `Microsoft.Extensions.Identity.Stores` (cần cho IdentityUser) — không depend ASP.NET Core framework
- **Application:** depend Domain; CQRS handlers + interfaces + DTOs + validators
- **Infrastructure:** depend Application + Domain; impl interfaces (EF, JWT, rendering, services)
- **Api:** depend Application + Infrastructure; Controllers + middleware + composition root
### 2.2 CQRS + MediatR pattern
- 1 feature = 1 file `{Verb}{Entity}Command.cs` / `{Verb}{Entity}Query.cs`
- File chứa: Command record + Validator + Handler (cùng 1 file cho compact)
- Dài thì tách: `{Feature}/Commands/{Create}/...`
**Command naming:**
```
Create{Entity}Command // tạo mới
Update{Entity}Command // update full
Patch{Entity}FieldCommand // update 1 field
Delete{Entity}Command // soft delete (default)
{Action}{Entity}Command // action domain-specific (vd TransitionContract)
```
**Query naming:**
```
Get{Entity}Query // single by Id
List{Entity}sQuery // list với paging
{Entity}{Purpose}Query // custom query (vd GetMyInboxQuery)
```
### 2.3 Validation
- FluentValidation (KHÔNG dùng DataAnnotation)
- Validator = class trong cùng file command: `{Command}Validator : AbstractValidator<{Command}>`
- Pipeline: `ValidationBehavior` auto-chạy trước Handler — throw `ValidationException` → 400 ProblemDetails
### 2.4 Error handling
- KHÔNG try-catch ở Controller
- Throw domain exception: `NotFoundException`, `ForbiddenException`, `UnauthorizedException`, `ConflictException`, `ValidationException`
- `GlobalExceptionMiddleware` map exception → HTTP status + ProblemDetails JSON
- Logging: Serilog structured, không `Console.WriteLine`
### 2.5 Async/await
- Tất cả I/O (DB, HTTP, file) PHẢI async
- Truyền `CancellationToken` mọi async call
- Tên method async: suffix `Async` (trừ ASP.NET action signature)
### 2.6 Entity rules
- Mọi entity mới extend `BaseEntity` (Id Guid + audit) hoặc `AuditableEntity` (+ soft delete)
- PK: `Id` type `Guid`, default `Guid.NewGuid()` trong constructor
- FK: `{Entity}Id` type `Guid`
- Enum: `HasConversion<int>()` trong EF config
- String: luôn `HasMaxLength(n)` trong EF config — không để `nvarchar(max)` trừ khi là JSON/rich text
- Money: `decimal` + `HasPrecision(18, 2)`
- DateTime: luôn UTC, lấy từ `IDateTime.UtcNow` (không `DateTime.Now`)
- Query filter soft delete: `HasQueryFilter(x => !x.IsDeleted)` cho AuditableEntity
### 2.7 DI registration
- Singleton: stateless services (DateTimeService, FormRenderer)
- Scoped: per-request services (JwtTokenService, ContractWorkflowService, DbContext)
- Transient: lightweight utility (hiếm dùng)
### 2.8 Package version pinning
- **KHÔNG dùng `*` hoặc `latest`** — luôn pin version cụ thể
- Check compatibility với .NET 10 trước khi add. Gotchas đã biết:
- MediatR 14 → lỗi IMediator DI, **pin 12.4.1**
- Swashbuckle 10 → conflict OpenApi 2, **pin 6.9.0**
- Xem [`gotchas.md`](gotchas.md) cho full list
## 3. Frontend — React 19 + Vite 8 + TS 6
### 3.1 Named export, không default export
```tsx
// ✅
export function SuppliersPage() { }
export const MyConstant = 1
// ❌
export default function SuppliersPage() { }
// Exception: App.tsx dùng default export (Vite convention)
```
### 3.2 erasableSyntaxOnly (Vite 8)
KHÔNG dùng `enum` — dùng const-object pattern:
```ts
// ❌
export enum SupplierType { NhaCungCap = 1 }
// ✅
export const SupplierType = { NhaCungCap: 1, NhaThauPhu: 2 } as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
```
### 3.3 Path alias
- Dùng `@/` cho absolute import: `import { api } from '@/lib/api'`
- Config ở `vite.config.ts` + `tsconfig.app.json` (chỉ `paths`, không `baseUrl`)
### 3.4 Data fetching
- **TanStack Query** cho mọi API call (KHÔNG useState + useEffect thủ công)
- `useQuery` cho GET, `useMutation` cho POST/PUT/DELETE
- `invalidateQueries` sau mutation thành công
### 3.5 API calls
- Qua `api` instance (`src/lib/api.ts`) — đã wire axios interceptor JWT + 401 redirect
- Base URL: `/api` (Vite proxy → `:5443`)
### 3.6 Permission guard
```tsx
<PermissionGuard menuKey="Contracts" action="Update">
<Button>Sửa</Button>
</PermissionGuard>
// Hook
const { can } = usePermission()
if (!can('Contracts', 'Update')) return null
```
Nhớ: FE guard chỉ là UX. BE policy `[Authorize(Policy = "Contracts.Update")]` là source of truth.
### 3.7 Component naming
- PascalCase cho file: `SuppliersPage.tsx`, `DataTable.tsx`
- kebab-case cho utility: `api.ts`, `cn.ts`
- React component = PascalCase
- Hook: prefix `use`, ví dụ `usePermission`
### 3.8 Toast + error
- Dùng `sonner` (đã wire `<Toaster>` ở App)
- `toast.success('...')`, `toast.error(getErrorMessage(err))`
- Error helper: `src/lib/apiError.ts` extract từ ProblemDetails
### 3.9 Duplicate giữa 2 FE
**CÓ CHỦ ĐÍCH.** Mỗi app (`fe-admin` + `fe-user`) là standalone — copy shared code giữa 2 app thay vì extract ra package chung. Lý do: 2 app có UX rất khác (admin mission-critical, user workflow-heavy), evolution độc lập.
Khi share code: `cp` file từ app này sang app kia. Đồng bộ tay khi có breaking change.
## 4. Database
### 4.1 Convention
| Item | Rule |
|---|---|
| Schema | 1 schema duy nhất: `dbo` |
| Table | PascalCase tiếng Anh, plural: `Contracts`, `Suppliers` |
| Column | PascalCase: `FullName`, `CreatedAt` |
| PK | `Id``uniqueidentifier` |
| FK | `{Entity}Id``uniqueidentifier` |
| Index | `IX_{Table}_{Col}` |
| Unique | `UX_{Table}_{Col}` |
Full: xem [`database/database-guide.md`](database/database-guide.md).
### 4.2 Migration
- Một commit = 1 migration (trừ khi nhiều change nhỏ trong cùng feature)
- Naming PascalCase: `AddMasterData`, `AddContractsWorkflow`
- Commit đủ 3 file: `{Name}.cs`, `{Name}.Designer.cs`, `ApplicationDbContextModelSnapshot.cs`
## 5. Git & commits
### 5.1 Branch
- `main` — deploy branch
- Feature branch (nếu nhiều người): `feature/phase{N}-{feature}`
### 5.2 Commit format
```
[CLAUDE] <scope>: <imperative message>
<body optional — chi tiết what + why>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
**Scope:** `Contract` · `Form` · `Workflow` · `Supplier` · `Auth` · `Admin` · `Api` · `App` · `Domain` · `Infra` · `FE-Admin` · `FE-User` · `Docs` · `CICD` · `Scripts` · `Report` · `Dashboard`
### 5.3 Một commit nên
- Build pass (BE + FE)
- Không leave WIP nửa chừng (trừ khi commit message nói rõ)
- Test E2E với endpoint mới (curl trong session log)
### 5.4 Không commit
- `.env`, `appsettings.*.Local.json`
- `node_modules/`, `bin/`, `obj/`
- `wwwroot/uploads/` (user files)
- `SolutionErp_Design` database (tạm cho EF CLI)
## 6. Docs
### 6.1 Khi nào viết session log
- Session có commit thay đổi code → PHẢI tạo `docs/changelog/sessions/YYYY-MM-DD-HHMM-{topic}.md`
- Session chỉ Q&A, không đụng file → không cần
### 6.2 Session log format
```markdown
# Session YYYY-MM-DD HH:MM — <topic>
**Dev:** Claude / Copilot / <name>
**Duration:** ~Nh
**Base commit:** <sha>
## Làm được
### Chunk X — <name>
- bullet list deliverable
## E2E verified
<curl output hoặc screenshot>
## Bug gặp + fix
| Bug | Fix |
## Docs updates
## Handoff
## Thông số cumulative
```
### 6.3 Update khi thêm feature
- Thêm entity → update [`database/database-guide.md`](database/database-guide.md) schema section
- Thêm endpoint → update [`flows/`](flows/) relevant flow
- Thêm bug fix lặp lại → update [`gotchas.md`](gotchas.md)
- Thêm pattern → update skill tương ứng ở `.claude/skills/`
- Phase đổi → update [`STATUS.md`](STATUS.md) + [`HANDOFF.md`](HANDOFF.md) + [`changelog/migration-todos.md`](changelog/migration-todos.md)
## 7. Testing (hiện chưa có test tự động)
**Phase 1-4 — manual test:**
- E2E qua curl/Postman trong mỗi session log
- Build + TS check mỗi commit
**Phase 5 — tự động (chưa làm):**
- Unit test: xUnit cho BE, Vitest cho FE
- Integration test: TestContainer SQL Server cho BE
- E2E test: Playwright cho FE
## 8. Security baseline
- HTTPS everywhere prod
- JWT Secret trong user-secrets / env var (không commit appsettings)
- Password hash: PBKDF2 (Identity default)
- CORS whitelist chỉ 2 FE origin
- SQL injection: EF Core parameterized (không raw SQL trừ khi phải)
- Audit log: mọi ghi phải log qua `ContractApproval` hoặc `AuditLog` (future)
- Rate limit: `/api/auth/login` 5 req/min/IP (chưa implement — Phase 5)
## 9. Liên quan
- [`CLAUDE.md`](../CLAUDE.md) — entry point cho AI agent
- [`architecture.md`](architecture.md) — kiến trúc chi tiết (layered, CQRS, state machine)
- [`gotchas.md`](gotchas.md) — pitfalls đã gặp
- [`database/database-guide.md`](database/database-guide.md) — DB conventions
- [`flows/`](flows/) — sequence diagram per feature

View File

@ -12,6 +12,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { FormsPage } from '@/pages/forms/FormsPage' import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { ReportsPage } from '@/pages/ReportsPage'
function App() { function App() {
return ( return (
@ -34,6 +35,7 @@ function App() {
<Route path="/forms" element={<FormsPage />} /> <Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} /> <Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} /> <Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route <Route
path="*" path="*"

View File

@ -0,0 +1,44 @@
import { cn } from '@/lib/cn'
type BarDatum = {
label: string
value: number
sublabel?: string
}
type Props = {
data: BarDatum[]
maxBars?: number
formatValue?: (v: number) => string
barClassName?: string
}
// Horizontal bar chart using plain Tailwind — không cần thư viện ngoài.
export function BarChart({ data, maxBars = 12, formatValue = v => v.toLocaleString('vi-VN'), barClassName }: Props) {
const shown = data.slice(0, maxBars)
const max = Math.max(1, ...shown.map(d => d.value))
return (
<div className="space-y-1.5">
{shown.length === 0 && <div className="py-6 text-center text-sm text-slate-400">Chưa dữ liệu</div>}
{shown.map((d, i) => {
const pct = (d.value / max) * 100
return (
<div key={i} className="group">
<div className="flex items-center justify-between text-xs text-slate-600">
<span className="truncate pr-2">{d.label}</span>
<span className="font-mono tabular-nums">{formatValue(d.value)}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className={cn('h-full rounded-full bg-brand-500 transition-all group-hover:bg-brand-600', barClassName)}
style={{ width: `${pct}%` }}
/>
</div>
{d.sublabel && <div className="mt-0.5 text-[10px] text-slate-400">{d.sublabel}</div>}
</div>
)
})}
</div>
)
}

View File

@ -1,28 +1,137 @@
import { useAuth } from '@/contexts/AuthContext' import { useQuery } from '@tanstack/react-query'
import { FileText, CheckCircle2, AlertTriangle, TrendingUp, Coins } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { BarChart } from '@/components/BarChart'
import { PhaseBadge } from '@/components/PhaseBadge'
import { api } from '@/lib/api'
import type { DashboardStats } from '@/types/reports'
const fmtMoney = (v: number) => {
if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + ' tỷ'
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + ' tr'
return v.toLocaleString('vi-VN')
}
function StatCard({ icon: Icon, label, value, hint, tone = 'default' }: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
hint?: string
tone?: 'default' | 'warn' | 'good'
}) {
const toneClass = tone === 'warn' ? 'text-amber-600' : tone === 'good' ? 'text-emerald-600' : 'text-brand-600'
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-500">{label}</div>
<Icon className={`h-4 w-4 ${toneClass}`} />
</div>
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
</div>
)
}
export function DashboardPage() { export function DashboardPage() {
const { user } = useAuth() const stats = useQuery({
queryKey: ['dashboard-stats'],
queryFn: async () => (await api.get<DashboardStats>('/reports/dashboard')).data,
staleTime: 60_000,
})
if (stats.isLoading) {
return ( return (
<div className="p-8"> <div className="p-6">
<h1 className="text-2xl font-bold text-slate-900">Tổng quan</h1> <PageHeader title="Tổng quan" />
<p className="mt-2 text-slate-600"> <div className="grid grid-cols-2 gap-4 md:grid-cols-5">
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '} {[1, 2, 3, 4, 5].map(i => (
<span className="font-mono text-sm">{user?.roles.join(', ')}</span> <div key={i} className="h-24 animate-pulse rounded-lg bg-slate-100" />
</p>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
{[
{ label: 'HĐ đang xử lý', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
{ label: 'HĐ chờ tôi duyệt', value: '—', hint: 'Sẽ hiển thị sau Phase 3' },
{ label: 'Tổng NCC', value: '—', hint: 'Sẽ hiển thị sau Phase 1 đợt 2' },
].map(card => (
<div key={card.label} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="text-xs font-medium text-slate-500">{card.label}</div>
<div className="mt-2 text-3xl font-bold text-slate-900">{card.value}</div>
<div className="mt-1 text-xs text-slate-400">{card.hint}</div>
</div>
))} ))}
</div> </div>
</div> </div>
) )
}
const d = stats.data
if (!d) return null
return (
<div className="p-6">
<PageHeader title="Tổng quan" description="Tình hình HĐ toàn hệ thống — cập nhật real-time khi refresh." />
{/* KPI Cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<StatCard icon={FileText} label="Tổng HĐ" value={d.totalContracts} />
<StatCard icon={TrendingUp} label="HĐ đang xử lý" value={d.activeContracts} tone="good" />
<StatCard icon={AlertTriangle} label="HĐ quá hạn SLA" value={d.overdueContracts} tone="warn" hint="SLA đã trôi" />
<StatCard icon={CheckCircle2} label="Phát hành tháng này" value={d.publishedThisMonth} tone="good" />
<StatCard icon={Coins} label="Tổng giá trị active" value={fmtMoney(d.totalValueActive)} hint="VND" />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* By Phase */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700"> theo phase</h2>
<div className="space-y-2">
{d.byPhase.length === 0 && <div className="py-6 text-center text-sm text-slate-400">Chưa nào</div>}
{d.byPhase
.slice()
.sort((a, b) => b.count - a.count)
.map(p => {
const total = d.byPhase.reduce((s, x) => s + x.count, 0) || 1
const pct = (p.count / total) * 100
return (
<div key={p.phase} className="flex items-center gap-3">
<div className="w-36">
<PhaseBadge phase={p.phase} />
</div>
<div className="h-2 flex-1 rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
</div>
<div className="w-12 text-right font-mono text-xs text-slate-600">{p.count}</div>
</div>
)
})}
</div>
</section>
{/* Monthly value */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700">Giá trị theo tháng (12 tháng gần nhất)</h2>
<BarChart
data={d.monthlyValue.map(m => ({
label: `${String(m.month).padStart(2, '0')}/${m.year}`,
value: m.totalValue,
sublabel: `${m.count}`,
}))}
formatValue={fmtMoney}
/>
</section>
{/* Top suppliers */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top NCC theo số </h2>
<BarChart
data={d.topSuppliers.map(s => ({
label: s.supplierName,
value: s.count,
sublabel: `Tổng ${fmtMoney(s.totalValue)} VND`,
}))}
/>
</section>
{/* Top projects */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top dự án theo số </h2>
<BarChart
data={d.topProjects.map(p => ({
label: p.projectName,
value: p.count,
sublabel: `Tổng ${fmtMoney(p.totalValue)} VND`,
}))}
/>
</section>
</div>
</div>
)
} }

View File

@ -0,0 +1,92 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Download, FileSpreadsheet } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, ContractPhaseLabel } from '@/types/contracts'
export function ReportsPage() {
const [phase, setPhase] = useState('')
const [fromDate, setFromDate] = useState('')
const [toDate, setToDate] = useState('')
const exportMut = useMutation({
mutationFn: async () => {
const params: Record<string, string> = {}
if (phase) params.phase = phase
if (fromDate) params.fromDate = fromDate
if (toDate) params.toDate = toDate
const res = await api.get('/reports/contracts/export', { params, responseType: 'blob' })
const filename = res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'contracts.xlsx'
return { blob: res.data as Blob, filename }
},
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')
},
onError: err => toast.error(getErrorMessage(err)),
})
return (
<div className="p-6">
<PageHeader
title="Báo cáo"
description="Xuất danh sách HĐ ra Excel theo bộ lọc."
/>
<div className="max-w-2xl rounded-lg border border-slate-200 bg-white p-6">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-slate-700">
<FileSpreadsheet className="h-4 w-4" />
Xuất danh sách (.xlsx)
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1.5">
<Label>Phase</Label>
<Select value={phase} onChange={e => setPhase(e.target.value)}>
<option value="">Tất cả phase</option>
{Object.values(ContractPhase).map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Từ ngày</Label>
<Input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Đến ngày</Label>
<Input type="date" value={toDate} onChange={e => setToDate(e.target.value)} />
</div>
</div>
<div className="mt-6 flex gap-2">
<Button onClick={() => exportMut.mutate()} disabled={exportMut.isPending}>
<Download className="h-4 w-4" />
{exportMut.isPending ? 'Đang xuất…' : 'Tải Excel'}
</Button>
</div>
</div>
<div className="mt-6 max-w-2xl rounded-lg border border-dashed border-slate-300 bg-slate-50 p-5 text-sm text-slate-500">
<strong className="text-slate-700">Báo cáo khác (Phase 4 iteration 2):</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li> quá hạn SLA theo phase / role</li>
<li>Biểu đ giá trị theo tháng / dự án</li>
<li>Export Approvals history</li>
<li>Export từng ra PDF</li>
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
export type DashboardStats = {
totalContracts: number
activeContracts: number
overdueContracts: number
publishedThisMonth: number
totalValueActive: number
byPhase: Array<{ phase: number; count: number }>
topSuppliers: Array<{ supplierId: string; supplierName: string; count: number; totalValue: number }>
topProjects: Array<{ projectId: string; projectName: string; count: number; totalValue: number }>
monthlyValue: Array<{ year: number; month: number; totalValue: number; count: number }>
}

View File

@ -0,0 +1,32 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Reports.Commands.ExportContractsToExcel;
using SolutionErp.Application.Reports.Dtos;
using SolutionErp.Application.Reports.Queries.GetDashboardStats;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/reports")]
[Authorize]
public class ReportsController(IMediator mediator) : ControllerBase
{
[HttpGet("dashboard")]
public async Task<ActionResult<DashboardStatsDto>> Dashboard(CancellationToken ct)
=> Ok(await mediator.Send(new GetDashboardStatsQuery(), ct));
[HttpGet("contracts/export")]
public async Task<IActionResult> ExportContracts(
[FromQuery] ContractPhase? phase,
[FromQuery] Guid? supplierId,
[FromQuery] Guid? projectId,
[FromQuery] DateTime? fromDate,
[FromQuery] DateTime? toDate,
CancellationToken ct)
{
var result = await mediator.Send(new ExportContractsToExcelCommand(phase, supplierId, projectId, fromDate, toDate), ct);
return File(result.Content, result.ContentType, result.FileName);
}
}

View File

@ -0,0 +1,20 @@
using MediatR;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Reports.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Reports.Commands.ExportContractsToExcel;
public record ExportContractsToExcelCommand(
ContractPhase? Phase = null,
Guid? SupplierId = null,
Guid? ProjectId = null,
DateTime? FromDate = null,
DateTime? ToDate = null) : IRequest<RenderResult>;
public class ExportContractsToExcelCommandHandler(IContractExcelExporter exporter)
: IRequestHandler<ExportContractsToExcelCommand, RenderResult>
{
public Task<RenderResult> Handle(ExportContractsToExcelCommand request, CancellationToken ct)
=> exporter.ExportAsync(request.Phase, request.SupplierId, request.ProjectId, request.FromDate, request.ToDate, ct);
}

View File

@ -0,0 +1,19 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Reports.Dtos;
public record DashboardStatsDto(
int TotalContracts,
int ActiveContracts, // chưa ở final phase (DaPhatHanh hoặc TuChoi)
int OverdueContracts, // SlaDeadline < UtcNow
int PublishedThisMonth, // DaPhatHanh trong tháng này
decimal TotalValueActive, // sum(GiaTri) của ActiveContracts
List<PhaseCountDto> ByPhase,
List<SupplierCountDto> TopSuppliers, // top 5 NCC theo số HĐ
List<ProjectCountDto> TopProjects, // top 5 dự án theo số HĐ
List<MonthlyValueDto> MonthlyValue); // 12 tháng gần nhất: tháng → tổng giá trị HĐ tạo
public record PhaseCountDto(ContractPhase Phase, int Count);
public record SupplierCountDto(Guid SupplierId, string SupplierName, int Count, decimal TotalValue);
public record ProjectCountDto(Guid ProjectId, string ProjectName, int Count, decimal TotalValue);
public record MonthlyValueDto(int Year, int Month, decimal TotalValue, int Count);

View File

@ -0,0 +1,73 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Reports.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Reports.Queries.GetDashboardStats;
public record GetDashboardStatsQuery : IRequest<DashboardStatsDto>;
public class GetDashboardStatsQueryHandler(IApplicationDbContext db, IDateTime dateTime)
: IRequestHandler<GetDashboardStatsQuery, DashboardStatsDto>
{
public async Task<DashboardStatsDto> Handle(GetDashboardStatsQuery request, CancellationToken ct)
{
var now = dateTime.UtcNow;
var monthStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var earliest = now.AddMonths(-11);
var earliestMonth = new DateTime(earliest.Year, earliest.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var total = await db.Contracts.AsNoTracking().CountAsync(ct);
var active = await db.Contracts.AsNoTracking()
.CountAsync(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi, ct);
var overdue = await db.Contracts.AsNoTracking()
.CountAsync(c => c.SlaDeadline != null && c.SlaDeadline < now
&& c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi, ct);
var publishedThisMonth = await db.Contracts.AsNoTracking()
.CountAsync(c => c.Phase == ContractPhase.DaPhatHanh && c.UpdatedAt != null && c.UpdatedAt >= monthStart, ct);
var totalValueActive = await db.Contracts.AsNoTracking()
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
.SumAsync(c => (decimal?)c.GiaTri, ct) ?? 0m;
var byPhase = await db.Contracts.AsNoTracking()
.GroupBy(c => c.Phase)
.Select(g => new PhaseCountDto(g.Key, g.Count()))
.ToListAsync(ct);
var topSuppliers = await (from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
group new { c, s } by new { c.SupplierId, s.Name } into g
orderby g.Count() descending
select new SupplierCountDto(g.Key.SupplierId, g.Key.Name, g.Count(), g.Sum(x => x.c.GiaTri)))
.Take(5)
.ToListAsync(ct);
var topProjects = await (from c in db.Contracts.AsNoTracking()
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
group new { c, p } by new { c.ProjectId, p.Name } into g
orderby g.Count() descending
select new ProjectCountDto(g.Key.ProjectId, g.Key.Name, g.Count(), g.Sum(x => x.c.GiaTri)))
.Take(5)
.ToListAsync(ct);
var monthlyRaw = await db.Contracts.AsNoTracking()
.Where(c => c.CreatedAt >= earliestMonth)
.GroupBy(c => new { c.CreatedAt.Year, c.CreatedAt.Month })
.Select(g => new { g.Key.Year, g.Key.Month, TotalValue = g.Sum(c => c.GiaTri), Count = g.Count() })
.ToListAsync(ct);
// Fill 12 tháng liên tục (kể cả tháng không có data)
var monthly = new List<MonthlyValueDto>();
for (int i = 0; i < 12; i++)
{
var d = earliestMonth.AddMonths(i);
var row = monthlyRaw.FirstOrDefault(r => r.Year == d.Year && r.Month == d.Month);
monthly.Add(new MonthlyValueDto(d.Year, d.Month, row?.TotalValue ?? 0m, row?.Count ?? 0));
}
return new DashboardStatsDto(
total, active, overdue, publishedThisMonth, totalValueActive,
byPhase, topSuppliers, topProjects, monthly);
}
}

View File

@ -0,0 +1,15 @@
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Reports.Services;
public interface IContractExcelExporter
{
Task<RenderResult> ExportAsync(
ContractPhase? phase,
Guid? supplierId,
Guid? projectId,
DateTime? fromDate,
DateTime? toDate,
CancellationToken ct = default);
}

View File

@ -5,11 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services; using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services; using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Reports.Services;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms; using SolutionErp.Infrastructure.Forms;
using SolutionErp.Infrastructure.Identity; using SolutionErp.Infrastructure.Identity;
using SolutionErp.Infrastructure.Persistence; using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors; using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Reports;
using SolutionErp.Infrastructure.Services; using SolutionErp.Infrastructure.Services;
namespace SolutionErp.Infrastructure; namespace SolutionErp.Infrastructure;
@ -26,6 +28,7 @@ public static class DependencyInjection
services.AddSingleton<IFormRenderer, FormRenderer>(); services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>(); services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>(); services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<AuditingInterceptor>(); services.AddScoped<AuditingInterceptor>();

View File

@ -0,0 +1,109 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Reports.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Reports;
public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime) : IContractExcelExporter
{
private static readonly Dictionary<ContractPhase, string> PhaseLabel = new()
{
[ContractPhase.DangChon] = "Đang chọn NCC",
[ContractPhase.DangSoanThao] = "Đang soạn thảo",
[ContractPhase.DangGopY] = "Đang góp ý",
[ContractPhase.DangDamPhan] = "Đang đàm phán",
[ContractPhase.DangInKy] = "Đang in ký",
[ContractPhase.DangKiemTraCCM] = "CCM kiểm tra",
[ContractPhase.DangTrinhKy] = "Đang trình ký",
[ContractPhase.DangDongDau] = "Đang đóng dấu",
[ContractPhase.DaPhatHanh] = "Đã phát hành",
[ContractPhase.TuChoi] = "Từ chối",
};
private static readonly Dictionary<ContractType, string> TypeLabel = new()
{
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
[ContractType.HopDongNhaCungCap] = "HĐ NCC",
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc DV",
};
public async Task<RenderResult> ExportAsync(
ContractPhase? phase, Guid? supplierId, Guid? projectId,
DateTime? fromDate, DateTime? toDate, CancellationToken ct = default)
{
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
select new { c, s, p };
if (phase is not null) q = q.Where(x => x.c.Phase == phase);
if (supplierId is not null) q = q.Where(x => x.c.SupplierId == supplierId);
if (projectId is not null) q = q.Where(x => x.c.ProjectId == projectId);
if (fromDate is not null) q = q.Where(x => x.c.CreatedAt >= fromDate);
if (toDate is not null) q = q.Where(x => x.c.CreatedAt < toDate);
var rows = await q.OrderByDescending(x => x.c.CreatedAt).ToListAsync(ct);
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Contracts");
// Header
var headers = new[] { "STT", "Mã HĐ", "Tên HĐ", "Loại", "Phase", "NCC", "Dự án", "Giá trị (VND)", "SLA", "Ngày tạo" };
for (int i = 0; i < headers.Length; i++)
ws.Cell(1, i + 1).Value = headers[i];
var headerRange = ws.Range(1, 1, 1, headers.Length);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = XLColor.LightBlue;
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
// Data
for (int i = 0; i < rows.Count; i++)
{
var r = rows[i];
var rowIdx = i + 2;
ws.Cell(rowIdx, 1).Value = i + 1;
ws.Cell(rowIdx, 2).Value = r.c.MaHopDong ?? "—";
ws.Cell(rowIdx, 3).Value = r.c.TenHopDong ?? "—";
ws.Cell(rowIdx, 4).Value = TypeLabel.GetValueOrDefault(r.c.Type, "?");
ws.Cell(rowIdx, 5).Value = PhaseLabel.GetValueOrDefault(r.c.Phase, "?");
ws.Cell(rowIdx, 6).Value = r.s.Name;
ws.Cell(rowIdx, 7).Value = r.p.Name;
ws.Cell(rowIdx, 8).Value = r.c.GiaTri;
ws.Cell(rowIdx, 8).Style.NumberFormat.Format = "#,##0";
ws.Cell(rowIdx, 9).Value = r.c.SlaDeadline?.ToString("yyyy-MM-dd HH:mm") ?? "—";
ws.Cell(rowIdx, 10).Value = r.c.CreatedAt.ToString("yyyy-MM-dd HH:mm");
}
// Auto-fit
ws.Columns().AdjustToContents();
ws.SheetView.FreezeRows(1);
// Footer summary
if (rows.Count > 0)
{
var sumRow = rows.Count + 3;
ws.Cell(sumRow, 7).Value = "TỔNG:";
ws.Cell(sumRow, 7).Style.Font.Bold = true;
ws.Cell(sumRow, 7).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
ws.Cell(sumRow, 8).FormulaA1 = $"=SUM(H2:H{rows.Count + 1})";
ws.Cell(sumRow, 8).Style.NumberFormat.Format = "#,##0";
ws.Cell(sumRow, 8).Style.Font.Bold = true;
}
using var ms = new MemoryStream();
wb.SaveAs(ms);
var fileName = $"Contracts_{dateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx";
return new RenderResult(
ms.ToArray(),
fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
}