[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:
pqhuy1987
2026-05-04 12:15:07 +07:00
parent 5c200978cb
commit 14f3c9f817
3 changed files with 44 additions and 10 deletions

View File

@ -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);
}