[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>
)}