@ -2,9 +2,11 @@ using FluentValidation;
using MediatR ;
using Microsoft.AspNetCore.Identity ;
using Microsoft.EntityFrameworkCore ;
using SolutionErp.Application.Common.Exceptions ;
using SolutionErp.Application.Common.Interfaces ;
using SolutionErp.Domain.ApprovalWorkflowsV2 ;
using SolutionErp.Domain.Identity ;
using SolutionErp.Domain.PurchaseEvaluations ;
namespace SolutionErp.Application.ApprovalWorkflowsV2 ;
@ -39,6 +41,7 @@ public record AwLevelDto(
bool AllowApproverEditBudget ,
bool AllowApproverSkipToFinal ,
bool AllowApproverFinalize , // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
bool AllowApproverDelete , // [Mig 68 S155] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
@ -185,7 +188,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto ( l . Id , l . Order , l . Name , l . ApproverUserId , info . FullName , info . Email ,
l . AllowReturnOneLevel , l . AllowReturnOneStep , l . AllowReturnToAssignee ,
l . AllowReturnToDrafter , l . AllowApproverEditDetails , l . AllowApproverEditBudget ,
l . AllowApproverSkipToFinal , l . AllowApproverFinalize ,
l . AllowApproverSkipToFinal , l . AllowApproverFinalize , l . AllowApproverDelete ,
proBudgetEditors . Contains ( l . ApproverUserId ) , ccmBudgetEditors . Contains ( l . ApproverUserId ) ) ;
} ) . ToList ( )
) ) . ToList ( ) ) ;
@ -227,7 +230,10 @@ public record CreateAwLevelInput(
bool AllowApproverEditDetails = false ,
bool AllowApproverEditBudget = false ,
bool AllowApproverSkipToFinal = false ,
bool AllowApproverFinalize = false ) ;
bool AllowApproverFinalize = false ,
// [Mig 68 S155] F6 — slot này được XÓA MỀM phiếu đang treo ChoDuyet khi tới
// lượt mình duyệt. Default false (admin opt-in per slot).
bool AllowApproverDelete = false ) ;
public record CreateAwStepInput (
int Order ,
@ -366,6 +372,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditBudget = l . AllowApproverEditBudget ,
AllowApproverSkipToFinal = l . AllowApproverSkipToFinal ,
AllowApproverFinalize = l . AllowApproverFinalize ,
AllowApproverDelete = l . AllowApproverDelete , // [Mig 68 S155] F6
} ) . ToList ( ) ,
} )
. ToList ( ) ,
@ -376,6 +383,747 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
}
}
// ============================================================================
// ========== PUT sửa TẠI CHỖ (S155 2026-07-27 — hạng mục F, owner chốt (7)) ==========
// ============================================================================
// Owner: "Chỗ quy trình duyệt -> Cho thêm tính năng thêm người/điều chỉnh quyền
// -> Chứ ko cần phải tạo mới. Khi nào cần thay đổi quy trình duyệt thì mới bắt
// buộc tạo mới."
//
// 🔴 VÌ SAO PHẢI CÓ (§F.0 spec S155 — phụ thuộc CỨNG, không phải tiện nghi):
// POST tạo version mới sinh Id MỚI (:336) → phiếu đang treo pin Id CŨ
// (`PurchaseEvaluationWorkflowService.cs:674` `w.Id == awId`, tham chiếu SỐNG
// không snapshot) → bản cũ chỉ bị IsActive=false (:334) nên vẫn resolve được →
// phiếu chạy tiếp trên cấu hình CŨ, IM LẶNG. Không có đường re-pin
// (`PE.ApprovalWorkflowId` chỉ ghi lúc create/update-nháp). ⇒ Tick cờ hôm nay
// bằng POST thì phiếu cần cứu KHÔNG BAO GIỜ thấy cờ.
// ⇒ Lệnh này GIỮ NGUYÊN Id + Version + IsActive. Giữ Id là TOÀN BỘ mục đích.
//
// 🔴 PHÂN LOẠI (§F.2) — AN TOÀN ⟺ thoả CẢ HAI phép thử:
// (T1) Khung không đổi — tập {Step.Order} và {Level.Order} giữ nguyên (con trỏ
// phiếu LAI: StepIndex = INDEX vào list sort `:686`, LevelOrder = so GIÁ
// TRỊ `:694` ⇒ giữ 2 tập Order thì không con trỏ nào dịch).
// Ở đây SIẾT MẠNH HƠN set-equality: Order của TỪNG row là BẤT BIẾN. Lý do:
// hoán vị Order giữa 2 Bước giữ nguyên TẬP nhưng đổi thứ tự thật (#11 PHÁ VỠ),
// set-equality một mình KHÔNG bắt được.
// (T2) Luật chơi không đổi — cấm trường quyết định ĐƯỜNG ĐI: CeoApprovalThreshold,
// F5 AllowApproverFinalize, Code, ApplicableType.
//
// ✅ Cho sửa: thêm/bớt người trong Cấp CÓ SẴN · đổi ApproverUserId · 7 cờ
// QUYỀN-TẠI-CHỖ (F1× 4, F3, F4, F6) · tên hiển thị (workflow/step/level) ·
// Step.DepartmentId (owner chốt AN TOÀN — BE chỉ dùng làm hint, không khớp người duyệt).
// ❌ Từ chối 409 + bảo tạo version mới: thêm/bớt/đổi thứ tự Bước hoặc Cấp ·
// Code · ApplicableType · CeoApprovalThreshold · F5 · F2.
//
// 🔸 KHAI QUYẾT ĐỊNH (spec KHÔNG xếp hạng F2 `AllowApproverSkipToFinal`): xếp F2
// vào nhóm CẤM-ĐỔI cùng F5 vì nó cho phép nhảy thẳng Cấp cuối = đổi đường đi
// phiếu, không phải "quyền tại chỗ". Chọn phía CHẶT khi spec im lặng; nới sau
// rẻ hơn thu hồi. Cả F2 lẫn F5 nhận theo kiểu ECHO: gửi lên phải KHỚP giá trị
// đang lưu (null = không khai báo, giữ nguyên), lệch ⇒ 409 — KHÔNG bao giờ ghi.
public record UpdateAwLevelInput (
// null = dòng MỚI (thêm người vào Cấp CÓ SẴN). Có Id = dòng đang tồn tại.
Guid ? Id ,
int Order ,
string? Name ,
Guid ApproverUserId ,
// 7 cờ QUYỀN-TẠI-CHỖ — sửa được tại chỗ.
bool AllowReturnOneLevel = false ,
bool AllowReturnOneStep = false ,
bool AllowReturnToAssignee = false ,
bool AllowReturnToDrafter = true ,
bool AllowApproverEditDetails = false ,
bool AllowApproverEditBudget = false ,
bool AllowApproverDelete = false ,
// 2 cờ ĐỔI-ĐƯỜNG-ĐI — ECHO-only (null = không khai báo). Lệch ⇒ 409.
bool? AllowApproverSkipToFinal = null ,
bool? AllowApproverFinalize = null ) ;
public record UpdateAwStepInput (
Guid Id , // BẮT BUỘC — không có đường thêm/bớt Bước ở lệnh này
int Order ,
string Name ,
Guid ? DepartmentId ,
List < UpdateAwLevelInput > Levels ) ;
public record UpdateAwDefinitionCommand (
Guid Id ,
string Name ,
string? Description ,
List < UpdateAwStepInput > Steps ,
// F-1c — lý do sửa, đi vào VẾT (changelog phiếu đang chạy). Optional.
string? Reason = null ,
// (T2) ECHO-only. Code/ApplicableType: null = không khai báo → bỏ qua.
string? Code = null ,
int? ApplicableType = null ,
// 🔴 CeoApprovalThreshold ECHO NGHIÊM: phải KHỚP TUYỆT ĐỐI giá trị đang lưu
// (cả 2 cùng null, hoặc cùng giá trị). KHÔNG dùng "null = bỏ qua" như 2 trường
// trên vì null LÀ một giá trị có nghĩa (= bỏ ngưỡng) ⇒ nếu bỏ qua thì admin xoá
// trắng ô ngưỡng sẽ nhận 204 mà số KHÔNG đổi = sai IM LẶNG, đúng cái class lỗi
// spec này đang đi chữa. Client PHẢI echo lại ngưỡng hiện tại.
decimal? CeoApprovalThreshold = null ) : IRequest ;
public class UpdateAwDefinitionCommandValidator : AbstractValidator < UpdateAwDefinitionCommand >
{
public UpdateAwDefinitionCommandValidator ( )
{
RuleFor ( x = > x . Id ) . NotEmpty ( ) ;
RuleFor ( x = > x . Name ) . NotEmpty ( ) . MaximumLength ( 200 ) ;
RuleFor ( x = > x . Description ) . MaximumLength ( 1000 ) ;
RuleFor ( x = > x . Reason ) . MaximumLength ( 500 ) ;
RuleFor ( x = > x . Steps ) . NotEmpty ( )
. WithMessage ( "Quy trình phải có ít nhất 1 bước." ) ;
RuleForEach ( x = > x . Steps ) . ChildRules ( step = >
{
step . RuleFor ( s = > s . Id ) . NotEmpty ( )
. WithMessage ( "Mỗi Bước phải kèm Id của Bước đang tồn tại (sửa tại chỗ không tạo Bước mới)." ) ;
step . RuleFor ( s = > s . Order ) . GreaterThanOrEqualTo ( 1 ) ;
step . RuleFor ( s = > s . Name ) . NotEmpty ( ) . MaximumLength ( 200 ) ;
step . RuleFor ( s = > s . Levels ) . NotEmpty ( )
. WithMessage ( "Mỗi bước phải có ít nhất 1 cấp duyệt." ) ;
step . RuleForEach ( s = > s . Levels ) . ChildRules ( level = >
{
level . RuleFor ( l = > l . Order )
. InclusiveBetween ( 1 , CreateAwDefinitionCommandValidator . MaxLevelsPerStep ) ;
level . RuleFor ( l = > l . Name ) . MaximumLength ( 200 ) ;
level . RuleFor ( l = > l . ApproverUserId ) . NotEmpty ( )
. WithMessage ( "Mỗi dòng cấp phải chỉ định 1 NV duyệt." ) ;
} ) ;
step . RuleFor ( s = > s . Levels ) . Must ( HaveNoDuplicateApproverInSameLevel )
. WithMessage ( "Một NV không được duyệt hai lần trong cùng một Cấp." ) ;
} ) ;
}
private static bool HaveNoDuplicateApproverInSameLevel ( List < UpdateAwLevelInput > levels ) = >
levels . GroupBy ( l = > new { l . Order , l . ApproverUserId } ) . All ( g = > g . Count ( ) = = 1 ) ;
}
public class UpdateAwDefinitionCommandHandler (
IApplicationDbContext db ,
ICurrentUser currentUser ,
IDateTime dateTime ,
UserManager < User > userManager ) : IRequestHandler < UpdateAwDefinitionCommand >
{
private const string NewVersionHint =
"Thay đổi này đổi CẤU TRÚC/ĐƯỜNG ĐI của quy trình nên không sửa tại chỗ được — hãy tạo phiên bản quy trình MỚI." ;
private sealed record LevelOpinionCount ( Guid LevelId , int Count ) ;
// F2 — một chữ ký CÒN LƯU trên dòng Cấp, kèm đủ thứ để NÓI TÊN hồ sơ đang giữ nó.
// `Code` nullable vì mọi module đều cho hồ sơ nháp chưa sinh mã.
private sealed record RetainedOpinionRef ( Guid LevelId , string Module , string? Code , bool ParentDeleted ) ;
public async Task Handle ( UpdateAwDefinitionCommand request , CancellationToken ct )
{
var def = await db . ApprovalWorkflows
. Include ( w = > w . Steps )
. ThenInclude ( s = > s . Levels )
. FirstOrDefaultAsync ( w = > w . Id = = request . Id , ct )
? ? throw new NotFoundException ( "Quy trình duyệt" , request . Id ) ;
// ================= (T2) LUẬT CHƠI KHÔNG ĐỔI =================
if ( request . Code is string code & & ! string . Equals ( code , def . Code , StringComparison . Ordinal ) )
throw new ConflictException (
$"Không được đổi Mã quy trình khi sửa tại chỗ ({def.Code} → {code}): mã là khoá của chuỗi phiên bản. {NewVersionHint}" ) ;
if ( request . ApplicableType is int applicableType & & applicableType ! = ( int ) def . ApplicableType )
throw new ConflictException (
$"Không được đổi Loại áp dụng khi sửa tại chỗ: phiếu đang pin quy trình này sẽ chạy sang module khác. {NewVersionHint}" ) ;
if ( request . CeoApprovalThreshold ! = def . CeoApprovalThreshold )
throw new ConflictException (
$"Không được đổi Ngưỡng giá trị gói CEO khi sửa tại chỗ ({FormatMoney(def.CeoApprovalThreshold)} → {FormatMoney(request.CeoApprovalThreshold)}): " +
$"phiếu đang chờ sẽ đổi nhánh cần/không-cần CEO giữa chừng, trong khi người đã duyệt thì duyệt theo ngưỡng họ nhìn thấy lúc đó. {NewVersionHint}" ) ;
// ================= (T1) KHUNG KHÔNG ĐỔI — Bước =================
var dbSteps = def . Steps . OrderBy ( s = > s . Order ) . ToList ( ) ;
var reqStepIds = request . Steps . Select ( s = > s . Id ) . ToList ( ) ;
if ( reqStepIds . Distinct ( ) . Count ( ) ! = reqStepIds . Count )
throw new ConflictException ( "Danh sách Bước gửi lên có Id trùng nhau." ) ;
if ( ! dbSteps . Select ( s = > s . Id ) . ToHashSet ( ) . SetEquals ( reqStepIds ) )
throw new ConflictException (
$"Không được thêm/bớt Bước khi sửa tại chỗ (quy trình đang có {dbSteps.Count} bước, gửi lên {reqStepIds.Count}). {NewVersionHint}" ) ;
// ================= Phân tích diff theo Level.Id (F-3: CẤM delete-then-insert) =================
var toRemove = new List < ApprovalWorkflowLevel > ( ) ; // bỏ người khỏi Cấp
var toUpdate = new List < ( ApprovalWorkflowLevel Db , UpdateAwLevelInput Req , ApprovalWorkflowStep Step ) > ( ) ;
var toAdd = new List < ( ApprovalWorkflowStep Step , UpdateAwLevelInput Req ) > ( ) ;
var approverChanged = new List < ApprovalWorkflowLevel > ( ) ; // đổi ApproverUserId (hạng NỬA #5)
foreach ( var reqStep in request . Steps )
{
var dbStep = dbSteps . First ( s = > s . Id = = reqStep . Id ) ;
var where = $"Bước {dbStep.Order} ({dbStep.Name})" ;
if ( reqStep . Order ! = dbStep . Order )
throw new ConflictException (
$"Không được đổi thứ tự Bước ({where}: {dbStep.Order} → {reqStep.Order}): con trỏ phiếu trỏ theo INDEX của danh sách đã sắp xếp nên sẽ trỏ sang bước khác, ÂM THẦM. {NewVersionHint}" ) ;
var dbLevels = dbStep . Levels . OrderBy ( l = > l . Order ) . ToList ( ) ;
var reqLevelIds = reqStep . Levels . Where ( l = > l . Id is Guid ) . Select ( l = > l . Id ! . Value ) . ToList ( ) ;
if ( reqLevelIds . Distinct ( ) . Count ( ) ! = reqLevelIds . Count )
throw new ConflictException ( $"{where}: danh sách cấp duyệt gửi lên có Id trùng nhau." ) ;
var dbLevelById = dbLevels . ToDictionary ( l = > l . Id ) ;
var unknown = reqLevelIds . Where ( id = > ! dbLevelById . ContainsKey ( id ) ) . ToList ( ) ;
if ( unknown . Count > 0 )
throw new ConflictException ( $"{where}: có dòng cấp duyệt không thuộc bước này (Id {string.Join(" , ", unknown)})." ) ;
foreach ( var reqLevel in reqStep . Levels )
{
if ( reqLevel . Id is Guid lid )
{
var dbLevel = dbLevelById [ lid ] ;
// Order của TỪNG dòng là BẤT BIẾN (siết mạnh hơn set-equality).
if ( reqLevel . Order ! = dbLevel . Order )
throw new ConflictException (
$"{where}: không được chuyển người duyệt từ Cấp {dbLevel.Order} sang Cấp {reqLevel.Order} khi sửa tại chỗ. {NewVersionHint}" ) ;
// (T2) 2 cờ ĐỔI-ĐƯỜNG-ĐI — echo phải khớp.
if ( reqLevel . AllowApproverFinalize is bool f & & f ! = dbLevel . AllowApproverFinalize )
throw new ConflictException (
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \" Duyệt là kết thúc ( không trình CEO ) \ " khi sửa tại chỗ — cờ này đổi ĐIỂM KẾT THÚC của phiếu và đóng băng số tiền ngay lúc duyệt. {NewVersionHint}" ) ;
if ( reqLevel . AllowApproverSkipToFinal is bool s & & s ! = dbLevel . AllowApproverSkipToFinal )
throw new ConflictException (
$"{where} — Cấp {dbLevel.Order}: không được bật/tắt cờ \" Duyệt vượt cấp tới Cấp cuối \ " khi sửa tại chỗ — cờ này cho bỏ qua các Bước/Cấp trung gian. {NewVersionHint}" ) ;
toUpdate . Add ( ( dbLevel , reqLevel , dbStep ) ) ;
if ( reqLevel . ApproverUserId ! = dbLevel . ApproverUserId )
approverChanged . Add ( dbLevel ) ;
}
else
{
// Dòng MỚI — người mới KHÔNG được mang cờ đổi-đường-đi.
if ( reqLevel . AllowApproverFinalize = = true )
throw new ConflictException (
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \" Duyệt là kết thúc ( không trình CEO ) \ ". {NewVersionHint}" ) ;
if ( reqLevel . AllowApproverSkipToFinal = = true )
throw new ConflictException (
$"{where} — Cấp {reqLevel.Order}: người mới thêm không được mang cờ \" Duyệt vượt cấp tới Cấp cuối \ ". {NewVersionHint}" ) ;
toAdd . Add ( ( dbStep , reqLevel ) ) ;
}
}
var removedHere = dbLevels . Where ( l = > ! reqLevelIds . Contains ( l . Id ) ) . ToList ( ) ;
foreach ( var gone in removedHere )
{
// Bỏ người đang giữ cờ đổi-đường-đi = đổi điểm kết thúc / bỏ đường tắt.
if ( gone . AllowApproverFinalize )
throw new ConflictException (
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \" Duyệt là kết thúc ( không trình CEO ) \ " — bỏ đi là đổi ĐIỂM KẾT THÚC của phiếu. {NewVersionHint}" ) ;
if ( gone . AllowApproverSkipToFinal )
throw new ConflictException (
$"{where} — Cấp {gone.Order}: không được bỏ người đang giữ cờ \" Duyệt vượt cấp tới Cấp cuối \ ". {NewVersionHint}" ) ;
}
toRemove . AddRange ( removedHere ) ;
// (T1) tập {Level.Order} SAU khi sửa phải trùng khít tập TRƯỚC.
var finalOrders = reqStep . Levels . Select ( l = > l . Order ) . ToHashSet ( ) ;
var dbOrders = dbLevels . Select ( l = > l . Order ) . ToHashSet ( ) ;
if ( ! dbOrders . SetEquals ( finalOrders ) )
{
var lost = dbOrders . Except ( finalOrders ) . OrderBy ( o = > o ) . ToList ( ) ;
if ( lost . Count > 0 )
throw new ConflictException (
$"{where}: Cấp {string.Join(" , ", lost)} sẽ KHÔNG CÒN người duyệt nào — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ lại ít nhất 1 người ở mỗi Cấp. {NewVersionHint}" ) ;
var added = finalOrders . Except ( dbOrders ) . OrderBy ( o = > o ) . ToList ( ) ;
throw new ConflictException (
$"{where}: không được thêm Cấp mới (Cấp {string.Join(" , ", added)}) khi sửa tại chỗ. {NewVersionHint}" ) ;
}
}
// ================= NV được trỏ tới phải tồn tại (chặn FK 547 → 500) =================
var referencedUserIds = toUpdate . Select ( t = > t . Req . ApproverUserId )
. Concat ( toAdd . Select ( t = > t . Req . ApproverUserId ) )
. Distinct ( ) . ToList ( ) ;
if ( referencedUserIds . Count > 0 )
{
var existing = await db . Users . AsNoTracking ( )
. Where ( u = > referencedUserIds . Contains ( u . Id ) )
. Select ( u = > u . Id ) . ToListAsync ( ct ) ;
var missing = referencedUserIds . Except ( existing ) . ToList ( ) ;
if ( missing . Count > 0 )
throw new ConflictException ( $"Có NV duyệt không tồn tại trong hệ thống (Id {string.Join(" , ", missing)})." ) ;
}
// ================= F-5 / F-5a — hạng NỬA: chặn LỊCH SỰ trước khi FK nổ =================
// Row đã có CHỮ KÝ (LevelOpinion) mà bị bỏ đi → FK Restrict nổ 547 → 500.
// Row đã có chữ ký mà bị đổi ApproverUserId → chữ ký của người A hoá thành
// của người B, IM LẶNG (opinion khoá theo LevelId, không theo UserId).
// Guard chạy HAI TẦNG (tầng 2 thêm ở wave 5 theo F2 của reviewer):
// tầng 1 `CountLiveOpinionsAsync` — chữ ký trên hồ sơ CÒN SỐNG (thông điệp cũ).
// tầng 2 `ListRetainedOpinionsAsync` — chữ ký còn LƯU nhưng không "sống"
// (hồ sơ cha xoá mềm, hoặc ý kiến tự nó xoá mềm).
// 🔴 ĐẢO QUYẾT ĐỊNH F-5a (bản trước): trước đây coi "phiếu đã xoá thì chặn admin là
// OAN" nên để purge xoá CỨNG chữ ký của phiếu xoá mềm. Sai — xoá mềm là KHÔI PHỤC
// ĐƯỢC và §A spec S155 có màn "Đã xoá" đọc lịch sử; xoá cứng chữ ký của nó là mất
// trắng, im lặng, không dấu vết. Nay chặn cả hai tầng; chỉ hồ sơ đã KHÔNG CÒN ROW
// mới bị coi là mồ côi (xem `PurgeOrphanOpinionsAsync`).
var guardedLevelIds = toRemove . Select ( l = > l . Id ) . Concat ( approverChanged . Select ( l = > l . Id ) ) . Distinct ( ) . ToList ( ) ;
if ( guardedLevelIds . Count > 0 )
{
var liveOpinions = await CountLiveOpinionsAsync ( guardedLevelIds , ct ) ;
var blocked = toRemove . Where ( l = > liveOpinions . GetValueOrDefault ( l . Id ) > 0 ) . ToList ( ) ;
if ( blocked . Count > 0 )
{
var first = blocked [ 0 ] ;
var step = dbSteps . First ( s = > s . Id = = first . ApprovalWorkflowStepId ) ;
throw new ConflictException (
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không bỏ được người này vì đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực. Bỏ đi là xoá mất lịch sử duyệt. {NewVersionHint}" ) ;
}
var blockedSwap = approverChanged . Where ( l = > liveOpinions . GetValueOrDefault ( l . Id ) > 0 ) . ToList ( ) ;
if ( blockedSwap . Count > 0 )
{
var first = blockedSwap [ 0 ] ;
var step = dbSteps . First ( s = > s . Id = = first . ApprovalWorkflowStepId ) ;
throw new ConflictException (
$"Bước {step.Order} ({step.Name}) — Cấp {first.Order}: không đổi được người duyệt vì dòng này đã có {liveOpinions[first.Id]} chữ ký/ý kiến trên phiếu còn hiệu lực — đổi người sẽ khiến ý kiến của người cũ hoá thành của người mới. {NewVersionHint}" ) ;
}
// ===== 🔴 F2 — TẦNG 2: chữ ký CÒN LƯU nhưng không "sống" =====
// Hai ca mà tầng 1 CỐ TÌNH bỏ qua, và trước bản vá bị `PurgeOrphanOpinionsAsync`
// xoá CỨNG (mất vĩnh viễn, chỉ để lại một con số đếm trong changelog):
// (a) hồ sơ cha XOÁ MỀM — row còn nguyên, khôi phục được, màn "Đã xoá" phải
// đọc được lịch sử ký;
// (b) chính opinion đã xoá mềm trên hồ sơ CÒN SỐNG — bản ghi KIỂM TOÁN của
// tài liệu đang hiệu lực.
// Nay chặn admin y như phiếu sống, và NÓI RÕ hồ sơ nào đang giữ chữ ký để họ
// xử lý được (khôi phục, hoặc xoá hẳn) thay vì đoán.
var retained = await ListRetainedOpinionsAsync ( guardedLevelIds , ct ) ;
if ( retained . Count > 0 )
{
var goneFirst = toRemove . FirstOrDefault ( l = > retained . ContainsKey ( l . Id ) ) ;
var target = goneFirst ? ? approverChanged . FirstOrDefault ( l = > retained . ContainsKey ( l . Id ) ) ;
if ( target is not null )
{
var step = dbSteps . First ( s = > s . Id = = target . ApprovalWorkflowStepId ) ;
var rows = retained [ target . Id ] ;
var verb = goneFirst is not null ? "không bỏ được người này" : "không đổi được người duyệt" ;
throw new ConflictException (
$"Bước {step.Order} ({step.Name}) — Cấp {target.Order}: {verb} vì còn {rows.Count} chữ ký/ý kiến đang LƯU, thuộc: {DescribeRetained(rows)}. " +
$"Chữ ký của hồ sơ đã xoá vẫn là LỊCH SỬ DUYỆT (màn \" Đ ã xoá \ " còn đọc được) nên hệ thống KHÔNG tự xoá cứng. " +
$"Hãy khôi phục hồ sơ rồi xử lý, hoặc xoá HẲN hồ sơ đó khỏi hệ thống trước. {NewVersionHint}" ) ;
}
}
}
// ================= 🔴 F1 (reviewer đợt 1) — RÀO GIAO DỊCH =================
// TRƯỚC bản vá: `PurgeOrphanOpinionsAsync` dùng `ExecuteDeleteAsync`, mà
// ExecuteDelete phát DELETE thẳng xuống SQL và **auto-commit NGAY** khi không có
// transaction ambient. `SaveChangesAsync` thì mãi cuối method. Giữa hai điểm đó có
// ÍT NHẤT 4 nguồn ném ĐỘC LẬP: `ResolveUserNamesAsync` (round-trip DB) ·
// `db.ApprovalWorkflowLevels.Remove` (severed-association nếu detach bắt hụt) ·
// truy vấn phiếu bị ảnh hưởng (round-trip DB) · chính `SaveChanges` (FK/unique/
// concurrency/timeout/huỷ-token). Ném ở bất kỳ chỗ nào ⇒ mọi thay đổi sau đó
// rollback, NHƯNG chữ ký đã bị xoá CỨNG thì không quay lại — admin nhận 409/500 và
// tưởng "không có gì xảy ra". Đúng class lỗi SAI IM LẶNG mà spec S155 đang đi chữa.
//
// Mở transaction TẠI ĐÂY — ngay trước thao tác KHÔNG-HOÀN-TÁC đầu tiên. Mọi guard
// phía trên chỉ ĐỌC (AsNoTracking) và ném trước khi chạm dữ liệu, nên không cần
// nằm trong rào; mở sớm hơn chỉ kéo dài thời gian giữ khoá trên SQL Server.
// KHÔNG dùng IsolationLevel.Serializable như các code-generator (`ContractCodeGenerator.cs:37`):
// ở đó Serializable để chống ĐUA cấp-số; ở đây yêu cầu là NGUYÊN TỬ (all-or-nothing),
// không phải chống đua — siết isolation chỉ làm màn quản trị dễ deadlock.
// `await using` + dispose = rollback nếu chưa Commit (khuôn nhà: `LeaveOtApprovalFeatures.cs:34-44`).
var context = ( DbContext ) db ;
await using var tx = await context . Database . BeginTransactionAsync ( ct ) ;
// ================= Dọn rác chữ ký MỒ CÔI trước khi xoá dòng =================
// 🔴 F2 (reviewer đợt 1) đã THU HẸP nhãn "mồ côi": chỉ còn opinion mà hồ sơ cha
// KHÔNG CÒN ROW trong DB. Hồ sơ xoá MỀM giữ nguyên chữ ký và bị guard chặn ở trên.
// 🔴 LevelOpinion là AuditableEntity ⇒ Remove() chỉ xoá MỀM (interceptor đổi
// Deleted→Modified) ⇒ row ở lại ⇒ FK vẫn chặn. Phải ExecuteDelete (SQL thẳng).
var purgedOrphans = 0 ;
if ( toRemove . Count > 0 )
purgedOrphans = await PurgeOrphanOpinionsAsync ( toRemove . Select ( l = > l . Id ) . ToList ( ) , ct ) ;
// ================= ÁP THAY ĐỔI (chỉ trường hạng AN TOÀN) =================
var userNames = await ResolveUserNamesAsync (
toUpdate . Select ( t = > t . Db . ApproverUserId )
. Concat ( toUpdate . Select ( t = > t . Req . ApproverUserId ) )
. Concat ( toAdd . Select ( t = > t . Req . ApproverUserId ) )
. Concat ( toRemove . Select ( l = > l . ApproverUserId ) )
. Distinct ( ) . ToList ( ) , ct ) ;
string NameOf ( Guid id ) = > userNames . GetValueOrDefault ( id ) ? ? id . ToString ( ) ;
var changes = new List < string > ( ) ;
if ( def . Name ! = request . Name ) changes . Add ( $"Đổi tên quy trình: \" { def . Name } \ " → \"{request.Name}\"" ) ;
def . Name = request . Name ;
def . Description = request . Description ;
foreach ( var reqStep in request . Steps )
{
var dbStep = dbSteps . First ( s = > s . Id = = reqStep . Id ) ;
if ( dbStep . Name ! = reqStep . Name ) changes . Add ( $"Bước {dbStep.Order}: đổi tên \" { dbStep . Name } \ " → \"{reqStep.Name}\"" ) ;
if ( dbStep . DepartmentId ! = reqStep . DepartmentId ) changes . Add ( $"Bước {dbStep.Order} ({reqStep.Name}): đổi phòng ban phụ trách" ) ;
dbStep . Name = reqStep . Name ;
dbStep . DepartmentId = reqStep . DepartmentId ;
}
foreach ( var ( dbLevel , reqLevel , step ) in toUpdate )
{
if ( dbLevel . ApproverUserId ! = reqLevel . ApproverUserId )
changes . Add ( $"Bước {step.Order} Cấp {dbLevel.Order}: đổi NV duyệt {NameOf(dbLevel.ApproverUserId)} → {NameOf(reqLevel.ApproverUserId)}" ) ;
var flagDiff = DescribeFlagChanges ( dbLevel , reqLevel ) ;
if ( flagDiff . Count > 0 )
changes . Add ( $"Bước {step.Order} Cấp {dbLevel.Order} — {NameOf(reqLevel.ApproverUserId)}: {string.Join(" ; ", flagDiff)}" ) ;
dbLevel . Name = reqLevel . Name ;
dbLevel . ApproverUserId = reqLevel . ApproverUserId ;
dbLevel . AllowReturnOneLevel = reqLevel . AllowReturnOneLevel ;
dbLevel . AllowReturnOneStep = reqLevel . AllowReturnOneStep ;
dbLevel . AllowReturnToAssignee = reqLevel . AllowReturnToAssignee ;
dbLevel . AllowReturnToDrafter = reqLevel . AllowReturnToDrafter ;
dbLevel . AllowApproverEditDetails = reqLevel . AllowApproverEditDetails ;
dbLevel . AllowApproverEditBudget = reqLevel . AllowApproverEditBudget ;
dbLevel . AllowApproverDelete = reqLevel . AllowApproverDelete ;
// ❌ KHÔNG gán AllowApproverFinalize / AllowApproverSkipToFinal — echo-only.
}
foreach ( var ( step , reqLevel ) in toAdd )
{
var newLevel = new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step . Id ,
Order = reqLevel . Order ,
Name = reqLevel . Name ,
ApproverUserId = reqLevel . ApproverUserId ,
AllowReturnOneLevel = reqLevel . AllowReturnOneLevel ,
AllowReturnOneStep = reqLevel . AllowReturnOneStep ,
AllowReturnToAssignee = reqLevel . AllowReturnToAssignee ,
AllowReturnToDrafter = reqLevel . AllowReturnToDrafter ,
AllowApproverEditDetails = reqLevel . AllowApproverEditDetails ,
AllowApproverEditBudget = reqLevel . AllowApproverEditBudget ,
AllowApproverDelete = reqLevel . AllowApproverDelete ,
AllowApproverSkipToFinal = false , // đổi-đường-đi: dòng mới luôn tắt
AllowApproverFinalize = false ,
} ;
// 🔴 PHẢI đăng ký TƯỜNG MINH qua DbSet.Add — KHÔNG được chỉ `step.Levels.Add(...)`.
// `BaseEntity.Id = Guid.NewGuid()` (BaseEntity.cs:5) ⇒ khoá LUÔN khác default.
// Khi DetectChanges gặp một entity lạ trong collection của cha ĐANG ĐƯỢC THEO DÕI,
// EF quyết Added/Modified bằng heuristic "khoá đã set ⇒ coi như đã tồn tại" ⇒ nó
// gán state **Modified** ⇒ phát UPDATE cho dòng CHƯA CÓ ⇒ 0 row ⇒
// DbUpdateConcurrencyException. DbSet.Add ép state Added, không qua heuristic.
// (POST tạo mới không dính vì Add từ GỐC — cả đồ thị bị ép Added.)
db . ApprovalWorkflowLevels . Add ( newLevel ) ;
step . Levels . Add ( newLevel ) ; // giữ nav in-memory nhất quán cho phần đọc phía sau
changes . Add ( $"Bước {step.Order} Cấp {reqLevel.Order}: THÊM NV duyệt {NameOf(reqLevel.ApproverUserId)}" ) ;
}
// 🔴 Gỡ THEO DÕI mọi bản sao chữ ký còn nằm trong change-tracker và trỏ vào các dòng
// Cấp sắp xoá — BẮT BUỘC làm TRƯỚC `Remove` bên dưới. Xem `DetachTrackedOpinionsOn`.
if ( toRemove . Count > 0 )
DetachTrackedOpinionsOn ( db , toRemove . Select ( l = > l . Id ) . ToHashSet ( ) ) ;
foreach ( var gone in toRemove )
{
var step = dbSteps . First ( s = > s . Id = = gone . ApprovalWorkflowStepId ) ;
changes . Add ( $"Bước {step.Order} Cấp {gone.Order}: BỎ NV duyệt {NameOf(gone.ApproverUserId)}" ) ;
db . ApprovalWorkflowLevels . Remove ( gone ) ; // BaseEntity → xoá CỨNG (không dính soft-delete)
}
if ( purgedOrphans > 0 )
changes . Add ( $"Dọn {purgedOrphans} chữ ký mồ côi (hồ sơ mang chữ ký đã KHÔNG CÒN trong hệ thống) để gỡ được người khỏi Cấp" ) ;
// ================= F-1c — GHI VẾT =================
// (a) Chạm entity cha để AuditingInterceptor đóng dấu UpdatedAt/UpdatedBy kể
// cả khi chỉ có dòng Cấp đổi (ai + lúc nào).
def . UpdatedAt = dateTime . UtcNow ;
// (b) LÝ DO + NỘI DUNG đổi → changelog của CHÍNH những phiếu đang chịu ảnh
// hưởng (phiếu chưa kết thúc, đang pin quy trình này). Đối xứng với yêu
// cầu ghi changelog khi xoá phiếu (§B4): người đọc lịch sử phiếu thấy
// đượ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) hiện CHỈ có UpdatedAt/
// UpdatedBy ở (a). Bảng `ApprovalWorkflowChangelogs` = việc tách riêng.
if ( changes . Count > 0 & &
def . ApplicableType is ApprovalWorkflowApplicableType . DuyetNcc
or ApprovalWorkflowApplicableType . DuyetNccPhuongAn )
{
var affected = await db . PurchaseEvaluations . AsNoTracking ( )
. Where ( p = > p . ApprovalWorkflowId = = def . Id
& & p . Phase ! = PurchaseEvaluationPhase . DaDuyet
& & p . Phase ! = PurchaseEvaluationPhase . TuChoi )
. 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
{
PurchaseEvaluationId = pe . Id ,
EntityType = PurchaseEvaluationEntityType . Workflow ,
Action = SolutionErp . Domain . Contracts . ChangelogAction . Update ,
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)" ,
ContextNote = note ,
} ) ;
}
}
await db . SaveChangesAsync ( ct ) ;
// 🔴 F1 — CHỐT rào: tới đây purge + gỡ dòng Cấp + changelog mới cùng nhau có hiệu lực.
// Ném ở BẤT KỲ đâu phía trên ⇒ dispose của `tx` rollback TRỌN, kể cả các câu DELETE
// của `PurgeOrphanOpinionsAsync` (ExecuteDelete tự enlist vào transaction đang mở).
await tx . CommitAsync ( ct ) ;
}
// Đếm chữ ký CÒN HIỆU LỰC trên các dòng Cấp — quét CẢ 7 bảng LevelOpinion đang
// FK vào ApprovalWorkflowLevels (PE + HĐ + 5 module Văn phòng số). Chỉ soi PE là
// vẫn nổ FK 547 khi quy trình thuộc module khác (§F.3 blast radius ≥6 module).
// 🔴 `!p.IsDeleted` viết TƯỜNG MINH: chỉ PE + Contract có global query filter,
// 5 bảng Office KHÔNG có ⇒ dựa filter là đúng PE mà sai 5 module kia.
private async Task < Dictionary < Guid , int > > CountLiveOpinionsAsync ( List < Guid > levelIds , CancellationToken ct )
{
var acc = levelIds . ToDictionary ( id = > id , _ = > 0 ) ;
void Merge ( List < LevelOpinionCount > rows )
{
foreach ( var r in rows ) acc [ r . LevelId ] = acc . GetValueOrDefault ( r . LevelId ) + r . Count ;
}
Merge ( await db . PurchaseEvaluationLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . PurchaseEvaluations . Any ( p = > p . Id = = o . PurchaseEvaluationId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . ContractLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . Contracts . Any ( p = > p . Id = = o . ContractId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . ProposalLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . Proposals . Any ( p = > p . Id = = o . ProposalId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . LeaveRequestLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . LeaveRequests . Any ( p = > p . Id = = o . LeaveRequestId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . OtRequestLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . OtRequests . Any ( p = > p . Id = = o . OtRequestId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . TravelRequestLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . TravelRequests . Any ( p = > p . Id = = o . TravelRequestId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
Merge ( await db . VehicleBookingLevelOpinions . AsNoTracking ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) & & ! o . IsDeleted
& & db . VehicleBookings . Any ( p = > p . Id = = o . VehicleBookingId & & ! p . IsDeleted ) )
. GroupBy ( o = > o . ApprovalWorkflowLevelId )
. Select ( g = > new LevelOpinionCount ( g . Key , g . Count ( ) ) ) . ToListAsync ( ct ) ) ;
return acc ;
}
// Chữ ký CÒN LƯU trên các dòng Cấp nhưng KHÔNG thuộc diện "sống" của
// `CountLiveOpinionsAsync` — tức hồ sơ cha VẪN CÒN ROW, mà (cha xoá mềm HOẶC ý kiến
// tự nó xoá mềm). Đây chính là tập mà bản trước đem đi xoá CỨNG (F2 của reviewer).
//
// 🔴 `IgnoreQueryFilters()` LÀ BẮT BUỘC, không phải trang trí: PE và Contract có global
// filter `!IsDeleted` (`PurchaseEvaluationConfiguration.cs:84`). Không tắt filter thì hồ
// sơ xoá mềm biến mất khỏi phép JOIN ⇒ ta lại kết luận "mồ côi" ⇒ đúng cái sai đang chữa.
// (5 bảng Văn phòng số KHÔNG có filter — gọi vẫn vô hại, giữ 7 khối cùng một hình dạng.)
private async Task < Dictionary < Guid , List < RetainedOpinionRef > > > ListRetainedOpinionsAsync (
List < Guid > levelIds , CancellationToken ct )
{
var rows = new List < RetainedOpinionRef > ( ) ;
rows . AddRange ( await db . PurchaseEvaluationLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . PurchaseEvaluations , o = > o . PurchaseEvaluationId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Phiếu duyệt NCC" , x . p . MaPhieu , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . ContractLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . Contracts , o = > o . ContractId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Hợp đồng" , x . p . MaHopDong , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . ProposalLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . Proposals , o = > o . ProposalId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Đề xuất" , x . p . MaDeXuat , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . LeaveRequestLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . LeaveRequests , o = > o . LeaveRequestId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Đơn nghỉ phép" , x . p . MaDonTu , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . OtRequestLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . OtRequests , o = > o . OtRequestId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Đơn tăng ca" , x . p . MaDonTu , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . TravelRequestLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . TravelRequests , o = > o . TravelRequestId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Đơn công tác" , x . p . MaDonTu , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
rows . AddRange ( await db . VehicleBookingLevelOpinions . AsNoTracking ( ) . IgnoreQueryFilters ( )
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId ) )
. Join ( db . VehicleBookings , o = > o . VehicleBookingId , p = > p . Id , ( o , p ) = > new { o , p } )
. Where ( x = > x . o . IsDeleted | | x . p . IsDeleted )
. Select ( x = > new RetainedOpinionRef ( x . o . ApprovalWorkflowLevelId , "Đơn xe" , x . p . MaDonTu , x . p . IsDeleted ) )
. ToListAsync ( ct ) ) ;
return rows . GroupBy ( r = > r . LevelId ) . ToDictionary ( g = > g . Key , g = > g . ToList ( ) ) ;
}
private static string DescribeRetained ( List < RetainedOpinionRef > rows )
{
var head = string . Join ( "; " , rows . Take ( 3 ) . Select ( r = >
$"{r.Module} {(string.IsNullOrWhiteSpace(r.Code) ? " ( chưa có mã ) " : r.Code)}" +
( r . ParentDeleted ? " — đã xoá" : " — ý kiến đã thu hồi" ) ) ) ;
return rows . Count > 3 ? $"{head}; … và {rows.Count - 3} hồ sơ khác" : head ;
}
// Xoá CỨNG những chữ ký THẬT SỰ MỒ CÔI — hồ sơ cha KHÔNG CÒN ROW trong DB (bị xoá cứng
// ngoài luồng ứng dụng: sửa tay, nhập liệu, hoặc lúc FK từng bị tắt). ExecuteDelete đi
// thẳng SQL, KHÔNG qua change-tracker nên không bị AuditingInterceptor đổi thành xoá mềm.
//
// 🔴 F2 (reviewer đợt 1) — ĐÃ THU HẸP hai vế, đây là chỗ dễ mất dữ liệu nhất của lệnh:
// • BỎ vế `o.IsDeleted` — opinion tự nó xoá mềm trên hồ sơ CÒN SỐNG là bản ghi KIỂM
// TOÁN của tài liệu đang hiệu lực, không phải rác.
// • `!Any(p => p.Id == … && !p.IsDeleted)` → `!IgnoreQueryFilters().Any(p => p.Id == …)`
// — hồ sơ XOÁ MỀM vẫn CÒN ROW, vẫn khôi phục và xem được ⇒ chữ ký của nó KHÔNG mồ côi.
// Hai ca đó nay bị guard tầng 2 (`ListRetainedOpinionsAsync`) CHẶN admin bằng 409, kèm
// tên hồ sơ đang giữ chữ ký — thay vì xoá lén rồi chỉ để lại một con số đếm.
//
// ⇒ BẤT BIẾN sau bản vá: tới được đây nghĩa là mọi dòng còn trỏ vào `levelIds` đều có cha
// đã biến mất khỏi DB. Xoá chúng là đường DUY NHẤT để FK Restrict cho gỡ dòng Cấp, và
// không màn hình nào còn đọc được chúng nữa. Điều kiện dưới đây vẫn viết TƯỜNG MINH
// (không dựa vào việc guard đã chạy) để purge tự phòng vệ nếu hai bên có ngày lệch nhau.
private async Task < int > PurgeOrphanOpinionsAsync ( List < Guid > levelIds , CancellationToken ct )
{
var n = 0 ;
n + = await db . PurchaseEvaluationLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . PurchaseEvaluations . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . PurchaseEvaluationId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . ContractLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . Contracts . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . ContractId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . ProposalLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . Proposals . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . ProposalId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . LeaveRequestLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . LeaveRequests . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . LeaveRequestId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . OtRequestLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . OtRequests . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . OtRequestId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . TravelRequestLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . TravelRequests . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . TravelRequestId ) )
. ExecuteDeleteAsync ( ct ) ;
n + = await db . VehicleBookingLevelOpinions
. Where ( o = > levelIds . Contains ( o . ApprovalWorkflowLevelId )
& & ! db . VehicleBookings . IgnoreQueryFilters ( ) . Any ( p = > p . Id = = o . VehicleBookingId ) )
. ExecuteDeleteAsync ( ct ) ;
return n ;
}
// Gỡ theo dõi (detach) mọi entity chữ ký đang nằm trong change-tracker và trỏ vào các
// dòng Cấp sắp bị XOÁ CỨNG.
//
// VÌ SAO CẦN: `PurgeOrphanOpinionsAsync` dùng `ExecuteDelete` — đi thẳng SQL, KHÔNG cập
// nhật change-tracker (giới hạn có ghi trong tài liệu EF). Nếu context đang giữ một bản
// sao của chính những chữ ký vừa bị xoá (hoặc bất kỳ chữ ký nào trỏ vào dòng Cấp sắp gỡ),
// thì `db.ApprovalWorkflowLevels.Remove(level)` sẽ kích cascade-check CLIENT-SIDE
// (CascadeDeleteTiming.Immediate = mặc định): EF thấy dependent còn tracked + FK
// `Restrict` + cột không nullable ⇒ ném InvalidOperationException "association ... has
// been severed" NGAY TẠI `Remove()`, chưa kịp chạm DB ⇒ 500 thay vì thao tác hợp lệ.
//
// AN TOÀN: tới thời điểm gọi, guard F-5 đã đọc DB (AsNoTracking) và xác nhận các dòng
// này có 0 chữ ký CÒN HIỆU LỰC ⇒ mọi bản sao còn tracked đều là rác (thuộc phiếu đã xoá,
// hoặc chính nó đã bị purge khỏi DB) ⇒ gỡ theo dõi là làm tracker khớp lại với DB.
// Quét theo TÊN CỘT nên phủ đủ cả 7 bảng chữ ký (PE + HĐ + 5 module Văn phòng số) mà
// không phải liệt kê tay từng DbSet.
private static void DetachTrackedOpinionsOn ( IApplicationDbContext db , HashSet < Guid > levelIds )
{
if ( levelIds . Count = = 0 ) return ;
const string fkName = nameof ( PurchaseEvaluationLevelOpinion . ApprovalWorkflowLevelId ) ;
var stale = ( ( DbContext ) db ) . ChangeTracker . Entries ( )
. Where ( e = > e . State ! = EntityState . Detached
& & e . Metadata . FindProperty ( fkName ) is not null
& & e . Property ( fkName ) . CurrentValue is Guid levelId
& & levelIds . Contains ( levelId ) )
. ToList ( ) ;
foreach ( var entry in stale )
entry . State = EntityState . Detached ;
}
private async Task < Dictionary < Guid , string > > ResolveUserNamesAsync ( List < Guid > userIds , CancellationToken ct )
{
if ( userIds . Count = = 0 ) return new Dictionary < Guid , string > ( ) ;
return await userManager . Users . AsNoTracking ( )
. Where ( u = > userIds . Contains ( u . Id ) )
. ToDictionaryAsync ( u = > u . Id , u = > u . FullName ? ? u . Email ? ? u . Id . ToString ( ) , ct ) ;
}
private static List < string > DescribeFlagChanges ( ApprovalWorkflowLevel db , UpdateAwLevelInput req )
{
var list = new List < string > ( ) ;
void Cmp ( string label , bool before , bool after )
{
if ( before ! = after ) list . Add ( $"{label} {(after ? " BẬT " : " TẮT ")}" ) ;
}
Cmp ( "Trả lại 1 Cấp" , db . AllowReturnOneLevel , req . AllowReturnOneLevel ) ;
Cmp ( "Trả lại 1 Bước" , db . AllowReturnOneStep , req . AllowReturnOneStep ) ;
Cmp ( "Trả lại NV đã ký" , db . AllowReturnToAssignee , req . AllowReturnToAssignee ) ;
Cmp ( "Trả lại người soạn" , db . AllowReturnToDrafter , req . AllowReturnToDrafter ) ;
Cmp ( "Sửa Hạng mục/NCC/Báo giá" , db . AllowApproverEditDetails , req . AllowApproverEditDetails ) ;
Cmp ( "Sửa ngân sách" , db . AllowApproverEditBudget , req . AllowApproverEditBudget ) ;
Cmp ( "Xoá phiếu khi đang duyệt" , db . AllowApproverDelete , req . AllowApproverDelete ) ;
return list ;
}
private static string FormatMoney ( decimal? v ) = > v is decimal d ? d . ToString ( "#,##0.##" ) : "(không đặt)" ;
}
// ========== PATCH user-selectable toggle (Mig 25 — admin pin/unpin cho user pick) ==========
// Independent với IsActive: cho phép multiple version cùng selectable. Default
// version mới IsUserSelectable=true (mirror IsActive default), admin có thể