[CLAUDE] App+Infra+Tests: W6 KHKK — duong ong HD V2 (view per-HD 3-site + inbox V2+ADMIN + guard trinh +PMH)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m52s

- View: vế isV2Approver per-HĐ ở CẢ 3 call-site GetEligiblePhases (:301/:381/:486) — không role-blanket, nháp-riêng-tư S89 giữ, deleted-view cùng vị ngữ
- Inbox: ghép v2InboxIds precompute (mirror PE ResolveV2InboxIdsAsync) + nhánh ADMIN phase-list tường minh +ChoDuyet (DR-4/F-B13) — HĐ ChoDuyet vào inbox cả approver lẫn admin
- Guard trình CWS:73-78: Drafter|DeptManager|người-tạo|Procurement + chặn null==null lọt guard (actorUserId is Guid); Reject :49-66 + vùng W3 :217-394 0-diff (O-A giữ)
- tests +7 (587/0): 5 PIN + admin-inbox DR-4 + Submit 4-vế/null-trap
- Reviewer: Trục 1-3 vai PASS 0-critical + 4-7 lead — 2 ghi-nhận owner: workflow-blanket-by-design độ rộng + Reject pre-existing AMPLIFIED (bán kính nổ tăng khi view mở)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 23:27:19 +07:00
parent d03d097886
commit 761d8183f3
3 changed files with 811 additions and 6 deletions

View File

