[CLAUDE] Workflow: KHKK 8 panel nhóm mirror DuyetNcc + BE guards (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

YC-013 (S182/L13). 8 leaf "Duyệt KHKK — Nhóm 1..8" ra đúng khuôn trang
DuyetNcc: slice per-Code + lockedCode + Sửa-tại-chỗ/Tạo-version/Ghim/Xoá.
D3=C (YC-014): 6 ô chưa-wire disabled-giữ-state-thật + tooltip trên label
(Chromium không hiện title trên input disabled — MAJOR-1); ô #4 thêm chữ
nhìn thấy được; #7 giữ; #8 Finalize 0-chạm (prod 8/88 dựa nó); ô CEO
disable type-10 + POST null.

BE 5 guard (0 migration):
- W1a deactivate-per-Code cho type-10 (Code+Type+IsActive — cấm regex);
  Code normalize Trim().ToUpperInvariant() tại đường ghi + IgnoreCase belt
  trong GroupCodePattern (MAJOR-2: SQLite test case-sensitive nguoc prod
  CI_AS collation)
- W1b ContractSigningPlanWorkflowGuards (public static, Application) —
  rào nhóm 3 cửa Create/UpdateDraft/Submit, giữ cho-qua-code-ngoài-họ
- W1b2 (D6) pin-check IsActive+IsUserSelectable 2 cửa, 2 message tách
- W1c changelog PUT type-10 vào ContractSigningPlanChangelogs
- W1d DELETE usage-check 409 đích danh (8 module pin, IgnoreQueryFilters
  cho PE/Contract/KHKK; 5 module office pin loose-0FK — guard là rào duy
  nhất chống dangling) + 404 NotFoundException + D5 rào version-cuối nhóm
  (+ ApplicableType — MINOR-3)

Test 645 → 660 (+14 spec + T15 D6-Create MAJOR-3); MINOR-1 pin nguyên câu
thứ-tự 1 ca. File test thứ 3 (GroupAndCatalog) đổi 1 assertion theo câu
409 mới của W1b — spec-change, không phải nới bar.

Khai accepted (reviewer PASS_WITH_FIXES 0C/3M+6m, đã vá 3M+2m):
- MINOR-4: panel nhóm tạo-version-mới ⇒ 6 cờ khoá đóng băng default FE
  (vô hại — BE không đọc 6 cờ đó cho type-10)
- MINOR-5: bỏ ghim/bỏ active bản cuối nhóm = đóng cửa tạo phiếu nhóm đó
  (răng thật của nút Ghim — không rào đợt này)
- race 2-admin cùng Code → 2601/500 thô (UNIQUE đỡ data, chấp nhận)

Evidence: .claude/workflows/runs/2026-08-08-S182-khkk-awf-mirror-ncc/
(3 fable-invest + 2 fable-review + spec FINAL + 4 lane + precheck prod
12 phiếu/pin-lệch-0 + reviewer pre-commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 12:14:37 +07:00
parent 18f8f76bac
commit 706134daa9
13 changed files with 1604 additions and 89 deletions

View File

@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans; // [S182 W1d] ContractSigningPlanWorkflowGuards.ParseGroup
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans; // [S182 W1c] changelog + phase KHKK
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
@ -330,21 +332,53 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
{
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
// 🔴 [S182 MAJOR-2] CHUẨN HOÁ `Code` TẠI ĐƯỜNG GHI — nguồn duy nhất cho cả handler.
//
// Trước bản này BA thành phần hỏi cùng câu "code này thuộc nhóm nào" bằng BA luật khác nhau:
// • `ContractSigningPlanWorkflowGuards.GroupCodePattern` — regex .NET, case-SENSITIVE;
// • `w.Code == request.Code` — EF → SQL Server, theo collation `*_CI_AS` = case-INSENSITIVE;
// • FE `KhkkCreatePage` — `.trim().toUpperCase()` = case-INSENSITIVE.
// ⇒ admin gõ `khkk-n1` ở panel type-10 gộp (ô Mã KHÔNG khoá vì `lockedCode = null`):
// DB coi là trùng ⇒ hạ `IsActive` của `KHKK-N1` THẬT · FE vẫn auto-pin sang bản mới ·
// nhưng regex trả `null` ⇒ rào nhóm 3-site và D5 IM LẶNG TẮT cho toàn bộ phiếu nhóm 1.
// Không lỗi nào báo.
// 🔴 Test KHÔNG thể bắt: 14 ca chạy SQLite, nơi `=` trên TEXT là BINARY (case-SENSITIVE)
// ⇒ ngữ nghĩa NGƯỢC prod. Mù theo CẤU TRÚC, không assertion nào bịt được ⇒ phải chặn
// bằng chuẩn-hoá, đừng chặn bằng test.
var code = request.Code.Trim().ToUpperInvariant();
// Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
var nextVersion = await db.ApprovalWorkflows
.Where(w => w.Code == request.Code)
.Where(w => w.Code == code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Deactivate active version cho ApplicableType này (only ONE active per type)
var actives = await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
// Tắt bản đang active — PHẠM VI phụ thuộc loại (bản trước: LUÔN per-type).
//
// • type-10 `ContractSigningPlan` (KHKK): **per-CODE**. Loại này có 8 quy trình
// SỐNG SONG SONG cùng type — mỗi nhóm duyệt N1..N8 một `Code` riêng
// (`DbInitializer.SeedKhkkGroupWorkflowsAsync:609`, seed cả 8 với IsActive=1).
// Tắt theo type ⇒ tạo version mới cho MỘT nhóm sẽ hạ IsActive của CẢ 7 nhóm còn
// lại (mỗi nhóm mất quy trình active ⇒ FE hết auto-pin ⇒ không tạo được phiếu).
// • các loại khác: **per-TYPE** như cũ — 1 active/type là bất biến của chúng.
//
// 🔴 [S182 R-6] Vị ngữ type-10 KHOÁ đúng 3 vế `Code == request.Code &&
// ApplicableType == typeEnum && IsActive`. CẤM thay bằng biến-thể regex
// `^KHKK-N[1-8]$`: `Code` type-10 nằm ngoài họ đó (vd `QT-KHKK-V2-001`) sẽ rơi
// lại nhánh per-type và nuke đúng 8 nhóm mà rào này sinh ra để cứu.
// (`nextVersion` `:334-337` vốn đã tính per-Code ⇒ nhánh này làm 2 vế khớp nhau.)
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
? await db.ApprovalWorkflows
.Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct)
: await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
var def = new ApprovalWorkflow
{
Code = request.Code,
Code = code, // [S182 MAJOR-2] bản đã chuẩn hoá, KHÔNG dùng request.Code thô
Version = nextVersion,
ApplicableType = typeEnum,
Name = request.Name,
@ -856,10 +890,17 @@ public class UpdateAwDefinitionCommandHandler(
// được "quy trình vừa bị sửa lúc nào, ai sửa, vì sao".
// ⚠️ KHAI GIỚI HẠN: repo CHƯA có bảng changelog cho quy trình duyệt, mà
// đợt 1 bị chốt "no new table" ⇒ vết cho workflow thuộc 5 module Văn
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) + KHKK (W1 S161 — reviewer F-6b:
// `ContractSigningPlanChangelogs` là changelog PHIẾU, không phải changelog QUY-TRÌNH;
// wire nếu cần ở W2/W3) hiện CHỈ có UpdatedAt/
// phòng số (Proposal/Leave/Ot/Travel/Vehicle) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
// 🔄 [S182 W1c] KHKK (type-10) ĐÃ WIRE — xem nhánh `else if` bên dưới. Câu cũ ở
// đây xếp KHKK chung rọ "chỉ có UpdatedAt/UpdatedBy" và hẹn "wire nếu cần ở W2/W3";
// đợt này wire thật nên câu đó gỡ, không để lại neo thối.
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
var summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)";
var actorName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống";
if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.DuyetNcc
or ApprovalWorkflowApplicableType.DuyetNccPhuongAn)
@ -871,10 +912,6 @@ public class UpdateAwDefinitionCommandHandler(
.Select(p => new { p.Id, p.Phase })
.ToListAsync(ct);
var note = string.Join(" | ", changes);
if (!string.IsNullOrWhiteSpace(request.Reason))
note = $"Lý do: {request.Reason.Trim()} | {note}";
foreach (var pe in affected)
{
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
@ -885,8 +922,48 @@ public class UpdateAwDefinitionCommandHandler(
EntityId = def.Id,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email ?? "Hệ thống",
Summary = $"Sửa quy trình duyệt tại chỗ: {def.Code} v{def.Version} (giữ nguyên phiên bản)",
UserName = actorName,
Summary = summary,
ContextNote = note,
});
}
}
// [S182 W1c] KHKK (type-10) — mirror khối PE ngay trên, cùng shape 8 field.
//
// VÌ SAO CẦN: dialog PUT hứa nguyên văn với admin "ghi vào lịch sử phiếu đang chạy"
// (`ApprovalWorkflowsV2Page.tsx:963-977`). Với type-10 lời hứa đó trước nay là RỖNG —
// sửa quy trình xong lịch sử phiếu KHKK không có dòng nào. Đây là fix cho lời hứa
// thành thật, không phải tính năng mới.
//
// 🔴 Vị ngữ phiếu "đang chịu ảnh hưởng" = CHƯA kết thúc: loại 2 phase TERMINAL
// (`DaDuyet` chốt giá + `TuChoi`). `TraLai` KHÔNG loại — phiếu trả lại sẽ được
// trình LẠI và chạy đúng quy trình vừa sửa ⇒ người soạn cần thấy vết.
// 🔴 CỐ Ý KHÔNG `IgnoreQueryFilters()`: `ContractSigningPlans` có global filter
// (`ContractSigningPlanConfiguration.cs:55`) ⇒ phiếu XOÁ MỀM tự rụng khỏi tập.
// Đúng ý muốn: ghi changelog cho phiếu đã xoá là đẻ rác không ai đọc. (Ngược với
// rào DELETE bên dưới — ở đó xoá mềm VẪN phải chặn vì FK còn thật.)
else if (changes.Count > 0 &&
def.ApplicableType is ApprovalWorkflowApplicableType.ContractSigningPlan)
{
var affectedPlans = await db.ContractSigningPlans.AsNoTracking()
.Where(p => p.ApprovalWorkflowId == def.Id
&& p.Phase != ContractSigningPlanPhase.DaDuyet
&& p.Phase != ContractSigningPlanPhase.TuChoi)
.Select(p => new { p.Id, p.Phase })
.ToListAsync(ct);
foreach (var plan in affectedPlans)
{
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Workflow,
Action = SolutionErp.Domain.Contracts.ChangelogAction.Update,
EntityId = def.Id,
PhaseAtChange = plan.Phase,
UserId = currentUser.UserId,
UserName = actorName,
Summary = summary,
ContextNote = note,
});
}
@ -1184,9 +1261,21 @@ public class SetAwUserSelectableCommandHandler(IApplicationDbContext db)
}
}
// ========== DELETE version (chỉ khi chưa có phiếu pin) ==========
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
// ========== DELETE version — CHẶN khi còn phiếu pin / còn chữ ký ==========
//
// 🔴 [S182 W1d] Câu cũ ở đây ("chưa có phiếu nào pin schema mới → unconditional delete OK cho
// UAT") đã HẾT ĐÚNG từ lâu: 8 module pin `ApprovalWorkflowId` thật trên prod. Xoá trần để
// lại 2 hậu quả khác hẳn nhau tuỳ module — và chính vì khác nhau nên rào này có HAI lý do,
// không phải một:
// • PE / Hợp đồng / KHKK có **FK vật lý** ⇒ xoá trần nổ SQL 547 = 500 thô. Rào = đổi
// 500 khó hiểu thành 409 đọc được. (UX)
// • Proposal / Leave / Ot / Travel / Vehicle pin **LOOSE, 0-FK** ⇒ DB không đỡ gì cả,
// xoá trần đi lọt ÊM và để lại con trỏ treo. Rào này là **hàng phòng thủ DUY NHẤT**.
// (TOÀN VẸN DỮ LIỆU — nặng hơn vế trên)
//
// Thứ tự kiểm: usage TRƯỚC (i+ii), D5 SAU (iv). Cả hai đều 409; usage đứng trước vì khi cả hai
// cùng đúng thì "5 phiếu đang gắn" là câu admin xử lý được, còn "nhóm phải còn ≥1 version" là
// luật chính sách — nói cái hành-động-được trước.
public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
@ -1195,9 +1284,90 @@ public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
{
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
{
// (iii) `NotFoundException` thay `KeyNotFoundException`: BCL exception KHÔNG được
// `GlobalExceptionMiddleware` map ⇒ id lạ trước nay ra 500 thô. Nay 404.
var def = await db.ApprovalWorkflows
.FirstOrDefaultAsync(d => d.Id == request.Id, ct)
?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");
?? throw new NotFoundException("ApprovalWorkflow", request.Id);
// ---- (i) Đếm phiếu ĐANG PIN, quét đủ 8 module có cột `ApprovalWorkflowId`.
//
// 🔴 `IgnoreQueryFilters()` cho PE / Contract / KHKK — 3 entity DUY NHẤT có global
// filter `!IsDeleted` (đã grep `HasQueryFilter` trong Configurations để chốt danh
// sách, không suy từ trí nhớ). Phiếu XOÁ MỀM vẫn CÒN ROW ⇒ FK vẫn thật ⇒ bỏ filter
// thì ta đếm ra 0 rồi xoá và nổ 547 đúng lúc chạy. 5 module Văn phòng số không có
// filter nên đếm thẳng.
var pinned = 0;
pinned += await db.PurchaseEvaluations.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.Contracts.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.ContractSigningPlans.IgnoreQueryFilters()
.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.Proposals.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.LeaveRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.OtRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.TravelRequests.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
pinned += await db.VehicleBookings.CountAsync(p => p.ApprovalWorkflowId == def.Id, ct);
// ---- (ii) Đếm CHỮ KÝ còn trỏ vào các dòng Cấp của quy trình này — 8 bảng
// `*LevelOpinion`. Phiếu có thể đã bỏ pin mà chữ ký vẫn còn (FK Restrict về
// `ApprovalWorkflowLevels`) ⇒ đếm pin thôi là chưa đủ để dám xoá.
// `IgnoreQueryFilters()` cả 8 cho ĐỒNG HÌNH — bảng nào không có filter thì gọi
// vô hại, bảng có filter mà quên gọi thì đếm hụt.
var levelIds = await db.ApprovalWorkflowLevels.AsNoTracking().IgnoreQueryFilters()
.Where(l => l.Step!.ApprovalWorkflowId == def.Id)
.Select(l => l.Id)
.ToListAsync(ct);
var signatures = 0;
if (levelIds.Count > 0)
{
signatures += await db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ContractLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ContractSigningPlanLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.ProposalLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.LeaveRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.OtRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.TravelRequestLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
signatures += await db.VehicleBookingLevelOpinions.IgnoreQueryFilters()
.CountAsync(o => levelIds.Contains(o.ApprovalWorkflowLevelId), ct);
}
if (pinned > 0 || signatures > 0)
throw new ConflictException(
$"Quy trình '{def.Code}' v{def.Version} đang được dùng: {pinned} hồ sơ đang gắn " +
$"+ {signatures} chữ ký đã lưu — không xoá được. " +
"Muốn ngừng dùng thì bỏ kích hoạt (hoặc bỏ ghim) thay vì xoá.");
// ---- (iv) [D5] Nhóm duyệt KHKK phải LUÔN còn ít nhất 1 version.
//
// Seeder `SeedKhkkGroupWorkflowsAsync` idempotent PER-CODE (`DbInitializer.cs:610`:
// `if (Any(w.Code == code)) continue;`) ⇒ xoá SẠCH một mã `KHKK-N{n}` thì lần khởi
// động sau seeder đẻ LẠI bản v01 mặc định — roster + cờ mà admin đã chỉnh biến mất
// âm thầm, không có lỗi nào báo. Rào ở đây đóng đường UI dẫn tới ca đó.
// (Version 0-usage mà KHÔNG phải bản cuối của mã ⇒ vẫn xoá bình thường.)
if (ContractSigningPlanWorkflowGuards.ParseGroup(def.Code) is int group)
{
// [S182 MINOR-3] Lọc CẢ `ApplicableType`: UNIQUE là `(Code, Version)` chứ không kèm
// type ⇒ một row `KHKK-N{n}` ở type KHÁC (Version khác) sẽ bị tính là "anh em" và
// cho phép xoá bản type-10 CUỐI CÙNG — đúng ca mà D5 sinh ra để chặn.
var siblings = await db.ApprovalWorkflows
.CountAsync(w => w.Code == def.Code
&& w.ApplicableType == def.ApplicableType
&& w.Id != def.Id, ct);
if (siblings == 0)
throw new ConflictException(
$"Nhóm N{group} phải còn ≥1 version — đây là version cuối cùng của quy trình " +
$"'{def.Code}'. Tạo version mới trước, rồi mới xoá bản này.");
}
db.ApprovalWorkflows.Remove(def);
await db.SaveChangesAsync(ct);

View File

@ -317,6 +317,34 @@ internal static class ContractSigningPlanScope
throw new ConflictException("Quy trình duyệt không thuộc loại Kế hoạch ký kết HĐ.");
}
/// [S182 W1b2 — D6] Quy trình có còn được phép PIN MỚI không: `IsActive` ∧ `IsUserSelectable`.
/// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147`.
///
/// 🔴 CHỈ gọi ở đường ĐẶT PIN MỚI (tạo phiếu / sửa nháp có ĐỔI quy trình). CỐ Ý KHÔNG gọi
/// khi giữ nguyên pin cũ: phiếu lỡ pin bản đã archived vẫn phải sửa được GhiChu/HoSoLink,
/// nếu không thì admin ngừng một quy trình là khoá cứng mọi phiếu nháp đang trỏ vào nó.
/// Cũng KHÔNG gọi ở đường TRÌNH duyệt vì lý do đó (D6 chốt 2 site).
///
/// 2 cờ ném 2 message TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): "đã ngừng
/// hoạt động" và "chưa ghim" sửa ở 2 nút khác nhau trong Designer — gộp thì người đọc lỗi
/// không biết bấm nút nào.
internal static async Task EnsureWorkflowPinnableAsync(
IApplicationDbContext db, Guid approvalWorkflowId, CancellationToken ct)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == approvalWorkflowId)
.Select(w => new { w.Code, w.IsActive, w.IsUserSelectable })
.FirstOrDefaultAsync(ct);
if (wf is null)
throw new NotFoundException("ApprovalWorkflow", approvalWorkflowId);
if (!wf.IsActive)
throw new ConflictException(
$"Quy trình '{wf.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
if (!wf.IsUserSelectable)
throw new ConflictException(
$"Quy trình '{wf.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
}
/// Precompute ID phiếu mà `userId` là approver của Cấp ĐANG CHỜ — mirror
/// `PurchaseEvaluationFeatures.cs:817-852 ResolveV2InboxIdsAsync`.
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list
@ -472,18 +500,20 @@ public class CreateContractSigningPlanCommandHandler(
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
// ---- Rào (iv-bis) [S182 W1b2 — D6] Quy trình phải CÒN DÙNG ĐƯỢC lúc pin.
// Mirror vá-3 `CreateContractFromSigningPlanFeatures.cs:141-147` (cùng lỗ, đã bịt cho
// type-3): FE chỉ hiện quy trình active+ghim, nhưng POST thẳng thì pin được bản đã
// archived ⇒ phiếu trình xong treo ở quy trình không ai bảo trì.
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, request.ApprovalWorkflowId, ct);
// ---- Rào (v) [K3 S165 — QĐ-3 "K3 chỉ ĐỌC ApprovalGroup"] Workflow họ nhóm
// `KHKK-N{n}` ⇒ n PHẢI khớp nhóm duyệt của phiếu (phiếu nhóm 3 pin quy trình nhóm 5
// = 409). Code NGOÀI họ (type-10 legacy/chung) → cho qua — ràng buộc chỉ áp cho
// 8 workflow nhóm seed @K3.
var wfCode = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == request.ApprovalWorkflowId)
.Select(w => w.Code).FirstOrDefaultAsync(ct);
var groupMatch = System.Text.RegularExpressions.Regex.Match(wfCode ?? string.Empty, "^KHKK-N([1-8])$");
if (groupMatch.Success && int.Parse(groupMatch.Groups[1].Value) != approvalGroup)
throw new ConflictException(
$"Quy trình '{wfCode}' thuộc nhóm duyệt N{groupMatch.Groups[1].Value}, " +
$"không khớp nhóm duyệt N{approvalGroup} của phiếu — chọn đúng quy trình của nhóm.");
// [S182 W1b] Thân rào DỜI sang `ContractSigningPlanWorkflowGuards` — cùng luật này
// nay chặn ở CẢ 3 cửa (tạo / sửa nháp re-pin / trình duyệt), trước chỉ có cửa này.
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, request.ApprovalWorkflowId, approvalGroup, ct);
// 🔴 GEN MÃ TRƯỚC KHI `Add` — `ContractSigningPlanCodeGenerator` TỰ COMMIT
// (`ContractSigningPlanCodeGenerator.cs:23-43`: transaction SERIALIZABLE +
@ -600,9 +630,18 @@ public class UpdateContractSigningPlanDraftCommandHandler(
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
// Re-pin quy trình: guard type LẠI (workflow có thể bị admin đổi loại giữa chừng).
//
// [S182 W1b/W1b2] Cửa này trước KHÔNG có rào nhóm ⇒ tạo phiếu đúng nhóm rồi sửa nháp
// pin sang quy trình nhóm khác là lọt. Nay đủ 3 rào như đường TẠO.
// 🔴 Cả 3 nằm TRONG nhánh `awId != plan.ApprovalWorkflowId` (đường ĐỔI pin) — CỐ Ý:
// client gửi lại đúng workflow cũ (form "Sửa" echo nguyên field) không được biến
// thành 409, nếu không thì sửa GhiChu của phiếu pin bản archived sẽ hỏng (D6).
if (request.ApprovalWorkflowId is Guid awId && awId != plan.ApprovalWorkflowId)
{
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, awId, ct);
await ContractSigningPlanScope.EnsureWorkflowPinnableAsync(db, awId, ct);
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, awId, plan.ApprovalGroup, ct);
plan.ApprovalWorkflowId = awId;
}

