[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
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:
@ -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]`.
|
||||||
|
|||||||
@ -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 ✓)*
|
||||||
|
|||||||
@ -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].
|
||||||
|
|||||||
@ -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 />} />
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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">Mô 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">Mã / 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>Mã 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>Mã 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 có 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 có lịch sử.</p>
|
|
||||||
return (
|
|
||||||
<ol className="space-y-1.5 text-sm">
|
|
||||||
{logs.data.map(l => (
|
|
||||||
<li key={l.id} className="border-l-2 border-slate-200 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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 có 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
|
|
||||||
}
|
|
||||||
@ -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 có 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 ký
|
||||||
<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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 “Tổng hợp ngân sách trình ký” 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>
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>Mô 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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[]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 />} />
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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">Mô 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">Mã / 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>Mã 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>Mã 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 có 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 có lịch sử.</p>
|
|
||||||
return (
|
|
||||||
<ol className="space-y-1.5 text-sm">
|
|
||||||
{logs.data.map(l => (
|
|
||||||
<li key={l.id} className="border-l-2 border-slate-200 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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 có 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
|
|
||||||
}
|
|
||||||
@ -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 có 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 ký
|
||||||
<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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 “Tổng hợp ngân sách trình ký” 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>
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>Mô 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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[]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 ?? "(trống)"}\" → \"{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
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@ -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 (đ)
|
||||||
|
|
||||||
|
|||||||
@ -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 bảng quản lý ngân sách dự án/gói thầu.
|
// đã XÓA — thay bằng "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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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ự.
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user