[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s

- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-13 01:07:27 +07:00
parent 6db195dd42
commit 79ef8da9f4
70 changed files with 9052 additions and 5956 deletions

View File

@ -68,6 +68,7 @@ BE (test+build) ~90s · FE × 2 ~60s/app · deploy ~30s · **total ~3min code /
## 📅 Recent runs (FIFO — older → archive/git) ## 📅 Recent runs (FIFO — older → archive/git)
- **2026-06-12 S60 Run #283 (run_number 283) sha=`37122f0` PASS ~5m (PE guard 4-thông-tin mục 3 khi gửi duyệt + bypass người-soạn-trong-chuỗi-duyệt + rename heading "Đơn vị NCC/TP được chọn" — CROSS-STACK 1 BE service + 2 FE PeDetailTabs ×2 + 1 NEW test file):** Push `792c030..37122f0` 7 files: `PurchaseEvaluationWorkflowService.cs` (BE submit-guard + drafter-bypass) + `PeDetailTabs.tsx` ×2 app + `PeSubmitGuardAndBypassTests.cs` (NEW, +14 → 240→**254** expected) + 3 agent-memory `.md` (harvest-curator/investigator-codebase/test-specialist — `.claude/agent-memory/**` matches `**/*.md` glob → ignored, but `.cs`+`.tsx` present ⟹ whole-range builds, Discovery #3). GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod `appsettings.Production.json``ConnectionStrings.Default`. Run IN-PROGRESS first poll (running 11:55) — correctly did NOT FAIL, polled iter5 status=success (started ~11:54 → success 11:59:26 ≈5m). CI gate (both test proj pre-deploy ⟹ status=success ⟹ test gate 254 passed; `tasks` endpoint reports terminal as `status:success`, `conclusion` empty — trust success). **Bundle ROTATE BOTH (load-bearing, verified AFTER status=success — anti-pattern#3): admin `B1DtNT9C→akytoBnc` + user `D6uF3Mln→BzSdQmN0`** ✓ both touched (FE changed both apps). Brand `1F7DC1` preserved both HTML. Health live+ready **200/200** + admin/eoffice root 200. Smoke: PE unauth **401** + contracts unauth **401** + control `/api/zzz-not-a-route` **404** (auth gates real, routing live). **NO migration** — prod `__EFMigrationsHistory` top = Mig 49 `AddWorkItemToPurchaseEvaluation` == repo, GIỮ NGUYÊN ✓ (PE.MaPhieu col, not Code). sys.tables(excl mighist)=**92** (convention diff vs narrative-93, no new table — col/logic-only). **DATA INTACT (no-touch verify, sqlcmd): PE_count=3 · PE/2026/A/001 EXISTS (=1, phiếu UAT thật giữ nguyên ✓) · Suppliers=23 · WorkItems=71** — counts vs S59 (PE was 1 #275, Suppliers 22 #278) GREW from legit ongoing-UAT (this commit = FE+BE-service, NO DbInitializer/seed change → cannot resurrect/wipe; growth is user activity not deploy-induced). 0 regression. **LESSON (cross-stack submit-guard + drafter-bypass = ship-proof via run-success + test 254 + bundle-rotate-both + PE-data-preserved; the guard/bypass logic is internal handler behavior — cannot curl-assert "block submit when mục-3 incomplete" or "skip drafter in chain" without authed multi-step flow → rely on +14 PeSubmitGuardAndBypassTests in CI gate passing). SSH→sqlcmd via `iconv UTF-16LE|base64`→`powershell -EncodedCommand` (nested bash→ssh→PS strips `$vars`/mangles quotes); PE code column = `MaPhieu` NOT `Code`.** Tag `[s60, run283, pass, pe-submit-guard, drafter-bypass-in-chain, cross-stack, bundle-rotate-both, no-mig, test254, pe-a001-preserved]`. **↳đợt2 (14:14): Run #284 (run_number 284, id398) sha=`6db195d` PASS ~4m31s — GỠ hành động "Từ chối" khỏi quy trình PE (chỉ còn Duyệt/Trả lại; CROSS-STACK Domain `PurchaseEvaluationPolicy.cs` + Infra `PurchaseEvaluationWorkflowService.cs` guard + FE `PeWorkflowPanel.tsx` ×2 app + 2 NEW test `PurchaseEvaluationPolicyTests`/`PurchaseEvaluationWorkflowServiceGuardTests`, +2 → 254→256 expected: 59 Domain + 197 Infra). Push `37122f0..6db195d` 6 files (.cs+.tsx → full pipeline). Tokens empty → anon Gitea API + prod appsettings DB pw `ConnectionStrings.Default`. Run IN-PROGRESS first poll (running 14:31) — correctly did NOT FAIL, polled iter5 status=success (14:30:51→14:35:22 ≈4m31s; CI both-proj-pre-deploy ⟹ success ⟹ 256-gate passed, `conclusion` empty trust success). Bundle ROTATE BOTH (verified AFTER status=success — anti#3): admin `akytoBnc→DSvM8h3A` + user `BzSdQmN0→Cs2Tt5n6` ✓ both touched. Health live+ready 200/200 + admin/eoffice root 200 + PE unauth 401 + control /api/zzz-not-a-route 404. NO migration — prod top=Mig 49 `AddWorkItemToPurchaseEvaluation`==repo GIỮ ✓. sys.tables(excl mighist)=92. DATA INTACT: PE_count=4 (grew from 3 @#283 — legit ongoing-UAT; BE-policy+FE-only NO seed change → cannot resurrect/wipe) · PE/2026/A/001 EXISTS (=1 phiếu UAT thật giữ ✓). 0 regression. LESSON: "Từ chối"-removal = internal policy/handler behavior, cannot curl-assert "reject action gone" without authed multi-step flow → rely on +2 PolicyTests/GuardTests in CI gate passing. Tag `[s60-dot2, run284, pass, pe-remove-reject-action, cross-stack, bundle-rotate-both, no-mig, test256, pe-a001-preserved]`.**
- **2026-06-11 S59-CLOSE Run #280 (run_number 280) sha=`69997da` PASS ~4m24s (FINAL đóng sổ session — FE-only ×2 PeDetailTabs+PeHeaderForm bỏ ô "Tên ngân sách" manual budget UAT vòng4):** Push `f21c55d..69997da` 4 `.tsx` (PeDetailTabs+PeHeaderForm ×2 app). **Run #279 (id393) sha=`f21c55d` (NCC table-fixed UAT vòng3) = `cancelled` @18:22:33 — supersede-BENIGN:** #280 push @18:22:34 (1s gap → Gitea concurrency-cancel in-flight) + `git merge-base --is-ancestor f21c55d 69997da`=TRUE ✓ (f21c55d preserved trong HEAD, ships via #280 — verified diff f21c55d→69997da chỉ +4 PeDetail/Header file, không re-touch 12 file vòng3). Tokens empty → anon Gitea API + prod appsettings DB. Poll iter4 status=success (18:22:34→18:26:58). **Bundle ROTATE BOTH FINAL (verified AFTER success +re-confirm STABLE no transient — anti#3): admin `BSh2fG2X→BKy_8OO9` + user `D22KfpPc→XcZ6PRyA`** ✓ session-close hash, brand `1F7DC1`+"Solutions ERP" preserved ×2. Health live+ready **200/200** + admin/eoffice root 200 + PE unauth 401 + control 404. **NO migration** (FE-only, Mig 49 held). LESSON (mirror Run #385 supersede-chain): same-SHA `cancelled` mid-flight = concurrency-supersede bởi newer push (1s HEAD-move), KHÔNG build/deploy-fault → ancestor-check TRUE = benign, verify prod qua SUCCESSFUL run #280 (NOT cancelled #279), KHÔNG escalate. Tag `[s59-close, run280-pass, run279-cancelled-benign, supersede-chain, fe-budget-name-remove-x2, bundle-rotate-both-FINAL, no-mig]`. **↳FINAL-v2 (tối): `80b64dd` (Run #281 cancelled-BENIGN) gỡ "Điều khoản thanh toán" 3-form ×2 → superseded bởi `792c030` Run #282 PASS ~4m (UAT vòng6 bỏ nút "+Thêm hạng mục" PeDetailTabs ×2). Ancestor 80b64dd⊂792c030=TRUE ✓ (792c030 chỉ re-touch PeDetailTabs, KHÔNG đụng PeHeaderForm/PeWorkspaceCreateView → paymentTerms-removal survives). Verify qua #282-success. Bundle ROTATE BOTH ĐÓNG-SỔ-THẬT (AFTER success +re-confirm STABLE no transient): admin `BKy_8OO9→B1DtNT9C` + user `XcZ6PRyA→D6uF3Mln` ✓, brand `1F7DC1` ok. Health live+ready 200/200 + 2 FE root 200. NO mig (FE-only). Lần thứ 3 liên tiếp supersede-chain (#279/#281 cancelled-benign) — pattern stable.** - **2026-06-11 S59-CLOSE Run #280 (run_number 280) sha=`69997da` PASS ~4m24s (FINAL đóng sổ session — FE-only ×2 PeDetailTabs+PeHeaderForm bỏ ô "Tên ngân sách" manual budget UAT vòng4):** Push `f21c55d..69997da` 4 `.tsx` (PeDetailTabs+PeHeaderForm ×2 app). **Run #279 (id393) sha=`f21c55d` (NCC table-fixed UAT vòng3) = `cancelled` @18:22:33 — supersede-BENIGN:** #280 push @18:22:34 (1s gap → Gitea concurrency-cancel in-flight) + `git merge-base --is-ancestor f21c55d 69997da`=TRUE ✓ (f21c55d preserved trong HEAD, ships via #280 — verified diff f21c55d→69997da chỉ +4 PeDetail/Header file, không re-touch 12 file vòng3). Tokens empty → anon Gitea API + prod appsettings DB. Poll iter4 status=success (18:22:34→18:26:58). **Bundle ROTATE BOTH FINAL (verified AFTER success +re-confirm STABLE no transient — anti#3): admin `BSh2fG2X→BKy_8OO9` + user `D22KfpPc→XcZ6PRyA`** ✓ session-close hash, brand `1F7DC1`+"Solutions ERP" preserved ×2. Health live+ready **200/200** + admin/eoffice root 200 + PE unauth 401 + control 404. **NO migration** (FE-only, Mig 49 held). LESSON (mirror Run #385 supersede-chain): same-SHA `cancelled` mid-flight = concurrency-supersede bởi newer push (1s HEAD-move), KHÔNG build/deploy-fault → ancestor-check TRUE = benign, verify prod qua SUCCESSFUL run #280 (NOT cancelled #279), KHÔNG escalate. Tag `[s59-close, run280-pass, run279-cancelled-benign, supersede-chain, fe-budget-name-remove-x2, bundle-rotate-both-FINAL, no-mig]`. **↳FINAL-v2 (tối): `80b64dd` (Run #281 cancelled-BENIGN) gỡ "Điều khoản thanh toán" 3-form ×2 → superseded bởi `792c030` Run #282 PASS ~4m (UAT vòng6 bỏ nút "+Thêm hạng mục" PeDetailTabs ×2). Ancestor 80b64dd⊂792c030=TRUE ✓ (792c030 chỉ re-touch PeDetailTabs, KHÔNG đụng PeHeaderForm/PeWorkspaceCreateView → paymentTerms-removal survives). Verify qua #282-success. Bundle ROTATE BOTH ĐÓNG-SỔ-THẬT (AFTER success +re-confirm STABLE no transient): admin `BKy_8OO9→B1DtNT9C` + user `XcZ6PRyA→D6uF3Mln` ✓, brand `1F7DC1` ok. Health live+ready 200/200 + 2 FE root 200. NO mig (FE-only). Lần thứ 3 liên tiếp supersede-chain (#279/#281 cancelled-benign) — pattern stable.**
- **2026-06-11 Run #278 (run_number 278) sha=`9c330d2` PASS ~3m45s (S59-đợt6 CROSS-STACK — BE SuppliersController POST hạ `[Authorize(Roles="Admin,CatalogManager")]` → class-level `[Authorize]` any-auth (anh chốt quick-add NCC đi-thầu phát sinh liên tục), PUT/DELETE GIỮ khóa Admin+CatalogManager; FE×2 PeDetailTabs AddSupplierDialog SearchableSelect+quick-create+upload-multi + PeWorkflowPanel ẩn Trả-lại/Từ-chối khi drafterUserId==currentUser):** Push `faed59f..9c330d2` 5 files: 4 FE `.tsx` (PeDetailTabs+PeWorkflowPanel ×2 app) + `SuppliersController.cs`. `.cs`+`.tsx` → full pipeline RAN. GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod appsettings.Production.json→`ConnectionStrings.Default` (`vrapp/buKL3...`). Run IN-PROGRESS first poll (running) — polled iter6 status=success. **Bundle ROTATE BOTH (load-bearing FE×2, verified AFTER status=success +re-confirm stable ×2 NO transient — anti-pattern#3): admin `ex7Tc92G→BSh2fG2X` + user `DzUeSk96→D22KfpPc`** ✓ both touched. Brand `1F7DC1`+"Solutions ERP" preserved. Health live+ready **200/200** + admin/eoffice root 200. **NO migration** — prod `__EFMigrationsHistory` top = Mig 49 `AddWorkItemToPurchaseEvaluation` == repo ✓. **★ AUTHZ PROBE 4-điểm (the change-point — asymmetric POST-open/DELETE-locked verify) ALL PASS:** (a) unauth POST /api/suppliers (no token) = **401** ✓ (vẫn phải login, KHÔNG anonymous — class `[Authorize]` giữ); (b1) nv.test (Drafter non-admin) POST `{code:ZZCICD-TEST,type:1}` = **201** ✓ id `bc64c0c0-...` (quick-add mở OK); (b2) nv.test DELETE same-id CÙNG token = **403** ✓ (Sửa/Xóa vẫn khóa Admin+CatalogManager — method-level attr giữ); cleanup admin DELETE = **204** + GET = **404** ✓ (probe gỡ sạch). Spot sqlcmd ground-truth: **WorkItems active=71 HELD** ✓ (no resurrect) + **Suppliers active=22** ✓ (==pre-probe; DELETE là SOFT `IsDeleted=1` → active count về 22, total=23 với 1 tombstone ZZCICD `IsDeleted=1` — by-design audit, KHÔNG leak: list/GET ẩn nó). ⚠️ API `/api/suppliers` list trả 20 (paginated/filtered default page — KHÔNG authoritative, dùng sqlcmd cho count thật). Test gate (CI both proj pre-deploy ⟹ success=passed). 0 regression. **LESSON: cross-stack authz-relax verify = probe CẢ asymmetry — (i) unauth vẫn 401 (relax ≠ anonymous), (ii) target-role action mở (201), (iii) SIBLING action vẫn locked (403 same token), (iv) cleanup soft-delete → active-count về baseline + tombstone total+1 (soft-delete ≠ rác nếu list/GET ẩn). API list-count KHÔNG tin (pagination) → sqlcmd `WHERE IsDeleted=0` cho count thật.** Tag `[s59-dot6, run278, pass, supplier-post-authz-relax, asymmetric-probe-401-201-403, soft-delete-cleanup, bundle-rotate-both, no-mig, wi71-held]`. - **2026-06-11 Run #278 (run_number 278) sha=`9c330d2` PASS ~3m45s (S59-đợt6 CROSS-STACK — BE SuppliersController POST hạ `[Authorize(Roles="Admin,CatalogManager")]` → class-level `[Authorize]` any-auth (anh chốt quick-add NCC đi-thầu phát sinh liên tục), PUT/DELETE GIỮ khóa Admin+CatalogManager; FE×2 PeDetailTabs AddSupplierDialog SearchableSelect+quick-create+upload-multi + PeWorkflowPanel ẩn Trả-lại/Từ-chối khi drafterUserId==currentUser):** Push `faed59f..9c330d2` 5 files: 4 FE `.tsx` (PeDetailTabs+PeWorkflowPanel ×2 app) + `SuppliersController.cs`. `.cs`+`.tsx` → full pipeline RAN. GITEA_TOKEN+PROD_DB_PW empty → anon Gitea API + DB pw từ prod appsettings.Production.json→`ConnectionStrings.Default` (`vrapp/buKL3...`). Run IN-PROGRESS first poll (running) — polled iter6 status=success. **Bundle ROTATE BOTH (load-bearing FE×2, verified AFTER status=success +re-confirm stable ×2 NO transient — anti-pattern#3): admin `ex7Tc92G→BSh2fG2X` + user `DzUeSk96→D22KfpPc`** ✓ both touched. Brand `1F7DC1`+"Solutions ERP" preserved. Health live+ready **200/200** + admin/eoffice root 200. **NO migration** — prod `__EFMigrationsHistory` top = Mig 49 `AddWorkItemToPurchaseEvaluation` == repo ✓. **★ AUTHZ PROBE 4-điểm (the change-point — asymmetric POST-open/DELETE-locked verify) ALL PASS:** (a) unauth POST /api/suppliers (no token) = **401** ✓ (vẫn phải login, KHÔNG anonymous — class `[Authorize]` giữ); (b1) nv.test (Drafter non-admin) POST `{code:ZZCICD-TEST,type:1}` = **201** ✓ id `bc64c0c0-...` (quick-add mở OK); (b2) nv.test DELETE same-id CÙNG token = **403** ✓ (Sửa/Xóa vẫn khóa Admin+CatalogManager — method-level attr giữ); cleanup admin DELETE = **204** + GET = **404** ✓ (probe gỡ sạch). Spot sqlcmd ground-truth: **WorkItems active=71 HELD** ✓ (no resurrect) + **Suppliers active=22** ✓ (==pre-probe; DELETE là SOFT `IsDeleted=1` → active count về 22, total=23 với 1 tombstone ZZCICD `IsDeleted=1` — by-design audit, KHÔNG leak: list/GET ẩn nó). ⚠️ API `/api/suppliers` list trả 20 (paginated/filtered default page — KHÔNG authoritative, dùng sqlcmd cho count thật). Test gate (CI both proj pre-deploy ⟹ success=passed). 0 regression. **LESSON: cross-stack authz-relax verify = probe CẢ asymmetry — (i) unauth vẫn 401 (relax ≠ anonymous), (ii) target-role action mở (201), (iii) SIBLING action vẫn locked (403 same token), (iv) cleanup soft-delete → active-count về baseline + tombstone total+1 (soft-delete ≠ rác nếu list/GET ẩn). API list-count KHÔNG tin (pagination) → sqlcmd `WHERE IsDeleted=0` cho count thật.** Tag `[s59-dot6, run278, pass, supplier-post-authz-relax, asymmetric-probe-401-201-403, soft-delete-cleanup, bundle-rotate-both, no-mig, wi71-held]`.
- **2026-06-11 Run #277 (run_number 277) sha=`faed59f` PASS ~4m09s (S59-đợt5 FE-only ×2 — NEW ui/SearchableSelect combobox gõ-lọc-bỏ-dấu + PeWorkspaceCreateView/PeHeaderForm Hạng mục+Dự án combobox + auto-fill Địa điểm từ Project.Location + PeDetailTabs paymentTerms Textarea; UAT 4-điểm screenshot 16:40):** Push `c869d26..faed59f` 8 files all `.tsx` (4 fe-admin + 4 fe-user, NEW SearchableSelect mirror ×2) → pipeline RAN. Tokens empty → anon API + prod appsettings pw. Run IN-PROGRESS first poll (running 17:40) — polled iter5 status=success (17:40:18→17:44:27). **Bundle ROTATE BOTH (verified AFTER success +re-confirm stable NO transient — anti-pattern#3): admin `BBA0KSWu→ex7Tc92G` + user `DzdTI18G→DzUeSk96`** ✓ both apps touched. Brand `1F7DC1` preserved both HTML. Health live+ready **200/200** + admin/eoffice root 200. **NO migration** (FE-only). Spot DB: **WorkItems active=71 HELD** (==#276 ✓ no resurrect — FE-only no restart risk) + PE=1 (info-only UAT leftover unchanged, NOT regression). Test gate (CI both proj pre-deploy ⟹ success=passed). 0 regression. LESSON: FE-only follow-up of a data-session needs only light WorkItems=71+PE re-confirm (no infra re-audit). SSH→sqlcmd pw-read: nested bash→ssh→PS strips `$vars` → use `iconv UTF-16LE|base64``powershell -EncodedCommand`. Tag `[s59-dot5, run277, pass, fe-searchableselect-x2, bundle-rotate-both, no-mig, wi71-held]`. - **2026-06-11 Run #277 (run_number 277) sha=`faed59f` PASS ~4m09s (S59-đợt5 FE-only ×2 — NEW ui/SearchableSelect combobox gõ-lọc-bỏ-dấu + PeWorkspaceCreateView/PeHeaderForm Hạng mục+Dự án combobox + auto-fill Địa điểm từ Project.Location + PeDetailTabs paymentTerms Textarea; UAT 4-điểm screenshot 16:40):** Push `c869d26..faed59f` 8 files all `.tsx` (4 fe-admin + 4 fe-user, NEW SearchableSelect mirror ×2) → pipeline RAN. Tokens empty → anon API + prod appsettings pw. Run IN-PROGRESS first poll (running 17:40) — polled iter5 status=success (17:40:18→17:44:27). **Bundle ROTATE BOTH (verified AFTER success +re-confirm stable NO transient — anti-pattern#3): admin `BBA0KSWu→ex7Tc92G` + user `DzdTI18G→DzUeSk96`** ✓ both apps touched. Brand `1F7DC1` preserved both HTML. Health live+ready **200/200** + admin/eoffice root 200. **NO migration** (FE-only). Spot DB: **WorkItems active=71 HELD** (==#276 ✓ no resurrect — FE-only no restart risk) + PE=1 (info-only UAT leftover unchanged, NOT regression). Test gate (CI both proj pre-deploy ⟹ success=passed). 0 regression. LESSON: FE-only follow-up of a data-session needs only light WorkItems=71+PE re-confirm (no infra re-audit). SSH→sqlcmd pw-read: nested bash→ssh→PS strips `$vars` → use `iconv UTF-16LE|base64``powershell -EncodedCommand`. Tag `[s59-dot5, run277, pass, fe-searchableselect-x2, bundle-rotate-both, no-mig, wi71-held]`.

View File

@ -34,3 +34,4 @@ H2 harvest-MD-integrity auditor **SOLUTION_ERP-self**. Read-only + **propose-onl
- **2026-06-11 (S59 @start RE-REPORT — post-S58 đóng-sạch):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `1577927` (commit 14:33:20), 11/11 agent-memory mtime ≤14:32 đều TRONG closeout → **0 delta mồ-côi**. Wave=0 (workflows/ chỉ README+hmw.js) · stray=0 (find prune node_modules) · 0-byte=0 (agent-memory + user-memory) · user-memory index 22 entry = 22 file khớp, MEMORY.md 5.6KB>0. Record-S58 hiện hữu: cicd `:71-74` (#385#386/#384/#382/#381) · designer proxy `:39` · inv `:73` (tag s57bis drift giữ nguyên). 4 on-behalf s57bis (db/impl-be `:77`/impl-fe `:45`/reviewer `:60`) mtime 13:00 = closeout đóng 4-MISS ✅. **cicd #383 VẪN `:89` khu archived NHƯNG em main đã ANNOTATE "[⚠️ VỊ TRÍ LẠC — entry MỚI 2026-06-11...]"** thay vì MOVE → guard chống archive-nhầm OK, relocate thật khi curate. Mojibake 2 hit ĐỀU quoted-evidence (cicd `:79` #377 + MY OWN `:33` self-quote khi tả hit cicd — các session sau đừng false-alarm file mình). Chore carry: **cicd 41.3KB + inv-codebase 32.9KB > cap → curate-L2 P1**; reviewer 29.6 + impl-be 27.9 watch. Method ⭐: `tail -c N` cắt giữa UTF-8 multi-byte in `<60>` GIẢ — corruption thật phải grep literal U+FFFD (chỉ 2 file). Brief em main liệt kê impl-fe+reviewer là "S58 spawn" nhưng on-disk 2 role chỉ có s57bis on-behalf — khớp GATE S58 8-spawn set, flag đối chiếu không phán. Tag [s59-start, clean, mojibake-self-quote]. - **2026-06-11 (S59 @start RE-REPORT — post-S58 đóng-sạch):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `1577927` (commit 14:33:20), 11/11 agent-memory mtime ≤14:32 đều TRONG closeout → **0 delta mồ-côi**. Wave=0 (workflows/ chỉ README+hmw.js) · stray=0 (find prune node_modules) · 0-byte=0 (agent-memory + user-memory) · user-memory index 22 entry = 22 file khớp, MEMORY.md 5.6KB>0. Record-S58 hiện hữu: cicd `:71-74` (#385#386/#384/#382/#381) · designer proxy `:39` · inv `:73` (tag s57bis drift giữ nguyên). 4 on-behalf s57bis (db/impl-be `:77`/impl-fe `:45`/reviewer `:60`) mtime 13:00 = closeout đóng 4-MISS ✅. **cicd #383 VẪN `:89` khu archived NHƯNG em main đã ANNOTATE "[⚠️ VỊ TRÍ LẠC — entry MỚI 2026-06-11...]"** thay vì MOVE → guard chống archive-nhầm OK, relocate thật khi curate. Mojibake 2 hit ĐỀU quoted-evidence (cicd `:79` #377 + MY OWN `:33` self-quote khi tả hit cicd — các session sau đừng false-alarm file mình). Chore carry: **cicd 41.3KB + inv-codebase 32.9KB > cap → curate-L2 P1**; reviewer 29.6 + impl-be 27.9 watch. Method ⭐: `tail -c N` cắt giữa UTF-8 multi-byte in `<60>` GIẢ — corruption thật phải grep literal U+FFFD (chỉ 2 file). Brief em main liệt kê impl-fe+reviewer là "S58 spawn" nhưng on-disk 2 role chỉ có s57bis on-behalf — khớp GATE S58 8-spawn set, flag đối chiếu không phán. Tag [s59-start, clean, mojibake-self-quote].
- **2026-06-11 (S59 `/session-end` 5-trục GATE — tối, 9 body-spawn + closeout-floor):** **GATE PASS 5/5, 0 MISS** (lần đầu 0 on-behalf cần đề xuất kể từ chuỗi 4-MISS post-S57bis). Coverage: tooling ×2 (`:35` start + `:36` end-run mtime 18:15 NGOÀI brief 9-spawn = closeout-floor H1 → reconcile tổng 11) · harvest `:34` · inv recon `:73` · **cicd 6/6 entry RIÊNG #273-278 `:71-76`, sha-chain khớp git log 1:1** (56882ac→9c330d2, topic khớp commit-msg từng run) · 7 role không-spawn 0 phantom. Completeness 4-field 10/10 (#275 honest PASS-PARTIAL PE=1 UAT-leftover). Placement: đúng nhà, stray=0, inv S51→archive VERBATIM moved-not-cut + pointer "curated S59"; nit FIFO swap #273(`:75`)↔#274(`:76`) + archive double-blank. Corruption: 0-byte=0 ×2 nơi · mojibake 3 hit ĐỀU quoted-cũ (cicd:85 = #377 shift ĐÚNG +6 từ :79 — arithmetic shift = số dòng insert, method nhanh phân biệt cũ/mới) · **cicd 54KB/102-dòng (brief nói ~46) + inv 32.9KB over-cap → curate-L2 P1**. Fidelity: bundle BSh2fG2X/D22KfpPc triangulated 5 nguồn (cicd:71 + STATUS:6/:27 + HANDOFF:5 + log:4/:43) · WI=71 ×3 entry · gotcha #61/#62 disk `:1099/:1111` · tooling end-entry tree-state khớp git status độc lập — no-escalate. **2 flag content:** cicd `:53` "Bundle live S59" STALE 1 run (ghi #277 ex7Tc92G/DzUeSk96, live thật = #278 — entry :71 đúng, chỉ status-line lệch) → propose 1-line fix; #275 "09:46:42 sáng" nghi UTC-mislabel = 16:46 chiều local (STATUS:6 nói "chiều nay"; kết luận not-resurrect GIỮ). Tag [s59-end, gate-pass-5/5, 0-miss, cicd-54kb, line53-stale]. - **2026-06-11 (S59 `/session-end` 5-trục GATE — tối, 9 body-spawn + closeout-floor):** **GATE PASS 5/5, 0 MISS** (lần đầu 0 on-behalf cần đề xuất kể từ chuỗi 4-MISS post-S57bis). Coverage: tooling ×2 (`:35` start + `:36` end-run mtime 18:15 NGOÀI brief 9-spawn = closeout-floor H1 → reconcile tổng 11) · harvest `:34` · inv recon `:73` · **cicd 6/6 entry RIÊNG #273-278 `:71-76`, sha-chain khớp git log 1:1** (56882ac→9c330d2, topic khớp commit-msg từng run) · 7 role không-spawn 0 phantom. Completeness 4-field 10/10 (#275 honest PASS-PARTIAL PE=1 UAT-leftover). Placement: đúng nhà, stray=0, inv S51→archive VERBATIM moved-not-cut + pointer "curated S59"; nit FIFO swap #273(`:75`)↔#274(`:76`) + archive double-blank. Corruption: 0-byte=0 ×2 nơi · mojibake 3 hit ĐỀU quoted-cũ (cicd:85 = #377 shift ĐÚNG +6 từ :79 — arithmetic shift = số dòng insert, method nhanh phân biệt cũ/mới) · **cicd 54KB/102-dòng (brief nói ~46) + inv 32.9KB over-cap → curate-L2 P1**. Fidelity: bundle BSh2fG2X/D22KfpPc triangulated 5 nguồn (cicd:71 + STATUS:6/:27 + HANDOFF:5 + log:4/:43) · WI=71 ×3 entry · gotcha #61/#62 disk `:1099/:1111` · tooling end-entry tree-state khớp git status độc lập — no-escalate. **2 flag content:** cicd `:53` "Bundle live S59" STALE 1 run (ghi #277 ex7Tc92G/DzUeSk96, live thật = #278 — entry :71 đúng, chỉ status-line lệch) → propose 1-line fix; #275 "09:46:42 sáng" nghi UTC-mislabel = 16:46 chiều local (STATUS:6 nói "chiều nay"; kết luận not-resurrect GIỮ). Tag [s59-end, gate-pass-5/5, 0-miss, cicd-54kb, line53-stale].
- **2026-06-12 (S60 @start RE-REPORT — post-S59 đóng-TRỌN):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `6bf28bf` (18:49:21), 11/11 agent-memory mtime ≤18:42:45 đều TRONG closeout → 0 mồ-côi. Coverage S59 14-spawn ĐỦ: H1 tooling `:35/:36` + H2 ×2 + recon inv `:73` + cicd run-coverage 10/10 #273#282 (6 entry `:72-77` + `:71` #279/280 + extension FINAL-v2 #281/282 — 9 spawn→8 record-unit do supersede-fold, 0 run thiếu). Wave=0 · stray=0 · 0-byte=0 ×2 nơi · user-memory 23 file = index 22 + MEMORY.md 5.6KB khớp · cicd tail `0a` sạch. **2 flag GATE S59-end RESOLVED bởi em main:** `:53` bundle-live → FINAL-v2 `B1DtNT9C`/`D6uF3Mln` #282 ✓ + #275 UTC annotate `:75` ✓. #383 vẫn lạc (`:89``:96` shift +7 đúng arithmetic, annotation guard intact). Chore P1 carry: **cicd 56,480B/103L over-cap 3rd-session + phình 54→56.5KB/buổi** + inv 32,931B → curate-L2 (kèm relocate #383 + FIFO swap #273#274); watch reviewer 30,354B + impl-be 28,585B. Tag [s60-start, clean, flags-resolved, cicd-56kb]. *(em main APPEND B3 — H2-proposed, verify: 0-byte/tree-clean/size đối chiếu độc lập ✓)* - **2026-06-12 (S60 @start RE-REPORT — post-S59 đóng-TRỌN):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `6bf28bf` (18:49:21), 11/11 agent-memory mtime ≤18:42:45 đều TRONG closeout → 0 mồ-côi. Coverage S59 14-spawn ĐỦ: H1 tooling `:35/:36` + H2 ×2 + recon inv `:73` + cicd run-coverage 10/10 #273#282 (6 entry `:72-77` + `:71` #279/280 + extension FINAL-v2 #281/282 — 9 spawn→8 record-unit do supersede-fold, 0 run thiếu). Wave=0 · stray=0 · 0-byte=0 ×2 nơi · user-memory 23 file = index 22 + MEMORY.md 5.6KB khớp · cicd tail `0a` sạch. **2 flag GATE S59-end RESOLVED bởi em main:** `:53` bundle-live → FINAL-v2 `B1DtNT9C`/`D6uF3Mln` #282 ✓ + #275 UTC annotate `:75` ✓. #383 vẫn lạc (`:89``:96` shift +7 đúng arithmetic, annotation guard intact). Chore P1 carry: **cicd 56,480B/103L over-cap 3rd-session + phình 54→56.5KB/buổi** + inv 32,931B → curate-L2 (kèm relocate #383 + FIFO swap #273#274); watch reviewer 30,354B + impl-be 28,585B. Tag [s60-start, clean, flags-resolved, cicd-56kb]. *(em main APPEND B3 — H2-proposed, verify: 0-byte/tree-clean/size đối chiếu độc lập ✓)*
- **2026-06-12 (S61 @start RE-REPORT — S60 đóng KHÔNG trọn):** Verdict 🟡 — S60 2 commit (37122f0 11:53 + 6db195d 14:30) KHÔNG docs-closeout (STATUS/HANDOFF/session-log đều dừng S59). Harvest-MD: 3 delta ĐÃ COMMIT bundle 37122f0 (harvest `:34` + inv ×2 `:73-75` tag s60 đúng-hết-drift + test-spec `:56` +14→254) + 1 mồ-côi cicd dirty `:71` mtime 14:37 entry-kép #283/#284 SANE (bundle-chain B1DtNT9C→akytoBnc→DSvM8h3A + user-chain khớp #282, sha khớp git, 2 test file disk-verified mtime trước commit). Coverage 5/6: **MISS reviewer die-mid-run** (commit body 37122f0 tự khai, file intact KHÔNG 0-byte) → on-behalf APPENDED S61-start (die lần 3: S57bis ×2 + S60). Đợt2 6db195d 0 sub-delta = em-main-direct evidence (16-min turnaround, test mtime 14:27) — flag không phán. Wave=0 · stray=0 · 0-byte=0 ×2 nơi · mojibake 2 hit quoted-cũ. Flags: test-spec baseline 254 stale vs **256 thật (59 Dom+197 Infra — em main dotnet test S61-start)** · CLAUDE.md "240 test" stale · inv `:51` pointer archive nhưng S52 entry chỉ ở git (cut-honest-labeled, nit) · **cicd 61KB/104L over-cap 4th session +4.5KB/buổi → curate-L2 P1 GẤP** + inv 32.7KB. Method ⭐: commit-body = nguồn spawn-evidence khi sub die không để delta (reviewer "die mid-run" tự khai trong msg 37122f0). Tag [s61-start, s60-incomplete-close, reviewer-die-3rd, cicd-61kb]. *(em main APPEND B3 — H2-proposed, verify: commit-body 37122f0 reviewer-die ✓ + test 256 tự chạy ✓ + cicd-dirty diff đối chiếu độc lập ✓)*

View File

@ -57,6 +57,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
## 📅 Recent activity (FIFO — older → archive/git) ## 📅 Recent activity (FIFO — older → archive/git)
- **2026-06-12 (S60 đợt1 PE submit-guard + drafter-bypass gate — KHÔNG DELIVER, die mid-run, on-behalf em main ghi hộ, H2-proposed):** Task: review `37122f0` cross-stack (BE TransitionAsync submit-guard đủ-4-thông-tin mục 3 + bypass người-soạn-trong-chuỗi V2 BƯỚC-ĐẦU-only + FE PeDetailTabs ×2 + 14 PeSubmitGuardAndBypassTests 240→254). Die mid-run #53-class (commit body tự khai "Reviewer die mid-run → em main self-gate evidence-checklist PASS 0 blocker") → ship Run #283 PASS prod-verified, bundle rotate both. LEARNED: self-gate em main đứng vững lần 2 (sau S57bis) — checklist deterministic (test gate + diff scope + prod smoke 401/404-control) đủ cho PE refinement cross-stack. SURPRISE: die lần 3 trong 2 ngày (S57bis die-0-byte ×2 + S60 mid-run) DÙ promote-tier inherit Fable 5 → model-tier KHÔNG phải nguyên nhân die (nghi resume-kill/harness class) — trend data cho Harness-4. Tag `[s60, die-mid-run-3rd, self-gate, on-behalf]`.
- **2026-06-11 (S57bis product gate — KHÔNG DELIVER, die-0-byte ×2, on-behalf em main ghi hộ, H2-proposed):** Cả 2 spawn (email-gate đầu + final gate) chết 0-byte output 0 return (resume-kill class #3, ref `feedback_agent_kill_recovery`) → em main SELF-GATE evidence-checklist: grep authz key-set + role-string vs AppRoles + Mig 49 Up/Down reversible + 240 test + Run #381 + prod smoke 401/404-control. LEARNED: output-file size=0 + im >5 phút = chết, KHÔNG đợi thêm; KHÔNG re-spawn >2 lần trong session có `--resume`. SURPRISE: khác S52 killed-with-partial — lần này 0-byte tuyệt đối (không gì recover được từ return). Tag `[s57bis, die-0-byte-x2, self-gate, on-behalf]`. - **2026-06-11 (S57bis product gate — KHÔNG DELIVER, die-0-byte ×2, on-behalf em main ghi hộ, H2-proposed):** Cả 2 spawn (email-gate đầu + final gate) chết 0-byte output 0 return (resume-kill class #3, ref `feedback_agent_kill_recovery`) → em main SELF-GATE evidence-checklist: grep authz key-set + role-string vs AppRoles + Mig 49 Up/Down reversible + 240 test + Run #381 + prod smoke 401/404-control. LEARNED: output-file size=0 + im >5 phút = chết, KHÔNG đợi thêm; KHÔNG re-spawn >2 lần trong session có `--resume`. SURPRISE: khác S52 killed-with-partial — lần này 0-byte tuyệt đối (không gì recover được từ return). Tag `[s57bis, die-0-byte-x2, self-gate, on-behalf]`.
- **2026-06-10 (S57-resume Harness-4 two-tier adopt gate — PASS-with-fixes, 0 blocker):** Gate trước send-email + commit (governance, không product code). Self-report spawn: `claude-fable-5[1m]` (reviewer = promote-list inherit → direct promote-tier evidence, em main cite được). Independent re-verify ALL GREEN: grep frontmatter = đúng 7 pin `claude-opus-4-8` + 4 `inherit` + 0 `[1m]`-in-frontmatter (2 body-text hits hợp lệ: database-agent.md:46 + README.md:9 MỚI — adap-report "match duy nhất" stale-by-own-edit) + 0 project-pin settings. Evidence track-record **8/8 REAL** vs HANDOFF/STATUS/own-memory (S51 MAJOR · S54 QTV-decoy · S53 Mig46 · S56 H2-4.5/5 + dept-IT-0-user · S57 ×3 controller +5/+5/+5 `[Authorize(Roles="Admin,CatalogManager")]` working-tree). Nấc G-011 đúng mọi chỗ load-bearing (demote = executed-file·pending-restart, 0 overclaim runtime). Fixes: hash PLACEHOLDER trước send (`nac: sent` + "SENT ✓" premature = đúng status-verb class broadcast cảnh báo) · STATUS "(runtime resolve 1M)" thiếu attribution AI_INFRA-s20 · hmw.js:91 log "same-model inherit" stale + :9 "8-agent" vs 9 roles · adap-report "(13)" vs "11" count · invalid-role typo → rơi 'opus' (fail-direction xuống vs H4.5 nghiêng-quality). **Learned:** gate adopt-governance = re-run MỌI grep claim + cross-check evidence vs HANDOFF nguyên văn; n=2 demoted spawn-test double-duty làm inherit-chain proof là HỢP LỆ (registry cached = chạy config cũ) nhưng cần phrase rõ kẻo đọc nhầm thành promote-list spawn-test. Tag [s57, harness-4, two-tier-gate, pre-send-gate, g011]. - **2026-06-10 (S57-resume Harness-4 two-tier adopt gate — PASS-with-fixes, 0 blocker):** Gate trước send-email + commit (governance, không product code). Self-report spawn: `claude-fable-5[1m]` (reviewer = promote-list inherit → direct promote-tier evidence, em main cite được). Independent re-verify ALL GREEN: grep frontmatter = đúng 7 pin `claude-opus-4-8` + 4 `inherit` + 0 `[1m]`-in-frontmatter (2 body-text hits hợp lệ: database-agent.md:46 + README.md:9 MỚI — adap-report "match duy nhất" stale-by-own-edit) + 0 project-pin settings. Evidence track-record **8/8 REAL** vs HANDOFF/STATUS/own-memory (S51 MAJOR · S54 QTV-decoy · S53 Mig46 · S56 H2-4.5/5 + dept-IT-0-user · S57 ×3 controller +5/+5/+5 `[Authorize(Roles="Admin,CatalogManager")]` working-tree). Nấc G-011 đúng mọi chỗ load-bearing (demote = executed-file·pending-restart, 0 overclaim runtime). Fixes: hash PLACEHOLDER trước send (`nac: sent` + "SENT ✓" premature = đúng status-verb class broadcast cảnh báo) · STATUS "(runtime resolve 1M)" thiếu attribution AI_INFRA-s20 · hmw.js:91 log "same-model inherit" stale + :9 "8-agent" vs 9 roles · adap-report "(13)" vs "11" count · invalid-role typo → rơi 'opus' (fail-direction xuống vs H4.5 nghiêng-quality). **Learned:** gate adopt-governance = re-run MỌI grep claim + cross-check evidence vs HANDOFF nguyên văn; n=2 demoted spawn-test double-duty làm inherit-chain proof là HỢP LỆ (registry cached = chạy config cũ) nhưng cần phrase rõ kẻo đọc nhầm thành promote-list spawn-test. Tag [s57, harness-4, two-tier-gate, pre-send-gate, g011].
- **2026-06-09 (S56 pre-golive authz live-curl — PASS, 0 blocker):** Live prod curl 8 new endpoints. **8/8 return 401 unauth**; admin-authed: hrm-configs/vehicles(2)+drivers(2), leave-balances/my(5 lazy), attendances/report+excel(200, 6797B xlsx) all 200; non-admin Drafter correctly 403 on the 2 Admin-only attendance endpoints. **gotcha #44 silent-403 sweep CLEAN:** capability GET /it-tickets/assignable-staff returns HTTP 200 `{canReassign:false,staff:[]}` for non-IT Drafter (NOT swallowed 403) + `{true,[]}` admin — handler returns flag, doesn't throw (`WorkflowAppsFeatures.cs:466`). assign-mutation guard fail-closed (:504). E2E: GET /projects payload has all +4 fields (70/70), CAL01 Investor live. Off_AttendanceReport menu key in admin /menus/me. **1 MINOR (non-block, defense-in-depth):** PUT /it-tickets/{id}/assign checks NotFound BEFORE Admin-OR-IT Forbidden (`WorkflowAppsFeatures.cs:496-508`) → existence-oracle leak; mutation itself fail-closed → post-golive hardening only. Tag [s56, pre-golive-verify, authz-clean, gotcha44-clean, notfound-before-forbidden-minor]. - **2026-06-09 (S56 pre-golive authz live-curl — PASS, 0 blocker):** Live prod curl 8 new endpoints. **8/8 return 401 unauth**; admin-authed: hrm-configs/vehicles(2)+drivers(2), leave-balances/my(5 lazy), attendances/report+excel(200, 6797B xlsx) all 200; non-admin Drafter correctly 403 on the 2 Admin-only attendance endpoints. **gotcha #44 silent-403 sweep CLEAN:** capability GET /it-tickets/assignable-staff returns HTTP 200 `{canReassign:false,staff:[]}` for non-IT Drafter (NOT swallowed 403) + `{true,[]}` admin — handler returns flag, doesn't throw (`WorkflowAppsFeatures.cs:466`). assign-mutation guard fail-closed (:504). E2E: GET /projects payload has all +4 fields (70/70), CAL01 Investor live. Off_AttendanceReport menu key in admin /menus/me. **1 MINOR (non-block, defense-in-depth):** PUT /it-tickets/{id}/assign checks NotFound BEFORE Admin-OR-IT Forbidden (`WorkflowAppsFeatures.cs:496-508`) → existence-oracle leak; mutation itself fail-closed → post-golive hardening only. Tag [s56, pre-golive-verify, authz-clean, gotcha44-clean, notfound-before-forbidden-minor].

View File

@ -24,8 +24,6 @@ import { UsersPage } from '@/pages/system/UsersPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage' import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage' import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
@ -80,9 +78,6 @@ function App() {
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} /> <Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} /> <Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} /> <Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} /> <Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} /> <Route path="/employees/new" element={<EmployeeCreatePage />} />

