[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
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:
@ -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<Guid?>` 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.");
|
||||
}
|
||||
|
||||
|
||||
@ -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Đ.");
|
||||
|
||||
Reference in New Issue
Block a user