[CLAUDE] PurchaseEvaluation: Section B (THỰC HIỆN) tách 3 cột per-cột kỳ-này (Mig 59, anh Kiệt FDC / CCM)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m8s

Section B "Tổng hợp ngân sách trình ký" tách 3 cột Dự án|PRO|CCM giống Section A —
mỗi cột nhập "NS kỳ này" RIÊNG + tính thực-hiện độc-lập (lũy-kế / còn-lại / % /
so-sánh) theo ngân-sách-full của cột đó:
- PRO: budgetPeriodAmount (có sẵn) đối chiếu proFull
- CCM: ccmBudgetPeriodAmount (MỚI, Mig 59) đối chiếu ccmFull — CCM nhập (canEditCcm)
- Dự án: — (chưa wire, như A)
Số thực-tế dùng chung mọi cột (NCC đề xuất, lũy kế lịch sử). Submit-guard giữ trên
budgetPeriodAmount (không đụng).

Mig 59 AddCcmBudgetPeriodToPurchaseEvaluation (1 cột nullable, no backfill). NEW
endpoint PATCH /budget/ccm-period role-gate Admin|CostControl (fail-closed). FE
Section B rewrite <BudgetRow> list -> <table> 3 cột (colCalc per-cột), 2 app SHA-mirror.
Test 395->402 (+7 authz setter). Build sạch BE + 2 FE. Workflow wf_aafb18fe-d9d
(BE->FE->test + 3-lane review: math PASS, authz PASS, mirror self-gated identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 16:06:59 +07:00
parent f1b10e9c33
commit 9b1ee0c3a5
14 changed files with 7121 additions and 397 deletions

View File

@ -1105,50 +1105,8 @@ function VndInlineEdit({
) )
} }
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú). // [Mig 59] BudgetRow (1-cột flex) GỠ — Section B nay là <table> 3 cột (DỰ ÁN|PRO|CCM)
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50. // mirror Section A. Dòng/ô dùng BudgetSharedNumCell / BudgetColValue / BudgetColCompareCell.
function BudgetRow({
label, sub, value, third, tone, danger, mono = true, indent = false,
}: {
label: React.ReactNode
sub?: React.ReactNode
value: React.ReactNode
third?: React.ReactNode
tone?: 'brand' | 'brand-soft' | 'blue-soft'
danger?: boolean
mono?: boolean
/** [S77 Tra Sol] mục con (không có số) → thụt dòng + gạch đầu dòng phân biệt với mục cha có số. */
indent?: boolean
}) {
const toneCls =
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
: tone === 'blue-soft' ? 'bg-blue-50'
: ''
return (
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
<div className={cn('min-w-0 flex-1', indent && 'pl-5')}>
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>
{indent && <span className="mr-1 text-slate-400"></span>}{label}
</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div>
</div>
)
}
// Block tiêu đề (A / B) // Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) { function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
@ -1252,6 +1210,50 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave
) )
} }
// [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM
// (dòng 1/2/6 không tách cột). value null → "Chưa chọn".
function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) {
return (
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
{value == null
? <span className="font-sans text-slate-400">Chưa chọn</span>
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
</td>
)
}
// [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—".
// signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc <td> sẵn
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> editable dòng 3).
function BudgetColValue({ value, pct, signed = false, asCell = false }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
}) {
const body = value == null
? <span className="text-slate-300"></span>
: (
<>
<span className={cn('font-mono text-[13px] tabular-nums', value < 0 ? 'font-semibold text-red-600' : 'text-slate-900')}>
{signed ? fmtVndSigned(value) : fmtVnd(value)}
</span>
{pct && <div className="text-[10px] text-slate-400">{pct}</div>}
</>
)
return asCell
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
: <>{body}</>
}
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
// fmtCompareValue); âm → đỏ. pct dòng phụ. Luôn bọc <td>.
function BudgetColCompareCell({ value, base, pct }: { value: number; base: number; pct: string | null }) {
return (
<td className={cn('border border-slate-300 px-2 py-1.5 text-right align-top font-mono tabular-nums', value < 0 && 'font-semibold text-red-600')}>
{fmtCompareValue(value, base)}
{pct && <div className="text-[10px] font-sans font-normal text-slate-400">{pct}</div>}
</td>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()
const bs = ev.budgetSummary const bs = ev.budgetSummary
@ -1292,6 +1294,15 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
onSuccess: () => { toast.success('Đã lưu'); invalidate() }, onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// [Mig 59 anh Kiệt FDC / CCM] PATCH /budget/ccm-period — "NS kỳ này" cột CCM (Section B
// tách 3 cột). Role-gate Admin|CostControl (BE Forbidden nếu khác). Absolute-set (null=
// clear). Gate FE qua bs.canEditCcm (BE-computed capability, mirror ô NS Block A).
const ccmPeriodMut = useMutation({
mutationFn: async (body: { ccmBudgetPeriodAmount: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget/ccm-period`, body),
onSuccess: () => { toast.success('Đã lưu NS kỳ này (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit) // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '') const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
@ -1309,6 +1320,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount) const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount]) useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// [Mig 59] Live-recompute cột CCM: giữ "NS kỳ này (CCM)" đang gõ → row5/7/9 + So sánh
// cột CCM nhảy ngay. Default ← bs.initialAmount (NS Ban hành lần đầu CCM) khi chưa nhập.
const [draftCcmRow3, setDraftCcmRow3] = useState<number | null>(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null)
useEffect(() => { setDraftCcmRow3(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null) }, [ev.ccmBudgetPeriodAmount, bs?.initialAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
@ -1320,30 +1335,46 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
} }
// ===== Số liệu Excel ===== // ===== Số liệu Excel =====
const full = bs.fullAmount // [Mig 59] Bỏ `full` đơn-cột (bs.fullAmount cũ) — Section A+B nay tách 3 cột, mỗi cột
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó. // dùng full RIÊNG = ban hành + hiệu chỉnh của cột đó (proFull / ccmFull dưới).
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0) const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0) const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—". // Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
// ===== Số THỰC-TẾ dùng CHUNG mọi cột (KHÔNG per-cột) =====
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn) const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4) const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4) // [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
const cmp56 = row5 - row6 // So với NS (row5 row6) // bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9) // full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
const colCalc = (periodAmount: number | null, colFull: number, remaining: number | null) => {
const r3 = periodAmount ?? 0
const r5 = row1 + r3 // Lũy kế NS đã dùng (= 1 + 3 cột)
const r7 = colFull - r5 // NS còn lại (= full cột 5 cột)
const r8 = remaining ?? r7 // Giá trị TH dự kiến còn lại (default = 7 cột)
const r9 = row4 + r8 // Giá trị tổng TH dự kiến (= 4 + 8 cột)
return {
r3, r5, r7, r8, r9,
cmpPeriod: r3 - row4, // So với NS kỳ này (= 3 cột 4)
cmp56: r5 - row6, // So với NS (= 5 cột 6)
cmpFull: colFull - r9, // So với NS full (= full cột 9 cột)
}
}
// Cột PRO: kỳ này = budgetPeriodAmount (live draftRow3) · full = proFull · row8 editable
// (draftRow8 ↔ expectedRemainingAmount, drafter). Cột CCM: kỳ này = ccmBudgetPeriodAmount
// (live draftCcmRow3) · full = ccmFull · row8 = default (chưa có field CCM-remaining riêng).
const pro = colCalc(draftRow3, proFull, draftRow8)
const ccm = colCalc(draftCcmRow3, ccmFull, null)
// Cờ tô màu cảnh báo // Cờ tô màu cảnh báo (cảnh báo MỀM, dựa cột PRO — luồng drafter chính). Hiện banner khi
// PRO HOẶC CCM vượt (đề xuất > NS kỳ này, hoặc tổng TH > NS full).
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7 const remainingOver = draftRow8 != null && draftRow8 > pro.r7
const anyOver = pro.cmpPeriod < 0 || pro.cmpFull < 0 || ccm.cmpPeriod < 0 || ccm.cmpFull < 0
return ( return (
<div className="overflow-hidden rounded-lg border border-slate-300"> <div className="overflow-hidden rounded-lg border border-slate-300">
@ -1353,8 +1384,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo. {/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0). Số dư còn lại âm vẫn lưu + gửi duyệt được. */} (cmpFull<0) ở BẤT KỲ cột PRO/CCM. Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{(cmpPeriod < 0 || cmpFull < 0) && ( {anyOver && (
<div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
<span className="shrink-0 text-sm leading-none"></span> <span className="shrink-0 text-sm leading-none"></span>
<span> <span>
@ -1469,117 +1500,146 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
</table> </table>
</div> </div>
{/* ===== Block B — THỰC HIỆN ===== */} {/* ===== Block B — THỰC HIỆN: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[Mig 59 anh Kiệt FDC / CCM] Mirror style Block A. Mỗi cột tính ĐỘC-LẬP theo "NS kỳ
này" + "full" của cột đó (pro/ccm = colCalc). Số thực-tế (1/2/4/6) dùng CHUNG mọi
cột. "NS kỳ này": PRO editable drafter (VndInlineEdit + /budget-adjust hiện có) ·
CCM editable canEditCcm (VndInlineEdit + PATCH /budget/ccm-period) · DỰ ÁN "—". */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader> <BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */} <div className="overflow-x-auto">
<BudgetRow <table className="w-full border-collapse text-[13px]">
label="1. Ngân sách trình duyệt trước" <thead>
value={bs.previousSubmittedCount === 0 <tr className="bg-slate-100 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
? <span className="font-sans text-slate-400">Chưa chọn</span> <th className="border border-slate-300 px-3 py-1.5 text-left">Khoản mục</th>
: fmtVnd(row1)} <th className="w-20 border border-slate-300 px-2 py-1.5 text-center">Dự án</th>
/> <th className="w-44 border border-slate-300 px-2 py-1.5 text-center">PRO</th>
<th className="w-44 border border-slate-300 px-2 py-1.5 text-center">CCM</th>
{/* 2 — Kỳ trước đã chọn thầu */} </tr>
<BudgetRow </thead>
label="2. Kỳ trước đã chọn thầu" <tbody>
value={bs.previousSelectedCount === 0 {/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
? <span className="font-sans text-slate-400">Chưa chọn</span> <tr>
: fmtVnd(row2)} <td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
/> <BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
</tr>
{/* 3Ngân sách - kỳ này (drafter editable) + % /full */} {/* 2Kỳ trước đã chọn thầu (CHUNG) */}
<BudgetRow <tr>
label="3. Ngân sách - kỳ này" <td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
value={ <BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
drafterEditable ? ( </tr>
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
3. Ngân sách - kỳ này
<div className="text-[10px] text-slate-400">% so với NS full của cột</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
{/* PRO — drafter giữ luồng /budget-adjust hiện có */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/> />
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span> ) : (
} <BudgetColValue value={ev.budgetPeriodAmount != null ? pro.r3 : null} pct={fmtPct(pro.r3, proFull)} />
third={fmtPct(row3, full) ?? undefined} )}
{drafterEditable && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(pro.r3, proFull) ?? ''}</div>}
</td>
{/* CCM — endpoint MỚI /budget/ccm-period, gate canEditCcm */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{bs.canEditCcm ? (
<VndInlineEdit
initial={ev.ccmBudgetPeriodAmount ?? bs.initialAmount}
saving={ccmPeriodMut.isPending || peFetching}
label="Ngân sách kỳ này (CCM)"
onLiveChange={setDraftCcmRow3}
onSave={v => ccmPeriodMut.mutate({ ccmBudgetPeriodAmount: v })}
/> />
) : (
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */} <BudgetColValue value={ev.ccmBudgetPeriodAmount != null ? ccm.r3 : null} pct={fmtPct(ccm.r3, ccmFull)} />
<BudgetRow )}
tone="blue-soft" {bs.canEditCcm && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(ccm.r3, ccmFull) ?? ''}</div>}
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" </td>
value={ </tr>
<span className="font-sans text-slate-700"> {/* 4 — Đề xuất kỳ này (CHUNG): NCC + giá trị + so sánh per-cột */}
<tr className="bg-blue-50">
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">4. Đ xuất kỳ này Tên thầu phụ / NCC</td>
<td className="border border-slate-300 px-2 py-1.5 text-left font-sans text-slate-700" colSpan={3}>
{ev.suppliers.some(s => s.isWinner) {ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') ? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>} : <span className="text-slate-400"> (chưa chọn)</span>}
</span> </td>
} </tr>
/> <tr className="bg-blue-50">
<BudgetRow <td className="border border-slate-300 px-3 py-1.5 pl-6 text-slate-700"><span className="mr-1 text-slate-400"></span>Giá trị kỳ này (CHUNG)</td>
tone="blue-soft" <td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums" colSpan={3}>
label="Giá trị kỳ này" {proposalOver
indent ? <span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
value={ : fmtVnd(row4)}
proposalOver ? ( </td>
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span> </tr>
) : fmtVnd(row4) <tr className="bg-blue-50">
} <td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
/> <span className="mr-1 text-slate-400"></span>So sánh với ngân sách kỳ này
<BudgetRow <div className="text-[10px] text-slate-400">= 3 4</div>
tone="blue-soft" </td>
label="So sánh với ngân sách kỳ này" <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
indent <BudgetColCompareCell value={pro.cmpPeriod} base={pro.r3} pct={fmtPct(pro.cmpPeriod, pro.r3)} />
sub="= 3 4" <BudgetColCompareCell value={ccm.cmpPeriod} base={ccm.r3} pct={fmtPct(ccm.cmpPeriod, ccm.r3)} />
value={fmtCompareValue(cmpPeriod, row3)} </tr>
third={fmtPct(cmpPeriod, row3) ?? undefined} {/* 5 — Lũy kế ngân sách đã sử dụng (PER-cột = 1 + 3) */}
danger={cmpPeriod < 0} <tr>
/> <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
5. Lũy kế ngân sách đã sử dụng
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */} <div className="text-[10px] text-slate-400">= 1 + 3</div>
<BudgetRow </td>
label="5. Lũy kế ngân sách đã sử dụng" <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
sub="= 1 + 3" <BudgetColValue asCell value={pro.r5} />
value={fmtVnd(row5)} <BudgetColValue asCell value={ccm.r5} />
/> </tr>
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */} <tr>
<BudgetRow <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
label="6. Lũy kế thực hiện" 6. Lũy kế thực hiện
sub="= 2 + 4" <div className="text-[10px] text-slate-400">= 2 + 4</div>
value={fmtVnd(row6)} </td>
/> <BudgetSharedNumCell colSpan={3} value={row6} />
<BudgetRow </tr>
label="So với NS" <tr>
indent <td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
sub="= 5 6" <span className="mr-1 text-slate-400"></span>So với NS
value={fmtCompareValue(cmp56, row5)} <div className="text-[10px] text-slate-400">= 5 6</div>
third={fmtPct(cmp56, row5) ?? undefined} </td>
danger={cmp56 < 0} <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
/> <BudgetColCompareCell value={pro.cmp56} base={pro.r5} pct={fmtPct(pro.cmp56, pro.r5)} />
<BudgetColCompareCell value={ccm.cmp56} base={ccm.r5} pct={fmtPct(ccm.cmp56, ccm.r5)} />
{/* 7 — Ngân sách còn lại (= full 5) + % /full */} </tr>
<BudgetRow {/* 7 — Ngân sách còn lại (PER-cột = full 5) + % /full cột */}
label="7. Ngân sách còn lại" <tr>
sub="= Ngân sách full 5" <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
value={<span className={cn(row7 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(row7)}</span>} 7. Ngân sách còn lại
third={fmtPct(row7, full) ?? undefined} <div className="text-[10px] text-slate-400">= NS full 5</div>
/> </td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */} <BudgetColValue asCell signed value={pro.r7} pct={fmtPct(pro.r7, proFull)} />
<div className={cn( <BudgetColValue asCell signed value={ccm.r7} pct={fmtPct(ccm.r7, ccmFull)} />
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', </tr>
remainingOver && 'bg-red-50', {/* 8 — Giá trị thực hiện dự kiến còn lại — PRO editable (drafter); CCM default */}
)}> <tr className={cn(remainingOver && 'bg-red-50')}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}> <td className={cn('border border-slate-300 px-3 py-1.5 align-top', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại 8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div> <div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div> </td>
<div className="w-48 shrink-0 text-right"> <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount ?? row7} initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại" label="Giá trị thực hiện dự kiến còn lại"
@ -1587,30 +1647,39 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/> />
) : ( ) : (
<span className={cn('font-mono tabular-nums', row8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}> <span className={cn('font-mono text-[13px] tabular-nums', pro.r8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVndSigned(row8)} {fmtVndSigned(pro.r8)}
</span> </span>
)} )}
</td>
<BudgetColValue asCell signed value={ccm.r8} />
</tr>
{/* 9 — Giá trị tổng thực hiện dự kiến (PER-cột = 4 + 8) — brand đậm */}
<tr className="bg-[#1F7DC1] font-semibold text-white">
<td className="border border-slate-300 px-3 py-1.5 align-top">
9. Giá trị tổng thực hiện dự kiến
<div className="text-[10px] text-white/70">= 4 + 8</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-white/60"></td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(pro.r9 < 0 && 'text-red-200')}>{fmtVndSigned(pro.r9)}</span>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(ccm.r9 < 0 && 'text-red-200')}>{fmtVndSigned(ccm.r9)}</span>
</td>
</tr>
<tr className="bg-[#1F7DC1]/10">
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
<span className="mr-1 text-slate-400"></span>So sánh với Ngân sách full
<div className="text-[10px] text-slate-400">= NS full 9</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColCompareCell value={pro.cmpFull} base={proFull} pct={fmtPct(pro.cmpFull, proFull)} />
<BudgetColCompareCell value={ccm.cmpFull} base={ccmFull} pct={fmtPct(ccm.cmpFull, ccmFull)} />
</tr>
</tbody>
</table>
</div> </div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={<span className={cn(row9 < 0 && 'font-bold text-red-600')}>{fmtVndSigned(row9)}</span>}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div> </div>
) )
} }

View File

@ -445,7 +445,11 @@ export type PeDetailBundle = {
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
// S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP): // S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột PRO — drafter nhập
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] 'NS kỳ này' cột CCM (Section B THỰC HIỆN
// tách 3 cột Dự án|PRO|CCM). PRO=budgetPeriodAmount sẵn có; CCM=cột này độc-lập.
// Nhập qua PATCH /budget/ccm-period role-gate Admin|CostControl (absolute-set, 0 hợp lệ).
ccmBudgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột CCM
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute) budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role. // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.

View File

@ -1105,50 +1105,8 @@ function VndInlineEdit({
) )
} }
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú). // [Mig 59] BudgetRow (1-cột flex) GỠ — Section B nay là <table> 3 cột (DỰ ÁN|PRO|CCM)
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50. // mirror Section A. Dòng/ô dùng BudgetSharedNumCell / BudgetColValue / BudgetColCompareCell.
function BudgetRow({
label, sub, value, third, tone, danger, mono = true, indent = false,
}: {
label: React.ReactNode
sub?: React.ReactNode
value: React.ReactNode
third?: React.ReactNode
tone?: 'brand' | 'brand-soft' | 'blue-soft'
danger?: boolean
mono?: boolean
/** [S77 Tra Sol] mục con (không có số) → thụt dòng + gạch đầu dòng phân biệt với mục cha có số. */
indent?: boolean
}) {
const toneCls =
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
: tone === 'blue-soft' ? 'bg-blue-50'
: ''
return (
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
<div className={cn('min-w-0 flex-1', indent && 'pl-5')}>
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>
{indent && <span className="mr-1 text-slate-400"></span>}{label}
</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div>
</div>
)
}
// Block tiêu đề (A / B) // Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) { function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
@ -1252,6 +1210,50 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave
) )
} }
// [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM
// (dòng 1/2/6 không tách cột). value null → "Chưa chọn".
function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) {
return (
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
{value == null
? <span className="font-sans text-slate-400">Chưa chọn</span>
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
</td>
)
}
// [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—".
// signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc <td> sẵn
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> editable dòng 3).
function BudgetColValue({ value, pct, signed = false, asCell = false }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
}) {
const body = value == null
? <span className="text-slate-300"></span>
: (
<>
<span className={cn('font-mono text-[13px] tabular-nums', value < 0 ? 'font-semibold text-red-600' : 'text-slate-900')}>
{signed ? fmtVndSigned(value) : fmtVnd(value)}
</span>
{pct && <div className="text-[10px] text-slate-400">{pct}</div>}
</>
)
return asCell
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
: <>{body}</>
}
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
// fmtCompareValue); âm → đỏ. pct dòng phụ. Luôn bọc <td>.
function BudgetColCompareCell({ value, base, pct }: { value: number; base: number; pct: string | null }) {
return (
<td className={cn('border border-slate-300 px-2 py-1.5 text-right align-top font-mono tabular-nums', value < 0 && 'font-semibold text-red-600')}>
{fmtCompareValue(value, base)}
{pct && <div className="text-[10px] font-sans font-normal text-slate-400">{pct}</div>}
</td>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()
const bs = ev.budgetSummary const bs = ev.budgetSummary
@ -1292,6 +1294,15 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
onSuccess: () => { toast.success('Đã lưu'); invalidate() }, onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
// [Mig 59 anh Kiệt FDC / CCM] PATCH /budget/ccm-period — "NS kỳ này" cột CCM (Section B
// tách 3 cột). Role-gate Admin|CostControl (BE Forbidden nếu khác). Absolute-set (null=
// clear). Gate FE qua bs.canEditCcm (BE-computed capability, mirror ô NS Block A).
const ccmPeriodMut = useMutation({
mutationFn: async (body: { ccmBudgetPeriodAmount: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget/ccm-period`, body),
onSuccess: () => { toast.success('Đã lưu NS kỳ này (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit) // proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '') const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
@ -1309,6 +1320,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount) const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount]) useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// [Mig 59] Live-recompute cột CCM: giữ "NS kỳ này (CCM)" đang gõ → row5/7/9 + So sánh
// cột CCM nhảy ngay. Default ← bs.initialAmount (NS Ban hành lần đầu CCM) khi chưa nhập.
const [draftCcmRow3, setDraftCcmRow3] = useState<number | null>(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null)
useEffect(() => { setDraftCcmRow3(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null) }, [ev.ccmBudgetPeriodAmount, bs?.initialAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
@ -1320,30 +1335,46 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
} }
// ===== Số liệu Excel ===== // ===== Số liệu Excel =====
const full = bs.fullAmount // [Mig 59] Bỏ `full` đơn-cột (bs.fullAmount cũ) — Section A+B nay tách 3 cột, mỗi cột
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó. // dùng full RIÊNG = ban hành + hiệu chỉnh của cột đó (proFull / ccmFull dưới).
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0) const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0) const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—". // Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
// ===== Số THỰC-TẾ dùng CHUNG mọi cột (KHÔNG per-cột) =====
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn) const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4) const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4) // [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
const cmp56 = row5 - row6 // So với NS (row5 row6) // bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9) // full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
const colCalc = (periodAmount: number | null, colFull: number, remaining: number | null) => {
const r3 = periodAmount ?? 0
const r5 = row1 + r3 // Lũy kế NS đã dùng (= 1 + 3 cột)
const r7 = colFull - r5 // NS còn lại (= full cột 5 cột)
const r8 = remaining ?? r7 // Giá trị TH dự kiến còn lại (default = 7 cột)
const r9 = row4 + r8 // Giá trị tổng TH dự kiến (= 4 + 8 cột)
return {
r3, r5, r7, r8, r9,
cmpPeriod: r3 - row4, // So với NS kỳ này (= 3 cột 4)
cmp56: r5 - row6, // So với NS (= 5 cột 6)
cmpFull: colFull - r9, // So với NS full (= full cột 9 cột)
}
}
// Cột PRO: kỳ này = budgetPeriodAmount (live draftRow3) · full = proFull · row8 editable
// (draftRow8 ↔ expectedRemainingAmount, drafter). Cột CCM: kỳ này = ccmBudgetPeriodAmount
// (live draftCcmRow3) · full = ccmFull · row8 = default (chưa có field CCM-remaining riêng).
const pro = colCalc(draftRow3, proFull, draftRow8)
const ccm = colCalc(draftCcmRow3, ccmFull, null)
// Cờ tô màu cảnh báo // Cờ tô màu cảnh báo (cảnh báo MỀM, dựa cột PRO — luồng drafter chính). Hiện banner khi
// PRO HOẶC CCM vượt (đề xuất > NS kỳ này, hoặc tổng TH > NS full).
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7 const remainingOver = draftRow8 != null && draftRow8 > pro.r7
const anyOver = pro.cmpPeriod < 0 || pro.cmpFull < 0 || ccm.cmpPeriod < 0 || ccm.cmpFull < 0
return ( return (
<div className="overflow-hidden rounded-lg border border-slate-300"> <div className="overflow-hidden rounded-lg border border-slate-300">
@ -1353,8 +1384,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo. {/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0). Số dư còn lại âm vẫn lưu + gửi duyệt được. */} (cmpFull<0) ở BẤT KỲ cột PRO/CCM. Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{(cmpPeriod < 0 || cmpFull < 0) && ( {anyOver && (
<div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
<span className="shrink-0 text-sm leading-none"></span> <span className="shrink-0 text-sm leading-none"></span>
<span> <span>
@ -1469,117 +1500,146 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
</table> </table>
</div> </div>
{/* ===== Block B — THỰC HIỆN ===== */} {/* ===== Block B — THỰC HIỆN: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[Mig 59 anh Kiệt FDC / CCM] Mirror style Block A. Mỗi cột tính ĐỘC-LẬP theo "NS kỳ
này" + "full" của cột đó (pro/ccm = colCalc). Số thực-tế (1/2/4/6) dùng CHUNG mọi
cột. "NS kỳ này": PRO editable drafter (VndInlineEdit + /budget-adjust hiện có) ·
CCM editable canEditCcm (VndInlineEdit + PATCH /budget/ccm-period) · DỰ ÁN "—". */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader> <BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */} <div className="overflow-x-auto">
<BudgetRow <table className="w-full border-collapse text-[13px]">
label="1. Ngân sách trình duyệt trước" <thead>
value={bs.previousSubmittedCount === 0 <tr className="bg-slate-100 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
? <span className="font-sans text-slate-400">Chưa chọn</span> <th className="border border-slate-300 px-3 py-1.5 text-left">Khoản mục</th>
: fmtVnd(row1)} <th className="w-20 border border-slate-300 px-2 py-1.5 text-center">Dự án</th>
/> <th className="w-44 border border-slate-300 px-2 py-1.5 text-center">PRO</th>
<th className="w-44 border border-slate-300 px-2 py-1.5 text-center">CCM</th>
{/* 2 — Kỳ trước đã chọn thầu */} </tr>
<BudgetRow </thead>
label="2. Kỳ trước đã chọn thầu" <tbody>
value={bs.previousSelectedCount === 0 {/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
? <span className="font-sans text-slate-400">Chưa chọn</span> <tr>
: fmtVnd(row2)} <td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
/> <BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
</tr>
{/* 3Ngân sách - kỳ này (drafter editable) + % /full */} {/* 2Kỳ trước đã chọn thầu (CHUNG) */}
<BudgetRow <tr>
label="3. Ngân sách - kỳ này" <td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
value={ <BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
drafterEditable ? ( </tr>
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
3. Ngân sách - kỳ này
<div className="text-[10px] text-slate-400">% so với NS full của cột</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
{/* PRO — drafter giữ luồng /budget-adjust hiện có */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/> />
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span> ) : (
} <BudgetColValue value={ev.budgetPeriodAmount != null ? pro.r3 : null} pct={fmtPct(pro.r3, proFull)} />
third={fmtPct(row3, full) ?? undefined} )}
{drafterEditable && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(pro.r3, proFull) ?? ''}</div>}
</td>
{/* CCM — endpoint MỚI /budget/ccm-period, gate canEditCcm */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{bs.canEditCcm ? (
<VndInlineEdit
initial={ev.ccmBudgetPeriodAmount ?? bs.initialAmount}
saving={ccmPeriodMut.isPending || peFetching}
label="Ngân sách kỳ này (CCM)"
onLiveChange={setDraftCcmRow3}
onSave={v => ccmPeriodMut.mutate({ ccmBudgetPeriodAmount: v })}
/> />
) : (
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */} <BudgetColValue value={ev.ccmBudgetPeriodAmount != null ? ccm.r3 : null} pct={fmtPct(ccm.r3, ccmFull)} />
<BudgetRow )}
tone="blue-soft" {bs.canEditCcm && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(ccm.r3, ccmFull) ?? ''}</div>}
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" </td>
value={ </tr>
<span className="font-sans text-slate-700"> {/* 4 — Đề xuất kỳ này (CHUNG): NCC + giá trị + so sánh per-cột */}
<tr className="bg-blue-50">
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">4. Đ xuất kỳ này Tên thầu phụ / NCC</td>
<td className="border border-slate-300 px-2 py-1.5 text-left font-sans text-slate-700" colSpan={3}>
{ev.suppliers.some(s => s.isWinner) {ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') ? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>} : <span className="text-slate-400"> (chưa chọn)</span>}
</span> </td>
} </tr>
/> <tr className="bg-blue-50">
<BudgetRow <td className="border border-slate-300 px-3 py-1.5 pl-6 text-slate-700"><span className="mr-1 text-slate-400"></span>Giá trị kỳ này (CHUNG)</td>
tone="blue-soft" <td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums" colSpan={3}>
label="Giá trị kỳ này" {proposalOver
indent ? <span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
value={ : fmtVnd(row4)}
proposalOver ? ( </td>
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span> </tr>
) : fmtVnd(row4) <tr className="bg-blue-50">
} <td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
/> <span className="mr-1 text-slate-400"></span>So sánh với ngân sách kỳ này
<BudgetRow <div className="text-[10px] text-slate-400">= 3 4</div>
tone="blue-soft" </td>
label="So sánh với ngân sách kỳ này" <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
indent <BudgetColCompareCell value={pro.cmpPeriod} base={pro.r3} pct={fmtPct(pro.cmpPeriod, pro.r3)} />
sub="= 3 4" <BudgetColCompareCell value={ccm.cmpPeriod} base={ccm.r3} pct={fmtPct(ccm.cmpPeriod, ccm.r3)} />
value={fmtCompareValue(cmpPeriod, row3)} </tr>
third={fmtPct(cmpPeriod, row3) ?? undefined} {/* 5 — Lũy kế ngân sách đã sử dụng (PER-cột = 1 + 3) */}
danger={cmpPeriod < 0} <tr>
/> <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
5. Lũy kế ngân sách đã sử dụng
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */} <div className="text-[10px] text-slate-400">= 1 + 3</div>
<BudgetRow </td>
label="5. Lũy kế ngân sách đã sử dụng" <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
sub="= 1 + 3" <BudgetColValue asCell value={pro.r5} />
value={fmtVnd(row5)} <BudgetColValue asCell value={ccm.r5} />
/> </tr>
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */} <tr>
<BudgetRow <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
label="6. Lũy kế thực hiện" 6. Lũy kế thực hiện
sub="= 2 + 4" <div className="text-[10px] text-slate-400">= 2 + 4</div>
value={fmtVnd(row6)} </td>
/> <BudgetSharedNumCell colSpan={3} value={row6} />
<BudgetRow </tr>
label="So với NS" <tr>
indent <td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
sub="= 5 6" <span className="mr-1 text-slate-400"></span>So với NS
value={fmtCompareValue(cmp56, row5)} <div className="text-[10px] text-slate-400">= 5 6</div>
third={fmtPct(cmp56, row5) ?? undefined} </td>
danger={cmp56 < 0} <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
/> <BudgetColCompareCell value={pro.cmp56} base={pro.r5} pct={fmtPct(pro.cmp56, pro.r5)} />
<BudgetColCompareCell value={ccm.cmp56} base={ccm.r5} pct={fmtPct(ccm.cmp56, ccm.r5)} />
{/* 7 — Ngân sách còn lại (= full 5) + % /full */} </tr>
<BudgetRow {/* 7 — Ngân sách còn lại (PER-cột = full 5) + % /full cột */}
label="7. Ngân sách còn lại" <tr>
sub="= Ngân sách full 5" <td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
value={<span className={cn(row7 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(row7)}</span>} 7. Ngân sách còn lại
third={fmtPct(row7, full) ?? undefined} <div className="text-[10px] text-slate-400">= NS full 5</div>
/> </td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */} <BudgetColValue asCell signed value={pro.r7} pct={fmtPct(pro.r7, proFull)} />
<div className={cn( <BudgetColValue asCell signed value={ccm.r7} pct={fmtPct(ccm.r7, ccmFull)} />
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', </tr>
remainingOver && 'bg-red-50', {/* 8 — Giá trị thực hiện dự kiến còn lại — PRO editable (drafter); CCM default */}
)}> <tr className={cn(remainingOver && 'bg-red-50')}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}> <td className={cn('border border-slate-300 px-3 py-1.5 align-top', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại 8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div> <div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div> </td>
<div className="w-48 shrink-0 text-right"> <td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount ?? row7} initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại" label="Giá trị thực hiện dự kiến còn lại"
@ -1587,30 +1647,39 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/> />
) : ( ) : (
<span className={cn('font-mono tabular-nums', row8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}> <span className={cn('font-mono text-[13px] tabular-nums', pro.r8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVndSigned(row8)} {fmtVndSigned(pro.r8)}
</span> </span>
)} )}
</td>
<BudgetColValue asCell signed value={ccm.r8} />
</tr>
{/* 9 — Giá trị tổng thực hiện dự kiến (PER-cột = 4 + 8) — brand đậm */}
<tr className="bg-[#1F7DC1] font-semibold text-white">
<td className="border border-slate-300 px-3 py-1.5 align-top">
9. Giá trị tổng thực hiện dự kiến
<div className="text-[10px] text-white/70">= 4 + 8</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-white/60"></td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(pro.r9 < 0 && 'text-red-200')}>{fmtVndSigned(pro.r9)}</span>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(ccm.r9 < 0 && 'text-red-200')}>{fmtVndSigned(ccm.r9)}</span>
</td>
</tr>
<tr className="bg-[#1F7DC1]/10">
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
<span className="mr-1 text-slate-400"></span>So sánh với Ngân sách full
<div className="text-[10px] text-slate-400">= NS full 9</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColCompareCell value={pro.cmpFull} base={proFull} pct={fmtPct(pro.cmpFull, proFull)} />
<BudgetColCompareCell value={ccm.cmpFull} base={ccmFull} pct={fmtPct(ccm.cmpFull, ccmFull)} />
</tr>
</tbody>
</table>
</div> </div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={<span className={cn(row9 < 0 && 'font-bold text-red-600')}>{fmtVndSigned(row9)}</span>}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div> </div>
) )
} }

