diff --git a/CLAUDE.md b/CLAUDE.md index 080d20b..0d065b2 100644 --- a/CLAUDE.md +++ b/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 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 7afc573..626413a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index d5ca424..4acc71d 100644 --- a/docs/STATUS.md +++ b/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`** diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..15562df --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/changelog/migration-todos.md b/docs/changelog/migration-todos.md index 5f1759a..b40216f 100644 --- a/docs/changelog/migration-todos.md +++ b/docs/changelog/migration-todos.md @@ -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 diff --git a/docs/changelog/sessions/2026-04-21-1430-phase4-report.md b/docs/changelog/sessions/2026-04-21-1430-phase4-report.md new file mode 100644 index 0000000..6185eb4 --- /dev/null +++ b/docs/changelog/sessions/2026-04-21-1430-phase4-report.md @@ -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()` | + +## 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) | diff --git a/docs/database/schema-diagram.md b/docs/database/schema-diagram.md new file mode 100644 index 0000000..2623a21 --- /dev/null +++ b/docs/database/schema-diagram.md @@ -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
Phase=2, SLA=+7d"] + + Transition1[Transition 2→3] + Transition1 --> C2["UPDATE Phase=3
INSERT ContractApprovals"] + + Comment[POST /comments] + Comment --> C3[INSERT ContractComments] + + Transition2[Transition 3→4→5→6→7] + Transition2 --> C4["UPDATE Phase + SlaDeadline
INSERT ContractApprovals"] + + Transition3[Transition 7→8 BOD ký] + Transition3 --> CG["ContractCodeGenerator
SERIALIZABLE tran
UPSERT ContractCodeSequences"] + CG --> C5["UPDATE Contract
SET MaHopDong='FLOCK 01/HĐGK/SOL&PVL/01',
Phase=8"] + + Transition4[Transition 8→9 HRA phát hành] + Transition4 --> C6["UPDATE Phase=9, SlaDeadline=NULL
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 diff --git a/docs/gotchas.md b/docs/gotchas.md index 5533a86..3235b5a 100644 --- a/docs/gotchas.md +++ b/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 ``. Khi đó `RequestHandlerDelegate` là delegate không tham số (v14 có thêm CancellationToken param). +**Fix:** Pin `MediatR 12.4.1`. Khi đó `RequestHandlerDelegate` 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'`. +**Triệu chứng:** `dotnet ef migrations add` → `Unable to resolve service for type 'DbContextOptions'`. -**Fix:** Tạo `IDesignTimeDbContextFactory` trong Infrastructure — EF CLI sẽ dùng factory này thay vì chạy full Host. +**Fix:** Tạo `IDesignTimeDbContextFactory` 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( +textElement.Space = new DocumentFormat.OpenXml.EnumValue< + DocumentFormat.OpenXml.SpaceProcessingModeValues>( DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve); ``` -### 10. Placeholder `{{field}}` bị split giữa 2 `` 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 `` khác nhau → regex replace miss. +**Vấn đề:** Word hay split text thành nhiều `` — placeholder miss khi regex replace. -**Fix (đã implement trong DocxRenderer):** Iterate theo Paragraph, gom text của mọi `` trong cùng paragraph → replace → gán lại vào `` đầu + clear rest. Giữ run style của text đầu. +**Fix:** Iterate Paragraph, gom text tất cả `` → 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 `. -**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 `. +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()` 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 diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..c89211e --- /dev/null +++ b/docs/rules.md @@ -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()` 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 + + + + +// 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 `` ở 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] : + + + +Co-Authored-By: Claude Opus 4.7 (1M context) +``` + +**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 — + +**Dev:** Claude / Copilot / +**Duration:** ~Nh +**Base commit:** + +## Làm được + +### Chunk X — +- bullet list deliverable + +## E2E verified + + + +## 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 diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index dd834b3..b0ee3a4 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> 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 ( +
+ {shown.length === 0 &&
Chưa có dữ liệu
} + {shown.map((d, i) => { + const pct = (d.value / max) * 100 + return ( +
+
+ {d.label} + {formatValue(d.value)} +
+
+
+
+ {d.sublabel &&
{d.sublabel}
} +
+ ) + })} +
+ ) +} diff --git a/fe-admin/src/pages/DashboardPage.tsx b/fe-admin/src/pages/DashboardPage.tsx index a8ad685..c1bafbe 100644 --- a/fe-admin/src/pages/DashboardPage.tsx +++ b/fe-admin/src/pages/DashboardPage.tsx @@ -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 ( +
+
+
{label}
+ +
+
{value}
+ {hint &&
{hint}
} +
+ ) +} export function DashboardPage() { - const { user } = useAuth() + const stats = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: async () => (await api.get('/reports/dashboard')).data, + staleTime: 60_000, + }) + + if (stats.isLoading) { + return ( +
+ +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+ ) + } + + const d = stats.data + if (!d) return null return ( -
-

Tổng quan

-

- Xin chào {user?.fullName}. Vai trò:{' '} - {user?.roles.join(', ')} -

-
- {[ - { 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 => ( -
-
{card.label}
-
{card.value}
-
{card.hint}
+
+ + + {/* KPI Cards */} +
+ + + + + +
+ +
+ {/* By Phase */} +
+

HĐ theo phase

+
+ {d.byPhase.length === 0 &&
Chưa có HĐ nào
} + {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 ( +
+
+ +
+
+
+
+
{p.count}
+
+ ) + })}
- ))} +
+ + {/* Monthly value */} +
+