View File

@ -47,10 +47,6 @@ function resolvePath(key: string): string | null {
CatalogWorkItems: '/master/catalogs/work-items', CatalogWorkItems: '/master/catalogs/work-items',
PurchaseEvaluations: '/purchase-evaluations', PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows', PeWorkflows: '/system/pe-workflows',
Budgets: '/budgets',
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON // [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới // Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent. // — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.

View File

@ -1,491 +0,0 @@
// Detail content cho 1 ngân sách. Flat render (no tabs):
// Section 1 = Thông tin Header
// Section 2 = Hạng mục (table CRUD inline)
// Approvals + Changelog → moved sang Panel 3 (BudgetWorkflowPanel).
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { 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 { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetChangelog,
type BudgetDetailBundle,
type BudgetDetailBody,
type BudgetDetailRow,
} from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetDetailTabs({
budget,
onBack,
onDelete,
readOnly = false,
}: {
budget: BudgetDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
}) {
const navigate = useNavigate()
const isDraft = budget.phase === BudgetPhase.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">{budget.tenNganSach}</h2>
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', BudgetPhaseColor[budget.phase])}>
{BudgetPhaseLabel[budget.phase]}
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{budget.maNganSach ?? '—'}</span>
<span>·</span>
<span>Năm {budget.namNganSach}</span>
<span>·</span>
<span>{budget.projectName}</span>
{budget.drafterName && (<><span>·</span><span>Soạn: {budget.drafterName}</span></>)}
</div>
</div>
<div className="flex gap-2">
{isDraft && !readOnly && (
<>
<Button
variant="ghost"
onClick={() => navigate(`/budgets/new?id=${budget.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>
<div className="divide-y divide-slate-200">
<Section title="Thông tin">
<InfoTab budget={budget} />
</Section>
<Section title={`Hạng mục (${budget.details.length}) — Tổng: ${fmtMoney(budget.tongNganSach)} đ`}>
<ItemsTab budget={budget} readOnly={readOnly} />
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function BudgetApprovalsSection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">
Lịch sử duyệt ({budget.approvals.length})
</h3>
<ApprovalsList budget={budget} />
</div>
)
}
export function BudgetHistorySection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đi</h3>
<HistoryList budget={budget} />
</div>
)
}
// ===== Section: Thông tin Header =====
function InfoTab({ budget }: { budget: BudgetDetailBundle }) {
return (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên ngân sách" value={budget.tenNganSach} />
<Field label="Mã ngân sách" value={<span className="font-mono">{budget.maNganSach ?? '—'}</span>} />
<Field label="Năm ngân sách" value={budget.namNganSach} />
<Field label="Dự án" value={budget.projectName} />
<Field label="Phòng ban" value={budget.departmentName ?? '—'} />
<Field label="Người soạn" value={budget.drafterName ?? '—'} />
<Field label="Tổng ngân sách" value={<span className="font-semibold text-brand-700">{fmtMoney(budget.tongNganSach)} đ</span>} />
<Field label="Trạng thái" value={<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[budget.phase])}>{BudgetPhaseLabel[budget.phase]}</span>} />
{budget.description && (
<div className="col-span-2">
<dt className="text-[11px] uppercase tracking-wide text-slate-400"> tả</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-slate-800">{budget.description}</dd>
</div>
)}
</dl>
)
}
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>
)
}
// ===== Section: Hạng mục table CRUD =====
function ItemsTab({ budget, readOnly = false }: { budget: BudgetDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<BudgetDetailRow | null>(null)
const isDraft = budget.phase === BudgetPhase.DangSoanThao
const canMutate = !readOnly && isDraft
const remove = useMutation({
mutationFn: async (rowId: string) => api.delete(`/budgets/${budget.id}/details/${rowId}`),
onSuccess: () => {
toast.success('Đã xóa hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div>
{canMutate && (
<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 hạng mục
</Button>
</div>
)}
{budget.details.length === 0 ? (
<p className="text-sm text-slate-500">
{canMutate ? 'Chưa có hạng mục nào. Thêm để bắt đầu lập ngân sách.' : 'Chưa có hạng mục.'}
</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-2 py-2 text-left">#</th>
<th className="px-2 py-2 text-left">Nhóm</th>
<th className="px-2 py-2 text-left"> / Nội dung</th>
<th className="px-2 py-2 text-left">ĐVT</th>
<th className="px-2 py-2 text-right">KL</th>
<th className="px-2 py-2 text-right">Đơn giá</th>
<th className="px-2 py-2 text-right">Thành tiền</th>
{canMutate && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{budget.details.map((d, idx) => (
<tr key={d.id} className="align-top">
<td className="px-2 py-2 text-slate-400">{idx + 1}</td>
<td className="px-2 py-2">
<div className="font-mono text-[11px] text-slate-500">{d.groupCode}</div>
<div className="text-[12px] text-slate-700">{d.groupName}</div>
</td>
<td className="px-2 py-2 max-w-md">
{d.itemCode && <div className="font-mono text-[11px] text-slate-500">{d.itemCode}</div>}
<div className="text-slate-800">{d.noiDung}</div>
{d.ghiChu && <div className="mt-0.5 text-[11px] italic text-slate-500">{d.ghiChu}</div>}
</td>
<td className="px-2 py-2 text-slate-600">{d.donViTinh ?? '—'}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.khoiLuong)}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.donGia)}</td>
<td className="px-2 py-2 text-right font-medium tabular-nums text-slate-900">
{fmtMoney(d.thanhTien)}
</td>
{canMutate && (
<td className="px-2 py-2 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditRow(d)}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm('Xóa hạng mục này?')) remove.mutate(d.id)
}}
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold">
<td colSpan={6} className="px-2 py-2 text-right text-slate-600">Tổng:</td>
<td className="px-2 py-2 text-right tabular-nums text-brand-700">
{fmtMoney(budget.tongNganSach)}
</td>
{canMutate && <td></td>}
</tr>
</tfoot>
</table>
</div>
)}
{open && (
<DetailRowDialog
budgetId={budget.id}
onClose={() => setOpen(false)}
/>
)}
{editRow && (
<DetailRowDialog
budgetId={budget.id}
existing={editRow}
onClose={() => setEditRow(null)}
/>
)}
</div>
)
}
// ===== Dialog: Thêm / Sửa hạng mục =====
function DetailRowDialog({
budgetId,
existing,
onClose,
}: {
budgetId: string
existing?: BudgetDetailRow
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState<BudgetDetailBody>({
groupCode: existing?.groupCode ?? '',
groupName: existing?.groupName ?? '',
itemCode: existing?.itemCode ?? null,
noiDung: existing?.noiDung ?? '',
donViTinh: existing?.donViTinh ?? null,
khoiLuong: existing?.khoiLuong ?? 0,
donGia: existing?.donGia ?? 0,
thanhTien: existing?.thanhTien ?? 0,
ghiChu: existing?.ghiChu ?? null,
})
// Auto-compute thành tiền khi đổi KL/đơn giá (UX nicety)
function setQty(v: number) {
const next = { ...form, khoiLuong: v, thanhTien: v * form.donGia }
setForm(next)
}
function setPrice(v: number) {
const next = { ...form, donGia: v, thanhTien: form.khoiLuong * v }
setForm(next)
}
const save = useMutation({
mutationFn: async () => {
const payload = {
groupCode: form.groupCode,
groupName: form.groupName,
itemCode: form.itemCode || null,
noiDung: form.noiDung,
donViTinh: form.donViTinh || null,
khoiLuong: form.khoiLuong,
donGia: form.donGia,
thanhTien: form.thanhTien,
ghiChu: form.ghiChu || null,
}
if (existing) {
return api.put(`/budgets/${budgetId}/details/${existing.id}`, payload)
}
return api.post(`/budgets/${budgetId}/details`, payload)
},
onSuccess: () => {
toast.success(existing ? 'Đã cập nhật hạng mục.' : 'Đã thêm hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budgetId] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={existing ? 'Sửa hạng mục' : 'Thêm hạng mục'}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button
onClick={() => save.mutate()}
disabled={!form.groupCode || !form.groupName || !form.noiDung || save.isPending}
>
{existing ? 'Lưu' : 'Thêm'}
</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label> nhóm *</Label>
<Input
value={form.groupCode}
onChange={e => setForm({ ...form, groupCode: e.target.value })}
placeholder="A.I"
/>
</div>
<div>
<Label>Tên nhóm *</Label>
<Input
value={form.groupName}
onChange={e => setForm({ ...form, groupName: e.target.value })}
placeholder="Vật tư xây dựng"
/>
</div>
</div>
<div>
<Label> hạng mục</Label>
<Input
value={form.itemCode ?? ''}
onChange={e => setForm({ ...form, itemCode: e.target.value || null })}
/>
</div>
<div>
<Label>Nội dung *</Label>
<Input
value={form.noiDung}
onChange={e => setForm({ ...form, noiDung: e.target.value })}
placeholder="Bê tông M250"
/>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>ĐVT</Label>
<Input
value={form.donViTinh ?? ''}
onChange={e => setForm({ ...form, donViTinh: e.target.value || null })}
placeholder="m³"
/>
</div>
<div>
<Label>Khối lượng</Label>
<Input
type="number"
step="0.0001"
value={form.khoiLuong}
onChange={e => setQty(Number(e.target.value))}
/>
</div>
<div>
<Label>Đơn giá</Label>
<Input
type="number"
step="0.01"
value={form.donGia}
onChange={e => setPrice(Number(e.target.value))}
/>
</div>
<div>
<Label>Thành tiền</Label>
<Input
type="number"
step="0.01"
value={form.thanhTien}
onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })}
/>
</div>
</div>
<div>
<Label>Ghi chú</Label>
<Input
value={form.ghiChu ?? ''}
onChange={e => setForm({ ...form, ghiChu: e.target.value || null })}
/>
</div>
</div>
</Dialog>
)
}
// ===== Sub: Approvals list =====
function ApprovalsList({ budget }: { budget: BudgetDetailBundle }) {
if (budget.approvals.length === 0)
return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{budget.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]', BudgetPhaseColor[a.fromPhase])}>
{BudgetPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.toPhase])}>
{BudgetPhaseLabel[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>
)
}
// ===== Sub: Changelog list =====
function HistoryList({ budget }: { budget: BudgetDetailBundle }) {
const logs = useQuery({
queryKey: ['budget-changelog', budget.id],
queryFn: async () => (await api.get<BudgetChangelog[]>(`/budgets/${budget.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0)
return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 py-1 pl-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}

View File

@ -1,225 +0,0 @@
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
// Pulls nextPhases từ BE bundle (single source of truth).
import { useState } from 'react'
import { useMutation, useQuery, 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 {
ApprovalDecision,
ApprovalStage,
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDepartmentApproval,
type BudgetDetailBundle,
} from '@/types/budget'
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
queryKey: ['budget-dept-approvals', budget.id],
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
})
const transition = useMutation({
mutationFn: async () =>
api.post(`/budgets/${budget.id}/transitions`, {
targetPhase: target,
decision: target === BudgetPhase.TuChoi ? ApprovalDecision.Reject : ApprovalDecision.Approve,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = budget.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">{budget.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{budget.workflow.activePhases
.filter(p => p !== BudgetPhase.TuChoi)
.map(p => {
const isCurrent = budget.phase === p
const isPast = isPastPhase(budget.phase, p, budget.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]', BudgetPhaseColor[p])}>{p}</span>
<span className="truncate">{BudgetPhaseLabel[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 === BudgetPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: p === BudgetPhase.DangSoanThao
? 'border-amber-300 text-amber-700 hover:bg-amber-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{BudgetPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${BudgetPhaseLabel[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>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
</div>
)}
<div className="border-t border-slate-200 pt-4">
<BudgetApprovalsSection budget={budget} />
</div>
<div className="border-t border-slate-200 pt-4">
<BudgetHistorySection budget={budget} />
</div>
</div>
)
}
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
function BudgetDeptApprovalsSection({
rows,
currentPhase,
}: {
rows: BudgetDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
for (const r of rows) {
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
const byDept = grouped.get(r.phaseAtApproval)!
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
byDept.get(r.departmentId)!.push(r)
}
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<div>
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
<p className="mt-0.5 text-[11px] text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="mt-2 space-y-3">
{phaseOrder.map(phase => {
const byDept = grouped.get(phase)!
return (
<div key={phase}>
<div className={cn(
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
</div>
<div className="mt-1 space-y-1.5">
{[...byDept.entries()].map(([deptId, stages]) => {
const review = stages.find(s => s.stage === ApprovalStage.Review)
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
const isPending = phase === currentPhase && review && !confirm
return (
<div key={deptId} className={cn(
'rounded border px-2 py-1.5 text-[11px]',
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
)}>
<div className="font-medium text-slate-700">{deptName}</div>
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
<span className="text-slate-500">Review:</span>
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
{review
? <> {review.approverName} <span className="text-slate-500"> {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
: '— chưa có'}
</span>
<span className="text-slate-500">Confirm:</span>
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
{confirm
? <> {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500"> {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
: '⏳ chờ TPB confirm'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
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 !== BudgetPhase.TuChoi
}

View File

@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react' import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -40,9 +40,8 @@ import {
type PeQuote, type PeQuote,
type PeSupplier, type PeSupplier,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import { SupplierType, SupplierTypeLabel } from '@/types/master' import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Paged, Supplier } from '@/types/master' import type { Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -174,9 +173,10 @@ export function PeDetailTabs({
const gia = computeGiaChaoThau(evaluation) const gia = computeGiaChaoThau(evaluation)
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
} }
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount) // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) { // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
missing.push("Chưa nhập Ngân sách") if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
missing.push("Chưa nhập Ngân sách kỳ này")
} }
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
@ -286,12 +286,9 @@ export function PeDetailTabs({
? <LevelOpinionsSectionV2 ev={evaluation} /> ? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />} : <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section> </Section>
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter {/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */} thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
<Section title="5. Điều chỉnh ngân sách">
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
</Section>
</div> </div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút: {/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
@ -680,9 +677,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
diaDiem: diaDiem || null, diaDiem: diaDiem || null,
moTa: moTa || null, moTa: moTa || null,
paymentTerms: paymentTerms || null, paymentTerms: paymentTerms || null,
budgetId: ev.budgetId, // S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
budgetManualName: ev.budgetManualName, // giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
budgetManualAmount: ev.budgetManualAmount, // TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -854,325 +853,434 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
) )
} }
// ===== b. Ngân sách inline editor (Mig 17) ===== // ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần // Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách / // BE trả `ev.budgetSummary`. 2 block:
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao / // A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt / // dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// từ chối. Empty values hiển thị empty (per user 2026-05-07). // B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { // (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
const canEdit = !readOnly && isEditablePhase(ev.phase) // budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
const qc = useQueryClient()
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link. // fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với const fmtVndSigned = (v: number) =>
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName). v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId const fmtPct = (num: number, denom: number): string | null =>
const [manualMode, setManualMode] = useState(initialManual) denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// Eligible budgets — chỉ fetch khi user có khả năng edit // Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
const eligibleBudgets = useQuery({ // cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
queryKey: ['eligible-budgets', ev.projectId], function VndInlineEdit({
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', { initial, allowNegative = false, onSave, saving, label,
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet }, }: {
})).data.items, initial: number | null
enabled: canEdit, allowNegative?: boolean
}) onSave: (v: number | null) => void
saving: boolean
// Dirty detect — compare current state vs ev original label?: string
const dirty = manualMode !== initialManual }) {
|| (manualMode && manualAmount !== (ev.budgetManualAmount ?? 0)) const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|| (!manualMode && budgetId !== (ev.budgetId ?? '')) const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const save = useMutation({ const n = parseVnd(text)
mutationFn: async () => { if (n === 0 && text.trim() === '') return null
const payload = manualMode return allowNegative && neg ? -n : n
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
<FormRow
label="b. Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"></span>}
/>
)
} }
const dirty = parse() !== initial
// Editable mode (canEdit=true)
return ( return (
<div className="flex gap-3"> <div className="flex items-center justify-end gap-1.5">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span> {allowNegative && (
<div className="min-w-0 flex-1 space-y-2"> <button
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> type="button"
<input onClick={() => setNeg(v => !v)}
type="checkbox" className={cn(
checked={manualMode} 'h-6 w-6 shrink-0 rounded border text-xs font-bold',
onChange={e => setManualMode(e.target.checked)} neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
className="h-3.5 w-3.5 rounded border-slate-300" )}
/> title="Đảo dấu âm/dương"
Nhập tay (không link) >
</label> {neg ? '' : '+'}
{!manualMode ? ( </button>
<Select )}
value={budgetId} <div className="relative w-40">
onChange={e => setBudgetId(e.target.value)} <Input
className="text-sm" type="text"
> inputMode="numeric"
<option value=""></option> value={text}
{eligibleBudgets.data?.map(b => ( onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
<option key={b.id} value={b.id}> placeholder="0"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ aria-label={label}
</option> className="h-7 pr-6 font-mono text-right text-[13px]"
))} />
</Select> <span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
) : ( </div>
<div className="relative max-w-xs"> <Button
<Input onClick={() => onSave(parse())}
type="text" disabled={!dirty || saving}
inputMode="numeric" className="h-7 px-2 text-[11px]"
value={formatVndInput(manualAmount)} >
onChange={e => setManualAmount(parseVnd(e.target.value))} {saving ? '…' : 'Lưu'}
placeholder="0" </Button>
className="pr-10 font-mono text-right text-sm" </div>
/> )
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span> }
</div>
)} // 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
{dirty && ( // tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
<div className="flex items-center gap-2"> function BudgetRow({
<Button label, sub, value, third, tone, danger, mono = true,
onClick={() => save.mutate()} }: {
disabled={save.isPending} label: React.ReactNode
className="h-7 px-3 text-xs" sub?: React.ReactNode
> value: React.ReactNode
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'} third?: React.ReactNode
</Button> tone?: 'brand' | 'brand-soft' | 'blue-soft'
<button danger?: boolean
onClick={() => { mono?: boolean
setManualMode(initialManual) }) {
setBudgetId(ev.budgetId ?? '') const toneCls =
setManualAmount(ev.budgetManualAmount ?? 0) tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
}} : tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
className="text-[11px] text-slate-500 hover:text-slate-700" : tone === 'blue-soft' ? 'bg-blue-50'
> : ''
Hủy thay đi return (
</button> <div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
</div> <div className="min-w-0 flex-1">
)} <div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div> </div>
</div> </div>
) )
} }
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) ===== // Block tiêu đề (A / B)
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet) function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint return (
// PATCH /budget-adjust riêng. Audit changelog tự động. <div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { {children}
const { user: currentUser } = useAuth() </div>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()
const [editing, setEditing] = useState(false) const bs = ev.budgetSummary
const isAdmin = currentUser?.roles?.includes('Admin') ?? false // Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id // khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao const drafterEditable = !readOnly && isEditablePhase(ev.phase)
|| ev.phase === PurchaseEvaluationPhase.TraLai
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly const invalidate = () => {
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match + qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true) qc.invalidateQueries({ queryKey: ['pe-list'] })
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace). }
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId // PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
const [manualMode, setManualMode] = useState(initialManual) const proMut = useMutation({
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '') mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0) api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)" onSuccess: () => { toast.success('Đã lưu dự trù PRO'); invalidate() },
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước) onError: e => toast.error(getErrorMessage(e)),
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets-adjust', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: editing && canAdjust,
}) })
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({ const adjustMut = useMutation({
mutationFn: async () => { mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
const payload = manualMode api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null } onSuccess: () => { toast.success('Đã lưu'); invalidate() },
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
},
onSuccess: () => {
toast.success('Đã điều chỉnh ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History. const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// Display read mode // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
const displayLink = ev.budget ? ( if (!bs) {
<span> return (
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span> <div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
{' · '}{ev.budget.tenNganSach} Phiếu chưa gắn Hạng mục công việc gắn Hạng mục đ dùng ngân sách gói thầu.
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> </div>
</span> )
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? ( }
<span>
{ev.budgetManualName && <span>{ev.budgetManualName}</span>} // ===== Số liệu Excel =====
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '} const full = bs.fullAmount
{ev.budgetManualAmount != null && ( const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span> const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
)} const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span> const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
</span> const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
) : <span className="italic text-slate-400">Chưa ngân sách</span> const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
const cmp56 = row5 - row6 // So với NS (row5 row6)
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
return ( return (
<div className="space-y-3"> <div className="overflow-hidden rounded-lg border border-slate-300">
{/* Read mode + Edit toggle */} <div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
{!editing && ( Tổng hợp ngân sách trình
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2"> </div>
<div className="flex items-start gap-2">
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<div className="text-sm text-slate-700">{displayLink}</div>
</div>
{canAdjust && (
<Button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
setEditing(true)
}}
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
>
<Pencil className="h-3 w-3" /> Điều chỉnh
</Button>
)}
</div>
)}
{/* Edit mode */} {/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
{editing && canAdjust && ( <BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
{isApproverChoDuyet && ( {/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800"> <BudgetRow
Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt thay đi sẽ đưc ghi vào lịch sử. tone="brand"
</div> label={
)} <span className="inline-flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> Ngân sách (full gói thầu)
<input {bs.fullIsEstimate && (
type="checkbox" <span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">dự trù PRO</span>
checked={manualMode} )}
onChange={e => setManualMode(e.target.checked)} </span>
className="h-3.5 w-3.5 rounded border-slate-300" }
value={fmtVnd(full)}
/>
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
<BudgetRow
label="Ngân sách Ban hành lần đầu"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.initialAmount}
saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
/> />
Nhập tay (không link Budget) ) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
</label> }
{!manualMode ? ( />
<div>
<Label className="text-[11px]">Chọn Budget từ danh sách</Label> {/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm"> <BudgetRow
<option value=""> (huỷ link)</option> label="Ngân sách V0 / hiệu chỉnh tăng giảm"
{eligibleBudgets.data?.map(b => ( value={
<option key={b.id} value={b.id}> bs.canEditCcm ? (
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ <VndInlineEdit
</option> initial={bs.adjustmentAmount}
))} allowNegative
</Select> saving={ccmMut.isPending}
</div> label="Ngân sách hiệu chỉnh tăng giảm"
) : ( onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
<div className="max-w-xs"> />
<Label className="text-[11px]">Số tiền (VND)</Label> ) : bs.adjustmentAmount != null ? (
<div className="relative"> <span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
<Input ) : <span className="text-slate-400"></span>
type="text" }
inputMode="numeric" />
value={formatVndInput(manualAmount)}
onChange={e => setManualAmount(parseVnd(e.target.value))} {/* Dòng 4 — Dự trù PRO (PRO editable) */}
placeholder="0" <BudgetRow
className="pr-10 font-mono text-right text-sm" label="Dự trù PRO"
/> value={
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span> bs.canEditPro ? (
<VndInlineEdit
initial={bs.proEstimateAmount}
saving={proMut.isPending}
label="Dự trù PRO"
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
/>
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
<div className="w-72 shrink-0">
{bs.canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú dự trù…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
className="h-6 px-2 text-[11px]"
>
{proMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div> </div>
</div> </div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.proNote || <span className="text-slate-400"></span>}
</div>
)} )}
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => setEditing(false)}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => adjustMut.mutate()}
disabled={adjustMut.isPending}
className="h-7 px-3 text-xs"
>
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
</Button>
</div>
</div> </div>
)} </div>
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */} {/* ===== Block B — THỰC HIỆN ===== */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */}
<BudgetRow
label="1. Ngân sách trình duyệt trước"
value={bs.previousSubmittedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row1)}
/>
{/* 2 — Kỳ trước đã chọn thầu */}
<BudgetRow
label="2. Kỳ trước đã chọn thầu"
value={bs.previousSelectedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row2)}
/>
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
<BudgetRow
label="3. Ngân sách - kỳ này"
value={
drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
}
third={fmtPct(row3, full) ?? undefined}
/>
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
<BudgetRow
tone="blue-soft"
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={
<span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>}
</span>
}
/>
<BudgetRow
tone="blue-soft"
label="Giá trị kỳ này"
value={
proposalOver ? (
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
) : fmtVnd(row4)
}
/>
<BudgetRow
tone="blue-soft"
label="So sánh với ngân sách kỳ này"
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
<BudgetRow
label="5. Lũy kế ngân sách đã sử dụng"
sub="= 1 + 3"
value={fmtVnd(row5)}
/>
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */}
<BudgetRow
label="6. Lũy kế thực hiện"
sub="= 2 + 4"
value={fmtVnd(row6)}
/>
<BudgetRow
label="So với NS"
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
{/* 7 — Ngân sách còn lại (= full 5) + % /full */}
<BudgetRow
label="7. Ngân sách còn lại"
sub="= Ngân sách full 5"
value={fmtVnd(row7)}
third={fmtPct(row7, full) ?? undefined}
/>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
<div className={cn(
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
remainingOver && 'bg-red-50',
)}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div>
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVnd(row8)}
</span>
)}
</div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={fmtVnd(row9)}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div> </div>
) )
} }
@ -1197,7 +1305,9 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} /> <NccSelectorRow ev={ev} readOnly={readOnly} />
<BudgetFieldRow ev={ev} readOnly={readOnly} /> {/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
<FormRow <FormRow
label="c. Giá chào thầu" label="c. Giá chào thầu"
value={ value={
@ -1620,21 +1730,9 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null) const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row. // S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
const budgetBundle = useQuery({ // không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
queryKey: ['budget-detail-for-pe', ev.budgetId], // TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
return ( return (
<div> <div>
@ -1658,8 +1756,6 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
detail={d} detail={d}
ev={ev} ev={ev}
readOnly={readOnly} readOnly={readOnly}
budgetRowMap={budgetRowMap}
showBudgetCol={showBudgetCol}
onEditDetail={() => setEditDetail(d)} onEditDetail={() => setEditDetail(d)}
/> />
))} ))}
@ -1675,13 +1771,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand. // Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click. // Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({ function HangMucCard({
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail, detail, ev, readOnly, onEditDetail,
}: { }: {
detail: PeDetailRow detail: PeDetailRow
ev: PeDetailBundle ev: PeDetailBundle
readOnly: boolean readOnly: boolean
budgetRowMap: Map<string, number>
showBudgetCol: boolean
onEditDetail: () => void onEditDetail: () => void
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
@ -1707,9 +1801,6 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
return ( return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm"> <div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap + {/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
@ -1741,20 +1832,9 @@ function HangMucCard({
<span className="ml-1 text-xs font-normal text-slate-500">đ</span> <span className="ml-1 text-xs font-normal text-slate-500">đ</span>
</div> </div>
</div> </div>
{showBudgetCol && bgValue != null && ( {/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
<div className="border-l border-slate-200 pl-3"> Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
<div className="text-[10px] uppercase text-slate-400">NS link</div> trình ký" cấp phiếu (PeBudgetSummaryTable). */}
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
</div> </div>
{!readOnly && ( {!readOnly && (
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">

View File

@ -18,8 +18,7 @@ import {
PurchaseEvaluationTypeLabel, PurchaseEvaluationTypeLabel,
type PeDetailBundle, type PeDetailBundle,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget' import type { Project } from '@/types/master'
import type { Paged, Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20) // VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -81,29 +80,13 @@ export function PeHeaderForm({
diaDiem: '', diaDiem: '',
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '' as string, // [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* cũ (module
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link) // Budget xóa hẳn; bảng Tổng hợp ngân sách gói thầu nằm ở PeDetailTabs).
budgetManual: false, budgetPeriodAmount: 0,
budgetManualName: '',
budgetManualAmount: 0,
})
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
}) })
useEffect(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
// S59: manual-mode detect theo CẢ amount (phiếu mới name=null sau khi bỏ ô Tên).
const hasManual = existing.data.budgetManualAmount !== null || existing.data.budgetManualName !== null
|| existing.data.budgetManualAmount !== null
setForm({ setForm({
type: existing.data.type, type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau, tenGoiThau: existing.data.tenGoiThau,
@ -112,27 +95,16 @@ export function PeHeaderForm({
diaDiem: existing.data.diaDiem ?? '', diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '', moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '', paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '', budgetPeriodAmount: existing.data.budgetPeriodAmount ?? 0,
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
budgetManual: hasManual && !existing.data.budgetId,
budgetManualName: existing.data.budgetManualName ?? '',
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
}) })
} }
}, [existing.data]) }, [existing.data])
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual. // [S61] PUT UpdateDraft null-safe: budgetPeriodAmount null = GIỮ giá trị cũ
const payloadBudgetFields = form.budgetManual // BE-side; gửi số > 0 mới set. (Clear hẳn → dùng bảng Tổng hợp/budget-adjust.)
? { const payloadBudgetFields = {
budgetId: null, budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
budgetManualName: null, // S59 anh chốt bỏ "Tên ngân sách" — manual chỉ còn Số tiền }
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
}
: {
budgetId: form.budgetId || null,
budgetManualName: null,
budgetManualAmount: null,
}
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -239,58 +211,23 @@ export function PeHeaderForm({
</div> </div>
<div> <div>
<div className="mb-1.5 flex items-center justify-between"> {/* [S61 Mig 50] Ô đơn "Ngân sách kỳ này" — thay picker Budget cũ + toggle
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> nhập tay. Số phân bổ cho RIÊNG phiếu này (row 3 bảng Tổng hợp). */}
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */} <Label>Ngân sách kỳ này</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="relative max-w-xs">
<input <Input
type="checkbox" type="text"
checked={form.budgetManual} inputMode="numeric"
onChange={e => setForm({ ...form, budgetManual: e.target.checked })} value={formatVndInput(form.budgetPeriodAmount)}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
/> placeholder="0"
Nhập tay (không link) className="pr-10 font-mono text-right"
</label> />
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div> </div>
{!form.budgetManual ? ( <p className="mt-1 text-[11px] text-slate-500">
<> Số phân bổ cho riêng phiếu này bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng "Tổng hợp ngân sách trình ký" trong phiếu.
<Select </p>
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div>
<Label className="text-[11px]">Số tiền</Label>
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
</div>
)}
</div> </div>
<div> <div>

View File

@ -18,8 +18,7 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation' import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget' import type { Project } from '@/types/master'
import type { Paged, Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20) // VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -59,11 +58,9 @@ export function PeWorkspaceCreateView({
diaDiem: '', diaDiem: '',
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '', // [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* (module
// Mig 17 — manual budget fallback // Budget cũ xóa hẳn; bảng Tổng hợp ngân sách gói thầu ở PeDetailTabs).
budgetManual: false, budgetPeriodAmount: 0,
budgetManualName: '',
budgetManualAmount: 0,
// Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo) // Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo)
approvalWorkflowId: '', approvalWorkflowId: '',
}) })
@ -104,20 +101,9 @@ export function PeWorkspaceCreateView({
}, },
}) })
const eligibleBudgets = useQuery({ const budgetPayload = {
queryKey: ['eligible-budgets', form.projectId], budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
queryFn: async () => { }
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
})
const budgetPayload = form.budgetManual
? { budgetId: null, budgetManualName: form.budgetManualName || null, budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null }
: { budgetId: form.budgetId || null, budgetManualName: null, budgetManualAmount: null }
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -224,7 +210,7 @@ export function PeWorkspaceCreateView({
const loc = p?.location ?? '' const loc = p?.location ?? ''
setForm(f => { setForm(f => {
const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem } return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
}) })
lastAutoLoc.current = loc lastAutoLoc.current = loc
}} }}
@ -258,55 +244,26 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (sau khi thêm NCC tham gia + chốt winner)</span>} value={<span className="text-slate-400"> (sau khi thêm NCC tham gia + chốt winner)</span>}
/> />
{/* b. Ngân sách — editable inline (Mig 17 toggle pattern) */} {/* [S61 Mig 50] b. Ngân sách kỳ này — ô đơn thay picker Budget cũ +
toggle nhập tay (module Budget xóa hẳn). Số phân bổ cho RIÊNG
phiếu này (row 3 bảng "Tổng hợp ngân sách trình ký"). */}
<div className="flex gap-3"> <div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span> <span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách kỳ này</span>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-1">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="relative max-w-xs">
<input <Input
type="checkbox" type="text"
checked={form.budgetManual} inputMode="numeric"
onChange={e => setForm({ ...form, budgetManual: e.target.checked })} value={formatVndInput(form.budgetPeriodAmount)}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/> />
Nhập tay (không link) <span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</label> </div>
{!form.budgetManual ? ( <p className="text-[11px] text-slate-500">
<> Bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng &ldquo;Tổng hợp ngân sách trình &rdquo; sau khi tạo phiếu.
<Select </p>
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
)}
</div> </div>
</div> </div>

