[CLAUDE] App: Chunk B — Lock edit guards (Phase != DangSoanThao) cho 17 handler
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) <noreply@anthropic.com>
This commit is contained in:
@ -294,6 +294,9 @@ public class AddBudgetDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct)
|
var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct)
|
||||||
?? throw new NotFoundException("Budget", request.BudgetId);
|
?? 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)
|
var maxOrder = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
|
||||||
.Select(d => (int?)d.Order).MaxAsync(ct);
|
.Select(d => (int?)d.Order).MaxAsync(ct);
|
||||||
var entity = new BudgetDetail
|
var entity = new BudgetDetail
|
||||||
@ -331,6 +334,11 @@ public class UpdateBudgetDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task Handle(UpdateBudgetDetailCommand request, CancellationToken ct)
|
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
|
var entity = await db.BudgetDetails
|
||||||
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
|
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
|
||||||
?? throw new NotFoundException("BudgetDetail", request.DetailId);
|
?? throw new NotFoundException("BudgetDetail", request.DetailId);
|
||||||
@ -339,8 +347,6 @@ public class UpdateBudgetDetailCommandHandler(
|
|||||||
entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien;
|
entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien;
|
||||||
entity.GhiChu = request.GhiChu;
|
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)
|
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
|
||||||
.SumAsync(d => d.ThanhTien, ct);
|
.SumAsync(d => d.ThanhTien, ct);
|
||||||
|
|
||||||
@ -355,13 +361,16 @@ public class DeleteBudgetDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task Handle(DeleteBudgetDetailCommand request, CancellationToken ct)
|
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
|
var entity = await db.BudgetDetails
|
||||||
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
|
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
|
||||||
?? throw new NotFoundException("BudgetDetail", request.DetailId);
|
?? throw new NotFoundException("BudgetDetail", request.DetailId);
|
||||||
db.BudgetDetails.Remove(entity);
|
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)
|
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id)
|
||||||
.SumAsync(d => d.ThanhTien, ct);
|
.SumAsync(d => d.ThanhTien, ct);
|
||||||
|
|
||||||
|
|||||||
@ -424,6 +424,9 @@ public class DeleteContractDetailHandler(IApplicationDbContext db, IChangelogSer
|
|||||||
{
|
{
|
||||||
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cmd.ContractId, ct)
|
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cmd.ContractId, ct)
|
||||||
?? throw new NotFoundException("Contract", cmd.ContractId);
|
?? 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
|
// Dispatch xóa theo Type — tránh load tất cả 7 DbSet
|
||||||
bool removed = false;
|
bool removed = false;
|
||||||
@ -477,6 +480,10 @@ internal static class ContractDetailsHelpers
|
|||||||
?? throw new NotFoundException("Contract", contractId);
|
?? throw new NotFoundException("Contract", contractId);
|
||||||
if (contract.Type != expectedType)
|
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}.");
|
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;
|
return contract;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,21 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
|||||||
|
|
||||||
namespace SolutionErp.Application.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<PurchaseEvaluation> 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) ==========
|
// ========== Detail (hạng mục + ngân sách) ==========
|
||||||
|
|
||||||
public record AddPurchaseEvaluationDetailCommand(
|
public record AddPurchaseEvaluationDetailCommand(
|
||||||
@ -40,8 +55,7 @@ public class AddPurchaseEvaluationDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
|
||||||
|
|
||||||
var maxOrder = await db.PurchaseEvaluationDetails
|
var maxOrder = await db.PurchaseEvaluationDetails
|
||||||
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
@ -100,6 +114,7 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
||||||
var entity = await db.PurchaseEvaluationDetails
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
@ -126,6 +141,7 @@ public class DeletePurchaseEvaluationDetailCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
||||||
var entity = await db.PurchaseEvaluationDetails
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
@ -152,6 +168,7 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
|||||||
{
|
{
|
||||||
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
||||||
// Verify parents exist + same phiếu
|
// Verify parents exist + same phiếu
|
||||||
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
||||||
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
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)
|
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct);
|
||||||
var quote = await (
|
var quote = await (
|
||||||
from q in db.PurchaseEvaluationQuotes
|
from q in db.PurchaseEvaluationQuotes
|
||||||
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
||||||
|
|||||||
Reference in New Issue
Block a user