[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s

Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-27 12:36:59 +07:00
parent d745fec445
commit b1bae77c9c
13 changed files with 8604 additions and 41 deletions

View File

@ -33,6 +33,23 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
return Ok(new { id });
}
// [S155 F-1/F-1a] SỬA TẠI CHỖ quy trình — KHÔNG tăng Version, KHÔNG đụng
// IsActive, KHÔNG đổi Id. Giữ Id là TOÀN BỘ mục đích: phiếu đang chạy pin
// ApprovalWorkflowId và đọc workflow SỐNG (PurchaseEvaluationWorkflowService
// :674 `w.Id == awId`) ⇒ chỉ có sửa-tại-chỗ mới tới được phiếu đang treo.
// Chỉ nhận thay đổi hạng AN TOÀN; handler thi hành 2 phép thử (T1) khung
// không đổi + (T2) luật chơi không đổi, vi phạm ⇒ 409.
// 🔴 Policy BẮT BUỘC — khớp 3 verb ghi còn lại của controller này. Thiếu nó =
// leo thang đặc quyền: thêm mình vào một Cấp ⇒ duyệt phiếu người khác;
// bật cờ F6 ⇒ tự cấp quyền xoá phiếu đang duyệt.
[HttpPut("{id:guid}")]
[Authorize(Policy = "Workflows.Create")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateAwDefinitionCommand cmd, CancellationToken ct)
{
await mediator.Send(cmd with { Id = id }, ct);
return NoContent();
}
public record SetUserSelectableBody(bool IsUserSelectable);
// Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu".

View File

@ -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 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 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 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 )" : 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ể

View File

@ -78,8 +78,20 @@ public class ApprovalWorkflowStep : BaseEntity
public List<ApprovalWorkflowLevel> Levels { get; set; } = new();
}
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ...
// Cấp = tập các row CÙNG Order trong 1 Bước. MỖI ROW = 1 NV cụ thể
// (ApproverUserId), nhưng 1 Cấp có thể có N row ⇒ N người duyệt **OR-of-N**:
// bất kỳ ai trong Cấp duyệt là Cấp đó xong. Sequential giữa các Cấp trong cùng
// bước: cấp 1 → cấp 2 → ...
//
// [F-10 S155 2026-07-27] Comment cũ ghi "Cấp = 1 NV cụ thể ... (KHÔNG OR-of-many)"
// — SAI, ngược với mã lẫn tài liệu. Bằng chứng: service group Levels theo Order
// rồi cho phép BẤT KỲ ai trong nhóm duyệt
// (`PurchaseEvaluationWorkflowService.cs:689` `GroupBy(l => l.Order)` + `:702`
// `allowedUserIds.Contains(actorUserId)`); validator chỉ cấm TRÙNG cặp
// {Order, ApproverUserId} chứ không cấm nhiều row cùng Order
// (`ApprovalWorkflowV2AdminFeatures.cs:309-314`); CLAUDE.md + skill
// contract-workflow đều ghi "N NV cụ thể (OR-of-N cùng cấp)". Ảnh UAT prod:
// Cấp 2 Phòng Cung ứng có 3 người.
public class ApprovalWorkflowLevel : BaseEntity
{
public Guid ApprovalWorkflowStepId { get; set; }
@ -141,5 +153,20 @@ public class ApprovalWorkflowLevel : BaseEntity
/// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern.
public bool AllowApproverFinalize { get; set; }
/// F6 (Mig 68 2026-07-27 — S155, UAT Tra Sol/anh Kiệt) — Cho phép NV slot này,
/// khi đang tới lượt duyệt của mình, XÓA MỀM phiếu đang treo ở ChoDuyet. Sinh ra
/// vì phiếu bấm sai gói thầu đang **ăn lũy kế** ngân sách hạng mục sai mà không
/// có cửa nào dừng: "Trả lại" chỉ DỜI số sang dòng khác chứ không triệt tiêu
/// (`PeBudgetAccumulator.cs:104-126`), còn xóa thì allow-list chỉ nhận
/// {DangSoanThao, TuChoi} (`PurchaseEvaluationFeatures.cs:1404-1406`).
/// Default false (admin opt-in per slot). Đọc **PER-ROW** `matchingLevel.
/// AllowApproverDelete` (khuôn F5 `PurchaseEvaluationWorkflowService.cs:859`),
/// KHÔNG đọc per-Cấp `g.Any(...)` — 1 Cấp có N người (OR-of-N), đọc `Any` sẽ
/// phát quyền huỷ chứng-từ cho CẢ Cấp thay vì đúng 1 người admin chỉ đích danh
/// (owner chốt 2026-07-27). Mirror F2/F3/F4/F5 admin opt-in per-slot pattern.
/// ⚠️ Cờ này chỉ là 1 trong 3 rào: policy `PurchaseEvaluations.Delete` ∧ đúng
/// lượt ∧ F6 — xem hạng mục B spec S155 (đợt 2).
public bool AllowApproverDelete { get; set; }
public ApprovalWorkflowStep? Step { get; set; }
}

View File

@ -98,5 +98,11 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
// Mig 68 (S155 2026-07-27 — UAT Tra Sol) — F6 per-NV: cho phép NV slot này
// xóa MỀM phiếu đang treo ở ChoDuyet khi tới lượt mình duyệt (phiếu bấm sai
// gói thầu đang ăn lũy kế). Default false (admin opt-in per slot). Đọc
// per-ROW chứ KHÔNG per-Cấp — 1 Cấp có N người (OR-of-N).
e.Property(x => x.AllowApproverDelete).HasDefaultValue(false);
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeAllowApproverDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowApproverDelete",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -192,6 +192,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AllowApproverDelete")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverEditBudget")
.ValueGeneratedOnAdd()
.HasColumnType("bit")

View File

@ -702,7 +702,18 @@ public class PurchaseEvaluationWorkflowService(
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value))
{
var names = string.Join(", ", allowedUserIds);
// [S155 F-6] Trước đây in THẲNG GUID (`string.Join(", ", allowedUserIds)`)
// dù biến đã tên là `names` — người dùng cuối đọc được một dãy GUID và
// không hiểu phải nhờ AI duyệt. Nay resolve sang tên thật; fallback về
// GUID nếu không tra được để thông điệp không bao giờ rỗng.
var allowedIdList = allowedUserIds.ToList();
var resolved = await userManager.Users.AsNoTracking()
.Where(u => allowedIdList.Contains(u.Id))
.Select(u => u.FullName ?? u.Email)
.ToListAsync(ct);
var names = resolved.Count > 0
? string.Join(", ", resolved)
: string.Join(", ", allowedIdList);
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
}