Compare commits

..

6 Commits

Author SHA1 Message Date
3990066b04 [CLAUDE] Scripts+Skill+Docs: hardening G-084 IPv4/IPv6 port hijack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m53s
Bài học từ VietReport VPS shared (2026-04-23): Next.js app hijack port
3000 IPv4 → Gitea bị đẩy IPv6-only → IIS ARR localhost:3000 resolve
IPv4 first → git.baocaogiaoduc.vn trả homepage VietReport.

Apply 3 rules G-084 preemptively cho SOLUTION_ERP (risk thấp vì API
in-process IIS, nhưng vẫn chuẩn hóa):

1. `scripts/deploy-iis.ps1` — HealthUrl `localhost` → `127.0.0.1`
2. `.claude/skills/iis-deploy-runbook/SKILL.md` — 7 ref localhost →
   127.0.0.1 + section Hardening mới giải thích G-084 + 3 rules + note
   SOLUTION_ERP relevance (risk thấp vì no standalone Kestrel/no ARR
   proxy hiện tại, nhưng tương lai thêm phải tuân)
3. `docs/gotchas.md` — thêm entry #33 G-084 full writeup (triệu chứng,
   root cause, 3 rules, SOLUTION_ERP relevance) + update debug
   checklist

3 rules:
- Reverse-proxy luôn IP literal 127.0.0.1, không localhost
- Backend services bind loopback IPv4 explicit, không 0.0.0.0
- Service dependency cho boot order khi nhiều service cùng port family
2026-04-23 17:34:22 +07:00
aaf03be8d7 [CLAUDE] Docs: chốt Phase 6 — Module Duyệt NCC E2E
Update 5 file:
 - STATUS.md: phase hiện tại + recently done 4 entry + cumulative stats
   (46 tables, ~110 endpoints, 12 migrations, ~26 FE pages)
 - HANDOFF.md: TL;DR + thêm row "Module Duyệt NCC E2E" 
 - changelog/migration-todos.md: thêm Phase 6 section với checklist
   done/optional (PE Workflow admin UI + Attachments + Auto-map
   Details skip MVP)
 - database/schema-diagram.md: Migration 12 row + section 11 "PurchaseEvaluation
   module" full (10 bảng + state machine + kế thừa HĐ flow)
 - changelog/sessions/2026-04-23-2300-purchase-evaluations.md: session
   log đầy đủ (user input + design + 4 commit + stats + skip MVP notes)
2026-04-23 17:01:53 +07:00
a385d70c2e [CLAUDE] App+Api+FE: Kế thừa HĐ từ phiếu Duyệt NCC (Phase 4)
BE:
 - CreateContractFromEvaluationCommand: guard DaDuyet + SelectedSupplier
   + ContractId=null → tạo Contract draft mới với SupplierId/ProjectId/
   DepartmentId kế thừa từ PE. GiaTri = sum(details.thanhTienNganSach).
   DraftData = PE.PaymentTerms. Gen MaHopDong ngay + pin WorkflowDefinitionId
   theo ContractType user chọn. Log Changelog cả 2 bảng (Contract +
   PurchaseEvaluation), link 2 chiều PE.ContractId = contract.Id.
 - ListApprovedPurchaseEvaluationsQuery: DaDuyet + ContractId=null cho
   FE picker.
 - 2 endpoint mới:
   GET  /api/purchase-evaluations/approved-pending-contract
   POST /api/purchase-evaluations/{id}/create-contract

FE:
 - PeDetailTabs InfoTab: nếu Phase=DaDuyet && !ContractId && SelectedSupplierId
   → banner emerald + button "Tạo HĐ từ phiếu" → CreateContractDialog
   (pick ContractType dropdown 7 loại + TenHopDong + bypass CCM flag)
 - Sau khi tạo → navigate /contracts/{newId}
 - Mirror fe-user.

KHÔNG auto-map PE Details → Contract Details per-type (PE schema ≠ 7
ContractType details schemas — user điền lại sau). PE → Contract link
qua FK ContractId cho navigation + history.
2026-04-23 16:58:41 +07:00
a737196b21 [CLAUDE] FE-Admin+FE-User: PurchaseEvaluation pages (3-panel list + tabs detail)
Types + pages + components cho module Duyệt NCC ở cả 2 FE (copy-share).

Pages:
 - PurchaseEvaluationsListPage: 3-panel lg:grid-cols-[340px_1fr_360px]
   * Panel 1: list filter theo type/phase/search + pendingMe inbox mode
   * Panel 2: PeDetailTabs (Thông tin/NCC/Hạng mục/Duyệt/Lịch sử)
   * Panel 3: PeWorkflowPanel với timeline + nextPhase buttons
   * Mobile fallback fullpage /purchase-evaluations/:id
 - PurchaseEvaluationCreatePage: form create/edit header (Type / Tên gói thầu
   / Dự án / Địa điểm / Mô tả / PaymentTerms JSON). Suppliers+Details+Quotes
   thêm sau khi save ở Detail tabs.

Components:
 - PeDetailTabs: 5 tab + dialogs (AddSupplier/EditSupplier/DetailDialog/
   QuoteDialog) + matrix N NCC × M hạng mục clickable cells + select winner
 - PeWorkflowPanel: policy timeline từ BE workflow.activePhases + transition
   confirmation dialog với comment

Routes (cả 2 app):
 - /purchase-evaluations (+ ?type=1|2&pendingMe=1&id=...)
 - /purchase-evaluations/new (+ ?type / ?id để edit)
 - /purchase-evaluations/:id (mobile fullpage)

Menu resolver:
 - Pe_<Code>_List → /purchase-evaluations?type=N
 - Pe_<Code>_Create → /purchase-evaluations/new?type=N
 - Pe_<Code>_Pending → /purchase-evaluations?type=N&pendingMe=1
 - PeWf_<Code> (fe-admin only) → /system/pe-workflows/<code>