Giá trị HĐ theo tháng (12 tháng gần nhất)

+ ({ + label: `${String(m.month).padStart(2, '0')}/${m.year}`, + value: m.totalValue, + sublabel: `${m.count} HĐ`, + }))} + formatValue={fmtMoney} + /> +
+ + {/* Top suppliers */} +
+

Top NCC theo số HĐ

+ ({ + label: s.supplierName, + value: s.count, + sublabel: `Tổng ${fmtMoney(s.totalValue)} VND`, + }))} + /> +
+ + {/* Top projects */} +
+

Top dự án theo số HĐ

+ ({ + label: p.projectName, + value: p.count, + sublabel: `Tổng ${fmtMoney(p.totalValue)} VND`, + }))} + /> +
) diff --git a/fe-admin/src/pages/ReportsPage.tsx b/fe-admin/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..60988a1 --- /dev/null +++ b/fe-admin/src/pages/ReportsPage.tsx @@ -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 = {} + 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 ( +
+ + +
+

+ + Xuất danh sách HĐ (.xlsx) +

+
+
+ + +
+
+ + setFromDate(e.target.value)} /> +
+
+ + setToDate(e.target.value)} /> +
+
+ +
+ +
+
+ +
+ Báo cáo khác (Phase 4 iteration 2): +
    +
  • HĐ quá hạn SLA theo phase / role
  • +
  • Biểu đồ giá trị HĐ theo tháng / dự án
  • +
  • Export Approvals history
  • +
  • Export từng HĐ ra PDF
  • +
+
+
+ ) +} diff --git a/fe-admin/src/types/reports.ts b/fe-admin/src/types/reports.ts new file mode 100644 index 0000000..c35e1fb --- /dev/null +++ b/fe-admin/src/types/reports.ts @@ -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 }> +} diff --git a/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs b/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs new file mode 100644 index 0000000..ec86161 --- /dev/null +++ b/src/Backend/SolutionErp.Api/Controllers/ReportsController.cs @@ -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> Dashboard(CancellationToken ct) + => Ok(await mediator.Send(new GetDashboardStatsQuery(), ct)); + + [HttpGet("contracts/export")] + public async Task 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); + } +} diff --git a/src/Backend/SolutionErp.Application/Reports/Commands/ExportContractsToExcel/ExportContractsToExcelCommand.cs b/src/Backend/SolutionErp.Application/Reports/Commands/ExportContractsToExcel/ExportContractsToExcelCommand.cs new file mode 100644 index 0000000..bd75d16 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Commands/ExportContractsToExcel/ExportContractsToExcelCommand.cs @@ -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; + +public class ExportContractsToExcelCommandHandler(IContractExcelExporter exporter) + : IRequestHandler +{ + public Task Handle(ExportContractsToExcelCommand request, CancellationToken ct) + => exporter.ExportAsync(request.Phase, request.SupplierId, request.ProjectId, request.FromDate, request.ToDate, ct); +} diff --git a/src/Backend/SolutionErp.Application/Reports/Dtos/DashboardStatsDto.cs b/src/Backend/SolutionErp.Application/Reports/Dtos/DashboardStatsDto.cs new file mode 100644 index 0000000..d36a067 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Dtos/DashboardStatsDto.cs @@ -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 ByPhase, + List TopSuppliers, // top 5 NCC theo số HĐ + List TopProjects, // top 5 dự án theo số HĐ + List 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); diff --git a/src/Backend/SolutionErp.Application/Reports/Queries/GetDashboardStats/GetDashboardStatsQuery.cs b/src/Backend/SolutionErp.Application/Reports/Queries/GetDashboardStats/GetDashboardStatsQuery.cs new file mode 100644 index 0000000..4ffb91e --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Queries/GetDashboardStats/GetDashboardStatsQuery.cs @@ -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; + +public class GetDashboardStatsQueryHandler(IApplicationDbContext db, IDateTime dateTime) + : IRequestHandler +{ + public async Task 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(); + 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); + } +} diff --git a/src/Backend/SolutionErp.Application/Reports/Services/IContractExcelExporter.cs b/src/Backend/SolutionErp.Application/Reports/Services/IContractExcelExporter.cs new file mode 100644 index 0000000..7fa25ec --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Services/IContractExcelExporter.cs @@ -0,0 +1,15 @@ +using SolutionErp.Application.Forms.Services; +using SolutionErp.Domain.Contracts; + +namespace SolutionErp.Application.Reports.Services; + +public interface IContractExcelExporter +{ + Task ExportAsync( + ContractPhase? phase, + Guid? supplierId, + Guid? projectId, + DateTime? fromDate, + DateTime? toDate, + CancellationToken ct = default); +} diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index 0928700..3a6a5f1 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs b/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs new file mode 100644 index 0000000..8be6acf --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs @@ -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 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 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 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"); + } +}