9.7 KiB
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-codebase4-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):
- Input quote — AddSupplierDialog
PeDetailTabs.tsx:2365— thêmnegstate + nút toggle ±, hiển thịformatVndInput(Math.abs(thanhTien)), lưuneg ? -parseVnd : parseVnd. Tái dùng pattern có sẵnVndInlineEdit:1003(propallowNegative+negstate:1015+ toggle:1025-1037+ saveneg?-n:n:1019). KHÔNG reinvent. - Input quote — QuoteDialog
:2917— cùng pattern;neginit từ dấu củaexisting.thanhTien(mở lại quote âm phải hiện đúng, không trắng ô). - Submit gate — AddSupplierDialog
:2226if (detailId && form.thanhTien > 0)→!== 0(âm tạo quote; rỗng/0 vẫn skip = "chưa báo giá"). Toast:2239cùng sửa. - 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áchthanhTienNganSach:2830(giữ ≥0). - Submit-guard R2-revised (BE + FE mirror): BE
PurchaseEvaluationWorkflowService.cs:206selectedThanhTien.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(submitChecklistlabel "Giá chào thầu > 0"). CẢ HAI dùngcomputeGiaChaoThau→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ọnev.suppliers[].quotes, predicate "có ≥1 selected quotethanhTien !== 0". Đổi CẢ:201(block khi!hasReal) +:235(ok khihasReal, label → "Đã nhập giá chào thầu"). Net-zero-có-giá-thật = submittable.
- 🔴 FE = 2 mirror (em-main gate S116, KHÔNG phải 1):
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):
- Ngân sách giữ nguyên: upsert quote âm → assert
PeWorkItemBudgetamounts UNCHANGED (PeWorkItemBudgetTests). winnerQuoteTotal= SUM mixed +/- selected (PeMultiWinnerTests).- 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). - 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()<=0bắ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
winnerQuoteTotalnet (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).