Skip MVP: PE Workflow admin designer UI, PE Attachments. TS build pass
cả 2 app.
2026-04-23 16:56:26 +07:00
4678d192e2 [CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService
Application (4 file, ~900 lines):
 - IPurchaseEvaluationWorkflowService + PurchaseEvaluationDtos
 - PurchaseEvaluationFeatures: Create / UpdateDraft / Transition / List /
   Inbox / GetDetail (bundle Suppliers+Details+Quotes+Approvals+Workflow) /
   Delete / ListChangelogs. IDOR filter role-based phase eligibility.
 - PurchaseEvaluationSupplierFeatures: Add / Update / Remove supplier
   (N:M Phiếu × Supplier). Block remove nếu còn Quote FK reference.
 - PurchaseEvaluationDetailFeatures: Add/Update/Delete hạng mục +
   Upsert/Delete Quote + SelectWinner (set SelectedSupplierId).

Infrastructure:
 - PurchaseEvaluationWorkflowService: policy load pinned definition →
   guard role + transition rules. Emit Notification drafter khi
   state-change. Tạo PurchaseEvaluationApproval + Changelog row.

Api:
 - PurchaseEvaluationsController ~15 endpoint: CRUD phiếu, N:M supplier,
   hạng mục CRUD, Quote upsert, SelectWinner, Changelog list.
   Route /api/purchase-evaluations.

DI: đăng ký IPurchaseEvaluationWorkflowService scoped.

Skip MVP: Attachments upload, Admin PeWorkflows designer UI (sẽ phase sau
— framework versioned WF table đã sẵn, designer pattern copy từ HĐ).
2026-04-23 16:43:47 +07:00
2c6f0cabfb [CLAUDE] Domain+Infra: PurchaseEvaluation module — 10 bảng + 2 workflow seed (migration 12)
Module Duyệt NCC (tiền-HĐ): phiếu trình duyệt so sánh giá N NCC × M hạng
mục trước khi ký HĐ. 2 quy trình: A DuyetNcc (3-step: Purchasing→CCM→CEO),
B DuyetNccPhuongAn (5-step: Purchasing→DựÁn→CCM→CEO PA→CEO NCC).

Domain (7 core + 3 workflow admin):
 - PurchaseEvaluation (header, AuditableEntity, pin WorkflowDefinitionId,
   SelectedSupplierId, PaymentTerms JSON, ContractId? FK kế thừa)
 - PurchaseEvaluationSupplier (N:M Phiếu × Supplier + contact + payment term)
 - PurchaseEvaluationDetail (hạng mục + ngân sách, group A.I/A.II/...)
 - PurchaseEvaluationQuote (báo giá per NCC per hạng mục + IsSelected)
 - PurchaseEvaluationApproval (workflow history, reuse ApprovalDecision)
 - PurchaseEvaluationChangelog (audit log, reuse ChangelogAction)
 - PurchaseEvaluationAttachment (file upload — báo giá NCC + spec...)
 - PurchaseEvaluationWorkflowDefinition/Step/StepApprover (config y như HĐ,
   tách table riêng vì Phase là PurchaseEvaluationPhase enum riêng)

Policy:
 - PurchaseEvaluationPolicy record + PurchaseEvaluationPolicies.NccOnly/
   NccWithPlan (default hardcoded) + FromDefinition(def) build runtime policy
   từ DB admin-authored. Default SLA: soạn 3d, step 1-2d, CEO 1d.

EF: 10 configurations với index phase+isDeleted, SupplierId, ProjectId,
SlaDeadline, WorkflowDefinitionId, ContractId. UX index (PeId, SupplierId)
+ (DetailId, SupplierId). HasQueryFilter soft delete cho header.

Migration 12 AddPurchaseEvaluations tạo 10 bảng. Idempotent seed:
 - SeedMenuTreeAsync +13 menu item (Pe_* root + 2 group + 6 action leaf
   + PeWorkflows root + 2 admin leaf)
 - SeedPurchaseEvaluationWorkflowsAsync seed QT-DN-A-v01 + QT-DN-B-v01
2026-04-23 16:37:55 +07:00
51 changed files with 9508 additions and 28 deletions

View File

@ -52,8 +52,8 @@ Internet
**SPA web.config:** 2 FE có `URL Rewrite` rule:
1. HTTP → HTTPS redirect (bắt buộc, CORS whitelist chỉ https)
2. `/api/* → http://localhost:5443/api/*` (ARR reverse proxy)
3. `/hubs/* → http://localhost:5443/hubs/*` (SignalR)
2. `/api/* → http://127.0.0.1:5443/api/*` (ARR reverse proxy)
3. `/hubs/* → http://127.0.0.1:5443/hubs/*` (SignalR)
4. React Router fallback: `/*``/index.html`
## Quick commands
@ -90,8 +90,8 @@ Get-Content "C:\inetpub\apps\SolutionErp\Api\Logs\stdout_*.log" -Tail 30
```powershell
# Từ server
curl http://localhost:5443/health/live
curl http://localhost:5443/health/ready
curl http://127.0.0.1:5443/health/live
curl http://127.0.0.1:5443/health/ready
# Từ ngoài
curl https://api.huypham.vn/health/ready
@ -215,12 +215,12 @@ Xem gotcha #25 (docs/gotchas.md):
### HTTP 502 Bad Gateway (Admin/User → API)
```
1. Check API up: curl http://localhost:5443/health/live
1. Check API up: curl http://127.0.0.1:5443/health/live
- Down → restart API site + check stdout log
2. Check ARR enabled: IIS Manager > server level > Application Request Routing
- "Enable proxy" phải tick
3. Check URL Rewrite rule fe web.config
- action type="Rewrite" url="http://localhost:5443/{R:0}"
- action type="Rewrite" url="http://127.0.0.1:5443/{R:0}"
```
### SignalR 401 (WebSocket connect fail)
@ -296,7 +296,7 @@ scp -r .\fe-user\dist\* user@server:C:/inetpub/apps/SolutionErp/User/
# Trên server:
Restart-WebAppPool -Name "SolutionErp-Api"
curl http://localhost:5443/health/ready
curl http://127.0.0.1:5443/health/ready
```
## Backup + recovery
@ -322,11 +322,42 @@ Restore: xem `docs/guides/runbook.md`.
- [ ] Gitea runner registration token (re-register service)
- [ ] Admin default `Admin@123456` (đổi qua `/system/users` admin UI ngay sau deploy)
## Hardening — IPv4/IPv6 port hijack (G-084 VietReport incident)
**Bài học từ VPS shared với VIETREPORT (2026-04-23):** VietReport team
deploy Next.js app chiếm port 3000 (0.0.0.0 bind) khiến Gitea bị đẩy
sang IPv6-only `[::]:3000` → IIS ARR `localhost:3000` resolve IPv4
first → hit Next.js thay vì Gitea → `git.baocaogiaoduc.vn` trả homepage
VietReport.
**3 rules áp dụng cho mọi service trên VPS shared:**
1. **Reverse-proxy luôn dùng IP literal `127.0.0.1`**, không dùng `localhost`
- IIS ARR rewrite rule: `http://127.0.0.1:5443/{R:0}`
- Health check curl: `curl http://127.0.0.1:5443/health/live`
- Windows DNS resolver có thể cache IPv6 first → fail nếu service bind IPv4-only
2. **Backend services bind loopback IPv4 explicit**, không `0.0.0.0`
- ASP.NET Core Kestrel (standalone): `UseUrls("http://127.0.0.1:5443")` hoặc env `ASPNETCORE_URLS=http://127.0.0.1:5443`
- IIS ASP.NET Core Module out-of-process: ANCM tự inject port ephemeral → KHÔNG cần manual (OK)
- Nếu deploy Kestrel standalone qua NSSM (tương lai): hardcode 127.0.0.1 trong appsettings.Production.json
3. **Service dependency cho boot order** khi nhiều services cùng port family
- NSSM: `nssm set <svc> DependOnService <other>`
- Không cần cho SOLUTION_ERP hiện tại (API in IIS app pool, không NSSM service)
**Hiện trạng SOLUTION_ERP — risk THẤP:**
- API host trong IIS app pool out-of-process → ANCM quản lý port Kestrel ephemeral
- FE gọi trực tiếp `https://api.huypham.vn` qua CORS (không ARR proxy)
- Không có standalone Kestrel service trên port cố định
- **Nhưng** tương lai nếu thêm reverse proxy (fe-admin/user → `/api` → api.huypham.vn, hoặc /hubs for SignalR) → PHẢI dùng 127.0.0.1 không localhost
## Related
- `docs/guides/deployment-iis.md` — first-time setup
- `docs/guides/runbook.md` — operations guide chi tiết
- `docs/guides/cicd.md` — CI/CD pipeline
- `docs/gotchas.md`#25 webSocket lock, #26 SignalR, #28 LibreOffice 404, #29 PS 5.1 UTF-16
- `docs/gotchas.md`#25 webSocket lock, #26 SignalR, #28 LibreOffice 404, #29 PS 5.1 UTF-16, **#33 IPv4/IPv6 port hijack (G-084)**
- `scripts/deploy-iis.ps1` · `scripts/backup-sql.ps1` · `scripts/install-libreoffice.ps1`
- `.gitea/workflows/deploy.yml` — CI/CD definition

View File

@ -1,15 +1,18 @@
# HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-04-23 22:30 (post-RolesPage + 7 demo HĐ + clear pending — UAT-ready 100%)
**Last updated:** 2026-04-23 (Phase 6 — PurchaseEvaluation module E2E)
## TL;DR
**UAT-ready 100%.** Tier 3 ERP + 4-bảng overhaul + 4 master catalogs +
Roles VN + RolesPage CRUD + User-kind approver runtime + Warning 20% SLA
+ Edit detail row inline. 36 DB tables, ~93 endpoints, 11 migrations.
Demo data đầy đủ: **15 NCC + 8 Project + 7 [DEMO] HĐ (varied phases/
details/approvals/comments) + 13 demo users + 60 master catalog items.**
Còn lại chỉ blockers user/ops: UAT thật + SMTP + rotate creds.
**Module Duyệt NCC (tiền-HĐ) E2E.** 2 quy trình A/B config được admin
(tái dùng framework versioned workflow), 10 bảng mới (7 core + 3
workflow config), 17 endpoint mới, 3 FE page × 2 app + PeDetailTabs
5-tab + PeWorkflowPanel timeline. Kế thừa HĐ 1-click từ phiếu DaDuyet:
Gen Contract draft với SupplierId/ProjectId/GiaTri kế thừa, link 2
chiều PE.ContractId.
**Tổng:** 46 DB tables, ~110 endpoints, 12 migrations. Còn blockers
user/ops cũ: UAT thật + SMTP + rotate creds.
## ⭐ Skills (.claude/skills/) — PHẢI dùng khi task khớp
@ -47,6 +50,7 @@ Còn lại chỉ blockers user/ops: UAT thật + SMTP + rotate creds.
| **Edit detail row inline** (7 typed Update commands + EditRowDialog) | ✅ Done |
| **Master expand 15 NCC + 8 Project** + backfill demo HĐ diverse | ✅ Done |
| **Deps audit script** (`scripts/deps-audit.ps1`) | ✅ Done |
| **Module Duyệt NCC (tiền-HĐ) E2E** — 10 bảng + 2 workflow + Kế thừa HĐ | ✅ Done |
| 6+ Post-launch (E-signature, Bravo/SAP, Mobile, AI) | 📝 Future |
## Run nhanh

View File

@ -2,9 +2,9 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-04-23 22:30 (post-RolesPage + 7 demo HĐ + clear pending — UAT-ready 100%)
**Last updated:** 2026-04-23 (Phase 6 — PurchaseEvaluation module E2E, migration 12, 46 DB tables)
## 📍 Phase hiện tại: **Pending tasks cleared — UAT-ready** — Prod live, 36 DB tables, ~93 endpoints, 11 migrations, 15 NCC + 8 Project + 7 demo HĐ + 13 demo users + 4 master catalogs. Chỉ còn blockers user/ops: UAT thật + Email outbox (chờ SMTP) + rotate creds.
## 📍 Phase hiện tại: **Module Duyệt NCC (tiền-HĐ) live E2E** — Prod live 3 domain, 46 DB tables (+10 PE), ~110 endpoints (+17 PE), 12 migrations. 2 quy trình Duyệt NCC config được admin (QT-DN-A 3-step / QT-DN-B 5-step). Kế thừa HĐ 1-click từ phiếu DaDuyet.
### 🌐 Production URLs
@ -22,6 +22,10 @@ _(không có — Tier 3 + skill governance đóng gói xong, chờ UAT + chờ c
| Ngày | Ai | Task | Commit |
|---|---|---|---|
| 2026-04-23 | Claude | **Kế thừa HĐ từ phiếu PE**`CreateContractFromEvaluationCommand` guard DaDuyet + SelectedSupplier + ContractId=null → tạo Contract draft với SupplierId/ProjectId/GiaTri kế thừa. Link 2 chiều PE.ContractId. 2 endpoint mới (approved-pending-contract + create-contract). FE PeDetailTabs InfoTab banner emerald + CreateContractDialog pick ContractType 7 loại. | `a385d70` |
| 2026-04-23 | Claude | **PE FE — 2 app pages (List/Create/Detail 3-panel)** — Types + PurchaseEvaluationsListPage 3-panel + PurchaseEvaluationCreatePage + PeDetailTabs (5 tab: Thông tin/NCC/Hạng mục+Quote matrix/Duyệt/Lịch sử) + PeWorkflowPanel timeline. Menu resolver Pe_* → /purchase-evaluations?type=N. fe-user mirror. TS build pass cả 2 app. | commit Phase 3 |
| 2026-04-23 | Claude | **PE App+Api CQRS** — ~900 dòng: Create/UpdateDraft/Transition/List/Inbox/GetDetail bundle/Delete + Supplier CRUD + Detail CRUD + Quote Upsert + SelectWinner + Changelog list. PurchaseEvaluationWorkflowService policy-based guard + notification push. PurchaseEvaluationsController ~15 endpoint. | commit Phase 2 BE |
| 2026-04-23 | Claude | **PurchaseEvaluation module — Domain+Infra (migration 12)** — 10 bảng mới (7 core: Header+Suppliers+Details+Quotes+Approvals+Changelogs+Attachments + 3 workflow config: Definitions/Steps/StepApprovers). 2 enum (PEType A/B, PEPhase 7 state + TuChoi). PurchaseEvaluationPolicy + Registry + FromDefinition. Seed 13 menu Pe_*/PeWf_* + 2 WorkflowDefinition v01 (QT-DN-A 3-step, QT-DN-B 5-step). | `2c6f0ca` |
| 2026-04-23 | Claude | **Mở rộng master data + backfill demo HĐ** — SeedDemoMasterDataAsync per-Code idempotent (5→15 NCC + 3→8 Project). DemoSupplierByType/DemoProjectByType maps đa dạng theo loại HĐ. BackfillDemoContractsSupplierProjectAsync update [DEMO] HĐ supplier+project nếu mismatch. Match business: ThauPhu↔NTP, NCC↔NCC, DV↔DV. | `bcdc007` |
| 2026-04-23 | Claude | **Edit detail row inline + deps audit script** — 7 typed UpdateXxxDetailCommand BE (ChangelogAction.Update log) + 7 PUT endpoints. FE ContractDetailsTab: ActionBtns (Pencil + Trash) + EditRowDialog reuse FIELDS_BY_TYPE config + buildPayload + populate form từ row data. Mirror fe-admin. `scripts/deps-audit.ps1` chạy dotnet+npm scan, color output, -FailOnHigh CI gate. | `e53cd3a` |
| 2026-04-23 | Claude | **User-kind approver runtime guard + Warning 20% SLA** — WorkflowPolicy +UserTransitions parallel dict (default null cho Standard/SkipCcm, populated qua FromDefinition khi WorkflowStepApprover Kind=User). IsTransitionAllowed signature update accept actorUserId, fallback User-kind nếu Role không match. SlaExpiryJob.ProcessWarningsAsync mới — pull HĐ !SlaWarningSent && remaining ≤ 20% × default SLA → notify Drafter via NotificationType.SlaWarning + set flag tránh spam. | `4edcd58` |
@ -111,13 +115,13 @@ Session logs: [P0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [P1
## 📊 Thông số cumulative
| | P0 | P1f | P1.2 | P2 | P3 | P4 | P5prep | Tier3 | +Toolkit/Catalogs/Roles | **+RolesPg+Demo+Pending** |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | ~3100 | ~3300 | ~4800 | ~7800 | **~8800** |
| DB tables | 0 | 7 | 12 | 14 | 19 | 19 | 19 | 24 | 36 | **36** (no schema change) |
| API endpoints | 0 | 4 | 20 | 23 | 31 | 33 | 35 | ~50 | ~80 | **~93** (+3 Roles CRUD +7 PUT Detail) |
| Migrations | 0 | 1 | 3 | 4 | 5 | 5 | 5 | 8 | 11 | 11 |
| FE pages | 0 | 2 | 6 | 7 | 14 | 16 | 16 | ~20 | ~22 | **~23** (+RolesPage) |
| | P0 | P1f | P1.2 | P2 | P3 | P4 | P5prep | Tier3 | +Toolkit | +RolesPg+Demo | **+PE module** |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| BE LOC | 0 | ~400 | ~1500 | ~1900 | ~2700 | ~3100 | ~3300 | ~4800 | ~7800 | ~8800 | **~11100** |
| DB tables | 0 | 7 | 12 | 14 | 19 | 19 | 19 | 24 | 36 | 36 | **46** (+10 PE) |
| API endpoints | 0 | 4 | 20 | 23 | 31 | 33 | 35 | ~50 | ~80 | ~93 | **~110** (+17 PE) |
| Migrations | 0 | 1 | 3 | 4 | 5 | 5 | 5 | 8 | 11 | 11 | **12** |
| FE pages | 0 | 2 | 6 | 7 | 14 | 16 | 16 | ~20 | ~22 | ~23 | **~26** (+3 PE pages × 2 app) |
| FE components | — | — | — | — | — | — | — | many | many+ | +EditRowDialog (refactor ActionBtns) |
| Scripts PS | 0 | 0 | 0 | 1 | 1 | 1 | 3 | 4 | 4 | **5** (+deps-audit.ps1) |
| CI/CD workflow | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 |

View File

@ -296,6 +296,20 @@
- [x] **Seed master data** — 9 dept + 5 supplier + 3 project + MyDashboard — `6197c84`
- [x] **Brand identity**#1F7DC1 palette + Be Vietnam Pro + Solutions logo — `4abb559`..`bf1fbe3`
## Phase 6 — Module Duyệt NCC (tiền-HĐ) ✅ Done
- [x] **Migration 12 AddPurchaseEvaluations** — 10 bảng: PurchaseEvaluations + Suppliers + Details + Quotes + Approvals + Changelogs + Attachments + WorkflowDefinitions + WorkflowSteps + WorkflowStepApprovers
- [x] **Domain** — 2 enum (Type A/B, Phase 7 state) + Policy record + Registry + FromDefinition builder (mirror WorkflowPolicyRegistry HĐ)
- [x] **Seed** — 13 menu Pe_*/PeWf_* + 2 WorkflowDefinition v01 (QT-DN-A 3-step, QT-DN-B 5-step)
- [x] **Application CQRS** — ~900 dòng: Create/Update/Transition/List/Inbox/Get/Delete + Supplier CRUD + Detail CRUD + Quote Upsert + SelectWinner + Changelog
- [x] **PurchaseEvaluationWorkflowService** — policy-based guard + approval + notification + changelog
- [x] **PurchaseEvaluationsController** — 17 endpoint REST
- [x] **FE 2 app** — Types + PurchaseEvaluationsListPage 3-panel + PurchaseEvaluationCreatePage + PeDetailTabs (5 tab) + PeWorkflowPanel + Menu resolver Pe_*
- [x] **Kế thừa HĐ** — CreateContractFromEvaluationCommand guard DaDuyet + SelectedSupplier + !ContractId → gen Contract draft kế thừa Supplier/Project/GiaTri/DraftData. FE CreateContractDialog pick ContractType 7 loại. Link 2 chiều PE.ContractId.
- [ ] **PE Workflow admin designer UI**`/system/pe-workflows/:typeCode` (framework đã sẵn, mirror `/system/workflows/:typeCode`) — optional
- [ ] **PE Attachments upload** (pattern reuse ContractAttachmentFeatures) — optional
- [ ] **Auto-map PE Details → Contract Details per-type** khi gen HĐ — phức tạp vì 7 ContractType schema khác nhau, user điền lại manual — optional
## Post-launch (Phase 6+ — future)
- [ ] **Email outbox** (MailKit + SMTP) — blocked chờ SMTP config

View File

@ -0,0 +1,144 @@
# Session 2026-04-23 ~23:00 — Module Duyệt NCC (tiền-HĐ) E2E
**Focus:** Build module mới "Quy trình chọn Thầu phụ - NCC" từ user spec
(Excel form trình duyệt so sánh giá + 2 flowchart A/B). Đây là đầu vào
của HĐ — phiếu duyệt xong kế thừa làm HĐ cho NCC đó.
4 commit (`2c6f0ca``a385d70`), 1 migration (12), ~2500 LOC BE + FE.
## User input
**2 file từ user:**
1. **Excel form** "BẢNG TỔNG HỢP TRÌNH DUYỆT SO SÁNH GIÁ" — dự án SOVI,
gói thầu Cung cấp bê tông, so sánh 4 NCC × hạng mục (nhóm A.I Bê tông,
A.II Phụ gia, A.III Bơm, A.IV Vận chuyển) + ý kiến 4 phòng ban + D
Điều kiện TT + E Thông tin liên hệ
2. **Flowchart** 2 quy trình:
- A "Duyệt NCC" (3 step): Purchasing → CCM → CEO
- B "Duyệt NCC - Phương án" (5 step): Purchasing → Dự án → CCM →
CEO (duyệt PA) → CEO (duyệt NCC)
**User confirm:**
- Menu: Quy trình chọn Thầu phụ - NCC → Duyệt NCC / Duyệt NCC Phương Án,
mỗi cái 3 sub (Danh sách / Thao tác / Duyệt)
- Kế thừa HĐ: user click "Tạo HĐ" → list phiếu đã duyệt → chọn → kế thừa
- Mã phiếu: tính sau (auto gen PE-YYYYMM-XXXX tạm)
- Tách Quotes ra bảng riêng (row-based)
- "7 bảng core" (thực tế 7 + 3 workflow config tách riêng vì Phase enum khác)
- "Config quy trình y như HĐ" (admin tự config version mới)
## Design
**10 bảng mới (migration 12 `AddPurchaseEvaluations`):**
Core 7:
1. `PurchaseEvaluations` — Header (Type enum A/B, Phase 7 state, WorkflowDefinitionId
pinned, SelectedSupplierId, PaymentTerms JSON, ContractId? FK kế thừa)
2. `PurchaseEvaluationSuppliers` — N:M NCC tham gia + contact + payment term per NCC
3. `PurchaseEvaluationDetails` — hạng mục so sánh + ngân sách (GroupCode A.I/II/III/IV)
4. `PurchaseEvaluationQuotes` — báo giá per NCC per Detail + IsSelected flag (matrix cell)
5. `PurchaseEvaluationApprovals` — workflow history
6. `PurchaseEvaluationChangelogs` — audit log unified
7. `PurchaseEvaluationAttachments` — file upload (báo giá + spec + phiếu export)
Workflow config 3:
8. `PurchaseEvaluationWorkflowDefinitions` — versioned per Type (A/B), UK(Code, Version)
9. `PurchaseEvaluationWorkflowSteps` — Order + Phase + Name + SlaDays
10. `PurchaseEvaluationWorkflowStepApprovers` — Kind (Role/User) + AssignmentValue
**Phase enum (PurchaseEvaluationPhase, 7 state + TuChoi):**
- 1 DangSoanThao / 2 ChoPurchasing / 3 ChoDuAn (chỉ B) / 4 ChoCCM
- 5 ChoCEODuyetPA (chỉ B) / 6 ChoCEODuyetNCC / 7 DaDuyet / 99 TuChoi
**Policy default (hardcoded fallback khi admin chưa author DB):**
- A NccOnly: DangSoanThao → ChoPurchasing (PRO) → ChoCCM (CCM) → ChoCEODuyetNCC (BOD) → DaDuyet
- B NccWithPlan: DangSoanThao → ChoPurchasing (PRO) → ChoDuAn (PM) → ChoCCM (CCM) → ChoCEODuyetPA (BOD) → ChoCEODuyetNCC (BOD) → DaDuyet
- SLA default: soạn 3d, step 2d, CEO 1d
- Reject path mọi phase → DangSoanThao
**Kế thừa HĐ flow:**
- Phiếu DaDuyet + SelectedSupplierId + !ContractId → banner emerald FE
- Click "Tạo HĐ từ phiếu" → dialog pick ContractType 7 loại + TenHopDong + bypass flag
- POST `/api/purchase-evaluations/{id}/create-contract`
- BE: verify → clone Supplier/Project/DepartmentId/GiaTri(sum details) → gen MaHopDong
ngay → pin ContractWorkflowDefinition[Type] → log Changelog cả 2 bảng → link
2 chiều PE.ContractId = contract.Id
- Navigate `/contracts/{newId}`
## Commits
### 1. `2c6f0ca` — Domain+Infra (migration 12)
- 10 entity files ở `Domain/PurchaseEvaluations/`
- 1 EF config file (10 configuration class)
- Policy + Registry + FromDefinition mirror HĐ
- MenuKeys +Pe_* + PeWorkflows constants
- DbInitializer seed 13 menu + 2 WorkflowDefinition v01
### 2. `(commit 2)` — App+Api CQRS
- `IPurchaseEvaluationWorkflowService` + `PurchaseEvaluationWorkflowService` impl
- 4 Features file (~900 LOC):
- `PurchaseEvaluationFeatures.cs` — Create/Update/Transition/List/Inbox/Get/Delete/Changelog
- `PurchaseEvaluationSupplierFeatures.cs` — Add/Update/Remove supplier
- `PurchaseEvaluationDetailFeatures.cs` — Add/Update/Delete hạng mục + Upsert/Delete Quote + SelectWinner
- `PurchaseEvaluationDtos.cs` — DTO records cho GetBundle
- `PurchaseEvaluationsController.cs` — 15 endpoint REST
- DI register
### 3. `(commit 3)` — FE 2 app
- `types/purchaseEvaluation.ts` — PEType/Phase enum + DTOs (copy-share)
- `pages/pe/PurchaseEvaluationsListPage.tsx` — 3-panel: list | detail tabs | workflow
- `pages/pe/PurchaseEvaluationCreatePage.tsx` — form create/edit header
- `components/pe/PeDetailTabs.tsx` — 5 tab + 4 dialog (AddSupplier/EditSupplier/DetailDialog/QuoteDialog) + matrix clickable
- `components/pe/PeWorkflowPanel.tsx` — timeline + nextPhase buttons
- Menu resolver Layout.tsx: `Pe_<Code>_<Action>` + `PeWf_<Code>` (admin only)
- App.tsx 3 route mới
- MenuKeys.ts +PurchaseEvaluations + PeWorkflows
- fe-user mirror (cùng pages, no admin hidden items)
### 4. `a385d70` — Kế thừa HĐ (Phase 4)
- `CreateContractFromEvaluationFeatures.cs` — Command + Query (list approved pending)
- 2 endpoint: `GET /approved-pending-contract` + `POST /{id}/create-contract`
- PeDetailTabs InfoTab: banner emerald + `CreateContractDialog` pick ContractType
## Build results
- Backend: 0 error, 2 pre-existing warning (DocxRenderer) — build pass
- fe-admin + fe-user: `tsc --noEmit` 0 error
## Skip MVP (tương lai)
- [ ] PE Workflow admin designer UI `/system/pe-workflows/:typeCode` (framework
đã có, mirror `/system/workflows/:typeCode`)
- [ ] PE Attachments upload endpoint + FE drag-drop (pattern reuse ContractAttachment)
- [ ] Auto-map PE Details → Contract Details per-type khi gen HĐ (phức tạp
vì 7 ContractType schema khác nhau)
- [ ] Demo data PE (1-2 phiếu sample cho UAT)
## Stats
| | Trước session | Sau session | Δ |
|---|---|---|---|
| BE LOC | ~8800 | ~11100 | +2300 |
| DB tables | 36 | 46 | +10 |
| Migrations | 11 | 12 | +1 |
| API endpoints | ~93 | ~110 | +17 |
| FE pages | ~23 | ~26 | +3 (× 2 app) |
| Commits session | — | 4 | `2c6f0ca``a385d70` |
## Notes
1. **Workflow config tách bảng riêng** thay vì share WorkflowDefinition với HĐ
— vì Phase enum khác (PurchaseEvaluationPhase vs ContractPhase). Code
duplication ~250 dòng (FromDefinition builder) nhưng design clean.
2. **Quote Dialog matrix UX** — click cell → popup nhập giá + IsSelected
per hạng mục (cho phép mỗi hạng mục NCC khác). `SelectWinner` riêng set
winner tổng thể (trường hợp 1 NCC thắng toàn bộ).
3. **Kế thừa HĐ non-copy details** — PE detail schema flat (GroupCode/NoiDung/
KhoiLuong/DonGia...) khác 7 ContractType details schemas — user điền
lại manual. Link qua PE.ContractId cho reference.
4. **MaPhieu format PE-YYYYMM-XXXX** — tạm random. Có thể đổi format sau
(vd theo dự án: `{ProjectCode}/PE/{seq}`) — user confirm format sau.

View File

@ -480,8 +480,9 @@ COMMIT;
| **9** | **`AddContractDetailsAndChangelog`** | **7 ContractType-specific Details + ContractChangelogs (unified audit log)** |
| **10** | **`AddMasterCatalogs`** | **UnitsOfMeasure, MaterialItems, ServiceItems, WorkItems** |
| **11** | **`AddRoleShortNameAndUserDepartment`** | **+Role.ShortName + User.DepartmentId/Position (cột thêm, không bảng mới)** |
| **12** | **`AddPurchaseEvaluations`** | **10 bảng module Duyệt NCC: PurchaseEvaluations + Suppliers + Details + Quotes + Approvals + Changelogs + Attachments + WorkflowDefinitions + WorkflowSteps + WorkflowStepApprovers** |
Tổng: **36 bảng** (+ `__EFMigrationsHistory` hệ thống).
Tổng: **46 bảng** (+ `__EFMigrationsHistory` hệ thống).
## 8bis. Bảng mới sau Migration 9-11
@ -566,7 +567,65 @@ Common: `AuditableEntity`, `IX_<Table>_Code` UNIQUE filtered `IsDeleted=0`,
- Runtime guard hiện tại chỉ dùng Role-kind (User-kind data model ready, enable iter sau)
```
## 10. Liên quan
## 11. PurchaseEvaluation module (Migration 12 — 10 bảng mới)
Module tiền-HĐ: phiếu trình duyệt so sánh giá N NCC × M hạng mục.
Sau khi DaDuyet → user click "Tạo HĐ từ phiếu" → gen Contract draft
kế thừa Supplier/Project/GiaTri, link qua `PurchaseEvaluation.ContractId`.
### Core (7 bảng):
| Bảng | Mục đích |
|---|---|
| `PurchaseEvaluations` | Header. MaPhieu auto PE-YYYYMM-XXXX, Type enum (1=DuyetNcc A, 2=DuyetNccPhuongAn B), Phase (7 state + TuChoi), WorkflowDefinitionId pinned at create, SelectedSupplierId (winner), PaymentTerms JSON (D section form), ContractId? FK kế thừa. AuditableEntity. |
| `PurchaseEvaluationSuppliers` | N:M Phiếu × Supplier. DisplayName ("TGN-30 ngày"), ContactName/Email/Phone (E section), PaymentTermText per NCC, Note (chip "ĐÃ CHỐT SO SÁNH LẦN 1/2"), Order. UK(EvaluationId, SupplierId). |
| `PurchaseEvaluationDetails` | Hạng mục so sánh. GroupCode (A.I/II/III/IV), GroupName (Bê tông/Phụ gia...), ItemCode, NoiDung, ĐơnViTinh, KhoiLuongNganSach/ThiCong, DonGiaNganSach, ThanhTienNganSach. |
| `PurchaseEvaluationQuotes` | Báo giá per NCC per Detail (matrix cell). FK Detail + Supplier-row, BgVat/ChuaVat/ThanhTien, IsSelected flag, Note. UK(DetailId, SupplierId). |
| `PurchaseEvaluationApprovals` | Workflow history (giống ContractApprovals). FromPhase/ToPhase/Decision/Comment/ApprovedAt. |
| `PurchaseEvaluationChangelogs` | Audit log unified (EntityType: Header/Supplier/Detail/Quote/Workflow/Attachment + Action: Insert/Update/Delete/Transition). |
| `PurchaseEvaluationAttachments` | File upload — báo giá NCC gửi, bản vẽ, phiếu export. Purpose enum. |
### Workflow config (3 bảng — tái dùng pattern HĐ versioned):
| Bảng | Mục đích |
|---|---|
| `PurchaseEvaluationWorkflowDefinitions` | Versioned definition per Type. Code+Version UNIQUE, IsActive (1 per Type). Seed v01: QT-DN-A (3-step) + QT-DN-B (5-step). |
| `PurchaseEvaluationWorkflowSteps` | Ordered steps: Order+Phase+Name+SlaDays. |
| `PurchaseEvaluationWorkflowStepApprovers` | Role/User kind + AssignmentValue (reuse WorkflowApproverKind enum từ HĐ). |
### State machine PE Phase:
```
A (NccOnly 3-step):
DangSoanThao → ChoPurchasing → ChoCCM → ChoCEODuyetNCC → DaDuyet
bất kỳ phase duyệt reject → DangSoanThao
DangSoanThao → TuChoi (cancel)
B (NccWithPlan 5-step):
DangSoanThao → ChoPurchasing → ChoDuAn → ChoCCM → ChoCEODuyetPA → ChoCEODuyetNCC → DaDuyet
Role mapping: Drafter/DeptManager → Procurement (PRO) → ProjectManager (PM) → CostControl (CCM) → Director (BOD)
```
### Kế thừa HĐ flow:
```
PE.Phase=DaDuyet && SelectedSupplierId && !ContractId
→ user FE click "Tạo HĐ từ phiếu"
→ pick ContractType (1-7) + TenHopDong + bypassCCM flag
→ POST /api/purchase-evaluations/{id}/create-contract
→ CreateContractFromEvaluationCommand:
1. Verify PE state
2. new Contract { SupplierId=PE.Selected, ProjectId=PE.Project, ... }
3. GiaTri = sum(PE.Details.ThanhTienNganSach)
4. DraftData = PE.PaymentTerms (carry)
5. WorkflowDefinitionId = active ContractWorkflowDefinition[Type]
6. Gen MaHopDong (ContractCodeGenerator SERIALIZABLE)
7. PE.ContractId = contract.Id (link 2 chiều)
8. Changelog cả 2 bảng
→ navigate /contracts/{newId}
```
## 12. Liên quan
- [`database-guide.md`](database-guide.md) — conventions + migration workflow + cheatsheet đầy đủ
- [`../architecture.md`](../architecture.md) — layered architecture + data flow

View File

@ -300,6 +300,39 @@ Write-Host "Setup IIS sites done" # thay vi "Hoan tat"
<NavLink to={path} end={path.includes('?')}>
```
## IIS / Windows Server (continued)
### 33. IPv4/IPv6 port hijack trên VPS shared (G-084)
**Triệu chứng:** `git.baocaogiaoduc.vn` trả về homepage Next.js của VietReport
thay vì Gitea UI. Headers lộ `x-nextjs-cache: HIT` + `X-Powered-By: ARR/3.0`
(request đã qua IIS ARR proxy rồi mới hit Next.js).
**Root cause:** Next.js app (NSSM service) được deploy lên VPS shared với
Gitea, ignore env `PORT=3001 HOSTNAME=127.0.0.1` và bind `0.0.0.0:3000`.
Gitea bind `0.0.0.0:3000` trước đó bị Windows fallback xuống IPv6-only
`[::]:3000` (default `IPV6_V6ONLY=1`). IIS ARR rewrite `http://localhost:3000`
→ Windows DNS resolve IPv4 first → hit Next.js → leak homepage cho TẤT CẢ
subdomain có ARR proxy về `:3000`.
**Fix (VietReport applied):**
1. Next.js NSSM env `PORT=3001 HOSTNAME=127.0.0.1` — bind loopback IPv4
2. Gitea `HTTP_ADDR=127.0.0.1` — bind loopback IPv4 explicit
3. IIS `web.config` rewrite URL dùng `127.0.0.1` thay `localhost`
4. NSSM `DependOnService=gitea` — boot order tránh race
**3 rules rút ra — áp dụng mọi service trên VPS shared:**
- Reverse-proxy luôn **IP literal `127.0.0.1`**, KHÔNG dùng `localhost`
- Backend services bind **loopback IPv4 explicit**, KHÔNG `0.0.0.0`
- Service dependency cho boot order khi nhiều service cùng port family
**SOLUTION_ERP relevance:**
- API host trong IIS app pool out-of-process (ANCM tự quản lý port Kestrel ephemeral) → risk THẤP
- FE gọi trực tiếp `https://api.huypham.vn` (không ARR proxy) → risk THẤP
- **NHƯNG** nếu tương lai thêm ARR reverse proxy (fe-admin/user `/api` proxy) hoặc
deploy Kestrel standalone qua NSSM → PHẢI apply 3 rules trên
- Scripts + skill doc đã update `localhost``127.0.0.1` để đồng bộ
## Checklist debug bug mới
1. Build pass không? → fail → check using + package version compat
@ -313,3 +346,4 @@ Write-Host "Setup IIS sites done" # thay vi "Hoan tat"
9. Nếu workflow 403 → check FE `workflow.nextPhases` sync từ BE pinned policy
10. Nếu SignalR 401 → dùng `accessTokenFactory` + BE OnMessageReceived hook (#26)
11. Nếu PS 5.1 script fail → check encoding UTF-8 / BOM / ASCII-only (#30)
12. Nếu subdomain trả sai content / bị hijack → check IPv4/IPv6 port collision trên VPS shared (#33)

View File

@ -18,6 +18,8 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ReportsPage } from '@/pages/ReportsPage'
import { UsersPage } from '@/pages/system/UsersPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
function App() {
return (
@ -47,6 +49,9 @@ function App() {
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route

View File

@ -44,6 +44,8 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows',
}
if (staticMap[key]) return staticMap[key]
@ -64,6 +66,24 @@ function resolvePath(key: string): string | null {
if (TYPE_CODE_TO_INT[code]) return `/system/workflows/${code}`
}
// Pe_<Code>_<Action> cho module Duyệt NCC
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
if (peMatch) {
const [, code, action] = peMatch
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
const typeInt = PE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
}
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
const peWfMatch = key.match(/^PeWf_(.+)$/)
if (peWfMatch) {
const code = peWfMatch[1]
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
}
return null
}

View File

@ -0,0 +1,703 @@
// Detail tabs cho 1 phiếu Duyệt NCC: Thông tin / NCC / Hạng mục + Báo giá /
// Duyệt / Lịch sử. Inline action dialog để add NCC, add Detail, upsert Quote,
// select winner.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeChangelog,
type PeDetailBundle,
type PeDetailRow,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import type { Supplier } from '@/types/master'
type TabKey = 'info' | 'suppliers' | 'items' | 'approvals' | 'history'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
}) {
const [tab, setTab] = useState<TabKey>('info')
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[11px] font-medium',
PurchaseEvaluationPhaseColor[evaluation.phase],
)}
>
{PurchaseEvaluationPhaseLabel[evaluation.phase]}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
<span>·</span>
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
<div className="flex gap-2">
{isDraft && (
<>
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
</div>
<nav className="flex gap-1 border-b border-slate-200 px-3 pt-2">
{(
[
['info', 'Thông tin'],
['suppliers', `NCC (${evaluation.suppliers.length})`],
['items', `Hạng mục (${evaluation.details.length})`],
['approvals', `Duyệt (${evaluation.approvals.length})`],
['history', 'Lịch sử'],
] as const
).map(([k, lbl]) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
'rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition',
tab === k
? 'border-brand-500 text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
{lbl}
</button>
))}
</nav>
<div className="p-5">
{tab === 'info' && <InfoTab ev={evaluation} />}
{tab === 'suppliers' && <SuppliersTab ev={evaluation} />}
{tab === 'items' && <ItemsTab ev={evaluation} />}
{tab === 'approvals' && <ApprovalsTab ev={evaluation} />}
{tab === 'history' && <HistoryTab ev={evaluation} />}
</div>
</div>
)
}
// ===== Tab: Thông tin =====
function InfoTab({ ev }: { ev: PeDetailBundle }) {
const canCreateContract = ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
return (
<div className="space-y-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên gói thầu" value={ev.tenGoiThau} />
<Field label="Dự án" value={ev.projectName} />
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} />
<Field label="Mô tả" value={ev.moTa ?? '—'} />
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
{ev.contractId && (
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} />
)}
</dl>
{canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-emerald-800">
Phiếu đã duyệt. Bấm đ tạo mới kế thừa NCC + hạng mục.
</div>
<Button onClick={() => setCreateOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo từ phiếu
</Button>
</div>
</div>
)}
{createOpen && <CreateContractDialog evaluation={ev} onClose={() => setCreateOpen(false)} />}
</div>
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
<Dialog
open
onClose={onClose}
title="Tạo HĐ từ phiếu Duyệt NCC"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Tạo</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p>
<div>
<Label>Loại </Label>
<Select value={form.contractType} onChange={e => setForm({ ...form, contractType: Number(e.target.value) })}>
{typeOptions.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</Select>
</div>
<div>
<Label>Tên </Label>
<Input value={form.tenHopDong} onChange={e => setForm({ ...form, tenHopDong: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.bypassProcurementAndCCM}
onChange={e => setForm({ ...form, bypassProcurementAndCCM: e.target.checked })}
/>
Bypass CCM (áp dụng với Chủ đu )
</label>
</div>
</Dialog>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
<dd className="mt-0.5 text-slate-800">{value}</dd>
</div>
)
}
// ===== Tab: NCC =====
function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<PeSupplier | null>(null)
const remove = useMutation({
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div>
<div className="mb-3 flex justify-end">
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm NCC
</Button>
</div>
{ev.suppliers.length === 0 ? (
<p className="text-sm text-slate-500">Chưa NCC. Thêm NCC đ bắt đu so sánh giá.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left">NCC</th>
<th className="px-3 py-2 text-left">Hiển thị</th>
<th className="px-3 py-2 text-left">Liên hệ</th>
<th className="px-3 py-2 text-left">Điều khoản TT</th>
<th className="px-3 py-2 text-left">Ghi chú</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => (
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
</td>
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px]',
ev.selectedSupplierId === s.supplierId
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
)}
title="Chọn NCC thắng"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
</div>
)
}
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
})
const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title="Thêm NCC vào phiếu"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={!form.supplierId || mut.isPending}>Thêm</Button>
</>}
>
<div className="space-y-3">
<div>
<Label>NCC (master)</Label>
<Select value={form.supplierId} onChange={e => setForm({ ...form, supplierId: e.target.value })}>
<option value="">-- Chọn --</option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
</div>
</div>
</Dialog>
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Sửa NCC — ${row.supplierName}`}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Hạng mục + Báo giá (matrix) =====
function ItemsTab({ ev }: { ev: PeDetailBundle }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
const [quoteEdit, setQuoteEdit] = useState<{ detail: PeDetailRow; supplier: PeSupplier; existing: PeQuote | null } | null>(null)
const removeDetail = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${ev.id}/details/${id}`),
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const quoteKey = (detailId: string, supplierRowId: string) =>
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500">
{ev.suppliers.length === 0
? 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.'
: `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}
</p>
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
</Button>
</div>
{ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border border-slate-200 text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
</th>
))}
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.details.map(d => (
<tr key={d.id}>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
return (
<td
key={s.id}
onClick={() => setQuoteEdit({ detail: d, supplier: s, existing: q })}
className={cn(
'cursor-pointer border-r border-slate-200 px-2 py-2 text-right font-mono transition hover:bg-brand-50',
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
)}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
)
})}
<td className="px-2 py-2">
<div className="flex gap-1">
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
<Pencil className="h-3 w-3" />
</button>
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
<Trash2 className="h-3 w-3" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
{quoteEdit && (
<QuoteDialog
evaluationId={ev.id}
detailId={quoteEdit.detail.id}
supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName}
itemName={quoteEdit.detail.noiDung}
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach}
existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)}
/>
)}
</div>
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
groupCode: row?.groupCode ?? 'A.I',
groupName: row?.groupName ?? '',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? '',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
donGiaNganSach: row?.donGiaNganSach ?? 0,
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
ghiChu: row?.ghiChu ?? '',
})
const mut = useMutation({
mutationFn: async () =>
row
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
setForm(next)
}
return (
<Dialog
open
onClose={onClose}
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
size="lg"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div><Label>Nhóm (A.I/A.II...)</Label><Input value={form.groupCode} onChange={e => setForm({ ...form, groupCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Tên nhóm</Label><Input value={form.groupName} onChange={e => setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." /></div>
<div><Label> (tùy chọn)</Label><Input value={form.itemCode} onChange={e => setForm({ ...form, itemCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Nội dung</Label><Input value={form.noiDung} onChange={e => setForm({ ...form, noiDung: e.target.value })} /></div>
<div><Label>ĐVT</Label><Input value={form.donViTinh} onChange={e => setForm({ ...form, donViTinh: e.target.value })} /></div>
<div><Label>KL ngân sách</Label><Input type="number" value={form.khoiLuongNganSach} onChange={e => updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} /></div>
<div><Label>KL thi công</Label><Input type="number" value={form.khoiLuongThiCong} onChange={e => setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} /></div>
<div><Label>Đơn giá ngân sách</Label><Input type="number" value={form.donGiaNganSach} onChange={e => updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} /></div>
<div className="col-span-2"><Label>Thành tiền ngân sách (auto)</Label><Input type="number" value={form.thanhTienNganSach} onChange={e => setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} /></div>
<div className="col-span-3"><Label>Ghi chú</Label><Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} /></div>
</div>
</div>
</Dialog>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
khoiLuong: number
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState({
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: existing?.thanhTien ?? 0,
isSelected: existing?.isSelected ?? false,
note: existing?.note ?? '',
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
next.thanhTien = Number(next.chuaVat) * khoiLuong
setForm(next)
}
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
...form,
}),
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async () =>
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
onSuccess: () => { toast.success('Đã xóa báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Báo giá — ${supplierName}`}
footer={<>
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
<div className="grid grid-cols-3 gap-3">
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
<div><Label>Đơn giá VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
Chọn NCC này cho hạng mục
</label>
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Duyệt =====
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{ev.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
</ol>
)
}
// ===== Tab: Lịch sử =====
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
const logs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}

