[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
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:
@ -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 HĐ</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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user