[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:
16
CLAUDE.md
16
CLAUDE.md
@ -63,15 +63,19 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
| File | Nội dung |
|
||||
|---|---|
|
||||
| [`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/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/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ |
|
||||
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD |
|
||||
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) |
|
||||
| [`docs/gotchas.md`](docs/gotchas.md) | ⭐ 17 bẫy đã gặp — đọc trước khi debug tương tự |
|
||||
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | Brief 5 phút: session trước làm gì + P1 tasks tiếp |
|
||||
| [`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 + migration workflow + cheatsheet |
|
||||
| [`docs/database/schema-diagram.md`](docs/database/schema-diagram.md) | ⭐ ERD + luồng DB + data flow 19 table |
|
||||
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract, form, SLA) |
|
||||
| [`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
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 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?
|
||||
|
||||
@ -11,10 +11,11 @@
|
||||
| 1 Alpha Core đợt 2 (CRUD + Permission) | ✅ Done |
|
||||
| 2 Form Engine MVP | ✅ Done |
|
||||
| 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 |
|
||||
| 4 Report + Polish | 📋 Next |
|
||||
| 5 Production (CI/CD IIS) | 📋 Queue |
|
||||
| **4 Report + Polish MVP (Dashboard + Excel)** | ✅ Done |
|
||||
| 4 Report iteration 2 (SLA report, PDF export) | 📝 Optional |
|
||||
| 5 Production (CI/CD IIS) | 📋 Next |
|
||||
|
||||
## Run nhanh
|
||||
|
||||
@ -31,34 +32,43 @@ cd fe-user && npm run dev # → http://localhost:8080
|
||||
|
||||
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 `/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
|
||||
- **`/forms`** (admin) → render template .docx với JSON data
|
||||
- **`/contracts/{id}`** → click "Duyệt → tiếp" chạy state machine. Phase 8 gen `MaHopDong` RG-001
|
||||
- **`/forms`** → render template .docx
|
||||
- **`/system/permissions`** → ma trận Role × MenuKey
|
||||
- **`/master/suppliers|projects|departments`** → CRUD
|
||||
|
||||
## Cần làm kế tiếp (ưu tiên)
|
||||
|
||||
### A. Phase 4 — Report + Polish (tuần 10-11)
|
||||
### A. Phase 5 — Production (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
|
||||
- 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
|
||||
**Đọc trước:** `docs/changelog/migration-todos.md` section Phase 5.
|
||||
|
||||
### 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`)
|
||||
- [ ] Email notification (MailKit) — pick up phase + SMTP config
|
||||
- [ ] In-app notification (SignalR + badge counter)
|
||||
- [ ] Upload attachment endpoint + FE multipart (store vào `wwwroot/uploads/contracts/{id}/`)
|
||||
- [ ] RowVersion optimistic concurrency (2 user race → 409)
|
||||
- [ ] Render HĐ docx lúc tạo (merge TemplateId + DraftData + ContractClause appendix)
|
||||
### B. Polish iterations (optional — khi rảnh)
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
**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)
|
||||
|
||||
@ -98,42 +108,53 @@ SOLUTION_ERP/
|
||||
│ ├── SolutionErp.Application/
|
||||
│ │ ├── Auth/ Login, Refresh, Me
|
||||
│ │ ├── 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
|
||||
│ │ ├── Master/ Suppliers, Projects, Departments CQRS
|
||||
│ │ └── Permissions/ GetMyMenuTree, matrix upsert
|
||||
│ │ ├── Permissions/ GetMyMenuTree, matrix upsert
|
||||
│ │ └── Reports/ **DashboardStats, ExportContractsToExcel, IContractExcelExporter** ← Phase 4
|
||||
│ ├── SolutionErp.Infrastructure/
|
||||
│ │ ├── Forms/ DocxRenderer, XlsxRenderer, FormRenderer ← Phase 2
|
||||
│ │ ├── Identity/ JwtSettings, JwtTokenService
|
||||
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (**5** now)
|
||||
│ │ └── Services/ DateTimeService, **ContractWorkflowService, ContractCodeGenerator** ← Phase 3
|
||||
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (**5**)
|
||||
│ │ ├── Reports/ **ContractExcelExporter** ← Phase 4
|
||||
│ │ └── Services/ DateTimeService, ContractWorkflowService, ContractCodeGenerator ← Phase 3
|
||||
│ └── SolutionErp.Api/
|
||||
│ ├── 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
|
||||
│ ├── Services/ CurrentUserService, WebHostEnvironmentLocator
|
||||
│ └── wwwroot/templates/ 5 file .docx/.xlsx ← Phase 2
|
||||
├── fe-admin/ (9 page)
|
||||
├── fe-admin/ (11 page)
|
||||
│ └── src/pages/
|
||||
│ ├── LoginPage
|
||||
│ ├── DashboardPage
|
||||
│ ├── DashboardPage ← Phase 4 rewrite (KPI cards + BarChart)
|
||||
│ ├── master/SuppliersPage, ProjectsPage, DepartmentsPage
|
||||
│ ├── system/PermissionsPage
|
||||
│ ├── forms/FormsPage ← Phase 2
|
||||
│ └── contracts/ContractsListPage, ContractDetailPage ← Phase 3
|
||||
│ ├── contracts/ContractsListPage, ContractDetailPage ← Phase 3
|
||||
│ └── ReportsPage ← Phase 4
|
||||
├── fe-user/ (5 page)
|
||||
│ └── src/pages/
|
||||
│ ├── LoginPage
|
||||
│ ├── InboxPage ← 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)
|
||||
└── .claude/skills/ (3 skill — all full spec: contract-workflow, form-engine, permission-matrix)
|
||||
├── docs/ (30 file)
|
||||
│ ├── 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
|
||||
|
||||
```
|
||||
(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
|
||||
54d6c9b — Phase 1.2 CRUD + Permission
|
||||
49a5f57 — Docs database-guide + flows
|
||||
@ -141,7 +162,7 @@ SOLUTION_ERP/
|
||||
25dad7f — Phase 0 scaffold
|
||||
|
||||
Branch: main
|
||||
Remote: chưa (Gitea URL chờ user)
|
||||
Remote: chưa (Gitea URL chờ user — cần cho Phase 5)
|
||||
```
|
||||
|
||||
## Credentials + URLs
|
||||
|
||||
105
docs/STATUS.md
105
docs/STATUS.md
@ -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`.
|
||||
|
||||
**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
|
||||
|
||||
@ -14,72 +14,73 @@ _(không có)_
|
||||
|
||||
| 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 2 Form Engine MVP** — OpenXml + ClosedXML renderer + seed 8 template + FE FormsPage | `5113e4c` |
|
||||
| 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix + FE 4 page | `54d6c9b` |
|
||||
| 2026-04-21 | Claude | **Docs + flows** | `49a5f57` |
|
||||
| 2026-04-21 | Claude | **Phase 1 foundation** — Clean Arch + Identity + JWT + 2 FE + login E2E | `702411f` |
|
||||
| 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 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 2 Form Engine MVP** | `5113e4c` |
|
||||
| 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix | `54d6c9b` |
|
||||
| 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` |
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
- [ ] Email notification (MailKit) khi chuyển phase
|
||||
- [ ] In-app notification (SignalR + badge)
|
||||
- [ ] Upload attachment endpoint + FE multipart
|
||||
- [ ] RowVersion optimistic concurrency
|
||||
- [ ] Render HĐ docx khi tạo (merge ContractClause appendix)
|
||||
- [ ] CI/CD Gitea Actions (`.gitea/workflows/deploy.yml`) deploy IIS
|
||||
- [ ] `scripts/deploy-iis.ps1` stop app pool → xcopy → start
|
||||
- [ ] Windows Server setup: IIS + URL Rewrite + ARR
|
||||
- [ ] HTTPS cert via win-acme
|
||||
- [ ] `appsettings.Production.json` + user secrets
|
||||
- [ ] 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
|
||||
- [ ] Excel export list HĐ (EPPlus/ClosedXML)
|
||||
- [ ] Report quá hạn SLA theo phase/role
|
||||
- [ ] UX polish: skeleton, empty state, error boundary
|
||||
- [ ] Accessibility pass
|
||||
- [ ] Index DB hot query
|
||||
- [ ] User guide docs
|
||||
- [ ] UAT với data thật
|
||||
**Phase 2 iter 2:** convert 3 .doc, field spec JSON + form builder, {{#loop}}, PDF convert, upload template UI
|
||||
**Phase 3 iter 2:** SLA auto-approve job, email/in-app notification, attachment upload, RowVersion, render HĐ khi tạo
|
||||
**Phase 4 iter 2:** SLA overdue report, PDF HĐ export, dashboard user-specific
|
||||
|
||||
### Phase 5 — Production (tuần 12-13)
|
||||
### Quick wins
|
||||
|
||||
- [ ] CI/CD Gitea Actions → IIS deploy
|
||||
- [ ] HTTPS cert + appsettings Production secrets
|
||||
- [ ] Rate limiting + Security audit
|
||||
- [ ] Backup/restore runbook
|
||||
|
||||
### Quick wins (không block)
|
||||
|
||||
- [ ] FE Users management + Roles CRUD
|
||||
- [ ] Filter Inbox theo phase FE
|
||||
- [ ] Refresh token auto (FE axios interceptor)
|
||||
- FE Users management + Roles CRUD (test permission non-admin)
|
||||
- Filter Inbox theo phase FE
|
||||
- FE refresh token auto interceptor
|
||||
|
||||
## 📊 Thông số cumulative
|
||||
|
||||
| | Phase 0 | 1f | 1.2 | 2 | **Phase 3 MVP** |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| BE LOC | 0 | ~400 | ~1500 | ~1900 | **~2700** |
|
||||
| DB tables | 0 | 7 | 12 | 14 | **19** |
|
||||
| API endpoints | 0 | 4 | ~20 | ~23 | **~31** |
|
||||
| Migrations | 0 | 1 | 3 | 4 | **5** |
|
||||
| FE pages | 0 | 2 | 6 | 7 | **14** (9 admin + 5 user) |
|
||||
| Docs | 10 | 13 | 14 | 24 | **26** |
|
||||
| Commits | 1 | 2 | 3 | 5 | **6** (sắp) |
|
||||
| | P0 | P1f | P1.2 | P2 | P3 | **P4** |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | **~3100** |
|
||||
| DB tables | 0 | 7 | 12 | 14 | 19 | **19** |
|
||||
| API endpoints | 0 | 4 | ~20 | ~23 | ~31 | **~33** |
|
||||
| FE pages | 0 | 2 | 6 | 7 | 14 | **16** |
|
||||
| Docs | 10 | 13 | 14 | 24 | 26 | **30** |
|
||||
| Commits | 1 | 2 | 3 | 5 | 6 | **7** (sắp) |
|
||||
|
||||
## 🚨 Blockers / risks
|
||||
|
||||
- ⏳ **Gitea remote URL** — vẫn chờ
|
||||
- ⚠️ **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
|
||||
- ⏳ **Gitea remote URL** — user sẽ cấp khi vào Phase 5
|
||||
- ⚠️ **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
|
||||
|
||||
@ -88,5 +89,5 @@ admin@solutionerp.local / Admin@123456
|
||||
```
|
||||
|
||||
- API: http://localhost:5443 — Swagger `/swagger`
|
||||
- Admin FE: http://localhost:8082
|
||||
- User FE: http://localhost:8080
|
||||
- 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 — Inbox → **`/inbox`**, **`/contracts/new`**, **`/my-contracts`**
|
||||
|
||||
250
docs/architecture.md
Normal file
250
docs/architecture.md
Normal 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
|
||||
@ -158,13 +158,25 @@
|
||||
|
||||
## Phase 4 — Reporting + Polish (T10-11)
|
||||
|
||||
- [ ] Dashboard admin: số HĐ theo phase, top NCC, top dự án, tổng giá trị theo tháng
|
||||
- [ ] Excel export theo bộ lọc (dùng EPPlus)
|
||||
- [ ] Report: HĐ quá hạn SLA bao nhiêu lần theo phase/role
|
||||
- [ ] UX polish: skeleton loader, empty state, error boundary có recovery button
|
||||
### MVP xong (iteration 1)
|
||||
|
||||
- [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
|
||||
- [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
|
||||
- [ ] Dark mode (optional, nếu rảnh)
|
||||
- [ ] Performance: index DB cho query hot (SupplierId, ProjectId, Phase)
|
||||
- [ ] Dark mode
|
||||
- [ ] Performance: explicit index DB cho query hot đã identify
|
||||
- [ ] 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
|
||||
|
||||
|
||||
122
docs/changelog/sessions/2026-04-21-1430-phase4-report.md
Normal file
122
docs/changelog/sessions/2026-04-21-1430-phase4-report.md
Normal 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, Mã HĐ, 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 HĐ, Đang xử lý, 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 HĐ, 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 HĐ ra PDF)
|
||||
- [ ] Dashboard user-specific (HĐ 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 HĐ 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) |
|
||||
358
docs/database/schema-diagram.md
Normal file
358
docs/database/schema-diagram.md
Normal 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
|
||||
198
docs/gotchas.md
198
docs/gotchas.md
@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
|
||||
@ -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.
|
||||
|
||||
**Nguyên nhân:** MediatR v14 (late 2025) refactored, extension methods khác.
|
||||
|
||||
**Fix:** Downgrade `<PackageReference Include="MediatR" Version="12.4.1" />`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken param).
|
||||
**Fix:** Pin `MediatR 12.4.1`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken).
|
||||
|
||||
### 2. Swashbuckle 10.x + Microsoft.OpenApi 2.x breaking change
|
||||
|
||||
**Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger endpoint 404.
|
||||
|
||||
**Nguyên nhân:** `.NET 10` template auto-cài `Microsoft.AspNetCore.OpenApi 10` → pull `Microsoft.OpenApi 2.0` → namespace `Microsoft.OpenApi.Models` đã bị remove.
|
||||
**Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger 404.
|
||||
|
||||
**Fix:**
|
||||
- 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`
|
||||
|
||||
**Triệu chứng:** `TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.` khi dùng `enum`.
|
||||
|
||||
**Nguyên nhân:** Vite 8 scaffold bật `erasableSyntaxOnly: true` — enum sinh runtime code nên bị cấm.
|
||||
|
||||
**Fix:** Dùng `const + as const + typeof[keyof]` pattern:
|
||||
|
||||
```ts
|
||||
// ❌ Không được
|
||||
export enum SupplierType { NhaCungCap = 1 }
|
||||
|
||||
// ✅ OK
|
||||
export const SupplierType = { NhaCungCap: 1, NhaThauPhu: 2 } as const
|
||||
export const SupplierType = { NhaCungCap: 1 } as const
|
||||
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
|
||||
```
|
||||
|
||||
### 4. TypeScript 6 deprecate `baseUrl`
|
||||
|
||||
**Triệu chứng:** `TS5101: Option 'baseUrl' is deprecated`.
|
||||
**Fix:** Bỏ `baseUrl` trong tsconfig, 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, phải downgrade. Dev local dùng Node 22 thoải mái.
|
||||
**Bài học NamGroup:** CI build fail trên Node latest.
|
||||
|
||||
**Fix:**
|
||||
- `package.json` engines: `">=20"` (min only, không upper bound)
|
||||
- `.nvmrc` = `20` (CI dùng)
|
||||
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version-file: '.nvmrc'` hoặc hardcode `'20.x'`
|
||||
- `package.json` engines: `">=20"` (min, không upper)
|
||||
- `.nvmrc` = `20` cho CI
|
||||
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version: '20.x'`
|
||||
|
||||
## EF Core 10
|
||||
|
||||
### 6. Expression tree không support switch expression
|
||||
|
||||
**Triệu chứng:** `CS8514: An expression tree may not contain a switch expression` khi viết `.AnyAsync(p => action switch { ... })`.
|
||||
**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
|
||||
// ❌
|
||||
var hasPermission = await query.AnyAsync(p => action switch { "Read" => p.CanRead, ... });
|
||||
|
||||
// ✅
|
||||
var hasPermission = action switch
|
||||
{
|
||||
"Read" => await query.AnyAsync(p => p.CanRead),
|
||||
"Create" => await query.AnyAsync(p => p.CanCreate),
|
||||
...
|
||||
_ => false,
|
||||
};
|
||||
```
|
||||
|
||||
### 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 có 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)
|
||||
|
||||
### 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<>`:
|
||||
**Fix:** Full path + wrap `EnumValue<>`:
|
||||
|
||||
```csharp
|
||||
textElement.Space = new DocumentFormat.OpenXml.EnumValue<DocumentFormat.OpenXml.SpaceProcessingModeValues>(
|
||||
textElement.Space = new DocumentFormat.OpenXml.EnumValue<
|
||||
DocumentFormat.OpenXml.SpaceProcessingModeValues>(
|
||||
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` (không đòi ref parameters):
|
||||
**Fix:** Dùng `SaveAs2`:
|
||||
|
||||
```powershell
|
||||
$doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault (.docx)
|
||||
$doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault
|
||||
```
|
||||
|
||||
### 12. Word COM stuck/hang
|
||||
|
||||
**Triệu chứng:** Script chạy không xong, process `WINWORD.EXE` còn nhưng CPU idle hoặc high.
|
||||
|
||||
**Nguyên nhân:** Hidden dialog (activation, recovery, template warning) block COM.
|
||||
### 12. Word COM stuck
|
||||
|
||||
**Fix:**
|
||||
- Set `$word.DisplayAlerts = 0` trước khi mở file
|
||||
- `$word.DisplayAlerts = 0`
|
||||
- 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:**
|
||||
- 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
|
||||
**Fix:** Dùng `curl --data-binary @file.json` (file UTF-8). API handle đúng qua axios/Swagger.
|
||||
|
||||
## 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/
|
||||
|
||||
**Quy ước:**
|
||||
- `wwwroot/uploads/` → **ignore** (user-uploaded files, không commit)
|
||||
- `wwwroot/templates/` → **commit** (template files là source of truth, phải version control)
|
||||
- `wwwroot/exports/` → ignore (rendered output, tạm)
|
||||
- `wwwroot/uploads/` → **ignore** (user files)
|
||||
- `wwwroot/templates/` → **commit** (source of truth)
|
||||
- `wwwroot/exports/` → ignore (temp)
|
||||
|
||||
## Dev workflow
|
||||
|
||||
### 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:
|
||||
- `{timestamp}_{Name}.cs` — up/down
|
||||
- `{timestamp}_{Name}.Designer.cs` — model snapshot lúc đó
|
||||
- `ApplicationDbContextModelSnapshot.cs` — current snapshot (update mỗi lần)
|
||||
### 18. Edit tool "File not read" sau system-reminder
|
||||
|
||||
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
|
||||
2. Log API startup có error ẩn không? → `tail` output file
|
||||
3. File đã persist đúng chưa? → `head -5` verify
|
||||
4. Nếu là package compat → thử downgrade về stable (không dùng preview/latest)
|
||||
5. Nếu là TS error exotic → check tsconfig flags (`erasableSyntaxOnly`, `verbatimModuleSyntax`)
|
||||
6. Nếu là EF expression tree error → tách logic ra ngoài query
|
||||
**Fix:** Read lại file rồi Write/Edit. Hoặc dùng Write (ghi đè full) thay Edit.
|
||||
|
||||
### 19. Build pass nhưng DI thiếu registration
|
||||
|
||||
**Triệu chứng:** `dotnet build` → 0 errors nhưng runtime throw `Unable to resolve service`.
|
||||
|
||||
**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
300
docs/rules.md
Normal 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
|
||||
@ -12,6 +12,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
import { ReportsPage } from '@/pages/ReportsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -34,6 +35,7 @@ function App() {
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
44
fe-admin/src/components/BarChart.tsx
Normal file
44
fe-admin/src/components/BarChart.tsx
Normal 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 có 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>
|
||||
)
|
||||
}
|
||||
@ -1,27 +1,136 @@
|
||||
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() {
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Tổng quan" />
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-lg bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = stats.data
|
||||
if (!d) return null
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Tổng quan</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
|
||||
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
|
||||
</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 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">HĐ 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 có HĐ 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ị HĐ 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} HĐ`,
|
||||
}))}
|
||||
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ố HĐ</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ố HĐ</h2>
|
||||
<BarChart
|
||||
data={d.topProjects.map(p => ({
|
||||
label: p.projectName,
|
||||
value: p.count,
|
||||
sublabel: `Tổng ${fmtMoney(p.totalValue)} VND`,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
92
fe-admin/src/pages/ReportsPage.tsx
Normal file
92
fe-admin/src/pages/ReportsPage.tsx
Normal 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 HĐ (.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>HĐ quá hạn SLA theo phase / role</li>
|
||||
<li>Biểu đồ giá trị HĐ theo tháng / dự án</li>
|
||||
<li>Export Approvals history</li>
|
||||
<li>Export từng HĐ ra PDF</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
fe-admin/src/types/reports.ts
Normal file
11
fe-admin/src/types/reports.ts
Normal 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 }>
|
||||
}
|
||||
32
src/Backend/SolutionErp.Api/Controllers/ReportsController.cs
Normal file
32
src/Backend/SolutionErp.Api/Controllers/ReportsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -5,11 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Forms;
|
||||
using SolutionErp.Infrastructure.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Reports;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure;
|
||||
@ -26,6 +28,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
|
||||
services.AddScoped<AuditingInterceptor>();
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user