[CLAUDE] PurchaseEvaluation: rang buoc du 4 thong tin muc 3 moi gui duyet + bypass nguoi soan trong chuoi duyet (UAT anh Kiet S60)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m38s

- 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<max -> 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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-12 11:53:26 +07:00
parent 6bf28bfdb4
commit 37122f0f64
7 changed files with 949 additions and 28 deletions

View File

@ -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 `<60>` GIẢ — corruption thật phải grep literal U+FFFD (chỉ 2 file). Brief em main liệt kê impl-fe+reviewer là "S58 spawn" nhưng on-disk 2 role chỉ có s57bis on-behalf — khớp GATE S58 8-spawn set, flag đối chiếu không phán. Tag [s59-start, clean, mojibake-self-quote].
- **2026-06-11 (S59 `/session-end` 5-trục GATE — tối, 9 body-spawn + closeout-floor):** **GATE PASS 5/5, 0 MISS** (lần đầu 0 on-behalf cần đề xuất kể từ chuỗi 4-MISS post-S57bis). Coverage: tooling ×2 (`:35` start + `:36` end-run mtime 18:15 NGOÀI brief 9-spawn = closeout-floor H1 → reconcile tổng 11) · harvest `:34` · inv recon `:73` · **cicd 6/6 entry RIÊNG #273-278 `:71-76`, sha-chain khớp git log 1:1** (56882ac→9c330d2, topic khớp commit-msg từng run) · 7 role không-spawn 0 phantom. Completeness 4-field 10/10 (#275 honest PASS-PARTIAL PE=1 UAT-leftover). Placement: đúng nhà, stray=0, inv S51→archive VERBATIM moved-not-cut + pointer "curated S59"; nit FIFO swap #273(`:75`)↔#274(`:76`) + archive double-blank. Corruption: 0-byte=0 ×2 nơi · mojibake 3 hit ĐỀU quoted-cũ (cicd:85 = #377 shift ĐÚNG +6 từ :79 — arithmetic shift = số dòng insert, method nhanh phân biệt cũ/mới) · **cicd 54KB/102-dòng (brief nói ~46) + inv 32.9KB over-cap → curate-L2 P1**. Fidelity: bundle BSh2fG2X/D22KfpPc triangulated 5 nguồn (cicd:71 + STATUS:6/:27 + HANDOFF:5 + log:4/:43) · WI=71 ×3 entry · gotcha #61/#62 disk `:1099/:1111` · tooling end-entry tree-state khớp git status độc lập — no-escalate. **2 flag content:** cicd `:53` "Bundle live S59" STALE 1 run (ghi #277 ex7Tc92G/DzUeSk96, live thật = #278 — entry :71 đúng, chỉ status-line lệch) → propose 1-line fix; #275 "09:46:42 sáng" nghi UTC-mislabel = 16:46 chiều local (STATUS:6 nói "chiều nay"; kết luận not-resurrect GIỮ). Tag [s59-end, gate-pass-5/5, 0-miss, cicd-54kb, line53-stale].
- **2026-06-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 ✓)*

View File

@ -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<bool>("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<SlaExpiryJob>()` (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].

View File

@ -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 182196, 0 fail). Mirror `PurchaseEvaluationWorkflowServiceGuardTests` (IdentityFixture+SQLite, reuse `NoOpNotificationService` internal). **F1 Section 3 guard (8):** submit branch (DangSoanThao/TraLaiChoDuyet) 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-budgetChoDuyet / đủ-4-BudgetIdChoDuyet. **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 đầuKHÔNG bypass StepIdx=0 Lvl=1 0-auto / 1-bước+drafter cấp cuốiDaDuyet pointers null SLA null / V1(awId null)→submit OK no-bypass no-crash / TraLai-resubmitbypass á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).

View File

@ -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,19 +158,50 @@ 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.`
: 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({
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section>
<Section title="3. Chọn NCC / TP thắng thầu">
<Section title="3. Đơn vị NCC/TP được chọn">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
@ -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'}
>
<Check className="h-3 w-3" />
</button>

View File

@ -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,19 +158,50 @@ 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.`
: 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({
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section>
<Section title="3. Chọn NCC / TP thắng thầu">
<Section title="3. Đơn vị NCC/TP được chọn">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
@ -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'}
>
<Check className="h-3 w-3" />
</button>

View File

@ -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<string>();
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.

View File

@ -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 k<max →
// Level=nextOrder · k==max+còn step → StepIdx=1/Level=1 · 1-step+k==max →
// terminal DaDuyet pointers null SlaDeadline null.
//
// LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng
// PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + 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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<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;
}
// 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<ApprovalWorkflow> 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<ApprovalWorkflow> 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<ConflictException>();
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<ConflictException>();
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<ConflictException>();
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<ConflictException>();
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<ConflictException>();
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<ConflictException>();
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<PurchaseEvaluation> 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<string>())).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<string>())).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<string>())).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<string>())).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<string>())).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");
}
}
}