[CLAUDE] PurchaseEvaluation: CCM 3-fix — multi-winner sum + budget=0 hợp lệ + cấp KẾT THÚC quy trình (Mig 58, anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng): 1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId). SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô + dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2). 2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL, số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE. 3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58) — admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet, bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt chốt). FE: banner cảnh báo + bắt chọn giá chốt. Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner, additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests +budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror. Build sạch BE + 2 FE local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -73,14 +73,10 @@ const NCC_PALETTES = [
|
|||||||
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
||||||
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
||||||
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||||||
const winnerSupplierRowId = ev.selectedSupplierId
|
// [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
|
||||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
// SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
|
||||||
: null
|
if (!ev.suppliers.some(s => s.isWinner)) return null
|
||||||
if (winnerSupplierRowId === null) return null
|
return ev.winnerQuoteTotal
|
||||||
return ev.details
|
|
||||||
.flatMap(d => d.quotes)
|
|
||||||
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
|
|
||||||
.reduce((sum, q) => sum + q.thanhTien, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main detail content — flat render 3 section không tabs.
|
// Main detail content — flat render 3 section không tabs.
|
||||||
@ -194,8 +190,9 @@ export function PeDetailTabs({
|
|||||||
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
||||||
const missingForApproval = useMemo(() => {
|
const missingForApproval = useMemo(() => {
|
||||||
const missing: string[] = []
|
const missing: string[] = []
|
||||||
// 1. Chưa chọn Đơn vị NCC/TP
|
// 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
|
||||||
if (evaluation.selectedSupplierId == null) {
|
// null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
|
||||||
|
if (!evaluation.suppliers.some(s => s.isWinner)) {
|
||||||
missing.push("Chưa chọn Đơn vị NCC/TP")
|
missing.push("Chưa chọn Đơn vị NCC/TP")
|
||||||
} else {
|
} else {
|
||||||
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
||||||
@ -207,8 +204,10 @@ export function PeDetailTabs({
|
|||||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||||
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
|
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
|
||||||
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
|
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
|
||||||
|
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
|
||||||
|
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
|
||||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
if (effBudget == null || effBudget <= 0) {
|
if (effBudget == null) {
|
||||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
missing.push("Chưa nhập Ngân sách kỳ này")
|
||||||
}
|
}
|
||||||
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
||||||
@ -227,14 +226,14 @@ export function PeDetailTabs({
|
|||||||
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
||||||
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||||
const submitChecklist = useMemo(() => {
|
const submitChecklist = useMemo(() => {
|
||||||
const supplierOk = evaluation.selectedSupplierId != null
|
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||||
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
return [
|
return [
|
||||||
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
||||||
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||||
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 },
|
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
|
||||||
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||||
]
|
]
|
||||||
}, [evaluation])
|
}, [evaluation])
|
||||||
@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
|
||||||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
|
||||||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
|
||||||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
|
||||||
|
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
|
||||||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const setWinner = useMutation({
|
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
|
||||||
mutationFn: async (supplierId: string | null) =>
|
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
const setWinners = useMutation({
|
||||||
|
mutationFn: async (supplierIds: string[]) =>
|
||||||
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Đã cập nhật NCC được chọn.')
|
toast.success('Đã cập nhật đơn vị trúng thầu.')
|
||||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||||
|
const toggle = (supplierId: string, on: boolean) =>
|
||||||
|
setWinners.mutate(on
|
||||||
|
? [...winnerIds, supplierId]
|
||||||
|
: winnerIds.filter(id => id !== supplierId))
|
||||||
|
const busy = setWinners.isPending || peFetching
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
|
const winners = ev.suppliers.filter(s => s.isWinner)
|
||||||
return (
|
return (
|
||||||
<FormRow
|
<FormRow
|
||||||
label="a. NCC / TP được chọn"
|
label="a. NCC / TP được chọn"
|
||||||
value={ev.selectedSupplierName
|
value={winners.length > 0
|
||||||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
? <span className="font-medium text-emerald-700">✓ {winners.map(s => s.supplierName).join(', ')}</span>
|
||||||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
|||||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
<Select
|
{ev.suppliers.length === 0 ? (
|
||||||
value={ev.selectedSupplierId ?? ''}
|
<p className="text-[11px] text-amber-600">
|
||||||
onChange={e => setWinner.mutate(e.target.value || null)}
|
Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu.
|
||||||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
</p>
|
||||||
className="text-sm"
|
) : (
|
||||||
>
|
<div className="space-y-1">
|
||||||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
{ev.suppliers.map(s => (
|
||||||
{ev.suppliers.map(s => (
|
<label
|
||||||
<option key={s.id} value={s.supplierId}>
|
key={s.id}
|
||||||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
className={cn(
|
||||||
</option>
|
'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
|
||||||
))}
|
s.isWinner
|
||||||
</Select>
|
? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
|
||||||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
: 'border-slate-200 bg-white hover:bg-slate-50',
|
||||||
{setWinner.isPending && (
|
)}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
>
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
<input
|
||||||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
type="checkbox"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
checked={s.isWinner}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={e => toggle(s.supplierId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ev.suppliers.length === 0 && (
|
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
|
||||||
<p className="mt-1 text-[11px] text-amber-600">
|
{winnerIds.length > 0 && (
|
||||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
<div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
|
||||||
</p>
|
<span className="text-emerald-700">Tổng chi phí đã chọn ({winnerIds.length} đơn vị):</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||||
|
<span>Đang cập nhật trúng thầu + sync cột giá Section 4…</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||||||
value={
|
value={
|
||||||
<span className="font-sans text-slate-700">
|
<span className="font-sans text-slate-700">
|
||||||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
{ev.suppliers.some(s => s.isWinner)
|
||||||
|
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
|
||||||
|
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
|
|||||||
|
|
||||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||||||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
||||||
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
||||||
const winnerSupplierRowId = ev.selectedSupplierId
|
const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
|
||||||
: null
|
|
||||||
const giaChaoThau = computeGiaChaoThau(ev)
|
const giaChaoThau = computeGiaChaoThau(ev)
|
||||||
|
|
||||||
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
|
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
|
||||||
@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
|||||||
<FormRow
|
<FormRow
|
||||||
label="c. Giá chào thầu"
|
label="c. Giá chào thầu"
|
||||||
value={
|
value={
|
||||||
winnerSupplierRowId === null ? (
|
!hasWinner ? (
|
||||||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||||||
) : giaChaoThau === 0 ? (
|
) : giaChaoThau === 0 ? (
|
||||||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||||||
@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
|
|||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
NCC: <strong>{evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Label>Loại HĐ</Label>
|
<Label>Loại HĐ</Label>
|
||||||
@ -2436,12 +2464,19 @@ function HangMucCard({
|
|||||||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
const setWinner = useMutation({
|
// [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên
|
||||||
mutationFn: async (supplierId: string | null) =>
|
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||||
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
const setWinners = useMutation({
|
||||||
|
mutationFn: async (supplierIds: string[]) =>
|
||||||
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const toggleWinner = (supplierId: string, on: boolean) =>
|
||||||
|
setWinners.mutate(on
|
||||||
|
? [...winnerIds, supplierId]
|
||||||
|
: winnerIds.filter(id => id !== supplierId))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -2537,7 +2572,7 @@ function HangMucCard({
|
|||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{ev.suppliers.map((s, idx) => {
|
{ev.suppliers.map((s, idx) => {
|
||||||
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
||||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
const isWinner = s.isWinner
|
||||||
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
||||||
const canDelete = !isWinner && !hasQuotes
|
const canDelete = !isWinner && !hasQuotes
|
||||||
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
||||||
@ -2606,12 +2641,13 @@ function HangMucCard({
|
|||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
|
onClick={() => toggleWinner(s.supplierId, !isWinner)}
|
||||||
|
disabled={setWinners.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1 py-0.5',
|
'rounded px-1 py-0.5 disabled:opacity-50',
|
||||||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
||||||
)}
|
)}
|
||||||
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
|
title={isWinner ? 'Bỏ chọn trúng thầu (chọn ≥2 = liên danh)' : 'Chọn trúng thầu (chọn ≥2 = liên danh)'}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -110,6 +110,10 @@ export function PeWorkflowPanel({
|
|||||||
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
||||||
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
||||||
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
||||||
|
// [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt
|
||||||
|
// (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp,
|
||||||
|
// KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt.
|
||||||
|
const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true
|
||||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||||
const priceCandidates = ([
|
const priceCandidates = ([
|
||||||
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
|
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
|
||||||
@ -149,8 +153,9 @@ export function PeWorkflowPanel({
|
|||||||
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||||
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done.
|
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done
|
||||||
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm)
|
// HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá).
|
||||||
|
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||||
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
|
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
|
||||||
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
|
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
|
||||||
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
|
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
|
||||||
@ -430,7 +435,7 @@ export function PeWorkflowPanel({
|
|||||||
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
||||||
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
|
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
|
||||||
const isApproveAction = !isCancel && !isSendBack
|
const isApproveAction = !isCancel && !isSendBack
|
||||||
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm)
|
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||||
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
|
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
|
||||||
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
|
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
|
||||||
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
|
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
|
||||||
@ -588,6 +593,16 @@ export function PeWorkflowPanel({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt:
|
||||||
|
BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */}
|
||||||
|
{isApproveAction && approverFinalizeEligible && (
|
||||||
|
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||||
|
<span className="font-medium">✅ Cấp này KẾT THÚC quy trình</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
|
||||||
|
Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
||||||
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
||||||
{shouldPickPrice && (
|
{shouldPickPrice && (
|
||||||
|
|||||||
@ -51,6 +51,7 @@ type LevelDto = {
|
|||||||
allowApproverEditDetails: boolean
|
allowApproverEditDetails: boolean
|
||||||
allowApproverEditBudget: boolean
|
allowApproverEditBudget: boolean
|
||||||
allowApproverSkipToFinal: boolean
|
allowApproverSkipToFinal: boolean
|
||||||
|
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
|
||||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
|
// [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
|
canEditProBudget: boolean
|
||||||
canEditCcmBudget: boolean
|
canEditCcmBudget: boolean
|
||||||
@ -106,6 +107,7 @@ type EditLevelEntry = {
|
|||||||
allowApproverEditDetails: boolean
|
allowApproverEditDetails: boolean
|
||||||
allowApproverEditBudget: boolean
|
allowApproverEditBudget: boolean
|
||||||
allowApproverSkipToFinal: boolean
|
allowApproverSkipToFinal: boolean
|
||||||
|
allowApproverFinalize: boolean
|
||||||
}
|
}
|
||||||
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
|
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
|
||||||
|
|
||||||
@ -154,6 +156,7 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
|||||||
allowApproverEditDetails: l.allowApproverEditDetails ?? false,
|
allowApproverEditDetails: l.allowApproverEditDetails ?? false,
|
||||||
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
|
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
|
||||||
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
|
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
|
||||||
|
allowApproverFinalize: l.allowApproverFinalize ?? false,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -171,6 +174,7 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
|
|||||||
allowApproverEditDetails: false,
|
allowApproverEditDetails: false,
|
||||||
allowApproverEditBudget: false,
|
allowApproverEditBudget: false,
|
||||||
allowApproverSkipToFinal: false,
|
allowApproverSkipToFinal: false,
|
||||||
|
allowApproverFinalize: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -598,6 +602,7 @@ function Designer({
|
|||||||
allowApproverEditDetails: e.allowApproverEditDetails,
|
allowApproverEditDetails: e.allowApproverEditDetails,
|
||||||
allowApproverEditBudget: e.allowApproverEditBudget,
|
allowApproverEditBudget: e.allowApproverEditBudget,
|
||||||
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
|
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
|
||||||
|
allowApproverFinalize: e.allowApproverFinalize,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@ -989,6 +994,17 @@ function Designer({
|
|||||||
/>
|
/>
|
||||||
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
|
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
|
||||||
|
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */}
|
||||||
|
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3"
|
||||||
|
checked={entry.allowApproverFinalize}
|
||||||
|
onChange={e => updateField('allowApproverFinalize', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -150,6 +150,10 @@ export type PeSupplier = {
|
|||||||
paymentTermText: string | null
|
paymentTermText: string | null
|
||||||
note: string | null
|
note: string | null
|
||||||
order: number
|
order: number
|
||||||
|
// [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu
|
||||||
|
// (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG
|
||||||
|
// dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner.
|
||||||
|
isWinner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeQuote = {
|
export type PeQuote = {
|
||||||
@ -399,6 +403,7 @@ export type ApprovalWorkflowOptions = {
|
|||||||
allowApproverEditDetails: boolean
|
allowApproverEditDetails: boolean
|
||||||
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
||||||
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
||||||
|
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||||
|
|||||||
@ -73,14 +73,10 @@ const NCC_PALETTES = [
|
|||||||
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
||||||
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
||||||
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||||||
const winnerSupplierRowId = ev.selectedSupplierId
|
// [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
|
||||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
// SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
|
||||||
: null
|
if (!ev.suppliers.some(s => s.isWinner)) return null
|
||||||
if (winnerSupplierRowId === null) return null
|
return ev.winnerQuoteTotal
|
||||||
return ev.details
|
|
||||||
.flatMap(d => d.quotes)
|
|
||||||
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
|
|
||||||
.reduce((sum, q) => sum + q.thanhTien, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main detail content — flat render 3 section không tabs.
|
// Main detail content — flat render 3 section không tabs.
|
||||||
@ -194,8 +190,9 @@ export function PeDetailTabs({
|
|||||||
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
||||||
const missingForApproval = useMemo(() => {
|
const missingForApproval = useMemo(() => {
|
||||||
const missing: string[] = []
|
const missing: string[] = []
|
||||||
// 1. Chưa chọn Đơn vị NCC/TP
|
// 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
|
||||||
if (evaluation.selectedSupplierId == null) {
|
// null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
|
||||||
|
if (!evaluation.suppliers.some(s => s.isWinner)) {
|
||||||
missing.push("Chưa chọn Đơn vị NCC/TP")
|
missing.push("Chưa chọn Đơn vị NCC/TP")
|
||||||
} else {
|
} else {
|
||||||
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
||||||
@ -207,8 +204,10 @@ export function PeDetailTabs({
|
|||||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||||
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
|
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
|
||||||
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
|
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
|
||||||
|
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
|
||||||
|
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
|
||||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
if (effBudget == null || effBudget <= 0) {
|
if (effBudget == null) {
|
||||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
missing.push("Chưa nhập Ngân sách kỳ này")
|
||||||
}
|
}
|
||||||
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
||||||
@ -227,14 +226,14 @@ export function PeDetailTabs({
|
|||||||
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
||||||
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||||
const submitChecklist = useMemo(() => {
|
const submitChecklist = useMemo(() => {
|
||||||
const supplierOk = evaluation.selectedSupplierId != null
|
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||||
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||||
return [
|
return [
|
||||||
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
||||||
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||||
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 },
|
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
|
||||||
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||||
]
|
]
|
||||||
}, [evaluation])
|
}, [evaluation])
|
||||||
@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
|
||||||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
|
||||||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
|
||||||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
|
||||||
|
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
|
||||||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const setWinner = useMutation({
|
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
|
||||||
mutationFn: async (supplierId: string | null) =>
|
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
const setWinners = useMutation({
|
||||||
|
mutationFn: async (supplierIds: string[]) =>
|
||||||
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Đã cập nhật NCC được chọn.')
|
toast.success('Đã cập nhật đơn vị trúng thầu.')
|
||||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||||
|
const toggle = (supplierId: string, on: boolean) =>
|
||||||
|
setWinners.mutate(on
|
||||||
|
? [...winnerIds, supplierId]
|
||||||
|
: winnerIds.filter(id => id !== supplierId))
|
||||||
|
const busy = setWinners.isPending || peFetching
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
|
const winners = ev.suppliers.filter(s => s.isWinner)
|
||||||
return (
|
return (
|
||||||
<FormRow
|
<FormRow
|
||||||
label="a. NCC / TP được chọn"
|
label="a. NCC / TP được chọn"
|
||||||
value={ev.selectedSupplierName
|
value={winners.length > 0
|
||||||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
? <span className="font-medium text-emerald-700">✓ {winners.map(s => s.supplierName).join(', ')}</span>
|
||||||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
|||||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
<Select
|
{ev.suppliers.length === 0 ? (
|
||||||
value={ev.selectedSupplierId ?? ''}
|
<p className="text-[11px] text-amber-600">
|
||||||
onChange={e => setWinner.mutate(e.target.value || null)}
|
Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu.
|
||||||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
</p>
|
||||||
className="text-sm"
|
) : (
|
||||||
>
|
<div className="space-y-1">
|
||||||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
{ev.suppliers.map(s => (
|
||||||
{ev.suppliers.map(s => (
|
<label
|
||||||
<option key={s.id} value={s.supplierId}>
|
key={s.id}
|
||||||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
className={cn(
|
||||||
</option>
|
'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
|
||||||
))}
|
s.isWinner
|
||||||
</Select>
|
? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
|
||||||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
: 'border-slate-200 bg-white hover:bg-slate-50',
|
||||||
{setWinner.isPending && (
|
)}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
>
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
<input
|
||||||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
type="checkbox"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
checked={s.isWinner}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={e => toggle(s.supplierId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ev.suppliers.length === 0 && (
|
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
|
||||||
<p className="mt-1 text-[11px] text-amber-600">
|
{winnerIds.length > 0 && (
|
||||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
<div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
|
||||||
</p>
|
<span className="text-emerald-700">Tổng chi phí đã chọn ({winnerIds.length} đơn vị):</span>
|
||||||
|
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||||
|
<span>Đang cập nhật trúng thầu + sync cột giá Section 4…</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||||||
value={
|
value={
|
||||||
<span className="font-sans text-slate-700">
|
<span className="font-sans text-slate-700">
|
||||||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
{ev.suppliers.some(s => s.isWinner)
|
||||||
|
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
|
||||||
|
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
|
|||||||
|
|
||||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||||||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
||||||
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
||||||
const winnerSupplierRowId = ev.selectedSupplierId
|
const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
|
||||||
: null
|
|
||||||
const giaChaoThau = computeGiaChaoThau(ev)
|
const giaChaoThau = computeGiaChaoThau(ev)
|
||||||
|
|
||||||
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
|
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
|
||||||
@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
|||||||
<FormRow
|
<FormRow
|
||||||
label="c. Giá chào thầu"
|
label="c. Giá chào thầu"
|
||||||
value={
|
value={
|
||||||
winnerSupplierRowId === null ? (
|
!hasWinner ? (
|
||||||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||||||
) : giaChaoThau === 0 ? (
|
) : giaChaoThau === 0 ? (
|
||||||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||||||
@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
|
|||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
NCC: <strong>{evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Label>Loại HĐ</Label>
|
<Label>Loại HĐ</Label>
|
||||||
@ -2436,12 +2464,19 @@ function HangMucCard({
|
|||||||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
const setWinner = useMutation({
|
// [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên
|
||||||
mutationFn: async (supplierId: string | null) =>
|
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||||
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
const setWinners = useMutation({
|
||||||
|
mutationFn: async (supplierIds: string[]) =>
|
||||||
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||||
|
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const toggleWinner = (supplierId: string, on: boolean) =>
|
||||||
|
setWinners.mutate(on
|
||||||
|
? [...winnerIds, supplierId]
|
||||||
|
: winnerIds.filter(id => id !== supplierId))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -2537,7 +2572,7 @@ function HangMucCard({
|
|||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{ev.suppliers.map((s, idx) => {
|
{ev.suppliers.map((s, idx) => {
|
||||||
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
||||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
const isWinner = s.isWinner
|
||||||
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
||||||
const canDelete = !isWinner && !hasQuotes
|
const canDelete = !isWinner && !hasQuotes
|
||||||
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
||||||
@ -2606,12 +2641,13 @@ function HangMucCard({
|
|||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
|
onClick={() => toggleWinner(s.supplierId, !isWinner)}
|
||||||
|
disabled={setWinners.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1 py-0.5',
|
'rounded px-1 py-0.5 disabled:opacity-50',
|
||||||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
||||||
)}
|
)}
|
||||||
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
|
title={isWinner ? 'Bỏ chọn trúng thầu (chọn ≥2 = liên danh)' : 'Chọn trúng thầu (chọn ≥2 = liên danh)'}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -110,6 +110,10 @@ export function PeWorkflowPanel({
|
|||||||
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
||||||
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
||||||
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
||||||
|
// [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt
|
||||||
|
// (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp,
|
||||||
|
// KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt.
|
||||||
|
const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true
|
||||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||||
const priceCandidates = ([
|
const priceCandidates = ([
|
||||||
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
|
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
|
||||||
@ -149,8 +153,9 @@ export function PeWorkflowPanel({
|
|||||||
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||||
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done.
|
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done
|
||||||
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm)
|
// HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá).
|
||||||
|
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||||
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
|
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
|
||||||
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
|
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
|
||||||
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
|
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
|
||||||
@ -430,7 +435,7 @@ export function PeWorkflowPanel({
|
|||||||
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
||||||
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
|
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
|
||||||
const isApproveAction = !isCancel && !isSendBack
|
const isApproveAction = !isCancel && !isSendBack
|
||||||
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm)
|
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||||
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
|
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
|
||||||
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
|
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
|
||||||
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
|
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
|
||||||
@ -588,6 +593,16 @@ export function PeWorkflowPanel({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt:
|
||||||
|
BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */}
|
||||||
|
{isApproveAction && approverFinalizeEligible && (
|
||||||
|
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||||
|
<span className="font-medium">✅ Cấp này KẾT THÚC quy trình</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
|
||||||
|
Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
||||||
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
||||||
{shouldPickPrice && (
|
{shouldPickPrice && (
|
||||||
|
|||||||
@ -154,6 +154,10 @@ export type PeSupplier = {
|
|||||||
paymentTermText: string | null
|
paymentTermText: string | null
|
||||||
note: string | null
|
note: string | null
|
||||||
order: number
|
order: number
|
||||||
|
// [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu
|
||||||
|
// (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG
|
||||||
|
// dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner.
|
||||||
|
isWinner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PeQuote = {
|
export type PeQuote = {
|
||||||
@ -401,6 +405,7 @@ export type ApprovalWorkflowOptions = {
|
|||||||
allowApproverEditDetails: boolean
|
allowApproverEditDetails: boolean
|
||||||
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
||||||
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
||||||
|
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||||
|
|||||||
@ -167,7 +167,7 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
|||||||
[HttpPost("{id:guid}/select-winner")]
|
[HttpPost("{id:guid}/select-winner")]
|
||||||
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
|
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct);
|
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierIds ?? Array.Empty<Guid>()), ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,7 +345,7 @@ public record AddSupplierBody(
|
|||||||
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
||||||
string? PaymentTermText, string? Note);
|
string? PaymentTermText, string? Note);
|
||||||
|
|
||||||
public record SelectWinnerBody(Guid? SupplierId); // null = bỏ chọn NCC (cho phép xóa + điền lại — anh Kiệt FDC C5)
|
public record SelectWinnerBody(IReadOnlyList<Guid>? SupplierIds); // [Mig 58] multi-winner: tập đơn vị NCC/TP thắng; [] hoặc null = bỏ chọn (anh Kiệt FDC C5 + CCM multi)
|
||||||
|
|
||||||
public record DetailBody(
|
public record DetailBody(
|
||||||
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ public record AwLevelDto(
|
|||||||
bool AllowApproverEditDetails,
|
bool AllowApproverEditDetails,
|
||||||
bool AllowApproverEditBudget,
|
bool AllowApproverEditBudget,
|
||||||
bool AllowApproverSkipToFinal,
|
bool AllowApproverSkipToFinal,
|
||||||
|
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
|
||||||
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
|
// [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);
|
// 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.
|
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
|
||||||
@ -184,7 +185,7 @@ public class GetAwAdminOverviewQueryHandler(
|
|||||||
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
|
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
|
||||||
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
|
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
|
||||||
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
|
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
|
||||||
l.AllowApproverSkipToFinal,
|
l.AllowApproverSkipToFinal, l.AllowApproverFinalize,
|
||||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||||
}).ToList()
|
}).ToList()
|
||||||
)).ToList());
|
)).ToList());
|
||||||
@ -225,7 +226,8 @@ public record CreateAwLevelInput(
|
|||||||
bool AllowReturnToDrafter = true,
|
bool AllowReturnToDrafter = true,
|
||||||
bool AllowApproverEditDetails = false,
|
bool AllowApproverEditDetails = false,
|
||||||
bool AllowApproverEditBudget = false,
|
bool AllowApproverEditBudget = false,
|
||||||
bool AllowApproverSkipToFinal = false);
|
bool AllowApproverSkipToFinal = false,
|
||||||
|
bool AllowApproverFinalize = false);
|
||||||
|
|
||||||
public record CreateAwStepInput(
|
public record CreateAwStepInput(
|
||||||
int Order,
|
int Order,
|
||||||
@ -363,6 +365,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
|||||||
AllowApproverEditDetails = l.AllowApproverEditDetails,
|
AllowApproverEditDetails = l.AllowApproverEditDetails,
|
||||||
AllowApproverEditBudget = l.AllowApproverEditBudget,
|
AllowApproverEditBudget = l.AllowApproverEditBudget,
|
||||||
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
|
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
|
||||||
|
AllowApproverFinalize = l.AllowApproverFinalize,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
|||||||
@ -51,7 +51,8 @@ public record PurchaseEvaluationSupplierDto(
|
|||||||
string? ContactPhone,
|
string? ContactPhone,
|
||||||
string? PaymentTermText,
|
string? PaymentTermText,
|
||||||
string? Note,
|
string? Note,
|
||||||
int Order);
|
int Order,
|
||||||
|
bool IsWinner); // [Mig 58] đơn vị này có thuộc tập winner không (multi)
|
||||||
|
|
||||||
public record PurchaseEvaluationQuoteDto(
|
public record PurchaseEvaluationQuoteDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@ -115,7 +116,8 @@ public record ApprovalWorkflowOptionsDto(
|
|||||||
bool AllowReturnToDrafter,
|
bool AllowReturnToDrafter,
|
||||||
bool AllowApproverEditDetails,
|
bool AllowApproverEditDetails,
|
||||||
bool AllowApproverEditBudget,
|
bool AllowApproverEditBudget,
|
||||||
bool AllowApproverSkipToFinal);
|
bool AllowApproverSkipToFinal,
|
||||||
|
bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
|
||||||
|
|
||||||
public record PurchaseEvaluationWorkflowSummaryDto(
|
public record PurchaseEvaluationWorkflowSummaryDto(
|
||||||
string PolicyName,
|
string PolicyName,
|
||||||
|
|||||||
@ -385,11 +385,17 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Select winner (NCC được chọn tổng thể) ==========
|
// ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ==========
|
||||||
|
|
||||||
// SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền
|
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng
|
||||||
// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%).
|
// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng:
|
||||||
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest;
|
// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5).
|
||||||
|
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat).
|
||||||
|
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner").
|
||||||
|
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien
|
||||||
|
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu.
|
||||||
|
public record SelectPurchaseEvaluationWinnerCommand(
|
||||||
|
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest;
|
||||||
|
|
||||||
public class SelectPurchaseEvaluationWinnerCommandHandler(
|
public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
@ -400,28 +406,30 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
|||||||
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
if (request.SupplierId is Guid supplierId)
|
// Load mọi supplier-row của phiếu (tracking — để set IsWinner).
|
||||||
{
|
var rows = await db.PurchaseEvaluationSuppliers
|
||||||
// ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ──
|
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
|
.ToListAsync(ct);
|
||||||
?? throw new NotFoundException("Supplier", supplierId);
|
|
||||||
|
|
||||||
// Verify supplier nằm trong danh sách phiếu
|
// Distinct các id được chọn (FE có thể gửi trùng).
|
||||||
var hasSupplier = await db.PurchaseEvaluationSuppliers
|
var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
|
||||||
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct);
|
|
||||||
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
|
|
||||||
|
|
||||||
entity.SelectedSupplierId = supplierId;
|
// Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale).
|
||||||
}
|
var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
|
||||||
else
|
foreach (var sid in selectedIds)
|
||||||
{
|
{
|
||||||
// ── Bỏ chọn (clear về none) — KHÔNG validate participating list ──
|
if (!participatingSupplierIds.Contains(sid))
|
||||||
// Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo
|
throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
|
||||||
// SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0.
|
|
||||||
// ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây.
|
|
||||||
entity.SelectedSupplierId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại.
|
||||||
|
foreach (var r in rows)
|
||||||
|
r.IsWinner = selectedIds.Contains(r.SupplierId);
|
||||||
|
|
||||||
|
// Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng
|
||||||
|
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null.
|
||||||
|
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null;
|
||||||
|
|
||||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
{
|
{
|
||||||
PurchaseEvaluationId = entity.Id,
|
PurchaseEvaluationId = entity.Id,
|
||||||
@ -430,7 +438,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
|||||||
PhaseAtChange = entity.Phase,
|
PhaseAtChange = entity.Phase,
|
||||||
UserId = currentUser.UserId,
|
UserId = currentUser.UserId,
|
||||||
UserName = currentUser.FullName ?? currentUser.Email,
|
UserName = currentUser.FullName ?? currentUser.Email,
|
||||||
Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu",
|
Summary = selectedIds.Count == 0
|
||||||
|
? "Bỏ chọn đơn vị NCC/TP trúng thầu"
|
||||||
|
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu",
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@ -52,7 +52,7 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
|
|||||||
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
||||||
RuleFor(x => x.HoSoLink).MaximumLength(1000);
|
RuleFor(x => x.HoSoLink).MaximumLength(1000);
|
||||||
// [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
|
// [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
|
||||||
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
|
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
|
||||||
.When(x => x.BudgetPeriodAmount.HasValue)
|
.When(x => x.BudgetPeriodAmount.HasValue)
|
||||||
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
|
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
|
||||||
}
|
}
|
||||||
@ -230,7 +230,7 @@ public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<U
|
|||||||
RuleFor(x => x.MoTa).MaximumLength(2000);
|
RuleFor(x => x.MoTa).MaximumLength(2000);
|
||||||
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
||||||
RuleFor(x => x.HoSoLink).MaximumLength(1000);
|
RuleFor(x => x.HoSoLink).MaximumLength(1000);
|
||||||
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
|
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
|
||||||
.When(x => x.BudgetPeriodAmount.HasValue)
|
.When(x => x.BudgetPeriodAmount.HasValue)
|
||||||
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
|
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
|
||||||
}
|
}
|
||||||
@ -336,12 +336,13 @@ public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<
|
|||||||
{
|
{
|
||||||
public AdjustPurchaseEvaluationBudgetCommandValidator()
|
public AdjustPurchaseEvaluationBudgetCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue);
|
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetPeriodAmount.HasValue); // [Mig 58 item 2] 0 hợp lệ
|
||||||
// [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC
|
// [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC
|
||||||
// vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13)
|
// vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13)
|
||||||
// yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc
|
// yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc
|
||||||
// GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm).
|
// GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm).
|
||||||
// BudgetPeriodAmount vẫn phải > 0; submit guard "đã nhập NS kỳ này" không đổi.
|
// [Mig 58 item 2 CCM] BudgetPeriodAmount cho phép = 0 (SSG ngân sách 0 hợp lệ); submit
|
||||||
|
// guard chỉ chặn khi NULL (chưa nhập gì), KHÔNG chặn 0 cố ý.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -958,7 +959,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
curLevel.AllowReturnToDrafter,
|
curLevel.AllowReturnToDrafter,
|
||||||
curLevel.AllowApproverEditDetails,
|
curLevel.AllowApproverEditDetails,
|
||||||
curLevel.AllowApproverEditBudget,
|
curLevel.AllowApproverEditBudget,
|
||||||
curLevel.AllowApproverSkipToFinal);
|
curLevel.AllowApproverSkipToFinal,
|
||||||
|
curLevel.AllowApproverFinalize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1083,7 +1085,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
|
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
|
||||||
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
|
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
|
||||||
var winnerSupplierRowIds = e.Suppliers
|
var winnerSupplierRowIds = e.Suppliers
|
||||||
.Where(s => s.SupplierId == e.SelectedSupplierId)
|
.Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
|
||||||
.Select(s => s.Id)
|
.Select(s => s.Id)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
var winnerQuoteTotal = e.Details
|
var winnerQuoteTotal = e.Details
|
||||||
@ -1119,7 +1121,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
s.Id, s.SupplierId,
|
s.Id, s.SupplierId,
|
||||||
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
|
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
|
||||||
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
|
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
|
||||||
s.PaymentTermText, s.Note, s.Order))
|
s.PaymentTermText, s.Note, s.Order, s.IsWinner))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
e.Details
|
e.Details
|
||||||
.OrderBy(d => d.Order)
|
.OrderBy(d => d.Order)
|
||||||
|
|||||||
@ -130,5 +130,16 @@ public class ApprovalWorkflowLevel : BaseEntity
|
|||||||
/// reinforced 3× cumulative. NO BACKFILL — 4 prod user lose value cũ per bro Option A.
|
/// reinforced 3× cumulative. NO BACKFILL — 4 prod user lose value cũ per bro Option A.
|
||||||
public bool AllowApproverSkipToFinal { get; set; }
|
public bool AllowApproverSkipToFinal { get; set; }
|
||||||
|
|
||||||
|
/// F5 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — Cho phép NV slot này, khi duyệt
|
||||||
|
/// ở cấp của mình, KẾT THÚC quy trình luôn (Phase=DaDuyet) KHÔNG cần trình CEO/
|
||||||
|
/// các Bước-Cấp sau. Admin opt-in per slot trong Workflow Designer ("Duyệt là
|
||||||
|
/// kết thúc (không trình CEO)"). Default false. KHÁC AllowApproverSkipToFinal
|
||||||
|
/// (skip = advance pointer tới Cấp cuối, CEO vẫn ký thật): F5 = chính NV này
|
||||||
|
/// approve = terminal NGAY. Service ApproveV2Async TỰ finalize khi
|
||||||
|
/// matchingLevel.AllowApproverFinalize=true (AUTO theo cấu hình quy trình, anh Kiệt
|
||||||
|
/// chốt 2026-06-24 — KHÔNG ô-tích runtime; admin per-level designation = cổng duy
|
||||||
|
/// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern.
|
||||||
|
public bool AllowApproverFinalize { get; set; }
|
||||||
|
|
||||||
public ApprovalWorkflowStep? Step { get; set; }
|
public ApprovalWorkflowStep? Step { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,5 +18,14 @@ public class PurchaseEvaluationSupplier : BaseEntity
|
|||||||
public string? Note { get; set; } // Chip trạng thái so sánh
|
public string? Note { get; set; } // Chip trạng thái so sánh
|
||||||
public int Order { get; set; } // Thứ tự cột trong bảng so sánh
|
public int Order { get; set; } // Thứ tự cột trong bảng so sánh
|
||||||
|
|
||||||
|
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP
|
||||||
|
// cùng trúng (whole-supplier multi-select). IsWinner=true → báo giá của đơn vị
|
||||||
|
// này tính vào "Giá trị kỳ này" (winnerQuoteTotal = SUM ThanhTien của MỌI
|
||||||
|
// winner). Thay tiêu chí cũ "SupplierId == SelectedSupplierId" (1 winner) bằng
|
||||||
|
// IsWinner trên supplier-row = nguồn-sự-thật mới. SelectedSupplierId vẫn sync:
|
||||||
|
// = winner duy nhất khi đúng 1, null khi ≥2 (multi). Backfill Mig 58:
|
||||||
|
// IsWinner=1 cho row khớp SelectedSupplierId cũ (single-winner phiếu UAT giữ).
|
||||||
|
public bool IsWinner { get; set; }
|
||||||
|
|
||||||
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
|
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,5 +93,10 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
|
|||||||
// slot này skip thẳng Cấp cuối lúc đang duyệt ChoDuyet. Default false
|
// slot này skip thẳng Cấp cuối lúc đang duyệt ChoDuyet. Default false
|
||||||
// (admin opt-in). Semantic + storage cũ ở Users table per-Drafter đã drop.
|
// (admin opt-in). Semantic + storage cũ ở Users table per-Drafter đã drop.
|
||||||
e.Property(x => x.AllowApproverSkipToFinal).HasDefaultValue(false);
|
e.Property(x => x.AllowApproverSkipToFinal).HasDefaultValue(false);
|
||||||
|
|
||||||
|
// Mig 58 (2026-06-24 — CCM/anh Kiệt FDC) — F5 per-NV: cho phép NV slot này
|
||||||
|
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
|
||||||
|
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
|
||||||
|
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,6 +82,9 @@ public class PurchaseEvaluationSupplierConfiguration : IEntityTypeConfiguration<
|
|||||||
b.Property(x => x.PaymentTermText).HasMaxLength(200);
|
b.Property(x => x.PaymentTermText).HasMaxLength(200);
|
||||||
b.Property(x => x.Note).HasMaxLength(500);
|
b.Property(x => x.Note).HasMaxLength(500);
|
||||||
|
|
||||||
|
// [Mig 58] Multi-winner flag — default false (mirror per-slot bool pattern).
|
||||||
|
b.Property(x => x.IsWinner).HasDefaultValue(false);
|
||||||
|
|
||||||
b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();
|
b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();
|
||||||
b.HasIndex(x => x.SupplierId);
|
b.HasIndex(x => x.SupplierId);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPeMultiWinnerAndLevelFinalize : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsWinner",
|
||||||
|
table: "PurchaseEvaluationSuppliers",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowApproverFinalize",
|
||||||
|
table: "ApprovalWorkflowLevels",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
// [Mig 58] Backfill IsWinner từ winner đơn lẻ cũ (SelectedSupplierId) —
|
||||||
|
// phiếu UAT đang chạy giữ winner đã chọn dưới mô hình multi-winner mới.
|
||||||
|
// Chạy SAU AddColumn (cột đã tồn tại). Idempotent: chỉ set 1 (default 0
|
||||||
|
// không đụng) → re-run no-op. KHÔNG un-backfill ở Down (DropColumn xoá hẳn).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE pes SET IsWinner = 1
|
||||||
|
FROM PurchaseEvaluationSuppliers pes
|
||||||
|
INNER JOIN PurchaseEvaluations pe ON pe.Id = pes.PurchaseEvaluationId
|
||||||
|
WHERE pe.SelectedSupplierId IS NOT NULL AND pes.SupplierId = pe.SelectedSupplierId;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsWinner",
|
||||||
|
table: "PurchaseEvaluationSuppliers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowApproverFinalize",
|
||||||
|
table: "ApprovalWorkflowLevels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -202,6 +202,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
.HasDefaultValue(false);
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowApproverFinalize")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<bool>("AllowApproverSkipToFinal")
|
b.Property<bool>("AllowApproverSkipToFinal")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
@ -5275,6 +5280,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("nvarchar(200)");
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsWinner")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<string>("Note")
|
b.Property<string>("Note")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("nvarchar(500)");
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|||||||
@ -172,28 +172,25 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
|
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
|
||||||
// tránh mismatch 2 tầng).
|
// tránh mismatch 2 tầng).
|
||||||
var missing = new List<string>();
|
var missing = new List<string>();
|
||||||
if (evaluation.SelectedSupplierId is not Guid winnerId)
|
// [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá
|
||||||
|
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner →
|
||||||
|
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới
|
||||||
|
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
|
||||||
|
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||||
|
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (winnerRowIds.Count == 0)
|
||||||
{
|
{
|
||||||
missing.Add("chưa chọn Đơn vị NCC/TP");
|
missing.Add("chưa chọn Đơn vị NCC/TP");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||||
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId)
|
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||||
.Select(s => s.Id)
|
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||||
.ToListAsync(ct);
|
if (winnerQuoteTotal <= 0)
|
||||||
if (winnerRowIds.Count == 0)
|
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
||||||
{
|
|
||||||
missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
|
|
||||||
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
|
||||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
|
||||||
if (winnerQuoteTotal <= 0)
|
|
||||||
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
|
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
|
||||||
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
|
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
|
||||||
@ -202,15 +199,18 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
|
// (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
|
||||||
// ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
|
// ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
|
||||||
// (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
|
// (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
|
||||||
|
// [Mig 58 item 2 CCM] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn gửi duyệt được). Chỉ
|
||||||
|
// fallback ProInitial khi drafter CHƯA nhập (null) — KHÔNG override số 0 cố ý thành
|
||||||
|
// ProInitial rồi vô tình null-block. Block cuối chỉ fire khi null; 0 (cố ý) đi qua.
|
||||||
decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
|
decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
|
||||||
if ((effectiveBudget is null || effectiveBudget <= 0) && evaluation.WorkItemId is Guid wiId)
|
if (effectiveBudget is null && evaluation.WorkItemId is Guid wiId)
|
||||||
{
|
{
|
||||||
effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
|
effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
|
||||||
.Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId)
|
.Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId)
|
||||||
.Select(b => b.ProInitialAmount)
|
.Select(b => b.ProInitialAmount)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
}
|
}
|
||||||
if (effectiveBudget is null || effectiveBudget <= 0)
|
if (effectiveBudget is null)
|
||||||
{
|
{
|
||||||
missing.Add("chưa nhập Ngân sách kỳ này");
|
missing.Add("chưa nhập Ngân sách kỳ này");
|
||||||
}
|
}
|
||||||
@ -851,6 +851,31 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
|
// (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
|
||||||
// kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
|
// kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
|
||||||
// Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
|
// Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
|
||||||
|
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC item 4] Cấp được cấu hình KẾT THÚC trong Quy
|
||||||
|
// trình duyệt (admin bật AllowApproverFinalize cho slot, vd Cấp anh Chương). Duyệt tới
|
||||||
|
// cấp này = TỰ kết thúc (DaDuyet), bỏ qua MỌI Bước/Cấp còn lại (kể cả CEO) — quy-trình-
|
||||||
|
// driven, KHÔNG cần ô-tích runtime (anh Kiệt chốt 2026-06-24 "cho bật trong quy trình
|
||||||
|
// hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt:
|
||||||
|
// người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC
|
||||||
|
// ccm-delegation + advance.
|
||||||
|
if (matchingLevel.AllowApproverFinalize)
|
||||||
|
{
|
||||||
|
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
|
||||||
|
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||||
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
|
evaluation.CurrentApprovalLevelOrder = null;
|
||||||
|
evaluation.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(
|
||||||
|
evaluation,
|
||||||
|
PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
actorUserId,
|
||||||
|
ApprovalDecision.Approve,
|
||||||
|
$"[Duyệt KẾT THÚC tại Bước {currentIdx + 1} Cấp {currentLevelOrder} — cấu hình quy trình, không trình CEO] {comment ?? ""}".Trim(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (finalizeByCcmDelegation)
|
if (finalizeByCcmDelegation)
|
||||||
{
|
{
|
||||||
if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
|
if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
|
||||||
@ -861,8 +886,7 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
|
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
|
||||||
|
|
||||||
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||||
.Where(s => s.PurchaseEvaluationId == evaluation.Id
|
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner
|
||||||
&& s.SupplierId == evaluation.SelectedSupplierId)
|
|
||||||
.Select(s => s.Id)
|
.Select(s => s.Id)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m
|
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m
|
||||||
|
|||||||
@ -0,0 +1,231 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-before/after] WinnerQuoteTotal multi-winner.
|
||||||
|
// item 1: winnerQuoteTotal = SUM(báo giá ThanhTien) MỌI supplier-row IsWinner — KHÔNG còn
|
||||||
|
// theo SelectedSupplierId (nguồn-sự-thật mới = IsWinner). Verify qua GetDetail read DTO
|
||||||
|
// (GetPurchaseEvaluationQueryHandler → PurchaseEvaluationDetailBundleDto.WinnerQuoteTotal,
|
||||||
|
// PurchaseEvaluationFeatures.cs ~1090-1097): build winnerSupplierRowIds = Suppliers IsWinner
|
||||||
|
// → SUM Details.Quotes thuộc các row đó.
|
||||||
|
//
|
||||||
|
// Harness mirror PeWorkItemBudgetTests (IdentityFixture + SQLite + GetPurchaseEvaluationQuery
|
||||||
|
// stack). KHÔNG touch production — test theo CODE đã land (S34). Query handler 3-dep
|
||||||
|
// (db + UserManager + ICurrentUser); dùng Admin để bỏ qua authz scope.
|
||||||
|
public class PeMultiWinnerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid? UserId { get; init; } = Guid.NewGuid();
|
||||||
|
public string? Email { get; init; } = "actor@test.local";
|
||||||
|
public string? FullName { get; init; } = "Actor Test";
|
||||||
|
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||||
|
public bool IsAuthenticated => UserId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeCurrentUser AsRoles(params string[] roles) => new() { Roles = roles };
|
||||||
|
|
||||||
|
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
|
||||||
|
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
|
||||||
|
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
|
||||||
|
|
||||||
|
private static async Task<PurchaseEvaluation> SeedPeAsync(TestApplicationDbContext db, string code = "PE-MW")
|
||||||
|
{
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||||
|
MaPhieu = code,
|
||||||
|
TenGoiThau = "Gói thầu multi-winner",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 detail dùng chung cho mọi NCC (mỗi NCC 1 quote thuộc detail này).
|
||||||
|
private static async Task<Guid> SeedDetailAsync(TestApplicationDbContext db, PurchaseEvaluation pe)
|
||||||
|
{
|
||||||
|
var detail = new PurchaseEvaluationDetail
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
GroupCode = "A.I",
|
||||||
|
GroupName = "Bê tông",
|
||||||
|
NoiDung = "Concrete M100",
|
||||||
|
Order = 0,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationDetails.Add(detail);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return detail.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin
|
||||||
|
// IsWinner trên PES row. Return supplierId master.
|
||||||
|
private static async Task<Guid> SeedSupplierWithQuoteAsync(
|
||||||
|
TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId,
|
||||||
|
string code, decimal thanhTien, bool isWinner, int order)
|
||||||
|
{
|
||||||
|
var supplier = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
|
||||||
|
db.Suppliers.Add(supplier);
|
||||||
|
var pes = new PurchaseEvaluationSupplier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
SupplierId = supplier.Id,
|
||||||
|
Order = order,
|
||||||
|
IsWinner = isWinner,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||||
|
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
PurchaseEvaluationDetailId = detailId,
|
||||||
|
PurchaseEvaluationSupplierId = pes.Id,
|
||||||
|
ThanhTien = thanhTien,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return supplier.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WinnerQuoteTotal_ThreeWinners_SumsAllThreeThanhTien()
|
||||||
|
{
|
||||||
|
// ⭐ TRỤC CỐT LÕI item 1: 3 NCC cùng trúng (IsWinner=true) → WinnerQuoteTotal =
|
||||||
|
// SUM cả 3 báo giá (37.8 + 9 + 10 = 56.8tr), KHÔNG chỉ 1 đơn vị.
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var pe = await SeedPeAsync(db, "PE-MW-3WIN");
|
||||||
|
var detailId = await SeedDetailAsync(db, pe);
|
||||||
|
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-A", 37_800_000m, isWinner: true, order: 0);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-B", 9_000_000m, isWinner: true, order: 1);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-C", 10_000_000m, isWinner: true, order: 2);
|
||||||
|
|
||||||
|
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||||
|
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
bundle.WinnerQuoteTotal.Should().Be(56_800_000m, "tổng giá gói = SUM báo giá MỌI đơn vị IsWinner");
|
||||||
|
bundle.SelectedSupplierId.Should().BeNull("≥2 winner → SelectedSupplierId không sync 1 đơn vị");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WinnerQuoteTotal_ExcludesNonWinnerSuppliers()
|
||||||
|
{
|
||||||
|
// 4 NCC tham gia, chỉ 2 IsWinner=true → WinnerQuoteTotal CHỈ tính 2 winner
|
||||||
|
// (100 + 50 = 150tr), bỏ qua 2 NCC không trúng (200 + 300tr KHÔNG cộng).
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var pe = await SeedPeAsync(db, "PE-MW-EXCL");
|
||||||
|
var detailId = await SeedDetailAsync(db, pe);
|
||||||
|
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W1", 100_000_000m, isWinner: true, order: 0);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W2", 50_000_000m, isWinner: true, order: 1);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L1", 200_000_000m, isWinner: false, order: 2);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L2", 300_000_000m, isWinner: false, order: 3);
|
||||||
|
|
||||||
|
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||||
|
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
bundle.WinnerQuoteTotal.Should().Be(150_000_000m, "chỉ SUM winner — NCC không trúng bị loại");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WinnerQuoteTotal_SingleWinner_EqualsThatSupplierQuote_SelectedSupplierIdSet()
|
||||||
|
{
|
||||||
|
// 1 winner (single-winner backward compat) → WinnerQuoteTotal = đúng báo giá đơn
|
||||||
|
// vị đó + SelectedSupplierId sync = winner (handler select-winner set khi count==1).
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var pe = await SeedPeAsync(db, "PE-MW-1WIN");
|
||||||
|
var detailId = await SeedDetailAsync(db, pe);
|
||||||
|
|
||||||
|
var winner = await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-SOLO", 75_000_000m, isWinner: true, order: 0);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-OTHER", 80_000_000m, isWinner: false, order: 1);
|
||||||
|
// Mô phỏng handler select-winner sync SelectedSupplierId khi đúng 1 winner.
|
||||||
|
pe.SelectedSupplierId = winner;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||||
|
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
bundle.WinnerQuoteTotal.Should().Be(75_000_000m, "1 winner → tổng = đúng báo giá đơn vị đó");
|
||||||
|
bundle.SelectedSupplierId.Should().Be(winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WinnerQuoteTotal_NoWinner_IsZero()
|
||||||
|
{
|
||||||
|
// Chưa chọn winner nào (mọi PES IsWinner=false) → WinnerQuoteTotal = 0 (KHÔNG
|
||||||
|
// lẫn báo giá các NCC tham gia chưa-trúng).
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var pe = await SeedPeAsync(db, "PE-MW-NONE");
|
||||||
|
var detailId = await SeedDetailAsync(db, pe);
|
||||||
|
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-X", 40_000_000m, isWinner: false, order: 0);
|
||||||
|
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-Y", 60_000_000m, isWinner: false, order: 1);
|
||||||
|
|
||||||
|
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||||
|
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
bundle.WinnerQuoteTotal.Should().Be(0m, "0 winner → tổng gói = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WinnerQuoteTotal_MultipleQuotesPerWinner_SumsAllRows()
|
||||||
|
{
|
||||||
|
// 1 winner nhưng có 2 detail (2 dòng báo giá thuộc cùng PES winner) → SUM cả 2
|
||||||
|
// dòng (20 + 30 = 50tr). Verify SelectMany Details.Quotes gộp mọi dòng của winner-row.
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var pe = await SeedPeAsync(db, "PE-MW-MULTQ");
|
||||||
|
|
||||||
|
// 2 detail rời.
|
||||||
|
var detail1 = await SeedDetailAsync(db, pe);
|
||||||
|
var detail2 = new PurchaseEvaluationDetail
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id,
|
||||||
|
GroupCode = "A.II", GroupName = "Thép", NoiDung = "Steel D10", Order = 1,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationDetails.Add(detail2);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// 1 winner PES + 2 quote (mỗi detail 1 dòng).
|
||||||
|
var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-2Q", Name = "NCC 2 dòng" };
|
||||||
|
db.Suppliers.Add(supplier);
|
||||||
|
var pes = new PurchaseEvaluationSupplier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, SupplierId = supplier.Id,
|
||||||
|
Order = 0, IsWinner = true,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||||
|
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
|
||||||
|
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 20_000_000m,
|
||||||
|
});
|
||||||
|
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
|
||||||
|
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||||
|
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
bundle.WinnerQuoteTotal.Should().Be(50_000_000m, "SUM mọi dòng báo giá của winner-row qua nhiều detail");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,15 +10,21 @@ using SolutionErp.Infrastructure.Tests.Common;
|
|||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner
|
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand
|
||||||
// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`.
|
// MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)` →
|
||||||
// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId:
|
// `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)`.
|
||||||
// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier
|
// Handler (PurchaseEvaluationDetailFeatures.cs ~404-447):
|
||||||
// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của
|
// • Load MỌI supplier-row của phiếu (tracking) → set IsWinner = (row.SupplierId ∈
|
||||||
// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x.
|
// selectedIds), false phần còn lại. IsWinner trên supplier-row = nguồn-sự-thật mới.
|
||||||
// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null,
|
// • selectedIds rỗng [] = BỎ CHỌN tất cả (IsWinner=false hết) — thay null cũ (anh Kiệt C5).
|
||||||
// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất).
|
// • Mỗi id chọn PHẢI thuộc participating-list (PurchaseEvaluationSuppliers) → Conflict.
|
||||||
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị.
|
// • Distinct id (FE có thể gửi trùng).
|
||||||
|
// • Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng tạo HĐ +
|
||||||
|
// DTO cũ); ≥2 HOẶC 0 → null.
|
||||||
|
// • KHÔNG còn check master Suppliers existence (logic mới chỉ validate participating-list)
|
||||||
|
// — NotFound master-existence bỏ; chỉ Conflict khi id không thuộc list của phiếu.
|
||||||
|
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary "Bỏ chọn đơn vị NCC/TP trúng thầu"
|
||||||
|
// (count==0) vs "Chọn {n} đơn vị NCC/TP trúng thầu".
|
||||||
//
|
//
|
||||||
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
|
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
|
||||||
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
|
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
|
||||||
@ -67,74 +73,97 @@ public class PeSelectWinnerClearTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
|
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
|
||||||
private static async Task AddParticipatingAsync(
|
// Return PES row Id để assert IsWinner.
|
||||||
|
private static async Task<Guid> AddParticipatingAsync(
|
||||||
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
|
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
|
||||||
{
|
{
|
||||||
db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier
|
var row = new PurchaseEvaluationSupplier
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
PurchaseEvaluationId = peId,
|
PurchaseEvaluationId = peId,
|
||||||
SupplierId = supplierId,
|
SupplierId = supplierId,
|
||||||
Order = order,
|
Order = order,
|
||||||
});
|
};
|
||||||
|
db.PurchaseEvaluationSuppliers.Add(row);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return row.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-read IsWinner cho 1 supplier-row (theo SupplierId) — AsNoTracking để né cache.
|
||||||
|
private static async Task<bool> IsWinnerAsync(
|
||||||
|
TestApplicationDbContext db, Guid peId, Guid supplierId)
|
||||||
|
=> await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||||
|
.Where(s => s.PurchaseEvaluationId == peId && s.SupplierId == supplierId)
|
||||||
|
.Select(s => s.IsWinner)
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 1. CLEAR branch (MỚI — trục quan trọng nhất)
|
// 1. CLEAR branch ([] = bỏ chọn tất cả — trục quan trọng nhất)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull()
|
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
|
||||||
{
|
{
|
||||||
// ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong
|
// ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list →
|
||||||
// participating-list → gọi SupplierId=null → clear về null, KHÔNG ném.
|
// gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false.
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
|
// Pre-set s1 = winner (mô phỏng phiếu đã chọn trước).
|
||||||
|
var row = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||||
|
row.IsWinner = true;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||||
await handler.Handle(cmd, CancellationToken.None);
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
|
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → mọi supplier-row IsWinner=false");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict()
|
public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict()
|
||||||
{
|
{
|
||||||
// Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có
|
// Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không
|
||||||
// SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị
|
// có id cần kiểm). Phiếu có 2 NCC tham gia, s1 đang là winner → clear [] →
|
||||||
// gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound.
|
// KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null.
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-GONE");
|
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
||||||
|
var s2 = await SeedSupplierAsync(db, "NCC-2");
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||||
// CỐ Ý không AddParticipating → s1 không thuộc list của phiếu.
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||||
|
r1.IsWinner = true;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().NotThrowAsync("clear KHÔNG validate participating-list");
|
await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list");
|
||||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
reloaded.SelectedSupplierId.Should().BeNull();
|
reloaded.SelectedSupplierId.Should().BeNull();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
|
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
|
||||||
{
|
{
|
||||||
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa =
|
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không row IsWinner) →
|
||||||
// idempotent, vẫn null, không ném. Empty participating-list cũng không cản.
|
// clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản.
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().NotThrowAsync();
|
await act.Should().NotThrowAsync();
|
||||||
@ -145,34 +174,35 @@ public class PeSelectWinnerClearTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_WritesChangelog_WithBoChonSummary()
|
public async Task Clear_WritesChangelog_WithBoChonSummary()
|
||||||
{
|
{
|
||||||
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC
|
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn đơn vị
|
||||||
// trúng thầu" (phân biệt với "Chọn NCC trúng thầu" của nhánh select).
|
// NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select).
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||||
await handler.Handle(cmd, CancellationToken.None);
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||||
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
log.Summary.Should().Be("Bỏ chọn NCC trúng thầu");
|
log.Summary.Should().Be("Bỏ chọn đơn vị NCC/TP trúng thầu");
|
||||||
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
||||||
log.Action.Should().Be(ChangelogAction.Update);
|
log.Action.Should().Be(ChangelogAction.Update);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2. SELECT branch (regression — logic cũ giữ nguyên 100%)
|
// 2. SELECT branch — 1 winner (backward compat single-winner)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId()
|
public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
|
||||||
{
|
{
|
||||||
// Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list)
|
// Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list) →
|
||||||
// → set SelectedSupplierId = đúng id.
|
// SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true.
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
|
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
|
||||||
@ -180,18 +210,19 @@ public class PeSelectWinnerClearTests
|
|||||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id });
|
||||||
await handler.Handle(cmd, CancellationToken.None);
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
reloaded.SelectedSupplierId.Should().Be(s1.Id);
|
reloaded.SelectedSupplierId.Should().Be(s1.Id, "đúng 1 winner → SelectedSupplierId sync");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2()
|
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner()
|
||||||
{
|
{
|
||||||
// Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId
|
// Đổi winner: phiếu đang chọn s1 → chọn [s2] (cả 2 trong list) → SelectedSupplierId
|
||||||
// chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null).
|
// chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại).
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
||||||
@ -199,68 +230,217 @@ public class PeSelectWinnerClearTests
|
|||||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
// s1 đang là winner.
|
||||||
|
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||||
|
r1.IsWinner = true;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s2.Id });
|
||||||
await handler.Handle(cmd, CancellationToken.None);
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
reloaded.SelectedSupplierId.Should().Be(s2.Id);
|
reloaded.SelectedSupplierId.Should().Be(s2.Id);
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 là winner mới");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("winner cũ s1 bị set false");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
|
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
|
||||||
{
|
{
|
||||||
// Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict.
|
// Supplier không thuộc participating-list của phiếu → Conflict. IsWinner +
|
||||||
// entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges).
|
// SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges).
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
|
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
|
||||||
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
|
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
|
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||||
|
r1.IsWinner = true;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { outsider.Id });
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<ConflictException>()
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
.WithMessage("*NCC chưa được thêm vào phiếu*");
|
.WithMessage("*NCC chưa được thêm vào phiếu*");
|
||||||
|
|
||||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên");
|
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC mutate → winner cũ s1 giữ nguyên");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("Conflict → IsWinner s1 không bị set false");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Select_SupplierNotInMaster_ThrowsNotFound()
|
public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate()
|
||||||
{
|
{
|
||||||
// Supplier Guid không tồn tại trong Suppliers master → NotFound (check master
|
// Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict (mỗi id
|
||||||
// existence TRƯỚC participating-list).
|
// phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ
|
||||||
|
// (validate TRƯỚC khi set IsWinner cho toàn bộ rows).
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-OK");
|
||||||
|
var outsider = await SeedSupplierAsync(db, "NCC-FORGE");
|
||||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid());
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, outsider.Id });
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<NotFoundException>();
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*NCC chưa được thêm vào phiếu*");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("Conflict → KHÔNG set winner cho id hợp lệ trong cùng lệnh");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 3. PE existence — chung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId)
|
// 3. SELECT branch — MULTI winner (≥2 → joint winner, SelectedSupplierId=null)
|
||||||
|
// [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
|
||||||
|
{
|
||||||
|
// Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 →
|
||||||
|
// không còn "1 winner" duy nhất). Trục multi-winner cốt lõi.
|
||||||
|
using var fix = new SqliteDbFixture();
|
||||||
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-J1");
|
||||||
|
var s2 = await SeedSupplierAsync(db, "NCC-J2");
|
||||||
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id });
|
||||||
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.SelectedSupplierId.Should().BeNull("≥2 winner → không sync 1 SelectedSupplierId");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull()
|
||||||
|
{
|
||||||
|
// 4 NCC tham gia, chọn 3 → đúng 3 row IsWinner=true, NCC thứ 4 IsWinner=false,
|
||||||
|
// SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row.
|
||||||
|
using var fix = new SqliteDbFixture();
|
||||||
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-Q1");
|
||||||
|
var s2 = await SeedSupplierAsync(db, "NCC-Q2");
|
||||||
|
var s3 = await SeedSupplierAsync(db, "NCC-Q3");
|
||||||
|
var s4 = await SeedSupplierAsync(db, "NCC-Q4");
|
||||||
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3);
|
||||||
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id, s3.Id });
|
||||||
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.SelectedSupplierId.Should().BeNull();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s3.Id)).Should().BeTrue();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s4.Id)).Should().BeFalse("NCC không được chọn → IsWinner=false");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
|
||||||
|
{
|
||||||
|
// FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner:
|
||||||
|
// s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
|
||||||
|
using var fix = new SqliteDbFixture();
|
||||||
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-DUP");
|
||||||
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||||
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s1.Id });
|
||||||
|
await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.SelectedSupplierId.Should().Be(s1.Id, "trùng id Distinct → count==1 → sync SelectedSupplierId");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
|
||||||
|
{
|
||||||
|
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false +
|
||||||
|
// SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi.
|
||||||
|
using var fix = new SqliteDbFixture();
|
||||||
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-R1");
|
||||||
|
var s2 = await SeedSupplierAsync(db, "NCC-R2");
|
||||||
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
|
await handler.Handle(
|
||||||
|
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||||
|
CancellationToken.None);
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
|
||||||
|
|
||||||
|
await handler.Handle(
|
||||||
|
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.SelectedSupplierId.Should().BeNull();
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear sau multi → false hết");
|
||||||
|
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary()
|
||||||
|
{
|
||||||
|
// Multi-winner ghi Changelog Summary "Chọn {n} đơn vị NCC/TP trúng thầu" (n = số
|
||||||
|
// đơn vị chọn). Verify count chính xác trong message.
|
||||||
|
using var fix = new SqliteDbFixture();
|
||||||
|
var db = fix.Db;
|
||||||
|
var s1 = await SeedSupplierAsync(db, "NCC-CL1");
|
||||||
|
var s2 = await SeedSupplierAsync(db, "NCC-CL2");
|
||||||
|
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||||
|
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||||
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
|
await handler.Handle(
|
||||||
|
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||||
|
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
||||||
|
.SingleAsync();
|
||||||
|
log.Summary.Should().Be("Chọn 2 đơn vị NCC/TP trúng thầu");
|
||||||
|
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
||||||
|
log.Action.Should().Be(ChangelogAction.Update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4. PE existence — chung mọi nhánh (check TRƯỚC khi rẽ nhánh SupplierIds)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
|
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
|
||||||
{
|
{
|
||||||
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
|
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
|
||||||
// khi xét SupplierId (kể cả nhánh clear null cũng không bỏ qua existence).
|
// khi xét SupplierIds (kể cả nhánh clear [] cũng không bỏ qua existence).
|
||||||
using var fix = new SqliteDbFixture();
|
using var fix = new SqliteDbFixture();
|
||||||
var db = fix.Db;
|
var db = fix.Db;
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: Array.Empty<Guid>());
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<NotFoundException>();
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
@ -274,7 +454,7 @@ public class PeSelectWinnerClearTests
|
|||||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||||
var handler = BuildHandler(db);
|
var handler = BuildHandler(db);
|
||||||
|
|
||||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id);
|
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: new[] { s1.Id });
|
||||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<NotFoundException>();
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
|
|||||||
@ -857,13 +857,25 @@ public class PeWorkItemBudgetTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdjustBudget_Validator_BudgetPeriodZero_FailsValidation()
|
public void AdjustBudget_Validator_BudgetPeriodZero_PassesValidation()
|
||||||
{
|
{
|
||||||
// GreaterThan(0) when HasValue → 0 không hợp lệ.
|
// [Mig 58 item 2 — SPEC CHANGE] GreaterThanOrEqualTo(0) when HasValue → 0 HỢP LỆ
|
||||||
|
// (trước GreaterThan(0) chặn 0). SSG ngân sách 0 vẫn lưu/gửi duyệt được (anh Kiệt FDC).
|
||||||
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
|
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
|
||||||
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));
|
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));
|
||||||
|
|
||||||
result.IsValid.Should().BeFalse();
|
result.IsValid.Should().BeTrue("ngân sách kỳ này = 0 hợp lệ sau Mig 58");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustBudget_Validator_BudgetPeriodNegative_FailsValidation()
|
||||||
|
{
|
||||||
|
// [Mig 58 item 2] Boundary: 0 hợp lệ NHƯNG ÂM vẫn KHÔNG (GreaterThanOrEqualTo(0)
|
||||||
|
// = >= 0, loại số âm). Chứng minh nới chỉ tới 0, không mở cho âm.
|
||||||
|
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
|
||||||
|
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), -1m, null));
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse("ngân sách kỳ này ÂM không hợp lệ");
|
||||||
result.Errors.Should().Contain(e =>
|
result.Errors.Should().Contain(e =>
|
||||||
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
|
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,319 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// ===== item 4 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — PER-LEVEL finalize "Duyệt là
|
||||||
|
// KẾT THÚC, không trình CEO" — AUTO theo cấu hình quy trình (anh Kiệt chốt 2026-06-24
|
||||||
|
// "cho bật trong quy trình hay hơn") — test theo CODE (S34 rule, KHÔNG touch production).
|
||||||
|
//
|
||||||
|
// SPEC item 4 (AUTO, KHÔNG ô-tích runtime): ApprovalWorkflowLevel +AllowApproverFinalize
|
||||||
|
// (bool, Mig 58). Trong ApproveV2Async, SAU opinion UPSERT + skipToFinal, TRƯỚC
|
||||||
|
// ccm-delegation + advance:
|
||||||
|
// if (matchingLevel.AllowApproverFinalize) {
|
||||||
|
// ApplyApprovedPriceOnFinalize(...); // human PHẢI chọn giá chốt (Conflict nếu null)
|
||||||
|
// Phase = DaDuyet; clear pointers (StepIdx/LevelOrder/SlaDeadline = null); return;
|
||||||
|
// }
|
||||||
|
// ⚠️ Quy-trình-driven: cấp được cấu hình KẾT THÚC → duyệt tới đó là TỰ done, KHÔNG cần
|
||||||
|
// cờ runtime. KHÔNG threshold, KHÔNG role-gate (admin chỉ-định slot = đủ thẩm quyền). Bỏ
|
||||||
|
// qua MỌI Bước/Cấp còn lại (kể cả CEO ở step sau). Wrong-actor vẫn Forbidden TRƯỚC finalize
|
||||||
|
// (actor-match guard chạy trước).
|
||||||
|
//
|
||||||
|
// Mirror harness PeCcmThresholdFinalizeTests.cs cùng folder (IdentityFixture + SQLite +
|
||||||
|
// SeedWorkflowAsync + BuildPeAtApprovalSlot + NoOpNotificationService). Dựng PE TRỰC TIẾP
|
||||||
|
// ở ChoDuyet tại slot NON-terminal (còn Step/CEO sau) → drive 1 Approve.
|
||||||
|
public class PeApproverFinalizeTests
|
||||||
|
{
|
||||||
|
private const decimal ValidApprovedPrice = 400_000_000m;
|
||||||
|
private const string ValidApprovedSource = "Ncc";
|
||||||
|
|
||||||
|
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||||
|
TestApplicationDbContext db, FixedDateTime clock) CreateService()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var clock = new FixedDateTime(new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||||
|
return (svc, fix, db, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PurchaseEvaluation BuildPeAtApprovalSlot(
|
||||||
|
Guid approvalWorkflowId, int stepIdx, int levelOrder, string code = "PE-AF-001")
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = code,
|
||||||
|
TenGoiThau = "Test item 4 — per-level finalize (auto)",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = approvalWorkflowId,
|
||||||
|
CurrentWorkflowStepIndex = stepIdx,
|
||||||
|
CurrentApprovalLevelOrder = levelOrder,
|
||||||
|
SlaDeadline = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed workflow V2. stepApprovers[s][lvl] = (approverUserId, allowFinalize) cho Cấp
|
||||||
|
// (Order lvl+1) của Bước (Order s+1). 1 NV / cấp. Return ApprovalWorkflow đã persist.
|
||||||
|
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||||
|
TestApplicationDbContext db, (Guid user, bool allowFinalize)[][] stepApprovers)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-AF-V2",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||||
|
Name = "QT test item 4 per-level finalize (auto)",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
for (int s = 0; s < stepApprovers.Length; s++)
|
||||||
|
{
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = s + 1,
|
||||||
|
Name = $"Bước {s + 1}",
|
||||||
|
};
|
||||||
|
for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
|
||||||
|
{
|
||||||
|
var (user, allow) = stepApprovers[s][lvl];
|
||||||
|
step.Levels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = lvl + 1,
|
||||||
|
Name = $"Cấp {lvl + 1}",
|
||||||
|
ApproverUserId = user,
|
||||||
|
AllowApproverFinalize = allow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
}
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return wf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTO model: KHÔNG còn finalizeByApprover param — finalize tự chạy theo cờ cấp.
|
||||||
|
private static Task ApproveAsync(
|
||||||
|
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||||
|
string[] roles,
|
||||||
|
decimal? approvedPriceAmount = null,
|
||||||
|
string? approvedPriceSource = null) =>
|
||||||
|
svc.TransitionAsync(
|
||||||
|
evaluation: pe,
|
||||||
|
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
|
||||||
|
actorUserId: actorUserId,
|
||||||
|
actorRoles: roles,
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: null,
|
||||||
|
approvedPriceAmount: approvedPriceAmount,
|
||||||
|
approvedPriceSource: approvedPriceSource,
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 1. ⭐ LOAD-BEARING — slot NON-terminal AllowApproverFinalize=true + approver duyệt
|
||||||
|
// (+ giá chốt) → TỰ DaDuyet, bỏ CEO (Step 2), pointers cleared. AUTO, không ô-tích.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_LevelAllowsFinalize_AutoFinalizesDaDuyet_SkipsCeo_PointersCleared()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
// Bước 1 Cấp 1 = approver (AllowApproverFinalize=TRUE, đang đứng đây).
|
||||||
|
// Bước 2 Cấp 1 = CEO (sẽ bị BỎ QUA hoàn toàn).
|
||||||
|
var approver = (await fix.CreateUserAsync("appr@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { (approver, true) }, // Bước 1 Cấp 1 = approver, finalize-allowed
|
||||||
|
new[] { (ceo, false) }, // Bước 2 Cấp 1 = CEO (bỏ qua)
|
||||||
|
});
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1);
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||||
|
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
"slot AllowApproverFinalize → duyệt tới đây là TỰ kết thúc, bỏ CEO");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
|
||||||
|
pe.SlaDeadline.Should().BeNull("terminal → SLA null");
|
||||||
|
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize");
|
||||||
|
pe.ApprovedPriceSource.Should().Be(ValidApprovedSource);
|
||||||
|
|
||||||
|
// CEO KHÔNG được ghi opinion (bị bỏ qua — chỉ approver Bước 1 ký).
|
||||||
|
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||||
|
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
var approverLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id;
|
||||||
|
opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == approverLevelId,
|
||||||
|
"chỉ slot approver ký, không ghi hộ CEO");
|
||||||
|
|
||||||
|
// 1 Approval Approve (của approver), finalize-branch return → KHÔNG double.
|
||||||
|
var approvals = await db.PurchaseEvaluationApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
|
||||||
|
.ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double-finalize");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 2. CONFIG GATES IT — slot AllowApproverFinalize=FALSE + approver duyệt → KHÔNG
|
||||||
|
// finalize, advance bình thường lên CEO (Step 2). Chứng minh CHỈ cấp được cấu
|
||||||
|
// hình KẾT THÚC mới dừng; cấp thường vẫn trình tiếp.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_LevelNotAllowed_AdvancesToCeo_PhaseStaysChoDuyet()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, clock) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var approver = (await fix.CreateUserAsync("appr2@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo2@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { (approver, false) }, // ⭐ slot KHÔNG cấu hình KẾT THÚC
|
||||||
|
new[] { (ceo, false) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-002");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, approver, Array.Empty<string>()); // không giá chốt — chưa terminal
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
"slot không cấu hình kết thúc → advance bình thường lên CEO");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO");
|
||||||
|
pe.ApprovedPriceAmount.Should().BeNull("chưa terminal → chưa bind giá chốt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 3. PRICE REQUIRED — slot allow=true NHƯNG approvedPrice null (human) →
|
||||||
|
// Conflict("giá chốt"). Finalize cũng cần giá chốt như terminal cuối.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_LevelAllowsFinalize_NullPrice_ThrowsConflict()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var approver = (await fix.CreateUserAsync("appr3@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo3@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { (approver, true) },
|
||||||
|
new[] { (ceo, false) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-003");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// slot allowed NHƯNG KHÔNG truyền giá chốt → finalize đòi giá chốt.
|
||||||
|
var act = async () => await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||||
|
approvedPriceAmount: null, approvedPriceSource: null);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*giá chốt*");
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 4. WRONG-ACTOR precedence — approver-match guard chạy TRƯỚC finalize-branch:
|
||||||
|
// NV KHÔNG thuộc Cấp hiện tại duyệt → Forbidden (không phải lượt), KHÔNG tới
|
||||||
|
// được finalize logic. Bảo vệ forge request.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_ActorNotInLevel_ThrowsForbidden_BeforeFinalize()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var realApprover = (await fix.CreateUserAsync("real4@af.test", "Real Approver", null, Array.Empty<string>())).Id;
|
||||||
|
var intruder = (await fix.CreateUserAsync("intruder4@af.test", "Intruder", null, Array.Empty<string>())).Id;
|
||||||
|
var ceo = (await fix.CreateUserAsync("ceo4@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { (realApprover, true) }, // slot cấu hình kết thúc — nhưng intruder KHÔNG phải người này
|
||||||
|
new[] { (ceo, false) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-004");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// intruder (không trong Cấp) duyệt → Forbidden TRƯỚC finalize logic.
|
||||||
|
var act = async () => await ApproveAsync(svc, pe, intruder, Array.Empty<string>(),
|
||||||
|
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>();
|
||||||
|
|
||||||
|
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "không phải lượt → không finalize");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 5. AT-LAST-SLOT — slot CUỐI (1 bước 1 cấp) + allow=true → DaDuyet qua finalize-
|
||||||
|
// branch (chạy TRƯỚC normal terminal advance). 1 Approval, không double.
|
||||||
|
// =====================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_AtLastSlot_LevelAllowsFinalize_FinalizesDaDuyet_NoDoubleApproval()
|
||||||
|
{
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
// 1 bước 1 cấp = approver (slot cuối luôn), allow finalize.
|
||||||
|
var approver = (await fix.CreateUserAsync("appr5@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(db, new[]
|
||||||
|
{
|
||||||
|
new[] { (approver, true) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-005");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||||
|
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||||
|
"finalize ở slot cuối → DaDuyet qua finalize-branch (trước normal advance)");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||||
|
|
||||||
|
var approvals = await db.PurchaseEvaluationApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
|
||||||
|
.ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,6 +89,7 @@ public class PeCcmThresholdFinalizeTests
|
|||||||
PurchaseEvaluationId = pe.Id,
|
PurchaseEvaluationId = pe.Id,
|
||||||
SupplierId = supplierId,
|
SupplierId = supplierId,
|
||||||
Order = 0,
|
Order = 0,
|
||||||
|
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
|
||||||
};
|
};
|
||||||
var detail = new PurchaseEvaluationDetail
|
var detail = new PurchaseEvaluationDetail
|
||||||
{
|
{
|
||||||
|
|||||||
@ -79,6 +79,9 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
|
|
||||||
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
|
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
|
||||||
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
|
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
|
||||||
|
// [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard +
|
||||||
|
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set
|
||||||
|
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner.
|
||||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||||
{
|
{
|
||||||
@ -88,6 +91,7 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
PurchaseEvaluationId = pe.Id,
|
PurchaseEvaluationId = pe.Id,
|
||||||
SupplierId = supplierId,
|
SupplierId = supplierId,
|
||||||
Order = 0,
|
Order = 0,
|
||||||
|
IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows
|
||||||
};
|
};
|
||||||
var detail = new PurchaseEvaluationDetail
|
var detail = new PurchaseEvaluationDetail
|
||||||
{
|
{
|
||||||
@ -257,15 +261,16 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict()
|
public async Task Submit_MissingBudget_NullBlocks_ThrowsConflict()
|
||||||
{
|
{
|
||||||
// (4) Đủ winner + quote + comparison. Thiếu ngân sách: BudgetId null VÀ
|
// (4) [Mig 58 item 2] Đủ winner + quote + comparison. Ngân sách = NULL (chưa nhập,
|
||||||
// BudgetManualAmount = 0 → "chưa nhập Ngân sách" (cover cả nhánh manual=0,
|
// và phiếu KHÔNG có WorkItemId fallback ProInitial) → "chưa nhập Ngân sách kỳ này".
|
||||||
// không chỉ null).
|
// ĐỔI từ trước (block khi null||<=0): NAY chỉ NULL block, 0 hợp lệ (test 4b). Phiếu
|
||||||
|
// KHÔNG pin WorkItemId → effectiveBudget=null sau khi không fallback được.
|
||||||
var (svc, fix, db, _) = CreateService();
|
var (svc, fix, db, _) = CreateService();
|
||||||
using (fix)
|
using (fix)
|
||||||
{
|
{
|
||||||
var pe = BuildPeNhap(budgetPeriodAmount: 0m);
|
var pe = BuildPeNhap(budgetPeriodAmount: null); // null = chưa nhập → block
|
||||||
db.PurchaseEvaluations.Add(pe);
|
db.PurchaseEvaluations.Add(pe);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||||
@ -283,6 +288,65 @@ public class PeSubmitGuardAndBypassTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_BudgetZero_IsValid_PassesBudgetGuard_ThenWorkflowRequired()
|
||||||
|
{
|
||||||
|
// (4b) [Mig 58 item 2] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn cho gửi duyệt).
|
||||||
|
// ĐỔI hành vi: trước `is null || <= 0` block; NAY chỉ `is null` block → 0 đi qua.
|
||||||
|
// Đủ winner + quote>0 + comparison + budget=0 → guard Section-3 PASS, KHÔNG còn
|
||||||
|
// "chưa nhập Ngân sách". Phiếu KHÔNG pin workflow → dừng ở require-workflow guard
|
||||||
|
// (chứng minh đã qua budget-guard: message NHẮC workflow chứ KHÔNG nhắc ngân sách).
|
||||||
|
var (svc, fix, db, _) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var pe = BuildPeNhap(budgetPeriodAmount: 0m); // 0 = cố ý → hợp lệ
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
SeedComparisonAttachment(db, pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
|
||||||
|
|
||||||
|
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||||
|
ex.Which.Message.Should().NotContain("Ngân sách", "ngân sách = 0 hợp lệ → qua budget-guard");
|
||||||
|
ex.Which.Message.Should().Contain("quy trình duyệt", "dừng ở require-workflow (sau budget-guard)");
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_BudgetZero_WithWorkflow_SucceedsToChoDuyet()
|
||||||
|
{
|
||||||
|
// (4c) [Mig 58 item 2] END-TO-END ngân sách 0 hợp lệ: đủ 4 Section-3 (winner +
|
||||||
|
// quote>0 + comparison + budget=0) + pin workflow V2 → submit THÀNH CÔNG, Phase
|
||||||
|
// ChoDuyet (KHÔNG ConflictException "chưa nhập Ngân sách"). Đây là trục item 2 cốt
|
||||||
|
// lõi: ngân sách 0 không cản gửi duyệt. Drafter ngoài chuỗi → KHÔNG bypass, start
|
||||||
|
// pointer Bước 1 Cấp 1.
|
||||||
|
var (svc, fix, db, clock) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var approver = (await fix.CreateUserAsync("appr0@s60.test", "Approver", null, Array.Empty<string>())).Id;
|
||||||
|
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||||
|
|
||||||
|
var pe = BuildPeNhap(budgetPeriodAmount: 0m, approvalWorkflowId: wf.Id, code: "PE-S60-B0");
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||||
|
pe.SelectedSupplierId = supplierId;
|
||||||
|
SeedComparisonAttachment(db, pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await SubmitAsync(svc, pe, Guid.NewGuid()); // drafter random (ngoài chuỗi)
|
||||||
|
|
||||||
|
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "ngân sách 0 + đủ ĐK + workflow → gửi duyệt OK");
|
||||||
|
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||||
|
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Submit_MissingComparisonTable_ThrowsConflict()
|
public async Task Submit_MissingComparisonTable_ThrowsConflict()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user