[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

@ -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