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