View File

@ -26,11 +26,8 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// Module Ngân sách (Phase 7) // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
Budgets: 'Budgets', // "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
Bg_List: 'Bg_List',
Bg_Create: 'Bg_Create',
Bg_Pending: 'Bg_Pending',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm', Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo', HrmHoSo: 'Hrm_HoSo',

View File

@ -1,173 +0,0 @@
// Create / edit draft Header ngân sách. Hạng mục chỉnh ở Detail tabs sau khi save.
// CreateBudgetCommand BE chỉ cho update Tên/Mô tả/Năm khi DangSoanThao
// — Project/Department khóa sau create.
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 { Wallet } 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 type { BudgetDetailBundle } from '@/types/budget'
import type { Department, Paged, Project } from '@/types/master'
export function BudgetCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const currentYear = new Date().getFullYear()
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () =>
(await api.get<Paged<Project>>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const departments = useQuery({
queryKey: ['all-departments'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['budget-detail', editId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
tenNganSach: '',
description: '',
namNganSach: currentYear,
projectId: '',
departmentId: '',
})
useEffect(() => {
if (existing.data) {
setForm({
tenNganSach: existing.data.tenNganSach,
description: existing.data.description ?? '',
namNganSach: existing.data.namNganSach,
projectId: existing.data.projectId,
departmentId: existing.data.departmentId ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/budgets/${editId}`, {
id: editId,
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
})
}
return api.post<{ id: string }>('/budgets', {
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
projectId: form.projectId,
departmentId: form.departmentId || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo ngân sách.')
qc.invalidateQueries({ queryKey: ['budget-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/budgets?id=${id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa header ngân sách' : 'Tạo ngân sách 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>Tên ngân sách *</Label>
<Input
value={form.tenNganSach}
onChange={e => setForm({ ...form, tenNganSach: e.target.value })}
placeholder="vd Ngân sách thi công Block A — FLOCK 01"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Năm ngân sách *</Label>
<Input
type="number"
min={2020}
max={2100}
value={form.namNganSach}
onChange={e => setForm({ ...form, namNganSach: Number(e.target.value) })}
/>
</div>
<div>
<Label>Phòng ban</Label>
<Select
value={form.departmentId}
disabled={!!editId}
onChange={e => setForm({ ...form, departmentId: e.target.value })}
>
<option value=""> (tùy chọn)</option>
{departments.data?.map(d => (
<option key={d.id} value={d.id}>{d.code} {d.name}</option>
))}
</Select>
</div>
</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>
{editId && (
<p className="mt-1 text-[11px] text-slate-500">Dự án + Phòng ban khóa sau khi tạo.</p>
)}
</div>
<div>
<Label> tả</Label>
<Textarea
rows={4}
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="Ghi chú ngân sách, phạm vi sử dụng, ràng buộc..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenNganSach || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo ngân sách'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -1,263 +0,0 @@
// List + Detail ngân sách — 3-panel: List | Detail (Header + Hạng mục) | Workflow + history.
// URL params: phase, projectId, q (search), id (selected), namNganSach.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Plus, Search, Wallet, 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 {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDetailBundle,
type BudgetListItem,
} from '@/types/budget'
import { BudgetDetailTabs } from '@/components/budgets/BudgetDetailTabs'
import { BudgetWorkflowPanel } from '@/components/budgets/BudgetWorkflowPanel'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const phaseFilter = sp.get('phase') ?? ''
const search = sp.get('q') ?? ''
const yearFilter = sp.get('namNganSach') ?? ''
const selectedId = sp.get('id')
// ?phase=Pending → highlight 2 phase chờ duyệt (ChoCCM + ChoCEO).
const isPendingMode = phaseFilter === 'Pending'
const list = useQuery({
queryKey: ['budget-list', { phaseFilter, search, yearFilter }],
queryFn: async () => {
const params: Record<string, unknown> = { pageSize: 100, search: search || undefined }
if (yearFilter) params.namNganSach = Number(yearFilter)
// Phase=Pending là alias FE → BE không filter (FE tự lọc 2 phase chờ).
if (phaseFilter && phaseFilter !== 'Pending') params.phase = phaseFilter
const res = await api.get<Paged<BudgetListItem>>('/budgets', { params })
return res.data
},
})
const detail = useQuery({
queryKey: ['budget-detail', selectedId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa ngân sách.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['budget-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(`/budgets/${id}`)
}
}
const allRows = list.data?.items ?? []
const rows = isPendingMode
? allRows.filter(b => b.phase === BudgetPhase.ChoCCM || b.phase === BudgetPhase.ChoCEO)
: allRows
const headerTitle = isPendingMode ? 'Ngân sách — Chờ duyệt' : 'Ngân sách dự án'
const phaseValues = Object.values(BudgetPhase)
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">
<Wallet 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">
{rows.length}
</span>
</div>
<Button onClick={() => navigate('/budgets/new')} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo ngân sách
</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 / dự án…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={isPendingMode ? '' : phaseFilter}
onChange={e => setParam('phase', e.target.value)}
disabled={isPendingMode}
>
<option value="">Tất cả phase</option>
{phaseValues.map(p => (
<option key={p} value={p}>{BudgetPhaseLabel[p]}</option>
))}
</Select>
<Input
type="number"
placeholder="Năm"
value={yearFilter}
onChange={e => setParam('namNganSach', e.target.value)}
/>
</div>
</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={Wallet}
title={isPendingMode ? 'Không có ngân sách chờ duyệt' : 'Chưa có ngân sách'}
description={isPendingMode ? 'Tất cả đã duyệt xong.' : 'Tạo ngân sách mới để bắt đầu.'}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(b => (
<li key={b.id}>
<button
onClick={() => selectRow(b.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === b.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">{b.tenNganSach}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{b.maNganSach ?? '—'}</span>
<span>·</span>
<span className="truncate">{b.projectName}</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[b.phase],
)}
>
{BudgetPhaseLabel[b.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">
Năm {b.namNganSach}
</span>
<span className="font-medium tabular-nums text-slate-700">
{fmtMoney(b.tongNganSach)} đ
</span>
</div>
<div className="mt-1 text-right">
<SlaTimer deadline={b.slaDeadline} createdAt={b.createdAt} />
</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={Wallet}
title="Chọn ngân sách ở danh sách"
description="Chi tiết hạng mục + duyệt sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<BudgetDetailTabs
budget={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={isPendingMode}
/>
)}
</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 ngân sách.
</div>
)}
{selectedId && detail.data && <BudgetWorkflowPanel budget={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/budgets/:id)
export function BudgetDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['budget-detail', id],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/budgets')
},
})
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 ngân sách.</div>
return (
<div className="space-y-4 p-6">
<BudgetDetailTabs
budget={detail.data}
onBack={() => navigate('/budgets')}
onDelete={() => del.mutate()}
/>
<BudgetWorkflowPanel budget={detail.data} />
</div>
)
}

View File

@ -35,7 +35,6 @@ import {
type ContractDetail, type ContractDetail,
type ContractListItem, type ContractListItem,
} from '@/types/contracts' } from '@/types/contracts'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -304,9 +303,8 @@ function ContractHeaderForm({
const [tenHopDong, setTenHopDong] = useState('') const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('') // [S61 Mig 50] Module Budget cũ XÓA — HĐ GIỮ ngân sách nhập tay (Tên + Số tiền),
// Mig 17 — manual budget fallback (toggle "Nhập tay") // chỉ bỏ chế độ link Budget entity.
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('') const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0) const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror // [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
@ -317,8 +315,6 @@ function ContractHeaderForm({
// Reset type về default khi typeFilter (parent prop) thay đổi // Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType]) useEffect(() => { setType(defaultType) }, [defaultType])
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
useEffect(() => { setBudgetId('') }, [projectId])
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['suppliers-all'], queryKey: ['suppliers-all'],
@ -345,21 +341,12 @@ function ContractHeaderForm({
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable) return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
}, },
}) })
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: !!projectId,
})
const qc = useQueryClient() const qc = useQueryClient()
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual. // [S61 Mig 50] HĐ chỉ còn ngân sách nhập tay (link Budget entity đã bỏ).
const budgetPayload = budgetManual const budgetPayload = {
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null } budgetManualName: budgetManualName || null,
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null } budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -440,69 +427,35 @@ function ContractHeaderForm({
)} )}
</div> </div>
<div className="mt-4 space-y-1.5"> <div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between"> {/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY (Tên + Số tiền) — chế độ link
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> Budget entity đã bỏ (module Budget cũ xóa hẳn). */}
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */} <Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<input <div>
type="checkbox" <Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
checked={budgetManual} <Input
onChange={e => setBudgetManual(e.target.checked)} value={budgetManualName}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/> />
Nhập tay (không link)
</label>
</div>
{!budgetManual ? (
<>
<Select
value={budgetId}
disabled={!projectId}
onChange={e => setBudgetId(e.target.value)}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div> </div>
)} <div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
</div> </div>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}> <Button type="submit" disabled={create.isPending}>
@ -592,10 +545,7 @@ function ContractEditForm({
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0)) const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '') const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '') const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '') // [S61 Mig 50] HĐ giữ ngân sách nhập tay — link Budget entity đã bỏ.
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '') const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0) const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
@ -603,20 +553,12 @@ function ContractEditForm({
queryKey: ['templates-by-type', contract.type], queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data, queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
}) })
// Eligible Budgets: cùng Project + Phase=DaDuyet
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', contract.projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: isDraft,
})
const qc = useQueryClient() const qc = useQueryClient()
const budgetPayload = budgetManual const budgetPayload = {
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null } budgetManualName: budgetManualName || null,
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null } budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const update = useMutation({ const update = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -716,74 +658,36 @@ function ContractEditForm({
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<div className="flex items-center justify-between"> {/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY — link Budget entity đã bỏ
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> (module Budget cũ xóa hẳn). */}
{isDraft && ( <Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
)}
</div>
{isDraft ? ( {isDraft ? (
!budgetManual ? ( <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<> <div>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}> <Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<option value=""> (không link)</option> <Input
{eligibleBudgets.data?.map(b => ( value={budgetManualName}
<option key={b.id} value={b.id}> onChange={e => setBudgetManualName(e.target.value)}
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ placeholder="vd Tạm tính dự toán T11/2025"
</option> maxLength={200}
))} />
</Select>
<p className="text-[11px] text-slate-500">
{eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div> </div>
) <div>
) : contract.budget ? ( <Label className="text-[11px]">Số tiền (đ)</Label>
<a <Input
href={`/budgets?id=${contract.budget.id}`} type="number"
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700" min={0}
> value={budgetManualAmount || ''}
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span> onChange={e => setBudgetManualAmount(Number(e.target.value))}
{' · '}{contract.budget.tenNganSach} placeholder="1000000000"
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> />
</a> {budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
) : contract.budgetManualAmount != null || contract.budgetManualName ? ( ) : contract.budgetManualAmount != null || contract.budgetManualName ? (
// Mig 17 — read-only display khi !isDraft + có manual data // Mig 17 — read-only display khi !isDraft + có manual data
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"> <div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">

View File

@ -1,168 +0,0 @@
// Types cho module Ngân sách (Budget) — mirror BE Domain.Budgets.
// Workflow simple 3-step (BudgetPolicy.Default hardcoded):
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet (+ TuChoi từ DangSoanThao).
export const BudgetPhase = {
DangSoanThao: 1,
ChoCCM: 2,
ChoCEO: 3,
DaDuyet: 4,
TraLai: 98,
TuChoi: 99,
} as const
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
export const BudgetPhaseLabel: Record<number, string> = {
1: 'Nháp',
2: 'Chờ CCM',
3: 'Chờ CEO',
4: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
export const BudgetPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-indigo-100 text-indigo-700',
3: 'bg-pink-100 text-pink-700',
4: 'bg-emerald-100 text-emerald-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}
// Mirror BE BudgetEntityType enum
export const BudgetEntityType = {
Header: 1,
Detail: 2,
Workflow: 3,
} as const
// Mirror BE ChangelogAction enum (reuse từ Contracts.ChangelogAction)
export const ChangelogAction = {
Insert: 1,
Update: 2,
Delete: 3,
Transition: 4,
} as const
// Mirror BE ApprovalDecision enum (reuse từ Contracts.ApprovalDecision)
export const ApprovalDecision = {
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
export const ApprovalStage = {
Review: 1,
Confirm: 2,
} as const
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
export type BudgetDepartmentApproval = {
id: string
phaseAtApproval: number
departmentId: string
departmentName: string | null
stage: number // 1=Review, 2=Confirm
approverUserId: string
approverName: string | null
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
comment: string | null
approvedAt: string
isBypassed: boolean
}
export type BudgetListItem = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
projectId: string
projectName: string
tongNganSach: number
slaDeadline: string | null
createdAt: string
}
export type BudgetDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
order: number
ghiChu: string | null
}
export type BudgetApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type BudgetWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type BudgetChangelog = {
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 BudgetDetailBundle = {
id: string
maNganSach: string | null
tenNganSach: string
description: string | null
namNganSach: number
phase: number
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
tongNganSach: number
slaDeadline: string | null
createdAt: string
updatedAt: string | null
details: BudgetDetailRow[]
approvals: BudgetApproval[]
workflow: BudgetWorkflowSummary
}
// Body shape POST/PUT detail — mirror BudgetDetailBody record BE.
export type BudgetDetailBody = {
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
ghiChu: string | null
}

View File

@ -130,16 +130,6 @@ export type WorkflowSummary = {
nextPhases: number[] nextPhases: number[]
} }
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
export type ContractBudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type ContractDetail = { export type ContractDetail = {
id: string id: string
maHopDong: string | null maHopDong: string | null
@ -162,9 +152,8 @@ export type ContractDetail = {
draftData: string | null draftData: string | null
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
budgetId: string | null // [S61 Mig 50] budgetId/budget link DROP (module Budget cũ xóa) — HĐ GIỮ
budget: ContractBudgetSummary | null // ngân sách nhập tay.
// Mig 17 — manual budget fallback khi không link Budget entity.
budgetManualName: string | null budgetManualName: string | null
budgetManualAmount: number | null budgetManualAmount: number | null
approvals: ContractApproval[] approvals: ContractApproval[]

View File

@ -131,6 +131,9 @@ export type PeListItem = {
drafterName: string | null drafterName: string | null
departmentId: string | null departmentId: string | null
departmentName: string | null departmentName: string | null
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
budgetPeriodAmount: number | null
expectedRemainingAmount: number | null
} }
export type PeSupplier = { export type PeSupplier = {
@ -275,14 +278,28 @@ export type PeChangelog = {
createdAt: string createdAt: string
} }
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE). // S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
export type BudgetSummary = { // BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
id: string // cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
maNganSach: string | null // canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
tenNganSach: string // capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
namNganSach: number // gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
phase: number export type PeBudgetSummary = {
tongNganSach: number budgetId: string | null
proEstimateAmount: number | null
proNote: string | null
initialAmount: number | null
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
fullAmount: number
fullIsEstimate: boolean
canEditPro: boolean
canEditCcm: boolean
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number
previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
previousSelectedCount: number
currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
} }
// Mirror BE PeDepartmentKind enum // Mirror BE PeDepartmentKind enum
@ -408,11 +425,10 @@ export type PeDetailBundle = {
slaDeadline: string | null slaDeadline: string | null
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
budgetId: string | null // S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budget: BudgetSummary | null budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK. expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetManualName: string | null budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
budgetManualAmount: number | null
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create). // Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null approvalWorkflowId: string | null
approvalWorkflowCode: string | null approvalWorkflowCode: string | null

View File

@ -17,8 +17,6 @@ import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pag
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage' import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage' import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage' import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
@ -62,9 +60,6 @@ function App() {
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} /> <Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} /> <Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} /> <Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} /> <Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} /> <Route path="/employees/new" element={<EmployeeCreatePage />} />

View File

@ -57,10 +57,6 @@ function resolvePath(key: string): string | null {
Dashboard: '/dashboard', Dashboard: '/dashboard',
Contracts: '/my-contracts', Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations', PurchaseEvaluations: '/purchase-evaluations',
Budgets: '/budgets',
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ // [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không // fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent. // MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.

View File

@ -1,491 +0,0 @@
// Detail content cho 1 ngân sách. Flat render (no tabs):
// Section 1 = Thông tin Header
// Section 2 = Hạng mục (table CRUD inline)
// Approvals + Changelog → moved sang Panel 3 (BudgetWorkflowPanel).
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { 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 { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetChangelog,
type BudgetDetailBundle,
type BudgetDetailBody,
type BudgetDetailRow,
} from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetDetailTabs({
budget,
onBack,
onDelete,
readOnly = false,
}: {
budget: BudgetDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
}) {
const navigate = useNavigate()
const isDraft = budget.phase === BudgetPhase.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">{budget.tenNganSach}</h2>
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', BudgetPhaseColor[budget.phase])}>
{BudgetPhaseLabel[budget.phase]}
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{budget.maNganSach ?? '—'}</span>
<span>·</span>
<span>Năm {budget.namNganSach}</span>
<span>·</span>
<span>{budget.projectName}</span>
{budget.drafterName && (<><span>·</span><span>Soạn: {budget.drafterName}</span></>)}
</div>
</div>
<div className="flex gap-2">
{isDraft && !readOnly && (
<>
<Button
variant="ghost"
onClick={() => navigate(`/budgets/new?id=${budget.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>
<div className="divide-y divide-slate-200">
<Section title="Thông tin">
<InfoTab budget={budget} />
</Section>
<Section title={`Hạng mục (${budget.details.length}) — Tổng: ${fmtMoney(budget.tongNganSach)} đ`}>
<ItemsTab budget={budget} readOnly={readOnly} />
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function BudgetApprovalsSection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">
Lịch sử duyệt ({budget.approvals.length})
</h3>
<ApprovalsList budget={budget} />
</div>
)
}
export function BudgetHistorySection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đi</h3>
<HistoryList budget={budget} />
</div>
)
}
// ===== Section: Thông tin Header =====
function InfoTab({ budget }: { budget: BudgetDetailBundle }) {
return (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên ngân sách" value={budget.tenNganSach} />
<Field label="Mã ngân sách" value={<span className="font-mono">{budget.maNganSach ?? '—'}</span>} />
<Field label="Năm ngân sách" value={budget.namNganSach} />
<Field label="Dự án" value={budget.projectName} />
<Field label="Phòng ban" value={budget.departmentName ?? '—'} />
<Field label="Người soạn" value={budget.drafterName ?? '—'} />
<Field label="Tổng ngân sách" value={<span className="font-semibold text-brand-700">{fmtMoney(budget.tongNganSach)} đ</span>} />
<Field label="Trạng thái" value={<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[budget.phase])}>{BudgetPhaseLabel[budget.phase]}</span>} />
{budget.description && (
<div className="col-span-2">
<dt className="text-[11px] uppercase tracking-wide text-slate-400"> tả</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-slate-800">{budget.description}</dd>
</div>
)}
</dl>
)
}
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>
)
}
// ===== Section: Hạng mục table CRUD =====
function ItemsTab({ budget, readOnly = false }: { budget: BudgetDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<BudgetDetailRow | null>(null)
const isDraft = budget.phase === BudgetPhase.DangSoanThao
const canMutate = !readOnly && isDraft
const remove = useMutation({
mutationFn: async (rowId: string) => api.delete(`/budgets/${budget.id}/details/${rowId}`),
onSuccess: () => {
toast.success('Đã xóa hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div>
{canMutate && (
<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 hạng mục
</Button>
</div>
)}
{budget.details.length === 0 ? (
<p className="text-sm text-slate-500">
{canMutate ? 'Chưa có hạng mục nào. Thêm để bắt đầu lập ngân sách.' : 'Chưa có hạng mục.'}
</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-2 py-2 text-left">#</th>
<th className="px-2 py-2 text-left">Nhóm</th>
<th className="px-2 py-2 text-left"> / Nội dung</th>
<th className="px-2 py-2 text-left">ĐVT</th>
<th className="px-2 py-2 text-right">KL</th>
<th className="px-2 py-2 text-right">Đơn giá</th>
<th className="px-2 py-2 text-right">Thành tiền</th>
{canMutate && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{budget.details.map((d, idx) => (
<tr key={d.id} className="align-top">
<td className="px-2 py-2 text-slate-400">{idx + 1}</td>
<td className="px-2 py-2">
<div className="font-mono text-[11px] text-slate-500">{d.groupCode}</div>
<div className="text-[12px] text-slate-700">{d.groupName}</div>
</td>
<td className="px-2 py-2 max-w-md">
{d.itemCode && <div className="font-mono text-[11px] text-slate-500">{d.itemCode}</div>}
<div className="text-slate-800">{d.noiDung}</div>
{d.ghiChu && <div className="mt-0.5 text-[11px] italic text-slate-500">{d.ghiChu}</div>}
</td>
<td className="px-2 py-2 text-slate-600">{d.donViTinh ?? '—'}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.khoiLuong)}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.donGia)}</td>
<td className="px-2 py-2 text-right font-medium tabular-nums text-slate-900">
{fmtMoney(d.thanhTien)}
</td>
{canMutate && (
<td className="px-2 py-2 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditRow(d)}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm('Xóa hạng mục này?')) remove.mutate(d.id)
}}
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold">
<td colSpan={6} className="px-2 py-2 text-right text-slate-600">Tổng:</td>
<td className="px-2 py-2 text-right tabular-nums text-brand-700">
{fmtMoney(budget.tongNganSach)}
</td>
{canMutate && <td></td>}
</tr>
</tfoot>
</table>
</div>
)}
{open && (
<DetailRowDialog
budgetId={budget.id}
onClose={() => setOpen(false)}
/>
)}
{editRow && (
<DetailRowDialog
budgetId={budget.id}
existing={editRow}
onClose={() => setEditRow(null)}
/>
)}
</div>
)
}
// ===== Dialog: Thêm / Sửa hạng mục =====
function DetailRowDialog({
budgetId,
existing,
onClose,
}: {
budgetId: string
existing?: BudgetDetailRow
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState<BudgetDetailBody>({
groupCode: existing?.groupCode ?? '',
groupName: existing?.groupName ?? '',
itemCode: existing?.itemCode ?? null,
noiDung: existing?.noiDung ?? '',
donViTinh: existing?.donViTinh ?? null,
khoiLuong: existing?.khoiLuong ?? 0,
donGia: existing?.donGia ?? 0,
thanhTien: existing?.thanhTien ?? 0,
ghiChu: existing?.ghiChu ?? null,
})
// Auto-compute thành tiền khi đổi KL/đơn giá (UX nicety)
function setQty(v: number) {
const next = { ...form, khoiLuong: v, thanhTien: v * form.donGia }
setForm(next)
}
function setPrice(v: number) {
const next = { ...form, donGia: v, thanhTien: form.khoiLuong * v }
setForm(next)
}
const save = useMutation({
mutationFn: async () => {
const payload = {
groupCode: form.groupCode,
groupName: form.groupName,
itemCode: form.itemCode || null,
noiDung: form.noiDung,
donViTinh: form.donViTinh || null,
khoiLuong: form.khoiLuong,
donGia: form.donGia,
thanhTien: form.thanhTien,
ghiChu: form.ghiChu || null,
}
if (existing) {
return api.put(`/budgets/${budgetId}/details/${existing.id}`, payload)
}
return api.post(`/budgets/${budgetId}/details`, payload)
},
onSuccess: () => {
toast.success(existing ? 'Đã cập nhật hạng mục.' : 'Đã thêm hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budgetId] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={existing ? 'Sửa hạng mục' : 'Thêm hạng mục'}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button
onClick={() => save.mutate()}
disabled={!form.groupCode || !form.groupName || !form.noiDung || save.isPending}
>
{existing ? 'Lưu' : 'Thêm'}
</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label> nhóm *</Label>
<Input
value={form.groupCode}
onChange={e => setForm({ ...form, groupCode: e.target.value })}
placeholder="A.I"
/>
</div>
<div>
<Label>Tên nhóm *</Label>
<Input
value={form.groupName}
onChange={e => setForm({ ...form, groupName: e.target.value })}
placeholder="Vật tư xây dựng"
/>
</div>
</div>
<div>
<Label> hạng mục</Label>
<Input
value={form.itemCode ?? ''}
onChange={e => setForm({ ...form, itemCode: e.target.value || null })}
/>
</div>
<div>
<Label>Nội dung *</Label>
<Input
value={form.noiDung}
onChange={e => setForm({ ...form, noiDung: e.target.value })}
placeholder="Bê tông M250"
/>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>ĐVT</Label>
<Input
value={form.donViTinh ?? ''}
onChange={e => setForm({ ...form, donViTinh: e.target.value || null })}
placeholder="m³"
/>
</div>
<div>
<Label>Khối lượng</Label>
<Input
type="number"
step="0.0001"
value={form.khoiLuong}
onChange={e => setQty(Number(e.target.value))}
/>
</div>
<div>
<Label>Đơn giá</Label>
<Input
type="number"
step="0.01"
value={form.donGia}
onChange={e => setPrice(Number(e.target.value))}
/>
</div>
<div>
<Label>Thành tiền</Label>
<Input
type="number"
step="0.01"
value={form.thanhTien}
onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })}
/>
</div>
</div>
<div>
<Label>Ghi chú</Label>
<Input
value={form.ghiChu ?? ''}
onChange={e => setForm({ ...form, ghiChu: e.target.value || null })}
/>
</div>
</div>
</Dialog>
)
}
// ===== Sub: Approvals list =====
function ApprovalsList({ budget }: { budget: BudgetDetailBundle }) {
if (budget.approvals.length === 0)
return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{budget.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]', BudgetPhaseColor[a.fromPhase])}>
{BudgetPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.toPhase])}>
{BudgetPhaseLabel[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>
)
}
// ===== Sub: Changelog list =====
function HistoryList({ budget }: { budget: BudgetDetailBundle }) {
const logs = useQuery({
queryKey: ['budget-changelog', budget.id],
queryFn: async () => (await api.get<BudgetChangelog[]>(`/budgets/${budget.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0)
return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 py-1 pl-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}

View File

@ -1,225 +0,0 @@
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
// Pulls nextPhases từ BE bundle (single source of truth).
import { useState } from 'react'
import { useMutation, useQuery, 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 {
ApprovalDecision,
ApprovalStage,
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDepartmentApproval,
type BudgetDetailBundle,
} from '@/types/budget'
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
queryKey: ['budget-dept-approvals', budget.id],
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
})
const transition = useMutation({
mutationFn: async () =>
api.post(`/budgets/${budget.id}/transitions`, {
targetPhase: target,
decision: target === BudgetPhase.TuChoi ? ApprovalDecision.Reject : ApprovalDecision.Approve,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = budget.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">{budget.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{budget.workflow.activePhases
.filter(p => p !== BudgetPhase.TuChoi)
.map(p => {
const isCurrent = budget.phase === p
const isPast = isPastPhase(budget.phase, p, budget.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]', BudgetPhaseColor[p])}>{p}</span>
<span className="truncate">{BudgetPhaseLabel[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 === BudgetPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: p === BudgetPhase.DangSoanThao
? 'border-amber-300 text-amber-700 hover:bg-amber-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{BudgetPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${BudgetPhaseLabel[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>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
</div>
)}
<div className="border-t border-slate-200 pt-4">
<BudgetApprovalsSection budget={budget} />
</div>
<div className="border-t border-slate-200 pt-4">
<BudgetHistorySection budget={budget} />
</div>
</div>
)
}
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
function BudgetDeptApprovalsSection({
rows,
currentPhase,
}: {
rows: BudgetDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
for (const r of rows) {
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
const byDept = grouped.get(r.phaseAtApproval)!
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
byDept.get(r.departmentId)!.push(r)
}
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<div>
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
<p className="mt-0.5 text-[11px] text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="mt-2 space-y-3">
{phaseOrder.map(phase => {
const byDept = grouped.get(phase)!
return (
<div key={phase}>
<div className={cn(
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
</div>
<div className="mt-1 space-y-1.5">
{[...byDept.entries()].map(([deptId, stages]) => {
const review = stages.find(s => s.stage === ApprovalStage.Review)
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
const isPending = phase === currentPhase && review && !confirm
return (
<div key={deptId} className={cn(
'rounded border px-2 py-1.5 text-[11px]',
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
)}>
<div className="font-medium text-slate-700">{deptName}</div>
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
<span className="text-slate-500">Review:</span>
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
{review
? <> {review.approverName} <span className="text-slate-500"> {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
: '— chưa có'}
</span>
<span className="text-slate-500">Confirm:</span>
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
{confirm
? <> {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500"> {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
: '⏳ chờ TPB confirm'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
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 !== BudgetPhase.TuChoi
}

View File

@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react' import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -40,9 +40,8 @@ import {
type PeQuote, type PeQuote,
type PeSupplier, type PeSupplier,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import { SupplierType, SupplierTypeLabel } from '@/types/master' import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Paged, Supplier } from '@/types/master' import type { Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -174,9 +173,10 @@ export function PeDetailTabs({
const gia = computeGiaChaoThau(evaluation) const gia = computeGiaChaoThau(evaluation)
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
} }
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount) // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) { // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
missing.push("Chưa nhập Ngân sách") if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
missing.push("Chưa nhập Ngân sách kỳ này")
} }
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
@ -286,12 +286,9 @@ export function PeDetailTabs({
? <LevelOpinionsSectionV2 ev={evaluation} /> ? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />} : <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section> </Section>
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter {/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */} thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
<Section title="5. Điều chỉnh ngân sách">
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
</Section>
</div> </div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút: {/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
@ -680,9 +677,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
diaDiem: diaDiem || null, diaDiem: diaDiem || null,
moTa: moTa || null, moTa: moTa || null,
paymentTerms: paymentTerms || null, paymentTerms: paymentTerms || null,
budgetId: ev.budgetId, // S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
budgetManualName: ev.budgetManualName, // giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
budgetManualAmount: ev.budgetManualAmount, // TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -854,325 +853,434 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
) )
} }
// ===== b. Ngân sách inline editor (Mig 17) ===== // ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần // Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách / // BE trả `ev.budgetSummary`. 2 block:
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao / // A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt / // dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// từ chối. Empty values hiển thị empty (per user 2026-05-07). // B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { // (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
const canEdit = !readOnly && isEditablePhase(ev.phase) // budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
const qc = useQueryClient()
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link. // fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với const fmtVndSigned = (v: number) =>
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName). v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId const fmtPct = (num: number, denom: number): string | null =>
const [manualMode, setManualMode] = useState(initialManual) denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// Eligible budgets — chỉ fetch khi user có khả năng edit // Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
const eligibleBudgets = useQuery({ // cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
queryKey: ['eligible-budgets', ev.projectId], function VndInlineEdit({
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', { initial, allowNegative = false, onSave, saving, label,
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet }, }: {
})).data.items, initial: number | null
enabled: canEdit, allowNegative?: boolean
}) onSave: (v: number | null) => void
saving: boolean
// Dirty detect — compare current state vs ev original label?: string
const dirty = manualMode !== initialManual }) {
|| (manualMode && manualAmount !== (ev.budgetManualAmount ?? 0)) const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|| (!manualMode && budgetId !== (ev.budgetId ?? '')) const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const save = useMutation({ const n = parseVnd(text)
mutationFn: async () => { if (n === 0 && text.trim() === '') return null
const payload = manualMode return allowNegative && neg ? -n : n
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
<FormRow
label="b. Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"></span>}
/>
)
} }
const dirty = parse() !== initial
// Editable mode (canEdit=true)
return ( return (
<div className="flex gap-3"> <div className="flex items-center justify-end gap-1.5">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span> {allowNegative && (
<div className="min-w-0 flex-1 space-y-2"> <button
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> type="button"
<input onClick={() => setNeg(v => !v)}
type="checkbox" className={cn(
checked={manualMode} 'h-6 w-6 shrink-0 rounded border text-xs font-bold',
onChange={e => setManualMode(e.target.checked)} neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
className="h-3.5 w-3.5 rounded border-slate-300" )}
/> title="Đảo dấu âm/dương"
Nhập tay (không link) >
</label> {neg ? '' : '+'}
{!manualMode ? ( </button>
<Select )}
value={budgetId} <div className="relative w-40">
onChange={e => setBudgetId(e.target.value)} <Input
className="text-sm" type="text"
> inputMode="numeric"
<option value=""></option> value={text}
{eligibleBudgets.data?.map(b => ( onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
<option key={b.id} value={b.id}> placeholder="0"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ aria-label={label}
</option> className="h-7 pr-6 font-mono text-right text-[13px]"
))} />
</Select> <span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
) : ( </div>
<div className="relative max-w-xs"> <Button
<Input onClick={() => onSave(parse())}
type="text" disabled={!dirty || saving}
inputMode="numeric" className="h-7 px-2 text-[11px]"
value={formatVndInput(manualAmount)} >
onChange={e => setManualAmount(parseVnd(e.target.value))} {saving ? '…' : 'Lưu'}
placeholder="0" </Button>
className="pr-10 font-mono text-right text-sm" </div>
/> )
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span> }
</div>
)} // 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
{dirty && ( // tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
<div className="flex items-center gap-2"> function BudgetRow({
<Button label, sub, value, third, tone, danger, mono = true,
onClick={() => save.mutate()} }: {
disabled={save.isPending} label: React.ReactNode
className="h-7 px-3 text-xs" sub?: React.ReactNode
> value: React.ReactNode
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'} third?: React.ReactNode
</Button> tone?: 'brand' | 'brand-soft' | 'blue-soft'
<button danger?: boolean
onClick={() => { mono?: boolean
setManualMode(initialManual) }) {
setBudgetId(ev.budgetId ?? '') const toneCls =
setManualAmount(ev.budgetManualAmount ?? 0) tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
}} : tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
className="text-[11px] text-slate-500 hover:text-slate-700" : tone === 'blue-soft' ? 'bg-blue-50'
> : ''
Hủy thay đi return (
</button> <div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
</div> <div className="min-w-0 flex-1">
)} <div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div> </div>
</div> </div>
) )
} }
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) ===== // Block tiêu đề (A / B)
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet) function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint return (
// PATCH /budget-adjust riêng. Audit changelog tự động. <div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { {children}
const { user: currentUser } = useAuth() </div>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()
const [editing, setEditing] = useState(false) const bs = ev.budgetSummary
const isAdmin = currentUser?.roles?.includes('Admin') ?? false // Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id // khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao const drafterEditable = !readOnly && isEditablePhase(ev.phase)
|| ev.phase === PurchaseEvaluationPhase.TraLai
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly const invalidate = () => {
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match + qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true) qc.invalidateQueries({ queryKey: ['pe-list'] })
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace). }
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId // PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
const [manualMode, setManualMode] = useState(initialManual) const proMut = useMutation({
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '') mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0) api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)" onSuccess: () => { toast.success('Đã lưu dự trù PRO'); invalidate() },
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước) onError: e => toast.error(getErrorMessage(e)),
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets-adjust', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: editing && canAdjust,
}) })
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({ const adjustMut = useMutation({
mutationFn: async () => { mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
const payload = manualMode api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null } onSuccess: () => { toast.success('Đã lưu'); invalidate() },
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
},
onSuccess: () => {
toast.success('Đã điều chỉnh ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History. const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// Display read mode // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
const displayLink = ev.budget ? ( if (!bs) {
<span> return (
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span> <div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
{' · '}{ev.budget.tenNganSach} Phiếu chưa gắn Hạng mục công việc gắn Hạng mục đ dùng ngân sách gói thầu.
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> </div>
</span> )
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? ( }
<span>
{ev.budgetManualName && <span>{ev.budgetManualName}</span>} // ===== Số liệu Excel =====
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '} const full = bs.fullAmount
{ev.budgetManualAmount != null && ( const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span> const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
)} const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span> const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
</span> const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
) : <span className="italic text-slate-400">Chưa ngân sách</span> const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
const cmp56 = row5 - row6 // So với NS (row5 row6)
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
return ( return (
<div className="space-y-3"> <div className="overflow-hidden rounded-lg border border-slate-300">
{/* Read mode + Edit toggle */} <div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
{!editing && ( Tổng hợp ngân sách trình
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2"> </div>
<div className="flex items-start gap-2">
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<div className="text-sm text-slate-700">{displayLink}</div>
</div>
{canAdjust && (
<Button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
setEditing(true)
}}
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
>
<Pencil className="h-3 w-3" /> Điều chỉnh
</Button>
)}
</div>
)}
{/* Edit mode */} {/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
{editing && canAdjust && ( <BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
{isApproverChoDuyet && ( {/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800"> <BudgetRow
Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt thay đi sẽ đưc ghi vào lịch sử. tone="brand"
</div> label={
)} <span className="inline-flex items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> Ngân sách (full gói thầu)
<input {bs.fullIsEstimate && (
type="checkbox" <span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">dự trù PRO</span>
checked={manualMode} )}
onChange={e => setManualMode(e.target.checked)} </span>
className="h-3.5 w-3.5 rounded border-slate-300" }
value={fmtVnd(full)}
/>
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
<BudgetRow
label="Ngân sách Ban hành lần đầu"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.initialAmount}
saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
/> />
Nhập tay (không link Budget) ) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
</label> }
{!manualMode ? ( />
<div>
<Label className="text-[11px]">Chọn Budget từ danh sách</Label> {/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm"> <BudgetRow
<option value=""> (huỷ link)</option> label="Ngân sách V0 / hiệu chỉnh tăng giảm"
{eligibleBudgets.data?.map(b => ( value={
<option key={b.id} value={b.id}> bs.canEditCcm ? (
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ <VndInlineEdit
</option> initial={bs.adjustmentAmount}
))} allowNegative
</Select> saving={ccmMut.isPending}
</div> label="Ngân sách hiệu chỉnh tăng giảm"
) : ( onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
<div className="max-w-xs"> />
<Label className="text-[11px]">Số tiền (VND)</Label> ) : bs.adjustmentAmount != null ? (
<div className="relative"> <span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
<Input ) : <span className="text-slate-400"></span>
type="text" }
inputMode="numeric" />
value={formatVndInput(manualAmount)}
onChange={e => setManualAmount(parseVnd(e.target.value))} {/* Dòng 4 — Dự trù PRO (PRO editable) */}
placeholder="0" <BudgetRow
className="pr-10 font-mono text-right text-sm" label="Dự trù PRO"
/> value={
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span> bs.canEditPro ? (
<VndInlineEdit
initial={bs.proEstimateAmount}
saving={proMut.isPending}
label="Dự trù PRO"
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
/>
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
<div className="w-72 shrink-0">
{bs.canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú dự trù…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
className="h-6 px-2 text-[11px]"
>
{proMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div> </div>
</div> </div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.proNote || <span className="text-slate-400"></span>}
</div>
)} )}
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => setEditing(false)}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => adjustMut.mutate()}
disabled={adjustMut.isPending}
className="h-7 px-3 text-xs"
>
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
</Button>
</div>
</div> </div>
)} </div>
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */} {/* ===== Block B — THỰC HIỆN ===== */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */}
<BudgetRow
label="1. Ngân sách trình duyệt trước"
value={bs.previousSubmittedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row1)}
/>
{/* 2 — Kỳ trước đã chọn thầu */}
<BudgetRow
label="2. Kỳ trước đã chọn thầu"
value={bs.previousSelectedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row2)}
/>
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
<BudgetRow
label="3. Ngân sách - kỳ này"
value={
drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
}
third={fmtPct(row3, full) ?? undefined}
/>
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
<BudgetRow
tone="blue-soft"
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={
<span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>}
</span>
}
/>
<BudgetRow
tone="blue-soft"
label="Giá trị kỳ này"
value={
proposalOver ? (
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
) : fmtVnd(row4)
}
/>
<BudgetRow
tone="blue-soft"
label="So sánh với ngân sách kỳ này"
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
<BudgetRow
label="5. Lũy kế ngân sách đã sử dụng"
sub="= 1 + 3"
value={fmtVnd(row5)}
/>
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */}
<BudgetRow
label="6. Lũy kế thực hiện"
sub="= 2 + 4"
value={fmtVnd(row6)}
/>
<BudgetRow
label="So với NS"
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
{/* 7 — Ngân sách còn lại (= full 5) + % /full */}
<BudgetRow
label="7. Ngân sách còn lại"
sub="= Ngân sách full 5"
value={fmtVnd(row7)}
third={fmtPct(row7, full) ?? undefined}
/>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
<div className={cn(
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
remainingOver && 'bg-red-50',
)}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div>
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVnd(row8)}
</span>
)}
</div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={fmtVnd(row9)}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div> </div>
) )
} }
@ -1197,7 +1305,9 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} /> <NccSelectorRow ev={ev} readOnly={readOnly} />
<BudgetFieldRow ev={ev} readOnly={readOnly} /> {/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
<FormRow <FormRow
label="c. Giá chào thầu" label="c. Giá chào thầu"
value={ value={
@ -1620,21 +1730,9 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null) const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row. // S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
const budgetBundle = useQuery({ // không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
queryKey: ['budget-detail-for-pe', ev.budgetId], // TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
return ( return (
<div> <div>
@ -1658,8 +1756,6 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
detail={d} detail={d}
ev={ev} ev={ev}
readOnly={readOnly} readOnly={readOnly}
budgetRowMap={budgetRowMap}
showBudgetCol={showBudgetCol}
onEditDetail={() => setEditDetail(d)} onEditDetail={() => setEditDetail(d)}
/> />
))} ))}
@ -1675,13 +1771,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand. // Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click. // Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({ function HangMucCard({
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail, detail, ev, readOnly, onEditDetail,
}: { }: {
detail: PeDetailRow detail: PeDetailRow
ev: PeDetailBundle ev: PeDetailBundle
readOnly: boolean readOnly: boolean
budgetRowMap: Map<string, number>
showBudgetCol: boolean
onEditDetail: () => void onEditDetail: () => void
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
@ -1707,9 +1801,6 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
return ( return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm"> <div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap + {/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
@ -1741,20 +1832,9 @@ function HangMucCard({
<span className="ml-1 text-xs font-normal text-slate-500">đ</span> <span className="ml-1 text-xs font-normal text-slate-500">đ</span>
</div> </div>
</div> </div>
{showBudgetCol && bgValue != null && ( {/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
<div className="border-l border-slate-200 pl-3"> Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
<div className="text-[10px] uppercase text-slate-400">NS link</div> trình ký" cấp phiếu (PeBudgetSummaryTable). */}
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
</div> </div>
{!readOnly && ( {!readOnly && (
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">

View File

@ -18,8 +18,7 @@ import {
PurchaseEvaluationTypeLabel, PurchaseEvaluationTypeLabel,
type PeDetailBundle, type PeDetailBundle,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget' import type { Project } from '@/types/master'
import type { Paged, Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20) // VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -81,29 +80,13 @@ export function PeHeaderForm({
diaDiem: '', diaDiem: '',
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '' as string, // [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* cũ (module
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link) // Budget xóa hẳn; bảng Tổng hợp ngân sách gói thầu nằm ở PeDetailTabs).
budgetManual: false, budgetPeriodAmount: 0,
budgetManualName: '',
budgetManualAmount: 0,
})
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
}) })
useEffect(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
// S59: manual-mode detect theo CẢ amount (phiếu mới name=null sau khi bỏ ô Tên).
const hasManual = existing.data.budgetManualAmount !== null || existing.data.budgetManualName !== null
|| existing.data.budgetManualAmount !== null
setForm({ setForm({
type: existing.data.type, type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau, tenGoiThau: existing.data.tenGoiThau,
@ -112,27 +95,16 @@ export function PeHeaderForm({
diaDiem: existing.data.diaDiem ?? '', diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '', moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '', paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '', budgetPeriodAmount: existing.data.budgetPeriodAmount ?? 0,
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
budgetManual: hasManual && !existing.data.budgetId,
budgetManualName: existing.data.budgetManualName ?? '',
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
}) })
} }
}, [existing.data]) }, [existing.data])
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual. // [S61] PUT UpdateDraft null-safe: budgetPeriodAmount null = GIỮ giá trị cũ
const payloadBudgetFields = form.budgetManual // BE-side; gửi số > 0 mới set. (Clear hẳn → dùng bảng Tổng hợp/budget-adjust.)
? { const payloadBudgetFields = {
budgetId: null, budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
budgetManualName: null, // S59 anh chốt bỏ "Tên ngân sách" — manual chỉ còn Số tiền }
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
}
: {
budgetId: form.budgetId || null,
budgetManualName: null,
budgetManualAmount: null,
}
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -239,58 +211,23 @@ export function PeHeaderForm({
</div> </div>
<div> <div>
<div className="mb-1.5 flex items-center justify-between"> {/* [S61 Mig 50] Ô đơn "Ngân sách kỳ này" — thay picker Budget cũ + toggle
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> nhập tay. Số phân bổ cho RIÊNG phiếu này (row 3 bảng Tổng hợp). */}
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */} <Label>Ngân sách kỳ này</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="relative max-w-xs">
<input <Input
type="checkbox" type="text"
checked={form.budgetManual} inputMode="numeric"
onChange={e => setForm({ ...form, budgetManual: e.target.checked })} value={formatVndInput(form.budgetPeriodAmount)}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
/> placeholder="0"
Nhập tay (không link) className="pr-10 font-mono text-right"
</label> />
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div> </div>
{!form.budgetManual ? ( <p className="mt-1 text-[11px] text-slate-500">
<> Số phân bổ cho riêng phiếu này bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng "Tổng hợp ngân sách trình ký" trong phiếu.
<Select </p>
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div>
<Label className="text-[11px]">Số tiền</Label>
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
</div>
)}
</div> </div>
<div> <div>

View File

@ -18,8 +18,7 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation' import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget' import type { Project } from '@/types/master'
import type { Paged, Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20) // VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -59,11 +58,9 @@ export function PeWorkspaceCreateView({
diaDiem: '', diaDiem: '',
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '', // [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* (module
// Mig 17 — manual budget fallback // Budget cũ xóa hẳn; bảng Tổng hợp ngân sách gói thầu ở PeDetailTabs).
budgetManual: false, budgetPeriodAmount: 0,
budgetManualName: '',
budgetManualAmount: 0,
// Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo) // Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo)
approvalWorkflowId: '', approvalWorkflowId: '',
}) })
@ -104,20 +101,9 @@ export function PeWorkspaceCreateView({
}, },
}) })
const eligibleBudgets = useQuery({ const budgetPayload = {
queryKey: ['eligible-budgets', form.projectId], budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
queryFn: async () => { }
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
})
const budgetPayload = form.budgetManual
? { budgetId: null, budgetManualName: form.budgetManualName || null, budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null }
: { budgetId: form.budgetId || null, budgetManualName: null, budgetManualAmount: null }
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -224,7 +210,7 @@ export function PeWorkspaceCreateView({
const loc = p?.location ?? '' const loc = p?.location ?? ''
setForm(f => { setForm(f => {
const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem } return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
}) })
lastAutoLoc.current = loc lastAutoLoc.current = loc
}} }}
@ -258,55 +244,26 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (sau khi thêm NCC tham gia + chốt winner)</span>} value={<span className="text-slate-400"> (sau khi thêm NCC tham gia + chốt winner)</span>}
/> />
{/* b. Ngân sách — editable inline (Mig 17 toggle pattern) */} {/* [S61 Mig 50] b. Ngân sách kỳ này — ô đơn thay picker Budget cũ +
toggle nhập tay (module Budget xóa hẳn). Số phân bổ cho RIÊNG
phiếu này (row 3 bảng "Tổng hợp ngân sách trình ký"). */}
<div className="flex gap-3"> <div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span> <span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách kỳ này</span>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-1">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="relative max-w-xs">
<input <Input
type="checkbox" type="text"
checked={form.budgetManual} inputMode="numeric"
onChange={e => setForm({ ...form, budgetManual: e.target.checked })} value={formatVndInput(form.budgetPeriodAmount)}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/> />
Nhập tay (không link) <span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</label> </div>
{!form.budgetManual ? ( <p className="text-[11px] text-slate-500">
<> Bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng &ldquo;Tổng hợp ngân sách trình &rdquo; sau khi tạo phiếu.
<Select </p>
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
)}
</div> </div>
</div> </div>

View File

@ -26,11 +26,8 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc', AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn', AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// Module Ngân sách (Phase 7) // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
Budgets: 'Budgets', // "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
Bg_List: 'Bg_List',
Bg_Create: 'Bg_Create',
Bg_Pending: 'Bg_Pending',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26) // Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm', Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo', HrmHoSo: 'Hrm_HoSo',

View File

@ -1,173 +0,0 @@
// Create / edit draft Header ngân sách. Hạng mục chỉnh ở Detail tabs sau khi save.
// CreateBudgetCommand BE chỉ cho update Tên/Mô tả/Năm khi DangSoanThao
// — Project/Department khóa sau create.
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 { Wallet } 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 type { BudgetDetailBundle } from '@/types/budget'
import type { Department, Paged, Project } from '@/types/master'
export function BudgetCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const currentYear = new Date().getFullYear()
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () =>
(await api.get<Paged<Project>>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const departments = useQuery({
queryKey: ['all-departments'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['budget-detail', editId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
tenNganSach: '',
description: '',
namNganSach: currentYear,
projectId: '',
departmentId: '',
})
useEffect(() => {
if (existing.data) {
setForm({
tenNganSach: existing.data.tenNganSach,
description: existing.data.description ?? '',
namNganSach: existing.data.namNganSach,
projectId: existing.data.projectId,
departmentId: existing.data.departmentId ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/budgets/${editId}`, {
id: editId,
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
})
}
return api.post<{ id: string }>('/budgets', {
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
projectId: form.projectId,
departmentId: form.departmentId || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo ngân sách.')
qc.invalidateQueries({ queryKey: ['budget-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/budgets?id=${id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa header ngân sách' : 'Tạo ngân sách 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>Tên ngân sách *</Label>
<Input
value={form.tenNganSach}
onChange={e => setForm({ ...form, tenNganSach: e.target.value })}
placeholder="vd Ngân sách thi công Block A — FLOCK 01"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Năm ngân sách *</Label>
<Input
type="number"
min={2020}
max={2100}
value={form.namNganSach}
onChange={e => setForm({ ...form, namNganSach: Number(e.target.value) })}
/>
</div>
<div>
<Label>Phòng ban</Label>
<Select
value={form.departmentId}
disabled={!!editId}
onChange={e => setForm({ ...form, departmentId: e.target.value })}
>
<option value=""> (tùy chọn)</option>
{departments.data?.map(d => (
<option key={d.id} value={d.id}>{d.code} {d.name}</option>
))}
</Select>
</div>
</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>
{editId && (
<p className="mt-1 text-[11px] text-slate-500">Dự án + Phòng ban khóa sau khi tạo.</p>
)}
</div>
<div>
<Label> tả</Label>
<Textarea
rows={4}
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="Ghi chú ngân sách, phạm vi sử dụng, ràng buộc..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenNganSach || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo ngân sách'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -1,263 +0,0 @@
// List + Detail ngân sách — 3-panel: List | Detail (Header + Hạng mục) | Workflow + history.
// URL params: phase, projectId, q (search), id (selected), namNganSach.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Plus, Search, Wallet, 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 {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDetailBundle,
type BudgetListItem,
} from '@/types/budget'
import { BudgetDetailTabs } from '@/components/budgets/BudgetDetailTabs'
import { BudgetWorkflowPanel } from '@/components/budgets/BudgetWorkflowPanel'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const phaseFilter = sp.get('phase') ?? ''
const search = sp.get('q') ?? ''
const yearFilter = sp.get('namNganSach') ?? ''
const selectedId = sp.get('id')
// ?phase=Pending → highlight 2 phase chờ duyệt (ChoCCM + ChoCEO).
const isPendingMode = phaseFilter === 'Pending'
const list = useQuery({
queryKey: ['budget-list', { phaseFilter, search, yearFilter }],
queryFn: async () => {
const params: Record<string, unknown> = { pageSize: 100, search: search || undefined }
if (yearFilter) params.namNganSach = Number(yearFilter)
// Phase=Pending là alias FE → BE không filter (FE tự lọc 2 phase chờ).
if (phaseFilter && phaseFilter !== 'Pending') params.phase = phaseFilter
const res = await api.get<Paged<BudgetListItem>>('/budgets', { params })
return res.data
},
})
const detail = useQuery({
queryKey: ['budget-detail', selectedId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa ngân sách.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['budget-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(`/budgets/${id}`)
}
}
const allRows = list.data?.items ?? []
const rows = isPendingMode
? allRows.filter(b => b.phase === BudgetPhase.ChoCCM || b.phase === BudgetPhase.ChoCEO)
: allRows
const headerTitle = isPendingMode ? 'Ngân sách — Chờ duyệt' : 'Ngân sách dự án'
const phaseValues = Object.values(BudgetPhase)
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">
<Wallet 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">
{rows.length}
</span>
</div>
<Button onClick={() => navigate('/budgets/new')} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo ngân sách
</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 / dự án…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={isPendingMode ? '' : phaseFilter}
onChange={e => setParam('phase', e.target.value)}
disabled={isPendingMode}
>
<option value="">Tất cả phase</option>
{phaseValues.map(p => (
<option key={p} value={p}>{BudgetPhaseLabel[p]}</option>
))}
</Select>
<Input
type="number"
placeholder="Năm"
value={yearFilter}
onChange={e => setParam('namNganSach', e.target.value)}
/>
</div>
</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={Wallet}
title={isPendingMode ? 'Không có ngân sách chờ duyệt' : 'Chưa có ngân sách'}
description={isPendingMode ? 'Tất cả đã duyệt xong.' : 'Tạo ngân sách mới để bắt đầu.'}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(b => (
<li key={b.id}>
<button
onClick={() => selectRow(b.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === b.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">{b.tenNganSach}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{b.maNganSach ?? '—'}</span>
<span>·</span>
<span className="truncate">{b.projectName}</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[b.phase],
)}
>
{BudgetPhaseLabel[b.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">
Năm {b.namNganSach}
</span>
<span className="font-medium tabular-nums text-slate-700">
{fmtMoney(b.tongNganSach)} đ
</span>
</div>
<div className="mt-1 text-right">
<SlaTimer deadline={b.slaDeadline} createdAt={b.createdAt} />
</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={Wallet}
title="Chọn ngân sách ở danh sách"
description="Chi tiết hạng mục + duyệt sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<BudgetDetailTabs
budget={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={isPendingMode}
/>
)}
</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 ngân sách.
</div>
)}
{selectedId && detail.data && <BudgetWorkflowPanel budget={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/budgets/:id)
export function BudgetDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['budget-detail', id],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/budgets')
},
})
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 ngân sách.</div>
return (
<div className="space-y-4 p-6">
<BudgetDetailTabs
budget={detail.data}
onBack={() => navigate('/budgets')}
onDelete={() => del.mutate()}
/>
<BudgetWorkflowPanel budget={detail.data} />
</div>
)
}

View File

@ -35,7 +35,6 @@ import {
type ContractDetail, type ContractDetail,
type ContractListItem, type ContractListItem,
} from '@/types/contracts' } from '@/types/contracts'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -304,9 +303,8 @@ function ContractHeaderForm({
const [tenHopDong, setTenHopDong] = useState('') const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('') // [S61 Mig 50] Module Budget cũ XÓA — HĐ GIỮ ngân sách nhập tay (Tên + Số tiền),
// Mig 17 — manual budget fallback (toggle "Nhập tay") // chỉ bỏ chế độ link Budget entity.
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('') const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0) const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror // [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
@ -317,8 +315,6 @@ function ContractHeaderForm({
// Reset type về default khi typeFilter (parent prop) thay đổi // Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType]) useEffect(() => { setType(defaultType) }, [defaultType])
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
useEffect(() => { setBudgetId('') }, [projectId])
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['suppliers-all'], queryKey: ['suppliers-all'],
@ -345,21 +341,12 @@ function ContractHeaderForm({
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable) return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
}, },
}) })
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: !!projectId,
})
const qc = useQueryClient() const qc = useQueryClient()
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual. // [S61 Mig 50] HĐ chỉ còn ngân sách nhập tay (link Budget entity đã bỏ).
const budgetPayload = budgetManual const budgetPayload = {
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null } budgetManualName: budgetManualName || null,
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null } budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -440,69 +427,35 @@ function ContractHeaderForm({
)} )}
</div> </div>
<div className="mt-4 space-y-1.5"> <div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between"> {/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY (Tên + Số tiền) — chế độ link
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> Budget entity đã bỏ (module Budget cũ xóa hẳn). */}
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */} <Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600"> <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<input <div>
type="checkbox" <Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
checked={budgetManual} <Input
onChange={e => setBudgetManual(e.target.checked)} value={budgetManualName}
className="h-3.5 w-3.5 rounded border-slate-300" onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/> />
Nhập tay (không link)
</label>
</div>
{!budgetManual ? (
<>
<Select
value={budgetId}
disabled={!projectId}
onChange={e => setBudgetId(e.target.value)}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div> </div>
)} <div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
</div> </div>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}> <Button type="submit" disabled={create.isPending}>
@ -592,10 +545,7 @@ function ContractEditForm({
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0)) const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '') const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '') const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '') // [S61 Mig 50] HĐ giữ ngân sách nhập tay — link Budget entity đã bỏ.
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '') const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0) const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
@ -603,20 +553,12 @@ function ContractEditForm({
queryKey: ['templates-by-type', contract.type], queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data, queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
}) })
// Eligible Budgets: cùng Project + Phase=DaDuyet
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', contract.projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: isDraft,
})
const qc = useQueryClient() const qc = useQueryClient()
const budgetPayload = budgetManual const budgetPayload = {
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null } budgetManualName: budgetManualName || null,
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null } budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const update = useMutation({ const update = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -716,74 +658,36 @@ function ContractEditForm({
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<div className="flex items-center justify-between"> {/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY — link Budget entity đã bỏ
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label> (module Budget cũ xóa hẳn). */}
{isDraft && ( <Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
)}
</div>
{isDraft ? ( {isDraft ? (
!budgetManual ? ( <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<> <div>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}> <Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<option value=""> (không link)</option> <Input
{eligibleBudgets.data?.map(b => ( value={budgetManualName}
<option key={b.id} value={b.id}> onChange={e => setBudgetManualName(e.target.value)}
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ placeholder="vd Tạm tính dự toán T11/2025"
</option> maxLength={200}
))} />
</Select>
<p className="text-[11px] text-slate-500">
{eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div> </div>
) <div>
) : contract.budget ? ( <Label className="text-[11px]">Số tiền (đ)</Label>
<a <Input
href={`/budgets?id=${contract.budget.id}`} type="number"
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700" min={0}
> value={budgetManualAmount || ''}
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span> onChange={e => setBudgetManualAmount(Number(e.target.value))}
{' · '}{contract.budget.tenNganSach} placeholder="1000000000"
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> />
</a> {budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
) : contract.budgetManualAmount != null || contract.budgetManualName ? ( ) : contract.budgetManualAmount != null || contract.budgetManualName ? (
// Mig 17 — read-only display khi !isDraft + có manual data // Mig 17 — read-only display khi !isDraft + có manual data
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"> <div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">

View File

@ -1,168 +0,0 @@
// Types cho module Ngân sách (Budget) — mirror BE Domain.Budgets.
// Workflow simple 3-step (BudgetPolicy.Default hardcoded):
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet (+ TuChoi từ DangSoanThao).
export const BudgetPhase = {
DangSoanThao: 1,
ChoCCM: 2,
ChoCEO: 3,
DaDuyet: 4,
TraLai: 98,
TuChoi: 99,
} as const
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
export const BudgetPhaseLabel: Record<number, string> = {
1: 'Nháp',
2: 'Chờ CCM',
3: 'Chờ CEO',
4: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
export const BudgetPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-indigo-100 text-indigo-700',
3: 'bg-pink-100 text-pink-700',
4: 'bg-emerald-100 text-emerald-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}
// Mirror BE BudgetEntityType enum
export const BudgetEntityType = {
Header: 1,
Detail: 2,
Workflow: 3,
} as const
// Mirror BE ChangelogAction enum (reuse từ Contracts.ChangelogAction)
export const ChangelogAction = {
Insert: 1,
Update: 2,
Delete: 3,
Transition: 4,
} as const
// Mirror BE ApprovalDecision enum (reuse từ Contracts.ApprovalDecision)
export const ApprovalDecision = {
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
export const ApprovalStage = {
Review: 1,
Confirm: 2,
} as const
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
export type BudgetDepartmentApproval = {
id: string
phaseAtApproval: number
departmentId: string
departmentName: string | null
stage: number // 1=Review, 2=Confirm
approverUserId: string
approverName: string | null
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
comment: string | null
approvedAt: string
isBypassed: boolean
}
export type BudgetListItem = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
projectId: string
projectName: string
tongNganSach: number
slaDeadline: string | null
createdAt: string
}
export type BudgetDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
order: number
ghiChu: string | null
}
export type BudgetApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type BudgetWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type BudgetChangelog = {
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 BudgetDetailBundle = {
id: string
maNganSach: string | null
tenNganSach: string
description: string | null
namNganSach: number
phase: number
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
tongNganSach: number
slaDeadline: string | null
createdAt: string
updatedAt: string | null
details: BudgetDetailRow[]
approvals: BudgetApproval[]
workflow: BudgetWorkflowSummary
}
// Body shape POST/PUT detail — mirror BudgetDetailBody record BE.
export type BudgetDetailBody = {
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
ghiChu: string | null
}

View File

@ -130,16 +130,6 @@ export type WorkflowSummary = {
nextPhases: number[] nextPhases: number[]
} }
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
export type ContractBudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type ContractDetail = { export type ContractDetail = {
id: string id: string
maHopDong: string | null maHopDong: string | null
@ -162,9 +152,8 @@ export type ContractDetail = {
draftData: string | null draftData: string | null
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
budgetId: string | null // [S61 Mig 50] budgetId/budget link DROP (module Budget cũ xóa) — HĐ GIỮ
budget: ContractBudgetSummary | null // ngân sách nhập tay.
// Mig 17 — manual budget fallback khi không link Budget entity.
budgetManualName: string | null budgetManualName: string | null
budgetManualAmount: number | null budgetManualAmount: number | null
approvals: ContractApproval[] approvals: ContractApproval[]

View File

@ -130,6 +130,9 @@ export type PeListItem = {
drafterName: string | null drafterName: string | null
departmentId: string | null departmentId: string | null
departmentName: string | null departmentName: string | null
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
budgetPeriodAmount: number | null
expectedRemainingAmount: number | null
} }
export type PeSupplier = { export type PeSupplier = {
@ -272,14 +275,28 @@ export type PeChangelog = {
createdAt: string createdAt: string
} }
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE). // S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
export type BudgetSummary = { // BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
id: string // cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
maNganSach: string | null // canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
tenNganSach: string // capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
namNganSach: number // gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
phase: number export type PeBudgetSummary = {
tongNganSach: number budgetId: string | null
proEstimateAmount: number | null
proNote: string | null
initialAmount: number | null
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
fullAmount: number
fullIsEstimate: boolean
canEditPro: boolean
canEditCcm: boolean
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number
previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
previousSelectedCount: number
currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
} }
// Mirror BE PeDepartmentKind enum // Mirror BE PeDepartmentKind enum
@ -405,11 +422,10 @@ export type PeDetailBundle = {
slaDeadline: string | null slaDeadline: string | null
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
budgetId: string | null // S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budget: BudgetSummary | null budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK. expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetManualName: string | null budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
budgetManualAmount: number | null
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create). // Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null approvalWorkflowId: string | null
approvalWorkflowCode: string | null approvalWorkflowCode: string | null

View File

@ -1,100 +0,0 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Budgets;
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Application.Common.Models;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/budgets")]
[Authorize]
public class BudgetsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<BudgetListItemDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
[FromQuery] BudgetPhase? phase = null,
[FromQuery] Guid? projectId = null,
[FromQuery] int? namNganSach = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListBudgetsQuery(phase, projectId, namNganSach)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<BudgetDetailBundleDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetBudgetQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create([FromBody] CreateBudgetCommand 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] UpdateBudgetDraftCommand 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] TransitionBudgetBody body, CancellationToken ct)
{
await mediator.Send(new TransitionBudgetCommand(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 DeleteBudgetCommand(id), ct);
return NoContent();
}
[HttpPost("{id:guid}/details")]
public async Task<ActionResult<object>> AddDetail(Guid id, [FromBody] BudgetDetailBody body, CancellationToken ct)
{
var newId = await mediator.Send(new AddBudgetDetailCommand(
id, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuong, body.DonGia, body.ThanhTien, body.GhiChu), ct);
return Ok(new { id = newId });
}
[HttpPut("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> UpdateDetail(Guid id, Guid detailId, [FromBody] BudgetDetailBody body, CancellationToken ct)
{
await mediator.Send(new UpdateBudgetDetailCommand(
id, detailId, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuong, body.DonGia, body.ThanhTien, 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 DeleteBudgetDetailCommand(id, detailId), ct);
return NoContent();
}
[HttpGet("{id:guid}/changelogs")]
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
// 2-stage department approval list (Phase 9 — Migration 16).
[HttpGet("{id:guid}/department-approvals")]
public async Task<ActionResult<List<BudgetDepartmentApprovalDto>>> ListDepartmentApprovals(
Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new ListBudgetDepartmentApprovalsQuery(id), ct));
}
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);
public record BudgetDetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu);

View File

@ -55,15 +55,35 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
// S22+4 — Feature 2: Section "Điều chỉnh ngân sách" tách endpoint riêng. // S22+4 — Feature 2: Section "Điều chỉnh ngân sách" tách endpoint riêng.
// Cho phép Drafter (DangSoanThao/TraLai) OR Approver currentLevel (ChoDuyet) // Cho phép Drafter (DangSoanThao/TraLai) OR Approver currentLevel (ChoDuyet)
// OR Admin adjust Budget* fields. Handler kiểm phase + actor scope. // OR Admin. [S61 Mig 50] Body đổi sang 2 field mới (NS kỳ này + dự kiến còn
// lại) — BudgetId/BudgetManual* cũ DROP cùng module Budget. Absolute-set.
[HttpPatch("{id:guid}/budget-adjust")] [HttpPatch("{id:guid}/budget-adjust")]
public async Task<IActionResult> AdjustBudget(Guid id, [FromBody] AdjustBudgetBody body, CancellationToken ct) public async Task<IActionResult> AdjustBudget(Guid id, [FromBody] AdjustBudgetBody body, CancellationToken ct)
{ {
await mediator.Send(new AdjustPurchaseEvaluationBudgetCommand( await mediator.Send(new AdjustPurchaseEvaluationBudgetCommand(
id, body.BudgetId, body.BudgetManualName, body.BudgetManualAmount), ct); id, body.BudgetPeriodAmount, body.ExpectedRemainingAmount), ct);
return NoContent(); return NoContent();
} }
public record AdjustBudgetBody(Guid? BudgetId, string? BudgetManualName, decimal? BudgetManualAmount); public record AdjustBudgetBody(decimal? BudgetPeriodAmount, decimal? ExpectedRemainingAmount);
// [S61 Mig 50] Ngân sách gói thầu per cặp (Dự án, Hạng mục) — nhập theo ROLE.
// Class [Authorize] any-auth; handler fine-gained Forbidden (PRO=Procurement,
// CCM=CostControl, Admin cả 2 — pattern AssignItTicketHandler S54).
[HttpPut("{id:guid}/budget/pro")]
public async Task<IActionResult> UpdateBudgetPro(Guid id, [FromBody] BudgetProBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePeBudgetProCommand(id, body.ProEstimateAmount, body.ProNote), ct);
return NoContent();
}
public record BudgetProBody(decimal? ProEstimateAmount, string? ProNote);
[HttpPut("{id:guid}/budget/ccm")]
public async Task<IActionResult> UpdateBudgetCcm(Guid id, [FromBody] BudgetCcmBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePeBudgetCcmCommand(id, body.InitialAmount, body.AdjustmentAmount), ct);
return NoContent();
}
public record BudgetCcmBody(decimal? InitialAmount, decimal? AdjustmentAmount);
[HttpPost("{id:guid}/transitions")] [HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)

View File

@ -1,74 +0,0 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Common;
namespace SolutionErp.Application.Budgets;
// 2-stage department approval list cho Budget (Phase 9 — Migration 16).
// Mirror PE/Contract — query để FE Workflow Panel render timeline progress.
//
// Insertion + Block logic ở TransitionBudgetCommandHandler (BudgetFeatures.cs).
public record ListBudgetDepartmentApprovalsQuery(Guid BudgetId)
: IRequest<List<BudgetDepartmentApprovalDto>>;
public record BudgetDepartmentApprovalDto(
Guid Id,
int PhaseAtApproval,
Guid DepartmentId,
string? DepartmentName,
ApprovalStage Stage,
Guid ApproverUserId,
string? ApproverName,
string? ApproverRoleSnapshot,
string? Comment,
DateTime ApprovedAt,
bool IsBypassed);
public class ListBudgetDepartmentApprovalsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListBudgetDepartmentApprovalsQuery, List<BudgetDepartmentApprovalDto>>
{
public async Task<List<BudgetDepartmentApprovalDto>> Handle(
ListBudgetDepartmentApprovalsQuery request, CancellationToken ct)
{
var rows = await (
from a in db.BudgetDepartmentApprovals.AsNoTracking()
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
from d in deptJoin.DefaultIfEmpty()
where a.BudgetId == request.BudgetId
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
select new
{
a.Id,
a.PhaseAtApproval,
a.DepartmentId,
DepartmentName = d != null ? d.Name : null,
a.Stage,
a.ApproverUserId,
a.ApproverRoleSnapshot,
a.Comment,
a.ApprovedAt,
a.IsBypassed,
}).ToListAsync(ct);
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
var users = await db.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
return rows.Select(r => new BudgetDepartmentApprovalDto(
r.Id,
r.PhaseAtApproval,
r.DepartmentId,
r.DepartmentName,
r.Stage,
r.ApproverUserId,
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
r.ApproverRoleSnapshot,
r.Comment,
r.ApprovedAt,
r.IsBypassed)).ToList();
}
}

View File

@ -1,531 +0,0 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Common; // ApprovalStage
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Application.Budgets;
// Compact CQRS feature file cho module Ngân sách. Pattern simplified vs PE
// (no versioned WF, hardcoded BudgetPolicy.Default). Workflow 5 phase:
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet/TuChoi.
// ========== CREATE ==========
public record CreateBudgetCommand(
string TenNganSach,
string? Description,
int NamNganSach,
Guid ProjectId,
Guid? DepartmentId) : IRequest<Guid>;
public class CreateBudgetCommandValidator : AbstractValidator<CreateBudgetCommand>
{
public CreateBudgetCommandValidator()
{
RuleFor(x => x.TenNganSach).NotEmpty().MaximumLength(500);
RuleFor(x => x.Description).MaximumLength(2000);
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.NamNganSach).InclusiveBetween(2020, 2100);
}
}
public class CreateBudgetCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<CreateBudgetCommand, Guid>
{
public async Task<Guid> Handle(CreateBudgetCommand request, CancellationToken ct)
{
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
?? throw new NotFoundException("Project", request.ProjectId);
var sla = BudgetPolicies.Default.PhaseSla.GetValueOrDefault(BudgetPhase.DangSoanThao);
var entity = new Budget
{
TenNganSach = request.TenNganSach,
Description = request.Description,
NamNganSach = request.NamNganSach,
ProjectId = request.ProjectId,
DepartmentId = request.DepartmentId,
DrafterUserId = currentUser.UserId,
Phase = BudgetPhase.DangSoanThao,
TongNganSach = 0,
SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value),
// Auto-gen MaNganSach đơn giản — atomic sequence sau (Phase 8)
MaNganSach = $"NS-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}",
};
db.Budgets.Add(entity);
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Header,
Action = ChangelogAction.Insert,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = $"Tạo ngân sách {entity.MaNganSach} — {entity.TenNganSach}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE draft ==========
public record UpdateBudgetDraftCommand(
Guid Id,
string TenNganSach,
string? Description,
int NamNganSach) : IRequest;
public class UpdateBudgetDraftCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdateBudgetDraftCommand>
{
public async Task Handle(UpdateBudgetDraftCommand request, CancellationToken ct)
{
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
if (entity.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException("Chỉ sửa được ngân sách khi Đang soạn thảo.");
entity.TenNganSach = request.TenNganSach;
entity.Description = request.Description;
entity.NamNganSach = request.NamNganSach;
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = "Cập nhật ngân sách",
});
await db.SaveChangesAsync(ct);
}
}
// ========== TRANSITION ==========
public record TransitionBudgetCommand(
Guid Id, BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment) : IRequest;
public class TransitionBudgetCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
UserManager<User> userManager,
INotificationService notifications,
IDateTime dateTime) : IRequestHandler<TransitionBudgetCommand>
{
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
// ===== Reject → TraLai (Session 17 spec mới) =====
// Bỏ smart-reject jump-back. Trả lại = Phase riêng (TraLai).
// Drafter từ TraLai gửi lại như Nháp — Policy `(TraLai, ChoCCM)` đã wire.
// Field RejectedFromPhase giữ DB column nhưng KHÔNG set value mới (data cũ vẫn đọc).
var fromPhase = entity.Phase;
var targetPhase = request.TargetPhase;
if (request.Decision == ApprovalDecision.Reject && targetPhase != BudgetPhase.TuChoi)
{
// Trả lại — override target → TraLai
targetPhase = BudgetPhase.TraLai;
}
var policy = BudgetPolicies.Default;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
// Policy guard
if (!isAdmin
&& !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles))
throw new ForbiddenException(
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
// Mirror PE/Contract. Low-priority cho Budget vì ít dept duyệt budget,
// nhưng giữ consistent UX 3 module.
if (request.Decision == ApprovalDecision.Approve
&& targetPhase != BudgetPhase.DangSoanThao
&& targetPhase != BudgetPhase.TraLai
&& targetPhase != BudgetPhase.TuChoi
&& !isAdmin
&& currentUser.UserId is Guid actorUid)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
if (actor?.DepartmentId is Guid deptId)
{
var isManager = currentUser.Roles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
var existing = await db.BudgetDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.BudgetId == entity.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage, ct);
if (existing is null)
{
db.BudgetDepartmentApprovals.Add(new BudgetDepartmentApproval
{
BudgetId = entity.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = request.Comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = request.Comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.BudgetDepartmentApprovals.AnyAsync(a =>
a.BudgetId == entity.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm, ct);
if (!hasConfirm)
{
// BLOCK transition. Log audit Approval + Changelog.
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {request.Comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
string? reviewerName = (actor.FullName ?? actor.Email);
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = request.Comment,
});
// Notify TPB cùng dept. Best effort.
try
{
var managers = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
.Select(u => u.Id)
.ToListAsync(ct);
foreach (var mgrId in managers)
{
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
if (mgr is null) continue;
var roles = await userManager.GetRolesAsync(mgr);
if (!roles.Contains(AppRoles.DeptManager)) continue;
await notifications.NotifyAsync(
mgrId,
NotificationType.ContractPhaseTransition,
title: $"NS {entity.MaNganSach ?? entity.TenNganSach} chờ TPB confirm",
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
href: $"/budgets/{entity.Id}",
refId: entity.Id,
ct: ct);
}
}
catch { /* notification fail non-critical */ }
await db.SaveChangesAsync(ct);
return;
}
}
}
entity.SlaWarningSent = false;
entity.Phase = targetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value);
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = currentUser.UserId,
Decision = request.Decision,
Comment = request.Comment,
ApprovedAt = DateTime.UtcNow,
});
string? actorName = null;
if (currentUser.UserId is Guid uid)
{
var u = await userManager.FindByIdAsync(uid.ToString());
actorName = u?.FullName ?? u?.Email;
}
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = targetPhase,
UserId = currentUser.UserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
ContextNote = request.Comment,
});
await db.SaveChangesAsync(ct);
}
}
// ========== LIST ==========
public record ListBudgetsQuery(
BudgetPhase? Phase = null,
Guid? ProjectId = null,
int? NamNganSach = null) : PagedRequest, IRequest<PagedResult<BudgetListItemDto>>;
public class ListBudgetsQueryHandler(
IApplicationDbContext db) : IRequestHandler<ListBudgetsQuery, PagedResult<BudgetListItemDto>>
{
public async Task<PagedResult<BudgetListItemDto>> Handle(ListBudgetsQuery request, CancellationToken ct)
{
var q = from e in db.Budgets.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
select new { e, p };
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 (request.NamNganSach is not null) q = q.Where(x => x.e.NamNganSach == request.NamNganSach);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x => (x.e.MaNganSach != null && x.e.MaNganSach.Contains(s))
|| x.e.TenNganSach.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 BudgetListItemDto(
x.e.Id, x.e.MaNganSach, x.e.TenNganSach, x.e.NamNganSach, x.e.Phase,
x.e.ProjectId, x.p.Name, x.e.TongNganSach, x.e.SlaDeadline, x.e.CreatedAt))
.ToListAsync(ct);
return new PagedResult<BudgetListItemDto>(items, total, request.Page, request.PageSize);
}
}
// ========== GET detail bundle ==========
public record GetBudgetQuery(Guid Id) : IRequest<BudgetDetailBundleDto>;
public class GetBudgetQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager) : IRequestHandler<GetBudgetQuery, BudgetDetailBundleDto>
{
public async Task<BudgetDetailBundleDto> Handle(GetBudgetQuery request, CancellationToken ct)
{
var e = await db.Budgets.AsNoTracking()
.Include(x => x.Details)
.Include(x => x.Approvals)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
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 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);
var policy = BudgetPolicies.Default;
return new BudgetDetailBundleDto(
e.Id, e.MaNganSach, e.TenNganSach, e.Description, e.NamNganSach, e.Phase,
e.ProjectId, project?.Name ?? "",
e.DepartmentId, department?.Name,
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
e.TongNganSach, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.Details.OrderBy(d => d.Order).Select(d => new BudgetDetailDto(
d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh,
d.KhoiLuong, d.DonGia, d.ThanhTien, d.Order, d.GhiChu)).ToList(),
e.Approvals.OrderBy(a => a.ApprovedAt).Select(a => new BudgetApprovalDto(
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 BudgetWorkflowSummaryDto(
policy.Name, policy.Description,
policy.ActivePhases.ToList(),
policy.NextPhasesFrom(e.Phase).ToList()));
}
}
// ========== DELETE ==========
public record DeleteBudgetCommand(Guid Id) : IRequest;
public class DeleteBudgetCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeleteBudgetCommand>
{
public async Task Handle(DeleteBudgetCommand request, CancellationToken ct)
{
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
if (entity.Phase != BudgetPhase.DangSoanThao && entity.Phase != BudgetPhase.TuChoi)
throw new ConflictException("Chỉ xóa được ngân sách phase Soạn thảo / Từ chối.");
db.Budgets.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ========== Detail CRUD ==========
public record AddBudgetDetailCommand(
Guid BudgetId, string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu) : IRequest<Guid>;
public class AddBudgetDetailCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<AddBudgetDetailCommand, Guid>
{
public async Task<Guid> Handle(AddBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể thêm hạng mục. Phải reject để Drafter sửa lại.");
var maxOrder = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.Select(d => (int?)d.Order).MaxAsync(ct);
var entity = new BudgetDetail
{
BudgetId = bg.Id,
GroupCode = request.GroupCode, GroupName = request.GroupName,
ItemCode = request.ItemCode, NoiDung = request.NoiDung, DonViTinh = request.DonViTinh,
KhoiLuong = request.KhoiLuong, DonGia = request.DonGia, ThanhTien = request.ThanhTien,
GhiChu = request.GhiChu, Order = (maxOrder ?? 0) + 1,
};
db.BudgetDetails.Add(entity);
// Recompute TongNganSach
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct) + entity.ThanhTien;
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = bg.Id, EntityType = BudgetEntityType.Detail, EntityId = entity.Id,
Action = ChangelogAction.Insert, PhaseAtChange = bg.Phase,
UserId = currentUser.UserId,
Summary = $"Thêm hạng mục {request.GroupCode} — {request.NoiDung}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateBudgetDetailCommand(
Guid BudgetId, Guid DetailId, string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu) : IRequest;
public class UpdateBudgetDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdateBudgetDetailCommand>
{
public async Task Handle(UpdateBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể sửa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
entity.GroupCode = request.GroupCode; entity.GroupName = request.GroupName;
entity.ItemCode = request.ItemCode; entity.NoiDung = request.NoiDung; entity.DonViTinh = request.DonViTinh;
entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien;
entity.GhiChu = request.GhiChu;
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}
}
public record DeleteBudgetDetailCommand(Guid BudgetId, Guid DetailId) : IRequest;
public class DeleteBudgetDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeleteBudgetDetailCommand>
{
public async Task Handle(DeleteBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể xóa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
db.BudgetDetails.Remove(entity);
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}
}
// ========== CHANGELOG list ==========
public record ListBudgetChangelogsQuery(Guid BudgetId, int Take = 200) : IRequest<List<BudgetChangelogDto>>;
public class ListBudgetChangelogsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListBudgetChangelogsQuery, List<BudgetChangelogDto>>
{
public async Task<List<BudgetChangelogDto>> Handle(ListBudgetChangelogsQuery request, CancellationToken ct) =>
await db.BudgetChangelogs.AsNoTracking()
.Where(c => c.BudgetId == request.BudgetId)
.OrderByDescending(c => c.CreatedAt)
.Take(request.Take)
.Select(c => new BudgetChangelogDto(
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote, c.CreatedAt))
.ToListAsync(ct);
}

View File

@ -1,90 +0,0 @@
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Budgets.Dtos;
public record BudgetListItemDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
int NamNganSach,
BudgetPhase Phase,
Guid ProjectId,
string ProjectName,
decimal TongNganSach,
DateTime? SlaDeadline,
DateTime CreatedAt);
public record BudgetDetailDto(
Guid Id,
string GroupCode,
string GroupName,
string? ItemCode,
string NoiDung,
string? DonViTinh,
decimal KhoiLuong,
decimal DonGia,
decimal ThanhTien,
int Order,
string? GhiChu);
public record BudgetApprovalDto(
Guid Id,
BudgetPhase FromPhase,
BudgetPhase ToPhase,
Guid? ApproverUserId,
string? ApproverName,
ApprovalDecision Decision,
string? Comment,
DateTime ApprovedAt);
public record BudgetChangelogDto(
Guid Id,
BudgetEntityType EntityType,
Guid? EntityId,
ChangelogAction Action,
BudgetPhase? PhaseAtChange,
Guid? UserId,
string? UserName,
string? Summary,
string? FieldChangesJson,
string? ContextNote,
DateTime CreatedAt);
public record BudgetWorkflowSummaryDto(
string PolicyName,
string PolicyDescription,
List<BudgetPhase> ActivePhases,
List<BudgetPhase> NextPhases);
// Snapshot ngân sách link cho PE / Contract DetailBundle. Compact — chỉ
// header info, không bao gồm BudgetDetails (gọi /budgets/{id} riêng nếu cần
// đối chiếu chi tiết theo từng GroupCode/ItemCode).
public record BudgetSummaryDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
int NamNganSach,
BudgetPhase Phase,
decimal TongNganSach);
public record BudgetDetailBundleDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
string? Description,
int NamNganSach,
BudgetPhase Phase,
Guid ProjectId,
string ProjectName,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
string? DrafterName,
decimal TongNganSach,
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<BudgetDetailDto> Details,
List<BudgetApprovalDto> Approvals,
BudgetWorkflowSummaryDto Workflow);

