[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

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