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