View File

@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details; using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms; using SolutionErp.Domain.Forms;
@ -71,6 +70,9 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; } DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel // Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel
DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions { get; } DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions { get; }
// [S61 Mig 50] Ngân sách gói thầu per cặp (ProjectId, WorkItemId) — 1 record/cặp,
// loose-Guid KHÔNG FK vật lý. Thay module Budget cũ (5 bảng đã drop).
DbSet<PeWorkItemBudget> PeWorkItemBudgets { get; }
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi // Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể). // drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
@ -78,13 +80,6 @@ public interface IApplicationDbContext
DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps { get; } DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps { get; }
DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels { get; } DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels { get; }
// Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; }
DbSet<BudgetDetail> BudgetDetails { get; }
DbSet<BudgetApproval> BudgetApprovals { get; }
DbSet<BudgetChangelog> BudgetChangelogs { get; }
DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals { get; }
// Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup. // Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup.
// 1 main + 5 satellite + 1 sequence. 1-1 với User qua UserId UNIQUE. // 1 main + 5 satellite + 1 sequence. 1-1 với User qua UserId UNIQUE.
// 3 HĐLĐ satellite defer Plan H2 sau. // 3 HĐLĐ satellite defer Plan H2 sau.

View File

@ -25,7 +25,8 @@ public record CreateContractCommand(
string? NoiDung, string? NoiDung,
bool BypassProcurementAndCCM, bool BypassProcurementAndCCM,
string? DraftData, string? DraftData,
Guid? BudgetId, // [S61 Mig 50] BudgetId DROP (module Budget cũ xóa hẳn) — GIỮ BudgetManual*
// (ngân sách nhập tay HĐ không đổi).
string? BudgetManualName, string? BudgetManualName,
decimal? BudgetManualAmount, decimal? BudgetManualAmount,
// [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create — // [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create —
@ -85,18 +86,6 @@ public class CreateContractCommandHandler(
$"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract)."); $"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract).");
} }
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
if (request.BudgetId is Guid bid)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != request.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
var entity = new Contract var entity = new Contract
{ {
Type = request.Type, Type = request.Type,
@ -111,7 +100,6 @@ public class CreateContractCommandHandler(
NoiDung = request.NoiDung, NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM, BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData, DraftData = request.DraftData,
BudgetId = request.BudgetId,
BudgetManualName = request.BudgetManualName, BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount, BudgetManualAmount = request.BudgetManualAmount,
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
@ -152,7 +140,7 @@ public record UpdateContractDraftCommand(
string? NoiDung, string? NoiDung,
Guid? TemplateId, Guid? TemplateId,
string? DraftData, string? DraftData,
Guid? BudgetId, // [S61 Mig 50] BudgetId DROP — GIỮ BudgetManual* (HĐ nhập tay không đổi).
string? BudgetManualName, string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest; decimal? BudgetManualAmount) : IRequest;
@ -168,18 +156,6 @@ public class UpdateContractDraftCommandHandler(
if (entity.Phase != ContractPhase.DangSoanThao) if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo."); throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// Capture diff trước update để log // Capture diff trước update để log
var changes = new List<object>(); var changes = new List<object>();
if (entity.GiaTri != request.GiaTri) if (entity.GiaTri != request.GiaTri)
@ -190,8 +166,6 @@ public class UpdateContractDraftCommandHandler(
changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung }); changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung });
if (entity.TemplateId != request.TemplateId) if (entity.TemplateId != request.TemplateId)
changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId }); changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId });
if (entity.BudgetId != request.BudgetId)
changes.Add(new { Field = "BudgetId", Old = entity.BudgetId, New = request.BudgetId });
if (entity.BudgetManualName != request.BudgetManualName) if (entity.BudgetManualName != request.BudgetManualName)
changes.Add(new { Field = "BudgetManualName", Old = entity.BudgetManualName, New = request.BudgetManualName }); changes.Add(new { Field = "BudgetManualName", Old = entity.BudgetManualName, New = request.BudgetManualName });
if (entity.BudgetManualAmount != request.BudgetManualAmount) if (entity.BudgetManualAmount != request.BudgetManualAmount)
@ -202,7 +176,6 @@ public class UpdateContractDraftCommandHandler(
entity.NoiDung = request.NoiDung; entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId; entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData; entity.DraftData = request.DraftData;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName; entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount; entity.BudgetManualAmount = request.BudgetManualAmount;
@ -462,16 +435,6 @@ public class GetContractQueryHandler(
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct); var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct); var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Load Budget summary nếu có link.
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
if (c.BudgetId is Guid budgetId)
{
budgetSummary = await db.Budgets.AsNoTracking()
.Where(b => b.Id == budgetId)
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach))
.FirstOrDefaultAsync(ct);
}
// Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded // Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded
WorkflowPolicy workflowPolicy; WorkflowPolicy workflowPolicy;
@ -557,7 +520,6 @@ public class GetContractQueryHandler(
c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null, c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData, c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt, c.CreatedAt, c.UpdatedAt,
c.BudgetId, budgetSummary,
c.BudgetManualName, c.BudgetManualAmount, c.BudgetManualName, c.BudgetManualAmount,
c.Approvals c.Approvals
.OrderBy(a => a.ApprovedAt) .OrderBy(a => a.ApprovedAt)

View File

@ -1,4 +1,3 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos; namespace SolutionErp.Application.Contracts.Dtos;
@ -39,8 +38,8 @@ public record ContractDetailDto(
string? DraftData, string? DraftData,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? UpdatedAt, DateTime? UpdatedAt,
Guid? BudgetId, // [S61 Mig 50] Module Budget cũ XÓA — Contract.BudgetId drop, GIỮ BudgetManual*
BudgetSummaryDto? Budget, // (ngân sách nhập tay của HĐ không đổi, anh Kiệt chỉ redesign ngân sách PE).
string? BudgetManualName, string? BudgetManualName,
decimal? BudgetManualAmount, decimal? BudgetManualAmount,
List<ContractApprovalDto> Approvals, List<ContractApprovalDto> Approvals,

View File

@ -79,9 +79,10 @@ public class CreateContractFromEvaluationCommandHandler(
NoiDung = pe.MoTa, NoiDung = pe.MoTa,
BypassProcurementAndCCM = request.BypassProcurementAndCCM, BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = pe.PaymentTerms, // carry forward payment terms DraftData = pe.PaymentTerms, // carry forward payment terms
BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link // [S61 Mig 50] Budget link cũ DROP — kế thừa "Ngân sách - kỳ này" của
BudgetManualName = pe.BudgetManualName, // carry forward manual budget (Mig 17) // phiếu sang ngân sách nhập tay HĐ (tham chiếu, HĐ sửa được sau).
BudgetManualAmount = pe.BudgetManualAmount, BudgetManualName = pe.MaPhieu is null ? null : $"NS kỳ này phiếu {pe.MaPhieu}",
BudgetManualAmount = pe.BudgetPeriodAmount,
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add( SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
@ -150,6 +151,7 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
e.SelectedSupplierId, s != null ? s.Name : null, e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.DrafterUserId, u != null ? u.FullName : null, e.DrafterUserId, u != null ? u.FullName : null,
e.DepartmentId, d != null ? d.Name : null)).ToListAsync(ct); e.DepartmentId, d != null ? d.Name : null,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount)).ToListAsync(ct);
} }
} }

