[CLAUDE] Contract: YC-029 W5 — đường xoá đồng-khuôn KHKK (owner-check + phase allow-list + changelog, test-before 13 ca)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 20:19:32 +07:00
parent 8a64f536e5
commit 2ae62f3fbc
7 changed files with 781 additions and 11 deletions

View File

@ -75,10 +75,22 @@ public class ContractsController(IMediator mediator) : ControllerBase
return Ok(new { id = commentId });
}
// [W5 S190 · YC-029] Xoá HĐ (mềm) + lý do. `reason` đi qua query-string vì verb DELETE
// không mang body — khuôn KHKK `ContractSigningPlansController.Delete`. Optional ⇒ 0 breaking
// cho caller cũ đang gọi `DELETE /api/contracts/{id}` không tham số.
//
// 🔴 CỐ Ý CHƯA gắn policy per-action cho verb này (giữ `[Authorize]` trần ở class). Đo LocalDB
// Dev: khoá menu `Contracts` có 12/13 role `CanDelete = 0` — trong đó có chính `Drafter` ⇒ gắn
// vào là khoá oan, người soạn mất nút xoá HĐ nháp của mình (đúng bài PE `PeSoftDeleteFeatures.cs:25-29`).
// Rào quyền THẬT nằm ở handler (chủ HĐ hoặc Admin, xem `ContractFeatures.cs` khối DELETE) —
// đây là chỗ duy nhất chặn được, không phải chỗ này. Mở quyền diện rộng là W8, owner đã HOÃN
// (chốt (82)); điều kiện mở: policy + grant `Drafter` land CÙNG một commit rồi đo lại trên prod.
// [carry:w8-delete-policy]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
public async Task<IActionResult> Delete(
Guid id, [FromQuery] string? reason = null, CancellationToken ct = default)
{
await mediator.Send(new DeleteContractCommand(id), ct);
await mediator.Send(new DeleteContractCommand(id, reason), ct);
return NoContent();
}

View File

