Compare commits

...

8 Commits

Author SHA1 Message Date
88368fd87e [CLAUDE] PurchaseEvaluation: cho nhập giá ÂM ô báo giá hạng mục + submit-guard all-zero (S116)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m28s
Feature (anh Kiệt FDC ⇄ Duy Nguyen 2026-07-13): cho nhập SỐ ÂM ô "Số tiền báo giá
cho hạng mục" (Quote.ThanhTien) — âm = phát-sinh-giảm / hoàn-tiền của NCC = giá THẬT.
Nguyên-tắc "có giá là được": ≥1 giá thật (âm/dương) → gửi được; chỉ chưa-nhập-gì mới chặn.

FE (2 app byte-identical PeDetailTabs.tsx):
- Nút ± toggle 2 input quote (AddSupplierDialog + QuoteDialog), ghi giá-CÓ-DẤU thẳng vào
  form.thanhTien (2 dialog POST commit trực tiếp, không nút Lưu riêng → tránh mất-dấu thầm lặng).
- Submit gate + toast >0 → !==0. Ô ngân sách giữ ≥0 (không toggle).
- 2 FE submit-mirror (:201 missingForApproval + :235 submitChecklist) đổi từ SUM
  computeGiaChaoThau → helper hasRealSelectedQuote (iterate details[].quotes IsSelected≠0;
  SUM không phân-biệt all-zero vs net-zero).

BE (1 điều-kiện): PurchaseEvaluationWorkflowService.cs:206 selectedThanhTien.Sum()<=0
  → All(x=>x==0) — chặn chỉ khi TẤT CẢ selected=0. BE+DB nhận âm sẵn (0 validator,
  decimal(18,2), no CHECK, KHÔNG migration).

Owner-decisions: R1 cho Contract.GiaTri âm (no clamp) · R2 nới submit-guard · R-CEO
  giữ NET threshold (dòng âm có thể kéo dưới ngưỡng → CCM finalize, owner chấp nhận).

Tests +9 (486→495): ngân-sách-bất-biến · winnerQuoteTotal signed-sum · submit-guard
  all-zero/negative/net-zero · CCM-threshold net.

Pipeline S116: /fable-clone invest (4-lane) → owner-gate → /fable-clone review (3-lane)
  → /fable-real (Fable, #53 garble → em-main-solo gate GO-WITH-FIXES) → hmw implement (Opus MAX).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:08:34 +07:00
2ef3f445bf wal: flush 20260713T1702 2026-07-13 17:02:40 +07:00
d3fa073c0c wal: flush 20260713T1637 2026-07-13 16:37:04 +07:00
e5f3c5d70a wal: flush 20260713T1622 2026-07-13 16:22:29 +07:00
3abc8a0950 wal: flush 20260713T1612 2026-07-13 16:12:58 +07:00
7676718cbe wal: flush 20260713T1611 2026-07-13 16:11:50 +07:00
6356def8c0 wal: flush 20260713T1605 2026-07-13 16:05:44 +07:00
5dc29cd23f wal: flush 20260713T1546 2026-07-13 15:46:31 +07:00
16 changed files with 724 additions and 93 deletions

View File

@ -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.

View File

@ -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]`.

View File

@ -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).

View File

@ -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.IsWinnerQuote.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 /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 " 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ột27, 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-boxsaveadvance" cơ-chế ĐÃ 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 IsWinnerIsSelected = 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-garbleem-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 IsWinnerQuote.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 previewconfirm, 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 `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ẮTCode). **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út403; 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.

View File

@ -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<CeoApprovalThresholdCCM 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-persistS111). 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`.

View File

@ -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".

View File

@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
## 📊 Baseline: 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ưỡngCCM 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) 458477 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-MSTexisting-Codebatch-MSTbatch-CodeNew: 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-CodeConflict "Cần NCC" · unique-exactConflict "đã 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) 450458 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 misleading): drafter bypass CHỈ cấp NV dưới THUẦN (Order<minOwn); pointer DỪNG tại minOwn (người soạn 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 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 ; first-sign / đè-rỗng 0 row, filter theo EntityId==levelId loại được Trả-lại-changelog EntityId=null). Helpers: `ApproveAsync` (approvedPrice khi 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).

View 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

View File

@ -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).

View File

@ -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>

View File

@ -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>

View File

@ -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");
}

View File

@ -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ỏ");
}
}

View File

@ -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 (805), 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);
}
}

View File

@ -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");
}
}
}

View File

@ -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);
}
}
}