View File

@ -1,4 +1,3 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
@ -32,7 +31,11 @@ public record PurchaseEvaluationListItemDto(
Guid? DrafterUserId, Guid? DrafterUserId,
string? DrafterName, string? DrafterName,
Guid? DepartmentId, Guid? DepartmentId,
string? DepartmentName); string? DepartmentName,
// [S61 Mig 50] 2 cột ngân sách mới (mirror detail — FE list chưa render, giữ
// parity type PeListItem).
decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount);
public record PurchaseEvaluationSupplierDto( public record PurchaseEvaluationSupplierDto(
Guid Id, Guid Id,
@ -220,10 +223,11 @@ public record PurchaseEvaluationDetailBundleDto(
DateTime? SlaDeadline, DateTime? SlaDeadline,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? UpdatedAt, DateTime? UpdatedAt,
Guid? BudgetId, // [S61 Mig 50] Ngân sách PE mới — thay BudgetId/Budget/BudgetManual* cũ
BudgetSummaryDto? Budget, // (module Budget XÓA HẲN, data BudgetManualAmount migrate → BudgetPeriodAmount).
string? BudgetManualName, decimal? BudgetPeriodAmount,
decimal? BudgetManualAmount, decimal? ExpectedRemainingAmount,
PeBudgetSummaryDto? BudgetSummary,
// Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code + // Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code +
// Name + Version để FE show "QT-DN-V2-001 - Quy trình Duyệt NCC (v01)". // Name + Version để FE show "QT-DN-V2-001 - Quy trình Duyệt NCC (v01)".
Guid? ApprovalWorkflowId, Guid? ApprovalWorkflowId,
@ -247,3 +251,29 @@ public record PurchaseEvaluationDetailBundleDto(
// legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message. // legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message.
List<PurchaseEvaluationLevelOpinionDto> LevelOpinions, List<PurchaseEvaluationLevelOpinionDto> LevelOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow); PurchaseEvaluationWorkflowSummaryDto Workflow);
// [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt — BE compute
// kèm PE detail GET. Record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp
// (ProjectId, WorkItemId). BudgetId null = phiếu cũ chưa gắn Hạng mục.
// FullAmount = (Initial??0)+(Adjustment??0); cả 2 null → ProEstimate??0 với
// FullIsEstimate=true (FE badge "dự trù PRO"). CanEditPro/CanEditCcm = capability
// flag BE-computed theo role (Procurement / CostControl | Admin — pattern S54).
// Lũy kế: các phiếu cùng cặp, Id != this, CreatedAt < this.CreatedAt:
// PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet)
// PreviousSelectedTotal = SUM(quote ThanhTien của SelectedSupplier) WHERE Phase = DaDuyet
// CurrentProposalTotal = SUM(quote ThanhTien của SelectedSupplier) phiếu NÀY (0 khi chưa chọn).
public record PeBudgetSummaryDto(
Guid? BudgetId,
decimal? ProEstimateAmount,
string? ProNote,
decimal? InitialAmount,
decimal? AdjustmentAmount,
decimal FullAmount,
bool FullIsEstimate,
bool CanEditPro,
bool CanEditCcm,
decimal PreviousSubmittedTotal,
int PreviousSubmittedCount,
decimal PreviousSelectedTotal,
int PreviousSelectedCount,
decimal CurrentProposalTotal);

View File

@ -0,0 +1,186 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// [S61 Mig 50] 2 handler nhập ngân sách gói thầu theo ROLE (anh Kiệt chốt):
// - PRO (Procurement | Admin): ProEstimateAmount (dự trù lần đầu) + ProNote.
// - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") +
// AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM).
// Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth,
// handler fine-grained ForbiddenException fail-closed (Forbidden TRƯỚC mọi
// side-effect — S56 #5). KHÔNG ràng Phase (CCM "nhập trong khi duyệt" theo lời
// anh = thời điểm nghiệp vụ, không hard-block — bảng ngân sách là tài liệu sống
// per cặp, chỉnh được bất kỳ lúc nào như file Excel; trade-off ghi nhận).
// Record PeWorkItemBudgets resolve qua PE (FE đang mở phiếu) — phiếu cũ chưa gắn
// Hạng mục → Conflict. Record chưa có → auto-create (cùng helper với Create PE).
// ===== Helper dùng chung (Create PE dùng bản pre-check riêng; PUT pro/ccm dùng đây) =====
internal static class PeWorkItemBudgetEnsurer
{
/// <summary>
/// Load record TRACKED của cặp (ProjectId, WorkItemId); chưa có → tạo mới.
/// Race-safe theo advise database-agent S61: UNIQUE filtered index = arbiter
/// cuối; thua race → DbUpdateException → detach + re-fetch record bên thắng;
/// lỗi KHÁC (không phải thua-race) → rethrow, KHÔNG nuốt.
/// </summary>
public static async Task<PeWorkItemBudget> EnsureTrackedAsync(
IApplicationDbContext db, Guid projectId, Guid workItemId, CancellationToken ct)
{
var existing = await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.ProjectId == projectId && b.WorkItemId == workItemId, ct);
if (existing is not null) return existing;
var rec = new PeWorkItemBudget { ProjectId = projectId, WorkItemId = workItemId };
db.PeWorkItemBudgets.Add(rec);
try
{
await db.SaveChangesAsync(ct);
return rec;
}
catch (DbUpdateException)
{
((DbContext)db).Entry(rec).State = EntityState.Detached;
var winner = await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.ProjectId == projectId && b.WorkItemId == workItemId, ct);
if (winner is null) throw; // không phải thua-race → lỗi thật phải nổi
return winner;
}
}
}
// ===== PRO — dự trù lần đầu + ghi chú =====
public record UpdatePeBudgetProCommand(
Guid PeId,
decimal? ProEstimateAmount,
string? ProNote) : IRequest;
public class UpdatePeBudgetProCommandValidator : AbstractValidator<UpdatePeBudgetProCommand>
{
public UpdatePeBudgetProCommandValidator()
{
RuleFor(x => x.ProEstimateAmount).GreaterThanOrEqualTo(0)
.When(x => x.ProEstimateAmount.HasValue);
RuleFor(x => x.ProNote).MaximumLength(1000);
}
}
public class UpdatePeBudgetProCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeBudgetProCommand>
{
public async Task Handle(UpdatePeBudgetProCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
// Fail-closed TRƯỚC mọi side-effect (kể cả auto-create record).
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.Procurement))
{
throw new ForbiddenException(
"Chỉ Phòng Cung ứng (PRO) hoặc Admin được nhập dự trù ngân sách gói thầu.");
}
if (pe.WorkItemId is not Guid workItemId)
throw new ConflictException(
"Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục trước khi nhập ngân sách gói thầu.");
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
var oldEstimate = rec.ProEstimateAmount;
var oldNote = rec.ProNote;
rec.ProEstimateAmount = request.ProEstimateAmount; // absolute-set (null = clear)
rec.ProNote = request.ProNote;
var parts = new List<string>();
if (oldEstimate != request.ProEstimateAmount)
parts.Add($"dự trù {oldEstimate?.ToString("N0") ?? "(trống)"}đ → {request.ProEstimateAmount?.ToString("N0") ?? "(trống)"}đ");
if (oldNote != request.ProNote)
parts.Add("ghi chú PRO cập nhật");
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Ngân sách gói thầu (PRO): {(parts.Count == 0 ? "không đi" : string.Join(", ", parts))}",
});
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — Ban hành lần đầu + V0/hiệu chỉnh (nhập thực tế trong khi duyệt) =====
public record UpdatePeBudgetCcmCommand(
Guid PeId,
decimal? InitialAmount,
decimal? AdjustmentAmount) : IRequest;
public class UpdatePeBudgetCcmCommandValidator : AbstractValidator<UpdatePeBudgetCcmCommand>
{
public UpdatePeBudgetCcmCommandValidator()
{
RuleFor(x => x.InitialAmount).GreaterThanOrEqualTo(0)
.When(x => x.InitialAmount.HasValue);
// AdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM.
}
}
public class UpdatePeBudgetCcmCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeBudgetCcmCommand>
{
public async Task Handle(UpdatePeBudgetCcmCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
{
throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập ngân sách ban hành/hiệu chỉnh.");
}
if (pe.WorkItemId is not Guid workItemId)
throw new ConflictException(
"Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục trước khi nhập ngân sách gói thầu.");
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
var oldInitial = rec.InitialAmount;
var oldAdjustment = rec.AdjustmentAmount;
rec.InitialAmount = request.InitialAmount; // absolute-set (null = clear)
rec.AdjustmentAmount = request.AdjustmentAmount;
var parts = new List<string>();
if (oldInitial != request.InitialAmount)
parts.Add($"ban hành lần đầu {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.InitialAmount?.ToString("N0") ?? "(trống)"}đ");
if (oldAdjustment != request.AdjustmentAmount)
parts.Add($"V0/hiệu chỉnh {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.AdjustmentAmount?.ToString("N0") ?? "(trống)"}đ");
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Ngân sách gói thầu (CCM): {(parts.Count == 0 ? "không đi" : string.Join(", ", parts))}",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -23,9 +23,7 @@ public record CreatePurchaseEvaluationCommand(
string? DiaDiem, string? DiaDiem,
string? MoTa, string? MoTa,
string? PaymentTerms, string? PaymentTerms,
Guid? BudgetId, decimal? BudgetPeriodAmount, // [S61 Mig 50] "Ngân sách - kỳ này" — drafter nhập, optional lúc tạo (guard lúc submit)
string? BudgetManualName,
decimal? BudgetManualAmount,
Guid? ApprovalWorkflowId = null, // [Mig 23] User chọn quy trình duyệt V2 lúc tạo Guid? ApprovalWorkflowId = null, // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
Guid? WorkItemId = null) : IRequest<Guid>; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty) Guid? WorkItemId = null) : IRequest<Guid>; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
@ -44,8 +42,10 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
.WithMessage("Phải chọn hạng mục công việc."); .WithMessage("Phải chọn hạng mục công việc.");
RuleFor(x => x.DiaDiem).MaximumLength(500); RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000); RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200); // [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue); RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
} }
} }
@ -93,19 +93,35 @@ public class CreatePurchaseEvaluationCommandHandler(
throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {request.Type}."); throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {request.Type}.");
} }
// Validate Budget link (nếu có): cùng Project + Phase=DaDuyet (chỉ cho // [S61 Mig 50] AUTO-CREATE PeWorkItemBudget cho cặp (ProjectId, WorkItemId)
// pick ngân sách đã duyệt mới được dùng làm reference đối chiếu). // nếu chưa có — 1 record/cặp dùng chung mọi phiếu cùng gói thầu. Race-safe:
decimal? linkedBudgetTotal = null; // pre-check AnyAsync + UNIQUE filtered index làm defense cuối; 2 phiếu cùng
if (request.BudgetId is Guid bid) // cặp tạo song song → catch DbUpdateException, detach record local + dùng
// record bên kia thắng race. SaveChanges RIÊNG trước khi add PE — insert
// record fail không lan sang phiếu.
if (request.WorkItemId is Guid wiBudget)
{ {
var bg = await db.Budgets.AsNoTracking() var pairExists = await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.Id == bid, ct) .AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
?? throw new NotFoundException("Budget", bid); if (!pairExists)
if (bg.ProjectId != request.ProjectId) {
throw new ConflictException("Ngân sách phải cùng dự án với phiếu."); var pairRec = new PeWorkItemBudget { ProjectId = request.ProjectId, WorkItemId = wiBudget };
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet) db.PeWorkItemBudgets.Add(pairRec);
throw new ConflictException("Chỉ link được ngân sách đã duyệt."); try
linkedBudgetTotal = bg.TongNganSach; {
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
((DbContext)db).Entry(pairRec).State = EntityState.Detached;
// [S61 advise database-agent] CHỈ nuốt khi đúng thua-race (record
// đã tồn tại từ request song song — UNIQUE filtered index arbiter).
// Lỗi khác (connection/constraint khác) phải nổi lên.
var nowExists = await db.PeWorkItemBudgets
.AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
if (!nowExists) throw;
}
}
} }
var entity = new PurchaseEvaluation var entity = new PurchaseEvaluation
@ -122,9 +138,7 @@ public class CreatePurchaseEvaluationCommandHandler(
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
ApprovalWorkflowId = request.ApprovalWorkflowId, // Mig 23 — schema mới V2 ApprovalWorkflowId = request.ApprovalWorkflowId, // Mig 23 — schema mới V2
PaymentTerms = request.PaymentTerms, PaymentTerms = request.PaymentTerms,
BudgetId = request.BudgetId, BudgetPeriodAmount = request.BudgetPeriodAmount, // S61 — "Ngân sách - kỳ này"
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
SlaDeadline = DateTime.UtcNow.Add( SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)), workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
}; };
@ -149,7 +163,8 @@ public class CreatePurchaseEvaluationCommandHandler(
// Auto-seed 1 Hạng mục mặc định lấy tên + giá trị từ gói thầu / ngân sách // Auto-seed 1 Hạng mục mặc định lấy tên + giá trị từ gói thầu / ngân sách
// — user yêu cầu Session 20: hạng mục là đơn vị làm việc chính, NCC expand // — user yêu cầu Session 20: hạng mục là đơn vị làm việc chính, NCC expand
// dưới hạng mục → cần có sẵn 1 row khi vào Detail. Có thể edit lại sau. // dưới hạng mục → cần có sẵn 1 row khi vào Detail. Có thể edit lại sau.
var defaultBudgetValue = linkedBudgetTotal ?? request.BudgetManualAmount ?? 0m; // [S61] nguồn giá trị = "Ngân sách - kỳ này" drafter nhập (Budget link cũ đã bỏ).
var defaultBudgetValue = request.BudgetPeriodAmount ?? 0m;
var defaultDetail = new PurchaseEvaluationDetail var defaultDetail = new PurchaseEvaluationDetail
{ {
PurchaseEvaluationId = entity.Id, PurchaseEvaluationId = entity.Id,
@ -190,12 +205,24 @@ public record UpdatePurchaseEvaluationDraftCommand(
string? DiaDiem, string? DiaDiem,
string? MoTa, string? MoTa,
string? PaymentTerms, string? PaymentTerms,
Guid? BudgetId, decimal? BudgetPeriodAmount = null, // [S61] null-safe: null = GIỮ giá trị cũ (chống null-hóa bug-class S42)
string? BudgetManualName, decimal? ExpectedRemainingAmount = null, // [S61] null-safe: null = GIỮ giá trị cũ
decimal? BudgetManualAmount,
Guid? ApprovalWorkflowId = null, // [Mig 23] cho User đổi quy trình khi sửa Nháp Guid? ApprovalWorkflowId = null, // [Mig 23] cho User đổi quy trình khi sửa Nháp
Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại
public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<UpdatePurchaseEvaluationDraftCommand>
{
public UpdatePurchaseEvaluationDraftCommandValidator()
{
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
}
}
public class UpdatePurchaseEvaluationDraftCommandHandler( public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand> ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand>
@ -225,18 +252,6 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {entity.Type}."); throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {entity.Type}.");
} }
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43). // [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43).
if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId) if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId)
{ {
@ -250,10 +265,13 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.DiaDiem = request.DiaDiem; entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa; entity.MoTa = request.MoTa;
entity.PaymentTerms = request.PaymentTerms; entity.PaymentTerms = request.PaymentTerms;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
entity.ApprovalWorkflowId = request.ApprovalWorkflowId; // Mig 23 — User đổi quy trình entity.ApprovalWorkflowId = request.ApprovalWorkflowId; // Mig 23 — User đổi quy trình
// [S61] null-safe 2 field ngân sách mới (mirror WorkItemId bên dưới):
// client không gửi → GIỮ giá trị cũ, KHÔNG null-hóa (bug-class S42).
if (request.BudgetPeriodAmount is not null)
entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
if (request.ExpectedRemainingAmount is not null)
entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;
// Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị. // Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị.
// Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên // Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên
// (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker). // (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker).
@ -286,18 +304,22 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
// DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem, // DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem,
// MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt. // MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt.
// AdjustBudget chỉ adjust Budget* — narrow scope hợp lý. // AdjustBudget chỉ adjust Budget* — narrow scope hợp lý.
// [S61 Mig 50] Body đổi sang 2 field mới (BudgetId/BudgetManual* DROP cùng module
// Budget cũ). ABSOLUTE-SET cả 2 (FE bảng "Tổng hợp ngân sách" gửi cả 2 giá trị
// state mỗi lần Lưu — null = clear; ExpectedRemaining null → FE hiển thị default
// = NS còn lại row 7). Actor guard GIỮ NGUYÊN 3-scope: Drafter (Nháp/Trả lại) ·
// Approver đúng cấp khi ChoDuyet + AllowApproverEditBudget · Admin mọi lúc.
public record AdjustPurchaseEvaluationBudgetCommand( public record AdjustPurchaseEvaluationBudgetCommand(
Guid Id, Guid Id,
Guid? BudgetId, decimal? BudgetPeriodAmount,
string? BudgetManualName, decimal? ExpectedRemainingAmount) : IRequest;
decimal? BudgetManualAmount) : IRequest;
public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<AdjustPurchaseEvaluationBudgetCommand> public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<AdjustPurchaseEvaluationBudgetCommand>
{ {
public AdjustPurchaseEvaluationBudgetCommandValidator() public AdjustPurchaseEvaluationBudgetCommandValidator()
{ {
RuleFor(x => x.BudgetManualName).MaximumLength(200); RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue);
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue); RuleFor(x => x.ExpectedRemainingAmount).GreaterThanOrEqualTo(0).When(x => x.ExpectedRemainingAmount.HasValue);
} }
} }
@ -371,44 +393,26 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
actorTag = "[Admin]"; actorTag = "[Admin]";
} }
// Validate Budget link nếu thay đổi // Capture old + apply (absolute-set cả 2 — xem comment record)
if (request.BudgetId is Guid bid && bid != entity.BudgetId) var oldPeriod = entity.BudgetPeriodAmount;
{ var oldExpected = entity.ExpectedRemainingAmount;
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// Capture old + apply entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
var oldBudgetId = entity.BudgetId; entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;
var oldBudgetManualName = entity.BudgetManualName;
var oldBudgetManualAmount = entity.BudgetManualAmount;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
// Audit changelog with diff (Vietnamese friendly format) // Audit changelog with diff (Vietnamese friendly format)
var parts = new List<string>(); var parts = new List<string>();
if (oldBudgetId != request.BudgetId) if (oldPeriod != request.BudgetPeriodAmount)
{ {
var oldDesc = oldBudgetId is null ? "(chưa link)" : "Budget#" + oldBudgetId.Value.ToString()[..8]; var oldAmt = oldPeriod?.ToString("N0") ?? "(trống)";
var newDesc = request.BudgetId is null ? "(huỷ link)" : "Budget#" + request.BudgetId.Value.ToString()[..8]; var newAmt = request.BudgetPeriodAmount?.ToString("N0") ?? "(trống)";
parts.Add($"link {oldDesc} → {newDesc}"); parts.Add($"NS kỳ này {oldAmt}đ → {newAmt}đ");
} }
if (oldBudgetManualName != request.BudgetManualName) if (oldExpected != request.ExpectedRemainingAmount)
{ {
parts.Add($"tên \"{oldBudgetManualName ?? "(trng)"}\" → \"{request.BudgetManualName ?? "(trống)"}\""); var oldAmt = oldExpected?.ToString("N0") ?? "(mặc định = NS còn lại)";
} var newAmt = request.ExpectedRemainingAmount?.ToString("N0") ?? "(mặc định = NS còn lại)";
if (oldBudgetManualAmount != request.BudgetManualAmount) parts.Add($"dự kiến còn lại {oldAmt}đ → {newAmt}đ");
{
var oldAmt = oldBudgetManualAmount?.ToString("N0") ?? "(trống)";
var newAmt = request.BudgetManualAmount?.ToString("N0") ?? "(trống)";
parts.Add($"số tiền {oldAmt}đ → {newAmt}đ");
} }
var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts); var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts);
@ -571,7 +575,8 @@ public class ListPurchaseEvaluationsQueryHandler(
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null, x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt, x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null, x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null)) x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize); return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
@ -660,7 +665,8 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null, x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt, x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null, x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null)) x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount))
.Take(100) .Take(100)
.ToListAsync(ct); .ToListAsync(ct);
} }
@ -753,15 +759,73 @@ public class GetPurchaseEvaluationQueryHandler(
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, 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); var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
// Load Budget summary nếu có link // [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt —
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null; // record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp (ProjectId,
if (e.BudgetId is Guid budgetId) // WorkItemId) + lũy kế các phiếu TRƯỚC (CreatedAt < this). Phiếu cũ chưa
// gắn Hạng mục (WorkItemId null) → null → FE banner nhắc gắn hạng mục.
PeBudgetSummaryDto? peBudgetSummary = null;
if (e.WorkItemId is Guid wiKey)
{ {
budgetSummary = await db.Budgets.AsNoTracking() var canEditPro = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
.Where(b => b.Id == budgetId) var canEditCcm = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach)) var pairRec = await db.PeWorkItemBudgets.AsNoTracking()
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey, ct);
// Lũy kế phiếu TRƯỚC cùng cặp. Row 1 = đã trình (ChoDuyet + DaDuyet);
// Row 2 = đã chọn thầu (DaDuyet + có đơn vị thắng). TraLai/DangSoanThao
// KHÔNG tính (quay về soạn = chưa trình).
var peers = db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ProjectId == e.ProjectId && p.WorkItemId == wiKey
&& p.Id != e.Id && p.CreatedAt < e.CreatedAt);
var submitted = await peers
.Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
|| p.Phase == PurchaseEvaluationPhase.DaDuyet)
.Select(p => p.BudgetPeriodAmount)
.ToListAsync(ct);
var prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
&& p.SelectedSupplierId != null);
var prevSelectedCount = await selectedPeers.CountAsync(ct);
var prevSelectedTotal = await (
from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId
where s.SupplierId == p.SelectedSupplierId
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
// Row 4 "Giá trị kỳ này" = tổng giá chào của đơn vị ĐƯỢC CHỌN phiếu này
// (mirror predicate submit-guard WorkflowService — winner quote total).
var currentProposalTotal = 0m;
if (e.SelectedSupplierId is Guid winId)
{
var winnerRowIds = e.Suppliers.Where(s => s.SupplierId == winId)
.Select(s => s.Id).ToList();
currentProposalTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
}
// Full = CCM (Initial + Adjustment); CCM chưa nhập gì → fallback dự trù
// PRO với cờ FullIsEstimate (FE badge "dự trù PRO").
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
var fullAmount = hasCcm
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
: (pairRec?.ProEstimateAmount ?? 0m);
peBudgetSummary = new PeBudgetSummaryDto(
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
pairRec?.InitialAmount, pairRec?.AdjustmentAmount,
fullAmount, !hasCcm,
canEditPro, canEditCcm,
prevSubmittedTotal, prevSubmittedCount,
prevSelectedTotal, prevSelectedCount,
currentProposalTotal);
} }
// Load supplier names for PE suppliers + approver names // Load supplier names for PE suppliers + approver names
@ -967,8 +1031,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.SelectedSupplierId, selectedSupplier?.Name, e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId, e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary, e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
e.BudgetManualName, e.BudgetManualAmount,
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions, e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
currentApproval, approvalFlow, currentApproval, approvalFlow,
e.Suppliers e.Suppliers

