[CLAUDE] PurchaseEvaluation: Mig 56 ngan sach MA TRAN 3 cot (Du an|PRO|CCM) + badge quyen NS theo role
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s
S76 (anh Kiet FDC + chi Tra Sol) — form ngan sach + hien thi quyen nhap NS trong flow: - Part 1: form ngan sach -> MA TRAN 3 cot, moi phong nhap+dieu chinh cot minh (PRO canEditPro / CCM canEditCcm / Du an FE hien-thi-only). Mig 56 +ProInitialAmount/ProAdjustmentAmount (additive-nullable + data-migrate ProEstimate->ProInitial). full moi cot = ban hanh + hieu chinh. - Part 2: Workflow Designer (fe-admin) +badge "NS PRO/CCM" canh approver (suy tu role Admin|Procurement / Admin|CostControl, hien-thi-only no-authz). - Part 3: flow quy trinh fe-user/fe-admin (Duyet NCC) +badge tuong tu. - Fix race mat-du-lieu Part 1 (useIsFetching khoa Luu khi refetch — dong cua-so stale-echo, reviewer Part2/3 bat). - Test 339->344 (+5). 2 workflow review (Part 1 PASS + Part 2/3 PASS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@ -105,3 +105,9 @@ src/Backend/SolutionErp.Api/wwwroot/exports/
|
||||
|
||||
# Sub-agent output dumps (JSON/HTML scratch)
|
||||
tmp/
|
||||
|
||||
# [S76] Guard cwd-misland (feedback_agent_cwd_relative_memory_misland): sub-agent `cd`
|
||||
# vào fe-user/fe-admin chạy npm build rồi ghi MEMORY.md relative-path → stray
|
||||
# `fe-*/.claude/agent-memory/...`. Canonical agent-memory ở ROOT `.claude/` vẫn tracked
|
||||
# (negation `!.claude/**` trên). Pattern AFTER negation → last-match-wins cho path FE-app.
|
||||
fe-*/.claude/
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
@ -1057,9 +1057,106 @@ function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// [S76] Ô tiền compact cho ma trận 3 cột — editable (input + nút Lưu) hoặc display.
|
||||
// allowNegative cho "hiệu chỉnh tăng giảm" (nút ± đảo dấu). onSave nhận number|null.
|
||||
function BudgetCell({ value, editable, allowNegative = false, saving, onSave }: {
|
||||
value: number | null
|
||||
editable: boolean
|
||||
allowNegative?: boolean
|
||||
saving: boolean
|
||||
onSave: (v: number | null) => void
|
||||
}) {
|
||||
const [text, setText] = useState(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
|
||||
const [neg, setNeg] = useState((value ?? 0) < 0)
|
||||
useEffect(() => {
|
||||
setText(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
|
||||
setNeg((value ?? 0) < 0)
|
||||
}, [value])
|
||||
if (!editable) {
|
||||
return value != null
|
||||
? <span className={cn('font-mono text-[13px] tabular-nums', value < 0 && 'text-red-600')}>{fmtVndSigned(value)}</span>
|
||||
: <span className="text-slate-300">—</span>
|
||||
}
|
||||
const parse = (): number | null => {
|
||||
const n = parseVnd(text)
|
||||
if (n === 0 && text.trim() === '') return null
|
||||
return allowNegative && neg ? -n : n
|
||||
}
|
||||
const dirty = parse() !== value
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{allowNegative && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeg(v => !v)}
|
||||
className={cn('h-7 w-7 shrink-0 rounded border text-[11px] 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"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
|
||||
placeholder="0"
|
||||
className="h-7 min-w-0 px-1.5 text-right font-mono text-[12px]"
|
||||
/>
|
||||
<Button onClick={() => onSave(parse())} disabled={!dirty || saving} className="h-7 shrink-0 px-2 text-[11px]">
|
||||
{saving ? '…' : 'Lưu'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// [S76] Dòng ghi chú phòng (Ghi chú từ PRO / từ CCM) — Textarea editable hoặc text display.
|
||||
function BudgetNoteRow({ label, editable, value, setValue, savedValue, saving, onSave }: {
|
||||
label: string
|
||||
editable: boolean
|
||||
value: string
|
||||
setValue: (v: string) => void
|
||||
savedValue: string | null
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">{label}</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{editable ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder="Ghi chú…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSave} disabled={value === (savedValue ?? '') || saving} className="h-6 px-2 text-[11px]">
|
||||
{saving ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{savedValue || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const bs = ev.budgetSummary
|
||||
// [S76] Khoá nút Lưu trong lúc pe-detail đang refetch (sau mỗi save) — đóng cửa-sổ
|
||||
// stale-echo: tránh lưu 1 ô khi bs (server snapshot) chưa cập nhật → đè field anh-em
|
||||
// (vd lưu PRO ban hành xong, lưu PRO hiệu chỉnh ngay sẽ echo bs.proInitial CŨ).
|
||||
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
|
||||
|
||||
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
|
||||
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
|
||||
@ -1070,9 +1167,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
}
|
||||
|
||||
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
|
||||
// PUT /budget/pro — chỉ khi canEditPro. [S76] proInitial + proAdjust + proNote.
|
||||
const proMut = useMutation({
|
||||
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
|
||||
mutationFn: async (body: { proInitialAmount: number | null; proAdjustmentAmount: number | null; proNote: string | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
@ -1112,6 +1209,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
|
||||
// ===== Số liệu Excel =====
|
||||
const full = bs.fullAmount
|
||||
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó.
|
||||
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 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 "—".
|
||||
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
|
||||
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
||||
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 row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
|
||||
@ -1149,132 +1252,97 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
|
||||
{/* ===== Block A — NGÂN SÁCH gói thầu: MA TRẬN 3 cột (DỰ ÁN | PRO | CCM) =====
|
||||
[S76 anh Kiệt FDC] Mỗi phòng nhập+điều chỉnh cột của CHÍNH mình (role-gate):
|
||||
PRO→cột PRO (canEditPro) · CCM→cột CCM (canEditCcm) · DỰ ÁN hiển thị-only
|
||||
(chưa wire BE — sau có người dự án nhập). Full mỗi cột = ban hành + hiệu chỉnh. */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu / gói vật tư)</BudgetBlockHeader>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Ngân sách (full gói thầu)
|
||||
{bs.fullIsEstimate && (
|
||||
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">ngân sách PRO</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
value={fmtVnd(full)}
|
||||
{/* Header 3 cột */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<div className="min-w-0 flex-1" />
|
||||
<div className="w-36 shrink-0 text-center">Dự án</div>
|
||||
<div className="w-36 shrink-0 text-center">PRO</div>
|
||||
<div className="w-36 shrink-0 text-center">CCM</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) per cột = ban hành + hiệu chỉnh — brand-soft */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 bg-[#1F7DC1]/10 px-3 py-2 text-[13px]">
|
||||
<div className="min-w-0 flex-1 font-semibold text-slate-800">Ngân sách (full gói thầu / gói vật tư)</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
|
||||
{proHasData ? fmtVnd(proFull) : <span className="text-slate-300">—</span>}
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
|
||||
{ccmHasData ? fmtVnd(ccmFull) : <span className="text-slate-300">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu: Dự án (—) | PRO (canEditPro) | CCM (canEditCcm) */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ngân sách Ban hành lần đầu</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.proInitialAmount}
|
||||
editable={bs.canEditPro}
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={v => proMut.mutate({ proInitialAmount: v, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: bs.proNote })}
|
||||
/>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách Ban hành lần đầu"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.initialAmount}
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách ban hành lần đầu"
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.initialAmount}
|
||||
editable={bs.canEditCcm}
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
|
||||
/>
|
||||
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.adjustmentAmount}
|
||||
{/* Dòng 3 — V0 / hiệu chỉnh tăng giảm (cho phép ÂM): Dự án (—) | PRO | CCM */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ngân sách V0 / hiệu chỉnh tăng giảm</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.proAdjustmentAmount}
|
||||
editable={bs.canEditPro}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách hiệu chỉnh tăng giảm"
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={v => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: v, proNote: bs.proNote })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.adjustmentAmount}
|
||||
editable={bs.canEditCcm}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
|
||||
/>
|
||||
) : bs.adjustmentAmount != null ? (
|
||||
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
|
||||
) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ghi chú từ CCM (CCM editable — Textarea, mirror Ghi chú từ PRO) — [Mig anh Kiệt FDC] */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ CCM</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditCcm ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={ccmNoteText}
|
||||
onChange={e => setCcmNoteText(e.target.value)}
|
||||
placeholder="Ghi chú ngân sách CCM…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
|
||||
disabled={ccmNoteText === (bs.ccmNote ?? '') || ccmMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{ccmMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.ccmNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách PRO"
|
||||
value={
|
||||
bs.canEditPro ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.proEstimateAmount}
|
||||
saving={proMut.isPending}
|
||||
label="Ngân sách PRO"
|
||||
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
|
||||
/>
|
||||
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditPro ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
{/* Ghi chú từ PRO + từ CCM (cuối block A, đúng thứ tự Excel anh Kiệt) */}
|
||||
<BudgetNoteRow
|
||||
label="Ghi chú từ PRO"
|
||||
editable={bs.canEditPro}
|
||||
value={proNoteText}
|
||||
onChange={e => setProNoteText(e.target.value)}
|
||||
placeholder="Ghi chú dự trù…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
setValue={setProNoteText}
|
||||
savedValue={bs.proNote}
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={() => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: proNoteText || null })}
|
||||
/>
|
||||
<BudgetNoteRow
|
||||
label="Ghi chú từ CCM"
|
||||
editable={bs.canEditCcm}
|
||||
value={ccmNoteText}
|
||||
setValue={setCcmNoteText}
|
||||
savedValue={bs.ccmNote}
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
|
||||
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{proMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.proNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Block B — THỰC HIỆN ===== */}
|
||||
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
|
||||
|
||||
@ -240,8 +240,22 @@ export function PeWorkflowPanel({
|
||||
{lv.status === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||
{lv.status === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{lv.approvers.map(a => a.fullName).join(' / ') || '(chưa cấu hình)'}
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
|
||||
{lv.approvers.length === 0
|
||||
? '(chưa cấu hình)'
|
||||
: lv.approvers.map((a, i) => (
|
||||
<span key={a.userId} className="inline-flex items-center gap-1">
|
||||
{i > 0 && <span className="text-slate-300">/</span>}
|
||||
<span>{a.fullName}</span>
|
||||
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
|
||||
{a.canEditProBudget && (
|
||||
<span className="rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO">✎ NS PRO</span>
|
||||
)}
|
||||
{a.canEditCcmBudget && (
|
||||
<span className="rounded bg-sky-100 px-1 py-0.5 text-[8px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM">✎ NS CCM</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -51,6 +51,9 @@ type LevelDto = {
|
||||
allowApproverEditDetails: boolean
|
||||
allowApproverEditBudget: boolean
|
||||
allowApproverSkipToFinal: boolean
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
|
||||
canEditProBudget: boolean
|
||||
canEditCcmBudget: boolean
|
||||
}
|
||||
type StepDto = {
|
||||
id: string
|
||||
@ -443,13 +446,20 @@ function DefinitionCard({
|
||||
</span>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{group.map(l => (
|
||||
<div key={l.id} className="flex items-center gap-1.5">
|
||||
<div key={l.id} className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-slate-800">
|
||||
{l.approverUserName ?? l.approverUserId}
|
||||
</span>
|
||||
{l.approverEmail && (
|
||||
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
|
||||
)}
|
||||
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
|
||||
{l.canEditProBudget && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO">✎ NS PRO</span>
|
||||
)}
|
||||
{l.canEditCcmBudget && (
|
||||
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM">✎ NS CCM</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -231,6 +231,9 @@ export type PeCurrentApprovalLevelApprover = {
|
||||
userId: string
|
||||
fullName: string
|
||||
email: string | null
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
|
||||
canEditProBudget: boolean
|
||||
canEditCcmBudget: boolean
|
||||
}
|
||||
|
||||
export type PeCurrentApproval = {
|
||||
@ -281,19 +284,21 @@ export type PeChangelog = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
|
||||
// BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
|
||||
// cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
|
||||
// canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
|
||||
// capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
|
||||
// gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
|
||||
// S61 · [S76] Ngân sách gói thầu MA TRẬN 3 cột (DỰ ÁN | PRO | CCM) — PeWorkItemBudgets
|
||||
// 1 record/cặp Dự án × Hạng mục. BE compute + trả kèm PE detail GET. Mỗi cột full =
|
||||
// ban hành + hiệu chỉnh. fullAmount (authoritative Block B) = CCM nếu CCM nhập, else
|
||||
// PRO (proInitial+proAdjust) + fullIsEstimate=true (badge "ngân sách PRO"). canEditPro
|
||||
// = Procurement|Admin · canEditCcm = CostControl|Admin (BE-computed, FE KHÔNG đoán role).
|
||||
// DỰ ÁN = FE hiển thị-only (chưa wire BE). budgetId=null → phiếu chưa gắn Hạng mục.
|
||||
export type PeBudgetSummary = {
|
||||
budgetId: string | null
|
||||
proEstimateAmount: number | null
|
||||
proEstimateAmount: number | null // [LEGACY ≤S75] FE không dùng — thay bằng proInitial/proAdjust
|
||||
proInitialAmount: number | null // [S76] PRO "Ban hành lần đầu"
|
||||
proAdjustmentAmount: number | null // [S76] PRO "V0/hiệu chỉnh" — cho phép ÂM
|
||||
proNote: string | null
|
||||
initialAmount: number | null
|
||||
initialAmount: number | null // CCM "Ban hành lần đầu"
|
||||
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
|
||||
ccmNote: string | null // [Mig — anh Kiệt FDC] Ghi chú từ CCM (mirror proNote)
|
||||
ccmNote: string | null // CCM "Ghi chú từ CCM"
|
||||
fullAmount: number
|
||||
fullIsEstimate: boolean
|
||||
canEditPro: boolean
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
@ -1057,9 +1057,106 @@ function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// [S76] Ô tiền compact cho ma trận 3 cột — editable (input + nút Lưu) hoặc display.
|
||||
// allowNegative cho "hiệu chỉnh tăng giảm" (nút ± đảo dấu). onSave nhận number|null.
|
||||
function BudgetCell({ value, editable, allowNegative = false, saving, onSave }: {
|
||||
value: number | null
|
||||
editable: boolean
|
||||
allowNegative?: boolean
|
||||
saving: boolean
|
||||
onSave: (v: number | null) => void
|
||||
}) {
|
||||
const [text, setText] = useState(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
|
||||
const [neg, setNeg] = useState((value ?? 0) < 0)
|
||||
useEffect(() => {
|
||||
setText(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
|
||||
setNeg((value ?? 0) < 0)
|
||||
}, [value])
|
||||
if (!editable) {
|
||||
return value != null
|
||||
? <span className={cn('font-mono text-[13px] tabular-nums', value < 0 && 'text-red-600')}>{fmtVndSigned(value)}</span>
|
||||
: <span className="text-slate-300">—</span>
|
||||
}
|
||||
const parse = (): number | null => {
|
||||
const n = parseVnd(text)
|
||||
if (n === 0 && text.trim() === '') return null
|
||||
return allowNegative && neg ? -n : n
|
||||
}
|
||||
const dirty = parse() !== value
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{allowNegative && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeg(v => !v)}
|
||||
className={cn('h-7 w-7 shrink-0 rounded border text-[11px] 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"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
|
||||
placeholder="0"
|
||||
className="h-7 min-w-0 px-1.5 text-right font-mono text-[12px]"
|
||||
/>
|
||||
<Button onClick={() => onSave(parse())} disabled={!dirty || saving} className="h-7 shrink-0 px-2 text-[11px]">
|
||||
{saving ? '…' : 'Lưu'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// [S76] Dòng ghi chú phòng (Ghi chú từ PRO / từ CCM) — Textarea editable hoặc text display.
|
||||
function BudgetNoteRow({ label, editable, value, setValue, savedValue, saving, onSave }: {
|
||||
label: string
|
||||
editable: boolean
|
||||
value: string
|
||||
setValue: (v: string) => void
|
||||
savedValue: string | null
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">{label}</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{editable ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder="Ghi chú…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSave} disabled={value === (savedValue ?? '') || saving} className="h-6 px-2 text-[11px]">
|
||||
{saving ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{savedValue || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const bs = ev.budgetSummary
|
||||
// [S76] Khoá nút Lưu trong lúc pe-detail đang refetch (sau mỗi save) — đóng cửa-sổ
|
||||
// stale-echo: tránh lưu 1 ô khi bs (server snapshot) chưa cập nhật → đè field anh-em
|
||||
// (vd lưu PRO ban hành xong, lưu PRO hiệu chỉnh ngay sẽ echo bs.proInitial CŨ).
|
||||
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
|
||||
|
||||
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
|
||||
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
|
||||
@ -1070,9 +1167,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
}
|
||||
|
||||
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
|
||||
// PUT /budget/pro — chỉ khi canEditPro. [S76] proInitial + proAdjust + proNote.
|
||||
const proMut = useMutation({
|
||||
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
|
||||
mutationFn: async (body: { proInitialAmount: number | null; proAdjustmentAmount: number | null; proNote: string | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
@ -1112,6 +1209,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
|
||||
// ===== Số liệu Excel =====
|
||||
const full = bs.fullAmount
|
||||
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó.
|
||||
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 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 "—".
|
||||
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
|
||||
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
||||
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 row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
|
||||
@ -1149,132 +1252,97 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
|
||||
{/* ===== Block A — NGÂN SÁCH gói thầu: MA TRẬN 3 cột (DỰ ÁN | PRO | CCM) =====
|
||||
[S76 anh Kiệt FDC] Mỗi phòng nhập+điều chỉnh cột của CHÍNH mình (role-gate):
|
||||
PRO→cột PRO (canEditPro) · CCM→cột CCM (canEditCcm) · DỰ ÁN hiển thị-only
|
||||
(chưa wire BE — sau có người dự án nhập). Full mỗi cột = ban hành + hiệu chỉnh. */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu / gói vật tư)</BudgetBlockHeader>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Ngân sách (full gói thầu)
|
||||
{bs.fullIsEstimate && (
|
||||
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">ngân sách PRO</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
value={fmtVnd(full)}
|
||||
{/* Header 3 cột */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<div className="min-w-0 flex-1" />
|
||||
<div className="w-36 shrink-0 text-center">Dự án</div>
|
||||
<div className="w-36 shrink-0 text-center">PRO</div>
|
||||
<div className="w-36 shrink-0 text-center">CCM</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) per cột = ban hành + hiệu chỉnh — brand-soft */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 bg-[#1F7DC1]/10 px-3 py-2 text-[13px]">
|
||||
<div className="min-w-0 flex-1 font-semibold text-slate-800">Ngân sách (full gói thầu / gói vật tư)</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
|
||||
{proHasData ? fmtVnd(proFull) : <span className="text-slate-300">—</span>}
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
|
||||
{ccmHasData ? fmtVnd(ccmFull) : <span className="text-slate-300">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu: Dự án (—) | PRO (canEditPro) | CCM (canEditCcm) */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ngân sách Ban hành lần đầu</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.proInitialAmount}
|
||||
editable={bs.canEditPro}
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={v => proMut.mutate({ proInitialAmount: v, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: bs.proNote })}
|
||||
/>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách Ban hành lần đầu"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.initialAmount}
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách ban hành lần đầu"
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.initialAmount}
|
||||
editable={bs.canEditCcm}
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
|
||||
/>
|
||||
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.adjustmentAmount}
|
||||
{/* Dòng 3 — V0 / hiệu chỉnh tăng giảm (cho phép ÂM): Dự án (—) | PRO | CCM */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ngân sách V0 / hiệu chỉnh tăng giảm</div>
|
||||
<div className="w-36 shrink-0 text-right text-slate-300">—</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.proAdjustmentAmount}
|
||||
editable={bs.canEditPro}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách hiệu chỉnh tăng giảm"
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={v => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: v, proNote: bs.proNote })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<BudgetCell
|
||||
value={bs.adjustmentAmount}
|
||||
editable={bs.canEditCcm}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
|
||||
/>
|
||||
) : bs.adjustmentAmount != null ? (
|
||||
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
|
||||
) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ghi chú từ CCM (CCM editable — Textarea, mirror Ghi chú từ PRO) — [Mig anh Kiệt FDC] */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ CCM</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditCcm ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={ccmNoteText}
|
||||
onChange={e => setCcmNoteText(e.target.value)}
|
||||
placeholder="Ghi chú ngân sách CCM…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
|
||||
disabled={ccmNoteText === (bs.ccmNote ?? '') || ccmMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{ccmMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.ccmNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách PRO"
|
||||
value={
|
||||
bs.canEditPro ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.proEstimateAmount}
|
||||
saving={proMut.isPending}
|
||||
label="Ngân sách PRO"
|
||||
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
|
||||
/>
|
||||
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditPro ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
{/* Ghi chú từ PRO + từ CCM (cuối block A, đúng thứ tự Excel anh Kiệt) */}
|
||||
<BudgetNoteRow
|
||||
label="Ghi chú từ PRO"
|
||||
editable={bs.canEditPro}
|
||||
value={proNoteText}
|
||||
onChange={e => setProNoteText(e.target.value)}
|
||||
placeholder="Ghi chú dự trù…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
setValue={setProNoteText}
|
||||
savedValue={bs.proNote}
|
||||
saving={proMut.isPending || peFetching}
|
||||
onSave={() => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: proNoteText || null })}
|
||||
/>
|
||||
<BudgetNoteRow
|
||||
label="Ghi chú từ CCM"
|
||||
editable={bs.canEditCcm}
|
||||
value={ccmNoteText}
|
||||
setValue={setCcmNoteText}
|
||||
savedValue={bs.ccmNote}
|
||||
saving={ccmMut.isPending || peFetching}
|
||||
onSave={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
|
||||
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{proMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.proNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Block B — THỰC HIỆN ===== */}
|
||||
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
|
||||
|
||||
@ -240,8 +240,22 @@ export function PeWorkflowPanel({
|
||||
{lv.status === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||
{lv.status === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{lv.approvers.map(a => a.fullName).join(' / ') || '(chưa cấu hình)'}
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
|
||||
{lv.approvers.length === 0
|
||||
? '(chưa cấu hình)'
|
||||
: lv.approvers.map((a, i) => (
|
||||
<span key={a.userId} className="inline-flex items-center gap-1">
|
||||
{i > 0 && <span className="text-slate-300">/</span>}
|
||||
<span>{a.fullName}</span>
|
||||
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
|
||||
{a.canEditProBudget && (
|
||||
<span className="rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO">✎ NS PRO</span>
|
||||
)}
|
||||
{a.canEditCcmBudget && (
|
||||
<span className="rounded bg-sky-100 px-1 py-0.5 text-[8px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM">✎ NS CCM</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -234,6 +234,9 @@ export type PeCurrentApprovalLevelApprover = {
|
||||
userId: string
|
||||
fullName: string
|
||||
email: string | null
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
|
||||
canEditProBudget: boolean
|
||||
canEditCcmBudget: boolean
|
||||
}
|
||||
|
||||
export type PeCurrentApproval = {
|
||||
@ -283,19 +286,21 @@ export type PeChangelog = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
|
||||
// BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
|
||||
// cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
|
||||
// canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
|
||||
// capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
|
||||
// gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
|
||||
// S61 · [S76] Ngân sách gói thầu MA TRẬN 3 cột (DỰ ÁN | PRO | CCM) — PeWorkItemBudgets
|
||||
// 1 record/cặp Dự án × Hạng mục. BE compute + trả kèm PE detail GET. Mỗi cột full =
|
||||
// ban hành + hiệu chỉnh. fullAmount (authoritative Block B) = CCM nếu CCM nhập, else
|
||||
// PRO (proInitial+proAdjust) + fullIsEstimate=true (badge "ngân sách PRO"). canEditPro
|
||||
// = Procurement|Admin · canEditCcm = CostControl|Admin (BE-computed, FE KHÔNG đoán role).
|
||||
// DỰ ÁN = FE hiển thị-only (chưa wire BE). budgetId=null → phiếu chưa gắn Hạng mục.
|
||||
export type PeBudgetSummary = {
|
||||
budgetId: string | null
|
||||
proEstimateAmount: number | null
|
||||
proEstimateAmount: number | null // [LEGACY ≤S75] FE không dùng — thay bằng proInitial/proAdjust
|
||||
proInitialAmount: number | null // [S76] PRO "Ban hành lần đầu"
|
||||
proAdjustmentAmount: number | null // [S76] PRO "V0/hiệu chỉnh" — cho phép ÂM
|
||||
proNote: string | null
|
||||
initialAmount: number | null
|
||||
initialAmount: number | null // CCM "Ban hành lần đầu"
|
||||
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
|
||||
ccmNote: string | null // [Mig — anh Kiệt FDC] Ghi chú từ CCM (mirror proNote)
|
||||
ccmNote: string | null // CCM "Ghi chú từ CCM"
|
||||
fullAmount: number
|
||||
fullIsEstimate: boolean
|
||||
canEditPro: boolean
|
||||
|
||||
@ -72,10 +72,10 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
[HttpPut("{id:guid}/budget/pro")]
|
||||
public async Task<IActionResult> UpdateBudgetPro(Guid id, [FromBody] BudgetProBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdatePeBudgetProCommand(id, body.ProEstimateAmount, body.ProNote), ct);
|
||||
await mediator.Send(new UpdatePeBudgetProCommand(id, body.ProInitialAmount, body.ProAdjustmentAmount, body.ProNote), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record BudgetProBody(decimal? ProEstimateAmount, string? ProNote);
|
||||
public record BudgetProBody(decimal? ProInitialAmount, decimal? ProAdjustmentAmount, string? ProNote);
|
||||
|
||||
[HttpPut("{id:guid}/budget/ccm")]
|
||||
public async Task<IActionResult> UpdateBudgetCcm(Guid id, [FromBody] BudgetCcmBody body, CancellationToken ct)
|
||||
|
||||
@ -37,7 +37,12 @@ public record AwLevelDto(
|
||||
bool AllowReturnToDrafter,
|
||||
bool AllowApproverEditDetails,
|
||||
bool AllowApproverEditBudget,
|
||||
bool AllowApproverSkipToFinal);
|
||||
bool AllowApproverSkipToFinal,
|
||||
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
|
||||
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
|
||||
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
|
||||
bool CanEditProBudget,
|
||||
bool CanEditCcmBudget);
|
||||
|
||||
public record AwStepDto(
|
||||
Guid Id,
|
||||
@ -146,6 +151,13 @@ public class GetAwAdminOverviewQueryHandler(
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
|
||||
|
||||
// [S76] Hiển thị-only: tập user được nhập/điều chỉnh ngân sách (đảo chiều
|
||||
// GetUsersInRoleAsync → set-lookup, no N+1). Khớp gate canEditPro/canEditCcm
|
||||
// (PurchaseEvaluationFeatures.cs:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
|
||||
var adminUserIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
|
||||
var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();
|
||||
var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();
|
||||
|
||||
AwDefinitionDto ToDto(ApprovalWorkflow d) => new(
|
||||
d.Id,
|
||||
d.Code,
|
||||
@ -172,7 +184,8 @@ public class GetAwAdminOverviewQueryHandler(
|
||||
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
|
||||
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
|
||||
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
|
||||
l.AllowApproverSkipToFinal);
|
||||
l.AllowApproverSkipToFinal,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList()
|
||||
)).ToList());
|
||||
|
||||
|
||||
@ -129,7 +129,11 @@ public record PurchaseEvaluationWorkflowSummaryDto(
|
||||
public record PurchaseEvaluationApprovalLevelApproverDto(
|
||||
Guid UserId,
|
||||
string FullName,
|
||||
string? Email);
|
||||
string? Email,
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role —
|
||||
// Admin|Procurement→PRO, Admin|CostControl→CCM). Badge "✎ NS PRO/CCM" trong flow Duyệt NCC.
|
||||
bool CanEditProBudget,
|
||||
bool CanEditCcmBudget);
|
||||
|
||||
public record PurchaseEvaluationCurrentApprovalDto(
|
||||
int StepIndex, // 0-based
|
||||
@ -306,4 +310,7 @@ public record PeBudgetSummaryDto(
|
||||
int PreviousSubmittedCount,
|
||||
decimal PreviousSelectedTotal,
|
||||
int PreviousSelectedCount,
|
||||
decimal CurrentProposalTotal);
|
||||
decimal CurrentProposalTotal,
|
||||
// [S76] PRO column split — ban hành + hiệu chỉnh riêng cho PRO (mirror CCM Initial/Adjustment).
|
||||
decimal? ProInitialAmount,
|
||||
decimal? ProAdjustmentAmount);
|
||||
|
||||
@ -9,8 +9,10 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// [S61 Mig 50] 2 handler nhập ngân sách gói thầu theo ROLE (anh Kiệt chốt):
|
||||
// - PRO (Procurement | Admin): ProEstimateAmount (dự trù lần đầu) + ProNote.
|
||||
// [S61 Mig 50 · S76 PRO split] 2 handler nhập ngân sách gói thầu MA TRẬN theo ROLE:
|
||||
// - PRO (Procurement | Admin): ProInitialAmount ("Ban hành lần đầu") +
|
||||
// ProAdjustmentAmount ("V0/hiệu chỉnh" — cho phép ÂM) + ProNote. [S76 tách 2 số;
|
||||
// ProEstimateAmount cũ migrate → ProInitialAmount qua Mig 56]
|
||||
// - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") +
|
||||
// AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM) + CcmNote (Mig 55).
|
||||
// Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth,
|
||||
@ -60,15 +62,17 @@ internal static class PeWorkItemBudgetEnsurer
|
||||
|
||||
public record UpdatePeBudgetProCommand(
|
||||
Guid PeId,
|
||||
decimal? ProEstimateAmount,
|
||||
decimal? ProInitialAmount,
|
||||
decimal? ProAdjustmentAmount,
|
||||
string? ProNote) : IRequest;
|
||||
|
||||
public class UpdatePeBudgetProCommandValidator : AbstractValidator<UpdatePeBudgetProCommand>
|
||||
{
|
||||
public UpdatePeBudgetProCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProEstimateAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ProEstimateAmount.HasValue);
|
||||
RuleFor(x => x.ProInitialAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ProInitialAmount.HasValue);
|
||||
// ProAdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM.
|
||||
RuleFor(x => x.ProNote).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
@ -95,14 +99,18 @@ public class UpdatePeBudgetProCommandHandler(
|
||||
|
||||
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
|
||||
|
||||
var oldEstimate = rec.ProEstimateAmount;
|
||||
var oldInitial = rec.ProInitialAmount;
|
||||
var oldAdjustment = rec.ProAdjustmentAmount;
|
||||
var oldNote = rec.ProNote;
|
||||
rec.ProEstimateAmount = request.ProEstimateAmount; // absolute-set (null = clear)
|
||||
rec.ProInitialAmount = request.ProInitialAmount; // absolute-set (null = clear)
|
||||
rec.ProAdjustmentAmount = request.ProAdjustmentAmount;
|
||||
rec.ProNote = request.ProNote;
|
||||
|
||||
var parts = new List<string>();
|
||||
if (oldEstimate != request.ProEstimateAmount)
|
||||
parts.Add($"dự trù {oldEstimate?.ToString("N0") ?? "(trống)"}đ → {request.ProEstimateAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldInitial != request.ProInitialAmount)
|
||||
parts.Add($"ban hành PRO {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.ProInitialAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldAdjustment != request.ProAdjustmentAmount)
|
||||
parts.Add($"V0/hiệu chỉnh PRO {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.ProAdjustmentAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldNote != request.ProNote)
|
||||
parts.Add("ghi chú PRO cập nhật");
|
||||
|
||||
|
||||
@ -842,21 +842,26 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
}
|
||||
|
||||
// Full = CCM (Initial + Adjustment); CCM chưa nhập gì → fallback dự trù
|
||||
// PRO với cờ FullIsEstimate (FE badge "dự trù PRO").
|
||||
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
|
||||
// Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE
|
||||
// badge "ngân sách PRO"). PRO full = ProInitial + ProAdjust (migrate từ
|
||||
// ProEstimate cũ qua Mig 56). Cả 2 trống → full 0, không badge.
|
||||
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
|
||||
var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
|
||||
var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
|
||||
var fullAmount = hasCcm
|
||||
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
|
||||
: (pairRec?.ProEstimateAmount ?? 0m);
|
||||
: proFull;
|
||||
|
||||
peBudgetSummary = new PeBudgetSummaryDto(
|
||||
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
|
||||
pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
|
||||
fullAmount, !hasCcm,
|
||||
fullAmount, !hasCcm && hasPro,
|
||||
canEditPro, canEditCcm,
|
||||
prevSubmittedTotal, prevSubmittedCount,
|
||||
prevSelectedTotal, prevSelectedCount,
|
||||
currentProposalTotal);
|
||||
currentProposalTotal,
|
||||
pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount);
|
||||
}
|
||||
|
||||
// Load supplier names for PE suppliers + approver names
|
||||
@ -966,6 +971,13 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
|
||||
|
||||
// [S76] Hiển thị-only: tập user được nhập/điều chỉnh ngân sách (đảo chiều
|
||||
// GetUsersInRoleAsync → set-lookup, no N+1). Khớp gate canEditPro/canEditCcm
|
||||
// (:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
|
||||
var adminBudgetIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
|
||||
var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();
|
||||
var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();
|
||||
|
||||
// Compute Status mỗi level theo Phase + currentStepIdx + currentLevelOrder
|
||||
var currentIdx = e.CurrentWorkflowStepIndex;
|
||||
var currentLevel = e.CurrentApprovalLevelOrder;
|
||||
@ -1013,7 +1025,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
return new PurchaseEvaluationApprovalLevelApproverDto(
|
||||
l.ApproverUserId,
|
||||
info.FullName ?? l.ApproverUserId.ToString(),
|
||||
info.Email);
|
||||
info.Email,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList();
|
||||
var levelName = g.FirstOrDefault()?.Name;
|
||||
return new PurchaseEvaluationApprovalFlowLevelDto(
|
||||
@ -1044,7 +1057,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
return new PurchaseEvaluationApprovalLevelApproverDto(
|
||||
l.ApproverUserId,
|
||||
info.FullName ?? l.ApproverUserId.ToString(),
|
||||
info.Email);
|
||||
info.Email,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList();
|
||||
var levelName = levelGroup.FirstOrDefault()?.Name;
|
||||
currentApproval = new PurchaseEvaluationCurrentApprovalDto(
|
||||
|
||||
@ -10,21 +10,26 @@ namespace SolutionErp.Domain.PurchaseEvaluations;
|
||||
// Loose-Guid convention PE (giống PE.ProjectId/WorkItemId/SelectedSupplierId):
|
||||
// KHÔNG FK vật lý, KHÔNG navigation property.
|
||||
//
|
||||
// Quyền nhập theo ROLE (anh Kiệt chốt S61):
|
||||
// - PRO (Procurement): ProEstimateAmount (dự trù lần đầu) + ProNote.
|
||||
// - CCM (CostControl): InitialAmount (Ban hành lần đầu) + AdjustmentAmount
|
||||
// (NS V0 hiệu chỉnh tăng/giảm — cho phép ÂM) + CcmNote (ghi chú CCM, Mig 55).
|
||||
// [S76 — anh Kiệt FDC] Form ngân sách MA TRẬN 3 cột (DỰ ÁN | PRO | CCM), mỗi
|
||||
// phòng nhập + điều chỉnh ngân sách của CHÍNH cột phòng đó (quyền theo ROLE):
|
||||
// - PRO (Procurement | Admin): ProInitialAmount (ban hành lần đầu) +
|
||||
// ProAdjustmentAmount (V0/hiệu chỉnh — cho phép ÂM) + ProNote.
|
||||
// - CCM (CostControl | Admin): InitialAmount (ban hành lần đầu) +
|
||||
// AdjustmentAmount (V0/hiệu chỉnh — cho phép ÂM) + CcmNote.
|
||||
// - DỰ ÁN: hiển thị FE-only (chưa wire BE — sau mới có người dự án nhập).
|
||||
//
|
||||
// "Ngân sách full gói thầu" KHÔNG lưu cột — BE compute:
|
||||
// full = (InitialAmount ?? 0) + (AdjustmentAmount ?? 0);
|
||||
// cả Initial + Adjustment đều null → fallback ProEstimateAmount ?? 0
|
||||
// với cờ fullIsEstimate=true (FE badge "dự trù PRO").
|
||||
// "Ngân sách full" mỗi cột = Initial + Adjustment (cột đó). Authoritative full
|
||||
// (Block B công thức) = CCM nếu CCM đã nhập, else PRO (fullIsEstimate=true → FE
|
||||
// badge "ngân sách PRO"). ProEstimateAmount = LEGACY single-estimate (≤S75) —
|
||||
// Mig 56 migrate → ProInitialAmount, FE KHÔNG dùng nữa; giữ cột back-compat.
|
||||
public class PeWorkItemBudget : AuditableEntity
|
||||
{
|
||||
public Guid ProjectId { get; set; } // loose-Guid Projects.Id
|
||||
public Guid WorkItemId { get; set; } // loose-Guid WorkItems.Id
|
||||
|
||||
public decimal? ProEstimateAmount { get; set; } // PRO dự trù lần đầu (đ)
|
||||
public decimal? ProEstimateAmount { get; set; } // [LEGACY ≤S75] PRO dự trù 1 số — Mig 56 migrate → ProInitialAmount
|
||||
public decimal? ProInitialAmount { get; set; } // [S76] PRO "Ban hành lần đầu" (đ)
|
||||
public decimal? ProAdjustmentAmount { get; set; } // [S76] PRO "V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
|
||||
public string? ProNote { get; set; } // "Ghi chú từ PRO"
|
||||
public decimal? InitialAmount { get; set; } // CCM "Ngân sách Ban hành lần đầu" (đ)
|
||||
public decimal? AdjustmentAmount { get; set; } // CCM "NS V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
|
||||
|
||||
@ -17,6 +17,8 @@ public class PeWorkItemBudgetConfiguration : IEntityTypeConfiguration<PeWorkItem
|
||||
|
||||
// Precision match BudgetManualAmount cũ (18,2).
|
||||
b.Property(x => x.ProEstimateAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProInitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProAdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.InitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.AdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProNote).HasMaxLength(1000);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProBudgetSplitToPeWorkItemBudget : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ProAdjustmentAmount",
|
||||
table: "PeWorkItemBudgets",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ProInitialAmount",
|
||||
table: "PeWorkItemBudgets",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
// [S76] Giu so PRO cu: so du tru 1-cot (ProEstimateAmount, <=S75) coi nhu
|
||||
// "ban hanh lan dau" cot PRO -> migrate sang ProInitialAmount. Chay SAU
|
||||
// AddColumn (cot phai ton tai truoc). Idempotent: chi set khi ProInitial trong.
|
||||
// Design-DB 0 rows; chay THAT lan dau tren prod co data (gotcha #64).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE PeWorkItemBudgets
|
||||
SET ProInitialAmount = ProEstimateAmount
|
||||
WHERE ProEstimateAmount IS NOT NULL AND ProInitialAmount IS NULL;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProAdjustmentAmount",
|
||||
table: "PeWorkItemBudgets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProInitialAmount",
|
||||
table: "PeWorkItemBudgets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4546,10 +4546,18 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("ProAdjustmentAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ProEstimateAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ProInitialAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProNote")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
@ -213,17 +213,17 @@ public class PeWorkItemBudgetTests
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
|
||||
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, "lần 1"), CancellationToken.None);
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, null, "lần 1"), CancellationToken.None);
|
||||
var firstId = (await db.PeWorkItemBudgets
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id)).Id;
|
||||
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, "lần 2"), CancellationToken.None);
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, null, "lần 2"), CancellationToken.None);
|
||||
|
||||
var recs = await db.PeWorkItemBudgets
|
||||
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
|
||||
recs.Should().ContainSingle("EnsureTrackedAsync idempotent — 2 lần cùng cặp KHÔNG nhân đôi record");
|
||||
recs[0].Id.Should().Be(firstId, "cùng record Id qua 2 lần gọi");
|
||||
recs[0].ProEstimateAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới");
|
||||
recs[0].ProInitialAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới (handler set ProInitialAmount)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -242,7 +242,7 @@ public class PeWorkItemBudgetTests
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
ProEstimateAmount = 999m,
|
||||
ProInitialAmount = 999m,
|
||||
IsDeleted = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
@ -251,7 +251,7 @@ public class PeWorkItemBudgetTests
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeBudgetProCommand(pe.Id, 50m, "mới"), CancellationToken.None);
|
||||
new UpdatePeBudgetProCommand(pe.Id, 50m, null, "mới"), CancellationToken.None);
|
||||
await act.Should().NotThrowAsync(
|
||||
"filtered index cho phép tạo record active mới khi slot cũ đã soft-delete");
|
||||
|
||||
@ -261,7 +261,7 @@ public class PeWorkItemBudgetTests
|
||||
.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
|
||||
.Should().Be(2, "record soft-deleted gốc giữ lại cho audit + active mới");
|
||||
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
|
||||
.ProEstimateAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
|
||||
.ProInitialAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@ -269,7 +269,7 @@ public class PeWorkItemBudgetTests
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePro_ProcurementRole_SetsEstimateAndNote()
|
||||
public async Task UpdatePro_ProcurementRole_SetsInitialAndNote()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
@ -278,11 +278,11 @@ public class PeWorkItemBudgetTests
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
|
||||
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, "Dự trù theo đơn giá Q2"),
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, null, "Dự trù theo đơn giá Q2"),
|
||||
CancellationToken.None);
|
||||
|
||||
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.ProEstimateAmount.Should().Be(1_500_000m);
|
||||
rec.ProInitialAmount.Should().Be(1_500_000m, "handler set ProInitialAmount (arg2 = ProInitial)");
|
||||
rec.ProNote.Should().Be("Dự trù theo đơn giá Q2");
|
||||
}
|
||||
|
||||
@ -300,19 +300,20 @@ public class PeWorkItemBudgetTests
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProEstimateAmount = 700m, ProNote = "giữ nguyên",
|
||||
ProInitialAmount = 700m, ProAdjustmentAmount = -10m, ProNote = "giữ nguyên",
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.CostControl));
|
||||
|
||||
await FluentActions.Awaiting(() => handler.Handle(
|
||||
new UpdatePeBudgetProCommand(pe.Id, 9_999m, "không được set"), CancellationToken.None))
|
||||
new UpdatePeBudgetProCommand(pe.Id, 9_999m, 1m, "không được set"), CancellationToken.None))
|
||||
.Should().ThrowAsync<ForbiddenException>("chỉ Procurement | Admin được nhập dự trù PRO");
|
||||
|
||||
var rec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.ProEstimateAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
|
||||
rec.ProInitialAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
|
||||
rec.ProAdjustmentAmount.Should().Be(-10m, "không field PRO nào bị mutate khi Forbidden");
|
||||
rec.ProNote.Should().Be("giữ nguyên");
|
||||
}
|
||||
|
||||
@ -326,10 +327,10 @@ public class PeWorkItemBudgetTests
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
|
||||
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, "admin set"), CancellationToken.None);
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, null, "admin set"), CancellationToken.None);
|
||||
|
||||
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
|
||||
.ProEstimateAmount.Should().Be(42m, "Admin được nhập PRO");
|
||||
.ProInitialAmount.Should().Be(42m, "Admin được nhập PRO");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -343,7 +344,7 @@ public class PeWorkItemBudgetTests
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
|
||||
|
||||
await FluentActions.Awaiting(() => handler.Handle(
|
||||
new UpdatePeBudgetProCommand(pe.Id, 10m, null), CancellationToken.None))
|
||||
new UpdatePeBudgetProCommand(pe.Id, 10m, null, null), CancellationToken.None))
|
||||
.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*chưa gắn Hạng mục công việc*");
|
||||
}
|
||||
@ -363,11 +364,56 @@ public class PeWorkItemBudgetTests
|
||||
.Should().BeFalse("tiền điều kiện: chưa có record cho cặp");
|
||||
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, "auto"), CancellationToken.None);
|
||||
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, null, "auto"), CancellationToken.None);
|
||||
|
||||
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.Id.Should().NotBe(Guid.Empty, "auto-create record có Id thật");
|
||||
rec.ProEstimateAmount.Should().Be(333m);
|
||||
rec.ProInitialAmount.Should().Be(333m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePro_SetsBothInitialAndNegativeAdjustment_PersistTogether()
|
||||
{
|
||||
// [S76] PRO column split — set CẢ ProInitial + ProAdjust (gồm ÂM) trong 1 lệnh.
|
||||
// ProAdjustmentAmount cho phép ÂM ("V0/hiệu chỉnh tăng giảm", validator KHÔNG ràng dấu —
|
||||
// mirror CCM AdjustmentAmount). Cả 2 field + note persist đủ.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-PRO5");
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
|
||||
|
||||
await handler.Handle(
|
||||
new UpdatePeBudgetProCommand(pe.Id, 100_000_000m, -20_000_000m, "ban hành + V0 giảm 20tr"),
|
||||
CancellationToken.None);
|
||||
|
||||
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.ProInitialAmount.Should().Be(100_000_000m, "ban hành lần đầu PRO");
|
||||
rec.ProAdjustmentAmount.Should().Be(-20_000_000m, "V0/hiệu chỉnh PRO ÂM được chấp nhận");
|
||||
rec.ProNote.Should().Be("ban hành + V0 giảm 20tr", "cả 3 field PRO persist trong 1 lệnh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePro_Validator_NegativeInitial_FailsValidation()
|
||||
{
|
||||
// ProInitialAmount >= 0 when HasValue → ÂM không hợp lệ (khác ProAdjustment).
|
||||
var validator = new UpdatePeBudgetProCommandValidator();
|
||||
var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), -1m, null, null));
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e =>
|
||||
e.PropertyName == nameof(UpdatePeBudgetProCommand.ProInitialAmount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePro_Validator_NegativeAdjustment_PassesValidation()
|
||||
{
|
||||
// ProAdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM (mirror CCM).
|
||||
var validator = new UpdatePeBudgetProCommandValidator();
|
||||
var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), 50m, -999m, null));
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@ -634,9 +680,10 @@ public class PeWorkItemBudgetTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetSummary_FullAmount_FallsBackToProEstimate_WhenCcmEmpty()
|
||||
public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
|
||||
{
|
||||
// CCM (Initial+Adjustment) cả null → fallback ProEstimate=500, FullIsEstimate=true.
|
||||
// [S76] CCM (Initial+Adjustment) cả null → fallback proFull = ProInitial(500)+ProAdjust(0)
|
||||
// = 500, FullIsEstimate=true. KHÔNG còn dùng ProEstimateAmount (legacy).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
@ -646,7 +693,7 @@ public class PeWorkItemBudgetTests
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProEstimateAmount = 500m, // CCM Initial + Adjustment đều null
|
||||
ProInitialAmount = 500m, // CCM Initial + Adjustment đều null
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
@ -654,8 +701,8 @@ public class PeWorkItemBudgetTests
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.FullAmount.Should().Be(500m, "CCM trống → full = dự trù PRO");
|
||||
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'dự trù PRO'");
|
||||
s.FullAmount.Should().Be(500m, "CCM trống → full = ngân sách PRO (ProInitial + ProAdjust)");
|
||||
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'ngân sách PRO'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -671,7 +718,7 @@ public class PeWorkItemBudgetTests
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProEstimateAmount = 500m, // có nhưng KHÔNG dùng khi CCM present
|
||||
ProInitialAmount = 500m, ProAdjustmentAmount = 20m, // PRO có nhưng KHÔNG dùng khi CCM present
|
||||
InitialAmount = 400m,
|
||||
AdjustmentAmount = -50m,
|
||||
});
|
||||
@ -681,10 +728,63 @@ public class PeWorkItemBudgetTests
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50)");
|
||||
s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50), PRO bị bỏ qua");
|
||||
s.FullIsEstimate.Should().BeFalse("không phải dự trù — CCM đã nhập");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetSummary_FullAmount_ProFull_SumsProInitialAndAdjustment_WhenCcmEmpty()
|
||||
{
|
||||
// [S76] CCM trống → proFull = ProInitial(100) + ProAdjust(50) = 150. FullIsEstimate=true.
|
||||
// DTO cũng surface ProInitial/ProAdjust riêng (2 field cuối record) cho FE render từng cột.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-SUM5");
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProInitialAmount = 100m, ProAdjustmentAmount = 50m, // CCM Initial + Adjustment đều null
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.FullAmount.Should().Be(150m, "CCM trống → full = proFull = ProInitial + ProAdjust (100 + 50)");
|
||||
s.FullIsEstimate.Should().BeTrue("PRO nhập + CCM trống → cờ dự trù PRO");
|
||||
s.ProInitialAmount.Should().Be(100m, "DTO surface ProInitial riêng cho FE");
|
||||
s.ProAdjustmentAmount.Should().Be(50m, "DTO surface ProAdjust riêng cho FE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetSummary_FullAmount_NegativeProAdjustment_ReducesProFull_WhenCcmEmpty()
|
||||
{
|
||||
// proFull cho phép ProAdjust ÂM → 100 + (-30) = 70. Chứng minh full = tổng đại số (không clamp).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-SUM6");
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProInitialAmount = 100m, ProAdjustmentAmount = -30m,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.FullAmount.Should().Be(70m, "proFull = ProInitial + ProAdjust ÂM (100 - 30)");
|
||||
s.FullIsEstimate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetSummary_CanEditFlags_FollowRole()
|
||||
{
|
||||
@ -696,7 +796,7 @@ public class PeWorkItemBudgetTests
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id);
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProEstimateAmount = 10m,
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProInitialAmount = 10m,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user