[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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user