[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:
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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 có mã)"}",
|
||||
ContextNote = reason,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user