[CLAUDE] PE+Contract+Budget integration — link Budget vào PE/HĐ + cột So với ngân sách
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s

BE wire BudgetId nullable FK qua command + DTO bundle:
- Budgets.Dtos: + BudgetSummaryDto (compact header snapshot, không kèm Details — gọi /budgets/{id} riêng nếu cần đối chiếu chi tiết)
- PurchaseEvaluations.Dtos: + BudgetId? + Budget? BudgetSummaryDto vào PurchaseEvaluationDetailBundleDto
- Contracts.Dtos: + BudgetId? + Budget? BudgetSummaryDto vào ContractDetailDto
- CreatePE + UpdatePEDraft + handlers: + BudgetId? param + validate (cùng Project + Phase=DaDuyet) + persist
- CreateContract + UpdateContractDraft + handlers: + BudgetId? param + validate + persist + log diff
- GetPE + GetContract handlers: load BudgetSummary nếu có link
- CreateContractFromEvaluation: carry forward pe.BudgetId → contract.BudgetId (nếu phiếu PE đã link)

FE PE (cả 2 app):
- types/purchaseEvaluation.ts: + BudgetSummary type + budgetId/budget vào PeDetailBundle
- PurchaseEvaluationCreatePage: thêm Select 'Ngân sách' filter Phase=DaDuyet + Project match (BE-side filter qua /budgets?projectId=&phase=4). Disabled khi chưa pick Project. Edit mode preserve.
- PeDetailTabs InfoTab: hiển thị Budget link với mã + tên + tổng (clickable → /budgets?id=)
- PeDetailTabs ItemsTab: thêm cột 'NS link · Δ' chỉ hiện khi ev.budgetId. Match per-row qua key groupCode|itemCode → fetch /budgets/{id} riêng. Footer aggregate row 'Tổng' + delta indicator (xanh dưới / đỏ vượt / xám khớp). No-match cell hiện '—'.

FE Contract (cả 2 app):
- types/contracts.ts: + ContractBudgetSummary + budgetId/budget vào ContractDetail
- ContractCreatePage HeaderForm: thêm Budget Select sau FormFields, useEffect reset khi đổi project
- ContractCreatePage EditForm: Select khi isDraft / read-only link card khi !isDraft