View File

@ -0,0 +1,124 @@
// Panel 3: workflow + transition buttons. Pulls nextPhases từ BE bundle
// (single source of truth) → render per-phase action button.
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
const transition = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: target === PurchaseEvaluationPhase.TuChoi ? 2 : 1,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = evaluation.workflow.nextPhases
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
<p className="mt-0.5 text-[11px] text-slate-500">{evaluation.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{evaluation.workflow.activePhases
.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
.map(p => {
const isCurrent = evaluation.phase === p
const isPast = isPastPhase(evaluation.phase, p, evaluation.workflow.activePhases)
return (
<li key={p}>
<div
className={cn(
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
)}
>
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', PurchaseEvaluationPhaseColor[p])}>
{p}
</span>
<span className="truncate">{PurchaseEvaluationPhaseLabel[p]}</span>
{isCurrent && <span className="ml-auto text-[10px] text-brand-700"> hiện tại</span>}
{isPast && <span className="ml-auto text-[10px] text-emerald-600"></span>}
</div>
</li>
)
})}
</ol>
{next.length > 0 && (
<div>
<Label className="text-xs">Chuyển tiếp:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => (
<button
key={p}
onClick={() => setTarget(p)}
className={cn(
'rounded border px-2 py-1 text-[11px] transition',
p === PurchaseEvaluationPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{PurchaseEvaluationPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
</>}
>
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)}
</div>
)
}
function isPastPhase(current: number, p: number, active: number[]): boolean {
const orderedIdx = active.indexOf(p)
const currentIdx = active.indexOf(current)
if (orderedIdx < 0 || currentIdx < 0) return false
return orderedIdx < currentIdx && p !== PurchaseEvaluationPhase.TuChoi
}

View File

@ -12,6 +12,8 @@ export const MenuKeys = {
Users: 'Users',
Roles: 'Roles',
Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,176 @@
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
// chỉnh sửa ở Detail tabs sau khi save).
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import type { Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['pe-detail', editId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
type: urlType as number,
tenGoiThau: '',
projectId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
})
useEffect(() => {
if (existing.data) {
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
</h1>
</header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label>Loại quy trình</Label>
<Select
value={form.type}
disabled={!!editId}
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
>
{Object.values(PurchaseEvaluationType).map(t => (
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
))}
</Select>
</div>
<div>
<Label>Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div>
<Label>Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
/>
</div>
<div>
<Label> tả</Label>
<Textarea
rows={3}
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
/>
</div>
<div>
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
<Textarea
rows={3}
value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,250 @@
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck, Plus, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { SlaTimer } from '@/components/SlaTimer'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
type PeListItem,
} from '@/types/purchaseEvaluation'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
export function PurchaseEvaluationsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const selectedId = sp.get('id')
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: { type: typeFilter ?? undefined },
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
params: {
pageSize: 50,
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['pe-detail', selectedId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa phiếu.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/purchase-evaluations/${id}`)
}
}
const rows = list.data?.items ?? []
const createHref =
typeFilter != null ? `/purchase-evaluations/new?type=${typeFilter}` : '/purchase-evaluations/new'
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{list.data?.total ?? 0}
</span>
</div>
<Button onClick={() => navigate(createHref)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo phiếu mới
</Button>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
{/* Panel 1: List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm mã / tên gói thầu / dự án…"
className="pl-8"
/>
</div>
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả phase</option>
{Object.values(PurchaseEvaluationPhase).map(p => (
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(p => (
<li key={p.id}>
<button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PurchaseEvaluationPhaseColor[p.phase],
)}
>
{PurchaseEvaluationPhaseLabel[p.phase]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
export function PurchaseEvaluationDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['pe-detail', id],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/purchase-evaluations')
},
})
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
</div>
)
}

View File

@ -0,0 +1,165 @@
// Types cho module Duyệt NCC (PurchaseEvaluation) — mirror BE Domain.
export const PurchaseEvaluationType = {
DuyetNcc: 1,
DuyetNccPhuongAn: 2,
} as const
export type PurchaseEvaluationType = typeof PurchaseEvaluationType[keyof typeof PurchaseEvaluationType]
export const PurchaseEvaluationTypeLabel: Record<number, string> = {
1: 'Duyệt NCC',
2: 'Duyệt NCC - Phương án',
}
export const PurchaseEvaluationTypeCode: Record<number, string> = {
1: 'DuyetNcc',
2: 'DuyetNccPhuongAn',
}
export const PurchaseEvaluationPhase = {
DangSoanThao: 1,
ChoPurchasing: 2,
ChoDuAn: 3,
ChoCCM: 4,
ChoCEODuyetPA: 5,
ChoCEODuyetNCC: 6,
DaDuyet: 7,
TuChoi: 99,
} as const
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
2: 'Chờ Purchasing',
3: 'Chờ Dự án',
4: 'Chờ CCM',
5: 'Chờ CEO duyệt PA',
6: 'Chờ CEO duyệt NCC',
7: 'Đã duyệt',
99: 'Từ chối',
}
export const PurchaseEvaluationPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-orange-100 text-orange-700',
4: 'bg-indigo-100 text-indigo-700',
5: 'bg-fuchsia-100 text-fuchsia-700',
6: 'bg-pink-100 text-pink-700',
7: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export type PeListItem = {
id: string
maPhieu: string | null
tenGoiThau: string
type: number
phase: number
projectId: string
projectName: string
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
slaDeadline: string | null
createdAt: string
}
export type PeSupplier = {
id: string
supplierId: string
supplierName: string
displayName: string | null
contactName: string | null
contactEmail: string | null
contactPhone: string | null
paymentTermText: string | null
note: string | null
order: number
}
export type PeQuote = {
id: string
purchaseEvaluationDetailId: string
purchaseEvaluationSupplierId: string
bgVat: number
chuaVat: number
thanhTien: number
isSelected: boolean
note: string | null
}
export type PeDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuongNganSach: number
khoiLuongThiCong: number
donGiaNganSach: number
thanhTienNganSach: number
order: number
ghiChu: string | null
quotes: PeQuote[]
}
export type PeApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type PeWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type PeChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export type PeDetailBundle = {
id: string
maPhieu: string | null
type: number
phase: number
tenGoiThau: string
diaDiem: string | null
moTa: string | null
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
paymentTerms: string | null
slaDeadline: string | null
createdAt: string
updatedAt: string | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]
workflow: PeWorkflowSummary
}

View File

@ -9,6 +9,8 @@ import { InboxPage } from '@/pages/InboxPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
function App() {
return (
@ -28,6 +30,9 @@ function App() {
<Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/my-contracts" element={<MyContractsPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -39,8 +39,9 @@ function getCtGroupCode(key: string): string | null {
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
Dashboard: '/dashboard', // Tổng quan riêng — KHÔNG trùng /inbox (Hộp thư)
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',
}
if (staticMap[key]) return staticMap[key]
@ -53,6 +54,18 @@ function resolvePath(key: string): string | null {
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
if (peMatch) {
const [, code, action] = peMatch
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
const typeInt = PE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
}
return null
}

View File

@ -0,0 +1,703 @@
// Detail tabs cho 1 phiếu Duyệt NCC: Thông tin / NCC / Hạng mục + Báo giá /
// Duyệt / Lịch sử. Inline action dialog để add NCC, add Detail, upsert Quote,
// select winner.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeChangelog,
type PeDetailBundle,
type PeDetailRow,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import type { Supplier } from '@/types/master'
type TabKey = 'info' | 'suppliers' | 'items' | 'approvals' | 'history'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
}) {
const [tab, setTab] = useState<TabKey>('info')
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[11px] font-medium',
PurchaseEvaluationPhaseColor[evaluation.phase],
)}
>
{PurchaseEvaluationPhaseLabel[evaluation.phase]}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
<span>·</span>
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
<div className="flex gap-2">
{isDraft && (
<>
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
</div>
<nav className="flex gap-1 border-b border-slate-200 px-3 pt-2">
{(
[
['info', 'Thông tin'],
['suppliers', `NCC (${evaluation.suppliers.length})`],
['items', `Hạng mục (${evaluation.details.length})`],
['approvals', `Duyệt (${evaluation.approvals.length})`],
['history', 'Lịch sử'],
] as const
).map(([k, lbl]) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
'rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition',
tab === k
? 'border-brand-500 text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
{lbl}
</button>
))}
</nav>
<div className="p-5">
{tab === 'info' && <InfoTab ev={evaluation} />}
{tab === 'suppliers' && <SuppliersTab ev={evaluation} />}
{tab === 'items' && <ItemsTab ev={evaluation} />}
{tab === 'approvals' && <ApprovalsTab ev={evaluation} />}
{tab === 'history' && <HistoryTab ev={evaluation} />}
</div>
</div>
)
}
// ===== Tab: Thông tin =====
function InfoTab({ ev }: { ev: PeDetailBundle }) {
const canCreateContract = ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
return (
<div className="space-y-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên gói thầu" value={ev.tenGoiThau} />
<Field label="Dự án" value={ev.projectName} />
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} />
<Field label="Mô tả" value={ev.moTa ?? '—'} />
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
{ev.contractId && (
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} />
)}
</dl>
{canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-emerald-800">
Phiếu đã duyệt. Bấm đ tạo mới kế thừa NCC + hạng mục.
</div>
<Button onClick={() => setCreateOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo từ phiếu
</Button>
</div>
</div>
)}
{createOpen && <CreateContractDialog evaluation={ev} onClose={() => setCreateOpen(false)} />}
</div>
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
<Dialog
open
onClose={onClose}
title="Tạo HĐ từ phiếu Duyệt NCC"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Tạo</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p>
<div>
<Label>Loại </Label>
<Select value={form.contractType} onChange={e => setForm({ ...form, contractType: Number(e.target.value) })}>
{typeOptions.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</Select>
</div>
<div>
<Label>Tên </Label>
<Input value={form.tenHopDong} onChange={e => setForm({ ...form, tenHopDong: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.bypassProcurementAndCCM}
onChange={e => setForm({ ...form, bypassProcurementAndCCM: e.target.checked })}
/>
Bypass CCM (áp dụng với Chủ đu )
</label>
</div>
</Dialog>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
<dd className="mt-0.5 text-slate-800">{value}</dd>
</div>
)
}
// ===== Tab: NCC =====
function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<PeSupplier | null>(null)
const remove = useMutation({
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div>
<div className="mb-3 flex justify-end">
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm NCC
</Button>
</div>
{ev.suppliers.length === 0 ? (
<p className="text-sm text-slate-500">Chưa NCC. Thêm NCC đ bắt đu so sánh giá.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left">NCC</th>
<th className="px-3 py-2 text-left">Hiển thị</th>
<th className="px-3 py-2 text-left">Liên hệ</th>
<th className="px-3 py-2 text-left">Điều khoản TT</th>
<th className="px-3 py-2 text-left">Ghi chú</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => (
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
</td>
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px]',
ev.selectedSupplierId === s.supplierId
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
)}
title="Chọn NCC thắng"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
</div>
)
}
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
})
const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title="Thêm NCC vào phiếu"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={!form.supplierId || mut.isPending}>Thêm</Button>
</>}
>
<div className="space-y-3">
<div>
<Label>NCC (master)</Label>
<Select value={form.supplierId} onChange={e => setForm({ ...form, supplierId: e.target.value })}>
<option value="">-- Chọn --</option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
</div>
</div>
</Dialog>
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Sửa NCC — ${row.supplierName}`}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Hạng mục + Báo giá (matrix) =====
function ItemsTab({ ev }: { ev: PeDetailBundle }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
const [quoteEdit, setQuoteEdit] = useState<{ detail: PeDetailRow; supplier: PeSupplier; existing: PeQuote | null } | null>(null)
const removeDetail = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${ev.id}/details/${id}`),
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const quoteKey = (detailId: string, supplierRowId: string) =>
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500">
{ev.suppliers.length === 0
? 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.'
: `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}
</p>
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
</Button>
</div>
{ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border border-slate-200 text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
</th>
))}
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.details.map(d => (
<tr key={d.id}>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
return (
<td
key={s.id}
onClick={() => setQuoteEdit({ detail: d, supplier: s, existing: q })}
className={cn(
'cursor-pointer border-r border-slate-200 px-2 py-2 text-right font-mono transition hover:bg-brand-50',
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
)}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
)
})}
<td className="px-2 py-2">
<div className="flex gap-1">
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
<Pencil className="h-3 w-3" />
</button>
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
<Trash2 className="h-3 w-3" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
{quoteEdit && (
<QuoteDialog
evaluationId={ev.id}
detailId={quoteEdit.detail.id}
supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName}
itemName={quoteEdit.detail.noiDung}
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach}
existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)}
/>
)}
</div>
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
groupCode: row?.groupCode ?? 'A.I',
groupName: row?.groupName ?? '',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? '',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
donGiaNganSach: row?.donGiaNganSach ?? 0,
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
ghiChu: row?.ghiChu ?? '',
})
const mut = useMutation({
mutationFn: async () =>
row
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
setForm(next)
}
return (
<Dialog
open
onClose={onClose}
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
size="lg"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div><Label>Nhóm (A.I/A.II...)</Label><Input value={form.groupCode} onChange={e => setForm({ ...form, groupCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Tên nhóm</Label><Input value={form.groupName} onChange={e => setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." /></div>
<div><Label> (tùy chọn)</Label><Input value={form.itemCode} onChange={e => setForm({ ...form, itemCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Nội dung</Label><Input value={form.noiDung} onChange={e => setForm({ ...form, noiDung: e.target.value })} /></div>
<div><Label>ĐVT</Label><Input value={form.donViTinh} onChange={e => setForm({ ...form, donViTinh: e.target.value })} /></div>
<div><Label>KL ngân sách</Label><Input type="number" value={form.khoiLuongNganSach} onChange={e => updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} /></div>
<div><Label>KL thi công</Label><Input type="number" value={form.khoiLuongThiCong} onChange={e => setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} /></div>
<div><Label>Đơn giá ngân sách</Label><Input type="number" value={form.donGiaNganSach} onChange={e => updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} /></div>
<div className="col-span-2"><Label>Thành tiền ngân sách (auto)</Label><Input type="number" value={form.thanhTienNganSach} onChange={e => setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} /></div>
<div className="col-span-3"><Label>Ghi chú</Label><Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} /></div>
</div>
</div>
</Dialog>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
khoiLuong: number
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState({
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: existing?.thanhTien ?? 0,
isSelected: existing?.isSelected ?? false,
note: existing?.note ?? '',
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
next.thanhTien = Number(next.chuaVat) * khoiLuong
setForm(next)
}
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
...form,
}),
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async () =>
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
onSuccess: () => { toast.success('Đã xóa báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Báo giá — ${supplierName}`}
footer={<>
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
<div className="grid grid-cols-3 gap-3">
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
<div><Label>Đơn giá VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
Chọn NCC này cho hạng mục
</label>
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Duyệt =====
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{ev.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
</ol>
)
}
// ===== Tab: Lịch sử =====
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
const logs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}

View File

@ -0,0 +1,124 @@
// Panel 3: workflow + transition buttons. Pulls nextPhases từ BE bundle
// (single source of truth) → render per-phase action button.
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
const transition = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: target === PurchaseEvaluationPhase.TuChoi ? 2 : 1,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = evaluation.workflow.nextPhases
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
<p className="mt-0.5 text-[11px] text-slate-500">{evaluation.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{evaluation.workflow.activePhases
.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
.map(p => {
const isCurrent = evaluation.phase === p
const isPast = isPastPhase(evaluation.phase, p, evaluation.workflow.activePhases)
return (
<li key={p}>
<div
className={cn(
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
)}
>
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', PurchaseEvaluationPhaseColor[p])}>
{p}
</span>
<span className="truncate">{PurchaseEvaluationPhaseLabel[p]}</span>
{isCurrent && <span className="ml-auto text-[10px] text-brand-700"> hiện tại</span>}
{isPast && <span className="ml-auto text-[10px] text-emerald-600"></span>}
</div>
</li>
)
})}
</ol>
{next.length > 0 && (
<div>
<Label className="text-xs">Chuyển tiếp:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => (
<button
key={p}
onClick={() => setTarget(p)}
className={cn(
'rounded border px-2 py-1 text-[11px] transition',
p === PurchaseEvaluationPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{PurchaseEvaluationPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
</>}
>
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)}
</div>
)
}
function isPastPhase(current: number, p: number, active: number[]): boolean {
const orderedIdx = active.indexOf(p)
const currentIdx = active.indexOf(current)
if (orderedIdx < 0 || currentIdx < 0) return false
return orderedIdx < currentIdx && p !== PurchaseEvaluationPhase.TuChoi
}

View File

@ -12,6 +12,8 @@ export const MenuKeys = {
Users: 'Users',
Roles: 'Roles',
Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,176 @@
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
// chỉnh sửa ở Detail tabs sau khi save).
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import type { Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['pe-detail', editId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
type: urlType as number,
tenGoiThau: '',
projectId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
})
useEffect(() => {
if (existing.data) {
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
</h1>
</header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label>Loại quy trình</Label>
<Select
value={form.type}
disabled={!!editId}
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
>
{Object.values(PurchaseEvaluationType).map(t => (
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
))}
</Select>
</div>
<div>
<Label>Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div>
<Label>Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
/>
</div>
<div>
<Label> tả</Label>
<Textarea
rows={3}
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
/>
</div>
<div>
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
<Textarea
rows={3}
value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,250 @@
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck, Plus, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { SlaTimer } from '@/components/SlaTimer'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
type PeListItem,
} from '@/types/purchaseEvaluation'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
export function PurchaseEvaluationsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const selectedId = sp.get('id')
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: { type: typeFilter ?? undefined },
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
params: {
pageSize: 50,
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['pe-detail', selectedId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa phiếu.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/purchase-evaluations/${id}`)
}
}
const rows = list.data?.items ?? []
const createHref =
typeFilter != null ? `/purchase-evaluations/new?type=${typeFilter}` : '/purchase-evaluations/new'
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{list.data?.total ?? 0}
</span>
</div>
<Button onClick={() => navigate(createHref)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo phiếu mới
</Button>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
{/* Panel 1: List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm mã / tên gói thầu / dự án…"
className="pl-8"
/>
</div>
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả phase</option>
{Object.values(PurchaseEvaluationPhase).map(p => (
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(p => (
<li key={p.id}>
<button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PurchaseEvaluationPhaseColor[p.phase],
)}
>
{PurchaseEvaluationPhaseLabel[p.phase]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
export function PurchaseEvaluationDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['pe-detail', id],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/purchase-evaluations')
},
})
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
</div>
)
}

View File

@ -0,0 +1,165 @@
// Types cho module Duyệt NCC (PurchaseEvaluation) — mirror BE Domain.
export const PurchaseEvaluationType = {
DuyetNcc: 1,
DuyetNccPhuongAn: 2,
} as const
export type PurchaseEvaluationType = typeof PurchaseEvaluationType[keyof typeof PurchaseEvaluationType]
export const PurchaseEvaluationTypeLabel: Record<number, string> = {
1: 'Duyệt NCC',
2: 'Duyệt NCC - Phương án',
}
export const PurchaseEvaluationTypeCode: Record<number, string> = {
1: 'DuyetNcc',
2: 'DuyetNccPhuongAn',
}
export const PurchaseEvaluationPhase = {
DangSoanThao: 1,
ChoPurchasing: 2,
ChoDuAn: 3,
ChoCCM: 4,
ChoCEODuyetPA: 5,
ChoCEODuyetNCC: 6,
DaDuyet: 7,
TuChoi: 99,
} as const
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
2: 'Chờ Purchasing',
3: 'Chờ Dự án',
4: 'Chờ CCM',
5: 'Chờ CEO duyệt PA',
6: 'Chờ CEO duyệt NCC',
7: 'Đã duyệt',
99: 'Từ chối',
}
export const PurchaseEvaluationPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-orange-100 text-orange-700',
4: 'bg-indigo-100 text-indigo-700',
5: 'bg-fuchsia-100 text-fuchsia-700',
6: 'bg-pink-100 text-pink-700',
7: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export type PeListItem = {
id: string
maPhieu: string | null
tenGoiThau: string
type: number
phase: number
projectId: string
projectName: string
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
slaDeadline: string | null
createdAt: string
}
export type PeSupplier = {
id: string
supplierId: string
supplierName: string
displayName: string | null
contactName: string | null
contactEmail: string | null
contactPhone: string | null
paymentTermText: string | null
note: string | null
order: number
}
export type PeQuote = {
id: string
purchaseEvaluationDetailId: string
purchaseEvaluationSupplierId: string
bgVat: number
chuaVat: number
thanhTien: number
isSelected: boolean
note: string | null
}
export type PeDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuongNganSach: number
khoiLuongThiCong: number
donGiaNganSach: number
thanhTienNganSach: number
order: number
ghiChu: string | null
quotes: PeQuote[]
}
export type PeApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type PeWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type PeChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export type PeDetailBundle = {
id: string
maPhieu: string | null
type: number
phase: number
tenGoiThau: string
diaDiem: string | null
moTa: string | null
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
paymentTerms: string | null
slaDeadline: string | null
createdAt: string
updatedAt: string | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]
workflow: PeWorkflowSummary
}

View File

@ -0,0 +1,191 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/purchase-evaluations")]
[Authorize]
public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<PurchaseEvaluationListItemDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
[FromQuery] PurchaseEvaluationType? type = null,
[FromQuery] PurchaseEvaluationPhase? phase = null,
[FromQuery] Guid? projectId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListPurchaseEvaluationsQuery(type, phase, projectId)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("inbox")]
public async Task<ActionResult<List<PurchaseEvaluationListItemDto>>> Inbox(
[FromQuery] PurchaseEvaluationType? type = null, CancellationToken ct = default)
=> Ok(await mediator.Send(new GetMyPurchaseEvaluationInboxQuery(type), ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<PurchaseEvaluationDetailBundleDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetPurchaseEvaluationQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create([FromBody] CreatePurchaseEvaluationCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdatePurchaseEvaluationDraftCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
{
await mediator.Send(new TransitionPurchaseEvaluationCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct);
return NoContent();
}
// ========== Suppliers (N:M) ==========
[HttpPost("{id:guid}/suppliers")]
public async Task<ActionResult<object>> AddSupplier(Guid id, [FromBody] AddSupplierBody body, CancellationToken ct)
{
var newId = await mediator.Send(new AddPurchaseEvaluationSupplierCommand(
id, body.SupplierId, body.DisplayName, body.ContactName, body.ContactEmail,
body.ContactPhone, body.PaymentTermText, body.Note), ct);
return Ok(new { id = newId });
}
[HttpPut("{id:guid}/suppliers/{supplierRowId:guid}")]
public async Task<IActionResult> UpdateSupplier(Guid id, Guid supplierRowId, [FromBody] AddSupplierBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePurchaseEvaluationSupplierCommand(
id, supplierRowId, body.DisplayName, body.ContactName, body.ContactEmail,
body.ContactPhone, body.PaymentTermText, body.Note), ct);
return NoContent();
}
[HttpDelete("{id:guid}/suppliers/{supplierRowId:guid}")]
public async Task<IActionResult> RemoveSupplier(Guid id, Guid supplierRowId, CancellationToken ct)
{
await mediator.Send(new RemovePurchaseEvaluationSupplierCommand(id, supplierRowId), ct);
return NoContent();
}
[HttpPost("{id:guid}/select-winner")]
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
{
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct);
return NoContent();
}
// ========== Details (hạng mục + ngân sách) ==========
[HttpPost("{id:guid}/details")]
public async Task<ActionResult<object>> AddDetail(Guid id, [FromBody] DetailBody body, CancellationToken ct)
{
var newId = await mediator.Send(new AddPurchaseEvaluationDetailCommand(
id, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach,
body.ThanhTienNganSach, body.GhiChu), ct);
return Ok(new { id = newId });
}
[HttpPut("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> UpdateDetail(Guid id, Guid detailId, [FromBody] DetailBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePurchaseEvaluationDetailCommand(
id, detailId, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach,
body.ThanhTienNganSach, body.GhiChu), ct);
return NoContent();
}
[HttpDelete("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> DeleteDetail(Guid id, Guid detailId, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationDetailCommand(id, detailId), ct);
return NoContent();
}
// ========== Quotes (báo giá per NCC per Detail) ==========
[HttpPost("{id:guid}/quotes")]
public async Task<ActionResult<object>> UpsertQuote(Guid id, [FromBody] QuoteBody body, CancellationToken ct)
{
var qid = await mediator.Send(new UpsertPurchaseEvaluationQuoteCommand(
id, body.PurchaseEvaluationDetailId, body.PurchaseEvaluationSupplierId,
body.BgVat, body.ChuaVat, body.ThanhTien, body.IsSelected, body.Note), ct);
return Ok(new { id = qid });
}
[HttpDelete("{id:guid}/quotes/{quoteId:guid}")]
public async Task<IActionResult> DeleteQuote(Guid id, Guid quoteId, CancellationToken ct)
{
await mediator.Send(new DeletePurchaseEvaluationQuoteCommand(id, quoteId), ct);
return NoContent();
}
// ========== Changelogs ==========
[HttpGet("{id:guid}/changelogs")]
public async Task<List<PurchaseEvaluationChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
=> await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct);
// ========== Kế thừa HĐ ==========
// List phiếu đã DaDuyet chưa gen HĐ — dùng cho modal "Tạo HĐ từ phiếu"
[HttpGet("approved-pending-contract")]
public async Task<List<PurchaseEvaluationListItemDto>> ListApproved(CancellationToken ct)
=> await mediator.Send(new ListApprovedPurchaseEvaluationsQuery(), ct);
[HttpPost("{id:guid}/create-contract")]
public async Task<ActionResult<object>> CreateContractFromEvaluation(
Guid id, [FromBody] CreateContractFromEvaluationBody body, CancellationToken ct)
{
var contractId = await mediator.Send(new CreateContractFromEvaluationCommand(
id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct);
return Ok(new { contractId });
}
}
public record CreateContractFromEvaluationBody(
Domain.Contracts.ContractType ContractType,
string? TenHopDong,
bool BypassProcurementAndCCM = false);
public record TransitionPeBody(PurchaseEvaluationPhase TargetPhase, ApprovalDecision Decision, string? Comment);
public record AddSupplierBody(
Guid SupplierId,
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
string? PaymentTermText, string? Note);
public record SelectWinnerBody(Guid SupplierId);
public record DetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
decimal KhoiLuongNganSach, decimal KhoiLuongThiCong, decimal DonGiaNganSach,
decimal ThanhTienNganSach, string? GhiChu);
public record QuoteBody(
Guid PurchaseEvaluationDetailId, Guid PurchaseEvaluationSupplierId,
decimal BgVat, decimal ChuaVat, decimal ThanhTien, bool IsSelected, string? Note);

View File

@ -6,6 +6,7 @@ using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.Common.Interfaces;
@ -44,5 +45,17 @@ public interface IApplicationDbContext
DbSet<NguyenTacNccDetail> NguyenTacNccDetails { get; }
DbSet<NguyenTacDvDetail> NguyenTacDvDetails { get; }
// Module Duyệt NCC (tiền-HĐ) — 7 core + 3 workflow config
DbSet<PurchaseEvaluation> PurchaseEvaluations { get; }
DbSet<PurchaseEvaluationSupplier> PurchaseEvaluationSuppliers { get; }
DbSet<PurchaseEvaluationDetail> PurchaseEvaluationDetails { get; }
DbSet<PurchaseEvaluationQuote> PurchaseEvaluationQuotes { get; }
DbSet<PurchaseEvaluationApproval> PurchaseEvaluationApprovals { get; }
DbSet<PurchaseEvaluationChangelog> PurchaseEvaluationChangelogs { get; }
DbSet<PurchaseEvaluationAttachment> PurchaseEvaluationAttachments { get; }
DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions { get; }
DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps { get; }
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,142 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// Kế thừa từ phiếu Duyệt NCC đã DaDuyet → tạo HĐ Draft mới. Map cơ bản:
// SupplierId = PE.SelectedSupplierId, ProjectId, DepartmentId, TenHopDong,
// GiaTri (sum ThanhTienNganSach của details). ContractType do user chọn
// (phiếu có thể gen HĐ ThauPhu/NhaCungCap/MuaBan... tùy gói thầu).
//
// KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ
// gen, tránh mapping sai (PE detail schema ≠ 7 Contract detail schemas).
// User có thể reference PE qua PE.ContractId để xem lại báo giá.
public record CreateContractFromEvaluationCommand(
Guid PurchaseEvaluationId,
ContractType ContractType,
string? TenHopDong,
bool BypassProcurementAndCCM = false) : IRequest<Guid>;
public class CreateContractFromEvaluationCommandValidator : AbstractValidator<CreateContractFromEvaluationCommand>
{
public CreateContractFromEvaluationCommandValidator()
{
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
RuleFor(x => x.ContractType).IsInEnum();
RuleFor(x => x.TenHopDong).MaximumLength(500);
}
}
public class CreateContractFromEvaluationCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow,
IContractCodeGenerator codeGenerator) : IRequestHandler<CreateContractFromEvaluationCommand, Guid>
{
public async Task<Guid> Handle(CreateContractFromEvaluationCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations
.Include(p => p.Details)
.FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
if (pe.Phase != PurchaseEvaluationPhase.DaDuyet)
throw new ConflictException("Chỉ tạo HĐ từ phiếu đã duyệt xong (DaDuyet).");
if (pe.SelectedSupplierId is null)
throw new ConflictException("Phiếu chưa chọn NCC thắng — click 'Chọn NCC' trước.");
if (pe.ContractId is not null)
throw new ConflictException("Phiếu này đã tạo HĐ rồi.");
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == pe.SelectedSupplierId, ct)
?? throw new NotFoundException("Supplier", pe.SelectedSupplierId.Value);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == pe.ProjectId, ct)
?? throw new NotFoundException("Project", pe.ProjectId);
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
.Where(w => w.ContractType == request.ContractType && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
var giaTri = pe.Details.Sum(d => d.ThanhTienNganSach);
var contract = new Contract
{
Type = request.ContractType,
Phase = ContractPhase.DangSoanThao,
SupplierId = pe.SelectedSupplierId.Value,
ProjectId = pe.ProjectId,
DepartmentId = pe.DepartmentId,
DrafterUserId = currentUser.UserId,
GiaTri = giaTri,
TenHopDong = request.TenHopDong ?? pe.TenGoiThau,
NoiDung = pe.MoTa,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = pe.PaymentTerms, // carry forward payment terms
WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
db.Contracts.Add(contract);
// Changelog HĐ: note kế thừa từ phiếu
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Contract,
Action = ChangelogAction.Insert,
PhaseAtChange = contract.Phase,
UserId = currentUser.UserId,
Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu {pe.MaPhieu ?? pe.TenGoiThau}",
ContextNote = $"Kế thừa từ PurchaseEvaluation {pe.Id}",
});
// Link 2 chiều
pe.ContractId = contract.Id;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu",
ContextNote = $"Contract {contract.Id}",
});
await db.SaveChangesAsync(ct);
return contract.Id;
}
}
// List phiếu đã duyệt chưa gen HĐ (cho FE modal picker trong ContractCreatePage)
public record ListApprovedPurchaseEvaluationsQuery : IRequest<List<PurchaseEvaluationListItemDto>>;
public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListApprovedPurchaseEvaluationsQuery, List<PurchaseEvaluationListItemDto>>
{
public async Task<List<PurchaseEvaluationListItemDto>> Handle(
ListApprovedPurchaseEvaluationsQuery request, CancellationToken ct)
{
return await (
from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
where e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ContractId == null
orderby e.CreatedAt descending
select new PurchaseEvaluationListItemDto(
e.Id, e.MaPhieu, e.TenGoiThau, e.Type, e.Phase,
e.ProjectId, p.Name,
e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt)).ToListAsync(ct);
}
}

View File

@ -0,0 +1,110 @@
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations.Dtos;
public record PurchaseEvaluationListItemDto(
Guid Id,
string? MaPhieu,
string TenGoiThau,
PurchaseEvaluationType Type,
PurchaseEvaluationPhase Phase,
Guid ProjectId,
string ProjectName,
Guid? SelectedSupplierId,
string? SelectedSupplierName,
Guid? ContractId,
DateTime? SlaDeadline,
DateTime CreatedAt);
public record PurchaseEvaluationSupplierDto(
Guid Id,
Guid SupplierId,
string SupplierName,
string? DisplayName,
string? ContactName,
string? ContactEmail,
string? ContactPhone,
string? PaymentTermText,
string? Note,
int Order);
public record PurchaseEvaluationQuoteDto(
Guid Id,
Guid PurchaseEvaluationDetailId,
Guid PurchaseEvaluationSupplierId,
decimal BgVat,
decimal ChuaVat,
decimal ThanhTien,
bool IsSelected,
string? Note);
public record PurchaseEvaluationDetailDto(
Guid Id,
string GroupCode,
string GroupName,
string? ItemCode,
string NoiDung,
string? DonViTinh,
decimal KhoiLuongNganSach,
decimal KhoiLuongThiCong,
decimal DonGiaNganSach,
decimal ThanhTienNganSach,
int Order,
string? GhiChu,
List<PurchaseEvaluationQuoteDto> Quotes);
public record PurchaseEvaluationApprovalDto(
Guid Id,
PurchaseEvaluationPhase FromPhase,
PurchaseEvaluationPhase ToPhase,
Guid? ApproverUserId,
string? ApproverName,
ApprovalDecision Decision,
string? Comment,
DateTime ApprovedAt);
public record PurchaseEvaluationChangelogDto(
Guid Id,
PurchaseEvaluationEntityType EntityType,
Guid? EntityId,
ChangelogAction Action,
PurchaseEvaluationPhase? PhaseAtChange,
Guid? UserId,
string? UserName,
string? Summary,
string? FieldChangesJson,
string? ContextNote,
DateTime CreatedAt);
public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName,
string PolicyDescription,
List<PurchaseEvaluationPhase> ActivePhases,
List<PurchaseEvaluationPhase> NextPhases);
public record PurchaseEvaluationDetailBundleDto(
Guid Id,
string? MaPhieu,
PurchaseEvaluationType Type,
PurchaseEvaluationPhase Phase,
string TenGoiThau,
string? DiaDiem,
string? MoTa,
Guid ProjectId,
string ProjectName,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
string? DrafterName,
Guid? SelectedSupplierId,
string? SelectedSupplierName,
Guid? ContractId,
string? PaymentTerms,
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<PurchaseEvaluationSupplierDto> Suppliers,
List<PurchaseEvaluationDetailDto> Details,
List<PurchaseEvaluationApprovalDto> Approvals,
PurchaseEvaluationWorkflowSummaryDto Workflow);

View File

@ -0,0 +1,249 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// ========== Detail (hạng mục + ngân sách) ==========
public record AddPurchaseEvaluationDetailCommand(
Guid PurchaseEvaluationId,
string GroupCode,
string GroupName,
string? ItemCode,
string NoiDung,
string? DonViTinh,
decimal KhoiLuongNganSach,
decimal KhoiLuongThiCong,
decimal DonGiaNganSach,
decimal ThanhTienNganSach,
string? GhiChu) : IRequest<Guid>;
public class AddPurchaseEvaluationDetailCommandValidator : AbstractValidator<AddPurchaseEvaluationDetailCommand>
{
public AddPurchaseEvaluationDetailCommandValidator()
{
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(50);
RuleFor(x => x.GroupName).NotEmpty().MaximumLength(200);
RuleFor(x => x.NoiDung).NotEmpty().MaximumLength(500);
}
}
public class AddPurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<AddPurchaseEvaluationDetailCommand, Guid>
{
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
{
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
var maxOrder = await db.PurchaseEvaluationDetails
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
.Select(d => (int?)d.Order)
.MaxAsync(ct);
var entity = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
GroupCode = request.GroupCode,
GroupName = request.GroupName,
ItemCode = request.ItemCode,
NoiDung = request.NoiDung,
DonViTinh = request.DonViTinh,
KhoiLuongNganSach = request.KhoiLuongNganSach,
KhoiLuongThiCong = request.KhoiLuongThiCong,
DonGiaNganSach = request.DonGiaNganSach,
ThanhTienNganSach = request.ThanhTienNganSach,
GhiChu = request.GhiChu,
Order = (maxOrder ?? 0) + 1,
};
db.PurchaseEvaluationDetails.Add(entity);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Detail,
EntityId = entity.Id,
Action = ChangelogAction.Insert,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Thêm hạng mục {request.GroupCode} — {request.NoiDung}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdatePurchaseEvaluationDetailCommand(
Guid PurchaseEvaluationId,
Guid DetailId,
string GroupCode,
string GroupName,
string? ItemCode,
string NoiDung,
string? DonViTinh,
decimal KhoiLuongNganSach,
decimal KhoiLuongThiCong,
decimal DonGiaNganSach,
decimal ThanhTienNganSach,
string? GhiChu) : IRequest;
public class UpdatePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
{
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
entity.GroupCode = request.GroupCode;
entity.GroupName = request.GroupName;
entity.ItemCode = request.ItemCode;
entity.NoiDung = request.NoiDung;
entity.DonViTinh = request.DonViTinh;
entity.KhoiLuongNganSach = request.KhoiLuongNganSach;
entity.KhoiLuongThiCong = request.KhoiLuongThiCong;
entity.DonGiaNganSach = request.DonGiaNganSach;
entity.ThanhTienNganSach = request.ThanhTienNganSach;
entity.GhiChu = request.GhiChu;
await db.SaveChangesAsync(ct);
}
}
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
public class DeletePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
{
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
db.PurchaseEvaluationDetails.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ========== Quote (báo giá per NCC per Detail) — upsert ==========
public record UpsertPurchaseEvaluationQuoteCommand(
Guid PurchaseEvaluationId,
Guid PurchaseEvaluationDetailId,
Guid PurchaseEvaluationSupplierId,
decimal BgVat,
decimal ChuaVat,
decimal ThanhTien,
bool IsSelected,
string? Note) : IRequest<Guid>;
public class UpsertPurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
{
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
{
// Verify parents exist + same phiếu
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.PurchaseEvaluationDetailId);
var supplier = await db.PurchaseEvaluationSuppliers.FirstOrDefaultAsync(
s => s.Id == request.PurchaseEvaluationSupplierId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.PurchaseEvaluationSupplierId);
var existing = await db.PurchaseEvaluationQuotes.FirstOrDefaultAsync(
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
if (existing is not null)
{
existing.BgVat = request.BgVat;
existing.ChuaVat = request.ChuaVat;
existing.ThanhTien = request.ThanhTien;
existing.IsSelected = request.IsSelected;
existing.Note = request.Note;
await db.SaveChangesAsync(ct);
return existing.Id;
}
var entity = new PurchaseEvaluationQuote
{
PurchaseEvaluationDetailId = request.PurchaseEvaluationDetailId,
PurchaseEvaluationSupplierId = request.PurchaseEvaluationSupplierId,
BgVat = request.BgVat,
ChuaVat = request.ChuaVat,
ThanhTien = request.ThanhTien,
IsSelected = request.IsSelected,
Note = request.Note,
};
db.PurchaseEvaluationQuotes.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
public class DeletePurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
{
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
{
var quote = await (
from q in db.PurchaseEvaluationQuotes
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
where q.Id == request.QuoteId && d.PurchaseEvaluationId == request.PurchaseEvaluationId
select q).FirstOrDefaultAsync(ct)
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId);
db.PurchaseEvaluationQuotes.Remove(quote);
await db.SaveChangesAsync(ct);
}
}
// ========== Select winner (NCC được chọn tổng thể) ==========
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid SupplierId) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<SelectPurchaseEvaluationWinnerCommand>
{
public async Task Handle(SelectPurchaseEvaluationWinnerCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
?? throw new NotFoundException("Supplier", request.SupplierId);
// Verify supplier nằm trong danh sách phiếu
var hasSupplier = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
entity.SelectedSupplierId = request.SupplierId;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = entity.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = "Chọn NCC trúng thầu",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,440 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.PurchaseEvaluations.Dtos;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// ========== CREATE ==========
public record CreatePurchaseEvaluationCommand(
PurchaseEvaluationType Type,
string TenGoiThau,
Guid ProjectId,
Guid? DepartmentId,
string? DiaDiem,
string? MoTa,
string? PaymentTerms) : IRequest<Guid>;
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{
public CreatePurchaseEvaluationCommandValidator()
{
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
}
}
public class CreatePurchaseEvaluationCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IPurchaseEvaluationWorkflowService workflow) : IRequestHandler<CreatePurchaseEvaluationCommand, Guid>
{
public async Task<Guid> Handle(CreatePurchaseEvaluationCommand request, CancellationToken ct)
{
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
?? throw new NotFoundException("Project", request.ProjectId);
var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Where(w => w.EvaluationType == request.Type && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
var entity = new PurchaseEvaluation
{
Type = request.Type,
Phase = PurchaseEvaluationPhase.DangSoanThao,
TenGoiThau = request.TenGoiThau,
ProjectId = request.ProjectId,
DepartmentId = request.DepartmentId,
DiaDiem = request.DiaDiem,
MoTa = request.MoTa,
DrafterUserId = currentUser.UserId,
WorkflowDefinitionId = activeWfId,
PaymentTerms = request.PaymentTerms,
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
};
// Auto-gen MaPhieu đơn giản PE-YYYYMM-XXXX (4 digit random) — format sau
entity.MaPhieu = $"PE-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}";
db.PurchaseEvaluations.Add(entity);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = entity.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Insert,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = $"Tạo phiếu {entity.MaPhieu} — {entity.TenGoiThau}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE draft ==========
public record UpdatePurchaseEvaluationDraftCommand(
Guid Id,
string TenGoiThau,
string? DiaDiem,
string? MoTa,
string? PaymentTerms) : IRequest;
public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand>
{
public async Task Handle(UpdatePurchaseEvaluationDraftCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao)
throw new ConflictException("Chỉ sửa được phiếu khi ở phase Đang soạn thảo.");
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
entity.PaymentTerms = request.PaymentTerms;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = entity.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = "Cập nhật thông tin phiếu",
});
await db.SaveChangesAsync(ct);
}
}
// ========== TRANSITION ==========
public record TransitionPurchaseEvaluationCommand(
Guid Id,
PurchaseEvaluationPhase TargetPhase,
ApprovalDecision Decision,
string? Comment) : IRequest;
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
{
public TransitionPurchaseEvaluationCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.TargetPhase).IsInEnum();
RuleFor(x => x.Decision).IsInEnum();
RuleFor(x => x.Comment).MaximumLength(1000);
}
}
public class TransitionPurchaseEvaluationCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IPurchaseEvaluationWorkflowService workflow) : IRequestHandler<TransitionPurchaseEvaluationCommand>
{
public async Task Handle(TransitionPurchaseEvaluationCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
await workflow.TransitionAsync(
entity,
request.TargetPhase,
currentUser.UserId,
currentUser.Roles,
request.Decision,
request.Comment,
ct);
}
}
// ========== LIST ==========
public record ListPurchaseEvaluationsQuery(
PurchaseEvaluationType? Type = null,
PurchaseEvaluationPhase? Phase = null,
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
public class ListPurchaseEvaluationsQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<ListPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
{
public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
ListPurchaseEvaluationsQuery request, CancellationToken ct)
{
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
select new { e, p, s };
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase
if (!currentUser.Roles.Contains(AppRoles.Admin))
{
var userId = currentUser.UserId;
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase));
}
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase);
if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
(x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) ||
x.e.TenGoiThau.Contains(s) ||
x.p.Name.Contains(s));
}
q = request.SortDesc ? q.OrderByDescending(x => x.e.CreatedAt) : q.OrderBy(x => x.e.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt))
.ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
}
internal static List<PurchaseEvaluationPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
{
var phases = new HashSet<PurchaseEvaluationPhase>();
void AddIfAny(string[] required, params PurchaseEvaluationPhase[] toAdd)
{
if (userRoles.Any(r => required.Contains(r)))
foreach (var p in toAdd) phases.Add(p);
}
AddIfAny([AppRoles.Drafter, AppRoles.DeptManager], PurchaseEvaluationPhase.DangSoanThao);
AddIfAny([AppRoles.Procurement], PurchaseEvaluationPhase.ChoPurchasing);
AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner],
PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC);
return phases.ToList();
}
}
// ========== INBOX ==========
public record GetMyPurchaseEvaluationInboxQuery(PurchaseEvaluationType? Type = null)
: IRequest<List<PurchaseEvaluationListItemDto>>;
public class GetMyPurchaseEvaluationInboxQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetMyPurchaseEvaluationInboxQuery, List<PurchaseEvaluationListItemDto>>
{
public async Task<List<PurchaseEvaluationListItemDto>> Handle(
GetMyPurchaseEvaluationInboxQuery request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
var userRoles = currentUser.Roles;
var isAdmin = userRoles.Contains(AppRoles.Admin);
var eligiblePhases = isAdmin
? [
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoDuAn,
PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetPA,
PurchaseEvaluationPhase.ChoCEODuyetNCC,
]
: ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles);
if (eligiblePhases.Count == 0) return [];
var q = from e in db.PurchaseEvaluations.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
from s in sj.DefaultIfEmpty()
where eligiblePhases.Contains(e.Phase)
select new { e, p, s };
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
return await q
.OrderBy(x => x.e.SlaDeadline ?? DateTime.MaxValue)
.Select(x => new PurchaseEvaluationListItemDto(
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
x.e.ProjectId, x.p.Name,
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt))
.Take(100)
.ToListAsync(ct);
}
}
// ========== GET detail bundle ==========
public record GetPurchaseEvaluationQuery(Guid Id) : IRequest<PurchaseEvaluationDetailBundleDto>;
public class GetPurchaseEvaluationQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager,
ICurrentUser currentUser) : IRequestHandler<GetPurchaseEvaluationQuery, PurchaseEvaluationDetailBundleDto>
{
public async Task<PurchaseEvaluationDetailBundleDto> Handle(
GetPurchaseEvaluationQuery request, CancellationToken ct)
{
var e = await db.PurchaseEvaluations.AsNoTracking()
.Include(x => x.Suppliers)
.Include(x => x.Details).ThenInclude(d => d.Quotes)
.Include(x => x.Approvals)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
if (!isAdmin)
{
var isDrafter = e.DrafterUserId == currentUser.UserId;
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
if (!isDrafter && !eligiblePhases.Contains(e.Phase))
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
}
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
// Load supplier names for PE suppliers + approver names
var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList();
var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, ct);
var userIds = new HashSet<Guid>();
if (e.DrafterUserId is Guid did) userIds.Add(did);
foreach (var a in e.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
var users = await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
// Resolve workflow policy
PurchaseEvaluationPolicy policy;
if (e.WorkflowDefinitionId is Guid wfId)
{
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = def is not null
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
: PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
}
else
{
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
}
return new PurchaseEvaluationDetailBundleDto(
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
e.ProjectId, project?.Name ?? "",
e.DepartmentId, department?.Name,
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.Suppliers
.OrderBy(s => s.Order)
.Select(s => new PurchaseEvaluationSupplierDto(
s.Id, s.SupplierId,
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
s.PaymentTermText, s.Note, s.Order))
.ToList(),
e.Details
.OrderBy(d => d.Order)
.Select(d => new PurchaseEvaluationDetailDto(
d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh,
d.KhoiLuongNganSach, d.KhoiLuongThiCong, d.DonGiaNganSach, d.ThanhTienNganSach,
d.Order, d.GhiChu,
d.Quotes.Select(q => new PurchaseEvaluationQuoteDto(
q.Id, q.PurchaseEvaluationDetailId, q.PurchaseEvaluationSupplierId,
q.BgVat, q.ChuaVat, q.ThanhTien, q.IsSelected, q.Note)).ToList()))
.ToList(),
e.Approvals
.OrderBy(a => a.ApprovedAt)
.Select(a => new PurchaseEvaluationApprovalDto(
a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
a.Decision, a.Comment, a.ApprovedAt))
.ToList(),
new PurchaseEvaluationWorkflowSummaryDto(
policy.Name, policy.Description,
policy.ActivePhases.ToList(),
policy.NextPhasesFrom(e.Phase).ToList()));
}
}
// ========== DELETE ==========
public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;
public class DeletePurchaseEvaluationCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
{
public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
&& entity.Phase != PurchaseEvaluationPhase.TuChoi)
throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");
db.PurchaseEvaluations.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ========== CHANGELOG list ==========
public record ListPurchaseEvaluationChangelogsQuery(Guid PurchaseEvaluationId, int Take = 200)
: IRequest<List<PurchaseEvaluationChangelogDto>>;
public class ListPurchaseEvaluationChangelogsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListPurchaseEvaluationChangelogsQuery, List<PurchaseEvaluationChangelogDto>>
{
public async Task<List<PurchaseEvaluationChangelogDto>> Handle(
ListPurchaseEvaluationChangelogsQuery request, CancellationToken ct)
{
return await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
.OrderByDescending(c => c.CreatedAt)
.Take(request.Take)
.Select(c => new PurchaseEvaluationChangelogDto(
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
c.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,137 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// Add NCC vào phiếu đánh giá (section II + E của form) — admin/drafter làm
// ở phase soạn thảo.
public record AddPurchaseEvaluationSupplierCommand(
Guid PurchaseEvaluationId,
Guid SupplierId,
string? DisplayName,
string? ContactName,
string? ContactEmail,
string? ContactPhone,
string? PaymentTermText,
string? Note) : IRequest<Guid>;
public class AddPurchaseEvaluationSupplierCommandValidator : AbstractValidator<AddPurchaseEvaluationSupplierCommand>
{
public AddPurchaseEvaluationSupplierCommandValidator()
{
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
RuleFor(x => x.SupplierId).NotEmpty();
RuleFor(x => x.DisplayName).MaximumLength(200);
RuleFor(x => x.ContactName).MaximumLength(200);
RuleFor(x => x.ContactEmail).MaximumLength(200);
RuleFor(x => x.ContactPhone).MaximumLength(50);
RuleFor(x => x.PaymentTermText).MaximumLength(200);
RuleFor(x => x.Note).MaximumLength(500);
}
}
public class AddPurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<AddPurchaseEvaluationSupplierCommand, Guid>
{
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
{
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
?? throw new NotFoundException("Supplier", request.SupplierId);
var dup = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
if (dup) throw new ConflictException("NCC đã được thêm vào phiếu.");
var maxOrder = await db.PurchaseEvaluationSuppliers
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
.Select(s => (int?)s.Order)
.MaxAsync(ct);
var entity = new PurchaseEvaluationSupplier
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
SupplierId = request.SupplierId,
DisplayName = request.DisplayName,
ContactName = request.ContactName,
ContactEmail = request.ContactEmail,
ContactPhone = request.ContactPhone,
PaymentTermText = request.PaymentTermText,
Note = request.Note,
Order = (maxOrder ?? 0) + 1,
};
db.PurchaseEvaluationSuppliers.Add(entity);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
EntityType = PurchaseEvaluationEntityType.Supplier,
EntityId = entity.Id,
Action = ChangelogAction.Insert,
PhaseAtChange = evaluation.Phase,
UserId = currentUser.UserId,
Summary = $"Thêm NCC {request.DisplayName ?? "#" + request.SupplierId.ToString()[..8]}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdatePurchaseEvaluationSupplierCommand(
Guid PurchaseEvaluationId,
Guid SupplierRowId,
string? DisplayName,
string? ContactName,
string? ContactEmail,
string? ContactPhone,
string? PaymentTermText,
string? Note) : IRequest;
public class UpdatePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
{
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct)
{
var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
row.DisplayName = request.DisplayName;
row.ContactName = request.ContactName;
row.ContactEmail = request.ContactEmail;
row.ContactPhone = request.ContactPhone;
row.PaymentTermText = request.PaymentTermText;
row.Note = request.Note;
await db.SaveChangesAsync(ct);
}
}
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
public class RemovePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
{
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct)
{
var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
// Block nếu có Quote row reference (FK Restrict) — user phải xóa quote trước
var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct);
if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước.");
db.PurchaseEvaluationSuppliers.Remove(row);
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,20 @@
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations.Services;
public interface IPurchaseEvaluationWorkflowService
{
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline.
Task TransitionAsync(
PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default);
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
}

View File

@ -42,13 +42,35 @@ public static class MenuKeys
// → mở /system/workflows/{typeCode} (filter theo type thay vì tab).
public static string WorkflowTypeLeaf(string typeCode) => $"Wf_{typeCode}";
// ============================================================
// Module Duyệt NCC (tiền-HĐ) — Pe_* prefix. 2 EvaluationType:
// DuyetNcc (A, NccOnly 3-step) + DuyetNccPhuongAn (B, NccWithPlan 5-step).
// Mỗi type có 3 action leaf (Danh sách / Thao tác / Duyệt) + 1 group.
// Workflow admin cho PE ở /system/pe-workflows/:typeCode.
// ============================================================
public const string PurchaseEvaluations = "PurchaseEvaluations"; // root group
public const string PeWorkflows = "PeWorkflows"; // workflow admin root
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
public static string PurchaseEvaluationGroup(string typeCode) => $"Pe_{typeCode}";
public static string PurchaseEvaluationList(string typeCode) => $"Pe_{typeCode}_List";
public static string PurchaseEvaluationCreate(string typeCode) => $"Pe_{typeCode}_Create";
public static string PurchaseEvaluationPending(string typeCode) => $"Pe_{typeCode}_Pending";
// Workflow admin leaf per PE type — dưới PeWorkflows, click leaf mở
// /system/pe-workflows/{typeCode}
public static string PeWorkflowTypeLeaf(string typeCode) => $"PeWf_{typeCode}";
public static readonly string[] All =
[
Dashboard,
Master, Suppliers, Projects, Departments,
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
Contracts, Forms, Reports,
System, Users, Roles, Permissions, Workflows,
PurchaseEvaluations,
System, Users, Roles, Permissions, Workflows, PeWorkflows,
];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -0,0 +1,36 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// Aggregate root cho phiếu Duyệt NCC — module tiền-HĐ.
// Sau khi phê duyệt xong (Phase=DaDuyet) user click "Tạo HĐ từ phiếu" →
// kế thừa Details/Quotes sang Contract + ContractDetails.
public class PurchaseEvaluation : AuditableEntity
{
public string? MaPhieu { get; set; } // Auto-gen khi create (format tính sau)
public PurchaseEvaluationType Type { get; set; }
public PurchaseEvaluationPhase Phase { get; set; } = PurchaseEvaluationPhase.DangSoanThao;
public string TenGoiThau { get; set; } = string.Empty; // "Cung cấp bê tông"
public Guid ProjectId { get; set; } // Dự án (FK Projects)
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; } // QS/NV.PB soạn
public string? DiaDiem { get; set; } // Lô K, KCN Lộc An...
public string? MoTa { get; set; }
public Guid? WorkflowDefinitionId { get; set; } // Pinned at create — config y như HĐ
public DateTime? SlaDeadline { get; set; }
public bool SlaWarningSent { get; set; }
public Guid? SelectedSupplierId { get; set; } // NCC thắng — null tới khi DaDuyet
public string? PaymentTerms { get; set; } // JSON {tamUng, thanhToanTam, quyetToan, baoHanh, hanMucCongNo, danhGia}
public Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
public List<PurchaseEvaluationApproval> Approvals { get; set; } = new();
public List<PurchaseEvaluationChangelog> Changelogs { get; set; } = new();
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
}

View File

@ -0,0 +1,18 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ApprovalDecision enum
namespace SolutionErp.Domain.PurchaseEvaluations;
// Lịch sử phê duyệt — giống ContractApproval pattern.
public class PurchaseEvaluationApproval : BaseEntity
{
public Guid PurchaseEvaluationId { get; set; }
public PurchaseEvaluationPhase FromPhase { get; set; }
public PurchaseEvaluationPhase ToPhase { get; set; }
public Guid? ApproverUserId { get; set; } // null = system SLA auto
public ApprovalDecision Decision { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
}

View File

@ -0,0 +1,25 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
public enum PurchaseEvaluationAttachmentPurpose
{
QuoteDocument = 1, // File báo giá NCC gửi (PDF/xlsx)
RequirementSpec = 2, // Bản vẽ/yêu cầu kỹ thuật kèm theo
DecisionExport = 3, // Bản phiếu duyệt đã export
Other = 99,
}
public class PurchaseEvaluationAttachment : BaseEntity
{
public Guid PurchaseEvaluationId { get; set; }
public Guid? PurchaseEvaluationSupplierId { get; set; } // Null nếu không gắn với NCC cụ thể
public string FileName { get; set; } = string.Empty;
public string StoragePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; } = string.Empty;
public PurchaseEvaluationAttachmentPurpose Purpose { get; set; }
public string? Note { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
}

View File

@ -0,0 +1,33 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ChangelogAction enum
namespace SolutionErp.Domain.PurchaseEvaluations;
// Audit log unified cho mọi thay đổi trên phiếu — Header / Supplier / Detail
// / Quote / Workflow / Attachment. Populate tương tự ContractChangelog qua
// IPurchaseEvaluationChangelogService.
public class PurchaseEvaluationChangelog : BaseEntity
{
public Guid PurchaseEvaluationId { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
public PurchaseEvaluationEntityType EntityType { get; set; }
public Guid? EntityId { get; set; }
public ChangelogAction Action { get; set; }
public PurchaseEvaluationPhase? PhaseAtChange { get; set; }
public Guid? UserId { get; set; }
public string? UserName { get; set; }
public string? Summary { get; set; }
public string? FieldChangesJson { get; set; }
public string? ContextNote { get; set; }
}
public enum PurchaseEvaluationEntityType
{
Header = 1,
Supplier = 2,
Detail = 3,
Quote = 4,
Workflow = 5,
Attachment = 6,
}

View File

@ -0,0 +1,24 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// Hạng mục so sánh giá + ngân sách (Excel III + "CHI TIẾT SO SÁNH").
// Tách Quotes ra PurchaseEvaluationQuote để normalize N NCC × M hạng mục.
public class PurchaseEvaluationDetail : BaseEntity
{
public Guid PurchaseEvaluationId { get; set; }
public string GroupCode { get; set; } = string.Empty; // "A.I", "A.II", "A.III", "A.IV"
public string GroupName { get; set; } = string.Empty; // "Bê tông", "Phụ gia", "Bơm bê tông", "Vận chuyển"
public string? ItemCode { get; set; } // "DMCCC0001"
public string NoiDung { get; set; } = string.Empty; // "Concrete M100"
public string? DonViTinh { get; set; } // "m3"
public decimal KhoiLuongNganSach { get; set; }
public decimal KhoiLuongThiCong { get; set; }
public decimal DonGiaNganSach { get; set; } // Chưa VAT
public decimal ThanhTienNganSach { get; set; } // = KL × Đơn giá (hoặc nhập tay)
public int Order { get; set; }
public string? GhiChu { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
}

View File

@ -0,0 +1,20 @@
namespace SolutionErp.Domain.PurchaseEvaluations;
// State machine cho phiếu Duyệt NCC (tiền-HĐ). 2 workflow khác nhau cùng
// share state space này — A (DuyetNcc) dùng subset, B (DuyetNccPhuongAn)
// dùng full.
//
// A: DangSoanThao → ChoPurchasing → ChoCCM → ChoCEODuyetNCC → DaDuyet
// B: DangSoanThao → ChoPurchasing → ChoDuAn → ChoCCM → ChoCEODuyetPA → ChoCEODuyetNCC → DaDuyet
// Cả 2: từ DangSoanThao có thể → TuChoi; từ mọi phase duyệt reject → DangSoanThao.
public enum PurchaseEvaluationPhase
{
DangSoanThao = 1,
ChoPurchasing = 2,
ChoDuAn = 3, // chỉ B
ChoCCM = 4,
ChoCEODuyetPA = 5, // chỉ B (duyệt phương án trước)
ChoCEODuyetNCC = 6, // chung cả A & B — duyệt chọn đơn vị
DaDuyet = 7, // terminal thành công
TuChoi = 99, // terminal từ chối
}

View File

@ -0,0 +1,203 @@
using SolutionErp.Domain.Contracts; // WorkflowApproverKind
using SolutionErp.Domain.Identity;
namespace SolutionErp.Domain.PurchaseEvaluations;
// Policy record cho phiếu Duyệt NCC — mirror WorkflowPolicy của HĐ nhưng
// dùng PurchaseEvaluationPhase enum. 2 default policy hardcoded (A/B)
// phục vụ seed + fallback khi admin chưa author định nghĩa DB.
public sealed record PurchaseEvaluationPolicy(
string Name,
string Description,
IReadOnlyDictionary<(PurchaseEvaluationPhase From, PurchaseEvaluationPhase To), string[]> Transitions,
IReadOnlyDictionary<PurchaseEvaluationPhase, TimeSpan?> PhaseSla,
IReadOnlyList<PurchaseEvaluationPhase> ActivePhases,
IReadOnlyDictionary<(PurchaseEvaluationPhase From, PurchaseEvaluationPhase To), string[]>? UserTransitions = null)
{
public bool HasPhase(PurchaseEvaluationPhase phase) => ActivePhases.Contains(phase);
public bool IsTransitionAllowed(
PurchaseEvaluationPhase from, PurchaseEvaluationPhase to,
IReadOnlyList<string> actorRoles, Guid? actorUserId = null)
{
if (!Transitions.TryGetValue((from, to), out var roles)) return false;
if (actorRoles.Any(r => roles.Contains(r))) return true;
if (actorUserId is null) return false;
if (UserTransitions is null) return false;
if (!UserTransitions.TryGetValue((from, to), out var userIds)) return false;
return userIds.Contains(actorUserId.Value.ToString());
}
public IReadOnlyList<PurchaseEvaluationPhase> NextPhasesFrom(PurchaseEvaluationPhase from) =>
Transitions.Keys.Where(k => k.From == from).Select(k => k.To).Distinct().ToList();
}
public static class PurchaseEvaluationPolicies
{
private static readonly Dictionary<PurchaseEvaluationPhase, TimeSpan?> DefaultSla = new()
{
[PurchaseEvaluationPhase.DangSoanThao] = TimeSpan.FromDays(3),
[PurchaseEvaluationPhase.ChoPurchasing] = TimeSpan.FromDays(2),
[PurchaseEvaluationPhase.ChoDuAn] = TimeSpan.FromDays(2),
[PurchaseEvaluationPhase.ChoCCM] = TimeSpan.FromDays(2),
[PurchaseEvaluationPhase.ChoCEODuyetPA] = TimeSpan.FromDays(1),
[PurchaseEvaluationPhase.ChoCEODuyetNCC] = TimeSpan.FromDays(1),
[PurchaseEvaluationPhase.DaDuyet] = null,
[PurchaseEvaluationPhase.TuChoi] = null,
};
// A — DuyetNcc (3 step thực + Drafter soạn): Drafter → Purchasing → CCM → CEO
public static readonly PurchaseEvaluationPolicy NccOnly = new(
Name: "NccOnly",
Description: "Duyệt NCC — 3 step (Purchasing → CCM → CEO). Không cần duyệt phương án.",
Transitions: new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]>
{
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetNCC,
PurchaseEvaluationPhase.DaDuyet,
PurchaseEvaluationPhase.TuChoi,
]);
// B — DuyetNccPhuongAn (5 step thực + Drafter): Drafter → Purchasing → Dự án → CCM → CEO(PA) → CEO(NCC)
public static readonly PurchaseEvaluationPolicy NccWithPlan = new(
Name: "NccWithPlan",
Description: "Duyệt NCC + Phương án — 5 step (Purchasing → Dự án → CCM → CEO duyệt PA → CEO duyệt NCC).",
Transitions: new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]>
{
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.ProjectManager],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoDuAn,
PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetPA,
PurchaseEvaluationPhase.ChoCEODuyetNCC,
PurchaseEvaluationPhase.DaDuyet,
PurchaseEvaluationPhase.TuChoi,
]);
}
public static class PurchaseEvaluationPolicyRegistry
{
public static readonly string[] AvailablePolicyNames = ["NccOnly", "NccWithPlan"];
public static PurchaseEvaluationPolicy ByName(string name) => name switch
{
"NccWithPlan" => PurchaseEvaluationPolicies.NccWithPlan,
_ => PurchaseEvaluationPolicies.NccOnly,
};
public static string DefaultPolicyNameFor(PurchaseEvaluationType type) => type switch
{
PurchaseEvaluationType.DuyetNccPhuongAn => "NccWithPlan",
_ => "NccOnly",
};
public static PurchaseEvaluationPolicy For(PurchaseEvaluationType type) =>
ByName(DefaultPolicyNameFor(type));
public static PurchaseEvaluationPolicy ForEvaluation(PurchaseEvaluation ev) =>
For(ev.Type);
// Build policy from persisted admin-authored definition (mirror
// WorkflowPolicyRegistry.FromDefinition for HĐ).
public static PurchaseEvaluationPolicy FromDefinition(PurchaseEvaluationWorkflowDefinition def)
{
var steps = def.Steps.OrderBy(s => s.Order).ToList();
var transitions = new Dictionary<(PurchaseEvaluationPhase From, PurchaseEvaluationPhase To), string[]>();
var userTransitions = new Dictionary<(PurchaseEvaluationPhase From, PurchaseEvaluationPhase To), string[]>();
var sla = new Dictionary<PurchaseEvaluationPhase, TimeSpan?>();
var activePhases = new List<PurchaseEvaluationPhase>();
PurchaseEvaluationPhase? prev = null;
foreach (var s in steps)
{
activePhases.Add(s.Phase);
sla[s.Phase] = s.SlaDays is int d ? TimeSpan.FromDays(d) : null;
var roles = s.Approvers
.Where(a => a.Kind == WorkflowApproverKind.Role)
.Select(a => a.AssignmentValue)
.Distinct()
.ToArray();
var hasUserKind = s.Approvers.Any(a => a.Kind == WorkflowApproverKind.User);
if (roles.Length == 0 && !hasUserKind) roles = [AppRoles.DeptManager];
var userIds = s.Approvers
.Where(a => a.Kind == WorkflowApproverKind.User)
.Select(a => a.AssignmentValue)
.Distinct()
.ToArray();
if (prev is not null)
{
transitions[(prev.Value, s.Phase)] = roles;
if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds;
// Reject path back to Drafter (common pattern)
if (prev.Value != PurchaseEvaluationPhase.DangSoanThao && s.Phase != PurchaseEvaluationPhase.DangSoanThao)
{
transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), roles);
if (userIds.Length > 0)
userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), userIds);
}
}
prev = s.Phase;
}
// First step có thể reject to TuChoi
if (steps.Count > 0)
transitions.TryAdd((steps[0].Phase, PurchaseEvaluationPhase.TuChoi),
[AppRoles.Drafter, AppRoles.DeptManager]);
// Terminal states always available
if (!activePhases.Contains(PurchaseEvaluationPhase.TuChoi))
activePhases.Add(PurchaseEvaluationPhase.TuChoi);
if (!activePhases.Contains(PurchaseEvaluationPhase.DaDuyet))
activePhases.Add(PurchaseEvaluationPhase.DaDuyet);
return new PurchaseEvaluationPolicy(
Name: $"{def.Code}-v{def.Version:D2}",
Description: def.Description ?? def.Name,
Transitions: transitions,
PhaseSla: sla,
ActivePhases: activePhases,
UserTransitions: userTransitions.Count > 0 ? userTransitions : null);
}
}

View File

@ -0,0 +1,20 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// Báo giá N NCC × M hạng mục. 1 row = 1 NCC chào 1 hạng mục.
// IsSelected cho per-item winner (nếu user chọn mỗi hạng mục 1 NCC khác nhau);
// PurchaseEvaluation.SelectedSupplierId là winner tổng thể (trường hợp 1 NCC thắng toàn bộ).
public class PurchaseEvaluationQuote : BaseEntity
{
public Guid PurchaseEvaluationDetailId { get; set; }
public Guid PurchaseEvaluationSupplierId { get; set; } // FK PurchaseEvaluationSuppliers
public decimal BgVat { get; set; } // Báo giá NCC gửi (đã VAT)
public decimal ChuaVat { get; set; } // Chưa VAT
public decimal ThanhTien { get; set; } // = KL × ChuaVat (tính sẵn)
public bool IsSelected { get; set; } // NCC được chọn cho hạng mục này
public string? Note { get; set; }
public PurchaseEvaluationDetail? Detail { get; set; }
public PurchaseEvaluationSupplier? Supplier { get; set; }
}

View File

@ -0,0 +1,22 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// N:M giữa PurchaseEvaluation × Supplier (master). Lưu contact + payment
// term per NCC (file Excel section E + cột header "TGN-30 ngày" /
// "Tiến Phát" / ... ngay dưới bảng II). Note chứa yellow chip:
// "ĐÃ CHỐT SO SÁNH LẦN 1/2", "ĐÀM PHÁN THÊM".
public class PurchaseEvaluationSupplier : BaseEntity
{
public Guid PurchaseEvaluationId { get; set; }
public Guid SupplierId { get; set; } // FK Suppliers master
public string? DisplayName { get; set; } // Override nếu khác Supplier.Name (vd kèm term: "TGN-30 ngày")
public string? ContactName { get; set; }
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
public string? PaymentTermText { get; set; } // Free text: "30 ngày", "45 ngày", "300tr"
public string? Note { get; set; } // Chip trạng thái so sánh
public int Order { get; set; } // Thứ tự cột trong bảng so sánh
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace SolutionErp.Domain.PurchaseEvaluations;
// 2 quy trình theo flowchart QT chọn NTP/NCC:
// A — NccOnly (3 step): Purchasing → CCM → CEO
// B — NccWithPlan (5 step): Purchasing → Dự án → CCM → CEO (PA) → CEO (NCC)
public enum PurchaseEvaluationType
{
DuyetNcc = 1, // A
DuyetNccPhuongAn = 2, // B
}

View File

@ -0,0 +1,46 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse WorkflowApproverKind
namespace SolutionErp.Domain.PurchaseEvaluations;
// Versioned workflow definition cho module Duyệt NCC — pattern giống HĐ
// nhưng tách table riêng vì Phase là PurchaseEvaluationPhase enum (không
// phải ContractPhase). Admin có UI /system/pe-workflows/:typeCode tương
// tự /system/workflows/:typeCode.
//
// Invariant: AT MOST ONE IsActive=true per PurchaseEvaluationType tại 1
// thời điểm. PurchaseEvaluation.WorkflowDefinitionId pin tại create →
// phiếu cũ không bị ảnh hưởng khi admin active version mới.
public class PurchaseEvaluationWorkflowDefinition : BaseEntity
{
public string Code { get; set; } = string.Empty; // "QT-DN-A" / "QT-DN-B" default
public int Version { get; set; }
public PurchaseEvaluationType EvaluationType { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public List<PurchaseEvaluationWorkflowStep> Steps { get; set; } = new();
}
public class PurchaseEvaluationWorkflowStep : BaseEntity
{
public Guid PurchaseEvaluationWorkflowDefinitionId { get; set; }
public int Order { get; set; }
public PurchaseEvaluationPhase Phase { get; set; }
public string Name { get; set; } = string.Empty;
public int? SlaDays { get; set; }
public PurchaseEvaluationWorkflowDefinition? Definition { get; set; }
public List<PurchaseEvaluationWorkflowStepApprover> Approvers { get; set; } = new();
}
public class PurchaseEvaluationWorkflowStepApprover : BaseEntity
{
public Guid PurchaseEvaluationWorkflowStepId { get; set; }
public WorkflowApproverKind Kind { get; set; } // reuse Role/User enum từ Contract
public string AssignmentValue { get; set; } = string.Empty;
public PurchaseEvaluationWorkflowStep? Step { get; set; }
}

View File

@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Application.Reports.Services;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
@ -32,6 +33,7 @@ public static class DependencyInjection
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>();

View File

@ -8,6 +8,7 @@ using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence;
@ -46,6 +47,17 @@ public class ApplicationDbContext
public DbSet<NguyenTacNccDetail> NguyenTacNccDetails => Set<NguyenTacNccDetail>();
public DbSet<NguyenTacDvDetail> NguyenTacDvDetails => Set<NguyenTacDvDetail>();
public DbSet<PurchaseEvaluation> PurchaseEvaluations => Set<PurchaseEvaluation>();
public DbSet<PurchaseEvaluationSupplier> PurchaseEvaluationSuppliers => Set<PurchaseEvaluationSupplier>();
public DbSet<PurchaseEvaluationDetail> PurchaseEvaluationDetails => Set<PurchaseEvaluationDetail>();
public DbSet<PurchaseEvaluationQuote> PurchaseEvaluationQuotes => Set<PurchaseEvaluationQuote>();
public DbSet<PurchaseEvaluationApproval> PurchaseEvaluationApprovals => Set<PurchaseEvaluationApproval>();
public DbSet<PurchaseEvaluationChangelog> PurchaseEvaluationChangelogs => Set<PurchaseEvaluationChangelog>();
public DbSet<PurchaseEvaluationAttachment> PurchaseEvaluationAttachments => Set<PurchaseEvaluationAttachment>();
public DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions => Set<PurchaseEvaluationWorkflowDefinition>();
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,205 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<PurchaseEvaluation>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluation> b)
{
b.ToTable("PurchaseEvaluations");
b.HasKey(x => x.Id);
b.Property(x => x.MaPhieu).HasMaxLength(100);
b.Property(x => x.Type).HasConversion<int>();
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.TenGoiThau).HasMaxLength(500).IsRequired();
b.Property(x => x.DiaDiem).HasMaxLength(500);
b.Property(x => x.MoTa).HasMaxLength(2000);
b.Property(x => x.PaymentTerms).HasColumnType("nvarchar(max)");
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.WorkflowDefinitionId);
b.HasIndex(x => x.ContractId);
b.HasMany(x => x.Suppliers).WithOne(s => s.PurchaseEvaluation).HasForeignKey(s => s.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Details).WithOne(d => d.PurchaseEvaluation).HasForeignKey(d => d.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Approvals).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Changelogs).WithOne(c => c.PurchaseEvaluation).HasForeignKey(c => c.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Attachments).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
// Quotes không FK trực tiếp tới PurchaseEvaluation (đi qua Detail) —
// nhưng collection navigation có nên cần config riêng bên dưới.
b.HasQueryFilter(x => !x.IsDeleted);
}
}
public class PurchaseEvaluationSupplierConfiguration : IEntityTypeConfiguration<PurchaseEvaluationSupplier>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationSupplier> b)
{
b.ToTable("PurchaseEvaluationSuppliers");
b.HasKey(x => x.Id);
b.Property(x => x.DisplayName).HasMaxLength(200);
b.Property(x => x.ContactName).HasMaxLength(200);
b.Property(x => x.ContactEmail).HasMaxLength(200);
b.Property(x => x.ContactPhone).HasMaxLength(50);
b.Property(x => x.PaymentTermText).HasMaxLength(200);
b.Property(x => x.Note).HasMaxLength(500);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();
b.HasIndex(x => x.SupplierId);
}
}
public class PurchaseEvaluationDetailConfiguration : IEntityTypeConfiguration<PurchaseEvaluationDetail>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationDetail> b)
{
b.ToTable("PurchaseEvaluationDetails");
b.HasKey(x => x.Id);
b.Property(x => x.GroupCode).HasMaxLength(50).IsRequired();
b.Property(x => x.GroupName).HasMaxLength(200).IsRequired();
b.Property(x => x.ItemCode).HasMaxLength(100);
b.Property(x => x.NoiDung).HasMaxLength(500).IsRequired();
b.Property(x => x.DonViTinh).HasMaxLength(50);
b.Property(x => x.GhiChu).HasMaxLength(1000);
b.Property(x => x.KhoiLuongNganSach).HasPrecision(18, 4);
b.Property(x => x.KhoiLuongThiCong).HasPrecision(18, 4);
b.Property(x => x.DonGiaNganSach).HasPrecision(18, 2);
b.Property(x => x.ThanhTienNganSach).HasPrecision(18, 2);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.Order });
b.HasMany(x => x.Quotes).WithOne(q => q.Detail).HasForeignKey(q => q.PurchaseEvaluationDetailId).OnDelete(DeleteBehavior.Cascade);
}
}
public class PurchaseEvaluationQuoteConfiguration : IEntityTypeConfiguration<PurchaseEvaluationQuote>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationQuote> b)
{
b.ToTable("PurchaseEvaluationQuotes");
b.HasKey(x => x.Id);
b.Property(x => x.BgVat).HasPrecision(18, 2);
b.Property(x => x.ChuaVat).HasPrecision(18, 2);
b.Property(x => x.ThanhTien).HasPrecision(18, 2);
b.Property(x => x.Note).HasMaxLength(500);
b.HasIndex(x => new { x.PurchaseEvaluationDetailId, x.PurchaseEvaluationSupplierId }).IsUnique();
// Quote → Supplier (restrict — không xóa Supplier-row khỏi phiếu nếu còn quote)
b.HasOne(x => x.Supplier)
.WithMany()
.HasForeignKey(x => x.PurchaseEvaluationSupplierId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public class PurchaseEvaluationApprovalConfiguration : IEntityTypeConfiguration<PurchaseEvaluationApproval>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationApproval> b)
{
b.ToTable("PurchaseEvaluationApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.FromPhase).HasConversion<int>();
b.Property(x => x.ToPhase).HasConversion<int>();
b.Property(x => x.Decision).HasConversion<int>();
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.ApprovedAt });
}
}
public class PurchaseEvaluationChangelogConfiguration : IEntityTypeConfiguration<PurchaseEvaluationChangelog>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationChangelog> b)
{
b.ToTable("PurchaseEvaluationChangelogs");
b.HasKey(x => x.Id);
b.Property(x => x.EntityType).HasConversion<int>();
b.Property(x => x.Action).HasConversion<int>();
b.Property(x => x.PhaseAtChange).HasConversion<int>();
b.Property(x => x.UserName).HasMaxLength(200);
b.Property(x => x.Summary).HasMaxLength(500);
b.Property(x => x.ContextNote).HasMaxLength(2000);
b.Property(x => x.FieldChangesJson).HasColumnType("nvarchar(max)");
b.HasIndex(x => new { x.PurchaseEvaluationId, x.CreatedAt });
b.HasIndex(x => new { x.PurchaseEvaluationId, x.EntityType });
}
}
public class PurchaseEvaluationAttachmentConfiguration : IEntityTypeConfiguration<PurchaseEvaluationAttachment>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationAttachment> b)
{
b.ToTable("PurchaseEvaluationAttachments");
b.HasKey(x => x.Id);
b.Property(x => x.FileName).HasMaxLength(255).IsRequired();
b.Property(x => x.StoragePath).HasMaxLength(500).IsRequired();
b.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
b.Property(x => x.Purpose).HasConversion<int>();
b.Property(x => x.Note).HasMaxLength(500);
b.HasIndex(x => x.PurchaseEvaluationId);
b.HasIndex(x => x.PurchaseEvaluationSupplierId);
}
}
public class PurchaseEvaluationWorkflowDefinitionConfiguration : IEntityTypeConfiguration<PurchaseEvaluationWorkflowDefinition>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationWorkflowDefinition> e)
{
e.ToTable("PurchaseEvaluationWorkflowDefinitions");
e.Property(x => x.Code).HasMaxLength(100).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.EvaluationType).HasConversion<int>();
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
e.HasIndex(x => new { x.EvaluationType, x.IsActive });
}
}
public class PurchaseEvaluationWorkflowStepConfiguration : IEntityTypeConfiguration<PurchaseEvaluationWorkflowStep>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationWorkflowStep> e)
{
e.ToTable("PurchaseEvaluationWorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Phase).HasConversion<int>();
e.HasOne(x => x.Definition)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.PurchaseEvaluationWorkflowDefinitionId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.PurchaseEvaluationWorkflowDefinitionId, x.Order });
}
}
public class PurchaseEvaluationWorkflowStepApproverConfiguration : IEntityTypeConfiguration<PurchaseEvaluationWorkflowStepApprover>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationWorkflowStepApprover> e)
{
e.ToTable("PurchaseEvaluationWorkflowStepApprovers");
e.Property(x => x.Kind).HasConversion<int>();
e.Property(x => x.AssignmentValue).HasMaxLength(100).IsRequired();
e.HasOne(x => x.Step)
.WithMany(s => s.Approvers)
.HasForeignKey(x => x.PurchaseEvaluationWorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -8,6 +8,7 @@ using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence;
@ -37,6 +38,7 @@ public static class DbInitializer
await SeedDemoMasterDataAsync(db, logger);
await SeedContractTemplatesAsync(db, logger);
await SeedWorkflowDefinitionsAsync(db, logger);
await SeedPurchaseEvaluationWorkflowsAsync(db, logger);
await SeedCatalogsAsync(db, logger);
// Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create).
@ -317,6 +319,79 @@ public static class DbInitializer
}
}
// Seed default workflow v01 cho 2 PurchaseEvaluationType (A NccOnly / B
// NccWithPlan) — mirror SeedWorkflowDefinitionsAsync của HĐ. Admin có
// thể tạo version mới qua /system/pe-workflows/:typeCode designer.
private static async Task SeedPurchaseEvaluationWorkflowsAsync(ApplicationDbContext db, ILogger logger)
{
var typeLabels = new Dictionary<PurchaseEvaluationType, (string Code, string Name)>
{
[PurchaseEvaluationType.DuyetNcc] = ("QT-DN-A", "Quy trình Duyệt NCC"),
[PurchaseEvaluationType.DuyetNccPhuongAn] = ("QT-DN-B", "Quy trình Duyệt NCC - Phương Án"),
};
var phaseNames = new Dictionary<PurchaseEvaluationPhase, string>
{
[PurchaseEvaluationPhase.DangSoanThao] = "Soạn thảo",
[PurchaseEvaluationPhase.ChoPurchasing] = "Purchasing tổng hợp",
[PurchaseEvaluationPhase.ChoDuAn] = "Dự án kiểm tra",
[PurchaseEvaluationPhase.ChoCCM] = "CCM kiểm tra ngân sách",
[PurchaseEvaluationPhase.ChoCEODuyetPA] = "CEO duyệt phương án",
[PurchaseEvaluationPhase.ChoCEODuyetNCC] = "CEO duyệt chọn đơn vị",
[PurchaseEvaluationPhase.DaDuyet] = "Đã duyệt",
};
var added = 0;
foreach (var (type, info) in typeLabels)
{
var alreadyExists = await db.PurchaseEvaluationWorkflowDefinitions
.AnyAsync(w => w.EvaluationType == type);
if (alreadyExists) continue;
var policy = PurchaseEvaluationPolicyRegistry.For(type);
var def = new PurchaseEvaluationWorkflowDefinition
{
Code = info.Code,
Version = 1,
EvaluationType = type,
Name = $"{info.Name} (v01)",
Description = policy.Description,
IsActive = true,
ActivatedAt = DateTime.UtcNow,
Steps = policy.ActivePhases
.Where(p => p != PurchaseEvaluationPhase.TuChoi && p != PurchaseEvaluationPhase.DaDuyet)
.Select((p, idx) =>
{
var roles = policy.Transitions
.Where(t => t.Key.To == p)
.SelectMany(t => t.Value)
.Distinct()
.ToList();
return new PurchaseEvaluationWorkflowStep
{
Order = idx + 1,
Phase = p,
Name = phaseNames.GetValueOrDefault(p, p.ToString()),
SlaDays = policy.PhaseSla.GetValueOrDefault(p) is TimeSpan s ? (int?)s.Days : null,
Approvers = roles.Select(r => new PurchaseEvaluationWorkflowStepApprover
{
Kind = WorkflowApproverKind.Role,
AssignmentValue = r,
}).ToList(),
};
})
.ToList(),
};
db.PurchaseEvaluationWorkflowDefinitions.Add(def);
added++;
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} PE workflow definitions (v01)", added);
}
}
// Map ContractType → preferred supplier code (đa dạng dữ liệu demo theo
// đúng business: ThauPhu/GiaoKhoan đi với NTP/TĐ, NCC/MuaBan/NTNcc đi
// với NCC, DichVu/NTDv đi với DV).
@ -786,6 +861,9 @@ public static class DbInitializer
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
// Module Duyệt NCC (tiền-HĐ)
(MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
@ -809,6 +887,30 @@ public static class DbInitializer
tree.Add((MenuKeys.WorkflowTypeLeaf(code), label, MenuKeys.Workflows, wfOrder++, "FileText"));
}
// Pe_* group per PurchaseEvaluationType + 3 action leaves each
var peTypeLabels = new Dictionary<string, string>
{
["DuyetNcc"] = "Quy trình Duyệt NCC",
["DuyetNccPhuongAn"] = "Quy trình Duyệt NCC - Phương Án",
};
var peOrder = 1;
foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)
{
var label = peTypeLabels.GetValueOrDefault(code, code);
tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck"));
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List"));
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus"));
tree.Add((MenuKeys.PurchaseEvaluationPending(code),"Duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
}
// PE workflow admin leaves dưới `PeWorkflows`
var peWfOrder = 96;
foreach (var code in MenuKeys.PurchaseEvaluationTypeCodes)
{
var label = peTypeLabels.GetValueOrDefault(code, code);
tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck"));
}
var existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync();
var added = 0;
foreach (var (key, label, parent, o, icon) in tree)

View File

@ -0,0 +1,455 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseEvaluations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseEvaluations",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaPhieu = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Type = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
TenGoiThau = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DiaDiem = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
MoTa = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
WorkflowDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
SelectedSupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
PaymentTerms = table.Column<string>(type: "nvarchar(max)", nullable: true),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationWorkflowDefinitions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Version = table.Column<int>(type: "int", nullable: false),
EvaluationType = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationWorkflowDefinitions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Decision = table.Column<int>(type: "int", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationApprovals", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationApprovals_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationSupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
FileName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Purpose = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationAttachments", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationAttachments_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationChangelogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EntityType = table.Column<int>(type: "int", nullable: false),
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Action = table.Column<int>(type: "int", nullable: false),
PhaseAtChange = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Summary = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
FieldChangesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ContextNote = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationChangelogs", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationChangelogs_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationDetails",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
GroupCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
GroupName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
ItemCode = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
NoiDung = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
DonViTinh = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
KhoiLuongNganSach = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
KhoiLuongThiCong = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
DonGiaNganSach = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ThanhTienNganSach = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
GhiChu = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationDetails", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationDetails_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationSuppliers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ContactName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ContactEmail = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ContactPhone = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
PaymentTermText = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Order = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationSuppliers", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationSuppliers_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationWorkflowSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationWorkflowDefinitionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
SlaDays = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationWorkflowSteps", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationWorkflowSteps_PurchaseEvaluationWorkflowDefinitions_PurchaseEvaluationWorkflowDefinitionId",
column: x => x.PurchaseEvaluationWorkflowDefinitionId,
principalTable: "PurchaseEvaluationWorkflowDefinitions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationQuotes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationDetailId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationSupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BgVat = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ChuaVat = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ThanhTien = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
IsSelected = table.Column<bool>(type: "bit", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationQuotes", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationQuotes_PurchaseEvaluationDetails_PurchaseEvaluationDetailId",
column: x => x.PurchaseEvaluationDetailId,
principalTable: "PurchaseEvaluationDetails",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PurchaseEvaluationQuotes_PurchaseEvaluationSuppliers_PurchaseEvaluationSupplierId",
column: x => x.PurchaseEvaluationSupplierId,
principalTable: "PurchaseEvaluationSuppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseEvaluationQuotes_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "PurchaseEvaluationWorkflowStepApprovers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationWorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
AssignmentValue = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationWorkflowStepApprovers", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationWorkflowStepApprovers_PurchaseEvaluationWorkflowSteps_PurchaseEvaluationWorkflowStepId",
column: x => x.PurchaseEvaluationWorkflowStepId,
principalTable: "PurchaseEvaluationWorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationApprovals_PurchaseEvaluationId_ApprovedAt",
table: "PurchaseEvaluationApprovals",
columns: new[] { "PurchaseEvaluationId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationAttachments_PurchaseEvaluationId",
table: "PurchaseEvaluationAttachments",
column: "PurchaseEvaluationId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationAttachments_PurchaseEvaluationSupplierId",
table: "PurchaseEvaluationAttachments",
column: "PurchaseEvaluationSupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationChangelogs_PurchaseEvaluationId_CreatedAt",
table: "PurchaseEvaluationChangelogs",
columns: new[] { "PurchaseEvaluationId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationChangelogs_PurchaseEvaluationId_EntityType",
table: "PurchaseEvaluationChangelogs",
columns: new[] { "PurchaseEvaluationId", "EntityType" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDetails_PurchaseEvaluationId_Order",
table: "PurchaseEvaluationDetails",
columns: new[] { "PurchaseEvaluationId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationQuotes_PurchaseEvaluationDetailId_PurchaseEvaluationSupplierId",
table: "PurchaseEvaluationQuotes",
columns: new[] { "PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationQuotes_PurchaseEvaluationId",
table: "PurchaseEvaluationQuotes",
column: "PurchaseEvaluationId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationQuotes_PurchaseEvaluationSupplierId",
table: "PurchaseEvaluationQuotes",
column: "PurchaseEvaluationSupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_ContractId",
table: "PurchaseEvaluations",
column: "ContractId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_MaPhieu",
table: "PurchaseEvaluations",
column: "MaPhieu",
unique: true,
filter: "[MaPhieu] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_Phase_IsDeleted",
table: "PurchaseEvaluations",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_ProjectId",
table: "PurchaseEvaluations",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_SlaDeadline",
table: "PurchaseEvaluations",
column: "SlaDeadline");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_WorkflowDefinitionId",
table: "PurchaseEvaluations",
column: "WorkflowDefinitionId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationSuppliers_PurchaseEvaluationId_SupplierId",
table: "PurchaseEvaluationSuppliers",
columns: new[] { "PurchaseEvaluationId", "SupplierId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationSuppliers_SupplierId",
table: "PurchaseEvaluationSuppliers",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowDefinitions_Code_Version",
table: "PurchaseEvaluationWorkflowDefinitions",
columns: new[] { "Code", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowDefinitions_EvaluationType_IsActive",
table: "PurchaseEvaluationWorkflowDefinitions",
columns: new[] { "EvaluationType", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowStepApprovers_PurchaseEvaluationWorkflowStepId",
table: "PurchaseEvaluationWorkflowStepApprovers",
column: "PurchaseEvaluationWorkflowStepId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowSteps_PurchaseEvaluationWorkflowDefinitionId_Order",
table: "PurchaseEvaluationWorkflowSteps",
columns: new[] { "PurchaseEvaluationWorkflowDefinitionId", "Order" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseEvaluationApprovals");
migrationBuilder.DropTable(
name: "PurchaseEvaluationAttachments");
migrationBuilder.DropTable(
name: "PurchaseEvaluationChangelogs");
migrationBuilder.DropTable(
name: "PurchaseEvaluationQuotes");
migrationBuilder.DropTable(
name: "PurchaseEvaluationWorkflowStepApprovers");
migrationBuilder.DropTable(
name: "PurchaseEvaluationDetails");
migrationBuilder.DropTable(
name: "PurchaseEvaluationSuppliers");
migrationBuilder.DropTable(
name: "PurchaseEvaluationWorkflowSteps");
migrationBuilder.DropTable(
name: "PurchaseEvaluations");
migrationBuilder.DropTable(
name: "PurchaseEvaluationWorkflowDefinitions");
}
}
}

View File

@ -1904,6 +1904,592 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DiaDiem")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaPhieu")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("MoTa")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("PaymentTerms")
.HasColumnType("nvarchar(max)");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("SelectedSupplierId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<string>("TenGoiThau")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("WorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId");
b.HasIndex("MaPhieu")
.IsUnique()
.HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex("ProjectId");
b.HasIndex("SlaDeadline");
b.HasIndex("WorkflowDefinitionId");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("PurchaseEvaluations", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Decision")
.HasColumnType("int");
b.Property<int>("FromPhase")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<int>("ToPhase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId", "ApprovedAt");
b.ToTable("PurchaseEvaluationApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("PurchaseEvaluationSupplierId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Purpose")
.HasColumnType("int");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("PurchaseEvaluationSupplierId");
b.ToTable("PurchaseEvaluationAttachments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Action")
.HasColumnType("int");
b.Property<string>("ContextNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<int>("EntityType")
.HasColumnType("int");
b.Property<string>("FieldChangesJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PhaseAtChange")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Summary")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId", "CreatedAt");
b.HasIndex("PurchaseEvaluationId", "EntityType");
b.ToTable("PurchaseEvaluationChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("DonGiaNganSach")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("DonViTinh")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("GhiChu")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("GroupCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("GroupName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ItemCode")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal>("KhoiLuongNganSach")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("KhoiLuongThiCong")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("NoiDung")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("ThanhTienNganSach")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId", "Order");
b.ToTable("PurchaseEvaluationDetails", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal>("BgVat")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ChuaVat")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsSelected")
.HasColumnType("bit");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("PurchaseEvaluationDetailId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("PurchaseEvaluationSupplierId")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("ThanhTien")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId");
b.HasIndex("PurchaseEvaluationSupplierId");
b.HasIndex("PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId")
.IsUnique();
b.ToTable("PurchaseEvaluationQuotes", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContactEmail")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ContactName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ContactPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<string>("PaymentTermText")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SupplierId");
b.HasIndex("PurchaseEvaluationId", "SupplierId")
.IsUnique();
b.ToTable("PurchaseEvaluationSuppliers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("EvaluationType")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Code", "Version")
.IsUnique();
b.HasIndex("EvaluationType", "IsActive");
b.ToTable("PurchaseEvaluationWorkflowDefinitions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationWorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("SlaDays")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order");
b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssignmentValue")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationWorkflowStepId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationWorkflowStepId");
b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
@ -2135,6 +2721,106 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("Approvals")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("Attachments")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("Changelogs")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("Details")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail")
.WithMany("Quotes")
.HasForeignKey("PurchaseEvaluationDetailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", null)
.WithMany("Quotes")
.HasForeignKey("PurchaseEvaluationId");
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", "Supplier")
.WithMany()
.HasForeignKey("PurchaseEvaluationSupplierId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Detail");
b.Navigation("Supplier");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("Suppliers")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition")
.WithMany("Steps")
.HasForeignKey("PurchaseEvaluationWorkflowDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Definition");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step")
.WithMany("Approvers")
.HasForeignKey("PurchaseEvaluationWorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");
@ -2176,6 +2862,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Navigation("Approvals");
b.Navigation("Attachments");
b.Navigation("Changelogs");
b.Navigation("Details");
b.Navigation("Quotes");
b.Navigation("Suppliers");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.Navigation("Quotes");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
{
b.Navigation("Approvers");
});
#pragma warning restore 612, 618
}
}

View File

@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Services;
// Mirror ContractWorkflowService. Load policy từ pinned
// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry.
public class PurchaseEvaluationWorkflowService(
IApplicationDbContext db,
IDateTime dateTime,
INotificationService notifications,
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
{
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
public async Task TransitionAsync(
PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default)
{
if (evaluation.Phase == targetPhase)
throw new ConflictException("Phiếu đã ở phase đích.");
PurchaseEvaluationPolicy policy;
if (evaluation.WorkflowDefinitionId is Guid wfId)
{
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = def is not null
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
}
else
{
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
}
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
if (!isAdmin && !isSystem)
{
if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles))
throw new ForbiddenException(
$"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}.");
if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId))
{
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " +
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
}
}
var fromPhase = evaluation.Phase;
evaluation.SlaWarningSent = false;
evaluation.Phase = targetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
});
// Resolve actor name for changelog
string? actorName = null;
if (actorUserId is Guid uid)
{
var user = await userManager.FindByIdAsync(uid.ToString());
actorName = user?.FullName ?? user?.Email;
}
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = targetPhase,
UserId = actorUserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
ContextNote = comment,
});
// Notify drafter
if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
{
var title = targetPhase switch
{
PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối",
_ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
};
var type = targetPhase switch
{
PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished,
PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected,
_ => NotificationType.ContractPhaseTransition,
};
await notifications.NotifyAsync(
drafterId, type, title,
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
href: $"/purchase-evaluations/{evaluation.Id}",
refId: evaluation.Id,
ct: ct);
}
await db.SaveChangesAsync(ct);
}
}