[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:
@ -57,6 +57,17 @@ public record BudgetWorkflowSummaryDto(
|
||||
List<BudgetPhase> ActivePhases,
|
||||
List<BudgetPhase> NextPhases);
|
||||
|
||||
// Snapshot ngân sách link cho PE / Contract DetailBundle. Compact — chỉ
|
||||
// header info, không bao gồm BudgetDetails (gọi /budgets/{id} riêng nếu cần
|
||||
// đối chiếu chi tiết theo từng GroupCode/ItemCode).
|
||||
public record BudgetSummaryDto(
|
||||
Guid Id,
|
||||
string? MaNganSach,
|
||||
string TenNganSach,
|
||||
int NamNganSach,
|
||||
BudgetPhase Phase,
|
||||
decimal TongNganSach);
|
||||
|
||||
public record BudgetDetailBundleDto(
|
||||
Guid Id,
|
||||
string? MaNganSach,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -79,6 +79,7 @@ public class CreateContractFromEvaluationCommandHandler(
|
||||
NoiDung = pe.MoTa,
|
||||
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
|
||||
DraftData = pe.PaymentTerms, // carry forward payment terms
|
||||
BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link
|
||||
WorkflowDefinitionId = activeWfId,
|
||||
SlaDeadline = DateTime.UtcNow.Add(
|
||||
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using SolutionErp.Application.Budgets.Dtos;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
@ -115,6 +116,8 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
DateTime? SlaDeadline,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
Guid? BudgetId,
|
||||
BudgetSummaryDto? Budget,
|
||||
List<PurchaseEvaluationSupplierDto> Suppliers,
|
||||
List<PurchaseEvaluationDetailDto> Details,
|
||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||
|
||||
@ -22,7 +22,8 @@ public record CreatePurchaseEvaluationCommand(
|
||||
Guid? DepartmentId,
|
||||
string? DiaDiem,
|
||||
string? MoTa,
|
||||
string? PaymentTerms) : IRequest<Guid>;
|
||||
string? PaymentTerms,
|
||||
Guid? BudgetId) : IRequest<Guid>;
|
||||
|
||||
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
|
||||
{
|
||||
@ -52,6 +53,19 @@ public class CreatePurchaseEvaluationCommandHandler(
|
||||
.Select(w => (Guid?)w.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Validate Budget link (nếu có): cùng Project + Phase=DaDuyet (chỉ cho
|
||||
// pick ngân sách đã duyệt mới được dùng làm reference đối chiếu).
|
||||
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 phiếu.");
|
||||
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
|
||||
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
|
||||
}
|
||||
|
||||
var entity = new PurchaseEvaluation
|
||||
{
|
||||
Type = request.Type,
|
||||
@ -64,6 +78,7 @@ public class CreatePurchaseEvaluationCommandHandler(
|
||||
DrafterUserId = currentUser.UserId,
|
||||
WorkflowDefinitionId = activeWfId,
|
||||
PaymentTerms = request.PaymentTerms,
|
||||
BudgetId = request.BudgetId,
|
||||
SlaDeadline = DateTime.UtcNow.Add(
|
||||
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
|
||||
};
|
||||
@ -96,7 +111,8 @@ public record UpdatePurchaseEvaluationDraftCommand(
|
||||
string TenGoiThau,
|
||||
string? DiaDiem,
|
||||
string? MoTa,
|
||||
string? PaymentTerms) : IRequest;
|
||||
string? PaymentTerms,
|
||||
Guid? BudgetId) : IRequest;
|
||||
|
||||
public class UpdatePurchaseEvaluationDraftCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
@ -110,10 +126,23 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
|
||||
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao)
|
||||
throw new ConflictException("Chỉ sửa được phiếu 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 phiếu.");
|
||||
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
|
||||
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
|
||||
}
|
||||
|
||||
entity.TenGoiThau = request.TenGoiThau;
|
||||
entity.DiaDiem = request.DiaDiem;
|
||||
entity.MoTa = request.MoTa;
|
||||
entity.PaymentTerms = request.PaymentTerms;
|
||||
entity.BudgetId = request.BudgetId;
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
@ -329,6 +358,17 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
|
||||
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
|
||||
|
||||
// Load Budget summary nếu có link
|
||||
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
|
||||
if (e.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);
|
||||
}
|
||||
|
||||
// Load supplier names for PE suppliers + approver names
|
||||
var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList();
|
||||
var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id))
|
||||
@ -366,6 +406,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
e.SelectedSupplierId, selectedSupplier?.Name,
|
||||
e.ContractId,
|
||||
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
||||
e.BudgetId, budgetSummary,
|
||||
e.Suppliers
|
||||
.OrderBy(s => s.Order)
|
||||
.Select(s => new PurchaseEvaluationSupplierDto(
|
||||
|
||||
Reference in New Issue
Block a user