Compare commits
8 Commits
c66dab7ee3
...
88368fd87e
| Author | SHA1 | Date | |
|---|---|---|---|
| 88368fd87e | |||
| 2ef3f445bf | |||
| d3fa073c0c | |||
| e5f3c5d70a | |||
| 3abc8a0950 | |||
| 7676718cbe | |||
| 6356def8c0 | |||
| 5dc29cd23f |
@ -74,17 +74,16 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
|
||||
|
||||
## 📅 Recent activity (FIFO — older → archive/git)
|
||||
|
||||
- **2026-07-13 (S116 PE cho nhập giá ÂM — submit-guard 1-line BE, owner anh Kiệt PROD-UAT-live, NO mig/FE/test, spec deterministic exact-line → ACCEPT Case 1):** Giá âm ô "Số tiền báo giá hạng mục" (form.thanhTien→Quote.ThanhTien) = phát-sinh-giảm/hoàn-tiền NCC = giá-trị THẬT (KHÔNG lỗi). Rule "có giá là được": ≥1 quote-selected khác 0 (âm/dương) → gửi được; CHỈ all-zero (chưa báo giá) mới chặn. **THAY DUY NHẤT** `PurchaseEvaluationWorkflowService.cs:206` submit-guard `selectedThanhTien.Sum() <= 0` → `.All(x => x == 0)`. Guard `Count==0` (chưa chọn NCC) ở `if` phía trên GIỮ RIÊNG → `else if` chỉ chạy khi list non-empty → `.All(==0)` = "mọi quote chọn đúng bằng 0". Message giữ "Đơn vị được chọn chưa có giá chào thầu". **KHÔNG đụng** (owner R1/R3/R-CEO): validator ≥0 khác (budget/price PeFeatures/PeWorkItemBudget/PeSuggestedPrice) GIỮ · clamp `CreateContractFromEvaluationFeatures.cs:88-100` GIỮ (Contract.GiaTri cho âm R1) · CEO-threshold winnerQuoteTotal NET GIỮ · KHÔNG thêm validator quote. DB nhận âm sẵn (Mig 65, decimal(18,2) 0 CHECK, 0 validator). Build `SolutionErp.Api` **0 warn/0 err** (Api ref Infrastructure → file compiled). ⚠️ Comment `:193-195` ("tổng ≤0"/"D5 giữ >0"/"winner phải có giá") giờ STALE vs all-zero-logic — KHÔNG sửa (owner "THAY DUY NHẤT :206"), flag owner quyết. LESSON: domain "âm là giá-trị-thật" → presence-check dùng `.All(==0)` KHÔNG `Sum()<=0`/`>=0` (sum-guard nhầm âm-lớn với chưa-nhập). Tag `[s116, pe-negative-quote, submit-guard-1line, all-zero-not-sum, co-gia-la-duoc, no-mig, stale-comment-flagged]`.
|
||||
|
||||
- **2026-07-12 (S113 Supplier import v2 BE — spec `wf_e7896ca6` fable-clone-ensemble, owner anh Kiệt, Mig 64, D1/D2/D3 pre-chốt → ACCEPT Case 1+2; ⚠️ return-1 garbled #53 → coordinator SendMessage-resume, xong solo):** IsPublic publish-gate + dedup MST-primary. **Mig 64 `AddSupplierPublishState`** (Supplier +`bool IsPublic` NOT NULL default false + `Sql("UPDATE Suppliers SET IsPublic=1")` backfill 22 prod + DropIndex→CreateIndex đổi Code-unique filter `[IsDeleted]=0 AND [Code]<>''` cho draft-row Code="" coexist; no new table 89). **Service** `SupplierExcelImportService`: (a) re-bake ExpectedHeaderTokens BYTE-EXACT raw-`\n` form = single-source cho validator(NormalizeHeader collapse \n→space)+BuildTemplate (spec §② 30 token, c7 trailing-space); (b) dedup **MST-primary + Code-backstop** — `BuildMstIndex`(NormalizeMst=Trim+strip-inner-ws `\s+`→"" OrdinalIgnoreCase, blank→skip) precedence existing-MST→existing-Code→batch-MST→batch-Code, `countedUpdates HashSet<Supplier>` tránh double-count, blank-MST KHÔNG vào batchByMst (2 blank=2 insert); (c) BỎ hard-error blank-Code (=draft), GIỮ Name+all-or-nothing; (d) New `IsPublic=!blank(Code)`, update-path KHÔNG đụng IsPublic; +MstMissing(soft-warn) +DraftCount; (e) `BuildTemplate()` ClosedXML sheet "Sheet1" 30 token ROW4 WrapText+FreezeRows. **PublishSupplierCommand**(Id,MaNcc?,Publish): set Code+unique-CI-check + gate Publish→Code-non-empty ConflictException, KHÔNG UpdateSupplierCommand (#73 clobber). **API THẬT:** GET `/api/suppliers/import/template`(xlsx "Mau-Database-NCC.xlsx") · POST `/api/suppliers/{id}/publish` body`{maNcc?,publish}` · GET `/api/suppliers?published={t/f}` filter · SupplierDto +`bool IsPublic` append-CUỐI (sửa 2 projection List+Get). **D3 authz:** import preview/confirm/template/publish `[Roles=Admin,CatalogManager]`→`[Policy="Suppliers.Update"]` (khớp FE PermissionGuard). DbInit +IsPublic=true 4 real+18 sample. Build slnx **0/0**, apply BOTH DB (Dev catch-up+Design). KHÔNG commit/FE/test. LESSON: S112 CI-dedup-at-service mở rộng → dual-key MST-primary; blank-key KHÔNG index. Tag `[s113, supplier-import-v2, mig64, ispublic-publish-gate, dedup-mst-primary-code-backstop, blank-code-draft, buildtemplate-single-source, publish-cmd-no-#73]`.
|
||||
|
||||
- **2026-07-12 (S112 PE sign-off close-review implement — `wf_7df56b89-7bb` GO-WITH-ADJ, owner anh Kiệt PROD-UAT-live, 4-region/1 file `PurchaseEvaluationWorkflowService.cs`, NO mig/FE/test → ACCEPT Case 1):** **C1 A1 (`ApplyDrafterBypassOnSubmitAsync`):** người soạn KÝ TAY ô cấp mình — `k=Max`→`minOwn=Min(drafterSlots.Order)`, bypass `<=k`→`<minOwn` (chỉ NV cấp dưới THUẦN), XÓA canned-opinion UPSERT (không tự ký hộ ai) + dead `maxLevelOrder`/`ownSlot`; advance 3-nhánh→1 `if(minOwn>1){pointer=minOwn}` STOP tại minOwn (người soạn tự ApproveV2Async); minOwn=1→bypass rỗng pointer giữ (0,1); non-contiguous Cấp1+Cấp3 ký tay đúng thứ tự. **C2 EDGE-5 hard-lock:** top `TransitionAsync` sau isAdmin/isSystem TRƯỚC mọi branch → `if(fromPhase is DaDuyet or TuChoi && !isAdmin && !isSystem) throw Conflict` (chặn non-admin lật phiếu terminal; Admin+System MIỄN); KHÔNG đụng EnsureCanRejectV2Async. **C3** no self-finalize (owner oQ1 permission-driven). **C4 opinion-history (`ApproveV2Async` UPSERT else):** old.Comment→Changelog (Workflow/Update/EntityId=level.Id) TRƯỚC overwrite chỉ khi non-empty — NO mig/bảng/endpoint. Build slnx **0/0**. Infra **4 FAIL/401 pass**: fail=C1 (PeSubmitGuardAndBypassTests 9/10/12/14 assert OLD auto-sign/terminal-on-submit); C2/C4 break 0 test → test-specialist ADD. LESSON: behavior-change write-site → run consumer tests, hand VERIFIED fail-list (KHÔNG sửa test). Tag `[s112, pe-signoff, a1-sign-own-level, edge5-hardlock, opinion-history-changelog, consumer-test-verified-handoff]`.
|
||||
- **2026-07-12 (S112 Supplier Excel-import Phase B BE — `wf_e2f23a84` invest-ensemble → `wf_75d6149b` close-review GO-WITH-ADJ, owner anh Kiệt, commit `e100ef0`, Mig 63 → ACCEPT Case 1/2):** **Service** `SupplierExcelImportService` (preview + confirm 2-stage) + 2 endpoint (`/suppliers/import/preview` [415 non-xlsx] · `/suppliers/import/confirm` [401 auth]) + **Mig 63 `AddSupplierImportSourceFields`** (Supplier +`SourceUpdatedAt datetime2?`+`SourceUpdatedBy nvarchar(200)?` provenance cột 29/30; 2 AddColumn nullable no-new-table 89). FE 2-app byte-identical (`SupplierImportDialog`). **LESSONS:** (1) dedup Code = **OrdinalIgnoreCase ở SERVICE** — DB unique-index CI-collation lật (SQLite fixture BINARY KHÔNG bắt, prod SQL Server CI bắt) → dedup KHÔNG dựa DB; (2) parser **absolute cell-index** (row-4 header-fingerprint), KHÔNG `CellsUsed()` (column-drift); (3) **all-or-nothing** gate TRƯỚC mutate (1 hard-error→Committed=false DB unchanged); (4) fill-nulls **SKIP Code/Name/Type** (chỉ điền field trống); (5) `.Normalize(FormC)` cho real-file NFD (close-review adjust bake 30 `RealFileHeaderTokens` byte-verify). **SURPRISE:** Approach-B (extend seed) ĐÃ ship 4 NCC prod (Mig 62) → spec "partial" SAI; import ghi entity **TRỰC TIẾP** (fill-where-non-empty) KHÔNG qua `UpdateSupplierCommand` (tránh NULL-clobber #73). Shipped Run #496 PASS, reviewer GO-WITH-ADJ (BE 6/6 axes). Tag `[s112, supplier-excel-import, mig63, ordinalignorecase-dedup-at-service, abs-cell-index-parser, all-or-nothing, fill-nulls-safe, normalize-formc-nfd, direct-entity-write]`.
|
||||
- **2026-06-25 (S88 PE stability cleanup — FROZEN-module mechanical relocation + 1 stale-comment fix BE — NO migration, 3 edit/2 file, em-main spec deterministic 100% file/line/boundary given → ACCEPT Case 1):** Pure mechanical move ZERO behavior-change. **T1 misplaced-command relocate:** `SetPeCcmBudgetPeriodCommand`+Validator+Handler (BUDGET setter writes `pe.CcmBudgetPeriodAmount` Section B "NS kỳ này" CCM, Mig 59) sống NHẦM trong suggested-PRICE file → move về budget file. (a) REMOVE `PeSuggestedPriceFeatures.cs` lines 144-199 (blank + `===== CCM NS kỳ này =====` header → `SetPeCcmBudgetPeriodCommandHandler` closing `}`); file end clean tại line 143 (`UpdatePeSuggestedPriceCcmCommandHandler` `}`), suggested-price PRO/CCM trên giữ nguyên. (b) APPEND `PeWorkItemBudgetFeatures.cs` END sau line 200 (`UpdatePeBudgetCcmCommandHandler` `}`) → cạnh budget setters `UpdatePeBudgetPro/Ccm`. Code BYTE-IDENTICAL (logic/authz Admin\|CostControl fail-closed/validation GTE(0).When(HasValue)/absolute-set null=clear/changelog UNCHANGED); CHỈ prepend 1 comment line `// [S88] Chuyển từ PeSuggestedPriceFeatures.cs về đây (đúng nhóm budget setter).`. Usings 8-header IDENTICAL 2 file → KHÔNG add duplicate. Namespace `SolutionErp.Application.PurchaseEvaluations` SAME → MediatR type-resolution transparent, controller `:125` KHÔNG đụng. **T2 stale-comment fix:** `Dtos/PurchaseEvaluationDtos.cs` ~250-254 comment trên `decimal WinnerQuoteTotal` ghi SUM "của SelectedSupplierId" (OLD single-winner) → verified actual compute `PurchaseEvaluationFeatures.cs:1093-1099` = `Suppliers.Where(s=>s.IsWinner)` (multi, Mig 58) → sửa CHỈ comment-text "MỌI đơn vị IsWinner (multi-winner Mig 58)". Field/code UNCHANGED. **LESSON:** frozen-relocation = read BOTH file FIRST exact boundary + verify usings-superset target + verify stale-comment claim vs real compute-site TRƯỚC sửa (KHÔNG trust comment blind, mirror S35 EF-source-of-truth). Build `SolutionErp.Application.csproj -v q` **0 warn 0 err** (4s). KHÔNG test (test-specialist)/FE/mig/commit. Tag `[s88, frozen-relocation, byte-identical-move, mediatr-type-resolve-transparent, stale-comment-verify-compute-site, no-behavior-change]`.
|
||||
- **2026-06-24 (PE Section B 3-cột THỰC HIỆN — CCM "NS kỳ này" cột riêng BE — Mig 59 `AddCcmBudgetPeriodToPurchaseEvaluation` 3-file, 5 edit/0 new file, em-main CHỐT spec+API-contract 100% → ACCEPT Case 1):** Section B tách 3 cột Dự án|PRO|CCM giống Section A. PRO=`BudgetPeriodAmount` sẵn có; CCM nhập cột riêng độc-lập. COOKIE-CUTTER mirror S77 suggested-price-Ccm + S65b HoSoLink one-column. (1) `PurchaseEvaluation.cs` +`decimal? CcmBudgetPeriodAmount` sau ExpectedRemainingAmount. (2) `PurchaseEvaluationConfiguration.cs` +`HasColumnType("decimal(18,2)")` sau ExpectedRemaining (spec asked literal decimal(18,2); siblings dùng HasPrecision(18,2) NHƯNG emit identical column — tôi follow spec verbatim HasColumnType). (3) Mig: Up=1 AddColumn decimal(18,2) nullable NO table/index, Down=1 DropColumn, snapshot verified — clean mirror Mig 52. (4) DTO `PurchaseEvaluationDetailBundleDto` +`decimal? CcmBudgetPeriodAmount` sau ExpectedRemainingAmount TRƯỚC BudgetSummary + projection PEFeatures.cs:1110 positional-insert đúng order (positional record → projection order BẮT BUỘC khít). (5) Setter `SetPeCcmBudgetPeriodCommand(Guid Id, decimal? CcmBudgetPeriodAmount):IRequest` append `PeSuggestedPriceFeatures.cs` (same concern PE role-gate setters) + Validator `GreaterThanOrEqualTo(0).When(HasValue)` (budget=0 hợp lệ) + Handler fail-closed Forbidden Admin|CostControl TRƯỚC side-effect (S56 #5) + absolute-set null=clear (gotcha #73) + changelog Header (sibling-consistency; spec min=SaveChanges). (6) Controller `[HttpPatch("{id:guid}/budget/ccm-period")]` + `CcmBudgetPeriodBody` (spec EXPLICIT PATCH dù siblings PUT — follow spec). **Call-site verify:** `new PurchaseEvaluationDetailBundleDto(` repo-wide → EXACTLY 1 (PEFeatures:1100, đã sửa) → positional-safe. Submit-guard `BudgetPeriodAmount` PRO KHÔNG đụng. Build SolutionErp.slnx (6 proj incl 2 test, gotcha #65) **0 warn 0 err**. KHÔNG apply DB/FE/test/commit. Route FE/test mirror: DTO field `ccmBudgetPeriodAmount` camelCase + PATCH `/api/purchase-evaluations/{id}/budget/ccm-period` body `{ ccmBudgetPeriodAmount }`. Tag `[pe-section-b-3col, ccm-budget-period, mig59, one-column-no-table, absolute-set-fail-closed, positional-dto-order, patch-per-spec]`.
|
||||
- **2026-06-19 (PE chuông báo approver BE — NO migration, 1 edit/1 file, em-main spec deterministic 100% → ACCEPT Case 1):** Tra Sol (Zalo) "không thấy chuông — việc có hồ sơ cần duyệt": approver Cấp hiện tại KHÔNG nhận notify khi phiếu ENTER/ADVANCE tới ChoDuyet (chỉ drafter báo terminal). FIX = +1 block trong `LogTransitionAsync` (PurchaseEvaluationWorkflowService.cs ~line 1058) NGAY SAU drafter-notify, KHÔNG endpoint mới (Pattern 4 — notify = side-effect của Submit/Approve-advance, cả 2 đã gọi LogTransitionAsync + end ChoDuyet). **Resolution mirror EXACT canonical** `EnsureActorInLevel` (line ~301): `db.ApprovalWorkflows.AsNoTracking().Include(Steps).ThenInclude(Levels)` → `stepsOrdered[CurrentWorkflowStepIndex]` (bounds-check) → `.Levels.Where(Order==CurrentApprovalLevelOrder && ApproverUserId!=Guid.Empty && !=actorUserId).Select(ApproverUserId).Distinct()` → `NotifyManyAsync(ids, Generic, "Phiếu cần bạn duyệt: {MaPhieu??TenGoiThau}", "Có phiếu Duyệt NCC đang chờ bạn duyệt.", /purchase-evaluations/{Id}, refId:Id, ct)`. **Recon:** (1) `ApprovalWorkflowLevel.ApproverUserId` = non-null `Guid` (NOT Guid?) → "exclude null" = `!=Guid.Empty` defensive (OR-of-N rows mỗi 1 NV). (2) `NotifyManyAsync` sig verified = `(IEnumerable<Guid>, NotificationType, title, string? desc, string? href, Guid? refId, CT)` khớp urgent-feature ref. (3) actor-exclude qua `!= actorUserId` (Guid != Guid? lift OK). **Guard V2-only:** `ApprovalWorkflowId is Guid && CurrentWorkflowStepIndex is int && CurrentApprovalLevelOrder is int` → V1/no-workflow skip. **Best-effort try/catch** nuốt lỗi (phiếu đã SaveChanges; notify fail KHÔNG rollback). Block đọc pointer SAU set → Submit (idx0/lvl1) + Approve-advance (pointer advance rồi LogTransition) đều fire đúng. Build SolutionErp.slnx (2 test project, gotcha #65) **0 warn 0 err**. KHÔNG test/FE/mig/commit. Tag `[pe-approver-bell, notify-many, log-transition-hook, no-mig, v2-only, best-effort-trycatch, pattern4-side-effect]`.
|
||||
- _(S77→S56 [2026-06-10..19, 7 entries] FIFO'd → `archive/2026-06.md` at S112; recall verbatim there / `_INDEX.md` / RAG `search_memory`.)_
|
||||
- _(2026-06-25→19 [S88 frozen-relocation · PE Section-B 3-cột Mig 59 · PE chuông approver] FIFO'd → `archive/2026-06.md` at S116; + S77→S56 [2026-06-10..19, 7 entries] at S112. Recall verbatim there / `_INDEX.md` / RAG `search_memory`.)_
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Curate trigger
|
||||
- >~30KB → archive recent → L2 `archive/<period>.md`. Stale >3mo → remove.
|
||||
- **Last curate: 2026-07-12 S112 (implementer-backend self-FIFO, hook-triggered @24KB)** (25.6→~16KB): moved Recent-activity S77→S56 (7 entries, 2026-06-10..19) verbatim → `archive/2026-06.md` (16.7→26.4KB), kept 4 newest (S112/S88/S87/chuông). Prev **S70 Harness-9 (em-main + Stage-B)** (33.2→17.4KB): L2 dark-matter recovery — 14 Recent-activity entries (S55→S35) → NEW `archive/2026-06.md` + `_INDEX.md` (substring sha-keyed) + `2026-0{5,6}.gist.md` (distill-gen:1). 0-byte-loss md5 byte-exact (Stage C audit CONCERN → read-side-gap MEMORY-L5→_INDEX fixed). _(cosmetic: 2 curate-meta lines carry `S?` worker-label.)_ Prev: S40 (30.9→~18KB dedup-split BE/FE/test) · S34 q3 · S22 q1.
|
||||
- **Last curate: 2026-07-13 S116 (implementer-backend self-FIFO, hook @21.5KB read-limit-guard)** (21.5→~15KB): moved 3 oldest Recent-activity (2026-06-19/24/25: PE chuông · PE Section-B Mig 59 · S88 frozen-relocation) verbatim → `archive/2026-06.md`, kept 4 newest (S116/S113/S112×2). Prev **S112** (25.6→~16KB): S77→S56 7-entry → 2026-06.md. Prev **S70 Harness-9** (33.2→17.4KB): L2 dark-matter recovery + `_INDEX` + gists. Prev: S40 dedup-split · S34 · S22.
|
||||
|
||||
@ -39,3 +39,8 @@
|
||||
- **2026-06-10 (S57-resume spawn-test H4.8 — Harness-4 two-tier):** Mình bị DEMOTE pin `model: claude-opus-4-8` (deterministic-scaffold class, double-gate reviewer+test+cicd sau lưng). Spawn-test echo model NGAY sau edit → self-report `claude-fable-5[1m]` = SE env (CCD harness) KHÔNG fresh-read frontmatter → pin ăn SAU restart CLI. Post-restart mình chạy Opus 4.8 (effort Max giữ env-wide); task hệ-trọng giao mình qua hmw có thể override `tier:'fable'`. Tag [h4-demote, spawn-test, pending-restart].
|
||||
|
||||
_(S56 GOLIVE-HARDEN 3 BE fix — ExecuteUpdate-atomic LeaveBalance + fail-closed AssignItTicket authz + DocxRenderer CS8602 → FIFO'd S77; verbatim git/archive. em-main post-review bumped tx → `IsolationLevel.Serializable` per database-agent. Test 228 green.)_
|
||||
|
||||
<!-- FIFO'd from MEMORY.md at S116 (2026-07-13): Recent-activity 2026-06-25..19 (S88 · PE Section-B Mig 59 · PE chuông) -->
|
||||
- **2026-06-25 (S88 PE stability cleanup — FROZEN-module mechanical relocation + 1 stale-comment fix BE — NO migration, 3 edit/2 file, em-main spec deterministic 100% file/line/boundary given → ACCEPT Case 1):** Pure mechanical move ZERO behavior-change. **T1 misplaced-command relocate:** `SetPeCcmBudgetPeriodCommand`+Validator+Handler (BUDGET setter writes `pe.CcmBudgetPeriodAmount` Section B "NS kỳ này" CCM, Mig 59) sống NHẦM trong suggested-PRICE file → move về budget file. (a) REMOVE `PeSuggestedPriceFeatures.cs` lines 144-199 (blank + `===== CCM NS kỳ này =====` header → `SetPeCcmBudgetPeriodCommandHandler` closing `}`); file end clean tại line 143 (`UpdatePeSuggestedPriceCcmCommandHandler` `}`), suggested-price PRO/CCM trên giữ nguyên. (b) APPEND `PeWorkItemBudgetFeatures.cs` END sau line 200 (`UpdatePeBudgetCcmCommandHandler` `}`) → cạnh budget setters `UpdatePeBudgetPro/Ccm`. Code BYTE-IDENTICAL (logic/authz Admin\|CostControl fail-closed/validation GTE(0).When(HasValue)/absolute-set null=clear/changelog UNCHANGED); CHỈ prepend 1 comment line `// [S88] Chuyển từ PeSuggestedPriceFeatures.cs về đây (đúng nhóm budget setter).`. Usings 8-header IDENTICAL 2 file → KHÔNG add duplicate. Namespace `SolutionErp.Application.PurchaseEvaluations` SAME → MediatR type-resolution transparent, controller `:125` KHÔNG đụng. **T2 stale-comment fix:** `Dtos/PurchaseEvaluationDtos.cs` ~250-254 comment trên `decimal WinnerQuoteTotal` ghi SUM "của SelectedSupplierId" (OLD single-winner) → verified actual compute `PurchaseEvaluationFeatures.cs:1093-1099` = `Suppliers.Where(s=>s.IsWinner)` (multi, Mig 58) → sửa CHỈ comment-text "MỌI đơn vị IsWinner (multi-winner Mig 58)". Field/code UNCHANGED. **LESSON:** frozen-relocation = read BOTH file FIRST exact boundary + verify usings-superset target + verify stale-comment claim vs real compute-site TRƯỚC sửa (KHÔNG trust comment blind, mirror S35 EF-source-of-truth). Build `SolutionErp.Application.csproj -v q` **0 warn 0 err** (4s). KHÔNG test (test-specialist)/FE/mig/commit. Tag `[s88, frozen-relocation, byte-identical-move, mediatr-type-resolve-transparent, stale-comment-verify-compute-site, no-behavior-change]`.
|
||||
- **2026-06-24 (PE Section B 3-cột THỰC HIỆN — CCM "NS kỳ này" cột riêng BE — Mig 59 `AddCcmBudgetPeriodToPurchaseEvaluation` 3-file, 5 edit/0 new file, em-main CHỐT spec+API-contract 100% → ACCEPT Case 1):** Section B tách 3 cột Dự án|PRO|CCM giống Section A. PRO=`BudgetPeriodAmount` sẵn có; CCM nhập cột riêng độc-lập. COOKIE-CUTTER mirror S77 suggested-price-Ccm + S65b HoSoLink one-column. (1) `PurchaseEvaluation.cs` +`decimal? CcmBudgetPeriodAmount` sau ExpectedRemainingAmount. (2) `PurchaseEvaluationConfiguration.cs` +`HasColumnType("decimal(18,2)")` sau ExpectedRemaining (spec asked literal decimal(18,2); siblings dùng HasPrecision(18,2) NHƯNG emit identical column — tôi follow spec verbatim HasColumnType). (3) Mig: Up=1 AddColumn decimal(18,2) nullable NO table/index, Down=1 DropColumn, snapshot verified — clean mirror Mig 52. (4) DTO `PurchaseEvaluationDetailBundleDto` +`decimal? CcmBudgetPeriodAmount` sau ExpectedRemainingAmount TRƯỚC BudgetSummary + projection PEFeatures.cs:1110 positional-insert đúng order (positional record → projection order BẮT BUỘC khít). (5) Setter `SetPeCcmBudgetPeriodCommand(Guid Id, decimal? CcmBudgetPeriodAmount):IRequest` append `PeSuggestedPriceFeatures.cs` (same concern PE role-gate setters) + Validator `GreaterThanOrEqualTo(0).When(HasValue)` (budget=0 hợp lệ) + Handler fail-closed Forbidden Admin|CostControl TRƯỚC side-effect (S56 #5) + absolute-set null=clear (gotcha #73) + changelog Header (sibling-consistency; spec min=SaveChanges). (6) Controller `[HttpPatch("{id:guid}/budget/ccm-period")]` + `CcmBudgetPeriodBody` (spec EXPLICIT PATCH dù siblings PUT — follow spec). **Call-site verify:** `new PurchaseEvaluationDetailBundleDto(` repo-wide → EXACTLY 1 (PEFeatures:1100, đã sửa) → positional-safe. Submit-guard `BudgetPeriodAmount` PRO KHÔNG đụng. Build SolutionErp.slnx (6 proj incl 2 test, gotcha #65) **0 warn 0 err**. KHÔNG apply DB/FE/test/commit. Route FE/test mirror: DTO field `ccmBudgetPeriodAmount` camelCase + PATCH `/api/purchase-evaluations/{id}/budget/ccm-period` body `{ ccmBudgetPeriodAmount }`. Tag `[pe-section-b-3col, ccm-budget-period, mig59, one-column-no-table, absolute-set-fail-closed, positional-dto-order, patch-per-spec]`.
|
||||
- **2026-06-19 (PE chuông báo approver BE — NO migration, 1 edit/1 file, em-main spec deterministic 100% → ACCEPT Case 1):** Tra Sol (Zalo) "không thấy chuông — việc có hồ sơ cần duyệt": approver Cấp hiện tại KHÔNG nhận notify khi phiếu ENTER/ADVANCE tới ChoDuyet (chỉ drafter báo terminal). FIX = +1 block trong `LogTransitionAsync` (PurchaseEvaluationWorkflowService.cs ~line 1058) NGAY SAU drafter-notify, KHÔNG endpoint mới (Pattern 4 — notify = side-effect của Submit/Approve-advance, cả 2 đã gọi LogTransitionAsync + end ChoDuyet). **Resolution mirror EXACT canonical** `EnsureActorInLevel` (line ~301): `db.ApprovalWorkflows.AsNoTracking().Include(Steps).ThenInclude(Levels)` → `stepsOrdered[CurrentWorkflowStepIndex]` (bounds-check) → `.Levels.Where(Order==CurrentApprovalLevelOrder && ApproverUserId!=Guid.Empty && !=actorUserId).Select(ApproverUserId).Distinct()` → `NotifyManyAsync(ids, Generic, "Phiếu cần bạn duyệt: {MaPhieu??TenGoiThau}", "Có phiếu Duyệt NCC đang chờ bạn duyệt.", /purchase-evaluations/{Id}, refId:Id, ct)`. **Recon:** (1) `ApprovalWorkflowLevel.ApproverUserId` = non-null `Guid` (NOT Guid?) → "exclude null" = `!=Guid.Empty` defensive (OR-of-N rows mỗi 1 NV). (2) `NotifyManyAsync` sig verified = `(IEnumerable<Guid>, NotificationType, title, string? desc, string? href, Guid? refId, CT)` khớp urgent-feature ref. (3) actor-exclude qua `!= actorUserId` (Guid != Guid? lift OK). **Guard V2-only:** `ApprovalWorkflowId is Guid && CurrentWorkflowStepIndex is int && CurrentApprovalLevelOrder is int` → V1/no-workflow skip. **Best-effort try/catch** nuốt lỗi (phiếu đã SaveChanges; notify fail KHÔNG rollback). Block đọc pointer SAU set → Submit (idx0/lvl1) + Approve-advance (pointer advance rồi LogTransition) đều fire đúng. Build SolutionErp.slnx (2 test project, gotcha #65) **0 warn 0 err**. KHÔNG test/FE/mig/commit. Tag `[pe-approver-bell, notify-many, log-transition-hook, no-mig, v2-only, best-effort-trycatch, pattern4-side-effect]`.
|
||||
|
||||
@ -6,6 +6,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 🆕 S116 (2026-07-13) — PE cho nhập SỐ ÂM báo giá "Số tiền báo giá cho hạng mục" ×2 app (owner anh Kiệt; BE+DB nhận âm sẵn 0 validator). Build PASS ×2, SHA-pair
|
||||
PeDetailTabs.tsx BYTE-IDENTICAL, cp fe-user→fe-admin. Âm = phát-sinh-giảm/hoàn tiền NCC = giá THẬT (KHÔNG lỗi). 5 change.
|
||||
- **Sign-toggle 2 dialog (AddSupplier :2379 + QuoteDialog :2948):** nút +/− pattern VndInlineEdit:1003. `neg`=useState RIÊNG (QuoteDialog `setForm({thanhTien})` thay CẢ object → nuốt neg nếu nhét vào form). 🔴 2 dialog POST commit form.thanhTien THẲNG (:2247/:2906, no nút "Lưu") → toggle PHẢI ghi giá-CÓ-DẤU NGAY `setForm({...,thanhTien:-form.thanhTien})`, chỉ flip cờ = MẤT DẤU thầm lặng. Display `formatVndInput(Math.abs(tt))`; onChange `mag=parseVnd();tt=neg?-mag:mag`. QuoteDialog init `neg=(existing?.thanhTien??0)<0`; AddSupplier `false`.
|
||||
- **GLOBAL parseVnd/formatVndInput :51-52 KHÔNG sửa** (share ngân sách) → Math.abs wrap thay vì cho helper handle âm. formatVndInput(0)='' → placeholder OK.
|
||||
- **Gate `!==0` (KHÔNG >0):** AddSupplier submit :2244 + toast :2257. `-0!==0`=false → 0/rỗng vẫn skip="chưa báo giá".
|
||||
- **2 FE gate ĐỔI (KHÔNG 1):** missingForApproval :212 + submitChecklist :243 → helper mới `hasRealSelectedQuote(ev)=ev.details.some(d=>d.quotes.some(q=>q.isSelected&&q.thanhTien!==0))`. GOTCHA quote ở `details[].quotes` KHÔNG `suppliers[].quotes` (PeSupplier no quotes field). Cũ SUM winnerQuoteTotal: net-zero +100/-100=0 chặn oan. Mirror BE selectedThanhTien WHERE IsSelected. Label→"Đã nhập giá chào thầu". computeGiaChaoThau GIỮ (Section3 display :1921 — no unused).
|
||||
- **Ngân sách :2861 GIỮ ≥0** (change 4 no toggle — không trong diff). SHA `3e16dba5…` ×2 (git-diff --no-index exit0). Build ×2 PASS (user 1939mod `index-shUGyhuw` 19.0s / admin 1950mod `index-Yk1qEFOg` 7.6s, 0 TS err). Warn pre-existing @import+>500KB+realtime. NO BE/test touch. Full precedent. Tag `[s116, pe-negative-quote, sign-toggle-immediate-commit, math-abs-no-global-mod, hasRealSelectedQuote, details-not-suppliers-quotes, gate-not-equal-zero, sha256-2pair]`
|
||||
|
||||
## 🆕 S113 (2026-07-12) — Supplier import v2 FE ×2 app (anh Kiệt owner; BE done ∥ impl-backend). Build PASS ×2
|
||||
3 file×2app: 2 SHA-pair (dialog+page byte-identical) + types DIVERGED (Department.parentId only S65 → edit-identical KHÔNG cp).
|
||||
- **types/master:** Supplier +`isPublic:boolean` (anchor `// Tình trạng`+status; git-diff = CHỈ `parentId`, isPublic ∉ diff = mirror khớp).
|
||||
|
||||
@ -17,22 +17,10 @@ Read-only INTERNAL audit SOLUTION_ERP codebase. Tools: Read, Grep, Glob, Bash +
|
||||
|
||||
---
|
||||
|
||||
## 🆕 2026-07-12 (S114-ens) — PE multi-winner PER-HANG-MUC invest, LENS product-history (fable-clone, RETURN-only)
|
||||
De: cho ≥2 NCC-TP trung CUNG 1 hang-muc, moi NCC 1 gia rieng; bang so sanh chon nhieu winner. **KEY history:** multi-winner Mig58/59 (S87/S88) DA SHIP nhung = **WHOLE-SUPPLIER** granularity — `PurchaseEvaluationSupplier.IsWinner` (:28), toggle per-supplier-column `PeDetailTabs.tsx:2746`, tooltip "chon ≥2 = lien danh". winnerQuoteTotal (`PurchaseEvaluationFeatures.cs:1189-1196`) = SUM ThanhTien MOI quote cua supplier IsWinner qua MOI hang-muc → **KHONG the split per-hang-muc** (A win item1-2, B win item3) vi all-or-nothing per supplier + double-count neu ≥2 winner cung quote 1 item. **`PurchaseEvaluationQuote.IsSelected` (:15, per hang-muc×NCC, "1 row=1 NCC chao 1 hang-muc") = latent hook TU Mig12 day-1 nhung DEAD** (persisted+echoed DTO+seeded, 0 consumer financial; FE hardcode `isSelected:false` never-toggle :2283/2916). Yeu cau moi = re-key winner-truth Supplier.IsWinner→Quote.IsSelected = **CARDINALITY CHANGE #2** (feedback_cardinality_change_grep_consumers — S87 da dinh 1 lan, sot 2 BE Block-B). Consumers IsWinner: ~9 BE (winnerQuoteTotal :1193 · Block-B :908-922 · submit-guard `WorkflowService:192-209` · ccm-threshold :885-908 · create-contract `CreateContractFromEvaluation:56/89-93/189`) + ~8 FE + 7 test file (PeMultiWinner/SelectWinnerClear/CreateContractMultiWinner/ListWinnerNames/WorkItemBudget/CcmThresholdFinalize/SubmitGuard). Contract inherit = 1 HĐ/winner-supplier, GiaTri per-winner (single=detailsSum legacy). Budget per-phieu (PE.WorkItemId single) khong doi cau truc. **#1 decision anh Kiet:** N-win 1 hang-muc = split KHOI LUONG hay full-price-moi-ben? (schema THIEU per-winner volume field — Quote.ThanhTien tinh tren full KL). Tag [s114, pe-multiwinner-per-hangmuc, isselected-dead-hook-mig12, cardinality-change-2, whole-supplier-vs-per-cell].
|
||||
|
||||
## 🆕 2026-07-12 (S113) — Supplier import v2 invest ensemble (5-lane, Opus MAX, RETURN-only) `[engine: fable-clone-ensemble · Opus-S113]`
|
||||
Em-main harvest @closeout (H2 — 5 lane RETURN-only; content ở `runs/2026-07-12-S113-supplier-import-v2-invest/` journal.jsonl + spec).
|
||||
- **task:** invest cách làm Supplier import v2 (R1-R7) — 5 lăng-kính CÙNG vai investigator-codebase: schema-data-model · import-service · fe-ux · public-visibility-consumers · dedup-validation. `wf_e7896ca6-901`.
|
||||
- **verdict:** 4/5 structured PASS + 1 lane `public-visibility-consumers` **#53-garble** → em-main-solo grep recover (consumers = PE picker `PeDetailTabs:2222` + `ContractCreatePage:321` + dashboard, đều qua ListSuppliersQuery).
|
||||
- **learned:** Conflation gốc — `Supplier.Code` comment "Mã NCC" nhưng S112 nhồi c4 viết-tắt vào → R3 muốn tách. Ensemble hội tụ 6 điểm (IsPublic-bool · dedup-MST+Code-backstop · backfill · PublishCommand-riêng · BE-template · re-bake-token) + chẻ đôi D1 (schema-lens=A vs import-lens=B) → anh chốt A.
|
||||
- **surprise:** visibility-lane garble ĐÚNG lane quan-trọng nhất (R4/R6 ẩn draft); recover-lane spec-flag NHƯNG FE implement vẫn sót wiring 4-picker → reviewer bắt draft-leak. Prod 37 NCC (không 22 ước). Tag `[s113, fable-clone-ensemble, supplier-import-v2, ma-ncc=code, visibility-garble-recover, dedup-mst]`.
|
||||
|
||||
## 🆕 2026-07-12 (S112) — 2 fable-clone invest ensemble (Opus MAX, RETURN-only) `[engine: fable-clone-ensemble · Opus-S112]`
|
||||
Em-main harvest @closeout (H2 gate — lanes RETURN-only, content verbatim ở `runs/2026-07-12-S112-{supplier-excel-import,pe-signoff-save-advance}/sub-invest-*.md`).
|
||||
- **task:** (A) Supplier Excel-import mapping/approach/edge `wf_e2f23a84` · (B) PE sign-off save-advance current-flow/change-design/reject `wf_fd8f2bfe`; 3 lane/ensemble.
|
||||
- **verdict:** cả 2 PASS_WITH_CONCERNS. Supplier 27/27 field bijective (30 cột→27, 2 audit-col=owner-call). PE design = **sửa 1 CHỖ** gỡ/gate `ApplyDrafterBypassOnSubmitAsync`.
|
||||
- **learned:** dedup Code `Ordinal` vs DB unique-index **CI-collation** → normalize case TRƯỚC dedup+store (MAJOR, cả-3-lane-SÓT, reviewer bắt); PE "comment-own-box→save→advance" cơ-chế ĐÃ có sẵn, chặn = bypass-feature S60 cần đảo.
|
||||
- **surprise:** Supplier Approach-B (extend seed) ĐÃ SHIP prod (Mig 62, 4 NCC live — invest bắt spec-"partial" SAI); PE EDGE-5 latent→**REACHABLE** (drafter forge Reject phiếu DaDuyet của mình). ⚠️ sub-file naming swapped (harvest reads content). Tag `[s112, fable-clone-ensemble, supplier-mapping, pe-bypass-a1, edge5-reachable]`.
|
||||
## 🆕 Recent invest (S112-S114 stubs — verbatim → git history + `archive/2026-07.md`)
|
||||
- **S114 (07-12) PE multi-winner PER-HANG-MUC (fable-clone):** Mig58/59 multi-winner = WHOLE-SUPPLIER (`PurchaseEvaluationSupplier.IsWinner`); `Quote.IsSelected` per hang-muc×NCC = latent-hook Mig12 nay LIVE (Mig65 backfill re-key winner-truth IsWinner→IsSelected = CARDINALITY-CHANGE #2). winnerQuoteTotal=SUM(Quote WHERE IsSelected) `PeFeatures.cs:1188`. #1 open: N-win 1 hang-muc split KHOI-LUONG hay full-price (schema thieu per-winner volume). Tag `[s114, pe-multiwinner-per-hangmuc, quote-isselected-live]`.
|
||||
- **S113 (07-12) Supplier import v2 (5-lane ensemble):** selection consumers qua ListSuppliersQuery (`PeDetailTabs:2222`+`ContractCreatePage:321`+dashboard); chot IsPublic-bool + dedup-MST+Code + fail-closed default. Prod 37 NCC. 1 lane #53-garble→em-main recover. Tag `[s113, supplier-import-v2, ma-ncc=code]`.
|
||||
- **S112 (07-12) Supplier Excel-import + PE sign-off (fable-clone):** Supplier 27/27 bijective (Mig62 SHIPPED prod); dedup Code CI-collation → normalize-case TRUOC store; PE bypass = go/gate `ApplyDrafterBypassOnSubmitAsync`; EDGE-5 drafter-forge-Reject REACHABLE. Tag `[s112, supplier-mapping, pe-bypass-a1, edge5-reachable]`.
|
||||
|
||||
## 📋 Patterns proven (apply confidently)
|
||||
|
||||
@ -87,13 +75,14 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
||||
|
||||
## 📅 Recent activity (compressed — full verbatim → `archive/2026-06.md` via `archive/_INDEX.md`)
|
||||
|
||||
- **2026-07-12 PE per-HANGMUC multi-winner invest LENS BE-consumer-audit (fable-clone ensemble, RETURN-only):** ⭐ **3 cardinality axes coexist — request ≠ existing multi-winner.** (A) `PE.SelectedSupplierId` = single whole-phiếu winner (legacy). (B) **Mig 58 "multi-winner" = WHOLE-SUPPLIER liên-danh** (`PurchaseEvaluationSupplier.IsWinner` bool): N suppliers each win ENTIRE package → 1 full contract each. NOT per-hangmuc. (C) **`PurchaseEvaluationQuote.IsSelected`** (per hangmuc×supplier, each row own price BgVat/ChuaVat/ThanhTien) = maps EXACTLY to anh Kiệt request BUT **INERT** — written `PeDetailFeatures.cs:308,332` + DbInit:1257, displayed DTO `PeFeatures.cs:1236`, **ZERO money/contract read** (grep confirm). **5 MONEY consumers ALL key off supplier-IsWinner** → over-count for per-hangmuc: R1 winnerQuoteTotal `PeFeatures.cs:1189-1196` · R2 submit-guard `PeWorkflowService.cs:196-211` · R3 **CCM-threshold-finalize FINANCIAL GATE** `PeWorkflowService.cs:885-895` (< CeoThreshold) · R4 CreateContract giaTri `CreateContractFromEvaluationFeatures.cs:56,84-93` (single=detailsSum vs multi=quote-sum INCONSISTENCY) · R5 budget-compare block-B `PeFeatures.cs:907-927`. Write-site SelectWinner `PeDetailFeatures.cs:400-431` = supplier-level list only, NO detail dim. ~6 test files break (PeMultiWinnerTests/PeWorkItemBudgetTests/CreateContractFromEvaluationMultiWinnerTests/PeCcmThresholdFinalizeTests/PeSubmitGuardAndBypassTests/PeSelectWinnerClearTests). Per-hangmuc = re-key money truth IsWinner→Quote.IsSelected (classic cardinality-grep-consumers trap). Tag `[pe-multiwinner, per-hangmuc, quote-isselected-inert, iswinner-whole-supplier, ccm-threshold-gate, cardinality-5-consumers]`.
|
||||
- **2026-07-12 (S112+ Supplier-import-v2 LENS-3 FE-dialog-UX invest `wf` fable-clone):** Task R2/R3/R7 FE. Ground-truth: SupplierImportDialog.tsx + SuppliersPage.tsx **SHA-identical 2-app** (`6313529c`/`e9143d93`) — but types/master.ts DRIFTS (fe-user thiếu `Department.parentId` Mig51, Supplier-portion vẫn mirror). Dialog = 2-step preview→confirm, table READ-ONLY (`:284-325`), **round-trips `preview.rows` verbatim** (`:190`+`:132-134`) → per-row edit ĐÃ khả-thi cho R3 (mutate `row.code`→confirm). Confirm gate `errorCount>0` disable (`:191`). **R2:** 0 template endpoint tồn tại; `ExpectedHeaderTokens` 30-token baked `SupplierExcelImportService.cs:35-68` = single-source cho BE-gen .xlsx row-4 (test Case6 chứng round-trip); blob-download precedent `PeDetailTabs.tsx:3273-79` (`api.get responseType:'blob'`+createObjectURL). **R7:** SuppliersPage cols code/name/type/taxCode/phone/actions (`:174-206`) — **KHÔNG có status/public col**; `Supplier` type có `status` (biz-status ≠ public) NHƯNG **0 field isPublic/visibility** (grep⌀) → R4/R7 cần field MỚI. **Mã NCC=Code** (Supplier.cs:7 comment + col4 TÊN-VIẾT-TẮT→Code). ⚠️**authz-mismatch:** import BE `[Roles=Admin,CatalogManager]` (`SuppliersController.cs:64,77`) vs FE btn gate `Suppliers/Create` (mở-all S59) → non-admin thấy nút→403; template sẽ kế-thừa. NO new menuKey/route. Tag `[s112, supplier-import-v2, fe-lens3, sha-mirror-drift-parentId, expectedheadertokens-template, no-ispublic-field, import-authz-mismatch]`.
|
||||
- **2026-07-13 PE quote-NEGATIVE feature LENS Budget-consumers (ensemble, RETURN-only):** anh Kiệt: cho nhập SỐ ÂM ô `form.thanhTien` (báo giá/hạng mục) dialog "Thêm NCC"; RULE = âm=khoản-TRỪ NCC, **ngân sách GIỮ NGUYÊN**. ⭐ **CRITICAL confirm: quote KHÔNG mutate `PeWorkItemBudgets`** — budget chỉ ghi bởi `UpdatePeBudgetPro/CcmCommandHandler` (role-gated PRO/CCM, `PeWorkItemBudgetFeatures.cs:105-107/175-177`) + Adjust/SetCcmBudget; quote write-site (`PurchaseEvaluationDetailFeatures.cs:308-345`) chỉ đụng quote-field + re-derive `IsWinner`, NEVER budget. **5 consumer đọc `SUM(ThanhTien WHERE IsSelected)`:** (1) winnerQuoteTotal DISPLAY `PeFeatures.cs:1188-1191` no-clamp (2) CCM-threshold `PeWorkflowService.cs:884-893` guard `>=ceoThreshold` reject HIGH ko-reject-âm→âm cho CCM-finalize dễ hơn (3) giaTri CreateContract PER-WINNER `CreateContractFromEvaluation:88-90,100` → Contract.GiaTri CÓ THỂ ÂM, **BYPASS** `CreateContractCommandValidator.GiaTri>=0` (`ContractFeatures.cs:46`, chỉ áp manual-create path) (4) "So-với-ngân-sách" block-B DISPLAY `PeFeatures.cs:904-947` budget read AsNoTracking untouched, soft-warn only (5) **submit-guard `PeWorkflowService.cs:196-209` = GUARD DUY NHẤT REJECT: `if Sum()<=0 → "chưa có giá chào thầu"`** — WHOLE-phiếu total, per-item âm OK miễn net>0. **NO quote validator tồn tại** (grep⌀ `UpsertPurchaseEvaluationQuoteCommandValidator`) → âm đã flow BE 0-block, chỉ FE chặn. Keep-≥0 fields có validator: BudgetPeriodAmount/ApprovedPriceAmount/ProInitial/InitialAmount. ĐÃ allow-âm sẵn (unrelated): ProAdjustment/Adjustment/ExpectedRemainingAmount (`PeFeatures.cs:345-348` mirror-LeaveBalance). Tag `[s115?, pe-negative-quote, budget-not-mutated, submit-guard-sum-le-0, giaTri-negative-bypass-validator, no-quote-validator]`.
|
||||
- **[→ git 07-12 · superseded by S115 entry above] PE per-HANGMUC multi-winner LENS BE-consumer-audit:** 3 cardinality axes — `SelectedSupplierId` single (legacy) · Mig58 `IsWinner`=WHOLE-SUPPLIER liên-danh · `Quote.IsSelected` per hangmuc×supplier (S114 made LIVE). 5 money consumers keyed off IsSelected now (winnerQuoteTotal/submit-guard/ccm-threshold/giaTri/budget-block-B). ~6 test files. Tag `[pe-multiwinner, per-hangmuc, quote-isselected, cardinality]`.
|
||||
- **[→ git 07-12] Supplier-import-v2 LENS-3 FE-dialog-UX:** SupplierImportDialog+SuppliersPage SHA-identical 2-app; dialog 2-step preview→confirm round-trips `preview.rows` → per-row edit feasible; `ExpectedHeaderTokens` 30-token baked `SupplierExcelImportService.cs:35-68` = template single-source; Mã NCC=Code; import authz-mismatch BE `[Roles=Admin,CatalogManager]` vs FE `Suppliers/Create` open. Tag `[s112, supplier-import-v2, fe-lens3, import-authz-mismatch]`.
|
||||
- **[→ git+session-log 07-07] S105 BVAAU cross-repo auth RCA:** Q1 root-cause = FE Home MÙ session (bundle cũ relative `/api` trên site TĨNH `web.config` loại `^/api/` → `/api/auth/me` 404 → anonymous); fix API_BASE-absolute `51c69fb` LIVE. Q2 CRM1 hở 2 tầng (FE soft + API `enforceCrmGate:false`) → flip `AUTH_ENFORCE_CRM_GATE=true`. Q3 Zalo "User_ID" KHÔNG có UI. Class **"hub+site-tĩnh relative-`/api` chết im prod"**; #53-garble-7 → SendMessage-resume recover. *(verbatim → archive/2026-07.md)*
|
||||
- **[→ 07-12 LENS-3 entry above + git 07-06] S104 Supplier 30-field 🅰️ schema-invest WF `wf_5ac2cdb0`:** SUPERSEDED anchors giữ: ClosedXML 0.105.0 = Excel-standard (KHÔNG OpenXml-raw, `csproj:8`); reuse-existing-cols>parallel; `UpdateSupplierCommand.cs:49-57` blind-set=latent#73; seed fill-nulls-if-exists (`DbInitializer.cs:2781`); `SupplierDto` hand-projected 2-site (`GetSupplierQuery.cs:21`+`ListSuppliersQuery.cs:48-50`); `Dialog.tsx:48` max-h-70vh; extract `ui/PathLink.tsx`. Chốt Mig 62 +18 nullable. *(verbatim → archive/2026-07.md)*
|
||||
- **[→ git+session-log 07-03 + adap-reports] S101 H19-adopt Lane-2:** map reviewer=review-gate / investigator=ground-truth-lane; 12-pt tier-checklist ALL✅ (11/11 inherit, C3 no-`[1m]`-in-frontmatter); toggle-marker MUST copy `.gitignore:87` last-match placement; VACUOUS-MET all-inherit+Fable. *(verbatim → archive/2026-07.md)*
|
||||
- **[→ git+session-log 07-02 + adap-reports] S100 H18-adopt WF1:** ⭐ ghost-wire-class **config-key-defined-but-unread** (`budget.json:54-55` script re-hardcode same-value→no-flag; catch=grep-key-across-scripts); cron=ghost-wire (CronList=0); `_ledger` orphan-catch 0/15-lifetime. *(verbatim → archive/2026-07.md)*
|
||||
- **2026-07-12 (S112-b) Supplier import-v2 LENS-4 public/visibility recon (anh Kiệt FDC, RETURN-only):** ⭐ **3 SELECTION consumers ALL hit `GET /suppliers` (ListSuppliersQuery)** → (1) master `SuppliersPage.tsx:83` ×2-app (admin, MUST see draft opt-in) (2) PE add-supplier dropdown `PeDetailTabs.tsx:2222` pageSize=1000 (3) Contract create `ContractCreatePage.tsx:321` pageSize=200. **DISPLAY-of-existing (join by stored SupplierId → MUST NOT filter)**: ContractFeatures:293/392/434 · ContractWorkflowService:168/373 · ContractExcelExporter:44 · GetDashboardStats:42 (TopSuppliers group-over-contracts → draft never appears) · PeFeatures:560/728/874/952 · CreateContractFromEvaluation:63/168. **WRITE-GUARD backstops** (validate-supplier-exists on attach → add draft-reject): `ContractFeatures.cs:64` + `PurchaseEvaluationSupplierFeatures.cs:49`. **NO IsPublic field today**; `SupplierStatus` enum = business (DangHoatDong/Blacklist/NgungHopTac) NOT visibility. **SupplierConfiguration.cs:47/50** = Code unique-filtered `[IsDeleted]=0` + IsRequired + `HasQueryFilter(!IsDeleted)` only. c4 Tên-viết-tắt→Code (SupplierExcelImportService:249) + import HARD-requires Code (:103-108) → conflicts R3 "Mã NCC nhập sau, Excel không có cột". DTO hand-projected 2-site (List+Get). Rec: fail-closed default (GET public-only → dropdowns unchanged, S87/S88-safe) + stored `IsPublic bit`. Tag `[s112b, supplier-visibility, lens4, fail-closed-default, no-global-queryfilter]`.
|
||||
- **[→ git 07-12 · Mig64 IsPublic shipped since] Supplier import-v2 LENS-4 public/visibility recon:** 3 SELECTION consumers ALL hit `GET /suppliers` ListSuppliersQuery (SuppliersPage:83 · PeDetailTabs:2222 · ContractCreatePage:321); DISPLAY-of-existing joins by stored SupplierId MUST NOT filter (ContractFeatures/PeFeatures/Dashboard); write-guard backstops `ContractFeatures.cs:64`+`PurchaseEvaluationSupplierFeatures.cs:49`. Rec fail-closed default + stored `IsPublic bit`. Tag `[s112b, supplier-visibility, fail-closed-default]`.
|
||||
- **[all verbatim → archive/2026-07.md] S98 verify-lane (detector 28→25, 8 derived-stale=FP vs canonical 61/89/440/79; grep exact misses README variant) · S97 feedback-audit (#77/#78/#79 `gotchas.md:1301/1317/1325`; #53-garble×6=cicd MEMORY-self-target ~17.5KB; Mig60 wrong-table Approvals→Mig61 Changelogs) · 07-01 Supplier-predecessor SUPERSEDED by 07-06 · S93 MFE denom=marks/AS/guards dedup-AS-xref=29, coverage exact-id OR ≥2-content-word.**
|
||||
|
||||
- **PE FROZEN-Mig59 patchwork + go-live recon cluster (S87→S89, 8 entries verbatim → `archive/2026-06.md`):** key catches — AREA-4 **LATENT #73-class BUG** HoSoLink absolute-set `PurchaseEvaluationFeatures.cs:280` but PeHeaderForm PUT OMITS→clears (2 PUT mutually destructive, no preserve-test) · AREA-3 AllowApproverFinalize AUTO(Mig58)+finalizeByCcmDelegation OPT-IN(Mig54) coexist AUTO-first, ApplyApprovedPrice allow-list {Ncc,ProMin,ProMax,Ccm} · AREA-2 budget-matrix band-aid `PeSuggestedPriceFeatures.cs:153-199` PRO=drafter vs CCM=role authz-asymm · AREA-6 FE all 6-comp+3-pages SHA-mirror 2-app, #70 stale-echo · S89 AllowApproverFinalize-NOTE **GAP BE-field-MỚI** (flag only `currentLevelOptions`, DTO no-carry→expose EndsBeforeCeo) · 06-24 winner=SINGLE `SelectedSupplierId` derived-3-site, terminal=per-Level bool · 06-23 urgent authz asymmetric, PE-create dropdown `PeWorkspaceCreateView.tsx:94`.
|
||||
@ -105,7 +94,8 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
||||
- **[→ archive/2026-06.md + git]** S52 P11-D/E/F 6-gap recon · S50 HrmConfigs add-kind 11-spot · S51 gotcha #57 EXT reachability (curated S59).
|
||||
- **[→ archive/2026-05-q4.md + git d2f52ba]** S29-S37: clean-room > NamGroup-port verified 4× · Pattern 12-bis cross-module mirror · FK+freetext dual-write.
|
||||
|
||||
- **2026-07-10 (S108 first-real-run H19) `[engine: fable-real-single · Fable-S108]`** (em-main harvest B3, GAP-2/3): Task cite-audit 3 artifact S108 (fable-real-runbook 610-line + adap-request hot-reload + email-draft), ~111 anchor. Verdict **PARTIAL — 103/111 exact**: 7 off-by-1/2 (cụm reviewer:37-56 + investigator:46/:77 — systematic quanh code-fence) + 1 drift-note-stale (runbook:123 “9 vai” trong khi engine :328 đã 10) + email-fact FALSE **bắt trúng bẫy honesty** (hmw.js:31 chưa-sửa lúc audit → em-main vá thật sau verdict). Learned: worker-draft cite lệch ±1-2 quanh fence → audit MỞ ĐÚNG DÒNG đừng tin range · drift-note về file khác re-check ngay-trước-commit (canonical có thể được patch song song). Surprise: **meta-drift 2 tầng** — đoạn cảnh-báo-drift tự drift vì engine update TRƯỚC khi note viết xong. Return dòng-1 VERDICT-header chuẩn, 0 garble.
|
||||
- **2026-07-13 PE negative-quote LENS edge/precedent/test (fable-clone, RETURN-only; complements Budget-consumers entry ↑):** ⭐ BE+DB đã cho âm; blocker = FE helper + submit-guard. (a) `parseVnd` `PeDetailTabs.tsx:51` strip `[^\d]` → minus mất; `formatVndInput:52` `n>0?:''` → âm+0 đều blank (lẫn "chưa báo giá"); AddSupplier gate `thanhTien>0` :2226 skip quote khi ≤0 (empty≡0 by-design); inline QuoteCellDialog :2870 always-POST (store 0 OK); fe-admin SHA-identical :51/:52/:2226. (b) precedent = OMIT validator: `ExpectedRemainingAmount` PurchaseEvaluationFeatures.cs:348 + LeaveBalance `AdjustmentDays` LeaveBalanceFeatures.cs:107; Quote `UpsertPurchaseEvaluationQuoteCommand` DetailFeatures.cs:272 = NO validator (grep⌀) → BE nhận âm sẵn. (c) 0 test assert ThanhTien≥0. (d) gotcha #81 orthogonal (IsWinner==Any(IsSelected) bool≠sign → no phantom). (e) NO DB CHECK (`HasCheckConstraint` grep⌀ whole Infra; col decimal(18,2) config:131). HARD-INTERACTION submit-guard `Sum()<=0` `PurchaseEvaluationWorkflowService.cs:206`+FE:201 → net-≤0 BLOCKED "chưa có giá"; che CCM-threshold:891. NS-giữ-nguyên architectural (quote never write PeWorkItemBudgets; âm chỉ shift winnerQuoteTotal:1188). Tag `[pe-neg-quote,parsevnd-strip-minus,sum<=0-guard,no-check-constraint,omit-validator,iswinner-orthogonal]`.
|
||||
- **[→ git 07-10] S108 first-real-run H19 cite-audit:** 103/111 exact; class = worker-draft cite lệch ±1-2 quanh code-fence (audit MỞ ĐÚNG DÒNG đừng tin range) + drift-note-stale + email-fact honesty-trap. 0 garble.
|
||||
|
||||
- **2026-07-12 (S111 H22 WAL deep-pass) `[engine: fable-real-single · Fable-S111]`:** H22 WAL adopt → READY_WITH_NUANCES + spec 3-mục (em-main gov/settings · w1 pause+tiep · w2 start/end delta · w3 hook+README). Chốt: (1) `.claude/settings*.json` KHÔNG tồn tại → hooks-entry CREATE mới; hook = STAGED-until-verified (S100 ghost-wire, chỉ claim khi thấy `wal:` commit phiên mới). (2) engine`:402` no-OS-hook = MÔ-TẢ (CAVEAT) không phải luật cấm; `:116` E.3 "hook fails-open ≠ gate" → persist-only TƯƠNG THÍCH, update :402; ACTIVE-MARKS 0 mark cấm-hook; H5 start`:41` chỉ cấm hook-switch MODEL. (3) AS-6 KHÔNG tăng: 2 runtime .json gitignored (`.gitignore:124-125`), hook commit LOCAL — push-set ≡ hôm nay. (4) cmd-PATH Git Bash trước WSL, bash 5.2.26 OK. (5) `.gitattributes` exists broadcasts-scoped → append `*.sh`; repo 0 .sh = zero churn. (6) 16 command 0 trùng pause/tiep. (7) Stop-hook fire chỉ TURN-boundary → chết giữa workflow-turn = vật-liệu DISK uncommitted, /tiep đọc cả git status/disk. (8) run.md thiếu taskList snapshot. Learned: "luật cấm hook" phải phân MÔ-TẢ vs LUẬT trước khi đòi carve-out. L1@19.8KB → suggest em-main curate.
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
- [S114 PE per-hạng-mục BE-financial review](project_s114_pe_multi_ncc_perhangmuc_financial_review.md) — NEEDS-FIX: 6 financial-SUM→IsSelected (0 sót) but DeleteQuote:396 + DeleteDetail:262 KHÔNG re-derive IsWinner → stranded winner → phantom HĐ GiaTri=0. Lesson: derived-invariant re-establish MỌI mutation kể cả DELETE.
|
||||
- [PE per-hạng-mục spec review (winner-truth lần 3)](project_pe_multi_ncc_perhangmuc_specreview.md) — PASS_WITH_ADJ. MUST: CreateContract isSingle→detailsSum (`:76-90`) over-count 1-winner≠cả-gói. Lesson: grep write-path field-NGUỒN (IsSelected) không chỉ field-đọc; cite-range bọc branch-decision.
|
||||
- [S114 PE per-hạng-mục IMPL review (flip lần 3)](project_s114_multi_ncc_perhangmuc_impl_review.md) — NEEDS-FIX. CAUGHT: DeleteQuote (`...Features.cs:371-397`) KHÔNG re-derive IsWinner → xóa quote cuối (nút Xóa FE:2897) VỠ invariant → phantom winner + HĐ giá 0. Lesson: cardinality grep-consumer PHẢI gồm Delete-handler field-nguồn; spec-enum writer KHÔNG đáng tin. SOLID: 5 SUM + non-tautology test + hash ae3788e9.
|
||||
- [PE negative-quote FE-only spec review (correctness-wire)](project_pe_negative_quote_correctness_review.md) — PASS_WITH_FIXES. R1/R2 no-op verified accurate (CreateContractFromEval:88-100 no-clamp · BE Sum()<=0 :206 + FE :201). MUST: sign-loss — form.thanhTien read DIRECT at POST :2232/:2875+gate :2226; toggle phải re-sign field-chung KHÔNG chỉ flip neg-flag (VndInlineEdit defer-parse-at-own-save không có ở 2 dialog). Consumer inventory COMPLETE 0-break (fmtMoney/toLocaleString neg-safe). Lesson: defer-parse widget→shared-form dialog = sign phải tới field-chung ở MỌI mutation incl toggle.
|
||||
|
||||
---
|
||||
|
||||
@ -73,6 +74,7 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
|
||||
|
||||
## 📅 Recent activity (compressed — full verbatim → `archive/2026-06.md` + `archive/2026-07.md` via `archive/_INDEX.md`)
|
||||
|
||||
- **S117 (2026-07-13) PE negative-quote FE-only spec-review (financial-invariant-owner lens) — PASS (0 blocking, 1 confirm + 2 test-notes):** budget-intact VERIFIED — UpsertQuote (`PurchaseEvaluationDetailFeatures.cs:282-366`) writes 0 budget fields; quote ThanhTien flows ONLY into read-only display aggregates (currentProposalTotal/prevSelectedTotal :911-926 + winnerQuoteTotal :1188) — NEVER mutates PeWorkItemBudget. #81 IsWinner + S114 multi-winner SAFE: winner-derive keyed on `IsSelected` boolean, sign-agnostic (negative ThanhTien irrelevant). R1 baked (ContractFeatures.cs:46 GiaTri>=0 untouched=manual path; CreateContractFromEvaluation:88-90 no-clamp=PE-inherit). R2 FE:201/BE:206 BOTH `SUM(ThanhTien where IsSelected)<=0` = IDENTICAL algebraic sum → 0 divergence from negatives. CONFIRM raised: enterable-negatives OPERATIONALIZE net-value driving CEO-escalation gate (winnerQuoteTotal<CeoApprovalThreshold→CCM skip-CEO) — governance, not display; owner sign-off. T3 note: `SeedWinnerWithQuoteAsync` seeds 1 quote/winner → "mixed net>0 submittable" half needs multi-quote seed. **Lesson: 2-tier guard divergence check = compare SUM EXPRESSION not just comparator; derived-flag safety = trace what flag is KEYED-ON (IsSelected≠ThanhTien); "budget intact" true for RECORD but quote-sum still flows to display+governance aggregates.**
|
||||
- **S116 (2026-07-13) outward-email GATE se→ai_infra H-22 WAL restart runtime-verify — PASS (0 over-claim/3 minor):** 6/6 ground-truth re-run MATCH (#5+#6 EXACT: archive-gate 12/12 sub<25.6KB + A7 246/246 pointers; crystallized 382713B/380K-tok/252K headroom). Caveat STRONG+prominent (defers real-persist→S111). Minor: (a)-persist WEAK — `.claude/WAL.md` git-committed 0-drift → survives-restart = git property NOT WAL-mechanism. Lesson: persist-claim on git-tracked WAL → verify tracked+0-drift; mechanism-proof lives at Stop-hook run. → `archive/2026-07.md`.
|
||||
- **S115 (2026-07-13) `/fable-real` spec-review presence-not-age adopt — PASS-WITH-FIXES (0C/2M/5m):** M1 acceptance-grep can't detect its OWN deliverable's miss (narrow regex 1/3 doc-lines → false-PASS; fix bare-token+classify). M2 skip-element on contiguous-prefix cut = correctness-inversion. Lesson: verify EACH regex-alt vs EACH target-line by hand; `parallel()` runtime-builtin robust-under-both = endorse-with-caveat. → `archive/2026-07.md`.
|
||||
- **S101 H19 fable-clone WF2 Lane-A (toggle) — PWC, CONCERN closed @S102:** marker WRITE-ONLY dead artifact — persist-claim cần CẢ reader-side (verify consumer exists, not just producer); reader BƯỚC 0.5b wired S101-cuối. → `archive/2026-07.md`.
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: project-pe-negative-quote-correctness-review
|
||||
description: PE negative-quote FE-only spec review (correctness-wire lens) — PASS_WITH_FIXES; sign-plumbing pitfall replicating deferred-parse pattern into shared-form dialog
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
# PE cho phép nhập số ÂM ở quote — correctness-wire lens review (owner anh Kiệt)
|
||||
|
||||
**Verdict: PASS_WITH_FIXES.** Plan FE-only (0 BE, 0 mig) sound; site inventory COMPLETE; R1/R2 no-op verified accurate on disk. 1 must-fix = sign-plumbing pitfall.
|
||||
|
||||
## Verified accurate (spec claims match disk)
|
||||
- R1 (Contract.GiaTri âm cho phép): `CreateContractFromEvaluationFeatures.cs:88-100` GiaTri = Sum(IsSelected quotes/winner) NO clamp → negative flows through. `ContractFeatures.cs:46` GreaterThanOrEqualTo(0) = separate MANUAL path (unaffected). No-op correct.
|
||||
- R2 (submit-guard net<=0 giữ): BE `PurchaseEvaluationWorkflowService.cs:206` `selectedThanhTien.Sum() <= 0` + FE `PeDetailTabs.tsx:201` `gia <= 0` — both SUM SIGNED → pure-neg blocks, mixed net>0 passes. No-op correct.
|
||||
- Submit gate empty→skip: `:2226` parseVnd('')=0, `0!==0` false, `-0!==0` false. Semantics preserved.
|
||||
- BE accepts neg: UpsertQuoteCommand `PurchaseEvaluationDetailFeatures.cs:272-280` no validator; ThanhTien decimal(18,2) `PurchaseEvaluationConfiguration.cs:131` no CHECK.
|
||||
- Mirror byte-identical NOW (sha256 fd9a68a5...). 4 test files exist.
|
||||
|
||||
## MUST-FIX (correctness gap in terse plan)
|
||||
Sign-loss pitfall. `form.thanhTien` is read DIRECTLY as saved value at POST body `:2232`(AddSupplierDialog) + `:2875`(QuoteDialog), submit gate `:2226`, toast `:2239`. VndInlineEdit (`:1014-1021`) works because it keeps abs in LOCAL `text` state + computes signed ONLY at its own "Lưu" onSave — these 2 dialogs have NO separate amount-save button. So spec MUST specify: store SIGNED in form.thanhTien (display Math.abs), and toggle +/- handler MUST re-sign form.thanhTien (not just flip a `neg` flag). If toggle only flips neg + user submits w/o further keystroke → POST sends wrong sign (positive) → neg quote silently saved positive = feature fails.
|
||||
|
||||
## Consumer inventory (Q4 — none break, all neg-safe)
|
||||
Table cell `:2695/:2699` fmtMoney · giaChaoThau `:1935` toLocaleString · winnerQuoteTotal+threshold `:344/:346/:966` fmtMoney+`<` · submit checklist `:235` gia>0 · budget soft-warning `:1329-1352` uses PRO/CCM suggested NOT quotes. Only blanking helper formatVndInput(`:52` n>0?…:'') confined to 3 inputs; spec correctly leaves global untouched → budget input `:2830` stays >=0.
|
||||
|
||||
## Lesson
|
||||
Replicating a DEFERRED-PARSE widget (local state + parse-at-own-save) into a SHARED-FORM dialog (value read by outer submit/POST) = sign/magnitude split must reach the shared field at EVERY mutation incl. the toggle, not just the widget's save. Grep every reader of the shared field (POST body + gate + toast) before trusting "reuse pattern X".
|
||||
@ -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: 477 tests = 477 PASS (45 Domain + 432 Infra) as of S113
|
||||
## 📊 Baseline: 495 tests = 495 PASS (45 Domain + 450 Infra) as of S116 (re-grounded from code; was 477@S113)
|
||||
- Count history + verbose per-session test-design notes (S54→D2): [archive/baseline-history.md](archive/baseline-history.md) (L2, on-demand).
|
||||
- Latest (D2): +6 PE Mig 58 multi-winner — `CreateContractFromEvaluationMultiWinnerTests.cs` (5, 1→N HĐ per IsWinner, GiaTri per-winner=quote-total / single=legacy-budget-sum) + `PeListWinnerNamesProjectionTests.cs` (1, EF collection-projection translation-smoke). No prod bug.
|
||||
> Pattern S67: private-static seed/init → invoke qua REFLECTION (`GetMethod(name, NonPublic|Static)` + `Invoke(null, [db, roleManager, NullLogger.Instance])`); seed MenuItem rows TRƯỚC Permission (FK MenuKey→MenuItem.Key Cascade, SQLite Error 19 nếu thiếu). Cycle-guard test: SqliteDbFixture đủ (no User); rollup-count test cần IdentityFixture.
|
||||
@ -56,6 +56,7 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
||||
|
||||
## 📅 Recent activity (one-line FIFO — verbose in [archive/baseline-history.md](archive/baseline-history.md))
|
||||
|
||||
- **S116 (2026-07-13) PE báo giá SỐ ÂM regression-lock (test-before cho guard R2-revised — critical-algo/financial) — 486→495 PASS (+9 Infra), no-red, NO prod bug:** feature anh Kiệt cho ThanhTien ÂM (phát-sinh-giảm/hoàn tiền NCC = giá THẬT). BE ĐÃ đổi submit-guard `:202-208` `Sum()<=0`→`Count==0`(chưa chọn)/`else All(x==0)`(chưa có giá). 4 nhóm test khớp hành-vi MỚI: **T1** `PeWorkItemBudgetTests` (+1) quote ÂM −20tr KHÔNG chạm PeWorkItemBudget record (FullAmount ngân sách bất biến) NHƯNG CHẢY vào CurrentProposalTotal=−20tr (chứng tách-biệt cột đề-xuất vs ngân-sách, "0 code" kiến-trúc). **T2** `PeMultiWinnerTests` (+2) winnerQuoteTotal=SUM CÓ DẤU khác-winner(+50,−20=30) + cùng-winner-2-dòng(80,−30=50). **T3** `PeSubmitGuardAndBypassTests` (+4, +2 helper `SeedWinnerWithQuotesAsync` params + `BuildV2PeReadyToSubmitWithQuotesAsync`): all-zero[0,0]→BLOCKED "chưa có giá"; pure-neg[−500k]→ChoDuyet; mixed net>0→ChoDuyet; 🔒 **mixed net==0 từng-dòng≠0 [+500k,−500k]→ChoDuyet** = ranh giới phân-biệt All(==0) vs Sum()<=0 (revert→RED). **T4** `PeCcmThresholdFinalizeTests` (+2, +helper 2-signed-quote): NET threshold — [+1.2tỷ,−0.3tỷ]=0.9tỷ<ngưỡng→CCM finalize no-throw + CONTRAST [+1.2tỷ] alone=1.2tỷ≥ngưỡng→Conflict (dòng âm LOAD-BEARING, R-CEO giữ NET). KHÔNG touch prod.
|
||||
- **S113 (2026-07-12) Supplier import v2 dedup + publish-guard TEST-AFTER (critical-algo + security) — 458→477 PASS (+19 Infra), no-red, 🔴 1 BUG-FOUND (REPORTED em-main, NOT fixed):** 2 new files `Services/SupplierImportV2DedupTests.cs` (13) + `Application/SupplierPublishAndListTests.cs` (6). Existing S112 file UNTOUCHED (re-baked ExpectedHeaderTokens=30 \n-token → real tokens khớp → Case9 tự flip LayoutValid=true). **Dedup precedence** existing-MST→existing-Code→batch-MST→batch-Code→New: strip-ws-normalize MST-match · 2-blank-MST=2-insert (blank không index) · same-MST-batch=1-insert+fill-null-skip · **Code-backstop CI** (S112: dedup ở SERVICE OrdinalIgnoreCase, SQLite BINARY KHÔNG bắt "backstop"≈"BACKSTOP" → xanh=chứng không dựa DB) · **MST→A/Code→B = MST-precedence WINS, no silent-clobber** (FillNulls skip Code → A.Code giữ, B untouched). **Publish** `PublishSupplierCommand` (KHÔNG UpdateSupplier #73): gate D2 Publish+blank-Code→Conflict "Cần Mã NCC" · unique-exact→Conflict "đã tồn tại" (⚠️ `x.Code==newCode` DB-collation, SQLite bắt exact-case only — prod SQL CI bắt CI-dup; asymmetry vs import, observation không bug) · unpublish always. ListSuppliers Published t/f/null · GetSupplier DTO.IsPublic. **🔴 BUG:** `NewSupplier` `Code=r.Code!.Trim()` → NRE khi row.Code==NULL (path R4 nháp: parser sinh null ô rỗng + preview classify New no-error → confirm NRE → 500 CẢ batch dbCount=0). Empty ""=OK (T8/T14). Characterization test CHỐT NRE → fix null-safe thì flip NotThrow+draft.
|
||||
- **D4 (2026-07-12) PE sign-off A1 + EDGE-5 + opinion-history TEST-BEFORE-MERGE (critical-algo bug-fix + security) — 450→458 PASS (+8 Infra, 4 updated in-place), no-red, NO prod bug:** BE đổi `ApplyDrafterBypassOnSubmitAsync` sang **A1** trong `PeSubmitGuardAndBypassTests.cs`. **4 updated + RENAMED** (tên cũ misleading): drafter bypass CHỈ cấp NV dưới THUẦN (Order<minOwn); pointer DỪNG tại minOwn (người soạn ký TAY qua ApproveV2Async); KHÔNG auto-sign opinion chính chủ / KHÔNG auto-terminal-on-submit / KHÔNG auto nhảy Bước. minOwn=1 (drafter Cấp1 hoặc non-contiguous Cấp1+3) → bypass RỖNG (opinion=0, AutoApprove=0). **8 new:** A1 happy-path (drafter ký Cấp2 comment THẬT — canned "duyệt tự động" đã GỠ khỏi prod, grep 0-match) · non-contiguous full-manual-chain đúng thứ tự · EDGE-5 ×4 (non-admin Reject từ DaDuyet/TuChoi → Conflict "trạng thái kết thúc"; **Admin + system[actorUserId=null+AutoApprove] MIỄN** → fall-through "không hỗ trợ" chứng minh qua-guard) · opinion-history ×2 (re-sign đè opinion non-empty → đúng 1 Changelog Workflow/Update EntityId=level.Id ContextNote chứa comment cũ; first-sign / đè-rỗng → 0 row, filter theo EntityId==levelId loại được Trả-lại-changelog EntityId=null). Helpers: `ApproveAsync` (approvedPrice khi ký cấp CUỐI terminal — ApplyApprovedPriceOnFinalize Conflict nếu thiếu, isSystem miễn) + `ReturnToDrafterAsync` (AllowReturnToDrafter default TRUE trên Level). Empty-opinion chỉ reachable qua seed-trực-tiếp (normalizedComment placeholder-hoá whitespace).
|
||||
|
||||
|
||||
34
.claude/workflows/runs/2026-07-13-pe-negative-quote/run.md
Normal file
34
.claude/workflows/runs/2026-07-13-pe-negative-quote/run.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Run-trace: PE negative-quote feature (2026-07-13, S116)
|
||||
|
||||
**Đề bài (owner anh Kiệt, Zalo ⇄ Duy Nguyen 2026-07-13):** Cho nhập SỐ ÂM ô "Số tiền báo giá cho hạng mục" (React state `form.thanhTien`, Quote per hạng mục) trong dialog "Thêm NCC vào phiếu" module PE.
|
||||
**Rule chốt:** số âm = khoản trừ/phát-sinh-giảm của NCC (do có làm trước); **NGÂN SÁCH GIỮ NGUYÊN** — âm KHÔNG cộng/trừ vào ngân sách dự án.
|
||||
|
||||
**Pipeline anh chỉ định:** `/fable-clone invest+review` → `/fable-real review` → hmw Opus 4.8 MAX → commit + deploy.
|
||||
|
||||
## Stages
|
||||
- [x] Stage 1 — invest (fable-clone ensemble, 4× investigator-codebase) → **DONE 4/4 clean 0-garble** (`wf_eaf52972-d18`, ~11', 516K tok). Kết luận: **FE-ONLY** (BE+DB đã nhận âm; ngân-sách bất-biến bằng kiến-trúc). Spec `spec-pe-negative-quote-13-07-2026.md` ghi xong.
|
||||
- [x] gate — anh Kiệt CHỐT: R1 = CHO HĐ âm (no-op) · R2 = **NỚI guard** `Sum()<=0`→`All(==0)` (anh hỏi lại: 1-NCC-âm phải gửi được, mâu thuẫn R1 → refine). Implement = FE + 1 điều-kiện BE guard + test.
|
||||
- [x] Stage 2 — review (fable-clone 3× reviewer `wf_4824f158-b66`): Lane1 PASS_WITH_FIXES (sign-plumbing baked) · Lane2/3 PASS (mirror clean · budget invariant + #81 safe)
|
||||
- [x] gate — anh Kiệt CHỐT CEO-threshold = NET (no-op, giữ hiện tại)
|
||||
- [x] Stage 3 — review (fable-real `wf_9648d207-21b`) **#53 garble → em-main-solo gate GO-WITH-FIXES** (Fable UP nhưng cut mid-work 0-verdict; em verify R2-FE trên đĩa → bắt thêm mirror `:235` + iterate-quotes)
|
||||
- [x] Stage 4 — implement (hmw `wf_48834aca-c41` Opus MAX, 0-garble): FE 2-app ± toggle + submit-gate + 2 mirror (`hasRealSelectedQuote`; quote ở `details[].quotes` KHÔNG `suppliers[]` — FE agent tự sửa spec) · BE `:206` `All(==0)` · 9 test → 495
|
||||
- [x] Stage 5 — verify em-main độc-lập: **dotnet test 495 (45+450) ✓** · FE mirror SHA `3e16dba5` ✓ · scope 11-file clean ✓ · stale-comment BE fixed
|
||||
- [ ] Stage 6 — commit + push (CI deploy) → cicd-monitor verify prod
|
||||
- [ ] gate — owner-decisions (nếu invest/review lòi ra)
|
||||
- [ ] Stage 4 — implement (hmw Opus 4.8 MAX)
|
||||
- [ ] Stage 5 — verify (test-before "ngân sách giữ nguyên" + build 2-app)
|
||||
- [ ] Stage 6 — commit + deploy
|
||||
|
||||
## Grounding (em-main scout)
|
||||
- **FE** `PeDetailTabs.tsx:2361-2378` (fe-user; mirror fe-admin): field `form.thanhTien`, input qua `parseVnd`/`formatVndInput` (nghi strip `-`), helper "Để trống/0 → chưa báo giá. Sửa sau bằng click số tiền trong bảng." → còn edit ở `EditSupplierDialog:2386` + inline cell.
|
||||
- **BE** validators non-neg: `PeSuggestedPriceFeatures` (Min/Max/CcmPrice >=0), `PeWorkItemBudgetFeatures` (budget >=0), `PurchaseEvaluationFeatures` (BudgetPeriodAmount/ApprovedPriceAmount >=0). **Precedent** `PurchaseEvaluationFeatures.cs:348` "mirror LeaveBalance cho phép số dư âm".
|
||||
- **State** Mig 65 · 89 bảng · 486 test · gotcha 81. S114: `Quote.IsSelected` per-hạng-mục · winnerQuoteTotal=SUM(selected) · ngưỡng CCM · giaTri(single=bid) · gotcha #81.
|
||||
|
||||
## Lanes (Stage 1) — same role investigator-codebase, 4 lens
|
||||
1. FE-surface · 2. BE-surface · 3. Budget-consumers · 4. Edge-precedent-tests
|
||||
|
||||
## wf run-id
|
||||
- Stage 1 invest: `wf_eaf52972-d18` (task `wslm7eymc`) — 4 lane investigator-codebase, DONE 4/4 clean, HMW-ON, lead Opus 4.8
|
||||
- Stage 2 review: `wf_4824f158-b66` (task `wjhpvoyum`) — 3 lane reviewer, DONE Lane1 PASS_WITH_FIXES + Lane2/3 PASS
|
||||
- Stage 3 fable-real: `wf_9648d207-21b` (task `we5wjoxiq`) — single reviewer deep-pass, model:claude-fable-5 (Fable UP); #53 garble → em-main-solo gate GO-WITH-FIXES
|
||||
- Stage 4 implement: `wf_48834aca-c41` (task `wf609ys7l`) — FE-2app ‖ (BE-guard→tests), Opus 4.8 MAX effort max
|
||||
@ -0,0 +1,62 @@
|
||||
# Spec — PE cho phép nhập SỐ ÂM ô "Số tiền báo giá cho hạng mục" (2026-07-13, S116)
|
||||
|
||||
> Engine: `/fable-clone investigator-codebase` 4-lane ensemble (`wf_eaf52972-d18`, 4/4 clean 0-garble) → lead verify + synthesize. Owner anh Kiệt (Zalo ⇄ Duy Nguyen 2026-07-13).
|
||||
|
||||
## ① Tính-năng / đề-bài
|
||||
Ô **"Số tiền báo giá cho hạng mục"** (React state `form.thanhTien` → `Quote.ThanhTien`) trong dialog **"Thêm NCC vào phiếu"** + dialog sửa giá (QuoteDialog) của module PE hiện **không nhập được số âm**. Yêu cầu: **cho nhập âm** (NCC có phát-sinh-giảm giá trị do có làm trước).
|
||||
|
||||
**Rule chốt (Duy Nguyen xác nhận 11:51):** số âm = khoản trừ/phát-sinh-giảm của **riêng NCC**; **NGÂN SÁCH GIỮ NGUYÊN** — quote âm KHÔNG cộng/trừ vào ngân sách dự án.
|
||||
|
||||
**Ngữ cảnh nghiệp-vụ (anh Kiệt S116):** số âm là giá-trị **THẬT** (vd NCC **hoàn tiền** cho mình / phát-sinh-giảm), **KHÔNG phải lỗi** → cấm coi âm là lỗi hay cảnh-báo-đỏ. Nguyên-tắc *"có giá là được"*: có ≥1 giá thật (âm/dương) → gửi được; chỉ **chưa-nhập-gì (selected toàn 0)** mới chặn. ⇒ nền cho R2-revised + R1 (cho HĐ âm).
|
||||
|
||||
## ② Cách implement — **FE + 1 điều-kiện BE** (0 migration · 0 bảng/cột mới)
|
||||
|
||||
**Đã verify (invest 4-lane hội-tụ):** BE + DB **đã chấp nhận âm sẵn** — `UpsertPurchaseEvaluationQuoteCommand` (`PurchaseEvaluationDetailFeatures.cs:272`) KHÔNG validator, cột `ThanhTien decimal(18,2)` (`PurchaseEvaluationConfiguration.cs:131`) 0 CHECK constraint. Ngân sách (`PeWorkItemBudgets`) chỉ ghi bởi command budget role-gated → **quote âm không đụng ngân sách** (rule thỏa bằng kiến-trúc, 0 code). ⇒ **Sửa FE (gỡ 3 chốt chặn âm) + nới 1 điều-kiện submit-guard BE (R2-revised).**
|
||||
|
||||
**5 thay đổi (mirror 2 app `fe-user` + `fe-admin` byte-identical — SHA-mirror bắt buộc):**
|
||||
|
||||
1. **Input quote — AddSupplierDialog** `PeDetailTabs.tsx:2365` — thêm `neg` state + nút toggle **±**, hiển thị `formatVndInput(Math.abs(thanhTien))`, lưu `neg ? -parseVnd : parseVnd`. **Tái dùng pattern có sẵn `VndInlineEdit:1003`** (prop `allowNegative` + `neg` state `:1015` + toggle `:1025-1037` + save `neg?-n:n` `:1019`). KHÔNG reinvent.
|
||||
2. **Input quote — QuoteDialog** `:2917` — cùng pattern; `neg` init từ dấu của `existing.thanhTien` (mở lại quote âm phải hiện đúng, không trắng ô).
|
||||
3. **Submit gate — AddSupplierDialog** `:2226` `if (detailId && form.thanhTien > 0)` → `!== 0` (âm tạo quote; rỗng/0 vẫn skip = "chưa báo giá"). Toast `:2239` cùng sửa.
|
||||
4. **KHÔNG đụng** `parseVnd`/`formatVndInput` (dùng-chung ô ngân sách cùng file `:51-52`) + **KHÔNG** truyền allowNegative cho ô ngân-sách `thanhTienNganSach:2830` (giữ ≥0).
|
||||
5. **Submit-guard R2-revised (BE + FE mirror):** BE `PurchaseEvaluationWorkflowService.cs:206` `selectedThanhTien.Sum() <= 0` → `selectedThanhTien.All(x => x == 0)` (hoặc `!Any(x => x != 0)`). Chặn **CHỈ khi chưa nhập giá nào**; có ≥1 giá thật (âm/dương, kể cả 1 NCC 1 hạng mục âm) → CHO gửi. Message giữ. `Count==0` (chưa chọn NCC) vẫn chặn riêng.
|
||||
- 🔴 **FE = 2 mirror (em-main gate S116, KHÔNG phải 1):** `:201` (`missingForApproval`, `gia <= 0`) VÀ `:235` (`submitChecklist` label "Giá chào thầu > 0"). CẢ HAI dùng `computeGiaChaoThau`→`ev.winnerQuoteTotal`=**SUM** (`:75-80`) — SUM KHÔNG phân-biệt all-zero vs net-zero (+100,−100=0) → phải **iterate list quote đã-chọn** `ev.suppliers[].quotes`, predicate "có ≥1 selected quote `thanhTien !== 0`". Đổi CẢ `:201` (block khi `!hasReal`) + `:235` (ok khi `hasReal`, label → "Đã nhập giá chào thầu"). Net-zero-có-giá-thật = submittable.
|
||||
|
||||
**Semantics giữ:** rỗng/0 = "chưa báo giá" (skip tạo quote); âm = quote thật (≠ rỗng). `formatVndInput(-n)→''` là lý do PHẢI tách magnitude+dấu (không chỉ nới parseVnd).
|
||||
|
||||
**Tests (regression-lock invariant tài chính — test-specialist):**
|
||||
1. **Ngân sách giữ nguyên**: upsert quote âm → assert `PeWorkItemBudget` amounts UNCHANGED (`PeWorkItemBudgetTests`).
|
||||
2. `winnerQuoteTotal` = SUM mixed +/- selected (`PeMultiWinnerTests`).
|
||||
3. submit-guard R2-revised: **all-zero → blocked** (giữ test `:104`); **thuần-âm / net-âm → SUBMITTABLE** (có giá thật, kể cả 1 NCC 1 hạng mục âm); mixed net-any → submittable (`PeSubmitGuardAndBypassTests`).
|
||||
4. CCM-threshold với total giảm do âm (`PeCcmThresholdFinalizeTests`).
|
||||
|
||||
## ✅ Owner-decisions (anh Kiệt CHỐT S116 via AskUser)
|
||||
- **R1 — Contract.GiaTri âm: CHO PHÉP** (khuyến nghị). Luồng PE→HĐ giữ số âm; **KHÔNG thêm clamp/floor**; validator tạo-tay `ContractFeatures.cs:46` (GiaTri≥0) GIỮ nguyên (đường khác). → **code no-op** (allow = không chặn thêm).
|
||||
- **R2 — submit-guard: NỚI (anh Kiệt refine S116)** — đổi chặn `Sum()<=0` → chặn CHỈ khi TẤT CẢ selected = 0 (`All(==0)`). Có ≥1 giá thật (âm/dương, **kể cả 1 NCC 1 hạng mục âm**) → CHO gửi. Lý do: rào cũ `Sum()<=0` bắt nhầm ca 1-NCC-âm → **mâu thuẫn R1** (cho HĐ âm nhưng không gửi được phiếu tạo ra nó). → **BE `:206` + FE mirror `:201` (fix nhỏ 2-tier).**
|
||||
- R3 (default): BgVat/ChuaVat giữ nguyên (FE chỉ expose input âm cho thanhTien).
|
||||
- **R-CEO — ngưỡng CEO: DÙNG NET (anh Kiệt chốt S116)** — CeoApprovalThreshold giữ tính trên `winnerQuoteTotal` net (dòng âm giảm tổng → có thể kéo dưới ngưỡng → CCM finalize bỏ CEO; owner chấp nhận, đúng ý "duyệt theo net cam-kết"). → **code no-op** (giữ hiện tại).
|
||||
→ **R1 + R-CEO no-op + R2 nới-guard ⇒ implement = 4 thay-đổi FE + 1 điều-kiện BE (submit-guard) + 4 test.**
|
||||
|
||||
## 🔍 Review-reconcile Stage 2 (fable-clone 3-lane `wf_4824f158-b66` — Lane1 PASS_WITH_FIXES · Lane2/3 PASS)
|
||||
**🔴 MUST bake (Lane-1 sign-plumbing):** 2 dialog commit `form.thanhTien` THẲNG ở POST (`:2232` AddSupplier · `:2875` QuoteDialog), KHÔNG có nút "Lưu số tiền" riêng như VndInlineEdit ⇒ nút ± PHẢI đảo dấu CHÍNH `form.thanhTien` NGAY (`setForm({...form, thanhTien: -form.thanhTien})`), display `formatVndInput(Math.abs(form.thanhTien))`, onChange áp dấu hiện tại. Chỉ flip cờ `neg` → bấm ± rồi Thêm không gõ thêm = **gửi sai dấu (dương) = mất-dấu thầm lặng**.
|
||||
**Lane-2 nits (bake):** (a) Toast `:2239` dùng CÙNG predicate `!== 0` với gate `:2226`. (b) `neg` = `useState` RỜI, KHÔNG nhét vào form object (QuoteDialog `:2921` `setForm({thanhTien})` thay-thế object → nuốt `neg`). (c) **Re-verify mirror SAU implement:** `git diff --no-index` 2 app PeDetailTabs = exit 0 (4 chèn × 2 app = drift-risk cao nhất).
|
||||
**Lane-3 test notes:** T1 (budget unchanged) hơi tautological (0 đường ghi budget từ quote) — giữ regression-lock, bề-mặt rò thật = T2. T3 cần seed ≥2 quote TRÁI DẤU/1 winner → mở rộng helper `SeedWinnerWithQuoteAsync` (`PeSubmitGuardAndBypassTests.cs:106-136`, hiện 1 quote/winner); base `:104` không đủ nửa "mixed net>0 submittable".
|
||||
**✅ Owner-confirm CEO-threshold:** anh Kiệt CHỐT dùng **NET** (giữ hiện tại, no-op) — chấp nhận dòng âm có thể kéo phiếu dưới ngưỡng CEO.
|
||||
|
||||
## ✅ Cổng cuối — em-main-solo gate (fable-real #53 garble recovery, S116)
|
||||
Fable-real deep-pass (`wf_9648d207-21b`, model=`claude-fable-5` — Fable UP) chạy 124K tok/35 tool-call NHƯNG **#53 return-garble** (cut mid-investigation ở CEO-threshold, 0 verdict trong transcript). Per playbook `feedback_agent_return_garble_recover` → **em-main-solo gate:**
|
||||
- **Verdict: GO-WITH-FIXES.** 7 agent (invest 4 + review 3) hội-tụ sạch; sign-plumbing (Lane-1) baked; R-CEO verified trên đĩa (fable-real transcript đọc đúng `winnerQuoteTotal SUM` `:884-889`).
|
||||
- **1 FIX em-main gate bắt thêm:** R2-revised FE = **2 mirror** (`:201`+`:235`) iterate-quotes-KHÔNG-sum (bake change #5) — điều fable-real chưa kịp soi.
|
||||
- Còn lại SOUND → **GO implement.**
|
||||
|
||||
## ③ Checklist (vai · deliverable · acceptance ĐO ĐƯỢC)
|
||||
| # | Vai | Deliverable | Acceptance đo được |
|
||||
|---|---|---|---|
|
||||
| C1 | implementer-frontend | ± toggle + neg-state cho 2 input quote (`:2365`, `:2917`) × 2 app | Nhập `-5.000.000` → ô hiện `-5.000.000` (KHÔNG trắng); reopen quote âm hiện đúng dấu; SHA `fe-user`==`fe-admin` |
|
||||
| C2 | implementer-frontend | Submit gate `:2226` `>0`→`!==0` + toast `:2239` × 2 app | Thêm NCC với quote âm → tạo quote (không bị nuốt im lặng); rỗng/0 vẫn "chỉ tạo NCC" |
|
||||
| C3 | implementer-frontend | Ô ngân-sách `:2830` GIỮ ≥0 (không allowNegative) | Nhập `-` vào ngân sách hạng mục → bị chặn/không nhận |
|
||||
| C3b | implementer-backend + implementer-frontend | Submit-guard R2-revised: BE `:206` `Sum()<=0`→`All(==0)` + **2 FE mirror** `:201`+`:235` (iterate quotes, KHÔNG sum) × 2 app | Phiếu **1 NCC 1 hạng mục âm → GỬI được**; net-zero-có-giá → gửi được; selected toàn 0 → vẫn chặn "chưa báo giá" |
|
||||
| C4 | test-specialist | 4 test regression-lock (ngân-sách-giữ-nguyên + winnerTotal + submit-guard + CCM-threshold) | `dotnet test` xanh; test #1 chứng budget bất-biến khi quote âm |
|
||||
| C5 | reviewer + cicd | build 2-app + deploy | `npm build` 2-app pass; bundle rotate; prod nhập âm được (anh UAT incognito) |
|
||||
|
||||
**Trạng thái sau implement dự kiến:** Mig 65 (GIỮ) · 89 bảng (GIỮ) · test 486→~490 · FE 2-app bundle rotate + BE 1 điều-kiện guard. → CI chạy (path-filter không skip).
|
||||
@ -79,6 +79,16 @@ function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||||
return ev.winnerQuoteTotal
|
||||
}
|
||||
|
||||
// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN
|
||||
// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT,
|
||||
// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero
|
||||
// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror
|
||||
// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG
|
||||
// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục.
|
||||
function hasRealSelectedQuote(ev: PeDetailBundle): boolean {
|
||||
return ev.details.some(d => d.quotes.some(q => q.isSelected && 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).
|
||||
//
|
||||
@ -195,10 +205,11 @@ export function PeDetailTabs({
|
||||
if (!evaluation.suppliers.some(s => s.isWinner)) {
|
||||
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")
|
||||
// 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá
|
||||
// được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá
|
||||
// THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi
|
||||
// TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected.
|
||||
if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu")
|
||||
}
|
||||
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
|
||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||
@ -227,12 +238,14 @@ export function PeDetailTabs({
|
||||
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||
const submitChecklist = useMemo(() => {
|
||||
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||
// [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không
|
||||
// dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected.
|
||||
const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation)
|
||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||
return [
|
||||
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
||||
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||
{ label: 'Đã nhập giá chào thầu', ok: hasRealQuote },
|
||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
|
||||
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||
]
|
||||
@ -2182,6 +2195,10 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
note: '',
|
||||
thanhTien: 0,
|
||||
})
|
||||
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
|
||||
// (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU
|
||||
// vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng).
|
||||
const [neg, setNeg] = useState(false)
|
||||
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
|
||||
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
|
||||
const hasError = !!(phoneError || emailError)
|
||||
@ -2222,8 +2239,9 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
note: form.note,
|
||||
})
|
||||
const newSupplierRowId = res.data.id
|
||||
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
|
||||
if (detailId && form.thanhTien > 0) {
|
||||
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0).
|
||||
// [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá".
|
||||
if (detailId && form.thanhTien !== 0) {
|
||||
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
|
||||
purchaseEvaluationDetailId: detailId,
|
||||
purchaseEvaluationSupplierId: newSupplierRowId,
|
||||
@ -2236,7 +2254,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
|
||||
toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
onClose()
|
||||
},
|
||||
@ -2361,19 +2379,32 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
{showQuote && (
|
||||
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
|
||||
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
|
||||
<div className="relative mt-1 max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.thanhTien)}
|
||||
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
<div className="mt-1 flex max-w-xs items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNeg(!neg); setForm({ ...form, thanhTien: -form.thanhTien }) }}
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(Math.abs(form.thanhTien))}
|
||||
onChange={e => { const mag = parseVnd(e.target.value); setForm({ ...form, thanhTien: neg ? -mag : mag }) }}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng.
|
||||
Để trống / 0 → chỉ tạo NCC, chưa báo giá. Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC). Sửa lại sau bằng cách click số tiền trong bảng.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -2864,6 +2895,10 @@ function QuoteDialog({
|
||||
const [form, setForm] = useState({
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
})
|
||||
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
|
||||
// (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo
|
||||
// dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng).
|
||||
const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0)
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () =>
|
||||
@ -2913,19 +2948,32 @@ function QuoteDialog({
|
||||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong></p>
|
||||
<div>
|
||||
<Label>Số tiền</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.thanhTien)}
|
||||
onChange={e => setForm({ thanhTien: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
className="pr-12 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNeg(!neg); setForm({ thanhTien: -form.thanhTien }) }}
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(Math.abs(form.thanhTien))}
|
||||
onChange={e => { const mag = parseVnd(e.target.value); setForm({ thanhTien: neg ? -mag : mag }) }}
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
className="pr-12 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
|
||||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC).</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@ -79,6 +79,16 @@ function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||||
return ev.winnerQuoteTotal
|
||||
}
|
||||
|
||||
// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN
|
||||
// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT,
|
||||
// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero
|
||||
// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror
|
||||
// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG
|
||||
// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục.
|
||||
function hasRealSelectedQuote(ev: PeDetailBundle): boolean {
|
||||
return ev.details.some(d => d.quotes.some(q => q.isSelected && 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).
|
||||
//
|
||||
@ -195,10 +205,11 @@ export function PeDetailTabs({
|
||||
if (!evaluation.suppliers.some(s => s.isWinner)) {
|
||||
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")
|
||||
// 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá
|
||||
// được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá
|
||||
// THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi
|
||||
// TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected.
|
||||
if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu")
|
||||
}
|
||||
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
|
||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||
@ -227,12 +238,14 @@ export function PeDetailTabs({
|
||||
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||
const submitChecklist = useMemo(() => {
|
||||
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||
// [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không
|
||||
// dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected.
|
||||
const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation)
|
||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||
return [
|
||||
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
||||
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||
{ label: 'Đã nhập giá chào thầu', ok: hasRealQuote },
|
||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
|
||||
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||
]
|
||||
@ -2182,6 +2195,10 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
note: '',
|
||||
thanhTien: 0,
|
||||
})
|
||||
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
|
||||
// (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU
|
||||
// vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng).
|
||||
const [neg, setNeg] = useState(false)
|
||||
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
|
||||
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
|
||||
const hasError = !!(phoneError || emailError)
|
||||
@ -2222,8 +2239,9 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
note: form.note,
|
||||
})
|
||||
const newSupplierRowId = res.data.id
|
||||
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
|
||||
if (detailId && form.thanhTien > 0) {
|
||||
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0).
|
||||
// [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá".
|
||||
if (detailId && form.thanhTien !== 0) {
|
||||
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
|
||||
purchaseEvaluationDetailId: detailId,
|
||||
purchaseEvaluationSupplierId: newSupplierRowId,
|
||||
@ -2236,7 +2254,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
|
||||
toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
onClose()
|
||||
},
|
||||
@ -2361,19 +2379,32 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
||||
{showQuote && (
|
||||
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
|
||||
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
|
||||
<div className="relative mt-1 max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.thanhTien)}
|
||||
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
<div className="mt-1 flex max-w-xs items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNeg(!neg); setForm({ ...form, thanhTien: -form.thanhTien }) }}
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(Math.abs(form.thanhTien))}
|
||||
onChange={e => { const mag = parseVnd(e.target.value); setForm({ ...form, thanhTien: neg ? -mag : mag }) }}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng.
|
||||
Để trống / 0 → chỉ tạo NCC, chưa báo giá. Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC). Sửa lại sau bằng cách click số tiền trong bảng.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -2864,6 +2895,10 @@ function QuoteDialog({
|
||||
const [form, setForm] = useState({
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
})
|
||||
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
|
||||
// (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo
|
||||
// dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng).
|
||||
const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0)
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () =>
|
||||
@ -2913,19 +2948,32 @@ function QuoteDialog({
|
||||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong></p>
|
||||
<div>
|
||||
<Label>Số tiền</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(form.thanhTien)}
|
||||
onChange={e => setForm({ thanhTien: parseVnd(e.target.value) })}
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
className="pr-12 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setNeg(!neg); setForm({ thanhTien: -form.thanhTien }) }}
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(Math.abs(form.thanhTien))}
|
||||
onChange={e => { const mag = parseVnd(e.target.value); setForm({ thanhTien: neg ? -mag : mag }) }}
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
className="pr-12 font-mono text-right"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
|
||||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC).</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@ -191,8 +191,9 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var missing = new List<string>();
|
||||
// [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected
|
||||
// = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected
|
||||
// toàn phiếu (D5 giữ >0). 0 selected → "chưa chọn"; có selected nhưng tổng ≤0 → "chưa có giá"
|
||||
// (item 2 chỉ nới NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
|
||||
// toàn phiếu (SUM có-dấu, chỉ dùng ở ngưỡng CEO/giaTri). [S116 anh Kiệt: cho nhập giá ÂM =
|
||||
// phát-sinh-giảm/hoàn-tiền = giá THẬT] 0 selected → "chưa chọn"; có selected nhưng TẤT CẢ = 0
|
||||
// → "chưa có giá" (âm/dương đều là giá thật → gửi được, kể cả 1 NCC 1 hạng mục âm; Sum()<=0→All(==0)).
|
||||
var selectedThanhTien = await (
|
||||
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
join d in db.PurchaseEvaluationDetails.AsNoTracking()
|
||||
@ -203,7 +204,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
{
|
||||
missing.Add("chưa chọn Đơn vị NCC/TP");
|
||||
}
|
||||
else if (selectedThanhTien.Sum() <= 0)
|
||||
else if (selectedThanhTien.All(x => x == 0))
|
||||
{
|
||||
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
||||
}
|
||||
|
||||
@ -266,4 +266,76 @@ public class PeMultiWinnerTests
|
||||
detailDto.Quotes.Should().HaveCount(3, "cả 3 NCC đều có báo giá cho hạng mục");
|
||||
detailDto.Quotes.Count(q => q.IsSelected).Should().Be(2, "đúng 2 báo giá được chọn");
|
||||
}
|
||||
|
||||
// ===== [S116 anh Kiệt — SỐ ÂM báo giá] winnerQuoteTotal = SUM CÓ DẤU =====
|
||||
// Báo giá ÂM (phát-sinh-giảm / hoàn tiền NCC) là giá-trị THẬT → cộng dồn CÓ DẤU,
|
||||
// KHÔNG clamp về 0 / KHÔNG Abs / KHÔNG loại. winnerQuoteTotal (DTO :1188-1191
|
||||
// `.Where(IsSelected).Sum(ThanhTien)`) phải giữ dấu âm. Nếu ai thêm clamp/Abs →
|
||||
// 2 test dưới RED.
|
||||
|
||||
[Fact]
|
||||
public async Task WinnerQuoteTotal_TwoWinnersOppositeSign_SumsWithSign()
|
||||
{
|
||||
// khác winner: NCC-POS chào +50tr (vật tư), NCC-NEG ghi −20tr (hoàn tiền / giảm
|
||||
// trừ) cùng 1 hạng mục — cả 2 ĐƯỢC CHỌN → tổng gói = 50 − 20 = 30tr.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-MW-SIGN");
|
||||
var detailId = await SeedDetailAsync(db, pe);
|
||||
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-POS", 50_000_000m, isWinner: true, order: 0);
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-NEG", -20_000_000m, isWinner: true, order: 1);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(30_000_000m,
|
||||
"SUM có dấu — báo giá âm (hoàn tiền / phát-sinh-giảm) TRỪ vào tổng, KHÔNG bị loại");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WinnerQuoteTotal_SameWinnerPositiveAndNegativeLines_SumsWithSign()
|
||||
{
|
||||
// cùng winner: 1 NCC trúng với 2 dòng (2 detail) — vật tư +80tr + phát-sinh-giảm
|
||||
// −30tr → tổng = 80 − 30 = 50tr. Verify SelectMany gộp mọi dòng của winner-row
|
||||
// GIỮ dấu (mirror WinnerQuoteTotal_MultipleQuotesPerWinner nhưng 1 dòng âm).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-MW-SIGN2");
|
||||
|
||||
var detail1 = await SeedDetailAsync(db, pe);
|
||||
var detail2 = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Giảm trừ", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-SGN", Name = "NCC 2 dòng trái dấu" };
|
||||
db.Suppliers.Add(supplier);
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, SupplierId = supplier.Id,
|
||||
Order = 0, IsWinner = true,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 80_000_000m, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = -30_000_000m, IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(50_000_000m,
|
||||
"cùng winner 2 dòng trái dấu → SUM có dấu (80 − 30), dòng âm KHÔNG bị bỏ");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1034,4 +1034,55 @@ public class PeWorkItemBudgetTests
|
||||
reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
|
||||
reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 7. [S116 anh Kiệt — SỐ ÂM báo giá] Regression-lock: báo giá ÂM (phát-sinh-giảm /
|
||||
// hoàn tiền NCC = giá-trị THẬT) KHÔNG rò rỉ vào ngân sách. "Ngân sách giữ nguyên"
|
||||
// là quyết định KIẾN-TRÚC (0 code nối quote→budget). Test tautological NHƯNG khoá
|
||||
// cứng: nếu ai sau này wire "auto-trừ ngân sách theo báo giá" thì dòng âm sẽ phá
|
||||
// ngân sách — test này bắt regression đó. Đồng thời chứng minh TÁCH BIỆT: dòng âm
|
||||
// CHẢY vào CurrentProposalTotal (cột đề xuất) chứ KHÔNG vào FullAmount (cột ngân
|
||||
// sách) + record PeWorkItemBudget bất biến.
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeSelectedQuote_DoesNotMutateBudgetRecord_ReflectsInProposalOnly()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-NEG1");
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id,
|
||||
PurchaseEvaluationPhase.DangSoanThao, code: "PE-NEG1");
|
||||
|
||||
// Ngân sách CCM ban hành: Initial 80tr, Adjustment −5tr → FullAmount 75tr.
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProInitialAmount = 100_000_000m, ProAdjustmentAmount = 0m,
|
||||
InitialAmount = 80_000_000m, AdjustmentAmount = -5_000_000m,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Báo giá ÂM −20tr được chọn (phát-sinh-giảm / hoàn tiền) cho phiếu này.
|
||||
await SeedWinnerWithQuoteAsync(db, pe, Guid.NewGuid(), -20_000_000m);
|
||||
|
||||
var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
|
||||
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
// Cột NGÂN SÁCH bất biến — dòng âm KHÔNG kéo giảm ngân sách.
|
||||
s.FullAmount.Should().Be(75_000_000m, "ngân sách = CCM Initial+Adjustment (80−5), KHÔNG bị báo giá âm chạm");
|
||||
s.FullIsEstimate.Should().BeFalse();
|
||||
// Cột ĐỀ XUẤT phản ánh dòng âm (SUM có dấu) — chứng minh âm chảy đúng chỗ.
|
||||
s.CurrentProposalTotal.Should().Be(-20_000_000m, "báo giá âm phản ánh ở đề xuất, KHÔNG phải ngân sách");
|
||||
|
||||
// Record PeWorkItemBudget persistent BẤT BIẾN sau khi thêm quote âm + GET.
|
||||
var rec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.InitialAmount.Should().Be(80_000_000m, "record ngân sách KHÔNG đổi vì báo giá âm");
|
||||
rec.AdjustmentAmount.Should().Be(-5_000_000m);
|
||||
rec.ProInitialAmount.Should().Be(100_000_000m);
|
||||
rec.ProAdjustmentAmount.Should().Be(0m);
|
||||
}
|
||||
}
|
||||
|
||||
@ -703,4 +703,122 @@ public class PeCcmThresholdFinalizeTests
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// [S116 anh Kiệt — SỐ ÂM báo giá] Seed 1 winner mang 2 báo giá ĐƯỢC CHỌN trái dấu
|
||||
// (mỗi giá 1 detail) → winnerQuoteTotal = SUM có dấu (NET). Cho test threshold theo
|
||||
// NET: R-CEO giữ ngưỡng NET, dòng âm (phát-sinh-giảm / hoàn tiền) kéo tổng xuống.
|
||||
private static async Task<Guid> SeedWinnerWithTwoSelectedSignedQuotesAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal a, decimal b)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true,
|
||||
};
|
||||
var d1 = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Vật tư", NoiDung = "Dòng +", Order = 0,
|
||||
};
|
||||
var d2 = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Dòng −", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(d1);
|
||||
db.PurchaseEvaluationDetails.Add(d2);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = d1.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = a, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = d2.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = b, IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (d) [S116 anh Kiệt — SỐ ÂM báo giá] NET threshold: dòng âm (phát-sinh-giảm /
|
||||
// hoàn tiền) kéo winnerQuoteTotal (SUM có dấu) xuống DƯỚI ngưỡng CEO → CCM
|
||||
// duyệt done miễn CEO HỢP LỆ. R-CEO giữ ngưỡng NET (KHÔNG đổi) — chỉ tổng đại
|
||||
// số quyết định, KHÔNG phải dòng dương lớn nhất.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_NegativeLinePullsNetBelowThreshold_Finalizes_NoThrow()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmNeg@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoNeg@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 = CCM (đứng đây)
|
||||
new[] { ceo }, // Bước 2 = CEO (sẽ bị bỏ qua)
|
||||
}, ceoThreshold: CeoThreshold); // 1 tỷ
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// +1.2 tỷ (vật tư) + (−0.3 tỷ) (giảm trừ) → NET 0.9 tỷ < ngưỡng 1 tỷ.
|
||||
// Dòng dương RIÊNG (1.2 tỷ) ≥ ngưỡng — nếu code lấy max-line / positive-only = SAI.
|
||||
var supplierId = await SeedWinnerWithTwoSelectedSignedQuotesAsync(
|
||||
db, pe, a: 1_200_000_000m, b: -300_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().NotThrowAsync(
|
||||
"winnerQuoteTotal = SUM có dấu (1.2 tỷ − 0.3 tỷ = 0.9 tỷ) < ngưỡng → CCM finalize hợp lệ");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "net dưới ngưỡng → duyệt done miễn CEO");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (e) [S116] CONTRAST — CÙNG dòng dương 1.2 tỷ NHƯNG KHÔNG có dòng âm → NET 1.2 tỷ
|
||||
// ≥ ngưỡng → Conflict. Chứng minh dòng âm ở (d) là LOAD-BEARING (chính nó kéo
|
||||
// net xuống mới cho finalize), test (d) KHÔNG phải happy-path trivial.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_SamePositiveLineWithoutNegative_NetAboveThreshold_ThrowsConflict()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmNeg2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoNeg2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm },
|
||||
new[] { ceo },
|
||||
}, ceoThreshold: CeoThreshold);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG2");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// CHỈ dòng +1.2 tỷ (KHÔNG dòng âm) → net 1.2 tỷ ≥ ngưỡng.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_200_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*ngưỡng CEO*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"không có dòng âm → net ≥ ngưỡng → CEO vẫn phải duyệt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1066,4 +1066,170 @@ public class PeSubmitGuardAndBypassTests
|
||||
cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// [S116 anh Kiệt — SỐ ÂM báo giá / R2-revised submit-guard] Báo giá ÂM
|
||||
// (phát-sinh-giảm / hoàn tiền NCC = giá-trị THẬT) → gửi duyệt được.
|
||||
// Guard ĐỔI (prod :202-208): `Sum() <= 0` (cũ) → `All(x => x == 0)` (mới):
|
||||
// • CHƯA chọn gì (0 quote IsSelected) → "chưa chọn Đơn vị NCC/TP" (chặn)
|
||||
// • Có chọn nhưng TẤT CẢ = 0 → "chưa có giá chào thầu" (chặn)
|
||||
// • Có ≥1 báo giá KHÁC 0 (âm HOẶC dương) → QUA guard (gửi được)
|
||||
// Regression-lock mạnh nhất = mixed net==0 nhưng từng dòng ≠0: dưới guard CŨ
|
||||
// (Sum<=0) bị CHẶN, dưới guard MỚI (All==0) cho QUA. Revert về Sum()<=0 →
|
||||
// 2 test net≤0 dưới đây RED ngay.
|
||||
// =====================================================================
|
||||
|
||||
// Seed 1 winner (PES) mang N báo giá ĐƯỢC CHỌN (mỗi giá 1 detail) — cho phép âm /
|
||||
// trái dấu. Mở rộng SeedWinnerWithQuoteAsync (:106, 1 quote/winner) → N quote/winner.
|
||||
private static async Task<Guid> SeedWinnerWithQuotesAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] quoteThanhTien)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
var order = 0;
|
||||
foreach (var tt in quoteThanhTien)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = $"Dòng {order}",
|
||||
Order = order,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = tt,
|
||||
IsSelected = true,
|
||||
});
|
||||
order++;
|
||||
}
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// Dựng PE V2 đủ 4 ĐK Section-3 NHƯNG winner mang N báo giá tuỳ ý (âm / mixed).
|
||||
// budget=1tr (>0 qua budget-guard) + comparison + pin workflow. Drafter ngoài
|
||||
// chuỗi → submit start pointer (0,1) KHÔNG bypass.
|
||||
private static async Task<PurchaseEvaluation> BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code,
|
||||
params decimal[] selectedQuoteThanhTien)
|
||||
{
|
||||
var pe = BuildPeNhap(
|
||||
budgetPeriodAmount: 1_000_000m,
|
||||
approvalWorkflowId: workflowId,
|
||||
drafterUserId: drafterUserId,
|
||||
code: code);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuotesAsync(db, pe, selectedQuoteThanhTien);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_AllSelectedQuotesZero_ThrowsConflict_NoBidPrice_R2Revised()
|
||||
{
|
||||
// all-zero: winner có 2 báo giá ĐƯỢC CHỌN NHƯNG cả 2 = 0 → All(==0) → chặn
|
||||
// "chưa có giá chào thầu" (số ÂM mới hợp lệ; số 0 vẫn = chưa nhập giá). Đủ
|
||||
// budget + comparison → message CHỈ nhắc giá. Bổ sung test (3) single-zero:
|
||||
// ở đây .All() chạy trên list NHIỀU phần tử (đều 0).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m, code: "PE-NEG-ZERO");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuotesAsync(db, pe, 0m, 0m);
|
||||
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");
|
||||
ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP");
|
||||
ex.Which.Message.Should().NotContain("Ngân sách");
|
||||
ex.Which.Message.Should().NotContain("Bảng so sánh");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, "guard chặn TRƯỚC mutate");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_PureNegativeQuote_PassesBidPriceGuard_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// ⭐ TRỤC CỐT LÕI: 1 winner, 1 báo giá ÂM −500k (hoàn tiền / phát-sinh-giảm =
|
||||
// giá THẬT) → All(==0) false → QUA guard → gửi duyệt OK (ChoDuyet 0,1). Dưới
|
||||
// guard CŨ (Sum<=0) case này BỊ CHẶN → đây là hành-vi mới R2-revised.
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprneg@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-PURE", -500_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid()); // actor ngoài chuỗi
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "báo giá âm là giá THẬT → gửi duyệt được");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MixedSignQuotes_NetPositive_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// mixed net>0: +1tr vật tư + (−300k) giảm trừ → net +700k, từng dòng ≠0 → qua guard.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprmix@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-MIXPOS", 1_000_000m, -300_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "net dương + từng dòng ≠0 → gửi duyệt OK");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MixedSignQuotes_NetZeroButIndividualNonzero_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// 🔒 STRONGEST regression-lock cho R2-revised: +500k + (−500k) → net == 0 NHƯNG
|
||||
// từng dòng ≠0 → All(==0) FALSE → QUA guard. Dưới guard CŨ (Sum()<=0) net==0 sẽ
|
||||
// BỊ CHẶN → nếu code revert về Sum()<=0, test này RED ngay. Đây chính là ranh
|
||||
// giới phân biệt All(==0) vs Sum()<=0.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprz@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-NET0", 500_000m, -500_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"net==0 nhưng từng báo giá ≠0 → All(==0) false → gửi được (phân biệt All==0 vs Sum<=0)");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user