View File

@ -1,34 +0,0 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// Aggregate root quản lý ngân sách. Gắn với Project (required), reference
// từ PurchaseEvaluation + Contract (cả 2 nullable FK Budget.Id).
//
// Workflow đơn giản 3-step: Drafter → CCM → CEO. Pattern hardcoded trong
// BudgetPolicy (chưa versioned, tương lai có thể thêm BudgetWorkflowDefinition
// nếu cần admin config).
public class Budget : AuditableEntity
{
public string? MaNganSach { get; set; } // Auto-gen NS-YYYYMM-XXXX
public string TenNganSach { get; set; } = string.Empty;
public string? Description { get; set; }
public int NamNganSach { get; set; } // Năm áp dụng (vd 2026)
public Guid ProjectId { get; set; } // FK Projects (required)
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; }
public BudgetPhase Phase { get; set; } = BudgetPhase.DangSoanThao;
public decimal TongNganSach { get; set; } // Tổng = sum BudgetDetails.ThanhTien (computed)
public DateTime? SlaDeadline { get; set; }
public bool SlaWarningSent { get; set; }
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao.
public BudgetPhase? RejectedFromPhase { get; set; }
public List<BudgetDetail> Details { get; set; } = new();
public List<BudgetApproval> Approvals { get; set; } = new();
public List<BudgetChangelog> Changelogs { get; set; } = new();
public List<BudgetDepartmentApproval> DepartmentApprovals { get; set; } = new();
}

