Compare commits
6 Commits
a7ea6ad3d6
...
3990066b04
| Author | SHA1 | Date | |
|---|---|---|---|
| 3990066b04 | |||
| aaf03be8d7 | |||
| a385d70c2e | |||
| a737196b21 | |||
| 4678d192e2 | |||
| 2c6f0cabfb |
@ -52,8 +52,8 @@ Internet
|
|||||||
|
|
||||||
**SPA web.config:** 2 FE có `URL Rewrite` rule:
|
**SPA web.config:** 2 FE có `URL Rewrite` rule:
|
||||||
1. HTTP → HTTPS redirect (bắt buộc, CORS whitelist chỉ https)
|
1. HTTP → HTTPS redirect (bắt buộc, CORS whitelist chỉ https)
|
||||||
2. `/api/* → http://localhost:5443/api/*` (ARR reverse proxy)
|
2. `/api/* → http://127.0.0.1:5443/api/*` (ARR reverse proxy)
|
||||||
3. `/hubs/* → http://localhost:5443/hubs/*` (SignalR)
|
3. `/hubs/* → http://127.0.0.1:5443/hubs/*` (SignalR)
|
||||||
4. React Router fallback: `/*` → `/index.html`
|
4. React Router fallback: `/*` → `/index.html`
|
||||||
|
|
||||||
## Quick commands
|
## Quick commands
|
||||||
@ -90,8 +90,8 @@ Get-Content "C:\inetpub\apps\SolutionErp\Api\Logs\stdout_*.log" -Tail 30
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Từ server
|
# Từ server
|
||||||
curl http://localhost:5443/health/live
|
curl http://127.0.0.1:5443/health/live
|
||||||
curl http://localhost:5443/health/ready
|
curl http://127.0.0.1:5443/health/ready
|
||||||
|
|
||||||
# Từ ngoài
|
# Từ ngoài
|
||||||
curl https://api.huypham.vn/health/ready
|
curl https://api.huypham.vn/health/ready
|
||||||
@ -215,12 +215,12 @@ Xem gotcha #25 (docs/gotchas.md):
|
|||||||
### HTTP 502 Bad Gateway (Admin/User → API)
|
### 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
|
- Down → restart API site + check stdout log
|
||||||
2. Check ARR enabled: IIS Manager > server level > Application Request Routing
|
2. Check ARR enabled: IIS Manager > server level > Application Request Routing
|
||||||
- "Enable proxy" phải tick
|
- "Enable proxy" phải tick
|
||||||
3. Check URL Rewrite rule fe web.config
|
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)
|
### SignalR 401 (WebSocket connect fail)
|
||||||
@ -296,7 +296,7 @@ scp -r .\fe-user\dist\* user@server:C:/inetpub/apps/SolutionErp/User/
|
|||||||
|
|
||||||
# Trên server:
|
# Trên server:
|
||||||
Restart-WebAppPool -Name "SolutionErp-Api"
|
Restart-WebAppPool -Name "SolutionErp-Api"
|
||||||
curl http://localhost:5443/health/ready
|
curl http://127.0.0.1:5443/health/ready
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup + recovery
|
## Backup + recovery
|
||||||
@ -322,11 +322,42 @@ Restore: xem `docs/guides/runbook.md`.
|
|||||||
- [ ] Gitea runner registration token (re-register service)
|
- [ ] Gitea runner registration token (re-register service)
|
||||||
- [ ] Admin default `Admin@123456` (đổi qua `/system/users` admin UI ngay sau deploy)
|
- [ ] 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
|
## Related
|
||||||
|
|
||||||
- `docs/guides/deployment-iis.md` — first-time setup
|
- `docs/guides/deployment-iis.md` — first-time setup
|
||||||
- `docs/guides/runbook.md` — operations guide chi tiết
|
- `docs/guides/runbook.md` — operations guide chi tiết
|
||||||
- `docs/guides/cicd.md` — CI/CD pipeline
|
- `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`
|
- `scripts/deploy-iis.ps1` · `scripts/backup-sql.ps1` · `scripts/install-libreoffice.ps1`
|
||||||
- `.gitea/workflows/deploy.yml` — CI/CD definition
|
- `.gitea/workflows/deploy.yml` — CI/CD definition
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
# HANDOFF — Brief 5 phút cho session tiếp theo
|
# 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
|
## TL;DR
|
||||||
|
|
||||||
**UAT-ready 100%.** Tier 3 ERP + 4-bảng overhaul + 4 master catalogs +
|
**Module Duyệt NCC (tiền-HĐ) E2E.** 2 quy trình A/B config được admin
|
||||||
Roles VN + RolesPage CRUD + User-kind approver runtime + Warning 20% SLA
|
(tái dùng framework versioned workflow), 10 bảng mới (7 core + 3
|
||||||
+ Edit detail row inline. 36 DB tables, ~93 endpoints, 11 migrations.
|
workflow config), 17 endpoint mới, 3 FE page × 2 app + PeDetailTabs
|
||||||
Demo data đầy đủ: **15 NCC + 8 Project + 7 [DEMO] HĐ (varied phases/
|
5-tab + PeWorkflowPanel timeline. Kế thừa HĐ 1-click từ phiếu DaDuyet:
|
||||||
details/approvals/comments) + 13 demo users + 60 master catalog items.**
|
Gen Contract draft với SupplierId/ProjectId/GiaTri kế thừa, link 2
|
||||||
Còn lại chỉ blockers user/ops: UAT thật + SMTP + rotate creds.
|
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
|
## ⭐ 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 |
|
| **Edit detail row inline** (7 typed Update commands + EditRowDialog) | ✅ Done |
|
||||||
| **Master expand 15 NCC + 8 Project** + backfill demo HĐ diverse | ✅ Done |
|
| **Master expand 15 NCC + 8 Project** + backfill demo HĐ diverse | ✅ Done |
|
||||||
| **Deps audit script** (`scripts/deps-audit.ps1`) | ✅ 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 |
|
| 6+ Post-launch (E-signature, Bravo/SAP, Mobile, AI) | 📝 Future |
|
||||||
|
|
||||||
## Run nhanh
|
## Run nhanh
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||||
|
|
||||||
**Last updated:** 2026-04-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
|
### 🌐 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 |
|
| 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 | **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 | **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` |
|
| 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
|
## 📊 Thông số cumulative
|
||||||
|
|
||||||
| | P0 | P1f | P1.2 | P2 | P3 | P4 | P5prep | Tier3 | +Toolkit/Catalogs/Roles | **+RolesPg+Demo+Pending** |
|
| | 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** |
|
| 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** (no schema change) |
|
| 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** (+3 Roles CRUD +7 PUT Detail) |
|
| 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 |
|
| Migrations | 0 | 1 | 3 | 4 | 5 | 5 | 5 | 8 | 11 | 11 | **12** |
|
||||||
| FE pages | 0 | 2 | 6 | 7 | 14 | 16 | 16 | ~20 | ~22 | **~23** (+RolesPage) |
|
| 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) |
|
| FE components | — | — | — | — | — | — | — | many | many+ | +EditRowDialog (refactor ActionBtns) |
|
||||||
| Scripts PS | 0 | 0 | 0 | 1 | 1 | 1 | 3 | 4 | 4 | **5** (+deps-audit.ps1) |
|
| 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 |
|
| CI/CD workflow | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 |
|
||||||
|
|||||||
@ -296,6 +296,20 @@
|
|||||||
- [x] **Seed master data** — 9 dept + 5 supplier + 3 project + MyDashboard — `6197c84`
|
- [x] **Seed master data** — 9 dept + 5 supplier + 3 project + MyDashboard — `6197c84`
|
||||||
- [x] **Brand identity** — #1F7DC1 palette + Be Vietnam Pro + Solutions logo — `4abb559`..`bf1fbe3`
|
- [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)
|
## Post-launch (Phase 6+ — future)
|
||||||
|
|
||||||
- [ ] **Email outbox** (MailKit + SMTP) — blocked chờ SMTP config
|
- [ ] **Email outbox** (MailKit + SMTP) — blocked chờ SMTP config
|
||||||
|
|||||||
144
docs/changelog/sessions/2026-04-23-2300-purchase-evaluations.md
Normal file
144
docs/changelog/sessions/2026-04-23-2300-purchase-evaluations.md
Normal 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.
|
||||||
@ -480,8 +480,9 @@ COMMIT;
|
|||||||
| **9** | **`AddContractDetailsAndChangelog`** | **7 ContractType-specific Details + ContractChangelogs (unified audit log)** |
|
| **9** | **`AddContractDetailsAndChangelog`** | **7 ContractType-specific Details + ContractChangelogs (unified audit log)** |
|
||||||
| **10** | **`AddMasterCatalogs`** | **UnitsOfMeasure, MaterialItems, ServiceItems, WorkItems** |
|
| **10** | **`AddMasterCatalogs`** | **UnitsOfMeasure, MaterialItems, ServiceItems, WorkItems** |
|
||||||
| **11** | **`AddRoleShortNameAndUserDepartment`** | **+Role.ShortName + User.DepartmentId/Position (cột thêm, không bảng mới)** |
|
| **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
|
## 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)
|
- 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 đủ
|
- [`database-guide.md`](database-guide.md) — conventions + migration workflow + cheatsheet đầy đủ
|
||||||
- [`../architecture.md`](../architecture.md) — layered architecture + data flow
|
- [`../architecture.md`](../architecture.md) — layered architecture + data flow
|
||||||
|
|||||||
@ -300,6 +300,39 @@ Write-Host "Setup IIS sites done" # thay vi "Hoan tat"
|
|||||||
<NavLink to={path} end={path.includes('?')}>
|
<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
|
## Checklist debug bug mới
|
||||||
|
|
||||||
1. Build pass không? → fail → check using + package version compat
|
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
|
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)
|
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)
|
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)
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
|||||||
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
||||||
import { ReportsPage } from '@/pages/ReportsPage'
|
import { ReportsPage } from '@/pages/ReportsPage'
|
||||||
import { UsersPage } from '@/pages/system/UsersPage'
|
import { UsersPage } from '@/pages/system/UsersPage'
|
||||||
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -47,6 +49,9 @@ function App() {
|
|||||||
<Route path="/contracts" element={<ContractsListPage />} />
|
<Route path="/contracts" element={<ContractsListPage />} />
|
||||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<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="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -44,6 +44,8 @@ function resolvePath(key: string): string | null {
|
|||||||
CatalogMaterials: '/master/catalogs/materials',
|
CatalogMaterials: '/master/catalogs/materials',
|
||||||
CatalogServices: '/master/catalogs/services',
|
CatalogServices: '/master/catalogs/services',
|
||||||
CatalogWorkItems: '/master/catalogs/work-items',
|
CatalogWorkItems: '/master/catalogs/work-items',
|
||||||
|
PurchaseEvaluations: '/purchase-evaluations',
|
||||||
|
PeWorkflows: '/system/pe-workflows',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
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}`
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
703
fe-admin/src/components/pe/PeDetailTabs.tsx
Normal file
703
fe-admin/src/components/pe/PeDetailTabs.tsx
Normal 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 HĐ</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 HĐ 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 HĐ 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 HĐ</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 HĐ</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 HĐ với Chủ đầu tư)
|
||||||
|
</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 có 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 có 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>Mã (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á có 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 có 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
fe-admin/src/components/pe/PeWorkflowPanel.tsx
Normal file
124
fe-admin/src/components/pe/PeWorkflowPanel.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ export const MenuKeys = {
|
|||||||
Users: 'Users',
|
Users: 'Users',
|
||||||
Roles: 'Roles',
|
Roles: 'Roles',
|
||||||
Permissions: 'Permissions',
|
Permissions: 'Permissions',
|
||||||
|
PurchaseEvaluations: 'PurchaseEvaluations',
|
||||||
|
PeWorkflows: 'PeWorkflows',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
176
fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal file
176
fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal 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>Mô 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
250
fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal 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 HĐ</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
165
fe-admin/src/types/purchaseEvaluation.ts
Normal file
165
fe-admin/src/types/purchaseEvaluation.ts
Normal 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
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ import { InboxPage } from '@/pages/InboxPage'
|
|||||||
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
||||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||||
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
||||||
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -28,6 +30,9 @@ function App() {
|
|||||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/my-contracts" element={<MyContractsPage />} />
|
<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="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@ -39,8 +39,9 @@ function getCtGroupCode(key: string): string | null {
|
|||||||
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
||||||
function resolvePath(key: string): string | null {
|
function resolvePath(key: string): string | null {
|
||||||
const staticMap: Record<string, string> = {
|
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',
|
Contracts: '/my-contracts',
|
||||||
|
PurchaseEvaluations: '/purchase-evaluations',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
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 === 'Create') return `/contracts/new?type=${typeInt}`
|
||||||
if (action === 'Pending') return `/inbox?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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
703
fe-user/src/components/pe/PeDetailTabs.tsx
Normal file
703
fe-user/src/components/pe/PeDetailTabs.tsx
Normal 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 HĐ</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 HĐ 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 HĐ 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 HĐ</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 HĐ</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 HĐ với Chủ đầu tư)
|
||||||
|
</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 có 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 có 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>Mã (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á có 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 có 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 có 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
fe-user/src/components/pe/PeWorkflowPanel.tsx
Normal file
124
fe-user/src/components/pe/PeWorkflowPanel.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ export const MenuKeys = {
|
|||||||
Users: 'Users',
|
Users: 'Users',
|
||||||
Roles: 'Roles',
|
Roles: 'Roles',
|
||||||
Permissions: 'Permissions',
|
Permissions: 'Permissions',
|
||||||
|
PurchaseEvaluations: 'PurchaseEvaluations',
|
||||||
|
PeWorkflows: 'PeWorkflows',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||||
|
|||||||
176
fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal file
176
fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal 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>Mô 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
250
fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal 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 HĐ</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
165
fe-user/src/types/purchaseEvaluation.ts
Normal file
165
fe-user/src/types/purchaseEvaluation.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -6,6 +6,7 @@ using SolutionErp.Domain.Identity;
|
|||||||
using SolutionErp.Domain.Master;
|
using SolutionErp.Domain.Master;
|
||||||
using SolutionErp.Domain.Master.Catalogs;
|
using SolutionErp.Domain.Master.Catalogs;
|
||||||
using SolutionErp.Domain.Notifications;
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
namespace SolutionErp.Application.Common.Interfaces;
|
namespace SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
@ -44,5 +45,17 @@ public interface IApplicationDbContext
|
|||||||
DbSet<NguyenTacNccDetail> NguyenTacNccDetails { get; }
|
DbSet<NguyenTacNccDetail> NguyenTacNccDetails { get; }
|
||||||
DbSet<NguyenTacDvDetail> NguyenTacDvDetails { 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);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -42,13 +42,35 @@ public static class MenuKeys
|
|||||||
// → mở /system/workflows/{typeCode} (filter theo type thay vì tab).
|
// → mở /system/workflows/{typeCode} (filter theo type thay vì tab).
|
||||||
public static string WorkflowTypeLeaf(string typeCode) => $"Wf_{typeCode}";
|
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 =
|
public static readonly string[] All =
|
||||||
[
|
[
|
||||||
Dashboard,
|
Dashboard,
|
||||||
Master, Suppliers, Projects, Departments,
|
Master, Suppliers, Projects, Departments,
|
||||||
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
|
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
|
||||||
Contracts, Forms, Reports,
|
Contracts, Forms, Reports,
|
||||||
System, Users, Roles, Permissions, Workflows,
|
PurchaseEvaluations,
|
||||||
|
System, Users, Roles, Permissions, Workflows, PeWorkflows,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces;
|
|||||||
using SolutionErp.Application.Contracts.Services;
|
using SolutionErp.Application.Contracts.Services;
|
||||||
using SolutionErp.Application.Forms.Services;
|
using SolutionErp.Application.Forms.Services;
|
||||||
using SolutionErp.Application.Notifications;
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
using SolutionErp.Application.Reports.Services;
|
using SolutionErp.Application.Reports.Services;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Infrastructure.Forms;
|
using SolutionErp.Infrastructure.Forms;
|
||||||
@ -32,6 +33,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
|
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
|
||||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
|
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
services.AddScoped<INotificationService, NotificationService>();
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
services.AddScoped<IChangelogService, ChangelogService>();
|
services.AddScoped<IChangelogService, ChangelogService>();
|
||||||
|
|||||||
@ -8,6 +8,7 @@ using SolutionErp.Domain.Identity;
|
|||||||
using SolutionErp.Domain.Master;
|
using SolutionErp.Domain.Master;
|
||||||
using SolutionErp.Domain.Master.Catalogs;
|
using SolutionErp.Domain.Master.Catalogs;
|
||||||
using SolutionErp.Domain.Notifications;
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Persistence;
|
namespace SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
@ -46,6 +47,17 @@ public class ApplicationDbContext
|
|||||||
public DbSet<NguyenTacNccDetail> NguyenTacNccDetails => Set<NguyenTacNccDetail>();
|
public DbSet<NguyenTacNccDetail> NguyenTacNccDetails => Set<NguyenTacNccDetail>();
|
||||||
public DbSet<NguyenTacDvDetail> NguyenTacDvDetails => Set<NguyenTacDvDetail>();
|
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)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ using SolutionErp.Domain.Forms;
|
|||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Domain.Master;
|
using SolutionErp.Domain.Master;
|
||||||
using SolutionErp.Domain.Master.Catalogs;
|
using SolutionErp.Domain.Master.Catalogs;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Persistence;
|
namespace SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ public static class DbInitializer
|
|||||||
await SeedDemoMasterDataAsync(db, logger);
|
await SeedDemoMasterDataAsync(db, logger);
|
||||||
await SeedContractTemplatesAsync(db, logger);
|
await SeedContractTemplatesAsync(db, logger);
|
||||||
await SeedWorkflowDefinitionsAsync(db, logger);
|
await SeedWorkflowDefinitionsAsync(db, logger);
|
||||||
|
await SeedPurchaseEvaluationWorkflowsAsync(db, logger);
|
||||||
await SeedCatalogsAsync(db, logger);
|
await SeedCatalogsAsync(db, logger);
|
||||||
|
|
||||||
// Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create).
|
// 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
|
// 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
|
// đúng business: ThauPhu/GiaoKhoan đi với NTP/TĐ, NCC/MuaBan/NTNcc đi
|
||||||
// với NCC, DichVu/NTDv đi với DV).
|
// 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.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
|
||||||
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
||||||
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
|
(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
|
// 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"));
|
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 existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync();
|
||||||
var added = 0;
|
var added = 0;
|
||||||
foreach (var (key, label, parent, o, icon) in tree)
|
foreach (var (key, label, parent, o, icon) in tree)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1904,6 +1904,592 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Notifications", (string)null);
|
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 =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
@ -2135,6 +2721,106 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvals");
|
b.Navigation("Approvals");
|
||||||
@ -2176,6 +2862,36 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.Navigation("Permissions");
|
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
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user