diff --git a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs index f8b11df..442c073 100644 --- a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs +++ b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs @@ -294,6 +294,9 @@ public class AddBudgetDetailCommandHandler( { var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct) ?? throw new NotFoundException("Budget", request.BudgetId); + // Lock edit guard (Phase 9 — Migration 16) + if (bg.Phase != BudgetPhase.DangSoanThao) + throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể thêm hạng mục. Phải reject để Drafter sửa lại."); var maxOrder = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id) .Select(d => (int?)d.Order).MaxAsync(ct); var entity = new BudgetDetail @@ -331,6 +334,11 @@ public class UpdateBudgetDetailCommandHandler( { public async Task Handle(UpdateBudgetDetailCommand request, CancellationToken ct) { + var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct) + ?? throw new NotFoundException("Budget", request.BudgetId); + // Lock edit guard (Phase 9 — Migration 16) + if (bg.Phase != BudgetPhase.DangSoanThao) + throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể sửa hạng mục. Phải reject để Drafter sửa lại."); var entity = await db.BudgetDetails .FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct) ?? throw new NotFoundException("BudgetDetail", request.DetailId); @@ -339,10 +347,8 @@ public class UpdateBudgetDetailCommandHandler( entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien; entity.GhiChu = request.GhiChu; - var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct); - if (bg != null) - bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id) - .SumAsync(d => d.ThanhTien, ct); + bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id) + .SumAsync(d => d.ThanhTien, ct); await db.SaveChangesAsync(ct); } @@ -355,15 +361,18 @@ public class DeleteBudgetDetailCommandHandler( { public async Task Handle(DeleteBudgetDetailCommand request, CancellationToken ct) { + var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct) + ?? throw new NotFoundException("Budget", request.BudgetId); + // Lock edit guard (Phase 9 — Migration 16) + if (bg.Phase != BudgetPhase.DangSoanThao) + throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể xóa hạng mục. Phải reject để Drafter sửa lại."); var entity = await db.BudgetDetails .FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct) ?? throw new NotFoundException("BudgetDetail", request.DetailId); db.BudgetDetails.Remove(entity); - var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct); - if (bg != null) - bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id) - .SumAsync(d => d.ThanhTien, ct); + bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id) + .SumAsync(d => d.ThanhTien, ct); await db.SaveChangesAsync(ct); } diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs index 6db3dfa..9880b48 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs @@ -424,6 +424,9 @@ public class DeleteContractDetailHandler(IApplicationDbContext db, IChangelogSer { var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cmd.ContractId, ct) ?? throw new NotFoundException("Contract", cmd.ContractId); + // Lock edit guard (Phase 9 — Migration 16): chỉ Phase=DangSoanThao xóa được. + if (contract.Phase != ContractPhase.DangSoanThao) + throw new ConflictException($"HĐ đã trình duyệt (Phase={contract.Phase}), không thể xóa chi tiết. Phải reject để Drafter sửa lại."); // Dispatch xóa theo Type — tránh load tất cả 7 DbSet bool removed = false; @@ -477,6 +480,10 @@ internal static class ContractDetailsHelpers ?? throw new NotFoundException("Contract", contractId); if (contract.Type != expectedType) throw new ConflictException($"HĐ này thuộc loại {contract.Type}, không thể thêm chi tiết loại {expectedType}."); + // Lock edit guard (Phase 9 — Migration 16): chỉ Phase=DangSoanThao mới + // được CRUD chi tiết. Đã trình duyệt → KHÔNG sửa được thông tin nữa. + if (contract.Phase != ContractPhase.DangSoanThao) + throw new ConflictException($"HĐ đã trình duyệt (Phase={contract.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại."); return contract; } } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs index a86f338..9f4ed4d 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs @@ -8,6 +8,21 @@ using SolutionErp.Domain.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations; +// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ========== +// Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia. +// Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước. +internal static class PurchaseEvaluationDraftGuard +{ + public static async Task EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct) + { + var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", id); + if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao) + throw new ConflictException($"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại."); + return pe; + } +} + // ========== Detail (hạng mục + ngân sách) ========== public record AddPurchaseEvaluationDetailCommand( @@ -40,8 +55,7 @@ public class AddPurchaseEvaluationDetailCommandHandler( { public async Task Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct) { - var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) - ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var maxOrder = await db.PurchaseEvaluationDetails .Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId) @@ -100,6 +114,7 @@ public class UpdatePurchaseEvaluationDetailCommandHandler( { public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct) { + await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var entity = await db.PurchaseEvaluationDetails .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); @@ -126,6 +141,7 @@ public class DeletePurchaseEvaluationDetailCommandHandler( { public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct) { + await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var entity = await db.PurchaseEvaluationDetails .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); @@ -152,6 +168,7 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler( { public async Task Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct) { + await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); // Verify parents exist + same phiếu var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync( d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) @@ -199,6 +216,7 @@ public class DeletePurchaseEvaluationQuoteCommandHandler( { public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct) { + await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); var quote = await ( from q in db.PurchaseEvaluationQuotes join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id