View File

@ -1,17 +0,0 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ApprovalDecision
namespace SolutionErp.Domain.Budgets;
public class BudgetApproval : BaseEntity
{
public Guid BudgetId { get; set; }
public BudgetPhase FromPhase { get; set; }
public BudgetPhase 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 Budget? Budget { get; set; }
}

View File

@ -1,28 +0,0 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ChangelogAction
namespace SolutionErp.Domain.Budgets;
// Audit log unified cho mọi thay đổi trên ngân sách.
public class BudgetChangelog : BaseEntity
{
public Guid BudgetId { get; set; }
public Budget? Budget { get; set; }
public BudgetEntityType EntityType { get; set; }
public Guid? EntityId { get; set; }
public ChangelogAction Action { get; set; }
public BudgetPhase? 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 BudgetEntityType
{
Header = 1,
Detail = 2,
Workflow = 3,
}

View File

@ -1,20 +0,0 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// 2-stage department approval cho Budget workflow (Phase 9 — Migration 16).
// Mirror schema ContractDepartmentApproval / PurchaseEvaluationDepartmentApproval.
public class BudgetDepartmentApproval : AuditableEntity
{
public Guid BudgetId { get; set; }
public int PhaseAtApproval { get; set; } // snapshot BudgetPhase int
public Guid DepartmentId { get; set; }
public ApprovalStage Stage { get; set; } // 1=Review (NV), 2=Confirm (TPB)
public Guid ApproverUserId { get; set; }
public string? ApproverRoleSnapshot { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public bool IsBypassed { get; set; }
public Budget? Budget { get; set; }
}

View File

@ -1,22 +0,0 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// Chi tiết ngân sách — pattern flat row giống PurchaseEvaluationDetail.
// Group A.I/A.II/... cho hạng mục cha, NoiDung cho item con.
public class BudgetDetail : BaseEntity
{
public Guid BudgetId { get; set; }
public string GroupCode { get; set; } = string.Empty; // "A.I", "A.II", ...
public string GroupName { get; set; } = string.Empty; // "Bê tông", "Phụ gia", ...
public string? ItemCode { get; set; }
public string NoiDung { get; set; } = string.Empty;
public string? DonViTinh { get; set; }
public decimal KhoiLuong { get; set; }
public decimal DonGia { get; set; }
public decimal ThanhTien { get; set; } // = KhoiLuong × DonGia (hoặc nhập tay)
public int Order { get; set; }
public string? GhiChu { get; set; }
public Budget? Budget { get; set; }
}

View File

@ -1,17 +0,0 @@
namespace SolutionErp.Domain.Budgets;
// State machine ngân sách — Session 17 spec mới (5 trạng thái mirror PE/HĐ):
// DangSoanThao (Nháp) → ChoCCM (Drafter trình)
// TraLai (Trả lại) → ChoCCM (Drafter sửa+gửi lại, chạy từ đầu)
// ChoCCM/ChoCEO → next phase OR TraLai OR TuChoi
// ChoCEO → DaDuyet (terminal)
// DangSoanThao/TraLai → TuChoi (Drafter huỷ)
public enum BudgetPhase
{
DangSoanThao = 1,
ChoCCM = 2,
ChoCEO = 3,
DaDuyet = 4,
TraLai = 98, // Phase riêng (không revert DangSoanThao)
TuChoi = 99,
}

View File

@ -1,71 +0,0 @@
using SolutionErp.Domain.Identity;
namespace SolutionErp.Domain.Budgets;
// Policy hardcoded đơn giản — chưa versioned (theo user "tạm thời simple
// default"). Tương lai nếu admin cần config qua UI: thêm BudgetWorkflow
// Definition tables tương tự PE workflow.
public sealed record BudgetPolicy(
string Name,
string Description,
IReadOnlyDictionary<(BudgetPhase From, BudgetPhase To), string[]> Transitions,
IReadOnlyDictionary<BudgetPhase, TimeSpan?> PhaseSla,
IReadOnlyList<BudgetPhase> ActivePhases)
{
public bool HasPhase(BudgetPhase phase) => ActivePhases.Contains(phase);
public bool IsTransitionAllowed(
BudgetPhase from, BudgetPhase to,
IReadOnlyList<string> actorRoles)
{
if (!Transitions.TryGetValue((from, to), out var roles)) return false;
return actorRoles.Any(r => roles.Contains(r));
}
public IReadOnlyList<BudgetPhase> NextPhasesFrom(BudgetPhase from) =>
Transitions.Keys.Where(k => k.From == from).Select(k => k.To).Distinct().ToList();
}
public static class BudgetPolicies
{
private static readonly Dictionary<BudgetPhase, TimeSpan?> DefaultSla = new()
{
[BudgetPhase.DangSoanThao] = TimeSpan.FromDays(5),
[BudgetPhase.TraLai] = TimeSpan.FromDays(5),
[BudgetPhase.ChoCCM] = TimeSpan.FromDays(3),
[BudgetPhase.ChoCEO] = TimeSpan.FromDays(2),
[BudgetPhase.DaDuyet] = null,
[BudgetPhase.TuChoi] = null,
};
// Session 17 spec: Reject = về TraLai (Phase riêng). Drafter từ TraLai
// gửi lại = entry point thứ 2 (mirror DangSoanThao → ChoCCM).
public static readonly BudgetPolicy Default = new(
Name: "Default",
Description: "Quy trình ngân sách 3-step (Drafter → CCM → CEO).",
Transitions: new Dictionary<(BudgetPhase, BudgetPhase), string[]>
{
[(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.DangSoanThao, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.TraLai, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.TraLai, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.TraLai)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.TuChoi)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
BudgetPhase.DangSoanThao,
BudgetPhase.TraLai,
BudgetPhase.ChoCCM,
BudgetPhase.ChoCEO,
BudgetPhase.DaDuyet,
BudgetPhase.TuChoi,
]);
}

View File

@ -24,10 +24,10 @@ public class Contract : AuditableEntity
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
public string? DraftData { get; set; } // JSON field values (render template) public string? DraftData { get; set; } // JSON field values (render template)
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public Guid? BudgetId { get; set; } // Reference Budget (Phase 7) — đối chiếu chi phí HĐ vs ngân sách
// Manual budget fields (Phase 9 — Migration 17 mirror PE): user nhập tay khi // Manual budget fields (Phase 9 — Migration 17): user nhập tay tham chiếu
// KHÔNG link Budget entity. Cả 2 cùng null OK. KHÔNG XOR validate với BudgetId. // ngân sách. [S61 Mig 50] BudgetId (FK module Budget cũ) đã DROP cùng module —
// 2 field manual GIỮ NGUYÊN (HĐ không đổi theo spec anh Kiệt S61).
public string? BudgetManualName { get; set; } // Tên tham chiếu public string? BudgetManualName { get; set; } // Tên tham chiếu
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ) public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ)

View File

@ -67,14 +67,10 @@ public static class MenuKeys
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới public const string ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"; // leaf cho Duyệt NCC và Giải pháp mới
// ============================================================ // [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_List/Bg_Create/Bg_Pending)
// Module Ngân sách (Phase 7) — 4 bng quản lý ngân sách dự án/gói thầu. // đã XÓA — thay bng "Ngân sách gói thầu" nhúng trong phiếu PE
// 1 root + 3 leaf action (Danh sách / Thao tác / Duyệt). // (PeWorkItemBudgets per cặp Dự án × Hạng mục). Menu/Permission rows cũ
// ============================================================ // cleanup qua SQL trong migration ReplaceBudgetModuleWithPeWorkItemBudgets.
public const string Budgets = "Budgets";
public const string BudgetList = "Bg_List";
public const string BudgetCreate = "Bg_Create";
public const string BudgetPending = "Bg_Pending";
// ============================================================ // ============================================================
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33 2026-05-26). // Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33 2026-05-26).
@ -154,7 +150,6 @@ public static class MenuKeys
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems, Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
Contracts, Forms, Reports, Contracts, Forms, Reports,
PurchaseEvaluations, PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1 Hrm, HrmHoSo, // Mig 34 — Phase 10.1
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2 HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
HrmConfigVehicles, HrmConfigDrivers, // Mig 44 — Phase 11 P11-C HrmConfigVehicles, HrmConfigDrivers, // Mig 44 — Phase 11 P11-C

View File

@ -0,0 +1,31 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// [S61 2026-06-12] Ngân sách gói thầu theo cặp (Dự án, Hạng mục công việc) —
// thay module Budget cũ (5 bảng, xóa cùng Mig 50). 1 record DUY NHẤT per cặp
// (UNIQUE composite filtered [IsDeleted] = 0 — gotcha #57), mọi phiếu PE cùng
// cặp dùng chung. Auto-create khi tạo phiếu PE đầu tiên của cặp.
//
// Loose-Guid convention PE (giống PE.ProjectId/WorkItemId/SelectedSupplierId):
// KHÔNG FK vật lý, KHÔNG navigation property.
//
// Quyền nhập theo ROLE (anh Kiệt chốt S61):
// - PRO (Procurement): ProEstimateAmount (dự trù lần đầu) + ProNote.
// - CCM (CostControl): InitialAmount (Ban hành lần đầu) + AdjustmentAmount
// (NS V0 hiệu chỉnh tăng/giảm — cho phép ÂM).
//
// "Ngân sách full gói thầu" KHÔNG lưu cột — BE compute:
// full = (InitialAmount ?? 0) + (AdjustmentAmount ?? 0);
// cả Initial + Adjustment đều null → fallback ProEstimateAmount ?? 0
// với cờ fullIsEstimate=true (FE badge "dự trù PRO").
public class PeWorkItemBudget : AuditableEntity
{
public Guid ProjectId { get; set; } // loose-Guid Projects.Id
public Guid WorkItemId { get; set; } // loose-Guid WorkItems.Id
public decimal? ProEstimateAmount { get; set; } // PRO dự trù lần đầu (đ)
public string? ProNote { get; set; } // "Ghi chú từ PRO"
public decimal? InitialAmount { get; set; } // CCM "Ngân sách Ban hành lần đầu" (đ)
public decimal? AdjustmentAmount { get; set; } // CCM "NS V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
}

View File

@ -28,15 +28,16 @@ public class PurchaseEvaluation : AuditableEntity
public string? PaymentTerms { get; set; } // JSON {tamUng, thanhToanTam, quyetToan, baoHanh, hanMucCongNo, danhGia} 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 Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu
public Guid? BudgetId { get; set; } // FK Budget (Phase 7) — đối chiếu báo giá vs ngân sách gói thầu
// Manual budget fields (Phase 9 — Migration 17): user nhập tay khi KHÔNG link // [S61 Mig 50] Ngân sách PE theo Excel anh Kiệt — thay BudgetId/BudgetManual*
// Budget entity (vd ngân sách chưa approve hoặc gói nhỏ ko cần workflow ngân // cũ (drop cùng Mig 50, data BudgetManualAmount migrate → BudgetPeriodAmount).
// sách riêng). Cả 2 cùng null OK (PE chưa có ngân sách gì cả). KHÔNG validate // - BudgetPeriodAmount: "Ngân sách - kỳ này" (row 3) — NGƯỜI SOẠN nhập khi
// XOR với BudgetId — tạm thời cho phép cả 2 cùng có (BE prefer BudgetId nếu // soạn phiếu. Submit guard yêu cầu > 0.
// set vì có Phase=DaDuyet guarantee, manual chỉ là fallback hiển thị). // - ExpectedRemainingAmount: "Giá trị thực hiện dự kiến còn lại" (row 8) —
public string? BudgetManualName { get; set; } // Tên tham chiếu vd "Tạm tính dự toán T11/2025" // tự nhập; null = FE default theo NS còn lại (row 7).
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ) // Ngân sách FULL gói thầu nằm ở PeWorkItemBudget (per cặp Project × WorkItem).
public decimal? BudgetPeriodAmount { get; set; }
public decimal? ExpectedRemainingAmount { get; set; }
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter // Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự. // sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.

View File

@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details; using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms; using SolutionErp.Domain.Forms;
@ -69,19 +68,14 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>(); public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic // Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic
public DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions => Set<PurchaseEvaluationLevelOpinion>(); public DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions => Set<PurchaseEvaluationLevelOpinion>();
// [S61 Mig 50] Ngân sách gói thầu per cặp (ProjectId, WorkItemId) — thay module Budget cũ.
public DbSet<PeWorkItemBudget> PeWorkItemBudgets => Set<PeWorkItemBudget>();
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT. // Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>(); public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();
public DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps => Set<ApprovalWorkflowStep>(); public DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps => Set<ApprovalWorkflowStep>();
public DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels => Set<ApprovalWorkflowLevel>(); public DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels => Set<ApprovalWorkflowLevel>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>();
public DbSet<BudgetApproval> BudgetApprovals => Set<BudgetApproval>();
public DbSet<BudgetChangelog> BudgetChangelogs => Set<BudgetChangelog>();
public DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals => Set<BudgetDepartmentApproval>();
// Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup. // Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup.
public DbSet<EmployeeProfile> EmployeeProfiles => Set<EmployeeProfile>(); public DbSet<EmployeeProfile> EmployeeProfiles => Set<EmployeeProfile>();
public DbSet<EmployeeWorkHistory> EmployeeWorkHistories => Set<EmployeeWorkHistory>(); public DbSet<EmployeeWorkHistory> EmployeeWorkHistories => Set<EmployeeWorkHistory>();

View File

@ -1,90 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Budgets;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
{
public void Configure(EntityTypeBuilder<Budget> b)
{
b.ToTable("Budgets");
b.HasKey(x => x.Id);
b.Property(x => x.MaNganSach).HasMaxLength(100);
b.Property(x => x.TenNganSach).HasMaxLength(500).IsRequired();
b.Property(x => x.Description).HasMaxLength(2000);
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.RejectedFromPhase).HasConversion<int?>();
b.Property(x => x.TongNganSach).HasPrecision(18, 2);
b.HasIndex(x => x.MaNganSach).IsUnique().HasFilter("[MaNganSach] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.NamNganSach);
b.HasIndex(x => x.SlaDeadline);
b.HasMany(x => x.Details).WithOne(d => d.Budget).HasForeignKey(d => d.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Approvals).WithOne(a => a.Budget).HasForeignKey(a => a.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Changelogs).WithOne(c => c.Budget).HasForeignKey(c => c.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}
public class BudgetDetailConfiguration : IEntityTypeConfiguration<BudgetDetail>
{
public void Configure(EntityTypeBuilder<BudgetDetail> b)
{
b.ToTable("BudgetDetails");
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.KhoiLuong).HasPrecision(18, 4);
b.Property(x => x.DonGia).HasPrecision(18, 2);
b.Property(x => x.ThanhTien).HasPrecision(18, 2);
b.HasIndex(x => new { x.BudgetId, x.Order });
}
}
public class BudgetApprovalConfiguration : IEntityTypeConfiguration<BudgetApproval>
{
public void Configure(EntityTypeBuilder<BudgetApproval> b)
{
b.ToTable("BudgetApprovals");
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.BudgetId, x.ApprovedAt });
}
}
public class BudgetChangelogConfiguration : IEntityTypeConfiguration<BudgetChangelog>
{
public void Configure(EntityTypeBuilder<BudgetChangelog> b)
{
b.ToTable("BudgetChangelogs");
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.BudgetId, x.CreatedAt });
b.HasIndex(x => new { x.BudgetId, x.EntityType });
}
}

View File

@ -27,7 +27,6 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
b.HasIndex(x => x.SupplierId); b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.ProjectId); b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline); b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.BudgetId);
b.HasIndex(x => x.ApprovalWorkflowId); b.HasIndex(x => x.ApprovalWorkflowId);
// FK ApprovalWorkflowId Restrict (Plan B Chunk A2 — Mig 32 mirror PE Mig 23) // FK ApprovalWorkflowId Restrict (Plan B Chunk A2 — Mig 32 mirror PE Mig 23)

View File

@ -1,19 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations; namespace SolutionErp.Infrastructure.Persistence.Configurations;
// 3 bảng *DepartmentApprovals (Phase 9 — Migration 16) — 2-stage approval per // 2 bảng *DepartmentApprovals (Phase 9 — Migration 16) — 2-stage approval per
// phase × phòng ban cho HĐ / PE / Budget. // phase × phòng ban cho HĐ / PE. (Budget variant XÓA cùng module Budget cũ —
// S61 Mig 50.)
// //
// UNIQUE (TargetId, PhaseAtApproval, DepartmentId, Stage) — 1 phase × 1 phòng // UNIQUE (TargetId, PhaseAtApproval, DepartmentId, Stage) — 1 phase × 1 phòng
// × 1 stage = 1 row duy nhất. UPDATE in-place khi user đổi ý → audit qua // × 1 stage = 1 row duy nhất. UPDATE in-place khi user đổi ý → audit qua
// *Changelog. // *Changelog.
// //
// FK Cascade theo target (xóa HĐ/PE/Budget → xóa approval rows). // FK Cascade theo target (xóa HĐ/PE → xóa approval rows).
public class ContractDepartmentApprovalConfiguration public class ContractDepartmentApprovalConfiguration
: IEntityTypeConfiguration<ContractDepartmentApproval> : IEntityTypeConfiguration<ContractDepartmentApproval>
@ -70,29 +70,3 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
} }
public class BudgetDepartmentApprovalConfiguration
: IEntityTypeConfiguration<BudgetDepartmentApproval>
{
public void Configure(EntityTypeBuilder<BudgetDepartmentApproval> b)
{
b.ToTable("BudgetDepartmentApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.Stage).HasConversion<int>();
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.BudgetId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
b.HasIndex(x => x.BudgetId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
b.HasOne(x => x.Budget)
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.BudgetId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// [S61 Mig 50] Ngân sách gói thầu per cặp (ProjectId, WorkItemId) — thay module
// Budget cũ. Loose-Guid convention PE: KHÔNG FK vật lý, KHÔNG navigation.
// UNIQUE composite FILTERED [IsDeleted] = 0 day-1 (gotcha #57 — soft-delete +
// UNIQUE phải filter, filter string byte-for-byte khớp 13 index filtered hiện có).
public class PeWorkItemBudgetConfiguration : IEntityTypeConfiguration<PeWorkItemBudget>
{
public void Configure(EntityTypeBuilder<PeWorkItemBudget> b)
{
b.ToTable("PeWorkItemBudgets");
b.HasKey(x => x.Id);
// Precision match BudgetManualAmount cũ (18,2).
b.Property(x => x.ProEstimateAmount).HasPrecision(18, 2);
b.Property(x => x.InitialAmount).HasPrecision(18, 2);
b.Property(x => x.AdjustmentAmount).HasPrecision(18, 2);
b.Property(x => x.ProNote).HasMaxLength(1000);
b.HasIndex(x => new { x.ProjectId, x.WorkItemId })
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.HasIndex(x => x.WorkItemId);
b.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@ -19,8 +19,9 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.DiaDiem).HasMaxLength(500); b.Property(x => x.DiaDiem).HasMaxLength(500);
b.Property(x => x.MoTa).HasMaxLength(2000); b.Property(x => x.MoTa).HasMaxLength(2000);
b.Property(x => x.PaymentTerms).HasColumnType("nvarchar(max)"); b.Property(x => x.PaymentTerms).HasColumnType("nvarchar(max)");
b.Property(x => x.BudgetManualName).HasMaxLength(200); // [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2).
b.Property(x => x.BudgetManualAmount).HasPrecision(18, 2); b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2);
b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL"); b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted }); b.HasIndex(x => new { x.Phase, x.IsDeleted });
@ -32,7 +33,6 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.HasIndex(x => x.WorkflowDefinitionId); b.HasIndex(x => x.WorkflowDefinitionId);
b.HasIndex(x => x.ApprovalWorkflowId); b.HasIndex(x => x.ApprovalWorkflowId);
b.HasIndex(x => x.ContractId); b.HasIndex(x => x.ContractId);
b.HasIndex(x => x.BudgetId);
// FK ApprovalWorkflowId Restrict (Mig 23 Session 17) — schema mới // FK ApprovalWorkflowId Restrict (Mig 23 Session 17) — schema mới
// ApprovalWorkflowsV2 pin lúc create. Restrict để KHÔNG xóa workflow // ApprovalWorkflowsV2 pin lúc create. Restrict để KHÔNG xóa workflow

View File

@ -1797,11 +1797,8 @@ public static class DbInitializer
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"), (MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"), (MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
(MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"), (MenuKeys.ApprovalWorkflowDuyetNccPhuongAnV2, "Duyệt NCC và Giải pháp (Mới)", MenuKeys.ApprovalWorkflowsV2, 2, "FileCheck"),
// Module Ngân sách (Phase 7) // [S61 Mig 50] Menu module Ngân sách cũ (Budgets + 3 leaf Bg_*) đã XÓA —
(MenuKeys.Budgets, "Ngân sách", null, 27, "Wallet"), // rows cũ trên DB cleanup qua SQL trong migration (idempotent DELETE).
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR. // Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). Root operational HR.
// [S57] "Cấu hình HRM" re-parent sang "Danh mục" (Master) — gom config 1 chỗ. // [S57] "Cấu hình HRM" re-parent sang "Danh mục" (Master) — gom config 1 chỗ.
// Hrm còn: Dashboard(1) → Hồ sơ(2), Dashboard đầu nhóm (khớp Puro). // Hrm còn: Dashboard(1) → Hồ sơ(2), Dashboard đầu nhóm (khớp Puro).
@ -2031,7 +2028,7 @@ public static class DbInitializer
// [S57] Mở quyền XEM (Read-only) cho TẤT CẢ role để mọi bộ phận review/góp ý // [S57] Mở quyền XEM (Read-only) cho TẤT CẢ role để mọi bộ phận review/góp ý
// các module HRM + Văn phòng số + Danh mục (master). KHÔNG đụng Duyệt NCC // các module HRM + Văn phòng số + Danh mục (master). KHÔNG đụng Duyệt NCC
// (Pe_*/PeWf_*/AwV2 — sắp go-live, giữ phân quyền cũ), Contracts/Budgets/System. // (Pe_*/PeWf_*/AwV2 — sắp go-live, giữ phân quyền cũ), Contracts/System.
// [S58] Scope grant THU HẸP còn Master/Catalogs/Pe_* — xem note trong method. // [S58] Scope grant THU HẸP còn Master/Catalogs/Pe_* — xem note trong method.
await SeedAllRolesReviewReadPermissionsAsync(db, roleManager, logger); await SeedAllRolesReviewReadPermissionsAsync(db, roleManager, logger);

View File

@ -0,0 +1,419 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ReplaceBudgetModuleWithPeWorkItemBudgets : Migration
{
/// <inheritdoc />
// [S61 Mig 50] Reorder TAY theo advise database-agent (scaffold mặc định
// drop-trước-add; RIÊNG RenameColumn EF tự đoán BudgetManualAmount →
// ExpectedRemainingAmount là SAI SEMANTICS — số nhập tay cũ phải migrate
// sang BudgetPeriodAmount "NS kỳ này" row 3, không phải "dự kiến còn lại"
// row 8): (1) AddColumn mới → (2) Sql UPDATE backfill (4 phiếu UAT thật
// PE/2026/A/001+ GIỮ SỐ) → (3) DropIndex trước DropColumn (SQL 5074) →
// (4) DropColumn cũ → (5) DropTable 5 bảng children-first (4 FK đều khai
// trên child, CASCADE — không cần DropForeignKey riêng) → (6) CreateTable
// mới → (7) Sql DELETE menu/permission (IN-list tường minh — LIKE 'Bg_%'
// dính bẫy underscore-wildcard T-SQL + miss root 'Budgets').
// Precedent data-migrate cùng shape: 20260513130144 (add→backfill→drop).
protected override void Up(MigrationBuilder migrationBuilder)
{
// (1) 2 cột ngân sách mới trên PE
migrationBuilder.AddColumn<decimal>(
name: "BudgetPeriodAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ExpectedRemainingAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
// (2) Backfill: số ngân sách nhập tay cũ = "NS kỳ này" của phiếu
migrationBuilder.Sql(
"UPDATE [PurchaseEvaluations] SET [BudgetPeriodAmount] = [BudgetManualAmount] WHERE [BudgetManualAmount] IS NOT NULL;");
// (3) DropIndex TRƯỚC DropColumn (SQL Server error 5074 nếu ngược)
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluations_BudgetId",
table: "PurchaseEvaluations");
migrationBuilder.DropIndex(
name: "IX_Contracts_BudgetId",
table: "Contracts");
// (4) Drop cột cũ (PE 3 cột; Contracts CHỈ BudgetId — BudgetManual*
// của HĐ GIỮ NGUYÊN, anh Kiệt chỉ redesign ngân sách PE)
migrationBuilder.DropColumn(
name: "BudgetId",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetManualName",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetManualAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetId",
table: "Contracts");
// (5) Drop 5 bảng module Budget cũ — children-first
migrationBuilder.DropTable(
name: "BudgetApprovals");
migrationBuilder.DropTable(
name: "BudgetChangelogs");
migrationBuilder.DropTable(
name: "BudgetDepartmentApprovals");
migrationBuilder.DropTable(
name: "BudgetDetails");
migrationBuilder.DropTable(
name: "Budgets");
migrationBuilder.CreateTable(
name: "PeWorkItemBudgets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WorkItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProEstimateAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
ProNote = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
InitialAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
AdjustmentAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, 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_PeWorkItemBudgets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PeWorkItemBudgets_ProjectId_WorkItemId",
table: "PeWorkItemBudgets",
columns: new[] { "ProjectId", "WorkItemId" },
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_PeWorkItemBudgets_WorkItemId",
table: "PeWorkItemBudgets",
column: "WorkItemId");
// (7) Dọn menu + permission module cũ. IN-list tường minh (advise
// database-agent: Permissions key theo MenuKey string FK CASCADE →
// MenuItems; MenuItems self-FK ParentKey RESTRICT → children TRƯỚC
// root SAU). DELETE idempotent tự nhiên.
migrationBuilder.Sql(
"DELETE FROM [Permissions] WHERE [MenuKey] IN ('Budgets','Bg_List','Bg_Create','Bg_Pending');");
migrationBuilder.Sql(
"DELETE FROM [MenuItems] WHERE [Key] IN ('Bg_List','Bg_Create','Bg_Pending');");
migrationBuilder.Sql(
"DELETE FROM [MenuItems] WHERE [Key] = 'Budgets';");
}
/// <inheritdoc />
// [S61] Down tái tạo SCHEMA đầy đủ (5 bảng + FK + index + cột cũ) nhưng
// KHÔNG khôi phục data Budget cũ + menu/permission rows đã DELETE —
// (data-loss one-way chấp nhận, anh Kiệt chốt "xóa hẳn module").
// BudgetPeriodAmount backfill ngược về BudgetManualAmount để số phiếu
// không mất khi rollback.
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PeWorkItemBudgets");
migrationBuilder.AddColumn<decimal>(
name: "BudgetManualAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.Sql(
"UPDATE [PurchaseEvaluations] SET [BudgetManualAmount] = [BudgetPeriodAmount] WHERE [BudgetPeriodAmount] IS NOT NULL;");
migrationBuilder.DropColumn(
name: "BudgetPeriodAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ExpectedRemainingAmount",
table: "PurchaseEvaluations");
migrationBuilder.AddColumn<Guid>(
name: "BudgetId",
table: "PurchaseEvaluations",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BudgetManualName",
table: "PurchaseEvaluations",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "BudgetId",
table: "Contracts",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "Budgets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
MaNganSach = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
NamNganSach = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RejectedFromPhase = table.Column<int>(type: "int", nullable: true),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
TenNganSach = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
TongNganSach = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Budgets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BudgetApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Decision = table.Column<int>(type: "int", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetApprovals", x => x.Id);
table.ForeignKey(
name: "FK_BudgetApprovals_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BudgetChangelogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Action = table.Column<int>(type: "int", nullable: false),
ContextNote = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
EntityType = table.Column<int>(type: "int", nullable: false),
FieldChangesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhaseAtChange = table.Column<int>(type: "int", nullable: true),
Summary = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetChangelogs", x => x.Id);
table.ForeignKey(
name: "FK_BudgetChangelogs_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BudgetDepartmentApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ApproverRoleSnapshot = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsBypassed = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
PhaseAtApproval = table.Column<int>(type: "int", nullable: false),
Stage = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetDepartmentApprovals", x => x.Id);
table.ForeignKey(
name: "FK_BudgetDepartmentApprovals_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BudgetDetails",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DonGia = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
DonViTinh = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
GhiChu = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
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),
KhoiLuong = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
NoiDung = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
ThanhTien = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetDetails", x => x.Id);
table.ForeignKey(
name: "FK_BudgetDetails_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_BudgetId",
table: "PurchaseEvaluations",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_Contracts_BudgetId",
table: "Contracts",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_BudgetApprovals_BudgetId_ApprovedAt",
table: "BudgetApprovals",
columns: new[] { "BudgetId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_BudgetChangelogs_BudgetId_CreatedAt",
table: "BudgetChangelogs",
columns: new[] { "BudgetId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_BudgetChangelogs_BudgetId_EntityType",
table: "BudgetChangelogs",
columns: new[] { "BudgetId", "EntityType" });
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_ApproverUserId",
table: "BudgetDepartmentApprovals",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_BudgetId",
table: "BudgetDepartmentApprovals",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_BudgetDepartmentApprovals_DepartmentId",
table: "BudgetDepartmentApprovals",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage",
table: "BudgetDepartmentApprovals",
columns: new[] { "BudgetId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BudgetDetails_BudgetId_Order",
table: "BudgetDetails",
columns: new[] { "BudgetId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_Budgets_MaNganSach",
table: "Budgets",
column: "MaNganSach",
unique: true,
filter: "[MaNganSach] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Budgets_NamNganSach",
table: "Budgets",
column: "NamNganSach");
migrationBuilder.CreateIndex(
name: "IX_Budgets_Phase_IsDeleted",
table: "Budgets",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Budgets_ProjectId",
table: "Budgets",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Budgets_SlaDeadline",
table: "Budgets",
column: "SlaDeadline");
}
}
}

View File

@ -298,339 +298,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ApprovalWorkflowSteps", (string)null); b.ToTable("ApprovalWorkflowSteps", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.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>("Description")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaNganSach")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("NamNganSach")
.HasColumnType("int");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<string>("TenNganSach")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal>("TongNganSach")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaNganSach")
.IsUnique()
.HasFilter("[MaNganSach] IS NOT NULL");
b.HasIndex("NamNganSach");
b.HasIndex("ProjectId");
b.HasIndex("SlaDeadline");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("Budgets", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.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<int>("ToPhase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("BudgetId", "ApprovedAt");
b.ToTable("BudgetApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Action")
.HasColumnType("int");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
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<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("BudgetId", "CreatedAt");
b.HasIndex("BudgetId", "EntityType");
b.ToTable("BudgetChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<string>("ApproverRoleSnapshot")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.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<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PhaseAtApproval")
.HasColumnType("int");
b.Property<int>("Stage")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("BudgetId");
b.HasIndex("DepartmentId");
b.HasIndex("BudgetId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage");
b.ToTable("BudgetDepartmentApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("DonGia")
.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>("KhoiLuong")
.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<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("BudgetId", "Order");
b.ToTable("BudgetDetails", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -640,9 +307,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ApprovalWorkflowId") b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("BudgetManualAmount") b.Property<decimal?>("BudgetManualAmount")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@ -740,8 +404,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("ApprovalWorkflowId"); b.HasIndex("ApprovalWorkflowId");
b.HasIndex("BudgetId");
b.HasIndex("MaHopDong") b.HasIndex("MaHopDong")
.IsUnique() .IsUnique()
.HasFilter("[MaHopDong] IS NOT NULL"); .HasFilter("[MaHopDong] IS NOT NULL");
@ -4842,6 +4504,66 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("WorkflowAppCodeSequences", (string)null); b.ToTable("WorkflowAppCodeSequences", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PeWorkItemBudget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("AdjustmentAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
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<decimal?>("InitialAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("ProEstimateAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ProNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("WorkItemId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("WorkItemId");
b.HasIndex("ProjectId", "WorkItemId")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
b.ToTable("PeWorkItemBudgets", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -4851,17 +4573,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ApprovalWorkflowId") b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<Guid?>("BudgetId") b.Property<decimal?>("BudgetPeriodAmount")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("BudgetManualAmount")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("BudgetManualName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("ContractId") b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@ -4893,6 +4608,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("DrafterUserId") b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<decimal?>("ExpectedRemainingAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@ -4952,8 +4671,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("ApprovalWorkflowId"); b.HasIndex("ApprovalWorkflowId");
b.HasIndex("BudgetId");
b.HasIndex("ContractId"); b.HasIndex("ContractId");
b.HasIndex("MaPhieu") b.HasIndex("MaPhieu")
@ -5756,50 +5473,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("ApprovalWorkflow"); b.Navigation("ApprovalWorkflow");
}); });
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Approvals")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Changelogs")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("DepartmentApprovals")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Details")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{ {
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -6392,17 +6065,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Levels"); b.Navigation("Levels");
}); });
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Navigation("Approvals");
b.Navigation("Changelogs");
b.Navigation("DepartmentApprovals");
b.Navigation("Details");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{ {
b.Navigation("Approvals"); b.Navigation("Approvals");

View File

@ -5,7 +5,6 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Notifications; using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services; using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications; using SolutionErp.Domain.Notifications;
@ -193,10 +192,12 @@ public class PurchaseEvaluationWorkflowService(
missing.Add("Đơn vị được chọn chưa có giá chào thầu"); missing.Add("Đơn vị được chọn chưa có giá chào thầu");
} }
} }
if (evaluation.BudgetId is null // [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
&& (evaluation.BudgetManualAmount is null || evaluation.BudgetManualAmount <= 0)) // (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
// mirror CÙNG predicate (đổi đồng bộ 2 tầng).
if (evaluation.BudgetPeriodAmount is null || evaluation.BudgetPeriodAmount <= 0)
{ {
missing.Add("chưa nhập Ngân sách"); missing.Add("chưa nhập Ngân sách kỳ này");
} }
var hasComparisonFile = await db.PurchaseEvaluationAttachments.AsNoTracking() var hasComparisonFile = await db.PurchaseEvaluationAttachments.AsNoTracking()
.AnyAsync(a => a.PurchaseEvaluationId == evaluation.Id .AnyAsync(a => a.PurchaseEvaluationId == evaluation.Id

View File

@ -1,145 +0,0 @@
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Domain.Tests.Budgets;
// Tests cho BudgetPolicy (hardcoded simple 3-step Default).
// Chống regression khi BudgetPhase enum thêm phase hoặc role mapping bị edit.
public class BudgetPolicyTests
{
[Fact]
public void Default_Drafter_DangSoanThao_To_ChoCCM_Allowed()
{
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
[AppRoles.Drafter])
.Should().BeTrue();
}
[Fact]
public void Default_DeptManager_DangSoanThao_To_ChoCCM_Allowed()
{
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
[AppRoles.DeptManager])
.Should().BeTrue();
}
[Fact]
public void Default_RandomRole_DangSoanThao_To_ChoCCM_Denied()
{
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
[AppRoles.Procurement])
.Should().BeFalse();
}
[Fact]
public void Default_CostControl_ChoCCM_To_ChoCEO_Allowed()
{
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO,
[AppRoles.CostControl])
.Should().BeTrue();
}
[Fact]
public void Default_CostControl_ChoCCM_To_TraLai_Allowed()
{
// Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.TraLai,
[AppRoles.CostControl])
.Should().BeTrue();
}
[Fact]
public void Default_Director_ChoCEO_To_DaDuyet_Allowed()
{
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
[AppRoles.Director])
.Should().BeTrue();
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
[AppRoles.AuthorizedSigner])
.Should().BeTrue();
}
[Fact]
public void Default_CCM_Cannot_Approve_To_DaDuyet()
{
// CCM chỉ chuyển đến ChoCEO, không tự duyệt thành DaDuyet
BudgetPolicies.Default
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
[AppRoles.CostControl])
.Should().BeFalse();
}
[Fact]
public void Default_DaDuyet_NoFurtherTransitions()
{
BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DaDuyet)
.Should().BeEmpty("DaDuyet là terminal");
}
[Fact]
public void Default_TuChoi_NoFurtherTransitions()
{
BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TuChoi)
.Should().BeEmpty("TuChoi là terminal");
}
[Fact]
public void Default_ActivePhases_Includes_All6States()
{
// Session 17: thêm TraLai = Phase riêng cho Trả lại
BudgetPolicies.Default.ActivePhases.Should().BeEquivalentTo(new[]
{
BudgetPhase.DangSoanThao, BudgetPhase.TraLai, BudgetPhase.ChoCCM,
BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, BudgetPhase.TuChoi,
});
}
[Fact]
public void Default_NextPhasesFrom_DangSoanThao_Includes_ChoCCM_And_TuChoi()
{
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DangSoanThao);
next.Should().Contain(BudgetPhase.ChoCCM);
next.Should().Contain(BudgetPhase.TuChoi);
}
[Fact]
public void Default_NextPhasesFrom_TraLai_Includes_ChoCCM_And_TuChoi()
{
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao)
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TraLai);
next.Should().Contain(BudgetPhase.ChoCCM);
next.Should().Contain(BudgetPhase.TuChoi);
}
[Fact]
public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai()
{
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.ChoCEO);
next.Should().Contain(BudgetPhase.DaDuyet);
next.Should().Contain(BudgetPhase.TraLai);
next.Should().Contain(BudgetPhase.TuChoi);
}
// SLA — chống regression khi đổi phase deadline accidentally
[Fact]
public void Default_SlaDeadlines_Match_Spec()
{
BudgetPolicies.Default.PhaseSla[BudgetPhase.DangSoanThao]
.Should().Be(TimeSpan.FromDays(5));
BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCCM]
.Should().Be(TimeSpan.FromDays(3));
BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCEO]
.Should().Be(TimeSpan.FromDays(2));
BudgetPolicies.Default.PhaseSla[BudgetPhase.DaDuyet]
.Should().BeNull("Terminal phase không có SLA");
}
}