View File

@ -447,7 +447,11 @@ export type PeDetailBundle = {
createdAt: string createdAt: string
updatedAt: string | null updatedAt: string | null
// S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP): // S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột PRO — drafter nhập
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] 'NS kỳ này' cột CCM (Section B THỰC HIỆN
// tách 3 cột Dự án|PRO|CCM). PRO=budgetPeriodAmount sẵn có; CCM=cột này độc-lập.
// Nhập qua PATCH /budget/ccm-period role-gate Admin|CostControl (absolute-set, 0 hợp lệ).
ccmBudgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột CCM
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute) budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role. // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.

View File

@ -115,6 +115,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
} }
public record SuggestedPriceCcmBody(decimal? CcmPrice, string? Note); public record SuggestedPriceCcmBody(decimal? CcmPrice, string? Note);
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] "NS kỳ này" cột CCM (Section B THỰC HIỆN
// tách 3 cột Dự án|PRO|CCM). PRO dùng /budget-adjust sẵn có; CCM nhập cột riêng.
// Class [Authorize] any-auth; handler fine-grained Forbidden (CCM=CostControl | Admin).
// Absolute-set (null=clear). Budget=0 hợp lệ.
[HttpPatch("{id:guid}/budget/ccm-period")]
public async Task<IActionResult> SetCcmBudgetPeriod(Guid id, [FromBody] CcmBudgetPeriodBody body, CancellationToken ct)
{
await mediator.Send(new SetPeCcmBudgetPeriodCommand(id, body.CcmBudgetPeriodAmount), ct);
return NoContent();
}
public record CcmBudgetPeriodBody(decimal? CcmBudgetPeriodAmount);
[HttpPost("{id:guid}/transitions")] [HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
{ {

View File

@ -239,6 +239,10 @@ public record PurchaseEvaluationDetailBundleDto(
// (module Budget XÓA HẲN, data BudgetManualAmount migrate → BudgetPeriodAmount). // (module Budget XÓA HẲN, data BudgetManualAmount migrate → BudgetPeriodAmount).
decimal? BudgetPeriodAmount, decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount, decimal? ExpectedRemainingAmount,
// [Mig 59 2026-06-24] "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột) —
// PRO=BudgetPeriodAmount sẵn có, CCM=cột này độc-lập. Nhập qua PATCH
// /budget/ccm-period role-gate Admin|CostControl.
decimal? CcmBudgetPeriodAmount,
PeBudgetSummaryDto? BudgetSummary, PeBudgetSummaryDto? BudgetSummary,
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle. // [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro, bool IsUrgentByPro,

View File

@ -141,3 +141,59 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
public record SetPeCcmBudgetPeriodCommand(
Guid Id,
decimal? CcmBudgetPeriodAmount) : IRequest;
public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator<SetPeCcmBudgetPeriodCommand>
{
public SetPeCcmBudgetPeriodCommandValidator()
{
RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
.When(x => x.CcmBudgetPeriodAmount.HasValue);
}
}
public class SetPeCcmBudgetPeriodCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<SetPeCcmBudgetPeriodCommand>
{
public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
// Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
{
throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập NS kỳ này (CCM).");
}
var oldVal = pe.CcmBudgetPeriodAmount;
pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // absolute-set (null = clear)
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -1107,7 +1107,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.SelectedSupplierId, selectedSupplier?.Name, e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId, e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary, e.BudgetPeriodAmount, e.ExpectedRemainingAmount, e.CcmBudgetPeriodAmount, peBudgetSummary,
e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO
e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice, // [Mig 54] giá đề xuất PRO/CCM e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice, // [Mig 54] giá đề xuất PRO/CCM
e.ProSuggestedPriceNote, e.CcmSuggestedPriceNote, // [Mig 57] ghi chú giải thích giá e.ProSuggestedPriceNote, e.CcmSuggestedPriceNote, // [Mig 57] ghi chú giải thích giá

View File

@ -40,6 +40,14 @@ public class PurchaseEvaluation : AuditableEntity
public decimal? BudgetPeriodAmount { get; set; } public decimal? BudgetPeriodAmount { get; set; }
public decimal? ExpectedRemainingAmount { get; set; } public decimal? ExpectedRemainingAmount { get; set; }
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] "NS kỳ này" của CỘT CCM (Section B
// THỰC HIỆN tách 3 cột Dự án|PRO|CCM). PRO dùng BudgetPeriodAmount sẵn có; CCM
// nhập cột riêng độc-lập, đối chiếu full-CCM = (InitialAmount+AdjustmentAmount)
// ở PeWorkItemBudget. Nhập qua endpoint riêng role-gate Admin|CostControl,
// absolute-set (null=clear). Budget=0 hợp lệ. KHÔNG submit-guard (guard chỉ trên
// BudgetPeriodAmount của PRO).
public decimal? CcmBudgetPeriodAmount { get; set; }
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter // Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự. // sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; } public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }

View File

@ -24,6 +24,9 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
// [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2). // [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2).
b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2); b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2);
b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2); b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2);
// [Mig 59 2026-06-24] "NS kỳ này" cột CCM (Section B 3 cột) — decimal(18,2)
// nullable, mirror BudgetPeriodAmount. Nhập role-gate Admin|CostControl.
b.Property(x => x.CcmBudgetPeriodAmount).HasColumnType("decimal(18,2)");
// [Mig 54 2026-06-18] Giá đề xuất PRO/CCM + giá chốt khi duyệt — precision (18,2) // [Mig 54 2026-06-18] Giá đề xuất PRO/CCM + giá chốt khi duyệt — precision (18,2)
// mirror các cột tiền khác. ApprovedPriceSource nvarchar(20): Ncc/ProMin/ProMax/Ccm. // mirror các cột tiền khác. ApprovedPriceSource nvarchar(20): Ncc/ProMin/ProMax/Ccm.
b.Property(x => x.ProSuggestedMinPrice).HasPrecision(18, 2); b.Property(x => x.ProSuggestedMinPrice).HasPrecision(18, 2);

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCcmBudgetPeriodToPurchaseEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CcmBudgetPeriodAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CcmBudgetPeriodAmount",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4611,6 +4611,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmBudgetPeriodAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmSuggestedPrice") b.Property<decimal?>("CcmSuggestedPrice")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");

View File

@ -0,0 +1,192 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// ===== NEW (Mig 59 2026-06-24 anh Kiệt FDC / CCM) — setter "NS kỳ này" cột CCM =====
// Section B (THỰC HIỆN) tách 3 cột Dự án|PRO|CCM. PRO dùng /budget-adjust sẵn có
// (BudgetPeriodAmount); CCM nhập "NS kỳ này" RIÊNG ở cột mình → PeSuggestedPriceFeatures.cs
// SetPeCcmBudgetPeriodCommand(+Validator+Handler). Test theo CODE đã land (S34 rule —
// KHÔNG touch production). Mirror harness PeSuggestedPriceSetterAuthzTests (handler 2-dep
// nhẹ db+ICurrentUser instantiate trực tiếp + FakeCurrentUser(params roles) + SeedPeAsync).
//
// Authz (mirror UpdatePeSuggestedPriceCcm / UpdatePeBudgetCcm S61):
// - role CostControl (CCM) HOẶC Admin set CcmBudgetPeriodAmount; role khác →
// ForbiddenException ("*CCM*") fail-closed TRƯỚC mọi side-effect (guard sau NotFound).
// - Absolute-set (null = clear). Budget=0 hợp lệ (GreaterThanOrEqualTo(0).When(HasValue)).
// - KHÔNG submit-guard (guard chỉ trên BudgetPeriodAmount của PRO — gotcha #73 không đụng).
//
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
// line 172-173 rồi 176-181). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
// Changelog.UserId = Guid? KHÔNG FK User (chỉ FK PE Cascade) → FakeCurrentUser random Guid an toàn.
public class PeCcmBudgetPeriodSetterAuthzTests
{
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
{
public Guid? UserId { get; } = Guid.NewGuid();
public string? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, string code = "PE-CCMB-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Gói thầu test NS kỳ này (CCM)",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// 1a. Admin set được CcmBudgetPeriodAmount (allow-list thứ nhất).
[Fact]
public async Task Admin_SetsCcmBudgetPeriod()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001A");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 250_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(250_000_000m, "Admin được nhập NS kỳ này (CCM)");
}
// 1b. CostControl (CCM) set được CcmBudgetPeriodAmount (allow-list thứ hai).
[Fact]
public async Task CostControl_SetsCcmBudgetPeriod()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001B");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 333_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(333_000_000m, "CCM (CostControl) được nhập NS kỳ này cột mình");
}
// 1c. Role khác (Drafter — phiếu của người khai, KHÔNG có vai CCM) → ForbiddenException
// + KHÔNG set giá (fail-closed: role-gate TRƯỚC side-effect → no partial-write).
[Fact]
public async Task OtherRole_Drafter_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001C");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 1m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("Drafter (không phải CCM/Admin) bị chặn → không set giá");
}
// 1c-bis. Role khác (Procurement = PRO, có cột riêng /budget-adjust) → cũng Forbidden
// (chắc chắn không chỉ Drafter bị chặn — PRO KHÔNG được nhập cột CCM).
[Fact]
public async Task OtherRole_Procurement_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001CP");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 9m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("PRO bị chặn → không set cột CCM (cột riêng độc lập)");
}
// 1d. value = 0 hợp lệ (no throw) — budget=0 cho ccmBudgetPeriodAmount luôn được
// (validator GreaterThanOrEqualTo(0).When(HasValue)). Handler set 0 vào DB.
[Fact]
public async Task ZeroValue_IsValid_AndPersisted()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001D");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 0m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(0m, "budget=0 hợp lệ cho cột CCM (no submit-guard ở đây)");
// Validator cũng xác nhận 0 valid (và âm invalid).
var validator = new SetPeCcmBudgetPeriodCommandValidator();
validator.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, 0m))
.IsValid.Should().BeTrue("0 hợp lệ");
validator.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, -1m))
.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
}
// 1e. Absolute-set null = clear — bắt đầu từ giá CÓ SẴN rồi gửi null → CLEAR
// (chứng minh absolute-set, KHÔNG skip-if-null giữ giá trị cũ — gotcha #73).
[Fact]
public async Task NullValue_ClearsExistingAmount_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001E");
// Pre-seed giá có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
pe.CcmBudgetPeriodAmount = 500_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, null), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("absolute-set: null CLEAR field, KHÔNG giữ giá cũ");
// Validator: null hợp lệ (clear).
new SetPeCcmBudgetPeriodCommandValidator()
.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, null))
.IsValid.Should().BeTrue("null (clear) hợp lệ");
}
// 2. Unknown PE → NotFound (existence check TRƯỚC authz gate — line 172-173 < 176).
[Fact]
public async Task UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(Guid.NewGuid(), 1m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}