View File

@ -0,0 +1,78 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.ContractSigningPlans;
// [S182 W1b — YC-013] Rào DÙNG CHUNG cho quan hệ "nhóm duyệt của phiếu" ⟷ "nhóm duyệt của
// quy trình đã pin". Trước đợt này logic chỉ nằm ở MỘT chỗ (đường TẠO phiếu,
// `ContractSigningPlanFeatures.cs` rào (v) — K3 S165) ⇒ hai cửa còn lại (sửa nháp re-pin,
// và TRÌNH duyệt) đi lọt: phiếu nhóm 3 vẫn trình được bằng quy trình nhóm 5.
//
// 🔴 VÌ SAO `public static class` ở tầng Application chứ không nhét vào
// `ContractSigningPlanScope` (internal, cùng file Features):
// site thứ 3 là `ContractSigningPlanWorkflowService.SubmitAsync` nằm ở **Infrastructure**
// ⇒ không thấy `internal` của Application. Ba lối đã cân nhắc và loại:
// • nới `Scope` thành public → mở TOÀN BỘ helper nội bộ ra ngoài, chỉ để dùng 1 method;
// • `InternalsVisibleTo(Infrastructure)` → mở cả assembly, cùng bệnh, còn khó soi hơn;
// • copy-inline sang Service → 2 bản luật ĐI LỆCH NHAU theo thời gian (đúng bug-class
// đang chữa: một chỗ có rào, chỗ kia không).
// ⇒ tách file riêng, đúng một method public, ba site cùng gọi.
public static class ContractSigningPlanWorkflowGuards
{
// Họ mã quy trình nhóm duyệt: `KHKK-N1` … `KHKK-N8`, seed ở
// `DbInitializer.SeedKhkkGroupWorkflowsAsync` (`DbInitializer.cs:609` — `$"KHKK-N{n}"`).
//
// 🔴 ĐÂY LÀ NHÀ DUY NHẤT của mẫu này phía Application. Trước đợt này mẫu được gõ tay
// trong `ContractSigningPlanFeatures.cs:482`; W1d cần lại đúng mẫu đó ở
// `ApprovalWorkflowV2AdminFeatures` (rào xoá version cuối) ⇒ nếu chép lần nữa thì có
// 3 bản chuỗi rời nhau, sửa 1 quên 2. Ai cần "code này thuộc nhóm mấy" thì gọi
// `ParseGroup`, đừng viết lại regex.
//
// ⚠️ ĐỪNG nhầm với rào W1a bên `ApprovalWorkflowV2AdminFeatures` (deactivate-per-Code):
// ở ĐÓ regex bị CẤM (R-6) vì phạm vi tắt-active phải phủ MỌI code type-10, kể cả code
// ngoài họ. Ở ĐÂY regex mới là đúng việc: mục đích của rào là NHẬN DIỆN họ `KHKK-N`,
// và code ngoài họ được cho qua CÓ CHỦ ĐÍCH (xem `EnsureWorkflowGroupMatchAsync`).
// 🔴 [S182 MAJOR-2] `IgnoreCase` = BELT cho dữ-liệu CŨ. Đường ghi nay đã chuẩn hoá
// `Code` về HOA (`CreateAwDefinitionCommandHandler` — `request.Code.Trim().ToUpperInvariant()`),
// nhưng row có TRƯỚC bản vá thì chưa qua cửa đó. Thiếu cờ này, một `khkk-n1` tồn dư
// sẽ trả `null` ⇒ rào nhóm 3-site + D5 im lặng TẮT cho cả nhóm 1, trong khi SQL Server
// (collation `*_CI_AS`) vẫn coi nó trùng `KHKK-N1` ⇒ hai tầng nói ngược nhau.
// Suspenders ⟂ belt: chuẩn-hoá chặn dữ-liệu MỚI, cờ này tha-thứ dữ-liệu CŨ.
private static readonly Regex GroupCodePattern =
new(@"^KHKK-N([1-8])$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
/// Số nhóm duyệt suy ra từ mã quy trình, hoặc `null` nếu mã KHÔNG thuộc họ `KHKK-N[1-8]`.
public static int? ParseGroup(string? workflowCode)
{
var m = GroupCodePattern.Match(workflowCode ?? string.Empty);
return m.Success ? int.Parse(m.Groups[1].Value) : null;
}
/// Quy trình thuộc họ nhóm `KHKK-N{k}` ⇒ `k` PHẢI khớp nhóm duyệt của phiếu, nếu không → 409.
///
/// 🔴 GIỮ NGUYÊN semantics "cho qua code NGOÀI họ" của bản gốc
/// (`ContractSigningPlanFeatures.cs:477-478` — chủ đích, không phải sót): ràng buộc này
/// chỉ nói về 8 workflow nhóm seed @K3. Quy trình type-10 đặt tay (vd `QT-KHKK-V2-001`)
/// không mang thông tin nhóm trong mã ⇒ không có gì để đối chiếu, chặn nó là bịa ràng
/// buộc. Rào loại (`EnsureWorkflowTypeAsync`) mới là chỗ chặn quy trình sai LOẠI.
///
/// Mã không tồn tại (`awId` rác) cũng rơi vào nhánh cho-qua: guard NotFound là việc của
/// `EnsureWorkflowTypeAsync` (Create/UpdateDraft) — chồng thêm ở đây sẽ đẻ 2 nguồn 404
/// cho cùng một nguyên nhân.
public static async Task EnsureWorkflowGroupMatchAsync(
IApplicationDbContext db, Guid awId, int approvalGroup, CancellationToken ct)
{
var code = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == awId)
.Select(w => w.Code)
.FirstOrDefaultAsync(ct);
var wfGroup = ParseGroup(code);
if (wfGroup is int k && k != approvalGroup)
throw new ConflictException(
$"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.");
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans; // ContractSigningPlanWorkflowGuards (rào nhóm dùng chung 3 site)
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.ApprovalWorkflowsV2;
@ -186,6 +187,15 @@ public class ContractSigningPlanWorkflowService(
throw new ConflictException(
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
// ── [S182 W1b] Rào NHÓM ⟷ QUY TRÌNH, site 3/3 (2 site kia ở đường tạo/sửa nháp).
// Cửa này là chốt CUỐI và là cửa DUY NHẤT bắt được phiếu pin lệch có TỪ TRƯỚC: hai
// rào kia chỉ chặn lúc GHI, nên phiếu tạo trước khi có rào (hoặc trước khi admin đổi
// mã quy trình) vẫn nằm sẵn trong DB với nhóm lệch. Đứng ngay trên `LoadStepsAsync`
// để phiếu lệch không kịp bước vào ChoDuyet rồi mới lộ.
// Thân luật + lý do "cho qua code ngoài họ": `ContractSigningPlanWorkflowGuards`.
await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
db, awId, plan.ApprovalGroup, ct);
var steps = await LoadStepsAsync(awId, ct);
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
if (firstLevelGroup.Count == 0)