TS build pass cả 2 app + dotnet build clean. No new migration (BudgetId? nullable FK đã có từ migration 14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-28 16:41:11 +07:00
parent df12fb19c8
commit 61e5d4d503
16 changed files with 569 additions and 6 deletions

View File

@ -158,6 +158,18 @@ function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: bool
<Field label="Mô tả" value={ev.moTa ?? '—'} />
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
<Field
label="Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}
{ev.budget.tenNganSach}
{' · '}
<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : <span className="text-slate-400"> (chưa link)</span>}
/>
{ev.contractId && (
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} />
)}
@ -471,6 +483,25 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
const quoteKey = (detailId: string, supplierRowId: string) =>
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
// Match key: groupCode|itemCode (case-sensitive match; itemCode null cho phép).
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
return (
<div>
<div className="mb-3 flex items-center justify-between">
@ -499,6 +530,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
{showBudgetCol && (
<th className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right" title="So với ngân sách đã link">
NS link · Δ
</th>
)}
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
@ -517,6 +553,26 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{showBudgetCol && (() => {
const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
if (bgValue == null)
return <td className="border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right text-slate-300"></td>
const delta = d.thanhTienNganSach - bgValue
return (
<td
className={cn(
'border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right font-mono',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}
title={`Ngân sách: ${fmtMoney(bgValue)} · Δ ${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
>
{fmtMoney(bgValue)}
<div className="text-[10px]">{delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}</div>
</td>
)
})()}
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
return (
@ -548,6 +604,34 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</tr>
))}
</tbody>
{showBudgetCol && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 text-xs font-semibold">
<tr>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-right">Tổng:</td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(totalPeNganSach)}</td>
<td className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right font-mono">
{fmtMoney(totalBudget)}
{(() => {
const delta = totalPeNganSach - totalBudget
return (
<div className={cn(
'text-[10px]',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
{delta === 0 ? 'khớp ngân sách' : delta > 0 ? `vượt +${fmtMoney(delta)}` : `dưới ${fmtMoney(delta)}`}
</div>
)
})()}
</td>
{ev.suppliers.map(s => <td key={s.id} className="border-r border-slate-200" />)}
{!readOnly && <td />}
</tr>
</tfoot>
)}
</table>
</div>
)}

View File

@ -35,6 +35,7 @@ import {
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -303,9 +304,12 @@ function ContractHeaderForm({
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('')
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
useEffect(() => { setBudgetId('') }, [projectId])
const suppliers = useQuery({
queryKey: ['suppliers-all'],
@ -319,6 +323,15 @@ function ContractHeaderForm({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: !!projectId,
})
const qc = useQueryClient()
const create = useMutation({
@ -334,6 +347,7 @@ function ContractHeaderForm({
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
budgetId: budgetId || null,
})
return res.data.id
},
@ -372,6 +386,28 @@ function ContractHeaderForm({
templates={templates.data ?? []}
typeReadonly={false}
/>
<div className="mt-4 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={budgetId}
disabled={!projectId}
onChange={e => setBudgetId(e.target.value)}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}>
<Save className="h-4 w-4" />
@ -460,11 +496,21 @@ function ContractEditForm({
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
const templates = useQuery({
queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', contract.projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: isDraft,
})
const qc = useQueryClient()
const update = useMutation({
@ -476,6 +522,7 @@ function ContractEditForm({
noiDung: noiDung || null,
templateId: templateId || null,
draftData: null,
budgetId: budgetId || null,
})
},
onSuccess: () => {
@ -563,6 +610,37 @@ function ContractEditForm({
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label>
{isDraft ? (
<>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : contract.budget ? (
<a
href={`/budgets?id=${contract.budget.id}`}
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700"
>
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span>
{' · '}{contract.budget.tenNganSach}
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : (
<Input value="(không link)" disabled className="bg-slate-50" />
)}
</div>
</div>
</div>

View File

@ -17,7 +17,8 @@ import {
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import type { Project } from '@/types/master'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
@ -43,6 +44,24 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '' as string,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet. BE filter trên Project +
// Phase server-side để FE không phải lọc thêm.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: {
pageSize: 100,
projectId: form.projectId,
phase: BudgetPhase.DaDuyet,
},
})
return res.data.items
},
enabled: !!form.projectId,
})
useEffect(() => {
@ -54,6 +73,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '',
})
}
}, [existing.data])
@ -67,6 +87,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
@ -76,6 +97,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
},
onSuccess: res => {
@ -133,6 +155,29 @@ export function PurchaseEvaluationCreatePage() {
</Select>
</div>
<div>
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div>
<Label>Đa điểm</Label>
<Input

View File

@ -101,6 +101,16 @@ export type WorkflowSummary = {
nextPhases: number[]
}
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
export type ContractBudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type ContractDetail = {
id: string
maHopDong: string | null
@ -123,6 +133,8 @@ export type ContractDetail = {
draftData: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: ContractBudgetSummary | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]

View File

@ -165,6 +165,16 @@ export type PeChangelog = {
createdAt: string
}
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE).
export type BudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type PeDetailBundle = {
id: string
maPhieu: string | null
@ -186,6 +196,8 @@ export type PeDetailBundle = {
slaDeadline: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: BudgetSummary | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]

View File

@ -158,6 +158,18 @@ function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: bool
<Field label="Mô tả" value={ev.moTa ?? '—'} />
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
<Field
label="Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}
{ev.budget.tenNganSach}
{' · '}
<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : <span className="text-slate-400"> (chưa link)</span>}
/>
{ev.contractId && (
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} />
)}
@ -471,6 +483,25 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
const quoteKey = (detailId: string, supplierRowId: string) =>
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
// Match key: groupCode|itemCode (case-sensitive match; itemCode null cho phép).
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
return (
<div>
<div className="mb-3 flex items-center justify-between">
@ -499,6 +530,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
{showBudgetCol && (
<th className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right" title="So với ngân sách đã link">
NS link · Δ
</th>
)}
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? s.supplierName}
@ -517,6 +553,26 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{showBudgetCol && (() => {
const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
if (bgValue == null)
return <td className="border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right text-slate-300"></td>
const delta = d.thanhTienNganSach - bgValue
return (
<td
className={cn(
'border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right font-mono',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}
title={`Ngân sách: ${fmtMoney(bgValue)} · Δ ${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
>
{fmtMoney(bgValue)}
<div className="text-[10px]">{delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}</div>
</td>
)
})()}
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
return (
@ -548,6 +604,34 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</tr>
))}
</tbody>
{showBudgetCol && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 text-xs font-semibold">
<tr>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-right">Tổng:</td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(totalPeNganSach)}</td>
<td className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right font-mono">
{fmtMoney(totalBudget)}
{(() => {
const delta = totalPeNganSach - totalBudget
return (
<div className={cn(
'text-[10px]',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
{delta === 0 ? 'khớp ngân sách' : delta > 0 ? `vượt +${fmtMoney(delta)}` : `dưới ${fmtMoney(delta)}`}
</div>
)
})()}
</td>
{ev.suppliers.map(s => <td key={s.id} className="border-r border-slate-200" />)}
{!readOnly && <td />}
</tr>
</tfoot>
)}
</table>
</div>
)}

View File

@ -35,6 +35,7 @@ import {
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -303,9 +304,12 @@ function ContractHeaderForm({
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('')
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
useEffect(() => { setBudgetId('') }, [projectId])
const suppliers = useQuery({
queryKey: ['suppliers-all'],
@ -319,6 +323,15 @@ function ContractHeaderForm({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: !!projectId,
})
const qc = useQueryClient()
const create = useMutation({
@ -334,6 +347,7 @@ function ContractHeaderForm({
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
budgetId: budgetId || null,
})
return res.data.id
},
@ -372,6 +386,28 @@ function ContractHeaderForm({
templates={templates.data ?? []}
typeReadonly={false}
/>
<div className="mt-4 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={budgetId}
disabled={!projectId}
onChange={e => setBudgetId(e.target.value)}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}>
<Save className="h-4 w-4" />
@ -460,11 +496,21 @@ function ContractEditForm({
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
const templates = useQuery({
queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', contract.projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: isDraft,
})
const qc = useQueryClient()
const update = useMutation({
@ -476,6 +522,7 @@ function ContractEditForm({
noiDung: noiDung || null,
templateId: templateId || null,
draftData: null,
budgetId: budgetId || null,
})
},
onSuccess: () => {
@ -563,6 +610,37 @@ function ContractEditForm({
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label>
{isDraft ? (
<>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : contract.budget ? (
<a
href={`/budgets?id=${contract.budget.id}`}
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700"
>
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span>
{' · '}{contract.budget.tenNganSach}
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : (
<Input value="(không link)" disabled className="bg-slate-50" />
)}
</div>
</div>
</div>

View File

@ -17,7 +17,8 @@ import {
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import type { Project } from '@/types/master'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
@ -43,6 +44,24 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '' as string,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet. BE filter trên Project +
// Phase server-side để FE không phải lọc thêm.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: {
pageSize: 100,
projectId: form.projectId,
phase: BudgetPhase.DaDuyet,
},
})
return res.data.items
},
enabled: !!form.projectId,
})
useEffect(() => {
@ -54,6 +73,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '',
})
}
}, [existing.data])
@ -67,6 +87,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
@ -76,6 +97,7 @@ export function PurchaseEvaluationCreatePage() {
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
},
onSuccess: res => {
@ -133,6 +155,29 @@ export function PurchaseEvaluationCreatePage() {
</Select>
</div>
<div>
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div>
<Label>Đa điểm</Label>
<Input

View File

@ -101,6 +101,16 @@ export type WorkflowSummary = {
nextPhases: number[]
}
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
export type ContractBudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type ContractDetail = {
id: string
maHopDong: string | null
@ -123,6 +133,8 @@ export type ContractDetail = {
draftData: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: ContractBudgetSummary | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]

View File

@ -165,6 +165,16 @@ export type PeChangelog = {
createdAt: string
}
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE).
export type BudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type PeDetailBundle = {
id: string
maPhieu: string | null
@ -186,6 +196,8 @@ export type PeDetailBundle = {
slaDeadline: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: BudgetSummary | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]

View File

@ -57,6 +57,17 @@ public record BudgetWorkflowSummaryDto(
List<BudgetPhase> ActivePhases,
List<BudgetPhase> NextPhases);
// Snapshot ngân sách link cho PE / Contract DetailBundle. Compact — chỉ
// header info, không bao gồm BudgetDetails (gọi /budgets/{id} riêng nếu cần
// đối chiếu chi tiết theo từng GroupCode/ItemCode).
public record BudgetSummaryDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
int NamNganSach,
BudgetPhase Phase,
decimal TongNganSach);
public record BudgetDetailBundleDto(
Guid Id,
string? MaNganSach,

View File

@ -24,7 +24,8 @@ public record CreateContractCommand(
string? TenHopDong,
string? NoiDung,
bool BypassProcurementAndCCM,
string? DraftData) : IRequest<Guid>;
string? DraftData,
Guid? BudgetId) : IRequest<Guid>;
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
@ -59,6 +60,18 @@ public class CreateContractCommandHandler(
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
if (request.BudgetId is Guid bid)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != request.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
var entity = new Contract
{
Type = request.Type,
@ -73,6 +86,7 @@ public class CreateContractCommandHandler(
NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData,
BudgetId = request.BudgetId,
WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
@ -106,7 +120,8 @@ public record UpdateContractDraftCommand(
string? TenHopDong,
string? NoiDung,
Guid? TemplateId,
string? DraftData) : IRequest;
string? DraftData,
Guid? BudgetId) : IRequest;
public class UpdateContractDraftCommandHandler(
IApplicationDbContext db,
@ -120,6 +135,18 @@ public class UpdateContractDraftCommandHandler(
if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// Capture diff trước update để log
var changes = new List<object>();
if (entity.GiaTri != request.GiaTri)
@ -130,12 +157,15 @@ public class UpdateContractDraftCommandHandler(
changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung });
if (entity.TemplateId != request.TemplateId)
changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId });
if (entity.BudgetId != request.BudgetId)
changes.Add(new { Field = "BudgetId", Old = entity.BudgetId, New = request.BudgetId });
entity.GiaTri = request.GiaTri;
entity.TenHopDong = request.TenHopDong;
entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData;
entity.BudgetId = request.BudgetId;
if (changes.Count > 0)
{
@ -393,6 +423,17 @@ public class GetContractQueryHandler(
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Load Budget summary nếu có link.
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
if (c.BudgetId is Guid budgetId)
{
budgetSummary = await db.Budgets.AsNoTracking()
.Where(b => b.Id == budgetId)
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach))
.FirstOrDefaultAsync(ct);
}
// Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded
WorkflowPolicy workflowPolicy;
if (c.WorkflowDefinitionId is Guid wfId)
@ -429,6 +470,7 @@ public class GetContractQueryHandler(
c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt,
c.BudgetId, budgetSummary,
c.Approvals
.OrderBy(a => a.ApprovedAt)
.Select(a => new ContractApprovalDto(

View File

@ -1,3 +1,4 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos;
@ -38,6 +39,8 @@ public record ContractDetailDto(
string? DraftData,
DateTime CreatedAt,
DateTime? UpdatedAt,
Guid? BudgetId,
BudgetSummaryDto? Budget,
List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments,

View File

@ -79,6 +79,7 @@ public class CreateContractFromEvaluationCommandHandler(
NoiDung = pe.MoTa,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = pe.PaymentTerms, // carry forward payment terms
BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link
WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),

View File

@ -1,3 +1,4 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
@ -115,6 +116,8 @@ public record PurchaseEvaluationDetailBundleDto(
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt,
Guid? BudgetId,
BudgetSummaryDto? Budget,
List<PurchaseEvaluationSupplierDto> Suppliers,
List<PurchaseEvaluationDetailDto> Details,
List<PurchaseEvaluationApprovalDto> Approvals,

View File

@ -22,7 +22,8 @@ public record CreatePurchaseEvaluationCommand(
Guid? DepartmentId,
string? DiaDiem,
string? MoTa,
string? PaymentTerms) : IRequest<Guid>;
string? PaymentTerms,
Guid? BudgetId) : IRequest<Guid>;
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{
@ -52,6 +53,19 @@ public class CreatePurchaseEvaluationCommandHandler(
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct);
// Validate Budget link (nếu có): cùng Project + Phase=DaDuyet (chỉ cho
// pick ngân sách đã duyệt mới được dùng làm reference đối chiếu).
if (request.BudgetId is Guid bid)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != request.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
var entity = new PurchaseEvaluation
{
Type = request.Type,
@ -64,6 +78,7 @@ public class CreatePurchaseEvaluationCommandHandler(
DrafterUserId = currentUser.UserId,
WorkflowDefinitionId = activeWfId,
PaymentTerms = request.PaymentTerms,
BudgetId = request.BudgetId,
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
};
@ -96,7 +111,8 @@ public record UpdatePurchaseEvaluationDraftCommand(
string TenGoiThau,
string? DiaDiem,
string? MoTa,
string? PaymentTerms) : IRequest;
string? PaymentTerms,
Guid? BudgetId) : IRequest;
public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db,
@ -110,10 +126,23 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao)
throw new ConflictException("Chỉ sửa được phiếu khi ở phase Đang soạn thảo.");
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
entity.PaymentTerms = request.PaymentTerms;
entity.BudgetId = request.BudgetId;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
@ -329,6 +358,17 @@ public class GetPurchaseEvaluationQueryHandler(
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
// Load Budget summary nếu có link
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
if (e.BudgetId is Guid budgetId)
{
budgetSummary = await db.Budgets.AsNoTracking()
.Where(b => b.Id == budgetId)
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach))
.FirstOrDefaultAsync(ct);
}
// Load supplier names for PE suppliers + approver names
var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList();
var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id))
@ -366,6 +406,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary,
e.Suppliers
.OrderBy(s => s.Order)
.Select(s => new PurchaseEvaluationSupplierDto(