@ -883,20 +883,107 @@ public class GetContractQueryHandler(
}
// ========== DELETE (soft) ==========
//
// [W5 S190 · YC-029] Đường xoá HĐ dựng lại đồng-khuôn KHKK (`ContractSigningPlanFeatures.cs`
// Region 7 `:1737-1807`). 5 nấc, THỨ TỰ là một phần của đặc tả — không phải sắp cho đẹp:
// (0) chưa đăng nhập → 401
// (1) không tìm thấy → 404
// (2) không phải chủ/Admin → 403
// (3) phase ngoài allow-list→ 409
// (4) xoá mềm + ghi 1 vết nhật ký mang LÝ DO
// Nấc (1) đứng TRƯỚC (2) để 403/404 không thành kênh dò "id này có tồn tại không"; nấc (2)
// đứng TRƯỚC (3) để người ngoài không đọc được HĐ đang ở bước nào.
//
// 🔴 3 bẫy đã né — ghi lại vì cả ba đều trông như "chỗ có thể gọn hơn":
//
// a. NẤC-0 KHÔNG CÓ trong khuôn KHKK (`:1763` so thẳng người soạn với actor). HĐ có
// `DrafterUserId` NULL (data legacy) gặp actor chưa đăng nhập (UserId null) ⇒ `null == null`
// ⇒ hoá "chủ sở hữu" ⇒ xoá LỌT. Nấc-0 lấy từ `PeSoftDeleteFeatures.cs:56-58`. Ai thấy hai
// khuôn "lệch nhau" thì sửa KHKK cho có nấc-0, ĐỪNG xoá nấc-0 ở đây cho giống.
//
// b. ALLOW-LIST PHASE CỦA HĐ RỘNG HƠN KHKK. KHKK là bộ 3 {Nháp, Từ chối, Trả lại}; HĐ là
// `Phase < DangInKy(5)` {TraLai(98), TuChoi(99)} — gồm cả legacy 1/3/4 (Dev còn HĐ sống
// ở `DangGopY`). Chép nguyên hình `is not (…)` của KHKK = 409 oan đúng 3 phase đó.
//
// c. THỨ TỰ `Remove` / `Add(changelog)` — chú thích tại chỗ (bug T26 S155).
//
// 🔶 LÝ DO xoá chỉ sống ở changelog: `IsDeleted/DeletedAt/DeletedBy` (do `AuditingInterceptor`
// đặt khi `Remove`) chỉ trả lời "ai + lúc nào".
// 🔶 Endpoint DELETE CỐ Ý chưa gắn policy per-action — lý do đo được ghi tại
// `ContractsController.Delete` [carry:w8-delete-policy].
// Test canh: `tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs`
// (13 ca T-1..T-13, viết TRƯỚC mã này).
public record DeleteContractCommand(Guid Id) : IRequest;
/// <param name="Reason">Lý do xoá (tuỳ chọn) — chảy vào `ContractChangelog.ContextNote`.</param>
public record DeleteContractCommand(Guid Id, string? Reason = null) : IRequest;
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
public class DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>
{
public DeleteContractCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
// Trần MATCH EF, không lấy theo spec: `ContractChangelogConfiguration.cs:20`
// `ContextNote` HasMaxLength(2000). Lệch số ở đây = EF nổ `DbUpdateException` (500)
// thay vì 400 có thông báo đọc được.
RuleFor(x => x.Reason).MaximumLength(2000);
}
}
public class DeleteContractCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
// (0) Chặn actor vô danh TRƯỚC mọi phép so người — bẫy (a) ở đầu khối.
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
// (2) Chủ HĐ hoặc Admin. Trước W5 khối này KHÔNG hỏi actor là ai ⇒ mọi user đăng nhập
// xoá được HĐ của người khác (lỗ #82 — rào hiển thị FE và rào API là 2 tầng độc lập;
// FE ẩn nút không phải là rào). An toàn khi so vì nấc (0) đã loại UserId null.
var isOwner = entity.DrafterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn hợp đồng hoặc Admin được xoá hợp đồng này.");
// (3) Phase — bẫy (b). `ChoDuyet(10)` cấm KỂ CẢ chủ HĐ (rút êm là cướp lượt của người
// đang duyệt; đường xoá-khi-đang-duyệt dành cho approver là việc khác, chưa mở).
// Mọi phase ≥ `DangInKy(5)` cấm mọi đường, kể cả Admin — tới đó HĐ đã mang mã chính thức.
var phaseAllowed = entity.Phase < ContractPhase.DangInKy
|| entity.Phase == ContractPhase.TraLai
|| entity.Phase == ContractPhase.TuChoi;
if (!phaseAllowed)
throw new ConflictException(
"Chỉ xoá được hợp đồng ở trạng thái soạn thảo, Trả lại hoặc Từ chối. " +
"Hợp đồng Đã gửi duyệt phải Trả lại/Từ chối trước; hợp đồng từ 'Đang in ký' " +
"trở đi (trình ký, đóng dấu, đã phát hành) thì không xoá được.");
var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
var phaseAtChange = entity.Phase;
// 🔴 THỨ TỰ: `Remove()` TRƯỚC, `Add(changelog)` SAU (bug T26 S155). EF chạy cascade
// client-side NGAY tại `Remove()` (`CascadeDeleteTiming.Immediate`); dòng nhật ký đã ở
// state `Added` trước đó sẽ bị DETACH ⇒ lưu xong 0 row, im lặng, không lỗi. Thêm SAU thì
// lượt cascade đã chạy xong lúc chưa có con nào để gỡ ⇒ vết sống.
db.Contracts.Remove(entity);
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = entity.Id,
EntityType = ChangelogEntityType.Contract,
EntityId = null, // header thì để trống (khuôn 2 site ghi tay)
Action = ChangelogAction.Delete,
PhaseAtChange = phaseAtChange,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Xoá hợp đồng {entity.MaHopDong ?? entity.TenHopDong ?? "(chưa )"}",
ContextNote = reason,
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -7,9 +7,19 @@ namespace SolutionErp.Domain.Contracts;
// log workflow transitions, dùng cho guard logic) — Changelog là VIEW LAYER
// cho user đọc lịch sử thao tác.
//
// Populate qua MediatR `AuditBehavior` interceptor — auto từ Insert/Update/
// Delete commands trên Contract + Details (xem Application/Common/Behaviors/
// AuditBehavior.cs).
// 🔴 [S190 W5] ĐÍNH CHÍNH — câu cũ ở đây ghi "populate qua MediatR `AuditBehavior` interceptor
// (xem Application/Common/Behaviors/AuditBehavior.cs)". **File đó CHƯA BAO GIỜ tồn tại**
// (`Common/Behaviors/` chỉ có `ValidationBehavior.cs`); nó nằm ở mục "Tier 4+ còn thiếu" của
// skill `contract-workflow`. Câu cũ nguy hiểm hơn thiếu chú thích: người đọc tưởng nhật ký tự
// ghi rồi bỏ qua ⇒ thao tác mới lặng lẽ không để lại vết.
//
// SỰ THẬT: changelog ghi TAY, 2 đường:
// (1) `Infrastructure/Services/ChangelogService.cs` (5 site — transition/comment/attachment…)
// (2) `Add(new ContractChangelog{…})` thẳng trong handler — 3 site:
// · `ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs:247` (Insert, bridge KHKK→HĐ)
// · `PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs:121` (Insert, bridge PE→HĐ)
// · `Contracts/ContractFeatures.cs` khối DELETE (Delete, S190 W5)
// Thêm thao tác mới mà muốn có vết thì phải TỰ ghi — không có ai ghi hộ.
public class ContractChangelog : BaseEntity
{
public Guid ContractId { get; set; }