[CLAUDE] PurchaseEvaluation: cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng giá trị (Mig 53) + 14 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
Yêu cầu anh Kiệt FDC (sau họp sếp). Mig 53 AddPeUrgentAndCeoApprovalThreshold — 3 AddColumn, no new table (Mig 52→53). Rollout an toàn: cột nullable, ngưỡng null = giữ luồng duyệt cũ 100% cho tới khi admin set.
B — CCM duyệt-final theo NGƯỠNG GIÁ TRỊ ("gói CEO phân quyền theo giá trị"):
- ApprovalWorkflow += CeoApprovalThreshold (decimal?, admin nhập trong Workflow Designer).
- ApproveV2Async: actor role CostControl (CCM) + winnerQuoteTotal (tổng giá NCC được chọn) < ngưỡng → DaDuyet luôn (bỏ CEO); ≥ ngưỡng → đẩy lên CEO như cũ. Ngưỡng null = luồng tuyến tính cũ. Q4 chốt nhận diện theo ROLE người duyệt.
- reviewer PASS 0 blocker: cascade-safe (Off/role không lan), tested load-bearing (CCM dưới ngưỡng → DaDuyet skip CEO).
A — cờ gấp per-vai (visibility-only, Q3 KHÔNG đổi luồng):
- PE += IsUrgentByPro (PRO đỏ) / IsUrgentByCcm (CCM xanh).
- Endpoint PUT /purchase-evaluations/{id}/urgent role-gated (Procurement→ByPro, CostControl→ByCcm, Admin→cả 2, khác→Forbidden) + notify CEO (Director) khi MỚI bật (best-effort).
FE ×2 app: Workflow Designer ô "Ngưỡng giá trị gói CEO" (fe-admin) + PE detail nút bật/tắt cờ gấp đỏ/xanh theo role + badge GẤP + hint "giá trị gói vs ngưỡng → CCM duyệt-final/cần CEO" + PE list badge gấp.
DTO: PE detail += isUrgentByPro/Ccm + winnerQuoteTotal + ceoApprovalThreshold; list += isUrgentByPro/Ccm; workflow V2 += ceoApprovalThreshold.
+14 test (292→306): PeCcmThresholdFinalizeTests 5 (B routing) + PeUrgentToggleAuthzTests 9 (A authz). Build slnx 0/0 · npm build ×2 0 err · dotnet test 306 PASS.
C (sau duyệt xong chuyển phiếu đến dự án) — chờ anh Kiệt làm chi tiết form, CHƯA làm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -70,6 +70,8 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **2026-06-17 (PE-workflow recon for FDC feature-plan — urgent flag + value-threshold routing, on-disk):** ⭐ **PE VALUE: NO stored "giá trị gói thầu" column.** Best-fit = winner-quote-total `SUM(Quote.ThanhTien WHERE supplier==SelectedSupplierId)` — COMPUTED (submit-guard `PurchaseEvaluationWorkflowService.cs:188-190` + `CurrentProposalTotal` in `PeBudgetSummaryDto`). Other amounts: `PE.BudgetPeriodAmount`(:40 drafter NS kỳ này)/`ExpectedRemainingAmount`(:41)/`PeWorkItemBudget.FullAmount`=(Initial??0)+(Adjustment??0) (`PeWorkItemBudget.cs:29-30`) — all budgets, not deal-value. **ROLES PRO/CCM/CEO = domain shorthand NOT constants** (`AppRoles.cs` has Procurement/CostControl/Director; PRO=Procurement CCM=CostControl CEO=Director). **V2 routing IGNORES roles** — approvers = specific `ApproverUserId` (`ApprovalWorkflow.cs:80`), OR-of-N = N Level rows same `Order` (GroupBy :687). "Phòng CCM" = seed Step NAME + non-strict DeptId hint only (`:67`). **CEO = positional (last level/last step), NOT conditional.** **ROUTING 100% LINEAR** (level→step, `DaDuyet` when `nextIdx>=steps.Count`). ZERO value/threshold/conditional config anywhere (grep 0 on AW/Step/Level/PEType). ⭐ **HOOK B (value-threshold) = `ApproveV2Async` advance block lines 816-845** (`:817` levelOrder++ / `:828-837` terminal DaDuyet / `:838-845` next step). Precedent: `skipToFinal :773-814` already "jump pointer to last step+level" — reuse mechanic conditioned on value. **HOOK A (urgent):** add `IsUrgent bit`/`PePriority` enum (mirror `ItTicketPriority{Low,Medium,High,Urgent}` `Office/Enums.cs:48-54`) AddColumn no-new-table; notify `INotificationService.NotifyAsync(userId,type,title,desc?,href?,refId?)` (`INotificationService.cs:10`)+SignalR interceptor; LogTransition notifies DRAFTER-only on terminal (`:960-980`), NO approver-notify yet. Badge DTOs: `PurchaseEvaluationListItemDto`(`PurchaseEvaluationDtos.cs:6`)+`DetailBundleDto`(:201). Type A/B (`PurchaseEvaluationType.cs:6-10`) constrains pinnable ApplicableType only — ZERO type-conditional routing. ⚠️ "Từ chối" REMOVED S60 hard-guard `:80-85` (throws even Admin; only Duyệt/Trả lại). ⚠️ drafter-in-chain bypass `:543` auto-approves drafter's own step-1 levels on submit (interacts w/ value-finalize). Tag `[pe-workflow-recon, value-threshold-hook, urgent-flag, fdc-feature-plan]`.
|
||||||
|
|
||||||
- **2026-06-17 (S69 recon — Office-module inventory + Hồ sơ-NS CSS-contract, on-disk):** ⭐ **PART A Office:** 21 `Off_*` keys (`MenuKeys.cs:99-121`): root `Off` + DanhBa(card-grid), `Off_PhongHop`{View=cal/Manage=room-CRUD-admin/Book}, `Off_DeXuat`{List/Create/Inbox=Proposal-V2}, `Off_DonTu`{Leave/Ot/Travel}, `Off_DatXe`, `Off_ItTicket`, `Off_ChamCong`(re-parent→Personal S57), `Off_AttendanceReport`(admin). 10 office pages `{fe-admin,fe-user}/src/pages/office/` ALL SHA256-MIRROR except **MyAttendancePage DIFFERS** + AttendanceReportPage ADMIN-ONLY. Routes `App.tsx` user:70-80/admin:88-100; staticMap `Layout.tsx:87-103` (workflow-apps :kind `/workflow-apps/{leave,ot,travel,vehicle}`); menuKeys.ts:45-63. **HIDE-FLAG** `RevokeTemporarilyHiddenModulesAsync` (`DbInitializer.cs:2157-2190` called :2040 LAST) wipes CRUD on `MenuKey.StartsWith("Off")||"Hrm"||==Personal` non-Admin, idempotent. **Golive flip:** remove :2040 call (+ re-add prefix InReviewScope grant). Office already S55-shell polished NOT bare. **PART B Hồ sơ-NS CSS:** layout=3-col flex (`EmployeesListPage.tsx` SHA256-identical x2, 1597 LOC): cây-tổ-chức TRÁI(:178) + NV-list MID(:244) + detail PHẢI = avatar-header `app-gradient-brand`(:643)+`text-white!`(:653)+initials chip bg-white/15 → 5-TAB(:507 Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) → `Card`(:1526 left-rail+icon-chip) w/ `Field`(:1572 label uppercase accent-tint + value `font-medium text-brand-800`, empty=`text-slate-300 —`). `ACCENT` map :497-503 Record<5,{chipBg/chipFg/head/rail/labelText}> accent∈{brand,teal,violet,amberx,greenx}, palettes stops 50/100/500/600/700 only no-800→headings -700 (brand -800 OK). Tokens `index.css`: brand-600=#1f7dc1 brand-800=#175685 @theme:5-55, font Be-Vietnam-Pro:53; classes `.app-gradient-brand`(:105 120deg b600→700→800),`.card-accent`(:112),`.icon-chip`(:128 --chip-bg/--chip-fg),`.stat-value`(:140),`.label-eyebrow`(:89). ⚠️ **GOTCHA #66 = `index.css:79-83` `h1,h2,h3,h4{color:#0b1220;font-weight:700}` OUTSIDE @layer** → TW-v4 unlayered wins → heading-tag inside gradient MUST `text-white!`. ⚠️ **CROSS-APP DRIFT:** fe-user=S68 (h1-4 #0b1220/700, label-eyebrow brand-600, 175L); **fe-admin STILL OLD** (h1-4 #0f172a/600, label-eyebrow #64748b slate, 167L) — fe-admin NOT synced S66-68 heading bump → mirror Office to fe-admin needs index.css sync. Tag `[s69, office-inventory, hoso-css-contract, gotcha66, fe-admin-css-drift]`.
|
- **2026-06-17 (S69 recon — Office-module inventory + Hồ sơ-NS CSS-contract, on-disk):** ⭐ **PART A Office:** 21 `Off_*` keys (`MenuKeys.cs:99-121`): root `Off` + DanhBa(card-grid), `Off_PhongHop`{View=cal/Manage=room-CRUD-admin/Book}, `Off_DeXuat`{List/Create/Inbox=Proposal-V2}, `Off_DonTu`{Leave/Ot/Travel}, `Off_DatXe`, `Off_ItTicket`, `Off_ChamCong`(re-parent→Personal S57), `Off_AttendanceReport`(admin). 10 office pages `{fe-admin,fe-user}/src/pages/office/` ALL SHA256-MIRROR except **MyAttendancePage DIFFERS** + AttendanceReportPage ADMIN-ONLY. Routes `App.tsx` user:70-80/admin:88-100; staticMap `Layout.tsx:87-103` (workflow-apps :kind `/workflow-apps/{leave,ot,travel,vehicle}`); menuKeys.ts:45-63. **HIDE-FLAG** `RevokeTemporarilyHiddenModulesAsync` (`DbInitializer.cs:2157-2190` called :2040 LAST) wipes CRUD on `MenuKey.StartsWith("Off")||"Hrm"||==Personal` non-Admin, idempotent. **Golive flip:** remove :2040 call (+ re-add prefix InReviewScope grant). Office already S55-shell polished NOT bare. **PART B Hồ sơ-NS CSS:** layout=3-col flex (`EmployeesListPage.tsx` SHA256-identical x2, 1597 LOC): cây-tổ-chức TRÁI(:178) + NV-list MID(:244) + detail PHẢI = avatar-header `app-gradient-brand`(:643)+`text-white!`(:653)+initials chip bg-white/15 → 5-TAB(:507 Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) → `Card`(:1526 left-rail+icon-chip) w/ `Field`(:1572 label uppercase accent-tint + value `font-medium text-brand-800`, empty=`text-slate-300 —`). `ACCENT` map :497-503 Record<5,{chipBg/chipFg/head/rail/labelText}> accent∈{brand,teal,violet,amberx,greenx}, palettes stops 50/100/500/600/700 only no-800→headings -700 (brand -800 OK). Tokens `index.css`: brand-600=#1f7dc1 brand-800=#175685 @theme:5-55, font Be-Vietnam-Pro:53; classes `.app-gradient-brand`(:105 120deg b600→700→800),`.card-accent`(:112),`.icon-chip`(:128 --chip-bg/--chip-fg),`.stat-value`(:140),`.label-eyebrow`(:89). ⚠️ **GOTCHA #66 = `index.css:79-83` `h1,h2,h3,h4{color:#0b1220;font-weight:700}` OUTSIDE @layer** → TW-v4 unlayered wins → heading-tag inside gradient MUST `text-white!`. ⚠️ **CROSS-APP DRIFT:** fe-user=S68 (h1-4 #0b1220/700, label-eyebrow brand-600, 175L); **fe-admin STILL OLD** (h1-4 #0f172a/600, label-eyebrow #64748b slate, 167L) — fe-admin NOT synced S66-68 heading bump → mirror Office to fe-admin needs index.css sync. Tag `[s69, office-inventory, hoso-css-contract, gotcha66, fe-admin-css-drift]`.
|
||||||
|
|
||||||
- **[→ git pre-S60]** S60 recon#2 V2-engine-map (ApprovalWorkflow.cs Step/Level Order 1-based per-step; OR-of-N=N rows cùng Order service GroupBy:475; ApproveV2Async:446-634 guard+UPSERT+advance; notify DRAFTER-only:748; skipToFinal F2:561-602 = precedent advance-không-ghi-opinion) · S60 PE Section-3 submit-guard (submit path POST/pe/{id}/transitions→TransitionAsync:38 ROLE-only guard NO data-check; Section-3 mục a/b/c/d map — SUPERSEDED bởi S65ter post-Mig50 Budget-drop; test mirror PurchaseEvaluationWorkflowServiceGuardTests). Full text git.
|
- **[→ git pre-S60]** S60 recon#2 V2-engine-map (ApprovalWorkflow.cs Step/Level Order 1-based per-step; OR-of-N=N rows cùng Order service GroupBy:475; ApproveV2Async:446-634 guard+UPSERT+advance; notify DRAFTER-only:748; skipToFinal F2:561-602 = precedent advance-không-ghi-opinion) · S60 PE Section-3 submit-guard (submit path POST/pe/{id}/transitions→TransitionAsync:38 ROLE-only guard NO data-check; Section-3 mục a/b/c/d map — SUPERSEDED bởi S65ter post-Mig50 Budget-drop; test mirror PurchaseEvaluationWorkflowServiceGuardTests). Full text git.
|
||||||
|
|||||||
@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
|
|||||||
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
|
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
|
||||||
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
||||||
|
|
||||||
## 📊 Baseline 292 tests = 292 PASS (45 Domain + 247 Infra) ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60).
|
## 📊 Baseline 306 tests = 306 PASS (45 Domain + 261 Infra) ← S69b +14 PE 2 feature anh Kiệt FDC (test-before-merge SECURITY/FINANCIAL): `PeCcmThresholdFinalizeTests.cs` (5, Services ns, value-threshold CCM-finalize ApproveV2Async) + `PeUrgentToggleAuthzTests.cs` (9, Application ns, urgent-toggle role authz). Prev 292 ← S69 +6 Office golive permission-seed (`OfficeModulePermissionSeedTests.cs`, test-after, mirror HrmProfilePermissionSeedTests S67). Prev 286 ← S67 +23 HRM test-after [DepartmentTreeTests 8 cycle-guard/rollup/orphan + PeHoSoLinkTests 9 absolute-set (⚠️spec-drift: HoSoLink gửi null=CLEAR, KHÔNG null-safe như Budget*/WorkItemId) + HrmProfilePermissionSeedTests 6 reflection private-static revoke→seed chain]. **em main PROXY-RECORD** — return truncated #53 (chết lúc update MEMORY), 3 file delivered + `dotnet test` 286 PASS verify-on-disk. Prev 263 (S61 +22 PeWorkItemBudget −14 BudgetPolicy; Domain 58→45 drop Budget module). Pre = 254 (S60).
|
||||||
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active).
|
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture (đếm User.DepartmentId active).
|
||||||
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build)
|
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build)
|
||||||
|
|
||||||
@ -54,6 +54,8 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
|||||||
|
|
||||||
## 📅 Recent activity (last 10 FIFO)
|
## 📅 Recent activity (last 10 FIFO)
|
||||||
|
|
||||||
|
- **2026-06-17 (S69b PE 2 feature anh Kiệt FDC — test-before-merge SECURITY+FINANCIAL workflow):** +14 test → **292→306 PASS** (45 Domain + Infra 247→261, 0 fail). BE done+builds, mirror harness PeSubmitGuardAndBypassTests/PeWorkItemGuardTests. **FEATURE B value-threshold CCM-finalize (`PeCcmThresholdFinalizeTests.cs` 5, Services ns, ApproveV2Async line 816-854):** NV duyệt role=CostControl + `aw.CeoApprovalThreshold!=null` + `winnerQuoteTotal < ngưỡng` + chưa-slot-cuối → Phase=DaDuyet bỏ CEO + pointers/SLA null. **⭐ BOUNDARY load-bearing: predicate `winnerQuoteTotal < ceoThreshold` STRICT-less-than (line 838)** → gói==đúng-ngưỡng = KHÔNG finalize = advance. Cover: (1)⭐LOAD-BEARING CCM<ngưỡng mid-wf→DaDuyet skip-CEO pointers-null + chỉ CCM-slot opinion no CEO-opinion / (2) ==ngưỡng→advance Bước2(CEO) stays-ChoDuyet SLA+7d / (2b) >ngưỡng→advance / (3) threshold-null→advance kể-cả-CCM+gói-1đ (backward-compat) / (4) non-CCM(PRO)<ngưỡng→advance (chỉ CostControl trigger, nhận-diện-theo-role) / (5) CCM-at-last-slot<ngưỡng→DaDuyet via NORMAL-advance (guard `!(idx==last&&lvl==max)` skip finalize-branch, nhánh advance terminal cũng DaDuyet — no double, 1 Approve row). Harness: dựng PE TRỰC TIẾP ở ChoDuyet pin pointer slot CCM (skip submit guard) + drive 1 Approve; `SeedWorkflowAsync(stepApprovers, ceoThreshold)`. **FEATURE A urgent-toggle authz (`PeUrgentToggleAuthzTests.cs` 9, Application ns, SetPurchaseEvaluationUrgentCommandHandler 4-dep db+ICurrentUser+UserManager+INotificationService):** role→cờ: PRO→IsUrgentByPro / CCM→IsUrgentByCcm / Admin→CẢ2 / else→ForbiddenException. Notify-CEO best-effort try/catch — KHÔNG assert (NoOpNotificationService nuốt; CreateUserAsync idempotent-register role nên GetUsersInRoleAsync(Director) no-throw). Cover: PRO-only-ByPro(Ccm-untouched) / CCM-only-ByCcm / Admin-both / Drafter→Forbidden+no-mutation / Finance→Forbidden / PRO-turn-off clears-only-Pro Ccm-preserved / **multi-role PRO+CCM no-Admin→else-if short-circuit chỉ ByPro (LOCK behavior)** / unknown-PE→NotFound. **No prod bug** — cả 2 feature code đúng spec (strict-`<` intentional rollout-safe, else-if priority Admin>PRO>CCM intentional). FakeCurrentUser configurable-roles ctor. Reuse NoOpNotificationService internal qua `using ...Tests.Services`. Tag [s69b, pe-ccm-threshold-finalize, value-threshold, strict-less-than-boundary, role-based-routing, urgent-toggle-authz, forbidden-no-mutation, else-if-short-circuit, test-before-merge].
|
||||||
|
|
||||||
- **2026-06-17 (S69 Office golive permission-seed regression — test-after SECURITY invariant, public Văn phòng số):** +6 test `tests/.../Application/OfficeModulePermissionSeedTests.cs` → **286→292 PASS** (45 Domain + Infra 241→247, 0 fail). Mirror `HrmProfilePermissionSeedTests` (S67) — SAME reflection harness (invoke 2 private-static `RevokeTemporarilyHiddenModulesAsync` + `SeedAllRolesOfficeModulePermissionsAsync` qua `GetMethod(name, NonPublic|Static).Invoke(null, [db, rm, NullLogger.Instance])`; SqliteDbFixture/IdentityFixture; seed MenuItem rows TRƯỚC Permission FK Cascade). **KHÁC HRM:** Office grant mở **CanRead AND CanCreate** (HRM read-only) trên allow-list **16 key**; HRM chỉ 2 key. Chain = revoke (StartsWith("Off")→all false non-Admin) → office-grant (allow-list→read+create, upgrade-only). **Cover:** (1) chain non-Admin allow-list-16 → read+create=true + **excluded-3 stay hidden** (`OffPhongHopManage`/`OffAttendanceReport`/`OffChamCong` ⭐ LOAD-BEARING security assert) / (2) allow-list Update+Delete stay false / (3) no-leak HRM-dashboard+Personal stay hidden / (4) Admin not-revoked keeps all incl excluded-3 / (5) create-missing-row read+create=true update/delete=false + excluded NOT created / (6) **upgrade-only preserves admin-raised Update/Delete=true** (office-grant chỉ đụng Read/Create, KHÔNG hạ). **No prod bug** — seed logic đúng spec (excluded-3 confirmed hidden, upgrade-only không phá quyền admin). Tag [s69, office-golive, permission-seed, security-invariant, excluded-3-hidden, read+create-grant, upgrade-only, reflection-private-static, test-after].
|
- **2026-06-17 (S69 Office golive permission-seed regression — test-after SECURITY invariant, public Văn phòng số):** +6 test `tests/.../Application/OfficeModulePermissionSeedTests.cs` → **286→292 PASS** (45 Domain + Infra 241→247, 0 fail). Mirror `HrmProfilePermissionSeedTests` (S67) — SAME reflection harness (invoke 2 private-static `RevokeTemporarilyHiddenModulesAsync` + `SeedAllRolesOfficeModulePermissionsAsync` qua `GetMethod(name, NonPublic|Static).Invoke(null, [db, rm, NullLogger.Instance])`; SqliteDbFixture/IdentityFixture; seed MenuItem rows TRƯỚC Permission FK Cascade). **KHÁC HRM:** Office grant mở **CanRead AND CanCreate** (HRM read-only) trên allow-list **16 key**; HRM chỉ 2 key. Chain = revoke (StartsWith("Off")→all false non-Admin) → office-grant (allow-list→read+create, upgrade-only). **Cover:** (1) chain non-Admin allow-list-16 → read+create=true + **excluded-3 stay hidden** (`OffPhongHopManage`/`OffAttendanceReport`/`OffChamCong` ⭐ LOAD-BEARING security assert) / (2) allow-list Update+Delete stay false / (3) no-leak HRM-dashboard+Personal stay hidden / (4) Admin not-revoked keeps all incl excluded-3 / (5) create-missing-row read+create=true update/delete=false + excluded NOT created / (6) **upgrade-only preserves admin-raised Update/Delete=true** (office-grant chỉ đụng Read/Create, KHÔNG hạ). **No prod bug** — seed logic đúng spec (excluded-3 confirmed hidden, upgrade-only không phá quyền admin). Tag [s69, office-golive, permission-seed, security-invariant, excluded-3-hidden, read+create-grant, upgrade-only, reflection-private-static, test-after].
|
||||||
|
|
||||||
- **2026-06-12 (S60 UAT anh Kiệt — 2 feature PE submit branch, test-after build PASS):** +14 test `tests/.../Services/PeSubmitGuardAndBypassTests.cs` → **240→254 PASS** (58 Domain + Infra 182→196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLai→ChoDuyet) build `missing` list 4 mục → ConflictException msg gộp prefix `'Chưa đủ thông tin mục 3 "Đơn vị NCC/TP được chọn"...'` + join `' · '`. Cover: thiếu cả 4 / winner-only / winner+quote=0 / budget (cả null+manual=0) / comparison / **attachment gắn NCC (PES_Id!=null) KHÔNG đếm bảng so sánh = vẫn Conflict** (predicate PES_Id==null) / đủ-4-manual-budget→ChoDuyet / đủ-4-BudgetId→ChoDuyet. **F2 drafter-bypass (6, V2-only `ApplyDrafterBypassOnSubmitAsync`):** k=drafterSlots.Max(Order) bước đầu → auto Cấp 1..k. Cover: drafter=TP(2/2)+2bước→StepIdx=1/Lvl=1+opinion 1 row slot TP+2 AutoApprove / drafter=NV(1/2)→Lvl=2 cùng bước+opinion slot NV / drafter ngoài bước đầu→KHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuối→DaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmit→bypass áp lại opinion UPSERT 1 row + approval cộng dồn 2 vết. **⚠️ GUARD-FIRST:** mọi bypass-test PHẢI dựng PE đủ 4 ĐK Section 3 (winner+quote>0+manual-budget+comparison-attach) qua guard. **Seed pattern S60:** `SeedWinnerWithQuoteAsync`(PES+Detail+Quote ThanhTien) map winner→quote sum · `SeedComparisonAttachment`(PES_Id=null) · `SeedWorkflowAsync(Guid[][] stepApprovers)` build multi-step V2 1-NV/cấp. **Opinion-only-ownSlot invariant:** bypass cấp NV skip KHÔNG ghi opinion (chỉ Approval AutoApprove + Changelog vết); assert `opinions.HaveCount(1)` + `ApprovalWorkflowLevelId==drafterSlot.Id`. **No prod bug** — code đúng spec, test theo CODE (S34 rule). Tag [s60, pe-submit-guard, section3-completeness, drafter-bypass, v2-only, guard-first, opinion-ownslot-only, test-after].
|
- **2026-06-12 (S60 UAT anh Kiệt — 2 feature PE submit branch, test-after build PASS):** +14 test `tests/.../Services/PeSubmitGuardAndBypassTests.cs` → **240→254 PASS** (58 Domain + Infra 182→196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLai→ChoDuyet) build `missing` list 4 mục → ConflictException msg gộp prefix `'Chưa đủ thông tin mục 3 "Đơn vị NCC/TP được chọn"...'` + join `' · '`. Cover: thiếu cả 4 / winner-only / winner+quote=0 / budget (cả null+manual=0) / comparison / **attachment gắn NCC (PES_Id!=null) KHÔNG đếm bảng so sánh = vẫn Conflict** (predicate PES_Id==null) / đủ-4-manual-budget→ChoDuyet / đủ-4-BudgetId→ChoDuyet. **F2 drafter-bypass (6, V2-only `ApplyDrafterBypassOnSubmitAsync`):** k=drafterSlots.Max(Order) bước đầu → auto Cấp 1..k. Cover: drafter=TP(2/2)+2bước→StepIdx=1/Lvl=1+opinion 1 row slot TP+2 AutoApprove / drafter=NV(1/2)→Lvl=2 cùng bước+opinion slot NV / drafter ngoài bước đầu→KHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuối→DaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmit→bypass áp lại opinion UPSERT 1 row + approval cộng dồn 2 vết. **⚠️ GUARD-FIRST:** mọi bypass-test PHẢI dựng PE đủ 4 ĐK Section 3 (winner+quote>0+manual-budget+comparison-attach) qua guard. **Seed pattern S60:** `SeedWinnerWithQuoteAsync`(PES+Detail+Quote ThanhTien) map winner→quote sum · `SeedComparisonAttachment`(PES_Id=null) · `SeedWorkflowAsync(Guid[][] stepApprovers)` build multi-step V2 1-NV/cấp. **Opinion-only-ownSlot invariant:** bypass cấp NV skip KHÔNG ghi opinion (chỉ Approval AutoApprove + Changelog vết); assert `opinions.HaveCount(1)` + `ApprovalWorkflowLevelId==drafterSlot.Id`. **No prod bug** — code đúng spec, test theo CODE (S34 rule). Tag [s60, pe-submit-guard, section3-completeness, drafter-bypass, v2-only, guard-first, opinion-ownslot-only, test-after].
|
||||||
@ -66,8 +68,6 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
|||||||
|
|
||||||
- **2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after):** +5 test → **191 PASS** (Infra 128→133). 2 file `tests/.../Application/`: **ItTicketCodeGenTests** (3 — MaTicket regex `^IT/\d{4}/\d{3}$` + sequential 001→002 cùng prefix `IT/{year}` LastSeq++ + per-year-prefix 2027 reset 001) + **AttendanceReportTests** (2 — full aggregate day-type/weighted + DepartmentId filter). **⭐ Serializable-on-SQLite GOTCHA = NON-ISSUE (confirmed):** `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `BeginTransactionAsync(IsolationLevel.Serializable)` chạy SẠCH trên SQLite — provider map isolation level gracefully (no throw), format+seq+per-year đều hold KHÔNG cần try/skip. Đã proven sẵn bởi WorkflowAppApproveV2Tests (DT/LR path). Handler `CreateItTicketHandler(db, cu, clock)` = 3 dep MediatR. **Day-type test pattern (P11-E core):** holiday check chạy TRƯỚC weekend/weekday → seed 2026-06-01 (thứ Hai) vào holidaySet → assert phân **Holiday** dù là weekday (override day-of-week). Holiday.Date=DateOnly → `BuildHoliday` dùng `DateOnly.FromDateTime`. OtWeighted = 2×1.5+3×2.0+1×3.0=12.0m. DepartmentId filter: seed 2 Department row + 2 user khác dept → query deptA chỉ trả 1 row (handler join Users `u.DepartmentId==deptId`, userMeta dùng `DefaultIfEmpty` nên dept row optional nhưng seed cho DepartmentName assert). No prod bug. **⚠️ MSBuild OOM** chạy full parallel → dùng `-maxcpucount:1 -p:BuildInParallel=false` (env resource, KHÔNG test fail). Tag [s52, p11-e, p11-f, codegen, day-type, serializable-sqlite-ok, test-after].
|
- **2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after):** +5 test → **191 PASS** (Infra 128→133). 2 file `tests/.../Application/`: **ItTicketCodeGenTests** (3 — MaTicket regex `^IT/\d{4}/\d{3}$` + sequential 001→002 cùng prefix `IT/{year}` LastSeq++ + per-year-prefix 2027 reset 001) + **AttendanceReportTests** (2 — full aggregate day-type/weighted + DepartmentId filter). **⭐ Serializable-on-SQLite GOTCHA = NON-ISSUE (confirmed):** `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `BeginTransactionAsync(IsolationLevel.Serializable)` chạy SẠCH trên SQLite — provider map isolation level gracefully (no throw), format+seq+per-year đều hold KHÔNG cần try/skip. Đã proven sẵn bởi WorkflowAppApproveV2Tests (DT/LR path). Handler `CreateItTicketHandler(db, cu, clock)` = 3 dep MediatR. **Day-type test pattern (P11-E core):** holiday check chạy TRƯỚC weekend/weekday → seed 2026-06-01 (thứ Hai) vào holidaySet → assert phân **Holiday** dù là weekday (override day-of-week). Holiday.Date=DateOnly → `BuildHoliday` dùng `DateOnly.FromDateTime`. OtWeighted = 2×1.5+3×2.0+1×3.0=12.0m. DepartmentId filter: seed 2 Department row + 2 user khác dept → query deptA chỉ trả 1 row (handler join Users `u.DepartmentId==deptId`, userMeta dùng `DefaultIfEmpty` nên dept row optional nhưng seed cho DepartmentName assert). No prod bug. **⚠️ MSBuild OOM** chạy full parallel → dùng `-maxcpucount:1 -p:BuildInParallel=false` (env resource, KHÔNG test fail). Tag [s52, p11-e, p11-f, codegen, day-type, serializable-sqlite-ok, test-after].
|
||||||
- **2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57):** +4 test `tests/.../Application/HrmConfigFilteredUniqueTests.cs` → **185 total = 183 PASS + 2 RED** (Infra 123→127). Mirror HolidayTests Case 7 (seed soft-deleted Code-slot → Create same Code → assert success + active==1 + all==2). **2 GREEN** Vehicle+Driver (Mig 44 config ĐÃ filtered → 2 catalog mới đúng). **2 RED INTENTIONAL = gotcha #57 REPRODUCED** (test-before): `CreateLeaveType_OnSoftDeletedCodeSlot...` → `SQLite Error 19 UNIQUE constraint failed: LeaveTypes.Code` + `CreateShift_OnSoftDeletedCodeSlot...` → `ShiftPatterns.Code` (bare `.IsUnique()` đếm cả row soft-deleted; handler app-check `!IsDeleted` PASS → Add+SaveChanges → DbUpdateException). NOT test lỗi — REPORTED em main fix Mig 45 `.HasFilter("[IsDeleted]=0")` cho 2 config → flip GREEN. **⚠️ Soft-delete trong test (giống Holiday):** AuditingInterceptor (prod soft-delete Deleted→Modified+IsDeleted=true) KHÔNG wire trong SqliteDbFixture → `Remove+SaveChanges` = HARD delete (không test được). PHẢI seed row `IsDeleted=true` thủ công để mô phỏng slot bị chiếm. Handlers chỉ cần IApplicationDbContext → `new CreateXxxHandler(db)`. Tag [s51, p11-c, gotcha-57, filtered-unique, test-before].
|
- **2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57):** +4 test `tests/.../Application/HrmConfigFilteredUniqueTests.cs` → **185 total = 183 PASS + 2 RED** (Infra 123→127). Mirror HolidayTests Case 7 (seed soft-deleted Code-slot → Create same Code → assert success + active==1 + all==2). **2 GREEN** Vehicle+Driver (Mig 44 config ĐÃ filtered → 2 catalog mới đúng). **2 RED INTENTIONAL = gotcha #57 REPRODUCED** (test-before): `CreateLeaveType_OnSoftDeletedCodeSlot...` → `SQLite Error 19 UNIQUE constraint failed: LeaveTypes.Code` + `CreateShift_OnSoftDeletedCodeSlot...` → `ShiftPatterns.Code` (bare `.IsUnique()` đếm cả row soft-deleted; handler app-check `!IsDeleted` PASS → Add+SaveChanges → DbUpdateException). NOT test lỗi — REPORTED em main fix Mig 45 `.HasFilter("[IsDeleted]=0")` cho 2 config → flip GREEN. **⚠️ Soft-delete trong test (giống Holiday):** AuditingInterceptor (prod soft-delete Deleted→Modified+IsDeleted=true) KHÔNG wire trong SqliteDbFixture → `Remove+SaveChanges` = HARD delete (không test được). PHẢI seed row `IsDeleted=true` thủ công để mô phỏng slot bị chiếm. Handlers chỉ cần IApplicationDbContext → `new CreateXxxHandler(db)`. Tag [s51, p11-c, gotcha-57, filtered-unique, test-before].
|
||||||
- **2026-05-30 (S43 P11-B Wave3 LeaveBalance):** +8 test `tests/.../Application/LeaveBalanceTests.cs` → **152 PASS** (Infra 86→94). Deduction hook (ApproveLeaveRequestHandler terminal) full: deduct single-level (create row from DaysPerYear), only-at-terminal multi-level (advance no-deduct + 1× terminal), accumulate UPSERT (5+2=7 no new row), negative allowed (Used20>Entitled12 → Remaining−8 no throw), Reject+Return no-deduct (split 5a/5b), GetMyLeaveBalances lazy synth (2 active type filter inactive), AdjustLeaveBalance upsert. **⚠️ FOUND + FIXED 2 pre-existing RED** in S42 template (`Approve_LastLevel_TransitionsToDaDuyet` + `Approve_EmptyComment_StoresPlaceholder`): Wave 1 deduction hook (uncommitted, prod) làm terminal insert LeaveBalance FK→LeaveTypes Restrict FAIL vì BuildLeave dùng `LeaveTypeId=Guid.NewGuid()`. **NOT prod bug** (prod đơn luôn pin LeaveType thật) — fix tại test: BuildLeave +optional leaveTypeId, seed LeaveType ở 2 test đó. Baseline thật trước S43 = 142-pass/2-RED (KHÔNG phải 144-green). REPORTED em main.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Anti-patterns (DO NOT)
|
## ⚠️ Anti-patterns (DO NOT)
|
||||||
|
|||||||
@ -121,6 +121,13 @@ export function PeDetailTabs({
|
|||||||
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
// S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
|
||||||
|
// (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
|
||||||
|
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
|
||||||
|
const isPro = currentUser?.roles?.includes('Procurement') ?? false
|
||||||
|
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
|
||||||
|
const canToggleProUrgent = isAdmin || isPro
|
||||||
|
const canToggleCcmUrgent = isAdmin || isCcm
|
||||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
const actorMatchesLevel = isAdmin
|
const actorMatchesLevel = isAdmin
|
||||||
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
@ -155,6 +162,19 @@ export function PeDetailTabs({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
|
||||||
|
// CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
|
||||||
|
const toggleUrgent = useMutation({
|
||||||
|
mutationFn: async (isUrgent: boolean) =>
|
||||||
|
api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
|
||||||
|
onSuccess: (_d, isUrgent) => {
|
||||||
|
toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
||||||
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
|
||||||
@ -223,6 +243,17 @@ export function PeDetailTabs({
|
|||||||
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
||||||
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
||||||
</span>
|
</span>
|
||||||
|
{/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
|
||||||
|
{evaluation.isUrgentByPro && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-semibold text-red-700" title="Phòng Cung ứng (PRO) đánh dấu gấp">
|
||||||
|
🔴 GẤP (PRO)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{evaluation.isUrgentByCcm && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[11px] font-semibold text-green-700" title="Phòng Kiểm soát chi phí (CCM) đánh dấu gấp">
|
||||||
|
🟢 GẤP (CCM)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
chế độ duyệt
|
chế độ duyệt
|
||||||
@ -239,6 +270,56 @@ export function PeDetailTabs({
|
|||||||
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
||||||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
|
||||||
|
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
{canToggleProUrgent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={toggleUrgent.isPending}
|
||||||
|
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByPro)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||||
|
evaluation.isUrgentByPro
|
||||||
|
? 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'border-slate-300 bg-white text-slate-600 hover:border-red-300 hover:text-red-700',
|
||||||
|
)}
|
||||||
|
title="Cờ ĐỎ — Phòng Cung ứng (PRO) đánh dấu gấp"
|
||||||
|
>
|
||||||
|
🔴 {evaluation.isUrgentByPro ? 'Bỏ gấp (PRO)' : 'Đánh dấu GẤP (PRO)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canToggleCcmUrgent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={toggleUrgent.isPending}
|
||||||
|
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByCcm)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||||
|
evaluation.isUrgentByCcm
|
||||||
|
? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'
|
||||||
|
: 'border-slate-300 bg-white text-slate-600 hover:border-green-300 hover:text-green-700',
|
||||||
|
)}
|
||||||
|
title="Cờ XANH — Phòng Kiểm soát chi phí (CCM) đánh dấu gấp"
|
||||||
|
>
|
||||||
|
🟢 {evaluation.isUrgentByCcm ? 'Bỏ gấp (CCM)' : 'Đánh dấu GẤP (CCM)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
|
||||||
|
{evaluation.ceoApprovalThreshold != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-2 py-1 text-[11px] text-slate-600">
|
||||||
|
Giá trị gói: <strong className="text-slate-800">{fmtMoney(evaluation.winnerQuoteTotal)}đ</strong>
|
||||||
|
{' — '}
|
||||||
|
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
|
||||||
|
<span className="font-medium text-emerald-600">CCM duyệt là xong</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-rose-600">Cần CEO duyệt</span>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-400">(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
||||||
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
||||||
|
|||||||
@ -349,7 +349,12 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
>
|
>
|
||||||
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
|
||||||
|
{/* S69 — chấm gấp: ĐỎ (PRO) / XANH-lá (CCM) cạnh tên gói. */}
|
||||||
|
{p.isUrgentByPro && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Cung ứng (PRO)">🔴</span>}
|
||||||
|
{p.isUrgentByCcm && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Kiểm soát chi phí (CCM)">🟢</span>}
|
||||||
|
<span className="truncate">{p.tenGoiThau}</span>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
|||||||
@ -70,6 +70,9 @@ type DefinitionDto = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
|
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
|
||||||
|
// S69 — ngưỡng giá trị "gói CEO". Null = không áp ngưỡng (luồng đủ quy trình).
|
||||||
|
// Gói < ngưỡng: CCM duyệt-final không cần CEO. ≥ ngưỡng: phải lên CEO.
|
||||||
|
ceoApprovalThreshold: number | null
|
||||||
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
|
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
|
||||||
// ALL xuống per slot Level (xem LevelDto). Admin opt-in per-Approver-slot
|
// ALL xuống per slot Level (xem LevelDto). Admin opt-in per-Approver-slot
|
||||||
// pattern proven 3× cumulative: F1+F3 (5 flag Mig 29) + F4 (Mig 30) + F2
|
// pattern proven 3× cumulative: F1+F3 (5 flag Mig 29) + F4 (Mig 30) + F2
|
||||||
@ -393,6 +396,15 @@ function DefinitionCard({
|
|||||||
Cho user chọn
|
Cho user chọn
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* S69 — badge ngưỡng gói CEO (nếu có set) */}
|
||||||
|
{def.ceoApprovalThreshold != null && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-medium text-rose-700"
|
||||||
|
title="Gói ≥ ngưỡng phải lên CEO duyệt. Gói < ngưỡng: CCM duyệt là xong."
|
||||||
|
>
|
||||||
|
Ngưỡng CEO: {def.ceoApprovalThreshold.toLocaleString('vi-VN')}đ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
|
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
|
||||||
|
|
||||||
@ -498,6 +510,10 @@ function Designer({
|
|||||||
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
|
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
|
||||||
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
|
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
|
||||||
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
||||||
|
// S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
|
||||||
|
const [ceoThreshold, setCeoThreshold] = useState(
|
||||||
|
cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '',
|
||||||
|
)
|
||||||
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
||||||
|
|
||||||
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
|
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
|
||||||
@ -550,6 +566,8 @@ function Designer({
|
|||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
// S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
|
||||||
|
ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
|
||||||
steps: steps.map((s, i) => ({
|
steps: steps.map((s, i) => ({
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@ -628,6 +646,20 @@ function Designer({
|
|||||||
<Label>Mô tả</Label>
|
<Label>Mô tả</Label>
|
||||||
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
|
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* S69 — Ngưỡng giá trị gói CEO (nullable). Empty = không áp ngưỡng. */}
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Ngưỡng giá trị gói CEO (đ)</Label>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
|
||||||
|
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
|
||||||
|
placeholder="Để trống = luôn theo đủ quy trình"
|
||||||
|
/>
|
||||||
|
<div className="text-[11px] leading-relaxed text-slate-400">
|
||||||
|
Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO.
|
||||||
|
Để trống = luôn theo đủ quy trình.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
|
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
|
||||||
|
|||||||
@ -134,6 +134,9 @@ export type PeListItem = {
|
|||||||
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
||||||
budgetPeriodAmount: number | null
|
budgetPeriodAmount: number | null
|
||||||
expectedRemainingAmount: number | null
|
expectedRemainingAmount: number | null
|
||||||
|
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
|
||||||
|
isUrgentByPro: boolean
|
||||||
|
isUrgentByCcm: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeSupplier = {
|
export type PeSupplier = {
|
||||||
@ -432,6 +435,15 @@ export type PeDetailBundle = {
|
|||||||
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
||||||
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
|
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
|
||||||
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
||||||
|
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.
|
||||||
|
isUrgentByPro: boolean
|
||||||
|
isUrgentByCcm: boolean
|
||||||
|
// S69 — tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner). 0 khi chưa chọn.
|
||||||
|
// FE so với ceoApprovalThreshold → hint "CCM duyệt là xong" / "Cần CEO duyệt".
|
||||||
|
winnerQuoteTotal: number
|
||||||
|
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi
|
||||||
|
// chưa pin workflow V2 hoặc admin chưa set ngưỡng.
|
||||||
|
ceoApprovalThreshold: 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
|
||||||
|
|||||||
@ -121,6 +121,13 @@ export function PeDetailTabs({
|
|||||||
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
// S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
|
||||||
|
// (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
|
||||||
|
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
|
||||||
|
const isPro = currentUser?.roles?.includes('Procurement') ?? false
|
||||||
|
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
|
||||||
|
const canToggleProUrgent = isAdmin || isPro
|
||||||
|
const canToggleCcmUrgent = isAdmin || isCcm
|
||||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
const actorMatchesLevel = isAdmin
|
const actorMatchesLevel = isAdmin
|
||||||
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
@ -155,6 +162,19 @@ export function PeDetailTabs({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
|
||||||
|
// CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
|
||||||
|
const toggleUrgent = useMutation({
|
||||||
|
mutationFn: async (isUrgent: boolean) =>
|
||||||
|
api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
|
||||||
|
onSuccess: (_d, isUrgent) => {
|
||||||
|
toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
||||||
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||||
|
|
||||||
@ -223,6 +243,17 @@ export function PeDetailTabs({
|
|||||||
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
||||||
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
||||||
</span>
|
</span>
|
||||||
|
{/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
|
||||||
|
{evaluation.isUrgentByPro && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-semibold text-red-700" title="Phòng Cung ứng (PRO) đánh dấu gấp">
|
||||||
|
🔴 GẤP (PRO)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{evaluation.isUrgentByCcm && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[11px] font-semibold text-green-700" title="Phòng Kiểm soát chi phí (CCM) đánh dấu gấp">
|
||||||
|
🟢 GẤP (CCM)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
chế độ duyệt
|
chế độ duyệt
|
||||||
@ -239,6 +270,56 @@ export function PeDetailTabs({
|
|||||||
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
||||||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
|
||||||
|
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
{canToggleProUrgent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={toggleUrgent.isPending}
|
||||||
|
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByPro)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||||
|
evaluation.isUrgentByPro
|
||||||
|
? 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'border-slate-300 bg-white text-slate-600 hover:border-red-300 hover:text-red-700',
|
||||||
|
)}
|
||||||
|
title="Cờ ĐỎ — Phòng Cung ứng (PRO) đánh dấu gấp"
|
||||||
|
>
|
||||||
|
🔴 {evaluation.isUrgentByPro ? 'Bỏ gấp (PRO)' : 'Đánh dấu GẤP (PRO)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canToggleCcmUrgent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={toggleUrgent.isPending}
|
||||||
|
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByCcm)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||||
|
evaluation.isUrgentByCcm
|
||||||
|
? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'
|
||||||
|
: 'border-slate-300 bg-white text-slate-600 hover:border-green-300 hover:text-green-700',
|
||||||
|
)}
|
||||||
|
title="Cờ XANH — Phòng Kiểm soát chi phí (CCM) đánh dấu gấp"
|
||||||
|
>
|
||||||
|
🟢 {evaluation.isUrgentByCcm ? 'Bỏ gấp (CCM)' : 'Đánh dấu GẤP (CCM)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
|
||||||
|
{evaluation.ceoApprovalThreshold != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-2 py-1 text-[11px] text-slate-600">
|
||||||
|
Giá trị gói: <strong className="text-slate-800">{fmtMoney(evaluation.winnerQuoteTotal)}đ</strong>
|
||||||
|
{' — '}
|
||||||
|
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
|
||||||
|
<span className="font-medium text-emerald-600">CCM duyệt là xong</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-rose-600">Cần CEO duyệt</span>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-400">(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
||||||
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
||||||
|
|||||||
@ -349,7 +349,12 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
>
|
>
|
||||||
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
|
||||||
|
{/* S69 — chấm gấp: ĐỎ (PRO) / XANH-lá (CCM) cạnh tên gói. */}
|
||||||
|
{p.isUrgentByPro && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Cung ứng (PRO)">🔴</span>}
|
||||||
|
{p.isUrgentByCcm && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Kiểm soát chi phí (CCM)">🟢</span>}
|
||||||
|
<span className="truncate">{p.tenGoiThau}</span>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
|||||||
@ -138,6 +138,9 @@ export type PeListItem = {
|
|||||||
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
||||||
budgetPeriodAmount: number | null
|
budgetPeriodAmount: number | null
|
||||||
expectedRemainingAmount: number | null
|
expectedRemainingAmount: number | null
|
||||||
|
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
|
||||||
|
isUrgentByPro: boolean
|
||||||
|
isUrgentByCcm: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeSupplier = {
|
export type PeSupplier = {
|
||||||
@ -434,6 +437,15 @@ export type PeDetailBundle = {
|
|||||||
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
||||||
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
|
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
|
||||||
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
||||||
|
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.
|
||||||
|
isUrgentByPro: boolean
|
||||||
|
isUrgentByCcm: boolean
|
||||||
|
// S69 — tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner). 0 khi chưa chọn.
|
||||||
|
// FE so với ceoApprovalThreshold → hint "CCM duyệt là xong" / "Cần CEO duyệt".
|
||||||
|
winnerQuoteTotal: number
|
||||||
|
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi
|
||||||
|
// chưa pin workflow V2 hoặc admin chưa set ngưỡng.
|
||||||
|
ceoApprovalThreshold: 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
|
||||||
|
|||||||
@ -85,6 +85,17 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
|||||||
}
|
}
|
||||||
public record BudgetCcmBody(decimal? InitialAmount, decimal? AdjustmentAmount);
|
public record BudgetCcmBody(decimal? InitialAmount, decimal? AdjustmentAmount);
|
||||||
|
|
||||||
|
// [S69 2026-06-17] Cờ gấp (urgent) — anh Kiệt FDC. Class [Authorize] any-auth;
|
||||||
|
// handler fine-grained Forbidden theo role (PRO=Procurement set cờ đỏ, CCM=
|
||||||
|
// CostControl set cờ xanh, Admin cả 2). Bật → notify CEO (Director). Visibility-only.
|
||||||
|
[HttpPut("{id:guid}/urgent")]
|
||||||
|
public async Task<IActionResult> SetUrgent(Guid id, [FromBody] SetUrgentBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SetPurchaseEvaluationUrgentCommand(id, body.IsUrgent), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
public record SetUrgentBody(bool IsUrgent);
|
||||||
|
|
||||||
[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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -57,6 +57,9 @@ public record AwDefinitionDto(
|
|||||||
string? Description,
|
string? Description,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
bool IsUserSelectable,
|
bool IsUserSelectable,
|
||||||
|
// [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — admin Designer hiển thị + edit.
|
||||||
|
// Null = không áp ngưỡng (luồng tuyến tính cũ).
|
||||||
|
decimal? CeoApprovalThreshold,
|
||||||
// Mig 29 (S21 t5) — 6 advanced options đã MOVE per-NV: F1+F3 xuống AwLevelDto
|
// Mig 29 (S21 t5) — 6 advanced options đã MOVE per-NV: F1+F3 xuống AwLevelDto
|
||||||
// (per slot Approver). Workflow-level Mig 28 dropped.
|
// (per slot Approver). Workflow-level Mig 28 dropped.
|
||||||
// Mig 31 (S23 t1) — F2 cũng refactor xuống Level slot (AllowApproverSkipToFinal
|
// Mig 31 (S23 t1) — F2 cũng refactor xuống Level slot (AllowApproverSkipToFinal
|
||||||
@ -153,6 +156,7 @@ public class GetAwAdminOverviewQueryHandler(
|
|||||||
d.Description,
|
d.Description,
|
||||||
d.IsActive,
|
d.IsActive,
|
||||||
d.IsUserSelectable,
|
d.IsUserSelectable,
|
||||||
|
d.CeoApprovalThreshold, // [S69] ngưỡng gói CEO
|
||||||
d.ActivatedAt,
|
d.ActivatedAt,
|
||||||
d.CreatedAt,
|
d.CreatedAt,
|
||||||
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
|
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
|
||||||
@ -221,7 +225,12 @@ public record CreateAwDefinitionCommand(
|
|||||||
string Code,
|
string Code,
|
||||||
string Name,
|
string Name,
|
||||||
string? Description,
|
string? Description,
|
||||||
List<CreateAwStepInput> Steps) : IRequest<Guid>;
|
List<CreateAwStepInput> Steps,
|
||||||
|
// [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — admin nhập trong Workflow Designer.
|
||||||
|
// CCM (CostControl) duyệt-final không cần lên CEO khi tổng giá NCC được chọn <
|
||||||
|
// ngưỡng; ≥ ngưỡng → vẫn lên CEO (Director). NULL = không áp ngưỡng (luồng cũ).
|
||||||
|
// Nullable passthrough — trailing-optional default backward-compat call-site cũ.
|
||||||
|
decimal? CeoApprovalThreshold = null) : IRequest<Guid>;
|
||||||
|
|
||||||
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
||||||
{
|
{
|
||||||
@ -243,6 +252,10 @@ public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefi
|
|||||||
RuleFor(x => x.Description).MaximumLength(1000);
|
RuleFor(x => x.Description).MaximumLength(1000);
|
||||||
RuleFor(x => x.Steps).NotEmpty()
|
RuleFor(x => x.Steps).NotEmpty()
|
||||||
.WithMessage("Quy trình phải có ít nhất 1 bước.");
|
.WithMessage("Quy trình phải có ít nhất 1 bước.");
|
||||||
|
// [S69] Ngưỡng gói CEO — nếu nhập phải >= 0 (NULL = không áp ngưỡng OK).
|
||||||
|
RuleFor(x => x.CeoApprovalThreshold).GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.CeoApprovalThreshold.HasValue)
|
||||||
|
.WithMessage("Ngưỡng giá trị gói CEO không được âm.");
|
||||||
RuleForEach(x => x.Steps).ChildRules(step =>
|
RuleForEach(x => x.Steps).ChildRules(step =>
|
||||||
{
|
{
|
||||||
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
||||||
@ -315,6 +328,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick
|
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick
|
||||||
// Mig 29 (S21 t5) — Allow* options đã move xuống Level slot (per-NV)
|
// Mig 29 (S21 t5) — Allow* options đã move xuống Level slot (per-NV)
|
||||||
|
CeoApprovalThreshold = request.CeoApprovalThreshold, // [S69] ngưỡng gói CEO (nullable passthrough)
|
||||||
ActivatedAt = DateTime.UtcNow,
|
ActivatedAt = DateTime.UtcNow,
|
||||||
Steps = request.Steps.OrderBy(s => s.Order)
|
Steps = request.Steps.OrderBy(s => s.Order)
|
||||||
.Select(s => new ApprovalWorkflowStep
|
.Select(s => new ApprovalWorkflowStep
|
||||||
|
|||||||
@ -152,6 +152,7 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
|
|||||||
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,
|
e.DepartmentId, d != null ? d.Name : null,
|
||||||
e.BudgetPeriodAmount, e.ExpectedRemainingAmount)).ToListAsync(ct);
|
e.BudgetPeriodAmount, e.ExpectedRemainingAmount,
|
||||||
|
e.IsUrgentByPro, e.IsUrgentByCcm)).ToListAsync(ct); // [S69] cờ gấp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,11 @@ public record PurchaseEvaluationListItemDto(
|
|||||||
// [S61 Mig 50] 2 cột ngân sách mới (mirror detail — FE list chưa render, giữ
|
// [S61 Mig 50] 2 cột ngân sách mới (mirror detail — FE list chưa render, giữ
|
||||||
// parity type PeListItem).
|
// parity type PeListItem).
|
||||||
decimal? BudgetPeriodAmount,
|
decimal? BudgetPeriodAmount,
|
||||||
decimal? ExpectedRemainingAmount);
|
decimal? ExpectedRemainingAmount,
|
||||||
|
// [S69 2026-06-17] Cờ gấp per-vai — FE render badge ĐỎ (PRO) / XANH (CCM) trên
|
||||||
|
// card list + ưu tiên hiển thị. 2 cờ độc lập.
|
||||||
|
bool IsUrgentByPro,
|
||||||
|
bool IsUrgentByCcm);
|
||||||
|
|
||||||
public record PurchaseEvaluationSupplierDto(
|
public record PurchaseEvaluationSupplierDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@ -230,6 +234,17 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
decimal? BudgetPeriodAmount,
|
decimal? BudgetPeriodAmount,
|
||||||
decimal? ExpectedRemainingAmount,
|
decimal? ExpectedRemainingAmount,
|
||||||
PeBudgetSummaryDto? BudgetSummary,
|
PeBudgetSummaryDto? BudgetSummary,
|
||||||
|
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
|
||||||
|
bool IsUrgentByPro,
|
||||||
|
bool IsUrgentByCcm,
|
||||||
|
// [S69] Tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner quote total) — SUM
|
||||||
|
// ThanhTien các báo giá thuộc supplier-rows của SelectedSupplierId. 0 khi chưa
|
||||||
|
// chọn. Mirror predicate submit-guard (PurchaseEvaluationWorkflowService ~:188).
|
||||||
|
// FE so với CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
|
||||||
|
decimal WinnerQuoteTotal,
|
||||||
|
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
|
||||||
|
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.
|
||||||
|
decimal? CeoApprovalThreshold,
|
||||||
// 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,
|
||||||
|
|||||||
@ -588,7 +588,8 @@ public class ListPurchaseEvaluationsQueryHandler(
|
|||||||
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))
|
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
|
||||||
|
x.e.IsUrgentByPro, x.e.IsUrgentByCcm))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
|
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
|
||||||
@ -678,7 +679,8 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
|||||||
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))
|
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
|
||||||
|
x.e.IsUrgentByPro, x.e.IsUrgentByCcm))
|
||||||
.Take(100)
|
.Take(100)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
@ -880,6 +882,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
|
|
||||||
string? awCode = null, awName = null;
|
string? awCode = null, awName = null;
|
||||||
int? awVersion = null;
|
int? awVersion = null;
|
||||||
|
decimal? awCeoThreshold = null; // [S69] ngưỡng gói CEO từ workflow pin
|
||||||
ApprovalWorkflowOptionsDto? currentLevelOptions = null;
|
ApprovalWorkflowOptionsDto? currentLevelOptions = null;
|
||||||
PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
|
PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
|
||||||
PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
|
PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
|
||||||
@ -894,6 +897,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
awCode = aw.Code;
|
awCode = aw.Code;
|
||||||
awName = aw.Name;
|
awName = aw.Name;
|
||||||
awVersion = aw.Version;
|
awVersion = aw.Version;
|
||||||
|
awCeoThreshold = aw.CeoApprovalThreshold; // [S69] ngưỡng gói CEO
|
||||||
|
|
||||||
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — Resolve
|
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — Resolve
|
||||||
// Cấp hiện tại + populate 7 Allow* flag của slot Approver đang
|
// Cấp hiện tại + populate 7 Allow* flag của slot Approver đang
|
||||||
@ -1034,6 +1038,18 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [S69] Giá trị gói = tổng ThanhTien báo giá của NCC/TP ĐƯỢC CHỌN (winner).
|
||||||
|
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
|
||||||
|
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
|
||||||
|
var winnerSupplierRowIds = e.Suppliers
|
||||||
|
.Where(s => s.SupplierId == e.SelectedSupplierId)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
var winnerQuoteTotal = e.Details
|
||||||
|
.SelectMany(det => det.Quotes)
|
||||||
|
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||||
|
.Sum(q => q.ThanhTien);
|
||||||
|
|
||||||
return new PurchaseEvaluationDetailBundleDto(
|
return new PurchaseEvaluationDetailBundleDto(
|
||||||
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
|
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
|
||||||
e.HoSoLink, // [HoSoLink] hyperlink thư mục hồ sơ NAS
|
e.HoSoLink, // [HoSoLink] hyperlink thư mục hồ sơ NAS
|
||||||
@ -1045,6 +1061,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
e.ContractId,
|
e.ContractId,
|
||||||
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
||||||
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
|
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
|
||||||
|
e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO
|
||||||
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
|
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
|
||||||
currentApproval, approvalFlow,
|
currentApproval, approvalFlow,
|
||||||
e.Suppliers
|
e.Suppliers
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// [S69 2026-06-17] Cờ gấp (urgent) phiếu Duyệt NCC — anh Kiệt FDC. PRO (role
|
||||||
|
// Procurement) bật/tắt cờ ĐỎ (IsUrgentByPro), CCM (role CostControl) bật/tắt cờ XANH
|
||||||
|
// (IsUrgentByCcm), Admin set CẢ 2. Khi MỚI bật (false→true) → notify CEO (role Director)
|
||||||
|
// để "biết gấp + xử lý sớm". Q3 chốt: VISIBILITY-ONLY — KHÔNG đổi luồng duyệt (ai duyệt
|
||||||
|
// vẫn theo ngưỡng giá trị). Mirror cấu trúc command PE hiện có (AdjustBudget/UpsertOpinion).
|
||||||
|
public record SetPurchaseEvaluationUrgentCommand(Guid Id, bool IsUrgent) : IRequest;
|
||||||
|
|
||||||
|
public class SetPurchaseEvaluationUrgentCommandValidator : AbstractValidator<SetPurchaseEvaluationUrgentCommand>
|
||||||
|
{
|
||||||
|
public SetPurchaseEvaluationUrgentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SetPurchaseEvaluationUrgentCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
INotificationService notifications) : IRequestHandler<SetPurchaseEvaluationUrgentCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SetPurchaseEvaluationUrgentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
|
var roles = currentUser.Roles;
|
||||||
|
var isAdmin = roles.Contains(AppRoles.Admin);
|
||||||
|
var isPro = roles.Contains(AppRoles.Procurement);
|
||||||
|
var isCcm = roles.Contains(AppRoles.CostControl);
|
||||||
|
|
||||||
|
if (!isAdmin && !isPro && !isCcm)
|
||||||
|
throw new ForbiddenException("Chỉ PRO (Procurement) / CCM (CostControl) / Admin được đánh dấu phiếu gấp.");
|
||||||
|
|
||||||
|
// Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần.
|
||||||
|
var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm;
|
||||||
|
|
||||||
|
// Role quyết định cờ nào: PRO → ĐỎ (ByPro), CCM → XANH (ByCcm), Admin → CẢ 2.
|
||||||
|
if (isAdmin)
|
||||||
|
{
|
||||||
|
entity.IsUrgentByPro = request.IsUrgent;
|
||||||
|
entity.IsUrgentByCcm = request.IsUrgent;
|
||||||
|
}
|
||||||
|
else if (isPro)
|
||||||
|
{
|
||||||
|
entity.IsUrgentByPro = request.IsUrgent;
|
||||||
|
}
|
||||||
|
else // isCcm
|
||||||
|
{
|
||||||
|
entity.IsUrgentByCcm = request.IsUrgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Notify CEO (Director) khi MỚI bật gấp (false→true). Best-effort — KHÔNG fail
|
||||||
|
// toggle nếu notify lỗi (Q3 visibility-only, cờ đã lưu thành công).
|
||||||
|
var nowUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm;
|
||||||
|
if (request.IsUrgent && nowUrgent && !wasUrgent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directors = await userManager.GetUsersInRoleAsync(AppRoles.Director);
|
||||||
|
var directorIds = directors.Select(u => u.Id).ToList();
|
||||||
|
if (directorIds.Count > 0)
|
||||||
|
{
|
||||||
|
await notifications.NotifyManyAsync(
|
||||||
|
directorIds,
|
||||||
|
NotificationType.Generic,
|
||||||
|
$"Phiếu Duyệt NCC {entity.MaPhieu ?? entity.TenGoiThau} được đánh dấu GẤP",
|
||||||
|
"Phiếu cần được xử lý sớm.",
|
||||||
|
$"/purchase-evaluations/{entity.Id}",
|
||||||
|
entity.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// best-effort — nuốt lỗi notify, toggle cờ gấp đã lưu thành công.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,14 @@ public class ApprovalWorkflow : BaseEntity
|
|||||||
// khi tạo version mới (mirror IsActive default), admin có thể unstick.
|
// khi tạo version mới (mirror IsActive default), admin có thể unstick.
|
||||||
public bool IsUserSelectable { get; set; }
|
public bool IsUserSelectable { get; set; }
|
||||||
|
|
||||||
|
// [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — anh Kiệt FDC: CCM (role CostControl)
|
||||||
|
// được duyệt-final KHÔNG cần lên CEO khi giá trị gói (tổng giá NCC được chọn,
|
||||||
|
// winnerQuoteTotal) < ngưỡng này; ≥ ngưỡng → vẫn đẩy lên CEO (Director) như luồng
|
||||||
|
// tuyến tính cũ. NULL = KHÔNG áp ngưỡng (giữ luồng cũ 100% — backward compat,
|
||||||
|
// rollout an toàn cho tới khi admin set). Admin nhập trong Workflow Designer. So
|
||||||
|
// sánh ở ApproveV2Async khi actor có role CostControl. decimal(18,2).
|
||||||
|
public decimal? CeoApprovalThreshold { get; set; }
|
||||||
|
|
||||||
// Mig 28 cũ 6 column workflow-level Allow* đã DROP trong Mig 29 (S21 t5).
|
// Mig 28 cũ 6 column workflow-level Allow* đã DROP trong Mig 29 (S21 t5).
|
||||||
// Refactor sang per-NV (per ApprovalWorkflowLevel slot + Users F2). Backfill
|
// Refactor sang per-NV (per ApprovalWorkflowLevel slot + Users F2). Backfill
|
||||||
// bulk SQL copy workflow → all Levels của workflow trước khi DROP — preserve
|
// bulk SQL copy workflow → all Levels của workflow trước khi DROP — preserve
|
||||||
|
|||||||
@ -57,6 +57,14 @@ public class PurchaseEvaluation : AuditableEntity
|
|||||||
// hiện tại khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc terminal.
|
// hiện tại khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc terminal.
|
||||||
public int? CurrentApprovalLevelOrder { get; set; }
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
|
// [S69 2026-06-17] Cờ gấp (urgent) — anh Kiệt FDC: PRO (role Procurement) bật cờ
|
||||||
|
// ĐỎ, CCM (role CostControl) bật cờ XANH — 2 cờ độc lập per-vai. Gói gấp → notify
|
||||||
|
// CEO (Director) biết gấp + xử lý sớm. VISIBILITY-ONLY: KHÔNG đổi luồng duyệt (ai
|
||||||
|
// duyệt vẫn theo ngưỡng giá trị) — chỉ badge + thông báo + ưu tiên list. Toggle qua
|
||||||
|
// endpoint theo role actor (PRO ↔ IsUrgentByPro, CCM ↔ IsUrgentByCcm).
|
||||||
|
public bool IsUrgentByPro { get; set; }
|
||||||
|
public bool IsUrgentByCcm { get; set; }
|
||||||
|
|
||||||
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
||||||
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
||||||
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
||||||
|
|||||||
@ -16,6 +16,10 @@ public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWo
|
|||||||
e.Property(x => x.Description).HasMaxLength(1000);
|
e.Property(x => x.Description).HasMaxLength(1000);
|
||||||
e.Property(x => x.ApplicableType).HasConversion<int>();
|
e.Property(x => x.ApplicableType).HasConversion<int>();
|
||||||
|
|
||||||
|
// [S69 2026-06-17] Ngưỡng giá trị "gói CEO" — decimal(18,2), nullable
|
||||||
|
// (null = không áp ngưỡng, giữ luồng tuyến tính cũ).
|
||||||
|
e.Property(x => x.CeoApprovalThreshold).HasPrecision(18, 2);
|
||||||
|
|
||||||
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
|
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
|
||||||
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
|
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPeUrgentAndCeoApprovalThreshold : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsUrgentByCcm",
|
||||||
|
table: "PurchaseEvaluations",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsUrgentByPro",
|
||||||
|
table: "PurchaseEvaluations",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CeoApprovalThreshold",
|
||||||
|
table: "ApprovalWorkflows",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsUrgentByCcm",
|
||||||
|
table: "PurchaseEvaluations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsUrgentByPro",
|
||||||
|
table: "PurchaseEvaluations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CeoApprovalThreshold",
|
||||||
|
table: "ApprovalWorkflows");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -137,6 +137,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<int>("ApplicableType")
|
b.Property<int>("ApplicableType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("CeoApprovalThreshold")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("Code")
|
b.Property<string>("Code")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@ -4624,6 +4628,12 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsUrgentByCcm")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsUrgentByPro")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("MaPhieu")
|
b.Property<string>("MaPhieu")
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|||||||
@ -813,6 +813,46 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [S69 2026-06-17] CCM duyệt-final theo NGƯỠNG GIÁ TRỊ (anh Kiệt FDC). Khi NV
|
||||||
|
// duyệt có role CostControl (CCM) + quy trình set CeoApprovalThreshold + giá trị
|
||||||
|
// gói (tổng giá NCC được chọn, winnerQuoteTotal) < ngưỡng → DaDuyet luôn, BỎ các
|
||||||
|
// Bước/Cấp còn lại (CEO). Q4 chốt: nhận diện theo ROLE người duyệt. Ngưỡng null =
|
||||||
|
// bỏ qua (luồng tuyến tính cũ — rollout an toàn). Cờ gấp KHÔNG ảnh hưởng routing
|
||||||
|
// (visibility-only, Q3). Guard "chưa ở slot cuối" → chỉ skip-forward (nếu CCM đã
|
||||||
|
// ở Cấp cuối Bước cuối thì normal-advance bên dưới cũng ra DaDuyet). Giả định quy
|
||||||
|
// trình đặt CCM ngay trước CEO — UAT anh Kiệt xác nhận cấu trúc.
|
||||||
|
if (aw.CeoApprovalThreshold is decimal ceoThreshold
|
||||||
|
&& actorRoles.Contains(AppRoles.CostControl)
|
||||||
|
&& !(currentIdx == steps.Count - 1 && currentLevelOrder == maxLevelOrder))
|
||||||
|
{
|
||||||
|
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||||
|
.Where(s => s.PurchaseEvaluationId == evaluation.Id
|
||||||
|
&& s.SupplierId == evaluation.SelectedSupplierId)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m
|
||||||
|
: await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||||
|
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||||
|
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||||
|
|
||||||
|
if (winnerQuoteTotal < ceoThreshold)
|
||||||
|
{
|
||||||
|
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = null;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(
|
||||||
|
evaluation,
|
||||||
|
PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
actorUserId,
|
||||||
|
ApprovalDecision.Approve,
|
||||||
|
$"[CCM duyệt cuối — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ, không cần CEO duyệt] {comment ?? ""}".Trim(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
|
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
|
||||||
if (currentLevelOrder < maxLevelOrder)
|
if (currentLevelOrder < maxLevelOrder)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,235 @@
|
|||||||
|
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.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// ===== FEATURE A (S69 anh Kiệt FDC) — urgent-toggle authz =====
|
||||||
|
// SetPurchaseEvaluationUrgentCommandHandler (PurchaseEvaluationUrgentFeatures.cs).
|
||||||
|
// Test theo CODE đã land (S34 rule — KHÔNG touch production).
|
||||||
|
//
|
||||||
|
// Logic role → cờ:
|
||||||
|
// - Procurement (PRO) → set IsUrgentByPro (IsUrgentByCcm KHÔNG đụng)
|
||||||
|
// - CostControl (CCM) → set IsUrgentByCcm (IsUrgentByPro KHÔNG đụng)
|
||||||
|
// - Admin → set CẢ 2 cờ
|
||||||
|
// - role khác (Drafter/Finance/...) → ForbiddenException (không lưu gì)
|
||||||
|
//
|
||||||
|
// Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert
|
||||||
|
// notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt
|
||||||
|
// call → try block không throw. UserManager lấy từ IdentityFixture (GetUsersInRoleAsync
|
||||||
|
// trả rỗng khi không seed Director — best-effort no-op, đúng visibility-only Q3).
|
||||||
|
//
|
||||||
|
// Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService).
|
||||||
|
// FakeCurrentUser configurable Roles per scenario.
|
||||||
|
public class PeUrgentToggleAuthzTests
|
||||||
|
{
|
||||||
|
// ICurrentUser stub — Roles set per test (giống FakeCurrentUser PeWorkItemGuardTests
|
||||||
|
// nhưng configurable roles thay vì hardcode Drafter).
|
||||||
|
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid? UserId { get; } = Guid.NewGuid();
|
||||||
|
public string? Email { get; } = "actor@test.local";
|
||||||
|
public string? FullName { get; } = "Actor Test";
|
||||||
|
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
|
||||||
|
public bool IsAuthenticated => UserId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SetPurchaseEvaluationUrgentCommandHandler BuildHandler(
|
||||||
|
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
|
||||||
|
=> new(db, currentUser, um, new NoOpNotificationService());
|
||||||
|
|
||||||
|
// PE chưa gấp (cả 2 cờ false) — state mặc định để verify role nào set cờ nào.
|
||||||
|
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||||
|
TestApplicationDbContext db,
|
||||||
|
bool urgentByPro = false,
|
||||||
|
bool urgentByCcm = false,
|
||||||
|
string code = "PE-URG-001")
|
||||||
|
{
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = code,
|
||||||
|
TenGoiThau = "Gói thầu test urgent",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
IsUrgentByPro = urgentByPro,
|
||||||
|
IsUrgentByCcm = urgentByCcm,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 1. Procurement → set CHỈ IsUrgentByPro=true, IsUrgentByCcm KHÔNG đụng.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Procurement_SetsOnlyIsUrgentByPro_CcmUntouched()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db);
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||||
|
|
||||||
|
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro)");
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeFalse("PRO KHÔNG đụng cờ XANH (ByCcm)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 2. CostControl → set CHỈ IsUrgentByCcm=true, IsUrgentByPro KHÔNG đụng.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task CostControl_SetsOnlyIsUrgentByCcm_ProUntouched()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db, code: "PE-URG-002");
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl));
|
||||||
|
|
||||||
|
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm)");
|
||||||
|
reloaded.IsUrgentByPro.Should().BeFalse("CCM KHÔNG đụng cờ ĐỎ (ByPro)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 3. Admin → set CẢ 2 cờ true.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_SetsBothFlags()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db, code: "PE-URG-003");
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
|
||||||
|
|
||||||
|
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByPro.Should().BeTrue("Admin set cả ĐỎ");
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeTrue("Admin set cả XANH");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 4. Role không có quyền (Drafter) → ForbiddenException, KHÔNG lưu gì.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Drafter_ThrowsForbidden_NoFlagSet()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db, code: "PE-URG-004");
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Drafter));
|
||||||
|
|
||||||
|
var act = async () => await handler.Handle(
|
||||||
|
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>()
|
||||||
|
.WithMessage("*PRO*CCM*Admin*");
|
||||||
|
|
||||||
|
// Side-effect: guard throw TRƯỚC mutate → cả 2 cờ giữ false.
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByPro.Should().BeFalse("Drafter bị chặn → không set cờ");
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 4b. Role Finance (cũng ngoài allow-list) → ForbiddenException. Cover thêm 1
|
||||||
|
// role thường để chắc chắn KHÔNG chỉ Drafter bị chặn.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Finance_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db, code: "PE-URG-004B");
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance));
|
||||||
|
|
||||||
|
var act = async () => await handler.Handle(
|
||||||
|
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 5. IsUrgent=false (tắt cờ) — PRO tắt chỉ ByPro, ByCcm giữ nguyên (vd CCM đã
|
||||||
|
// bật trước đó). Verify partial-clear theo role + KHÔNG đụng cờ role khác.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Procurement_TurnOff_ClearsOnlyProFlag_CcmFlagPreserved()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
// State: cả 2 cờ đang bật (PRO đã bật ĐỎ, CCM đã bật XANH).
|
||||||
|
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-005");
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||||
|
|
||||||
|
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByPro.Should().BeFalse("PRO tắt cờ ĐỎ của mình");
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 6. Multi-role actor có CẢ Procurement (không Admin) — vẫn chỉ là PRO-path
|
||||||
|
// (Admin > PRO > CCM trong if-else). Đây actor PRO → set ByPro only.
|
||||||
|
// (Edge: nếu sau này role priority đổi → test này đỏ → review chủ đích.)
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ActorWithBothProAndCcmRoles_NoAdmin_SetsBothFlagsViaElseIfChain_LocksBehavior()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var pe = await SeedPeAsync(db, code: "PE-URG-006");
|
||||||
|
// Actor mang CẢ Procurement + CostControl (KHÔNG Admin).
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.CostControl));
|
||||||
|
|
||||||
|
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
// Code path (line 51-63): isAdmin=false → else-if isPro=true → CHỈ set ByPro
|
||||||
|
// (else-if chain ngắn mạch, KHÔNG vào nhánh isCcm). LOCK behavior hiện tại:
|
||||||
|
// PRO ưu tiên hơn CCM khi user kiêm 2 role nhưng không Admin → chỉ ByPro.
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.IsUrgentByPro.Should().BeTrue("PRO branch chạy (else-if đầu khớp)");
|
||||||
|
reloaded.IsUrgentByCcm.Should().BeFalse(
|
||||||
|
"else-if ngắn mạch — actor kiêm PRO+CCM (no Admin) chỉ set ByPro, KHÔNG vào nhánh CCM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 7. PE không tồn tại → NotFoundException (guard đầu handler trước authz).
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownPe_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||||
|
|
||||||
|
var act = async () => await handler.Handle(
|
||||||
|
new SetPurchaseEvaluationUrgentCommand(Guid.NewGuid(), IsUrgent: true), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,389 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// ===== FEATURE B (S69 anh Kiệt FDC) — value-threshold CCM-finalize =====
|
||||||
|
// PurchaseEvaluationWorkflowService.ApproveV2Async, line 816-854 prod (test theo
|
||||||
|
// CODE — S34 rule, KHÔNG touch production).
|
||||||
|
//
|
||||||
|
// Logic: khi NV đang duyệt (ChoDuyet, decision=Approve) có role AppRoles.CostControl
|
||||||
|
// (CCM) VÀ aw.CeoApprovalThreshold != null VÀ winnerQuoteTotal (SUM ThanhTien của
|
||||||
|
// các báo giá của NCC được chọn) < ngưỡng VÀ chưa-ở-slot-cuối → set Phase=DaDuyet
|
||||||
|
// (bỏ qua các Bước/Cấp còn lại, incl CEO), pointers null, SLA null.
|
||||||
|
// Else → advance tuyến tính bình thường.
|
||||||
|
//
|
||||||
|
// ⚠️ BOUNDARY (load-bearing): predicate là `winnerQuoteTotal < ceoThreshold` (STRICT
|
||||||
|
// less-than, code line 838) → bằng đúng ngưỡng = KHÔNG finalize → advance bình thường.
|
||||||
|
//
|
||||||
|
// Mirror harness PeSubmitGuardAndBypassTests.cs cùng folder (IdentityFixture + SQLite
|
||||||
|
// + SeedWorkflowAsync + SeedWinnerWithQuoteAsync + reuse NoOpNotificationService internal).
|
||||||
|
// Khác PeSubmit*: tests này dựng PE TRỰC TIẾP ở ChoDuyet (đã qua submit guard) + pin
|
||||||
|
// pointer Step/Level đứng tại slot CCM → drive 1 lần Approve.
|
||||||
|
public class PeCcmThresholdFinalizeTests
|
||||||
|
{
|
||||||
|
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||||
|
TestApplicationDbContext db, FixedDateTime clock) CreateService()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var clock = new FixedDateTime(new DateTime(2026, 6, 17, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||||
|
return (svc, fix, db, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PE đứng ở ChoDuyet tại pointer (stepIdx, levelOrder). SelectedSupplierId pin
|
||||||
|
// winner. ApprovalWorkflowId pin V2 → ApproveV2Async branch.
|
||||||
|
private static PurchaseEvaluation BuildPeAtApprovalSlot(
|
||||||
|
Guid approvalWorkflowId,
|
||||||
|
Guid? selectedSupplierId,
|
||||||
|
int stepIdx,
|
||||||
|
int levelOrder,
|
||||||
|
string code = "PE-FB-001")
|
||||||
|
{
|
||||||
|
return new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = code,
|
||||||
|
TenGoiThau = "Test Feature B — CCM threshold finalize",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = approvalWorkflowId,
|
||||||
|
SelectedSupplierId = selectedSupplierId,
|
||||||
|
CurrentWorkflowStepIndex = stepIdx,
|
||||||
|
CurrentApprovalLevelOrder = levelOrder,
|
||||||
|
SlaDeadline = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
|
||||||
|
// quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId.
|
||||||
|
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||||
|
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||||
|
{
|
||||||
|
var supplierId = Guid.NewGuid();
|
||||||
|
var pes = new PurchaseEvaluationSupplier
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
SupplierId = supplierId,
|
||||||
|
Order = 0,
|
||||||
|
};
|
||||||
|
var detail = new PurchaseEvaluationDetail
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
GroupCode = "A.I",
|
||||||
|
GroupName = "Bê tông",
|
||||||
|
NoiDung = "Concrete M100",
|
||||||
|
Order = 0,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||||
|
db.PurchaseEvaluationDetails.Add(detail);
|
||||||
|
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
PurchaseEvaluationDetailId = detail.Id,
|
||||||
|
PurchaseEvaluationSupplierId = pes.Id,
|
||||||
|
ThanhTien = quoteThanhTien,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return supplierId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed workflow V2 multi-step: stepApprovers[s] = mảng NV cho từng cấp (Order
|
||||||
|
// 1-based) của bước s (Order s+1). 1 NV / cấp. ceoThreshold set CeoApprovalThreshold
|
||||||
|
// (null = bỏ qua finalize). Return ApprovalWorkflow đã persist.
|
||||||
|
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||||
|
TestApplicationDbContext db, Guid[][] stepApprovers, decimal? ceoThreshold)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-FB-V2",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||||
|
Name = "QT test Feature B threshold",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
CeoApprovalThreshold = ceoThreshold,
|
||||||
|
};
|
||||||
|
for (int s = 0; s < stepApprovers.Length; s++)
|
||||||
|
{
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = s + 1,
|
||||||
|
Name = $"Bước {s + 1}",
|
||||||
|
};
|
||||||
|
for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
|
||||||
|
{
|
||||||
|
step.Levels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = lvl + 1,
|
||||||
|
Name = $"Cấp {lvl + 1}",
|
||||||
|
ApproverUserId = stepApprovers[s][lvl],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
}
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return wf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task ApproveAsync(
|
||||||
|
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||||
|
params string[] roles) =>
|
||||||
|
svc.TransitionAsync(
|
||||||
|
evaluation: pe,
|
||||||
|
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer)
|
||||||
|
actorUserId: actorUserId,
|
||||||
|
actorRoles: roles,
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: null,
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 1. ⭐ LOAD-BEARING — CCM duyệt, gói < ngưỡng, CCM mid-workflow (còn CEO sau)
|
||||||
|
// → DaDuyet luôn, bỏ qua CEO, pointers null.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_CcmBelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
// Workflow 2 bước: Bước 1 Cấp 1 = CCM (đang đứng đây), Bước 2 Cấp 1 = CEO.
|
||||||
|
var ccm = (await fix.CreateUserAsync("ccm@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { ccm }, // Bước 1 Cấp 1 = CCM
|
||||||
|
new[] { ceo }, // Bước 2 Cấp 1 = CEO (sẽ bị bỏ qua)
|
||||||
|
}, ceoThreshold: 1_000_000_000m);
|
||||||
|
|
||||||
|
// PE đứng tại Bước 1 (stepIdx 0) Cấp 1 — đến lượt CCM.
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1);
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
// Gói 500tr < ngưỡng 1 tỷ.
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 500_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||||
|
|
||||||
|
// ⭐ Phiếu DaDuyet ngay — KHÔNG advance sang Bước 2 (CEO bị bỏ qua).
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
"CCM duyệt + gói < ngưỡng CEO → finalize, bỏ CEO");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
|
||||||
|
pe.SlaDeadline.Should().BeNull("terminal → SLA null");
|
||||||
|
|
||||||
|
// CEO KHÔNG được ghi opinion (bị bỏ qua hoàn toàn — chỉ CCM ký).
|
||||||
|
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||||
|
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
var ccmLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id;
|
||||||
|
opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == ccmLevelId,
|
||||||
|
"chỉ slot CCM ký, không ghi hộ CEO");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 2. CCM duyệt, gói >= ngưỡng → advance sang CEO, Phase GIỮ ChoDuyet (NOT DaDuyet).
|
||||||
|
// Boundary: gói == đúng ngưỡng (strict-less-than → KHÔNG finalize).
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_CcmAtOrAboveThreshold_AdvancesToCeo_PhaseStaysChoDuyet()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, clock) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var ccm = (await fix.CreateUserAsync("ccm2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { ccm }, // Bước 1 = CCM
|
||||||
|
new[] { ceo }, // Bước 2 = CEO
|
||||||
|
}, ceoThreshold: 1_000_000_000m);
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
// Gói == ĐÚNG ngưỡng (1 tỷ) → strict `<` false → KHÔNG finalize.
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
"gói == ngưỡng (không < ngưỡng) → advance bình thường, chưa DaDuyet");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1, "Cấp 1 của Bước 2");
|
||||||
|
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho approver kế (CEO)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 2b. CCM duyệt, gói > ngưỡng (vượt rõ rệt) → advance sang CEO (không finalize).
|
||||||
|
// Phân biệt với case (1) cùng setup nhưng chỉ khác giá trị gói.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_CcmAboveThreshold_AdvancesToCeo_NotFinalized()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var ccm = (await fix.CreateUserAsync("ccm2b@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo2b@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { ccm },
|
||||||
|
new[] { ceo },
|
||||||
|
}, ceoThreshold: 1_000_000_000m);
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002B");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
// Gói 2 tỷ > ngưỡng 1 tỷ.
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói > ngưỡng → CEO vẫn phải duyệt");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 3. CeoApprovalThreshold == null → advance tuyến tính KỂ CẢ CCM (no early-finalize).
|
||||||
|
// Backward-compat: workflow chưa set ngưỡng → behavior cũ.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_ThresholdNull_CcmApprovesBelowAnyValue_AdvancesNormally_NoFinalize()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var ccm = (await fix.CreateUserAsync("ccm3@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo3@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { ccm },
|
||||||
|
new[] { ceo },
|
||||||
|
}, ceoThreshold: null); // ⭐ ngưỡng null → finalize-branch không chạy
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-003");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
// Gói 1đ (siêu nhỏ — chắc chắn dưới mọi ngưỡng nếu có) nhưng ngưỡng null.
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
"ngưỡng null → KHÔNG finalize dù CCM + gói nhỏ → advance bình thường");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) như cũ");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 4. Non-CCM actor (Procurement) duyệt dưới ngưỡng → advance bình thường (no
|
||||||
|
// early-finalize; CHỈ CostControl mới trigger). Cover nhận-diện-theo-role.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_NonCcmActor_BelowThreshold_AdvancesNormally_NoFinalize()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
// Bước 1 Cấp 1 = Procurement (KHÔNG phải CCM), Bước 2 = CEO.
|
||||||
|
var pro = (await fix.CreateUserAsync("pro4@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo4@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { pro },
|
||||||
|
new[] { ceo },
|
||||||
|
}, ceoThreshold: 1_000_000_000m);
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-004");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
// Gói 100tr << ngưỡng 1 tỷ — NHƯNG actor là PRO không phải CCM.
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 100_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, pro, AppRoles.Procurement);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
"PRO duyệt + gói nhỏ nhưng KHÔNG phải CostControl → no finalize");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) bình thường");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 5. (optional) CCM đã ở slot cuối (Bước cuối Cấp cuối) + dưới ngưỡng → DaDuyet
|
||||||
|
// qua advance bình thường (guard `!(currentIdx==last && level==max)` chặn
|
||||||
|
// finalize-branch → nhánh advance terminal vẫn ra DaDuyet, không double-finalize).
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_CcmAtLastSlot_BelowThreshold_DaDuyetViaNormalAdvance_NoDoubleFinalize()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
// Workflow 1 bước, 1 cấp = CCM (đây là slot CUỐI luôn).
|
||||||
|
var ccm = (await fix.CreateUserAsync("ccm5@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { ccm }, // Bước 1 Cấp 1 = CCM = slot cuối
|
||||||
|
}, ceoThreshold: 1_000_000_000m);
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-005");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 300_000_000m); // < ngưỡng
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||||
|
|
||||||
|
// Slot cuối → finalize-branch bị guard skip (currentIdx==last && level==max),
|
||||||
|
// nhưng nhánh advance "nextIdx >= steps.Count" cũng set DaDuyet. Kết quả giống.
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
"CCM ở slot cuối → DaDuyet qua advance bình thường (không cần finalize-branch)");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
|
// KHÔNG double-finalize: chỉ 1 Approval Approve (của CCM), không phát sinh vết thừa.
|
||||||
|
var approvals = await db.PurchaseEvaluationApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||||
|
&& a.Decision == ApprovalDecision.Approve).ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1, "1 lần CCM duyệt = 1 Approval row, không double");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user