[CLAUDE] Contract: W4+W6BE guard 4-cửa pin/re-pin nhà chung (khoá cứng theo loại, bridge auto-pin nullable) + Đ2-guard đúng-trạm V2 (V1 giữ nguyên) + test-before security
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -3,6 +3,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
// [S187 W4] Alias thay vì `using SolutionErp.Application.Contracts;`: file này đã mở
|
||||
// `SolutionErp.Domain.Contracts` (Contract, ContractPhase, WorkflowDefinition…) — kéo trọn
|
||||
// namespace Application cùng tên vào đây là mời một va-chạm tên trong tương lai mà lỗi sẽ hiện ra
|
||||
// ở một file chẳng liên quan. Cần đúng 1 lớp thì trỏ đúng 1 lớp.
|
||||
using ContractWorkflowGuards = SolutionErp.Application.Contracts.ContractWorkflowGuards;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
@ -48,6 +53,26 @@ public class ContractWorkflowService(
|
||||
// ===== REJECT BRANCH =====
|
||||
if (decision == ApprovalDecision.Reject)
|
||||
{
|
||||
// ---- [S187 W6] CỬA ĐÚNG-TRẠM cho HĐ đã pin quy trình V2 — vá lỗ gotcha #82-S156.
|
||||
//
|
||||
// Trước đợt này thân nhánh dưới chạy TRƯỚC MỌI rào: bất kỳ ai đăng nhập gửi
|
||||
// `decision=Reject` là lật được HĐ BẤT KỲ sang TraLai/TuChoi — kể cả HĐ đã
|
||||
// `DaPhatHanh` (đã gen mã, đã phát hành). Màn duyệt V2 (W6-FE) chỉ tắt 2 nút ở UI;
|
||||
// API còn hở thì phần tắt đó là trang trí, forge 1 request qua DevTools là xong.
|
||||
//
|
||||
// 🔴 PHẠM VI CỐ Ý HẸP — chỉ HĐ pin V2 (`ApprovalWorkflowId != null`). HĐ V1 legacy đi
|
||||
// tiếp y nguyên từng byte (owner: "chỗ hợp đồng cứ từ từ" cho V1). Đo Dev
|
||||
// 2026-08-10 (W4): 7 HĐ, 0 HĐ pin V2 ⇒ đợt này KHÔNG đổi hành vi của bất kỳ HĐ nào
|
||||
// đang sống, chỉ dựng rào sẵn cho luồng V2 sắp mở. Siết V1 là việc riêng, rủi ro
|
||||
// riêng, cần owner chốt riêng.
|
||||
//
|
||||
// 🔴 ĐẶT Ở ĐÂY chứ KHÔNG nhân bản thân nhánh thành bản V2 thứ hai: phần thân dưới
|
||||
// (set Phase + xoá con-trỏ + log) đúng cho CẢ V1 lẫn V2. Chép ra bản thứ hai là đẻ
|
||||
// hai luật rồi để chúng đi lệch nhau — đúng cái bệnh mà
|
||||
// `ContractWorkflowGuards.cs:14-21` vừa gỡ ở đường pin quy trình.
|
||||
if (contract.ApprovalWorkflowId is Guid rejectAwId)
|
||||
await EnsureCanRejectV2Async(contract, rejectAwId, targetPhase, actorUserId, isAdmin, ct);
|
||||
|
||||
if (targetPhase == ContractPhase.TuChoi)
|
||||
{
|
||||
contract.Phase = ContractPhase.TuChoi;
|
||||
@ -90,6 +115,34 @@ public class ContractWorkflowService(
|
||||
throw new ForbiddenException(
|
||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");
|
||||
}
|
||||
|
||||
// ---- [S187 W4] CỬA 4/4 — quy trình ĐÃ PIN còn hợp lệ không?
|
||||
//
|
||||
// 3 cửa GHI (Create · cầu K7 · sửa nháp) chỉ chặn ĐÚNG LÚC GHI. HĐ pin từ trước khi có
|
||||
// rào — hoặc pin xong rồi Admin đổi mã quy trình đó trong Designer — vẫn nằm sẵn trong
|
||||
// DB với loại lệch. Đây là cửa duy nhất bắt được lớp đó.
|
||||
//
|
||||
// 🔴 ĐẶT SAU rào vai (403 đứng trước 409): sai người thì lý do đầu tiên người dùng cần
|
||||
// nghe là "không phải việc của bạn", không phải chuyện cấu hình quy trình.
|
||||
//
|
||||
// 🔴 CỐ Ý KHÔNG cắm thêm vào nhánh APPROVE dù cùng là "đường duyệt V2": HĐ đã sang
|
||||
// `ChoDuyet` thì KHÔNG còn đường sửa quy trình (`UpdateContractDraftCommandHandler`
|
||||
// chỉ mở ở `DangSoanThao`) ⇒ chặn ở đó là tự tay giam phiếu, không ai gỡ được kể cả
|
||||
// Admin. Rào chỉ được đứng ở cửa mà người dùng còn lối đi tiếp: ở ĐÂY, HĐ đang Nháp,
|
||||
// người lập sửa lại quy trình qua cửa 3/4 rồi trình lại.
|
||||
// ⚠️ Dư-lượng đã biết, KHÔNG vá trong W4: HĐ ở `TraLai` cũng không sửa được
|
||||
// (`UpdateContractDraft` chỉ nhận `DangSoanThao`) ⇒ nếu một HĐ `TraLai` mang pin
|
||||
// lệch thì nó kẹt. Hiện KHÔNG có HĐ nào rơi vào ca đó (đo Dev 2026-08-10: 7 HĐ,
|
||||
// 0 HĐ pin V2) và nới phase được-sửa là đổi hành-vi nằm ngoài phạm vi W4.
|
||||
//
|
||||
// 🔴 KHÔNG hỏi `IsActive`/`IsUserSelectable` — xem lý do trong
|
||||
// `ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync`: hai cờ đó thuộc về
|
||||
// khả-năng-CHỌN, không phải hiệu-lực; hỏi ở đây thì một thao tác Designer chẳng liên
|
||||
// quan sẽ làm HĐ nháp đang chạy hết trình được.
|
||||
if (contract.ApprovalWorkflowId is Guid pinnedAwId)
|
||||
await ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync(
|
||||
db, pinnedAwId, contract.Type, ct);
|
||||
|
||||
contract.Phase = ContractPhase.ChoDuyet;
|
||||
contract.CurrentWorkflowStepIndex = 0;
|
||||
// [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153.
|
||||
@ -214,6 +267,91 @@ public class ContractWorkflowService(
|
||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||
}
|
||||
|
||||
// ===== [S187 W6] CỬA ĐÚNG-TRẠM CHO TRẢ LẠI / TỪ CHỐI — CHỈ HĐ PIN V2 =====
|
||||
// Vá gotcha #82-S156. Cùng vế quyền với nhánh Duyệt (`ApproveV2Async` :301-312) và đọc
|
||||
// con-trỏ bằng CÙNG một phép (`?? 0` / `?? 1`, xem :284/:288) — hai đường phải hiểu "ai đang
|
||||
// tới lượt" y hệt nhau, lệch một nước là phiếu vừa duyệt được vừa trả lại được bởi hai người
|
||||
// khác nhau. Khuôn: KHKK `ContractSigningPlanWorkflowService.ReturnOrRejectAsync:374-390`
|
||||
// (cửa trạng-thái đứng trước cửa người) + PE
|
||||
// `PurchaseEvaluationWorkflowService.EnsureCanRejectV2Async:336`.
|
||||
//
|
||||
// 🔴 KHÁC PE/KHKK Ở KIỂU NGOẠI LỆ — CỐ Ý, đừng "sửa" cho đồng bộ:
|
||||
// PE `:359` và KHKK `:477` ném `ForbiddenException` (403) cho cùng vị từ này; ở đây là
|
||||
// `ConflictException` (409) theo lệnh W6. Lý do đứng được: dưới OR-of-N thì "sai người" và
|
||||
// "chưa tới lượt" là MỘT vị từ không tách được, mà "chưa tới lượt" là xung đột TRẠNG THÁI
|
||||
// (lát nữa chính người đó lại duyệt được) chứ không phải thiếu quyền vĩnh viễn.
|
||||
// ⚠️ Dư-lượng đã biết, KHAI thẳng: trong CÙNG panel W6-FE, nút Duyệt sai người trả 403
|
||||
// (`:309`) còn nút Trả lại sai người trả 409. Muốn 2 nút cùng mã thì đổi Ở ĐÂY, KHÔNG đổi
|
||||
// `:309` — đường Duyệt đang bị lưới hành-vi neo 403
|
||||
// (`ContractWorkflowServiceApproveV2Tests.cs:456`).
|
||||
//
|
||||
// 🔴 Admin MIỄN TRỌN cửa này (kể cả vế trạng-thái) — giữ nguyên tiền lệ owner đã chốt ở PE
|
||||
// EDGE-5 (`PurchaseEvaluationWorkflowService.cs:58-72`: "ADMIN + SYSTEM MIỄN … Admin đầy
|
||||
// đủ nhất", giữ cả khả năng un-terminal). Admin cũng là đường gỡ kẹt DUY NHẤT khi quy
|
||||
// trình pin hỏng/mất row; chặn admin ở đây là tự tay giam HĐ mà không ai mở được.
|
||||
//
|
||||
// 🔴 `actorUserId == null` ⇒ CHẶN, không thả. Hôm nay KHÔNG caller nào rơi vào đây: caller
|
||||
// hệ-thống duy nhất là `SlaExpiryJob.cs:100-107` và nó gửi `AutoApprove` chứ không bao giờ
|
||||
// `Reject`; đường người dùng `ContractFeatures.cs:252` đã chặn thiếu UserId từ trước. Mai
|
||||
// có cron tự-trả-lại thì phải khai tường minh, KHÔNG mượn lỗ null đi qua — chỗ thả null ở
|
||||
// PE `:342` chính là phần hở còn lại của khuôn nguồn, không phải thứ đáng chép.
|
||||
private async Task EnsureCanRejectV2Async(
|
||||
Contract contract,
|
||||
Guid awId,
|
||||
ContractPhase targetPhase,
|
||||
Guid? actorUserId,
|
||||
bool isAdmin,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (isAdmin) return;
|
||||
|
||||
var actionLabel = targetPhase == ContractPhase.TuChoi ? "từ chối" : "trả lại";
|
||||
|
||||
// (1) CỬA TRẠNG THÁI — đứng TRƯỚC cửa người: HĐ không ở "Đã gửi duyệt" thì không có Cấp
|
||||
// nào đang chờ để mà xét tới lượt ai. Đây chính là vế khoá `DaPhatHanh` của S156.
|
||||
if (contract.Phase != ContractPhase.ChoDuyet)
|
||||
throw new ConflictException(
|
||||
$"Chỉ {actionLabel} được HĐ đang ở trạng thái Đã gửi duyệt."
|
||||
+ (contract.Phase == ContractPhase.DangSoanThao
|
||||
? " HĐ còn Bản nháp thì dùng Xoá HĐ."
|
||||
: string.Empty));
|
||||
|
||||
// (2) CỬA NGƯỜI — actor phải có tên trong Cấp ĐANG CHỜ (OR-of-N: một trong N là đủ).
|
||||
if (actorUserId is not Guid actorId)
|
||||
throw new ConflictException(
|
||||
$"Không xác định được người {actionLabel} HĐ — thao tác này cần đăng nhập.");
|
||||
|
||||
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||
?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");
|
||||
|
||||
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
|
||||
if (steps.Count == 0)
|
||||
throw new ConflictException("Quy trình chưa có bước nào.");
|
||||
|
||||
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
|
||||
if (currentIdx < 0 || currentIdx >= steps.Count)
|
||||
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
|
||||
|
||||
var currentStep = steps[currentIdx];
|
||||
var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;
|
||||
|
||||
var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
|
||||
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
|
||||
if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
|
||||
throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");
|
||||
|
||||
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||
?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
|
||||
|
||||
if (!pendingLevelGroup.Any(l => l.ApproverUserId == actorId))
|
||||
throw new ConflictException(
|
||||
$"Không đến lượt bạn — HĐ đang chờ Bước {currentIdx + 1} ({currentStep.Name}) Cấp {currentLevelOrder}; " +
|
||||
$"bạn không thuộc cấp duyệt hiện tại nên không {actionLabel} được HĐ này.");
|
||||
}
|
||||
|
||||
// ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) =====
|
||||
// Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
|
||||
// Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ
|
||||
|
||||
Reference in New Issue
Block a user