@ -299,7 +299,19 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
{
var userId = currentUser.UserId;
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
// [W6 S161] +2 vế cho đường ống V2 (spec §②-3). Vế V2 là THEO-TỪNG-HĐ
// (workflow pin của CHÍNH HĐ đó), KHÔNG phải role-blanket — xem chú thích
// khối helper dưới `GetEligiblePhases`.
var myWorkflowIds = await ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
var isHardCopyActor = HasHardCopyActorRole(currentUser.Roles);
q = q.Where(x => x.c.DrafterUserId == userId
|| eligiblePhases.Contains(x.c.Phase)
// (a) có chân duyệt trong workflow ĐÃ PIN của HĐ này + HĐ đã rời Nháp (S89:
// nháp = riêng tư người soạn, approver chỉ thấy khi đã gửi duyệt).
|| (x.c.Phase != ContractPhase.DangSoanThao
&& myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
// (b) vai bản-cứng khâu ký/đóng dấu → HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
|| (isHardCopyActor && x.c.Phase == ContractPhase.DaPhatHanh));
}
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
@ -349,6 +361,38 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau);
return phases.ToList();
}
// ===== [W6 S161] Vế V2 per-HĐ — CỐ Ý TÁCH KHỎI `GetEligiblePhases` =====
// 🔴 KHÔNG được nhét `ChoDuyet`/`DaPhatHanh` vào `GetEligiblePhases`: hàm đó chỉ nhận
// ROLE (không biết đang xét HĐ nào) ⇒ thêm phase vào đó = MỌI user mang role đó thấy
// MỌI HĐ ở phase đó. Nặng hơn: nó là hàm DÙNG CHUNG với `:381` ListDeleted ⇒ HĐ
// `ChoDuyet` đã XÓA MỀM sẽ rò sang màn "Đã xóa" (acceptance §③-B cấm đúng ca này).
// Vì vậy 2 vế dưới chỉ gắn ở 2 call-site `:301` List + Detail (`GetContractQueryHandler`),
// KHÔNG gắn ở ListDeleted — quyết định có chủ đích, xem chú thích tại chỗ `:381`.
/// Vai bản-cứng khâu ký / đóng dấu / phát hành (spec §②-3 — actor b.19/20/21): được xem
/// HĐ **đã phát hành** (`DaPhatHanh`) dù không có chân trong workflow V2 nào. Cho GĐ4/W7.
internal static readonly string[] HardCopyActorRoles =
[AppRoles.Director, AppRoles.AuthorizedSigner, AppRoles.HrAdmin, AppRoles.CostControl];
internal static bool HasHardCopyActorRole(IReadOnlyList<string> userRoles)
=> userRoles.Any(r => HardCopyActorRoles.Contains(r));
/// Các workflow V2 mà `userId` có chân duyệt (BẤT KỲ Bước/Cấp) — bản-set cho query LIST,
/// mirror `ContractSigningPlanFeatures.cs:275-283`; bản 1-phiếu là `AnyAsync` kiểu PE
/// `PurchaseEvaluationFeatures.cs:889-891`.
/// 🔴 Trả `List&lt;Guid?&gt;` CÓ CHỦ ĐÍCH: cột `Contract.ApprovalWorkflowId` là `Guid?` ⇒
/// `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` dịch thẳng `IN (...)` không cần
/// `.Value` (NULL không bao giờ khớp `IN` ⇒ HĐ V1 legacy tự rớt khỏi vế này).
internal static async Task<List<Guid?>> ResolveUserApprovalWorkflowIdsAsync(
IApplicationDbContext db, Guid? userId, CancellationToken ct)
=> userId is null
? []
: await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApproverUserId == userId.Value)
.Select(l => (Guid?)l.Step!.ApprovalWorkflowId)
.Distinct()
.ToListAsync(ct);
}
// ========== LIST DELETED contracts — màn "Đã xóa" (chỉ-xem) ==========
@ -379,6 +423,11 @@ public class ListDeletedContractsQueryHandler(IApplicationDbContext db, ICurrent
{
var userId = currentUser.UserId;
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
// 🔴 [W6 S161] CALL-SITE THỨ 3 — CỐ Ý **KHÔNG** ghép 2 vế V2 (`myWorkflowIds` /
// `HardCopyActorRoles`) đã thêm ở List `:301` + Detail. Đây là màn "Đã xóa":
// acceptance W6 §③-B đòi HĐ `ChoDuyet` xóa mềm KHÔNG được rò sang user V2.
// Scope giữ NGUYÊN (Drafter eligible-phase-role) = 0 dòng thay đổi hành vi.
// Đụng vào đây là mở lỗ, không phải "làm cho đủ 3 chỗ".
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
}
@ -429,6 +478,24 @@ public class GetMyInboxQueryHandler(
[ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
};
/// 🔴 [W6 S161 — DR-4] Phase Admin thấy trong Inbox, LIỆT KÊ TƯỜNG MINH (mirror
/// `PurchaseEvaluationFeatures.cs:731-742`). Trước W6 admin lấy `PhaseActorRoles.Keys`,
/// mà dict đó 100% phase legacy (0 key `ChoDuyet`) ⇒ ghép `v2InboxIds` xong admin VẪN
/// mù HĐ chờ duyệt. Danh sách này = 7 phase legacy CŨ **+ `ChoDuyet`** ⇒ THUẦN CỘNG
/// THÊM, không bỏ phase nào admin đang thấy (cố ý KHÔNG bê luật S89 "bỏ Nháp khỏi Inbox"
/// của PE sang: đó là gỡ hành-vi, ngoài phạm vi W6).
internal static readonly ContractPhase[] AdminInboxPhases =
[
ContractPhase.DangSoanThao,
ContractPhase.DangGopY,
ContractPhase.DangDamPhan,
ContractPhase.DangInKy,
ContractPhase.DangKiemTraCCM,
ContractPhase.DangTrinhKy,
ContractPhase.DangDongDau,
ContractPhase.ChoDuyet,
];
public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
@ -438,18 +505,26 @@ public class GetMyInboxQueryHandler(
// Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
var eligiblePhases = isAdmin
? PhaseActorRoles.Keys.ToList()
? AdminInboxPhases.ToList()
: PhaseActorRoles
.Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
.Select(kv => kv.Key)
.ToList();
if (eligiblePhases.Count == 0) return [];
// [W6 S161] V2-aware: HĐ pin V2 + Phase=ChoDuyet + actor là approver CẤP ĐANG CHỜ.
// Admin đi bằng nhánh PHASE (`ChoDuyet` ở trên) nên để rỗng — mirror PE `:746-748`.
var v2InboxIds = isAdmin
? new HashSet<Guid>()
: await ResolveV2InboxIdsAsync(currentUser.UserId, ct);
// 🔴 Điều kiện thoát sớm phải xét CẢ 2 nguồn: user chỉ có chân duyệt V2 mà không mang
// role legacy nào (eligiblePhases rỗng) vẫn PHẢI thấy hộp thư của mình.
if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
where eligiblePhases.Contains(c.Phase)
where eligiblePhases.Contains(c.Phase) || v2InboxIds.Contains(c.Id)
orderby c.SlaDeadline ?? DateTime.MaxValue
select new ContractListItemDto(
c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
@ -458,6 +533,48 @@ public class GetMyInboxQueryHandler(
return await q.Take(100).ToListAsync(ct);
}
/// Precompute ID các HĐ mà `userId` là approver của Cấp ĐANG CHỜ — mirror
/// `PurchaseEvaluationFeatures.cs:817-852` (và bản KHKK `ContractSigningPlanFeatures.cs:229-264`).
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list Steps
/// đã sort theo `Order`, KHÔNG phải chính `Order` ⇒ không dịch thẳng sang SQL được.
/// So Cấp bằng GIÁ TRỊ `Level.Order` + lấy `Any(...)` ⇒ giữ nguyên OR-of-N cùng Cấp.
/// HĐ xóa mềm tự rớt: `ContractConfiguration.cs:44` có global filter `!IsDeleted`.
private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
{
if (userId is null) return new HashSet<Guid>();
var candidates = await (
from c in db.Contracts.AsNoTracking()
where c.Phase == ContractPhase.ChoDuyet
&& c.ApprovalWorkflowId != null
&& c.CurrentWorkflowStepIndex != null
&& c.CurrentApprovalLevelOrder != null
select new { c.Id, c.ApprovalWorkflowId, c.CurrentWorkflowStepIndex, c.CurrentApprovalLevelOrder }
).ToListAsync(ct);
if (candidates.Count == 0) return new HashSet<Guid>();
var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
var workflows = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => wfIds.Contains(w.Id))
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.ToDictionaryAsync(w => w.Id, ct);
var result = new HashSet<Guid>();
foreach (var c in candidates)
{
if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
var steps = wf.Steps.OrderBy(s => s.Order).ToList();
var idx = c.CurrentWorkflowStepIndex!.Value;
if (idx < 0 || idx >= steps.Count) continue;
var match = steps[idx].Levels.Any(l =>
l.Order == c.CurrentApprovalLevelOrder!.Value && l.ApproverUserId == userId.Value);
if (match) result.Add(c.Id);
}
return result;
}
}
// ========== GET detail ==========
@ -485,7 +602,22 @@ public class GetContractQueryHandler(
var isDrafter = c.DrafterUserId == currentUser.UserId;
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
var isEligibleByRole = eligiblePhases.Contains(c.Phase);
if (!isDrafter && !isEligibleByRole)
// [W6 S161] Vế V2 THEO-TỪNG-HĐ — mirror PE `PurchaseEvaluationFeatures.cs:886-898`:
// actor có chân `ApproverUserId` ở BẤT KỲ Cấp nào của workflow ĐÃ PIN CỦA HĐ NÀY.
// Hỏi theo `c.ApprovalWorkflowId` (không phải theo role) ⇒ user ngoài workflow vẫn 403.
var isV2Approver = false;
if (c.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck)
{
isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking()
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
&& l.ApproverUserId == uidForCheck, ct);
}
// [S89 mirror PE] Nháp = RIÊNG TƯ người soạn: V2-approver KHÔNG mở được HĐ còn Nháp.
var isDraftPhase = c.Phase == ContractPhase.DangSoanThao;
// Vai bản-cứng ký/đóng dấu → xem được HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
var isHardCopyActor = ListContractsQueryHandler.HasHardCopyActorRole(currentUser.Roles)
&& c.Phase == ContractPhase.DaPhatHanh;
if (!isDrafter && !isEligibleByRole && !isHardCopyActor && (isDraftPhase || !isV2Approver))
throw new ForbiddenException("Bạn không có quyền xem HĐ này.");
}

View File

@ -70,9 +70,22 @@ public class ContractWorkflowService(
if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai)
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
{
// [W6 S161] Nới vai được TRÌNH: `Drafter DeptManager người-tạo Procurement`.
// Lỗ đo 07-29: nhân sự Phòng Mua hàng (role `Procurement`) bị 403 khi trình HĐ do
// chính mình lập. Vẫn giữ 403 cho role NGOÀI 4 vế này.
// 🔴 `actorUserId is Guid` là BẮT BUỘC, không được viết thẳng
// `contract.CreatedBy == actorUserId`: cả hai đều `Guid?` ⇒ HĐ seed/legacy có
// `CreatedBy == null` gặp actor null sẽ ra `null == null` = TRUE và lọt guard.
// Xét cả `DrafterUserId` vì đó là field handler tạo HĐ gán tường minh
// (`ContractFeatures.cs:96`), còn `CreatedBy` do `AuditingInterceptor.cs:45` gán —
// HĐ nạp từ seed có thể trống ô sau.
var isCreator = actorUserId is Guid actorId
&& (contract.CreatedBy == actorId || contract.DrafterUserId == actorId);
if (!isAdmin && !isSystem
&& !actorRoles.Contains(AppRoles.Drafter)
&& !actorRoles.Contains(AppRoles.DeptManager))
&& !actorRoles.Contains(AppRoles.DeptManager)
&& !actorRoles.Contains(AppRoles.Procurement)
&& !isCreator)
{
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");