View File

@ -74,7 +74,6 @@ public class CreateContractCommandApplicableTypeTests
NoiDung: null, NoiDung: null,
BypassProcurementAndCCM: false, BypassProcurementAndCCM: false,
DraftData: null, DraftData: null,
BudgetId: null,
BudgetManualName: null, BudgetManualName: null,
BudgetManualAmount: null, BudgetManualAmount: null,
ApprovalWorkflowId: peOnlyWf.Id); ApprovalWorkflowId: peOnlyWf.Id);

View File

@ -0,0 +1,694 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
namespace SolutionErp.Infrastructure.Tests.Application;
// [S61 Mig 50] "Ngân sách PE theo Excel anh Kiệt" — thay module Budget cũ (5 bảng
// drop cùng Mig 50). Entity mới PeWorkItemBudget = 1 record/cặp (ProjectId,
// WorkItemId) loose-Guid, UNIQUE composite FILTERED [IsDeleted]=0 (gotcha #57 day-1).
//
// Test guard theo CODE đã land (single source of truth — S34 rule). 6 nhóm:
// 1. Auto-create on PE create (CreatePurchaseEvaluationCommandHandler ~:96-129)
// 2. EnsureTrackedAsync helper (idempotent + soft-deleted slot → tạo MỚI được)
// 3. UpdatePeBudgetPro authz matrix (Procurement|Admin fail-closed TRƯỚC side-effect)
// 4. UpdatePeBudgetCcm authz matrix (CostControl|Admin, Adjustment cho phép ÂM)
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler ~:762-829)
// 6. AdjustBudget mới (absolute-set 2 field + validator >0 / -≥0 when HasValue)
//
// Pattern reuse: IdentityFixture + TestApplicationDbContext (SQLite) +
// FixedDateTime + FakeCurrentUser (mirror PeWorkItemGuardTests S57bis +
// ItTicketReassignAuthzTests S54). GUARD-FIRST seed: test fail đúng lý do.
//
// LƯU Ý SOFT-DELETE TRONG TEST: AuditingInterceptor (prod soft-delete) KHÔNG wire
// trong TestApplicationDbContext → Remove = HARD delete. Để test slot soft-deleted
// (test nhóm 2) SEED row IsDeleted=true thủ công. Filtered index [IsDeleted]=0 đã
// có day-1 (PeWorkItemBudgetConfiguration.cs:24-26) nên reuse slot hoạt động —
// KHÁC gotcha #57 RED của MasterCatalog (bare .IsUnique()).
public class PeWorkItemBudgetTests
{
// ICurrentUser fake với Roles configurable (matrix PRO / CCM / Admin / none).
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "actor@test.local";
public string? FullName { get; init; } = "Actor Test";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static FakeCurrentUser AsRoles(params string[] roles)
=> new() { Roles = roles };
// ---- shared seed helpers ----
private static async Task<Project> SeedProjectAsync(TestApplicationDbContext db, string code = "PRJ-BG")
{
var p = new Project { Id = Guid.NewGuid(), Code = code, Name = "Dự án ngân sách " + code };
db.Projects.Add(p);
await db.SaveChangesAsync(CancellationToken.None);
return p;
}
private static async Task<WorkItem> SeedWorkItemAsync(
TestApplicationDbContext db, string code, bool isActive = true)
{
var wi = new WorkItem
{
Id = Guid.NewGuid(),
Code = code,
Name = "Hạng mục " + code,
IsActive = isActive,
};
db.WorkItems.Add(wi);
await db.SaveChangesAsync(CancellationToken.None);
return wi;
}
// Create handler stack đầy đủ (db + ICurrentUser + workflow svc + codeGen).
private static CreatePurchaseEvaluationCommandHandler BuildCreateHandler(
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
{
var dt = new FixedDateTime(new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var workflow = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
var codeGen = new PurchaseEvaluationCodeGenerator(db, dt);
return new CreatePurchaseEvaluationCommandHandler(db, currentUser, workflow, codeGen);
}
private static CreatePurchaseEvaluationCommand BuildCreateCommand(Guid projectId, Guid? workItemId)
=> new(
Type: PurchaseEvaluationType.DuyetNcc,
TenGoiThau: "Gói thầu test",
ProjectId: projectId,
DepartmentId: null,
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetPeriodAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);
// Phiếu Nháp có gắn WorkItemId — dựng thủ công (cho test Pro/Ccm/Adjust/Summary).
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid projectId,
Guid? workItemId,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao,
Guid? drafterUserId = null,
decimal? budgetPeriodAmount = null,
Guid? selectedSupplierId = null,
DateTime? createdAt = null,
string code = "PE-BG")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu gốc",
ProjectId = projectId,
WorkItemId = workItemId,
DrafterUserId = drafterUserId ?? Guid.NewGuid(),
BudgetPeriodAmount = budgetPeriodAmount,
SelectedSupplierId = selectedSupplierId,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// CreatedAt do BaseEntity/DB default set — test summary cần kiểm soát thứ tự.
// TestApplicationDbContext KHÔNG wire AuditingInterceptor → set sau + Save lại
// để override giá trị default (lũy kế dùng CreatedAt < this).
if (createdAt is DateTime ca)
{
pe.CreatedAt = ca;
await db.SaveChangesAsync(CancellationToken.None);
}
return pe;
}
// =====================================================================
// 1. AUTO-CREATE ON PE CREATE
// =====================================================================
[Fact]
public async Task Create_WithWorkItem_AutoCreatesExactlyOnePeWorkItemBudget()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-AC1");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().HaveCount(1, "tạo phiếu đầu của cặp → auto-create đúng 1 record");
}
[Fact]
public async Task Create_SecondPeSamePair_StillOnlyOneBudgetRecord()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-AC2");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
// Phiếu THỨ 2 cùng cặp (ProjectId, WorkItemId) → pre-check exists → KHÔNG thêm record.
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().HaveCount(1, "1 record DUY NHẤT per cặp dùng chung mọi phiếu cùng gói thầu");
}
[Fact]
public async Task Create_TwoDistinctPairs_CreatesTwoBudgetRecords()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wiA = await SeedWorkItemAsync(db, "WI-ACa");
var wiB = await SeedWorkItemAsync(db, "WI-ACb");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wiA.Id), CancellationToken.None);
await handler.Handle(BuildCreateCommand(project.Id, wiB.Id), CancellationToken.None);
(await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id))
.Should().Be(2, "2 cặp khác hạng mục → 2 record độc lập");
}
// =====================================================================
// 2. EnsureTrackedAsync helper (internal — gọi qua handler PUT pro)
// Idempotent + soft-deleted slot → tạo record MỚI được (filtered index).
// =====================================================================
[Fact]
public async Task EnsurePair_CalledTwiceSamePair_ReturnsSameRecordId()
{
// EnsureTrackedAsync internal → drive qua UpdatePeBudgetProCommandHandler 2 lần
// (Admin role). Lần 2 PHẢI tái dùng record lần 1 (cùng Id), KHÔNG tạo thêm.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ENS1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, "lần 1"), CancellationToken.None);
var firstId = (await db.PeWorkItemBudgets
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id)).Id;
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, "lần 2"), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().ContainSingle("EnsureTrackedAsync idempotent — 2 lần cùng cặp KHÔNG nhân đôi record");
recs[0].Id.Should().Be(firstId, "cùng record Id qua 2 lần gọi");
recs[0].ProEstimateAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới");
}
[Fact]
public async Task EnsurePair_SoftDeletedRecordExists_CreatesNewActiveRecord()
{
// Slot (Project, WorkItem) đang bị 1 record SOFT-DELETED (IsDeleted=true) chiếm.
// Filtered unique [IsDeleted]=0 + HasQueryFilter(!IsDeleted) → exists-check bỏ
// qua deleted → EnsureTrackedAsync tạo record MỚI active được (KHÔNG đụng record cũ).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ENS2");
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(),
ProjectId = project.Id,
WorkItemId = wi.Id,
ProEstimateAmount = 999m,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
var act = async () => await handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 50m, "mới"), CancellationToken.None);
await act.Should().NotThrowAsync(
"filtered index cho phép tạo record active mới khi slot cũ đã soft-delete");
(await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.Should().Be(1, "query filter !IsDeleted chỉ thấy 1 record active");
(await db.PeWorkItemBudgets.IgnoreQueryFilters()
.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.Should().Be(2, "record soft-deleted gốc giữ lại cho audit + active mới");
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.ProEstimateAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
}
// =====================================================================
// 3. UpdatePeBudgetPro authz matrix
// =====================================================================
[Fact]
public async Task UpdatePro_ProcurementRole_SetsEstimateAndNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, "Dự trù theo đơn giá Q2"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.ProEstimateAmount.Should().Be(1_500_000m);
rec.ProNote.Should().Be("Dự trù theo đơn giá Q2");
}
[Fact]
public async Task UpdatePro_CostControlRole_ThrowsForbidden_AndDoesNotMutateRecord()
{
// CCM KHÔNG có quyền nhập PRO → ForbiddenException + record KHÔNG đổi (side-effect assert).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
// Pre-seed record với giá trị PRO sẵn để chứng minh KHÔNG bị mutate khi Forbidden.
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 700m, ProNote = "giữ nguyên",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.CostControl));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 9_999m, "không được set"), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ Procurement | Admin được nhập dự trù PRO");
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.ProEstimateAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
rec.ProNote.Should().Be("giữ nguyên");
}
[Fact]
public async Task UpdatePro_AdminRole_Succeeds()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, "admin set"), CancellationToken.None);
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.ProEstimateAmount.Should().Be(42m, "Admin được nhập PRO");
}
[Fact]
public async Task UpdatePro_PeWorkItemIdNull_ThrowsConflict()
{
// Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → Conflict (resolve record qua PE.WorkItemId).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var pe = await SeedPeAsync(db, project.Id, workItemId: null);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 10m, null), CancellationToken.None))
.Should().ThrowAsync<ConflictException>()
.WithMessage("*chưa gắn Hạng mục công việc*");
}
[Fact]
public async Task UpdatePro_RecordMissing_AutoCreatesThenSets()
{
// Cặp chưa có record (phiếu seed thủ công KHÔNG đi qua auto-create của Create handler)
// → handler auto-create qua EnsureTrackedAsync rồi set. budgetId non-null sau call.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO4");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
(await db.PeWorkItemBudgets.AnyAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.Should().BeFalse("tiền điều kiện: chưa có record cho cặp");
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, "auto"), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.Id.Should().NotBe(Guid.Empty, "auto-create record có Id thật");
rec.ProEstimateAmount.Should().Be(333m);
}
// =====================================================================
// 4. UpdatePeBudgetCcm authz matrix
// =====================================================================
[Fact]
public async Task UpdateCcm_CostControlRole_SetsInitialAndNegativeAdjustment()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
// Adjustment ÂM 5tr — "hiệu chỉnh tăng giảm" cho phép ÂM (validator KHÔNG ràng dấu).
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(80_000_000m);
rec.AdjustmentAmount.Should().Be(-5_000_000m, "Adjustment ÂM được chấp nhận");
}
[Fact]
public async Task UpdateCcm_ProcurementRole_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh");
}
[Fact]
public async Task UpdateCcm_AdminRole_Succeeds()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(1m);
rec.AdjustmentAmount.Should().Be(2m);
}
// =====================================================================
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler)
// =====================================================================
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
// Set pe.SelectedSupplierId = supplierId trùng PurchaseEvaluationSupplier.SupplierId
// (join row 2 prevSelectedTotal: PES.SupplierId == p.SelectedSupplierId → quotes).
private static async Task SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{
var pes = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
Order = 0,
};
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete",
Order = 0,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationDetails.Add(detail);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
});
await db.SaveChangesAsync(CancellationToken.None);
}
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
[Fact]
public async Task BudgetSummary_AccumulatesPreviousSubmittedAndSelected_IgnoresTraLaiAndOtherPairs()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM1");
var otherWi = await SeedWorkItemAsync(db, "WI-SUM-OTHER");
var baseT = new DateTime(2026, 6, 13, 8, 0, 0, DateTimeKind.Utc);
// P1 DaDuyet — BudgetPeriod 100, winner quote 90, CreatedAt -3d.
var p1Winner = Guid.NewGuid();
var p1 = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriodAmount: 100m, selectedSupplierId: p1Winner,
createdAt: baseT.AddDays(-3), code: "PE-SUM-1");
await SeedWinnerWithQuoteAsync(db, p1, p1Winner, 90m);
// P2 ChoDuyet — BudgetPeriod 50, CreatedAt -2d (tính submitted, KHÔNG tính selected).
await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriodAmount: 50m, createdAt: baseT.AddDays(-2), code: "PE-SUM-2");
// P3 TraLai — BudgetPeriod 999, CreatedAt -1d → KHÔNG tính (quay về soạn).
await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TraLai,
budgetPeriodAmount: 999m, createdAt: baseT.AddDays(-1), code: "PE-SUM-3");
// Phiếu KHÁC CẶP (cùng project, hạng mục khác) DaDuyet 777 → KHÔNG lẫn.
var otherWinner = Guid.NewGuid();
var pOther = await SeedPeAsync(db, project.Id, otherWi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriodAmount: 777m, selectedSupplierId: otherWinner,
createdAt: baseT.AddDays(-2), code: "PE-SUM-OTHER");
await SeedWinnerWithQuoteAsync(db, pOther, otherWinner, 777m);
// P_this DangSoanThao — BudgetPeriod 70, CreatedAt = baseT (mới nhất).
var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
budgetPeriodAmount: 70m, createdAt: baseT, code: "PE-SUM-THIS");
// GET P_this bằng Admin (bỏ qua authz scope) → đọc peBudgetSummary.
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);
bundle.BudgetSummary.Should().NotBeNull("phiếu có WorkItemId → summary build");
var s = bundle.BudgetSummary!;
s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
s.PreviousSubmittedCount.Should().Be(2);
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote");
s.PreviousSelectedCount.Should().Be(1);
}
[Fact]
public async Task BudgetSummary_FullAmount_FallsBackToProEstimate_WhenCcmEmpty()
{
// CCM (Initial+Adjustment) cả null → fallback ProEstimate=500, FullIsEstimate=true.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 500m, // CCM Initial + Adjustment đều null
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(500m, "CCM trống → full = dự trù PRO");
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'dự trù PRO'");
}
[Fact]
public async Task BudgetSummary_FullAmount_UsesCcmInitialPlusAdjustment_WhenCcmPresent()
{
// CCM Initial=400 + Adjustment=-50 → full=350, FullIsEstimate=false.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 500m, // có nhưng KHÔNG dùng khi CCM present
InitialAmount = 400m,
AdjustmentAmount = -50m,
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50)");
s.FullIsEstimate.Should().BeFalse("không phải dự trù — CCM đã nhập");
}
[Fact]
public async Task BudgetSummary_CanEditFlags_FollowRole()
{
// canEditPro theo Procurement|Admin; canEditCcm theo CostControl|Admin.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM4");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProEstimateAmount = 10m,
});
await db.SaveChangesAsync(CancellationToken.None);
// PRO user (drafter của phiếu để pass authz scope) → canEditPro true, canEditCcm false.
var proUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.Procurement } };
var bundlePro = await BuildQueryHandler(fix, db, proUser)
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundlePro.BudgetSummary!.CanEditPro.Should().BeTrue();
bundlePro.BudgetSummary!.CanEditCcm.Should().BeFalse("Procurement không sửa CCM");
var ccmUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.CostControl } };
var bundleCcm = await BuildQueryHandler(fix, db, ccmUser)
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundleCcm.BudgetSummary!.CanEditPro.Should().BeFalse("CostControl không sửa PRO");
bundleCcm.BudgetSummary!.CanEditCcm.Should().BeTrue();
}
[Fact]
public async Task BudgetSummary_Null_WhenPeHasNoWorkItem()
{
// Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → summary null (FE banner nhắc gắn).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var pe = await SeedPeAsync(db, project.Id, workItemId: null);
var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.BudgetSummary.Should().BeNull("WorkItemId null → KHÔNG build summary");
}
// =====================================================================
// 6. AdjustBudget mới (absolute-set 2 field + validator)
// =====================================================================
[Fact]
public async Task AdjustBudget_DrafterDraft_SetsBothFields()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ADJ1");
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);
// Actor = chính Drafter của phiếu (scope Drafter khi Nháp).
var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);
await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, 30m),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
reloaded.BudgetPeriodAmount.Should().Be(80m);
reloaded.ExpectedRemainingAmount.Should().Be(30m);
}
[Fact]
public void AdjustBudget_Validator_BudgetPeriodZero_FailsValidation()
{
// GreaterThan(0) when HasValue → 0 không hợp lệ.
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
}
[Fact]
public void AdjustBudget_Validator_ExpectedRemainingNegative_FailsValidation()
{
// GreaterThanOrEqualTo(0) when HasValue → âm không hợp lệ (row 8 không cho âm).
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 80m, -1m));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.ExpectedRemainingAmount));
}
[Fact]
public async Task AdjustBudget_AbsoluteSet_NullExpectedRemaining_ClearsField()
{
// Absolute-set: gửi (BudgetPeriod=80, ExpectedRemaining=null) → ExpectedRemaining
// bị CLEAR về null (đúng thiết kế — KHÔNG partial-keep). Bắt đầu từ giá trị có sẵn.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ADJ2");
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);
// Set ExpectedRemaining sẵn = 999 trước khi adjust với null.
var tracked = await db.PurchaseEvaluations.SingleAsync(x => x.Id == pe.Id);
tracked.ExpectedRemainingAmount = 999m;
await db.SaveChangesAsync(CancellationToken.None);
var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);
await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
}
}

View File

@ -83,9 +83,7 @@ public class PeWorkItemGuardTests
DiaDiem: null, DiaDiem: null,
MoTa: null, MoTa: null,
PaymentTerms: null, PaymentTerms: null,
BudgetId: null, BudgetPeriodAmount: null,
BudgetManualName: null,
BudgetManualAmount: null,
ApprovalWorkflowId: null, ApprovalWorkflowId: null,
WorkItemId: workItemId); WorkItemId: workItemId);
@ -233,9 +231,7 @@ public class PeWorkItemGuardTests
DiaDiem: null, DiaDiem: null,
MoTa: null, MoTa: null,
PaymentTerms: null, PaymentTerms: null,
BudgetId: null, BudgetPeriodAmount: null,
BudgetManualName: null,
BudgetManualAmount: null,
ApprovalWorkflowId: null, ApprovalWorkflowId: null,
WorkItemId: workItemId); WorkItemId: workItemId);

View File

@ -52,8 +52,7 @@ public class PeSubmitGuardAndBypassTests
// random (test guard không cần drafter trong chuỗi). V2 nếu awId set. // random (test guard không cần drafter trong chuỗi). V2 nếu awId set.
private static PurchaseEvaluation BuildPeNhap( private static PurchaseEvaluation BuildPeNhap(
Guid? selectedSupplierId = null, Guid? selectedSupplierId = null,
Guid? budgetId = null, decimal? budgetPeriodAmount = null,
decimal? budgetManualAmount = null,
Guid? approvalWorkflowId = null, Guid? approvalWorkflowId = null,
Guid? drafterUserId = null, Guid? drafterUserId = null,
string code = "PE-S60-001") string code = "PE-S60-001")
@ -67,8 +66,7 @@ public class PeSubmitGuardAndBypassTests
ProjectId = Guid.NewGuid(), ProjectId = Guid.NewGuid(),
DrafterUserId = drafterUserId ?? Guid.NewGuid(), DrafterUserId = drafterUserId ?? Guid.NewGuid(),
SelectedSupplierId = selectedSupplierId, SelectedSupplierId = selectedSupplierId,
BudgetId = budgetId, BudgetPeriodAmount = budgetPeriodAmount, // [S61 Mig 50] thay BudgetId/BudgetManualAmount
BudgetManualAmount = budgetManualAmount,
ApprovalWorkflowId = approvalWorkflowId, ApprovalWorkflowId = approvalWorkflowId,
}; };
} }
@ -215,7 +213,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetManualAmount: 500_000m); var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
SeedComparisonAttachment(db, pe); SeedComparisonAttachment(db, pe);
@ -237,7 +235,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetManualAmount: 500_000m); var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0 var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0
@ -261,7 +259,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetId: null, budgetManualAmount: 0m); var pe = BuildPeNhap(budgetPeriodAmount: 0m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
@ -286,7 +284,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetManualAmount: 500_000m); var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
@ -309,7 +307,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetManualAmount: 500_000m); var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
@ -347,7 +345,7 @@ public class PeSubmitGuardAndBypassTests
var (svc, fix, db, clock) = CreateService(); var (svc, fix, db, clock) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetManualAmount: 750_000m); var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
@ -364,27 +362,9 @@ public class PeSubmitGuardAndBypassTests
} }
} }
[Fact] // [S61 Mig 50] Test (8) "đủ 4 qua BudgetId" XÓA — nhánh BudgetId không còn
public async Task Submit_AllFourMet_ViaBudgetId_ManualNull_SetsChoDuyet() // tồn tại (module Budget cũ drop, predicate (3) chỉ còn BudgetPeriodAmount).
{ // Nhánh thoả-mãn duy nhất đã cover bởi test (7) budgetPeriodAmount > 0.
// (8) Đủ 4 qua BudgetId (manual null) → OK. Cover nhánh budget thoả qua FK
// Budget thay vì manual amount.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetId: Guid.NewGuid(), budgetManualAmount: null);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
await SubmitAsync(svc, pe, Guid.NewGuid());
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
}
}
// ===================================================================== // =====================================================================
// FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only) // FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only)
@ -397,7 +377,7 @@ public class PeSubmitGuardAndBypassTests
TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code) TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code)
{ {
var pe = BuildPeNhap( var pe = BuildPeNhap(
budgetManualAmount: 1_000_000m, budgetPeriodAmount: 1_000_000m,
approvalWorkflowId: workflowId, approvalWorkflowId: workflowId,
drafterUserId: drafterUserId, drafterUserId: drafterUserId,
code: code); code: code);
@ -569,7 +549,7 @@ public class PeSubmitGuardAndBypassTests
using (fix) using (fix)
{ {
var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id; var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id;
var pe = BuildPeNhap(budgetManualAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013"); var pe = BuildPeNhap(budgetPeriodAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);