From 14f3c9f817a532a429adefa58038268cea25c418 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 4 May 2026 12:15:07 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20App:=20Chunk=20B=20=E2=80=94=20Lock?= =?UTF-8?q?=20edit=20guards=20(Phase=20!=3D=20DangSoanThao)=20cho=2017=20h?= =?UTF-8?q?andler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ràng buộc 1 (Phase 9): khi đã trình duyệt → KHÔNG sửa được Header + Detail + Quote nữa. Phải reject để Drafter sửa lại. Pattern dùng: extend helper EnsureContractType + tạo helper PurchaseEvaluationDraftGuard mới cho PE + inline guard cho Budget. Single source of truth cho mỗi module. Handlers added Phase guard (17 total): Contract module (15) — qua EnsureContractType helper: - 7 Add*DetailHandler (ThauPhu/GiaoKhoan/NhaCungCap/DichVu/MuaBan/NguyenTacNcc/NguyenTacDv) - 7 Update*DetailHandler (cùng 7 type) - DeleteContractDetailHandler (inline guard) PE module (5) — qua PurchaseEvaluationDraftGuard helper mới: - AddPurchaseEvaluationDetail - UpdatePurchaseEvaluationDetail - DeletePurchaseEvaluationDetail - UpsertPurchaseEvaluationQuote - DeletePurchaseEvaluationQuote Budget module (3) — inline guard: - AddBudgetDetail - UpdateBudgetDetail (refactor: load Budget thay vì FirstOrDefault sau Detail load → bỏ null check không cần) - DeleteBudgetDetail (refactor: tương tự) KHÔNG lock (intentional): - ContractComment (cần được trong DangGopY phase 3) - ContractAttachment Upload/Delete (Drafter scan ký ở DangInKy phase 5) - PE OpinionUpsert (Ý kiến 4 PB là sign-off, có thể nhập sau khi trình) - PE Attachment (báo giá NCC upload xuyên suốt workflow) Verify: - Build pass (2 warning DocxRenderer cũ) - 77 unit test pass (54 Domain + 23 Infra) — domain policy không đổi Smart reject (ràng buộc 2) + 2-stage dept approval (ràng buộc 3) làm ở Chunk C + D. WorkflowService transition guard chưa update. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Budgets/BudgetFeatures.cs | 25 +++++++++++++------ .../Contracts/ContractDetailsFeatures.cs | 7 ++++++ .../PurchaseEvaluationDetailFeatures.cs | 22 ++++++++++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) 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