From 37122f0f64d0df2e97296a7b5762938a954fb75d Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 12 Jun 2026 11:53:26 +0700 Subject: [PATCH] [CLAUDE] PurchaseEvaluation: rang buoc du 4 thong tin muc 3 moi gui duyet + bypass nguoi soan trong chuoi duyet (UAT anh Kiet S60) - Rename muc 3: "Chon NCC / TP thang thau" -> "Don vi NCC/TP duoc chon" (anh Kiet chot chu) x2 app + wording phu nhat quan - Guard gui duyet du CA 4 (anh chot): don vi duoc chon + gia chao thau >0 + ngan sach (Budget link HOAC nhap tay) + bang so sanh dinh kem + BE ConflictException gop moi muc thieu 1 lan, ap ca Admin (TransitionAsync submit branch) + FE pre-check missingForApproval cung predicate -> disable nut + tooltip liet ke du (computeGiaChaoThau extract single-source) - Bypass drafter-in-chain (luat GENERIC theo cap, anh chot): V2-only, BUOC DAU only - nguoi soan la approver cap k -> auto qua Cap 1..k khi gui + Audit 3 tang: Approval row AutoApprove per cap + LevelOpinion CHI slot chinh chu (khong gan chu ky NV bi skip) + Changelog + Pointer: k Cap k+1; het buoc -> Buoc 2 Cap 1; workflow 1 buoc -> terminal DaDuyet + TraLai resubmit ap lai idempotent (opinion UPSERT) - Tests: +14 PeSubmitGuardAndBypassTests (240 -> 254 PASS) - Reviewer die mid-run (gotcha #53 class) -> em main self-gate evidence-checklist PASS 0 blocker Co-Authored-By: Claude Fable 5 --- .../agent-memory/harvest-curator/MEMORY.md | 1 + .../investigator-codebase/MEMORY.md | 8 +- .../agent-memory/test-specialist/MEMORY.md | 3 +- fe-admin/src/components/pe/PeDetailTabs.tsx | 66 +- fe-user/src/components/pe/PeDetailTabs.tsx | 66 +- .../PurchaseEvaluationWorkflowService.cs | 197 ++++++ .../Services/PeSubmitGuardAndBypassTests.cs | 636 ++++++++++++++++++ 7 files changed, 949 insertions(+), 28 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs diff --git a/.claude/agent-memory/harvest-curator/MEMORY.md b/.claude/agent-memory/harvest-curator/MEMORY.md index ccbee95..ab4b827 100644 --- a/.claude/agent-memory/harvest-curator/MEMORY.md +++ b/.claude/agent-memory/harvest-curator/MEMORY.md @@ -33,3 +33,4 @@ H2 harvest-MD-integrity auditor **SOLUTION_ERP-self**. Read-only + **propose-onl - **2026-06-11 (S58 `/session-end` 5-trục GATE — chiều, 8 spawn):** **GATE PASS 5/5** — Coverage 9 record/8 spawn (H1+H2 start · inv recon `:73` · cicd #382/#383/#384 + **#385→#386 supersede-chain UNCOMMITTED mtime 14:20 — run-cuối 3ebaf84 đã về có record, nhắc commit bundle** · designer proxy `:39`) · Completeness 4-field 8/8 · Placement đúng nhà, stray=0, **NIT: cicd #383 nằm `:89` GIỮA khu archived (dưới #377, trên Run #232) thay vì slot FIFO :72-73 → propose MOVE trước curate kẻo archive nhầm entry 1-ngày-tuổi** · Corruption 0-byte=0 cả repo + user-memory, last-byte 11/11 `0a`, 9/9 entry mới close-Tag sạch, mojibake 1 hit = quoted-evidence `C�ng` cicd:79 (#377 sqlcmd lesson, cố ý) · Fidelity 5 on-behalf/proxy marked tường minh ("on-behalf em main ghi hộ, H2-proposed" ×4 + designer "[em main proxy — truncated #53, 2nd consecutive]") + reviewer honest "KHÔNG DELIVER die-0-byte ×2", spot `git merge-base --is-ancestor ea793a4 3ebaf84`=YES khớp claim #386 → no-escalate. Wave=0 confirm (workflows chỉ README+hmw.js). **Chore: cicd 41.1KB (sáng 32.2 → +4 entry siêu dài/ngày!) + inv-codebase 32.9KB ĐỀU over-cap → curate-L2 P1 next session; reviewer 29.6KB + impl-be 27.9KB watch.** Method ⭐: diff-filter `grep -v "^[+-][+-]"` NUỐT dòng `+- **…` bullet-append → suýt false-MISS investigator; extract added-bullet phải dùng `grep "^+-"`. Trend: self-tag session-drift lần 3 (inv `:73` tag s57bis cho spawn S58; trước: cicd s50→s51, inv s56→s57). 12-vs-8 spawn-count để em main reconcile. Wrote OWN diary only. Tag [s58-end, gate-pass-5/5, 383-misplaced, cicd-41kb]. - **2026-06-11 (S59 @start RE-REPORT — post-S58 đóng-sạch):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `1577927` (commit 14:33:20), 11/11 agent-memory mtime ≤14:32 đều TRONG closeout → **0 delta mồ-côi**. Wave=0 (workflows/ chỉ README+hmw.js) · stray=0 (find prune node_modules) · 0-byte=0 (agent-memory + user-memory) · user-memory index 22 entry = 22 file khớp, MEMORY.md 5.6KB>0. Record-S58 hiện hữu: cicd `:71-74` (#385→#386/#384/#382/#381) · designer proxy `:39` · inv `:73` (tag s57bis drift giữ nguyên). 4 on-behalf s57bis (db/impl-be `:77`/impl-fe `:45`/reviewer `:60`) mtime 13:00 = closeout đóng 4-MISS ✅. **cicd #383 VẪN `:89` khu archived NHƯNG em main đã ANNOTATE "[⚠️ VỊ TRÍ LẠC — entry MỚI 2026-06-11...]"** thay vì MOVE → guard chống archive-nhầm OK, relocate thật khi curate. Mojibake 2 hit ĐỀU quoted-evidence (cicd `:79` #377 + MY OWN `:33` self-quote khi tả hit cicd — các session sau đừng false-alarm file mình). Chore carry: **cicd 41.3KB + inv-codebase 32.9KB > cap → curate-L2 P1**; reviewer 29.6 + impl-be 27.9 watch. Method ⭐: `tail -c N` cắt giữa UTF-8 multi-byte in `�` GIẢ — corruption thật phải grep literal U+FFFD (chỉ 2 file). Brief em main liệt kê impl-fe+reviewer là "S58 spawn" nhưng on-disk 2 role chỉ có s57bis on-behalf — khớp GATE S58 8-spawn set, flag đối chiếu không phán. Tag [s59-start, clean, mojibake-self-quote]. - **2026-06-11 (S59 `/session-end` 5-trục GATE — tối, 9 body-spawn + closeout-floor):** **GATE PASS 5/5, 0 MISS** (lần đầu 0 on-behalf cần đề xuất kể từ chuỗi 4-MISS post-S57bis). Coverage: tooling ×2 (`:35` start + `:36` end-run mtime 18:15 NGOÀI brief 9-spawn = closeout-floor H1 → reconcile tổng 11) · harvest `:34` · inv recon `:73` · **cicd 6/6 entry RIÊNG #273-278 `:71-76`, sha-chain khớp git log 1:1** (56882ac→9c330d2, topic khớp commit-msg từng run) · 7 role không-spawn 0 phantom. Completeness 4-field 10/10 (#275 honest PASS-PARTIAL PE=1 UAT-leftover). Placement: đúng nhà, stray=0, inv S51→archive VERBATIM moved-not-cut + pointer "curated S59"; nit FIFO swap #273(`:75`)↔#274(`:76`) + archive double-blank. Corruption: 0-byte=0 ×2 nơi · mojibake 3 hit ĐỀU quoted-cũ (cicd:85 = #377 shift ĐÚNG +6 từ :79 — arithmetic shift = số dòng insert, method nhanh phân biệt cũ/mới) · **cicd 54KB/102-dòng (brief nói ~46) + inv 32.9KB over-cap → curate-L2 P1**. Fidelity: bundle BSh2fG2X/D22KfpPc triangulated 5 nguồn (cicd:71 + STATUS:6/:27 + HANDOFF:5 + log:4/:43) · WI=71 ×3 entry · gotcha #61/#62 disk `:1099/:1111` · tooling end-entry tree-state khớp git status độc lập — no-escalate. **2 flag content:** cicd `:53` "Bundle live S59" STALE 1 run (ghi #277 ex7Tc92G/DzUeSk96, live thật = #278 — entry :71 đúng, chỉ status-line lệch) → propose 1-line fix; #275 "09:46:42 sáng" nghi UTC-mislabel = 16:46 chiều local (STATUS:6 nói "chiều nay"; kết luận not-resurrect GIỮ). Tag [s59-end, gate-pass-5/5, 0-miss, cicd-54kb, line53-stale]. +- **2026-06-12 (S60 @start RE-REPORT — post-S59 đóng-TRỌN):** Verdict 🟢 CLEAN cả 5 mục — tree clean HEAD `6bf28bf` (18:49:21), 11/11 agent-memory mtime ≤18:42:45 đều TRONG closeout → 0 mồ-côi. Coverage S59 14-spawn ĐỦ: H1 tooling `:35/:36` + H2 ×2 + recon inv `:73` + cicd run-coverage 10/10 #273→#282 (6 entry `:72-77` + `:71` #279/280 + extension FINAL-v2 #281/282 — 9 spawn→8 record-unit do supersede-fold, 0 run thiếu). Wave=0 · stray=0 · 0-byte=0 ×2 nơi · user-memory 23 file = index 22 + MEMORY.md 5.6KB khớp · cicd tail `0a` sạch. **2 flag GATE S59-end RESOLVED bởi em main:** `:53` bundle-live → FINAL-v2 `B1DtNT9C`/`D6uF3Mln` #282 ✓ + #275 UTC annotate `:75` ✓. #383 vẫn lạc (`:89`→`:96` shift +7 đúng arithmetic, annotation guard intact). Chore P1 carry: **cicd 56,480B/103L over-cap 3rd-session + phình 54→56.5KB/buổi** + inv 32,931B → curate-L2 (kèm relocate #383 + FIFO swap #273↔#274); watch reviewer 30,354B + impl-be 28,585B. Tag [s60-start, clean, flags-resolved, cicd-56kb]. *(em main APPEND B3 — H2-proposed, verify: 0-byte/tree-clean/size đối chiếu độc lập ✓)* diff --git a/.claude/agent-memory/investigator-codebase/MEMORY.md b/.claude/agent-memory/investigator-codebase/MEMORY.md index feac156..d75f745 100644 --- a/.claude/agent-memory/investigator-codebase/MEMORY.md +++ b/.claude/agent-memory/investigator-codebase/MEMORY.md @@ -70,6 +70,10 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte ## 📅 Recent activity (FIFO — older → archive/git) +- **2026-06-12 (S60 recon #2 — V2 engine map cho drafter-in-chain bypass):** ⭐ 3 entity V2 CÙNG 1 file `Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs` — Step.Order :65 1-based, Level.Order :78 1-based PER-STEP (reset mỗi step), Level.ApproverUserId :80 Guid đơn; **OR-of-N = N Level rows cùng Order** (service GroupBy :475 — entity comment :73 "KHÔNG OR-of-many" STALE Mig22-era). `PurchaseEvaluationWorkflowService.cs`: submit :131-158 init pointer StepIdx=0 :151 + LevelOrder=1-if-V2 :153 (TraLai resubmit CÙNG path = restart từ đầu); ApproveV2Async :446-634 — guard actor∈HashSet ApproverUserId :488, Approval row :501, LevelOpinion UPSERT :522-546 (SignedByUserId KHÔNG nullable — Guid.Empty system :536; placeholder "(duyệt — không ý kiến)" :526), advance level++ :605 / step++ :628 / terminal DaDuyet :617-624 (pointers null + LogTransition). **Notify DRAFTER-only :748 — KHÔNG notify approver Ở ĐÂU CẢ** (submit silent vì drafter==actor). V1/V2 fork approve :167; submit chung (chỉ :153 conditional). skipToFinal F2 :561-602 = PRECEDENT advance-pointer-KHÔNG-ghi-opinion cho slot bị skip. FE Section 5 `fe-admin/src/components/pe/PeDetailTabs.tsx:510` render approvalFlow×levelOpinions match stepOrder :528; counter :531 đếm TỪ opinions (level bị skip hiện "chưa ký"); badge :592 signedByUserId!==approverUserId → "⚠ Admin … duyệt thay" :602 (text misleading nếu bypass ký hộ slot người khác). Chỗ chèn bypass: submit branch sau :153. Tag `[s60-recon2, v2-engine-map, drafter-bypass]`. + +- **2026-06-12 (S60 PE Section-3 submit-guard recon, on-disk):** ⭐ **Submit path:** `POST /pe/{id}/transitions` (Controller:68) → `TransitionPurchaseEvaluationCommand` (Features:434, validator :446 shape-only) → handler :462 (auth+NotFound, NO data check) → `PurchaseEvaluationWorkflowService.TransitionAsync:38`; submit branch :131-158 (Nháp/TraLai→ChoDuyet) guard ROLE-only (Drafter/DeptMgr/Admin :138-144), KHÔNG validate supplier/budget/quote/attachment. WorkItem guard S57bis create-only (:43/:66). **Section-3 map (ChonNccSection PeDetailTabs:1135):** (a) winner = header `SelectedSupplierId` (entity :27 loose-Guid; set `POST /{id}/select-winner` → DetailFeatures:390, NO phase guard); (b) budget dual `BudgetId` || `BudgetManualAmount` (ManualName always-null :847, manual-detect :825); (c) giá chào thầu = DERIVED sum quotes winner row :1139-48 (0=chưa nhập → guard cần >0); (d) bản so sánh = attachments `supplierRowId===null` ONLY :1150-53, KHÔNG check Purpose=4 → BE guard mirror predicate null-row tránh FE mismatch. **Label:** heading DUY NHẤT :228 (CAPS do h3 uppercase :317); SHA256 identical 2 app. **Nút gửi:** PeDetailTabs :291-304 + `canSubmitForApproval`:146 + `submitDisabledReason`:153 = chỗ chèn FE pre-check; S59 hide-self PeWorkflowPanel:271. **Test mirror:** `Services/PurchaseEvaluationWorkflowServiceGuardTests.cs` (BuildPeInChoDuyet:46) + `Application/PeWorkItemGuardTests.cs` (:43 BuildCreateHandler). Tag `[s60-recon, pe-submit-guard, section3-map]`. + - **2026-06-11 (S59 recon — prod test-data wipe + PE tree Hạng mục, prod+on-disk):** ⭐ **Prod:** PE=10 active (1 Nháp + 1 DaDuyet(7) + 8 ChoDuyet(10), MaPhieu A/031-040, ALL WorkItemId NULL) + child 20/10/20/28/138/18/18 (Sup/Det/Quote/Appr/Chg/Att/LvlOp); Contracts=7 ALL `[DEMO]` 05-08 pin V1 (AwId NULL) + Appr15 + details15; Budgets/WorkflowApps/Proposals/Attendances/Meetings ALL 0; Notifications 64. Seq: PE/2026/A=40 B=1; CT=7 demo prefix LastSeq=1. **FK:** PE child CASCADE trừ `Quotes→PE NO_ACTION` (multi-path; Plan R S23 proved single `DELETE FROM PurchaseEvaluations` OK — NO_ACTION check end-of-statement sau cascade Details→Quotes). Contract child ALL CASCADE. PE.ApprovalWorkflowId Restrict → wipe PE trước khi xóa AW QT-DN-V2-001 v1 (inactive, còn 1 PE pin). AW V2=8: 7 ghim KEEP. **Uploads orphan:** purchase-evaluations/ 19 folder vs 10 PE → ~10 orphan từ S23 (file không xóa); contracts/ 1. **Demo gate OK:** SeedDemoContracts/PE TRONG `DemoSeed:Disabled` (DbInitializer:80,131-132) → wipe không resurrect. **Surprise:** Users 55 total / 21 active — 20 user THẬT batch 2026-06-11 06:01 (S58 seed fix ăn; thanh.lethanh NOW EXISTS — stale S57bis mem; chuong.phan typo-domain VẪN active song song twin). **FE tree:** `pe/PurchaseEvaluationsListPage.tsx:138-179` Project>Year(createdAt :150)>Supplier; SHA256 identical 2 app; PeListItem ĐÃ có workItemId/Name (types :116-118, BE Features :514/570/644) → đổi tree FE-only. Tag `[s59-recon, prod-wipe, pe-tree-workitem]`. - **2026-06-11 (S57bis lock no-op — prod user census, on-disk+prod):** ⭐ `LockDemoSampleUsersAsync` (DbInitializer.cs:1552, chạy CUỐI :98) hardcode 14 named-person email (bod.huynh/pm.nguyen/fin.do/qs.hoang…) = population CHỈ CÓ TRÊN DEV. **Prod 34 user ALL-active:** 20 UAT-matrix placeholder hand-created batch 2026-05-13 15:04-05, scheme `{act,equ,fin,hra,pm,qs}.{nv,pp,tp}@` + `bod.{1,2}@` (FullName tự khai "ACT NV - Drafter+Accounting", "[Bypass]"/"[SkipFinal]" = test Mig 29-31 flags) + 9 real staff hand-created 05-04→05-12 + `binh.lethanh@` (người thật Lê Thanh Bình — seed dùng `thanh.lethanh@` KHÔNG tồn tại prod) + `chuong.phan@solution.com.vn` TYPO-domain dup (twin đúng tạo 05-12) + admin/catalog.manager/nv.test. **ROOT CAUSE seed-user never-on-prod:** prod `Identity:Password:RequiredLength=12` (appsettings.Production.json) vs `DemoUserPassword="User@123456"`=11 chars → CreateAsync silent-fail MỌI startup từ prod-init 04-21 (code comment :1675-79 đã biết); Dev fallback 8 (DependencyInjection.cs:67 `?? 8`, Development.json no Identity section) → Dev đủ 33 user named-person. `bod.1@` NEVER in git pickaxe = tạo tay qua admin UI, không phải seed. Surprise: _Dev hiện CŨNG chưa khóa (Locked=0; LockoutEnd=MaxValue sẽ persist qua reconcile re-activate :1714 nếu từng chạy) → lock chưa từng execute against _Dev runtime. Fix cần 20 email prod-thật; GIỮ binh.lethanh + 9 real + admin/catalog.manager; `nv.test@` = creds smoke-verify (khóa = vỡ cicd smoke). Tag `[s58, s57bis-lock-noop-recon, prod-user-census, pwd-policy-env-divergence]`. @@ -88,9 +92,7 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte - **2026-06-09 (S55 master-data Excel-import recon — 3 master + seed mechanism, on-disk):** ⭐ **"Hạng mục"/WorkItem master TỒN TẠI** — `Domain/Master/Catalogs/WorkItem.cs:6-14` (Code(50)UNIQUE-filtered/Name(200)/Category(100,idx)/DefaultUnit(50)/Description/IsActive), config `CatalogsConfiguration.cs:60-74`, full CRUD `CatalogsFeatures.cs:260-324` → group(VẬT TƯ/THẦU PHỤ/MEP)→Category, "1 Mat"→Code, item→Name. KHÔNG cần table/migration mới. **PE detail = pure free-text** (`PurchaseEvaluationDetail.cs` GroupCode/GroupName/ItemCode/NoiDung strings, NO FK→WorkItem) → load WorkItems non-breaking. **Project** (`Project.cs:5-14`, cfg `:14-21`): Code(50,UNIQUE `[IsDeleted]=0` Mig47)+Name(200) REQUIRED, StartDate/EndDate/BudgetTotal(18,2)/Note(1000)/ManagerUserId optional. ❌ **THIẾU Year/Investor/Location/Package** — chỉ Note free-text catch-all. Create cmd `ProjectFeatures.cs:67` dup-check `:87 AnyAsync(Code==)`. **Supplier** (`Supplier.cs:5-16`, cfg `:14-27`): Code/Name req + Type enum + TaxCode(20)/Phone/Email/Address/ContactPerson/Note. `SupplierType.cs`: NhaCungCap=1/NhaThauPhu=2/ToDoi=3/DonViDichVu=4/ChuDauTu=5. ❌ **THIẾU Status/TinhTrang (KHÔNG có field/enum nào)** + bank-acct + legal-rep (≠ContactPerson) + quality-score; "Cả hai" PHÂN LOẠI unmappable (Type single-valued). Create `CreateSupplierCommand.cs:10` dup `:45`. **Seed = idempotent `existingCodes.Contains→skip`** (`DbInitializer.SeedDemoMasterDataAsync:2149`, today 18 supplier `:2155` + 8 project `:2222`; WorkItems 15 rows tuple-loop `SeedCatalogsAsync:576-599`). **NO bulk import** — Master chỉ single CRUD; Import/Upload hits = Forms/PE/Employees attachment only; POST one-at-a-time. **Seed→prod:** `DbInitializer.InitializeAsync` chạy MỌI startup (`Program.cs:197` unless `--no-db-init`) → `MigrateAsync` THEN seed; demo gated `config.GetValue("DemoSeed:Disabled")` (`:80`) NHƯNG SeedDemoMasterData+SeedCatalogs chạy BẤT KỂ flag (ngoài if-block :108/:115) → seed method mới auto-reach prod next deploy. Rec: idempotent DbInitializer mirror (NOT API loop). Surprise: real+demo data sẽ trộn chung Suppliers/Projects/WorkItems (18/8/15 demo rows) → cân nhắc gate demo off prod. Tag `[master-import, workitem-exists, seed-idempotent, s55]`. -- **2026-06-08 (S52 Phase 11-D/E/F product-close recon — 6 gap, on-disk):** ⭐ **GAP1 IT-pool KHÔNG TỒN TẠI:** AppRoles.All=13 role (`AppRoles.cs:23`) NO "IT"; 9 dept seed (`DbInitializer.cs:2066` PM/QS/CCM/PRO/FIN/ACT/EQU/HRA/BOD) NO dept IT; MenuKeys NO It_* group (chỉ `OffItTicket="Off_ItTicket"` 1 leaf :123). → round-robin pool PHẢI tạo signal mới: option (a) +AppRoles.ItStaff const + seed user, (b) +dept "IT" code, (c) per-user flag `User.IsItStaff`. Least-loaded query = `Users.Where(pool).OrderBy(u => Tickets.Count(AssignedToUserId==u.Id && Status!=Closed))` — `ItTicket.AssignedToUserId Guid?` SẴN (`ItTicket.cs:21`). **GAP2 HostedService:** đăng ký tại `Infrastructure/DependencyInjection.cs:46 AddHostedService()` (KHÔNG Program.cs — grep Program.cs rỗng). Pattern `SlaExpiryJob.cs`: `BackgroundService` + ctor `(IServiceProvider sp, ILogger)` + `ExecuteAsync` Task.Delay(30s warmup)+while loop Interval 15min → `_sp.CreateAsyncScope()` resolve scoped `IApplicationDbContext`+`IDateTime`+`INotificationService` (:61-65). ItTicketSlaJob mirror: thêm dòng :47 + clone file. **GAP3 OtPolicy (`Hrm/OtPolicy.cs`):** 3 multiplier decimal(4,2) `MultiplierWeekday/Weekend/Holiday` (:21-23, seed 1.5/2.0/3.0) + 3 cap int `MaxHoursPer Day/Month/Year` (:26-28) + `Code` UNIQUE + `IsActive` (1 default công ty). `Attendance.OtHours decimal?` (`Office/Attendance.cs:37`) per-row, KHÔNG link OtPolicyId → join thủ công qua IsActive=true; công thức OT-pay = `OtHours × multiplier(dayType) × hourlyRate`, dayType phân loại từ AttendanceDate (Holiday tra Hrm_Holiday, Sat/Sun=Weekend, else Weekday). **GAP4 Excel reuse:** `IContractExcelExporter.ExportAsync→RenderResult` record `(byte[] Content, string FileName, string ContentType)` (`IFormRenderer.cs:3`); impl `ContractExcelExporter.cs` ClosedXML `XLWorkbook`+`Worksheets.Add`+`MemoryStream→ToArray()` (:103-109 content-type `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`); DI scoped :40; controller stream `return File(result.Content, result.ContentType, result.FileName)` (`ReportsController.cs:35`, mirror Forms/PE/Contracts). AttendanceExporter = clone + đổi columns + new CQRS command (mẫu `ExportContractsToExcelCommand`). **GAP5 Attendance API:** `AttendancesController.cs` 3 endpoint check-in/check-out/me (`[Authorize]` ko role); CQRS inline `Office/WorkflowAppsFeatures.cs` REGION 6 (:401-490) — `CheckInCommand`/`CheckOutCommand`/`GetMyAttendanceQuery(Year,Month)` chỉ trả LIST cá nhân (1 user/tháng). ❌ CHƯA có aggregate/monthly-report/all-users query → P11-E phải +`GetAttendanceReportQuery(year,month,deptId?)`. ItTicket CQRS cũng inline cùng file (:354 GetItTicketsQuery + CreateItTicketCommand + UpdateItTicketStatusCommand, controller `ItTicketsController.cs`). **GAP6 FE state:** ItTicketsPage + MyAttendancePage TỒN TẠI cả 2 app (fe-admin+fe-user, comment "MIRROR SHA256 identical"), routes `/it-tickets`+`/attendance` (`App.tsx:101-102`), menuKeys `OffItTicket`+`OffChamCong` (:65-66), Layout map :84-85. ❌ THIẾU: ItTicket = SKELETON read-only kanban (banner :32-34 "Form tạo + auto-assign + SLA timer defer Phase 11"), NO create form/assign-UI/SLA-badge; Attendance = check-in/out OK nhưng NO admin report page / Excel export button / OT-pay column. NO menuKey `Attendance_Report`/`It_Assign`. Surprise: ItTicket+Attendance KHÔNG dùng Workflow V2 (kanban status flow, comment `ItTicket.cs:6`) — khác Leave/OT/Travel/Vehicle (LevelOpinion). Tag `[p11-def-recon, it-pool-absent, otpolicy-multiplier, excel-reuse, s52]`. - -- **[→ archive/2026-06.md]** S50 P11-C HrmConfigs add-kind 11-chỗ pattern · S50 wave h2-verify B6 gitignore ordering + POSIX-not-pwsh (curated S57bis) · S51 gotcha #57 EXT reachability 3-Master-fix/3-skip global-filter-makes-bug (curated S59). +- **[→ archive/2026-06.md + git]** S52 P11-D/E/F 6-gap recon (IT-pool absent → S56 corrected: dept IT exists 0 user · SlaExpiryJob HostedService DI:46 pattern · OtPolicy 3-multiplier · ClosedXML exporter reuse · Attendance API cá nhân-only · FE skeleton state — full text git pre-S60) · S50 P11-C HrmConfigs add-kind 11-chỗ pattern · S50 wave h2-verify B6 gitignore ordering + POSIX-not-pwsh (curated S57bis) · S51 gotcha #57 EXT reachability 3-Master-fix/3-skip global-filter-makes-bug (curated S59). - **2026-06-07 (Harness 1/2/3 adap-apply recon — 3 slice, HMW wave):** Governance recon AI_INFRA broadcast harness-1/2/3. **H1/H2 (Harness 1):** roster 8→10 — CREATE 2 sub TÁCH BIỆT `tooling-auditor` (H1 freshness 4-mặt skill/sub-role/plugin/docs) + `harvest-curator` (H2 integrity 5-trục). H2 PARTIAL sẵn: `session-end.md` Phase 1.5 §L.b(d) spawn-record 4-field + (f) double-check moved-not-cut + (c) 0-byte AS-8 = Coverage+Completeness+Corruption (3/5); THIẾU Fidelity-escalate + Placement. RE-REPORT @session-start = 0 (chỉ generic Phase 2.7). 2 sub mirror inv-codebase read-set + store_memory strip + NO Write/Edit; color brown+teal (8 màu cũ hết). **H2 wave (Harness 2):** SE `hmw.js` = OLD pre-wave (no subMdPath/writeGuard/wave-block); AI_INFRA `hmw.js` = canonical template. ⭐ `git check-ignore -v` = ground-truth B6: `.claude/workflows/wave-test/wave.md` HIỆN match `.gitignore:83 !.claude/**` = TRACKED → wave pattern PHẢI đặt AFTER `!.claude/**` (last-match-wins, mẫu `hmw-mode.on` :87). Read-only sub (4)=inv-cb/inv-api/reviewer/cicd; Write sub (4)=impl×2/test/fe-designer. B5 depends H2 harvest-curator. **H3 email (Harness 3):** broadcasts/ absent; id authoritative = `se` (NOT solution_erp), 6 others short `{ai_infra,vipix,dyd,namgroup,ashico,bvaau}` từ `AI_INFRA/broadcasts/sister-commands/send-email.md:13-22` (folder name = 2nd source-truth); `adap-apply.md:14` base-path STALE flat → `outbox/all/*.md` (latent bug). broadcasts/ ở root → commit OK (no gitignore rule). **Containment post-P2:** git-diff bắt 1 file-write (inv-api self-MEMORY), chunk-count 2414=2414 (0 RAG-write) = defense-in-depth proven. Tag [harness-recon, governance, hmw-wave, 2026-06-07]. diff --git a/.claude/agent-memory/test-specialist/MEMORY.md b/.claude/agent-memory/test-specialist/MEMORY.md index a907a60..ac6b3ab 100644 --- a/.claude/agent-memory/test-specialist/MEMORY.md +++ b/.claude/agent-memory/test-specialist/MEMORY.md @@ -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: decide WHAT to test (test plan) → em main + reviewer chốt priority -## 📊 Baseline 240 tests = 240 PASS (58 Domain + 182 Infra) ← S57bis +12 (PeWorkItemGuardTests: 3 validator + 4 create-FK-guard + 5 update-null-safe). Pre = 228 (S56). +## 📊 Baseline 254 tests = 254 PASS (58 Domain + 196 Infra) ← S60 +14 (PeSubmitGuardAndBypassTests: 8 Section-3 guard + 6 drafter-bypass). Pre = 240 (S57bis). Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build) ### ⚠️ Pattern: deduction hook FK → seed LeaveType cho terminal test (S43) @@ -53,6 +53,7 @@ Test theo CODE (single source truth), document mismatch header comment + report. ## 📅 Recent activity (last 10 FIFO) +- **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-11 (S57bis P2 PE WorkItemId guard Mig 49 — test-after, code đã đúng sẵn):** +12 test `tests/.../Application/PeWorkItemGuardTests.cs` → **228→240 PASS** (58 Domain + Infra 170→182, 0 fail). PE `Guid? WorkItemId` loose-Guid (KHÔNG FK vật lý, convention giống ProjectId). **Cover-map 3 trục:** (1) **Validator ×3** — `CreatePurchaseEvaluationCommandValidator.Validate(cmd)` plain API (KHÔNG có FluentValidation.TestHelper package): null→invalid+error trên WorkItemId / present→valid. (2) **Create-FK-guard ×4** — handler 4-dep instantiate THẬT trên SQLite (`new PurchaseEvaluationWorkflowService(db,dt,notify,um)` + `new PurchaseEvaluationCodeGenerator(db,dt)` — Serializable-tx non-issue SQLite proven S52; reuse `NoOpNotificationService` internal từ ...Services ns + IdentityFixture): bogus-Guid→Conflict / inactive→Conflict / active→OK+`saved.WorkItemId==active.Id`. (3) **Update-null-safe ×5 (bug-class S42 picker)** — `UpdatePurchaseEvaluationDraftCommandHandler(db,cu)` 2-dep nhẹ: request.WorkItemId=null→GIỮ w1 KHÔNG null-hoá (⭐ core) / W2-active→đổi / bogus→Conflict+giữ w1 (AsNoTracking re-read DB-truth) / inactive→Conflict / same-as-existing→skip-lookup-success. **⚠️ SPEC-DRIFT FOUND (test theo CODE, S34 rule):** `NotEmpty()` trên `Guid?` (nullable) chỉ bắt `null`, KHÔNG bắt `Guid.Empty` (FV 7.2 so default(Guid?)==null) → Guid.Empty PASS validator. KHÔNG phải lỗ hổng — create handler FK-guard (`is Guid wiId` true cho Empty + AnyAsync false) chặn → Conflict. Test LOCK behavior (1 validator-test assert Empty pass + 1 handler-test chứng minh defense-in-depth catch). REPORT em main: validator một mình không reject Guid.Empty, dựa handler. No prod bug — code đúng spec, defense-in-depth layered. Tag [s57bis, p2, pe-workitemid, mig49, validator-plain-api, null-safe-partial-update, guid-empty-nullable-notempty-drift, defense-in-depth]. - **2026-06-09 (S56 GOLIVE-HARDEN TEST stage — 4 pre-golive fixes, test-after build):** +12 test → **216→228 PASS** (58 Domain + Infra 158→170, 0 fail). Build stage đã land prod fixes (CONTRACT từ build, signatures UNCHANGED). **#3 LeaveBalance lost-update fix:** handler terminal nay increment `db.LeaveBalances.Where(...).ExecuteUpdateAsync(s=>s.SetProperty(b=>b.UsedDays, b=>b.UsedDays+p.NumDays))` server-side + 1 explicit tx (READ COMMITTED, NO IsolationLevel). **⭐ GOTCHA: ExecuteUpdateAsync BYPASS change tracker** → instance bal tracked (Add STEP1 hoặc pre-seed cùng context) GIỮ UsedDays PRE-increment. **4 test cũ LeaveBalanceTests (case 1/2/3/4 line 163/201/240/269) FAIL ở baseline = stale-tracked-read, KHÔNG regression** (spec TEST GUIDANCE đã tiên đoán). Fix = `.AsNoTracking()` re-read (hoặc `ChangeTracker.Clear()`). +2 new: `TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites` (3+5=8 chứng minh increment accumulate KHÔNG overwrite = race-free invariant) + `Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct` (early guard Status!=DaGuiDuyet:296 → exactly-once, balance vẫn 3 not 6). **#4 Travel/Vehicle ApproveV2 smoke (WorkflowAppApproveV2Tests.cs +4):** mỗi module Submit→Approve→DaDuyet happy + outsider→Forbidden. ApplicableType Travel=9 prefix `DT/CT`, Vehicle=7 prefix `DX/XE`. Travel/Vehicle KHÔNG trừ balance → không seed LeaveType. Helper mới `SeedWorkflowForTypeAsync(type,code,...approverIds)`. **#5 ItTicket existence-oracle (ItTicketReassignAuthzTests.cs +2):** authz reorder (Forbidden TRƯỚC NotFound) — non-IT non-admin nhận Forbidden cho ticketId tồn tại VÀ không tồn tại (cặp 5b/5c phản hồi giống nhau = no oracle leak). Reorder KHÔNG vỡ test cũ (Case5 đã expect Forbidden; TicketNotFound dùng Admin caller pass authz hợp lệ). **#6 DocxRenderer (Forms/DocxRendererTests.cs NEW +4):** 0 test trước đó. MainDocumentPart null→`InvalidOperationException("*MainDocumentPart*")` (OpenXml 3.5.1 `WordprocessingDocument.Create(path,type)` tạo package RỖNG no main part) + placeholder replace happy + unknown-key giữ literal + null-value→empty. **⚠️ test helper ExtractBodyText: tránh `MainPart!.Document.Body!` (CS8602 warning) → dùng `?.Document?.Body` + `.Should().NotBeNull()`.** No prod bug found — tất cả fixes là build-stage, tôi WRITE test theo CONTRACT. Tag [s56, golive-harden, executeupdate-tracker-bypass, asnotracking-reread, travel-vehicle-smoke, existence-oracle, docxrenderer]. ↳ **[em main post-review S56]** Shipped tx = `IsolationLevel.Serializable` (code `LeaveOtApprovalFeatures.cs:369`), KHÔNG READ COMMITTED — entry's '(READ COMMITTED, NO IsolationLevel)' = build-stage snapshot, **superseded** post-review (SQLite test path unaffected — codegen Serializable already green). diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index e3846d3..cf7fd54 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -69,6 +69,21 @@ const NCC_PALETTES = [ 'border-l-pink-400 bg-pink-50/40', ] as const +// Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner +// supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check +// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn +// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. +function computeGiaChaoThau(ev: PeDetailBundle): number | null { + const winnerSupplierRowId = ev.selectedSupplierId + ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null + : null + if (winnerSupplierRowId === null) return null + return ev.details + .flatMap(d => d.quotes) + .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) + .reduce((sum, q) => sum + q.thanhTien, 0) +} + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -143,20 +158,51 @@ export function PeDetailTabs({ const forwardPhase = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) + + // Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt). + // CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị + // NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song). + // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. + const missingForApproval = useMemo(() => { + const missing: string[] = [] + // 1. Chưa chọn Đơn vị NCC/TP + if (evaluation.selectedSupplierId == null) { + missing.push("Chưa chọn Đơn vị NCC/TP") + } else { + // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). + // Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1). + const gia = computeGiaChaoThau(evaluation) + if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") + } + // 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount) + if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) { + missing.push("Chưa nhập Ngân sách") + } + // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) + if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { + missing.push("Chưa đính kèm Bảng so sánh") + } + return missing + }, [evaluation]) + const canSubmitForApproval = mode === 'workspace' && canEditPhase && !readOnly && forwardPhase != null + && missingForApproval.length === 0 // Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt" - // không bấm được — user feedback 2026-05-07). + // không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/ + // readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng. const submitDisabledReason = !canEditPhase ? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.` : readOnly ? 'Chế độ chỉ đọc.' : !forwardPhase ? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.` - : null + : missingForApproval.length > 0 + ? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `• ${m}`).join('\n')}` + : null return (
@@ -225,7 +271,7 @@ export function PeDetailTabs({ )} -
+
@@ -1136,16 +1182,12 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const [createOpen, setCreateOpen] = useState(false) - // c. Giá chào thầu = sum quotes của NCC được chọn (winner) + // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper + // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. const winnerSupplierRowId = ev.selectedSupplierId ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null : null - const giaChaoThau = winnerSupplierRowId - ? ev.details - .flatMap(d => d.quotes) - .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) - .reduce((sum, q) => sum + q.thanhTien, 0) - : null + const giaChaoThau = computeGiaChaoThau(ev) // d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null const banSoSanhAttachments = ev.attachments.filter( @@ -1661,7 +1703,7 @@ function HangMucCard({ const setWinner = useMutation({ mutationFn: async (supplierId: string) => api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), - onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, + onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) @@ -1847,7 +1889,7 @@ function HangMucCard({ 'rounded px-1 py-0.5', isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', )} - title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'} + title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'} > diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index e3846d3..cf7fd54 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -69,6 +69,21 @@ const NCC_PALETTES = [ 'border-l-pink-400 bg-pink-50/40', ] as const +// Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner +// supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check +// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn +// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. +function computeGiaChaoThau(ev: PeDetailBundle): number | null { + const winnerSupplierRowId = ev.selectedSupplierId + ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null + : null + if (winnerSupplierRowId === null) return null + return ev.details + .flatMap(d => d.quotes) + .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) + .reduce((sum, q) => sum + q.thanhTien, 0) +} + // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // @@ -143,20 +158,51 @@ export function PeDetailTabs({ const forwardPhase = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) + + // Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt). + // CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị + // NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song). + // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. + const missingForApproval = useMemo(() => { + const missing: string[] = [] + // 1. Chưa chọn Đơn vị NCC/TP + if (evaluation.selectedSupplierId == null) { + missing.push("Chưa chọn Đơn vị NCC/TP") + } else { + // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). + // Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1). + const gia = computeGiaChaoThau(evaluation) + if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") + } + // 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount) + if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) { + missing.push("Chưa nhập Ngân sách") + } + // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) + if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { + missing.push("Chưa đính kèm Bảng so sánh") + } + return missing + }, [evaluation]) + const canSubmitForApproval = mode === 'workspace' && canEditPhase && !readOnly && forwardPhase != null + && missingForApproval.length === 0 // Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt" - // không bấm được — user feedback 2026-05-07). + // không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/ + // readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng. const submitDisabledReason = !canEditPhase ? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.` : readOnly ? 'Chế độ chỉ đọc.' : !forwardPhase ? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.` - : null + : missingForApproval.length > 0 + ? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `• ${m}`).join('\n')}` + : null return (
@@ -225,7 +271,7 @@ export function PeDetailTabs({ )}
-
+
@@ -1136,16 +1182,12 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const [createOpen, setCreateOpen] = useState(false) - // c. Giá chào thầu = sum quotes của NCC được chọn (winner) + // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper + // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. const winnerSupplierRowId = ev.selectedSupplierId ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null : null - const giaChaoThau = winnerSupplierRowId - ? ev.details - .flatMap(d => d.quotes) - .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) - .reduce((sum, q) => sum + q.thanhTien, 0) - : null + const giaChaoThau = computeGiaChaoThau(ev) // d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null const banSoSanhAttachments = ev.attachments.filter( @@ -1661,7 +1703,7 @@ function HangMucCard({ const setWinner = useMutation({ mutationFn: async (supplierId: string) => api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), - onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, + onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) @@ -1847,7 +1889,7 @@ function HangMucCard({ 'rounded px-1 py-0.5', isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', )} - title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'} + title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'} > diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 5fad08b..5b50a9c 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -142,6 +142,61 @@ public class PurchaseEvaluationWorkflowService( throw new ForbiddenException( $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu."); } + + // ===== UAT S60 (anh Kiệt) — Section 3 completeness guard ===== + // "Mục chọn thầu phải có thông tin mới cho gửi duyệt" — đủ CẢ 4 + // mọi trường hợp (anh chốt S60): (1) Đơn vị NCC/TP được chọn + // (2) Giá chào thầu > 0 của đơn vị đó (3) Ngân sách — link Budget + // HOẶC nhập tay (4) Bảng so sánh đính kèm. + // Message gộp MỌI mục thiếu 1 lần (UX — không bắt thử lại từng lỗi). + // Áp CẢ Admin/system (data-quality ≠ authz — phiếu thiếu thông tin + // thì không ai được trình, kể cả gửi hộ). + // Predicate "Bảng so sánh" = attachment chung (SupplierRowId null) + // — mirror ĐÚNG FE PeDetailTabs banSoSanhAttachments + pre-check + // missingForApproval (FE KHÔNG check Purpose enum → BE cũng không, + // tránh mismatch 2 tầng). + var missing = new List(); + if (evaluation.SelectedSupplierId is not Guid winnerId) + { + missing.Add("chưa chọn Đơn vị NCC/TP"); + } + else + { + var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() + .Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId) + .Select(s => s.Id) + .ToListAsync(ct); + if (winnerRowIds.Count == 0) + { + missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia"); + } + else + { + var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking() + .Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId)) + .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; + if (winnerQuoteTotal <= 0) + missing.Add("Đơn vị được chọn chưa có giá chào thầu"); + } + } + if (evaluation.BudgetId is null + && (evaluation.BudgetManualAmount is null || evaluation.BudgetManualAmount <= 0)) + { + missing.Add("chưa nhập Ngân sách"); + } + var hasComparisonFile = await db.PurchaseEvaluationAttachments.AsNoTracking() + .AnyAsync(a => a.PurchaseEvaluationId == evaluation.Id + && a.PurchaseEvaluationSupplierId == null, ct); + if (!hasComparisonFile) + missing.Add("chưa đính kèm Bảng so sánh"); + + if (missing.Count > 0) + { + throw new ConflictException( + "Chưa đủ thông tin mục 3 \"Đơn vị NCC/TP được chọn\" để gửi duyệt: " + + string.Join(" · ", missing) + "."); + } + evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; // Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated. @@ -153,6 +208,15 @@ public class PurchaseEvaluationWorkflowService( evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null; evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); + + // UAT S60 (anh Kiệt) — bypass khi NGƯỜI SOẠN nằm trong chuỗi duyệt + // bước đầu (vd Trưởng phòng tự tạo phiếu → không bắt NV duyệt lại + // + không bắt TP tự bấm duyệt phiếu mình). V2-only. Chạy SAU + // LogTransition để Changelog đúng thứ tự: "Chuyển phase" → "Bỏ qua + // Cấp...". TraLai-resubmit đi cùng branch (reset Cấp 1) → tự áp lại. + if (evaluation.ApprovalWorkflowId is Guid submitAwId) + await ApplyDrafterBypassOnSubmitAsync(evaluation, submitAwId, ct); + await db.SaveChangesAsync(ct); return; } @@ -440,6 +504,139 @@ public class PurchaseEvaluationWorkflowService( return summary; } + // ===== UAT S60 — Drafter-in-chain bypass khi gửi duyệt (V2-only) ===== + // Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên duyệt lại." + // Luật GENERIC theo cấp (anh chốt S60): chỉ xét BƯỚC ĐẦU (= phòng soạn). + // Người soạn (DrafterUserId — KHÔNG phải actor submit, Admin gửi hộ vẫn + // tính theo người soạn) là approver ở cấp nào trong bước đầu → auto qua + // Cấp 1..k (k = MAX Order có slot drafter — drafter có thể ở nhiều cấp, + // Designer chỉ chặn duplicate cùng cấp). Phiếu bắt đầu chờ Cấp k+1 / Bước 2 + // Cấp 1 / terminal DaDuyet (quy trình 1 bước mà drafter là cấp cuối). + // Các bước SAU + BOD duyệt đầy đủ bình thường. + // + // Audit trail 3 tầng: + // - LevelOpinion: ghi CHỈ slot CHÍNH CHỦ (Level.ApproverUserId == drafter) + // — KHÔNG ghi hộ cấp NV bị skip (không gán chữ ký người không duyệt + + // không trigger FE badge "duyệt thay" sai nghĩa). Cấp skip để trống + // Section 5 (đúng sự thật); vết nằm ở Approval row + Changelog. + // - Approval row per cấp (Decision=AutoApprove) → ApprovalsTab có vết. + // - Changelog 1 dòng tóm tắt pointer nhảy. + // Idempotent khi TraLai-resubmit: opinion UPSERT, approval row add thêm + // (lịch sử 2 lần gửi = 2 vết — đúng semantics history). + // Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn + // hợp lệ pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt). + private async Task ApplyDrafterBypassOnSubmitAsync( + PurchaseEvaluation evaluation, Guid awId, CancellationToken ct) + { + if (evaluation.DrafterUserId is not Guid drafterId) return; + + var aw = await db.ApprovalWorkflows.AsNoTracking() + .Include(w => w.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Levels.OrderBy(l => l.Order)) + .FirstOrDefaultAsync(w => w.Id == awId, ct); + if (aw is null) return; + + var steps = aw.Steps.OrderBy(s => s.Order).ToList(); + if (steps.Count == 0) return; + + var firstStep = steps[0]; + var levelGroups = firstStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList(); + if (levelGroups.Count == 0) return; + + var drafterSlots = firstStep.Levels.Where(l => l.ApproverUserId == drafterId).ToList(); + if (drafterSlots.Count == 0) return; // drafter ngoài chuỗi bước đầu — flow thường + + var k = drafterSlots.Max(l => l.Order); + var maxLevelOrder = levelGroups.Max(g => g.Key); + var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct); + var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o <= k).OrderBy(o => o).ToList(); + + foreach (var order in bypassedOrders) + { + var ownSlot = drafterSlots.FirstOrDefault(l => l.Order == order); + + // Approval row vết audit per cấp — Decision=AutoApprove phân biệt + // rõ với duyệt tay (mirror style ApproveV2Async :501-510). + db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval + { + PurchaseEvaluationId = evaluation.Id, + FromPhase = PurchaseEvaluationPhase.ChoDuyet, + ToPhase = PurchaseEvaluationPhase.ChoDuyet, + ApproverUserId = drafterId, + Decision = ApprovalDecision.AutoApprove, + Comment = ownSlot is not null + ? $"[Bước 1 — Cấp {order}] (duyệt tự động khi gửi — người soạn phiếu là người duyệt cấp này)" + : $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)", + ApprovedAt = dateTime.UtcNow, + }); + + // Opinion CHỈ slot chính chủ (UPSERT — mirror ApproveV2Async Mig 26). + if (ownSlot is not null) + { + const string autoComment = "(duyệt tự động — người soạn phiếu là người duyệt cấp này)"; + var existingOpinion = await db.PurchaseEvaluationLevelOpinions + .FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id + && o.ApprovalWorkflowLevelId == ownSlot.Id, ct); + if (existingOpinion is null) + { + db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion + { + PurchaseEvaluationId = evaluation.Id, + ApprovalWorkflowLevelId = ownSlot.Id, + Comment = autoComment, + SignedAt = dateTime.UtcNow, + SignedByUserId = drafterId, + SignedByFullName = drafterFullName, + }); + } + else + { + existingOpinion.Comment = autoComment; + existingOpinion.SignedAt = dateTime.UtcNow; + existingOpinion.SignedByUserId = drafterId; + existingOpinion.SignedByFullName = drafterFullName; + } + } + } + + // Advance pointer qua Cấp k (next = min Order > k — robust khi Order + // không liên tục; engine gốc +1 giả định liên tục, đây superset). + if (k < maxLevelOrder) + { + var nextOrder = levelGroups.Select(g => g.Key).Where(o => o > k).Min(); + evaluation.CurrentApprovalLevelOrder = nextOrder; + await LogTransitionAsync(evaluation, + PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet, + drafterId, ApprovalDecision.AutoApprove, + $"Bỏ qua Cấp 1..{k} Bước 1 (người soạn {drafterFullName} là người duyệt Cấp {k}) — phiếu chờ từ Cấp {nextOrder}", + ct); + } + else if (steps.Count > 1) + { + evaluation.CurrentWorkflowStepIndex = 1; + evaluation.CurrentApprovalLevelOrder = 1; + await LogTransitionAsync(evaluation, + PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet, + drafterId, ApprovalDecision.AutoApprove, + $"Bỏ qua toàn bộ Bước 1 (người soạn {drafterFullName} là người duyệt cấp cuối của bước) — phiếu chờ Bước 2 (Cấp 1)", + ct); + } + else + { + // Quy trình chỉ 1 bước + drafter là cấp cuối → terminal DaDuyet + // (mirror ApproveV2Async terminal :617-624). + evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.CurrentApprovalLevelOrder = null; + evaluation.SlaDeadline = null; + await LogTransitionAsync(evaluation, + PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet, + drafterId, ApprovalDecision.AutoApprove, + "(duyệt tự động toàn bộ — quy trình 1 bước, người soạn là người duyệt cấp cuối)", + ct); + } + } + // ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels ===== // Mig 31 (S23 t1 Plan K) — `skipToFinal` 8th param: F2 Approver scope ChoDuyet. // Admin opt-in flag per slot tại matchingLevel.AllowApproverSkipToFinal. diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs new file mode 100644 index 0000000..1f6ad03 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs @@ -0,0 +1,636 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +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; + +// ===== UAT S60 (anh Kiệt) — 2 feature mới trong submit branch ===== +// File mirror pattern PurchaseEvaluationWorkflowServiceGuardTests.cs cùng folder +// (helper dựng PE + AwV2 + IdentityFixture SQLite). KHÔNG touch production code — +// test theo CODE hiện tại trong PurchaseEvaluationWorkflowService.cs. +// +// Feature 1 — Section 3 completeness guard (submit branch DangSoanThao/TraLai → +// ChoDuyet, line 158-198 prod): chặn ConflictException khi thiếu 1 trong 4: +// (1) SelectedSupplierId null → "chưa chọn Đơn vị NCC/TP" +// (2) winner quote sum (map PES.SupplierId==winner → row Ids → quotes) ≤ 0 +// → "chưa có giá chào thầu" +// (3) BudgetId null && (BudgetManualAmount null || ≤0) → "chưa nhập Ngân sách" +// (4) KHÔNG có attachment PES_Id==null → "chưa đính kèm Bảng so sánh" +// Message gộp MỌI mục thiếu 1 lần. Áp CẢ Admin (data-quality ≠ authz). +// +// Feature 2 — Drafter-in-chain bypass khi submit (ApplyDrafterBypassOnSubmitAsync, +// V2-only, line 528-638 prod): drafter là approver cấp k (MAX Order match) BƯỚC +// ĐẦU → auto qua Cấp 1..k. Approval rows per cấp (Decision=AutoApprove); opinion +// CHỈ ghi slot chính chủ (ownSlot.ApproverUserId==drafter); pointer k0 + manual budget + comparison file). +public class PeSubmitGuardAndBypassTests +{ + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, + TestApplicationDbContext db, FixedDateTime clock) CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var clock = new FixedDateTime(new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um); + return (svc, fix, db, clock); + } + + // PE ở Nháp (DangSoanThao) — entry point submit branch. Drafter mặc định + // random (test guard không cần drafter trong chuỗi). V2 nếu awId set. + private static PurchaseEvaluation BuildPeNhap( + Guid? selectedSupplierId = null, + Guid? budgetId = null, + decimal? budgetManualAmount = null, + Guid? approvalWorkflowId = null, + Guid? drafterUserId = null, + string code = "PE-S60-001") + { + return new PurchaseEvaluation + { + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = code, + TenGoiThau = "Test S60 submit guard + bypass", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafterUserId ?? Guid.NewGuid(), + SelectedSupplierId = selectedSupplierId, + BudgetId = budgetId, + BudgetManualAmount = budgetManualAmount, + ApprovalWorkflowId = approvalWorkflowId, + }; + } + + // Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1 + // quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref). + private static async Task 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; + } + + // Attachment "Bảng so sánh" = PurchaseEvaluationSupplierId NULL (chung phiếu). + private static void SeedComparisonAttachment(TestApplicationDbContext db, PurchaseEvaluation pe) + { + db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment + { + PurchaseEvaluationId = pe.Id, + PurchaseEvaluationSupplierId = null, // chung phiếu = bảng so sánh + FileName = "bang-so-sanh.xlsx", + StoragePath = "/uploads/bang-so-sanh.xlsx", + FileSize = 1024, + ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Purpose = PurchaseEvaluationAttachmentPurpose.ComparisonTable, + }); + } + + // Seed workflow V2: 1 bước (1 phòng) với N cấp (Order 1..levelApprovers.Length), + // mỗi cấp 1 NV theo approverUserIds[i]. Return ApprovalWorkflow đã persist. + private static async Task SeedWorkflowSingleStepAsync( + TestApplicationDbContext db, params Guid[] approverUserIds) + { + return await SeedWorkflowAsync(db, new[] { approverUserIds }); + } + + // 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 (V2 OR-of-N nhưng test dùng 1). + private static async Task SeedWorkflowAsync( + TestApplicationDbContext db, Guid[][] stepApprovers) + { + var wf = new ApprovalWorkflow + { + Code = "QT-S60-V2", + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = "QT test S60 bypass", + IsActive = true, + IsUserSelectable = true, + }; + 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 SubmitAsync( + PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, + params string[] roles) => + svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: actorUserId, + actorRoles: roles.Length > 0 ? roles : new[] { AppRoles.Drafter }, + decision: ApprovalDecision.Approve, + comment: null, + ct: CancellationToken.None); + + // ===================================================================== + // FEATURE 1 — Section 3 completeness guard + // ===================================================================== + + [Fact] + public async Task Submit_MissingAllFour_ThrowsConflict_MessageContainsAllFourClauses() + { + // (1) Thiếu CẢ 4 → message gộp đủ 4 cụm. SelectedSupplier null → branch (1) + // chạy + KHÔNG seed winner quote, budget, comparison. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(); // SelectedSupplierId null, budget null, no attach + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + // Khi winner null → branch winner-quote KHÔNG chạy (chỉ add "chưa chọn + // Đơn vị NCC/TP"). Còn lại 3 mục: ngân sách + bảng so sánh. Tổng 3 cụm. + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP"); + ex.Which.Message.Should().Contain("chưa nhập Ngân sách"); + ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh"); + // Guard chặn trước mutate + pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao); + } + } + + [Fact] + public async Task Submit_MissingWinnerOnly_ThrowsConflict() + { + // (2) Đủ budget + comparison, CHỈ thiếu winner (SelectedSupplierId null). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetManualAmount: 500_000m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP"); + ex.Which.Message.Should().NotContain("chưa nhập Ngân sách"); + ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh"); + } + } + + [Fact] + public async Task Submit_WinnerWithZeroQuote_ThrowsConflict_NoBidPrice() + { + // (3) Winner CÓ (PES row tồn tại) nhưng sum quote = 0 → "chưa có giá chào thầu". + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetManualAmount: 500_000m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0 + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa có giá chào thầu"); + } + } + + [Fact] + public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict() + { + // (4) Đủ winner + quote + comparison. Thiếu ngân sách: BudgetId null VÀ + // BudgetManualAmount = 0 → "chưa nhập Ngân sách" (cover cả nhánh manual=0, + // không chỉ null). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetId: null, budgetManualAmount: 0m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa nhập Ngân sách"); + ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP"); + ex.Which.Message.Should().NotContain("chưa có giá chào thầu"); + ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh"); + } + } + + [Fact] + public async Task Submit_MissingComparisonTable_ThrowsConflict() + { + // (5) Đủ winner + quote + budget. KHÔNG seed attachment chung → thiếu bảng so sánh. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetManualAmount: 500_000m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh"); + } + } + + [Fact] + public async Task Submit_AttachmentBoundToSupplier_DoesNotCountAsComparison_StillConflict() + { + // (6) Có attachment NHƯNG gắn NCC cụ thể (PurchaseEvaluationSupplierId != null) + // → KHÔNG đếm là bảng so sánh (predicate PES_Id==null) → vẫn Conflict thiếu + // bảng so sánh. Boundary: chỉ attachment chung phiếu mới qua. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetManualAmount: 500_000m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + + // Lấy PES row Id của winner để gắn attachment vào NCC cụ thể. + var winnerRowId = await db.PurchaseEvaluationSuppliers + .Where(s => s.PurchaseEvaluationId == pe.Id && s.SupplierId == supplierId) + .Select(s => s.Id).FirstAsync(); + db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment + { + PurchaseEvaluationId = pe.Id, + PurchaseEvaluationSupplierId = winnerRowId, // gắn NCC → KHÔNG phải bảng so sánh + FileName = "bao-gia-ncc.pdf", + StoragePath = "/uploads/bao-gia-ncc.pdf", + FileSize = 2048, + ContentType = "application/pdf", + Purpose = PurchaseEvaluationAttachmentPurpose.QuoteDocument, + }); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh"); + } + } + + [Fact] + public async Task Submit_AllFourMet_ManualBudget_NoWorkflow_SetsChoDuyet() + { + // (7) Đủ 4 (manual budget > 0, KHÔNG BudgetId, KHÔNG ApprovalWorkflowId) → + // submit OK Phase=ChoDuyet. V1/no-workflow: pointer StepIdx=0, Level null + // (line 208 — chỉ init level=1 nếu V2). + var (svc, fix, db, clock) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetManualAmount: 750_000m); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + await SubmitAsync(svc, pe, Guid.NewGuid()); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(0); + pe.CurrentApprovalLevelOrder.Should().BeNull("phiếu không pin V2 → level pointer null"); + pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7)); + } + } + + [Fact] + public async Task Submit_AllFourMet_ViaBudgetId_ManualNull_SetsChoDuyet() + { + // (8) Đủ 4 qua BudgetId (manual null) → OK. Cover nhánh budget thoả qua FK + // Budget thay vì manual amount. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetId: Guid.NewGuid(), budgetManualAmount: null); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + await SubmitAsync(svc, pe, Guid.NewGuid()); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + } + } + + // ===================================================================== + // FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only) + // Mọi test dưới đây PHẢI dựng PE đủ 4 điều kiện Section 3 (guard chạy trước). + // ===================================================================== + + // Helper: dựng PE V2 đủ 4 điều kiện Section 3 + pin workflow. Drafter = + // drafterUserId. Return PE đã persist (Nháp). + private static async Task BuildV2PeReadyToSubmitAsync( + TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code) + { + var pe = BuildPeNhap( + budgetManualAmount: 1_000_000m, + approvalWorkflowId: workflowId, + drafterUserId: drafterUserId, + code: code); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_500_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + return pe; + } + + [Fact] + public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesAllLevels_MovesToStep2() + { + // (9) Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV + + // Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). k=2=maxLevel bước 1 + còn bước 2 + // → pointer StepIdx=1 Level=1. Opinion CHỈ 1 row slot TP (Cấp 2). 2 approval + // AutoApprove (Cấp 1 + Cấp 2). KHÔNG opinion Cấp 1. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var nvLevel1 = (await fix.CreateUserAsync("nv1@s60.test", "NV Cap 1", null, Array.Empty())).Id; + var drafterTp = (await fix.CreateUserAsync("tp@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id; + var step2Boss = (await fix.CreateUserAsync("boss@s60.test", "Boss Step2", null, Array.Empty())).Id; + + // bước 1: Cấp 1 = NV, Cấp 2 = TP(drafter) · bước 2: Cấp 1 = boss + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { nvLevel1, drafterTp }, + new[] { step2Boss }, + }); + var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterTp, "PE-S60-009"); + + await SubmitAsync(svc, pe, drafterTp); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(1, "k=max bước 1 + còn bước 2 → sang Bước 2"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + + // Opinion CHỈ slot chính chủ (TP Cấp 2). KHÔNG opinion Cấp 1 (NV bị skip). + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().HaveCount(1, "chỉ ghi opinion slot drafter, không ghi hộ NV skip"); + var tpLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id; + opinions[0].ApprovalWorkflowLevelId.Should().Be(tpLevelId); + opinions[0].SignedByUserId.Should().Be(drafterTp); + opinions[0].Comment.Should().Contain("duyệt tự động"); + + // 2 approval AutoApprove (Cấp 1 + Cấp 2 bước 1). + var autoApprovals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id + && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); + autoApprovals.Should().HaveCount(2, "2 cấp bị bypass = 2 vết Approval AutoApprove"); + } + } + + [Fact] + public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesLevel1Only_MovesToLevel2SameStep() + { + // (10) Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). k=1 < maxLevel=2 + // → pointer Level=2 cùng bước (StepIdx giữ 0). Opinion slot NV Cấp 1. 1 approval + // AutoApprove. Cấp 2 KHÔNG bypass (approver khác). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var drafterNv = (await fix.CreateUserAsync("nv@s60.test", "NV Drafter", null, new[] { AppRoles.Drafter })).Id; + var level2Boss = (await fix.CreateUserAsync("boss2@s60.test", "Boss Cap 2", null, Array.Empty())).Id; + + var wf = await SeedWorkflowSingleStepAsync(db, drafterNv, level2Boss); // Cấp1=drafter, Cấp2=boss + var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterNv, "PE-S60-010"); + + await SubmitAsync(svc, pe, drafterNv); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(0, "cùng bước"); + pe.CurrentApprovalLevelOrder.Should().Be(2, "k=1 < max=2 → chờ Cấp 2"); + + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().HaveCount(1); + var nvLevelId = wf.Steps.First().Levels.First(l => l.Order == 1).Id; + opinions[0].ApprovalWorkflowLevelId.Should().Be(nvLevelId, "slot NV Cấp 1 chính chủ"); + opinions[0].SignedByUserId.Should().Be(drafterNv); + + var autoApprovals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id + && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); + autoApprovals.Should().HaveCount(1, "chỉ Cấp 1 bypass"); + } + } + + [Fact] + public async Task Submit_DrafterNotInFirstStep_NoBypass_StartsAtStep1Level1() + { + // (11) Drafter KHÔNG ở bước đầu (chỉ ở bước 2). drafterSlots bước 1 empty → + // return sớm KHÔNG bypass. Pointer giữ init Step 0 Level 1. 0 approval auto. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var step1Nv = (await fix.CreateUserAsync("s1nv@s60.test", "NV Buoc 1", null, Array.Empty())).Id; + var drafterStep2 = (await fix.CreateUserAsync("d2@s60.test", "Drafter Buoc 2", null, new[] { AppRoles.Drafter })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { step1Nv }, // bước 1: NV khác (KHÔNG phải drafter) + new[] { drafterStep2 }, // bước 2: drafter — nhưng bypass chỉ xét bước đầu + }); + var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterStep2, "PE-S60-011"); + + await SubmitAsync(svc, pe, drafterStep2); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(0, "không bypass → start Bước 1"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().BeEmpty("drafter ngoài bước đầu → KHÔNG ghi opinion bypass"); + + var autoApprovals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id + && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); + autoApprovals.Should().BeEmpty("0 cấp bypass"); + } + } + + [Fact] + public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet_PointersNull() + { + // (12) Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). k=2=maxLevel + + // chỉ 1 bước → terminal DaDuyet, pointers null, SlaDeadline null. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var level1Nv = (await fix.CreateUserAsync("l1@s60.test", "NV Cap 1", null, Array.Empty())).Id; + var drafterLast = (await fix.CreateUserAsync("last@s60.test", "Drafter Cap cuoi", null, new[] { AppRoles.Drafter })).Id; + + var wf = await SeedWorkflowSingleStepAsync(db, level1Nv, drafterLast); // Cấp1=NV, Cấp2=drafter(cuối) + var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterLast, "PE-S60-012"); + + await SubmitAsync(svc, pe, drafterLast); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước + drafter cấp cuối → terminal"); + pe.CurrentWorkflowStepIndex.Should().BeNull(); + pe.CurrentApprovalLevelOrder.Should().BeNull(); + pe.SlaDeadline.Should().BeNull(); + + // Opinion CHỈ slot drafter (Cấp 2). KHÔNG opinion Cấp 1. + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().HaveCount(1); + var drafterLevelId = wf.Steps.First().Levels.First(l => l.Order == 2).Id; + opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId); + + var autoApprovals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id + && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); + autoApprovals.Should().HaveCount(2, "Cấp 1 + Cấp 2 bypass"); + } + } + + [Fact] + public async Task Submit_V1Phieu_NoApprovalWorkflowId_SubmitsOk_NoBypass_NoCrash() + { + // (13) Phiếu V1 (ApprovalWorkflowId null) → submit OK, KHÔNG bypass (V2-only), + // KHÔNG crash. Đủ 4 điều kiện Section 3 vẫn áp. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id; + var pe = BuildPeNhap(budgetManualAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + await SubmitAsync(svc, pe, drafter); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentApprovalLevelOrder.Should().BeNull("V1 → level pointer null, không bypass"); + + var autoApprovals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id + && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); + autoApprovals.Should().BeEmpty("V1 không bypass → 0 AutoApprove row"); + } + } + + [Fact] + public async Task Resubmit_FromTraLai_ReAppliesBypass_OpinionNotDuplicated_ApprovalAccumulates() + { + // (14) TraLai → resubmit → bypass áp lại. Opinion KHÔNG duplicate (UPSERT 1 + // row). Approval rows cộng dồn vết (2 lần gửi = 2× AutoApprove cùng cấp). + // Dùng workflow 1 bước 1 cấp = drafter → mỗi submit terminal DaDuyet. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var drafterSolo = (await fix.CreateUserAsync("solo@s60.test", "Drafter Solo", null, new[] { AppRoles.Drafter })).Id; + var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter + var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterSolo, "PE-S60-014"); + + // Lần gửi 1 → terminal DaDuyet, 1 opinion + 1 AutoApprove. + await SubmitAsync(svc, pe, drafterSolo); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); + + var opinionsAfter1 = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinionsAfter1.Should().HaveCount(1); + var autoAfter1 = await db.PurchaseEvaluationApprovals + .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); + autoAfter1.Should().Be(1); + + // Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm). + pe.Phase = PurchaseEvaluationPhase.TraLai; + pe.CurrentWorkflowStepIndex = null; + pe.CurrentApprovalLevelOrder = null; + pe.SlaDeadline = null; + await db.SaveChangesAsync(CancellationToken.None); + + // Lần gửi 2 (resubmit từ TraLai) → bypass áp lại. + await SubmitAsync(svc, pe, drafterSolo); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "resubmit áp lại bypass → terminal"); + + var opinionsAfter2 = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinionsAfter2.Should().HaveCount(1, "UPSERT — opinion KHÔNG duplicate sau resubmit"); + + var autoAfter2 = await db.PurchaseEvaluationApprovals + .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); + autoAfter2.Should().Be(2, "approval rows cộng dồn — 2 lần gửi = 2 vết AutoApprove"); + } + } +}