[CLAUDE] App: YC-016 guard file-khi-duyệt KHKK theo TAY CẦM PHIẾU (slot 67 đóng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s

Anh chốt "(c) THEO TAY CẦM PHIẾU": Nháp/Trả-lại (tay người soạn) ⇒ chỉ
người soạn up file duyệt; Chờ-duyệt ⇒ chỉ thành viên quy trình đã pin
(membership, không đòi đúng-lượt — chữ anh @S171 không thu hẹp); terminal
DaDuyet/TuChoi ⇒ chặn cả hai (hồ sơ đã chốt).

Guard mới EnsureApprovalAttachmentUploaderAsync (WorkflowGuards) wire vào
UploadContractSigningPlanAttachmentCommandHandler — CHỈ rẽ nhánh
Purpose=ApprovalAttachment; đường căn-cứ dossier giữ nguyên hành vi cũ
(có control-test chặn nới guard ngoài phán quyết).

Đóng lỗ đo prod @S172: drafter qua policy KeHoachKyKet.Update up được
file vào phiếu không thuộc tay mình (#82 hai tầng — policy là quyền
module, không phải quyền theo tay). FE 0-đổi (nút vốn chỉ trong dialog
duyệt — display-layer; guard này là API-layer thật).

Test 660 → 668 (+8: 3 nhánh tay × 2 chủ thể + ngoài-cuộc + terminal-2-vế
+ control đường-soạn). YC-016 vào sổ; YC-013 nhắc=2 (YC-015 verify 4-chân).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 13:10:31 +07:00
parent 2a83e4410c
commit 72568f052e
4 changed files with 240 additions and 1 deletions

View File

@ -1597,6 +1597,12 @@ public class UploadContractSigningPlanAttachmentCommandHandler(
.FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
// [YC-016 @S183 — "(c) THEO TAY CẦM PHIẾU"] CHỈ rẽ nhánh file-khi-duyệt; đường
// căn-cứ dossier (purpose khác, người soạn upload lúc soạn) GIỮ NGUYÊN hành vi cũ.
if (request.Purpose == ContractSigningPlanAttachmentPurpose.ApprovalAttachment)
await ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync(
db, plan, currentUser.UserId, ct);
// Căn cứ gắn kèm (nếu có) phải thuộc ĐÚNG phiếu này — `DossierItemId` chỉ có index,
// KHÔNG FK vật lý (`ContractSigningPlanAttachment.cs:14-19` — tránh multiple cascade
// path) ⇒ DB không kiểm hộ, handler phải tự kiểm.

View File

@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Application.ContractSigningPlans;
@ -75,4 +76,52 @@ public static class ContractSigningPlanWorkflowGuards
$"Phiếu nhóm N{approvalGroup} đang gắn quy trình N{k} — " +
"vào phiếu (Nháp/Trả lại) đổi lại quy trình đúng nhóm trước khi trình.");
}
/// [YC-016 @S183 — anh chốt "(c) THEO TAY CẦM PHIẾU"] File-khi-duyệt (`Purpose =
/// ApprovalAttachment`) đi theo TAY đang cầm phiếu, không theo policy module:
/// • Nháp / Trả lại (tay người soạn) ⇒ CHỈ người soạn (`CreatedBy`);
/// • Chờ duyệt (tay người duyệt) ⇒ CHỈ thành viên quy trình đã pin
/// (mọi `Level.ApproverUserId` của workflow — membership, KHÔNG đòi đúng-lượt:
/// chữ anh @S171 "file do người duyệt tải lên trong quá trình duyệt" không thu
/// hẹp về acting-level; siết thêm sau = 1 mệnh-đề);
/// • Đã duyệt / Từ chối (terminal — không ai cầm) ⇒ CHẶN cả hai (hồ sơ đã chốt).
///
/// 🔴 Vì sao cần rào này dù endpoint đã có `[Authorize(Policy="KeHoachKyKet.Update")]`:
/// policy là quyền MODULE (người soạn đương nhiên có để soạn phiếu) ⇒ đo prod @S172:
/// `test.drafter@` up được file duyệt vào phiếu KHÔNG thuộc tay mình. Display-layer
/// (nút chỉ nằm trong dialog duyệt) không phải rào — #82 hai tầng độc lập.
public static async Task EnsureApprovalAttachmentUploaderAsync(
IApplicationDbContext db, ContractSigningPlan plan, Guid? userId, CancellationToken ct)
{
switch (plan.Phase)
{
case ContractSigningPlanPhase.DangSoanThao:
case ContractSigningPlanPhase.TraLai:
if (userId is null || plan.CreatedBy != userId)
throw new ConflictException(
"Phiếu đang ở tay người soạn (Nháp/Trả lại) — chỉ người soạn được đính kèm file duyệt.");
return;
case ContractSigningPlanPhase.ChoDuyet:
{
// Submit đã ép pin (`SubmitAsync` guard `ApprovalWorkflowId is not Guid ⇒ 409`)
// nên nhánh null ở đây là fail-safe, không phải đường đi thường.
if (plan.ApprovalWorkflowId is not Guid awId)
throw new ConflictException(
"Phiếu chưa gắn quy trình duyệt — không xác định được người duyệt.");
var isMember = userId is Guid uid && await db.ApprovalWorkflowLevels
.AsNoTracking()
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awId && l.ApproverUserId == uid, ct);
if (!isMember)
throw new ConflictException(
"Phiếu đang chờ duyệt — chỉ người duyệt trong quy trình được đính kèm file khi duyệt.");
return;
}
default: // DaDuyet / TuChoi — terminal, hồ sơ đã chốt
throw new ConflictException(
"Phiếu đã kết thúc — hồ sơ đã chốt, không đính kèm thêm file duyệt.");
}
}
}