[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s

- ContractSigningPlanWorkflowService: copy khuon ApproveV2Async (con-tro doi StepIndex+LevelOrder, GroupBy Order, OR-of-N, LevelOpinions UPSERT "(duyet — khong y kien)") — CAM Proposal-flatten; KHONG port 2 duong ket-thuc-som (owner phan PHA VO S155)
- Choke-point finalize ApplyApprovedValuesOnFinalize (Line.ApprovedAmount ??= Proposed) 1-SITE-BY-DESIGN: hop-dong transitions khong co targetPhase => admin cung xuyen choke-point (triet lop #81 tu goc); RULE-comment khuon :356-360
- Transitions endpoint: submit/approve/return/reject (hop-dong LEAD-dinh-nghia literal — reviewer Truc-4 KHOP 5/5, truc W2 tung dut 6 diem); guard trinh CreatedBy|Drafter|DeptManager-cung-phong|Admin; changelog/approvals bang KHKK rieng (CAM IChangelogService — FK-547 rao bang vang-mat-dep); notify Cap-ke dich danh + exclusion actor/drafter (S86)
- Save-diem: 1 SaveChanges cuoi = 1 transition 1 unit-of-work (service la diem vao, khac nguon :537)
- FE KhkkWorkflowPanel x2 app SHA-pair: so-do Buoc/Cap + banner den-luot + Gui/Duyet/Tra/Tu-choi + y kien; panel GATE nut theo actor (khong de an 403)
- Test-before: 6 test san PIN RED-evidence 6/6 (5 NotImplemented + 1 assert) -> GREEN 6/6; sua-test dung 1 seed FAULT-INJECT sot (0 assert doi); suite 574 -> 580/0
- Reviewer 7/7 truc DAT 0 chan (4 truc vai + 3 truc lead on-behalf; Q1-Q6 duyet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 22:33:11 +07:00
parent d7bdb45312
commit df52fa0cd0
11 changed files with 2370 additions and 6 deletions

View File

@ -40,6 +40,9 @@ public static class DependencyInjection
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();
// [W3 đợt-1 — S161] workflow duyệt 3 trạm KHKK. Đợt-1 = SKELETON (mọi nhánh ném
// NotImplementedException); đăng ký sẵn để endpoint + test resolve được đúng chữ ký.
services.AddScoped<IContractSigningPlanWorkflowService, ContractSigningPlanWorkflowService>();
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();

View File

@ -0,0 +1,589 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (enum dùng chung)
using SolutionErp.Domain.Identity; // AppRoles
using SolutionErp.Domain.Notifications; // NotificationType
namespace SolutionErp.Infrastructure.Services;
// [W3 ĐỢT-3 — S161 2026-07-29] Workflow phiếu "Kế hoạch ký kết HĐ" (GĐ2).
//
// Đợt-1 để skeleton ném NotImplementedException cho 6 test sàn ĐỎ THẬT; đợt này điền
// logic để chúng TỰ xanh. Hợp-đồng đợt-1 (4 GIỮ + 3 ADAPT + 2 CẤM) chép lại rút gọn
// bên dưới kèm ĐỊA CHỈ, và ghi rõ chỗ nào đã thi hành / chỗ nào CỐ Ý không.
//
// NGUỒN COPY: `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`).
// 🔴 CẤM copy `ProposalFeatures` — Proposal FLATTEN Level (SelectMany) nên OR-of-N biến
// thành AND ÂM THẦM, và nó chỉ có 1 con-trỏ LevelOrder ⇒ không diễn tả được "Bước mới
// reset Cấp về 1". Ở đây giữ đúng khuôn nguồn: `GroupBy(l => l.Order)` + `Contains(actor)`.
//
// 4 ĐIỂM GIỮ (mỗi điểm = 1 acceptance, đều đã thi hành):
// 1. Con-trỏ ĐÔI `CurrentWorkflowStepIndex` (0-based INDEX vào list Steps đã sort theo
// Order — KHÔNG phải giá trị Order) + `CurrentApprovalLevelOrder` (1-based).
// Sang Bước mới ⇒ Cấp reset về 1 (`AdvancePointer` bên dưới).
// 2. GroupBy Order = Cấp (khuôn `ContractWorkflowService.cs:246`).
// 3. OR-of-N: MỘT người trong Cấp duyệt là Cấp đó xong (khuôn `:255-266`) —
// `ResolveActingLevel` khớp theo tập, KHÔNG đòi đủ chữ ký cả nhóm.
// 4. UPSERT ý kiến vào `ContractSigningPlanLevelOpinions` (khuôn `:292-316`), 1 row /
// cặp (Plan × Level), `SignedByUserId` = người ký THẬT (Admin duyệt thay vẫn ghi Admin).
//
// 3 ĐIỂM ADAPT:
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
// Đó chính là write-path 2 mà test T3 đo.
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
//
// LỊCH SỬ: mỗi transition ghi `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs`
// qua `LogSigningPlanTransitionAsync` (method KHKK-RIÊNG, bên dưới).
// 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync(...)`: nó nhận `Guid contractId` và
// ghi `ContractChangelogs` — bảng có FK VẬT LÝ `ContractId` Cascade
// (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = FK violation 547 LÚC CHẠY,
// compiler KHÔNG chặn. Rào bằng THIẾT KẾ: service CỐ Ý không inject `IChangelogService`
// (không có sẵn thì không lỡ tay gọi) — giữ nguyên từ đợt-1.
//
// ĐIỂM LƯU (khai để lead phân xử): ĐÚNG MỘT `SaveChangesAsync` ở CUỐI `TransitionAsync`.
// Khác nguồn copy (`ContractWorkflowService.cs:537` TransitionAsync KHÔNG tự save, caller
// save) vì ở đây service LÀ điểm vào (controller gọi thẳng, không qua MediatR) ⇒ không có
// "caller downstream" nào để save hộ. 1 transition = 1 unit-of-work: con-trỏ + hàng
// Approvals + Changelogs + LevelOpinion + giá chốt + Notifications cùng sống hoặc cùng chết
// (`NotificationService.cs:7-8` cố ý KHÔNG tự SaveChanges để caller giữ atomicity).
public class ContractSigningPlanWorkflowService(
IApplicationDbContext db,
ICurrentUser currentUser,
IDateTime dateTime,
INotificationService notifications) : IContractSigningPlanWorkflowService
{
// Tập action khoá theo hợp-đồng literal của lead. So khớp KHÔNG phân biệt hoa/thường
// (client gửi "Submit" vẫn hiểu), nhưng nhánh xử lý luôn chạy trên bản đã chuẩn hoá.
private static readonly HashSet<string> AllowedActions =
new(StringComparer.OrdinalIgnoreCase) { "submit", "approve", "return", "reject" };
// Ý kiến rỗng → chữ thay thế (khuôn nguồn copy `ContractWorkflowService.cs:295-297`):
// ô câm không phân biệt được "đã ký, không ý kiến" với "chưa ký".
private const string NoOpinionPlaceholder = "(duyệt — không ý kiến)";
// Trần độ dài ý kiến = cột CHẶT NHẤT trong các nơi chứa nguyên văn comment:
// `ContractSigningPlanApprovals.Comment` = 1000 (`ContractSigningPlanApprovalConfiguration.cs:26`).
// Chặn TẠI CỔNG thay vì cắt ngầm: đường này KHÔNG đi qua MediatR nên KHÔNG có
// FluentValidation nào đỡ; để lọt thì SQL Server ném 2628 (chuỗi bị cắt) = 500 khó hiểu.
private const int MaxCommentLength = 1000;
public async Task<ContractSigningPlanTransitionResult> TransitionAsync(
Guid planId,
string action,
string? comment,
CancellationToken ct = default)
{
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
// không phải "không tìm thấy phiếu" — trả 409 để FE hiện đúng nguyên nhân.
var normalized = (action ?? string.Empty).Trim().ToLowerInvariant();
if (!AllowedActions.Contains(normalized))
throw new ConflictException("Hành động không hợp lệ.");
var normalizedComment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
if (normalizedComment is not null && normalizedComment.Length > MaxCommentLength)
throw new ConflictException($"Ý kiến duyệt tối đa {MaxCommentLength} ký tự.");
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var actorId = currentUser.UserId.Value;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
// Include Lines NGAY: nhánh finalize cần chốt giá, và nạp kèm ở đây thì phép lọc
// "chỉ dòng của phiếu này" là do CHÍNH quan hệ cha-con bảo đảm (xem LUẬT ở
// `ApplyApprovedValuesOnFinalize`), không phải do nhớ viết `Where(PlanId == ...)`.
var plan = await db.ContractSigningPlans
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.Id == planId, ct)
?? throw new NotFoundException("ContractSigningPlan", planId);
// switch TÁCH RIÊNG từng nhánh (không gộp) — mỗi hành động có guard riêng; gộp lại
// là mất ranh giới giữa "ai được trình" và "ai được duyệt".
switch (normalized)
{
case "submit":
await SubmitAsync(plan, actorId, isAdmin, normalizedComment, ct);
break;
case "approve":
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
break;
case "return":
await ReturnOrRejectAsync(
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TraLai, ct);
break;
case "reject":
await ReturnOrRejectAsync(
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TuChoi, ct);
break;
default:
// Không tới được (đã lọc ở rào trên) — giữ để switch đóng kín.
throw new ConflictException("Hành động không hợp lệ.");
}
await db.SaveChangesAsync(ct);
// Trả trạng thái SAU transition, đọc từ chính entity vừa lưu ⇒ FE vẽ lại panel không
// cần refetch mà vẫn không thể lệch với DB.
return new ContractSigningPlanTransitionResult(
(int)plan.Phase, plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder);
}
// =====================================================================
// submit — người soạn trình phiếu
// =====================================================================
private async Task SubmitAsync(
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
{
if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai))
throw new ConflictException("Chỉ trình duyệt được phiếu đang ở trạng thái Nháp hoặc Trả lại.");
// Nói RÕ nguyên nhân: phiếu chưa gắn quy trình thì không có Bước/Cấp nào để chờ,
// trình đại = phiếu treo ở "Đã gửi duyệt" mà không nằm trong hộp thư của ai.
if (plan.ApprovalWorkflowId is not Guid awId)
throw new ConflictException(
"Phiếu chưa gắn quy trình duyệt — chọn quy trình trong phiếu nháp trước khi trình.");
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
var steps = await LoadStepsAsync(awId, ct);
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
if (firstLevelGroup.Count == 0)
throw new ConflictException(
$"Quy trình duyệt chưa cấu hình Cấp 1 cho Bước 1 ({steps[0].Name}) — chưa trình được.");
var fromPhase = plan.Phase;
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
// 🔴 RESET về đầu quy trình cho CẢ hai lối vào (Nháp lần đầu VÀ Trả lại gửi lại):
// nối tiếp chỗ đang dở thì bản sửa của người soạn không được các Bước trước xem lại
// lần nào (acceptance #6).
plan.CurrentWorkflowStepIndex = 0;
plan.CurrentApprovalLevelOrder = 1;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Pending, comment,
actingLevelId: null,
summary: fromPhase == ContractSigningPlanPhase.TraLai
? "Trình duyệt lại kế hoạch ký kết (chạy lại từ Bước 1 Cấp 1)"
: "Trình duyệt kế hoạch ký kết",
ct: ct);
await NotifyPendingApproversAsync(plan, firstLevelGroup, actorId, ct);
}
/// Guard trình — CỐ Ý khác khuôn role-based của Contract (né lớp 403-PMH): phiếu là của
/// người soạn + phòng của người soạn, không phải của một vai trong quy trình.
/// 🔴 KHAI RÕ nới 1 vế so với chữ của spec ("CreatedBy == actor"): chấp nhận CẢ
/// `DrafterUserId == actor`. Hai cột này chỉ đường tới CÙNG một người ở đường tạo phiếu
/// W2 (`ContractSigningPlanFeatures.cs:372` gán `DrafterUserId = actorId`, còn `CreatedBy`
/// do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ nới KHÔNG mở thêm ai. Nhưng
/// phiếu sinh ngoài đường CQRS (seed/import chạy dưới danh nghĩa hệ thống) sẽ có
/// `CreatedBy = null` — đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình.
private async Task EnsureCanSubmitAsync(
ContractSigningPlan plan, Guid actorId, bool isAdmin, CancellationToken ct)
{
if (isAdmin) return;
if (plan.CreatedBy == actorId || plan.DrafterUserId == actorId) return;
// Trưởng phòng CÙNG phòng ban CỦA PHIẾU. So với `plan.DepartmentId` (phòng của phiếu),
// KHÔNG phải `Step.DepartmentId` — cột kia chỉ là hint của quy trình
// (`ContractSigningPlan.cs:26-28`).
if (currentUser.Roles.Contains(AppRoles.DeptManager) && plan.DepartmentId is Guid deptId)
{
var sameDept = await db.Users.AsNoTracking()
.AnyAsync(u => u.Id == actorId && u.DepartmentId == deptId, ct);
if (sameDept) return;
}
throw new ForbiddenException(
"Chỉ người soạn kế hoạch, trưởng phòng cùng phòng ban của phiếu hoặc Admin được trình duyệt.");
}
// =====================================================================
// approve — approver Cấp đang chờ duyệt (OR-of-N)
// =====================================================================
private async Task ApproveV2Async(
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
{
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
if (plan.ApprovalWorkflowId is not Guid awId)
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
var steps = await LoadStepsAsync(awId, ct);
var (currentIdx, currentStep, currentLevelOrder, levelGroups, maxLevelOrder) =
ResolvePointer(plan, steps);
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
var actingLevel = ResolveActingLevel(
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
// Ý kiến sync ngay lúc duyệt (Q1=1B — KHÔNG endpoint ý kiến rời).
await UpsertLevelOpinionAsync(plan, actingLevel, actorId, comment, ct);
var fromPhase = plan.Phase;
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
// hết Bước → FINALIZE.
if (currentLevelOrder < maxLevelOrder)
{
plan.CurrentApprovalLevelOrder = currentLevelOrder + 1;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — chuyển Cấp {currentLevelOrder + 1}",
ct);
await NotifyPendingApproversAsync(
plan, LevelsOf(currentStep, currentLevelOrder + 1), actorId, ct);
return;
}
var nextIdx = currentIdx + 1;
if (nextIdx < steps.Count)
{
plan.CurrentWorkflowStepIndex = nextIdx;
plan.CurrentApprovalLevelOrder = 1; // 🔴 Bước mới ⇒ Cấp RESET về 1
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count} — chuyển Bước {nextIdx + 1} (Cấp 1)",
ct);
await NotifyPendingApproversAsync(plan, LevelsOf(steps[nextIdx], 1), actorId, ct);
return;
}
// ---- Cấp cuối của Bước cuối ⇒ terminal.
ApplyApprovedValuesOnFinalize(plan);
plan.Phase = ContractSigningPlanPhase.DaDuyet;
plan.CurrentWorkflowStepIndex = null; // terminal ⇒ không nằm trong hộp thư ai nữa
plan.CurrentApprovalLevelOrder = null;
await LogSigningPlanTransitionAsync(
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
actingLevel.Id,
$"Duyệt Cấp cuối (Bước {currentIdx + 1}/{steps.Count}) — kế hoạch ký kết ĐÃ DUYỆT, chốt giá",
ct);
}
/// 🔴 LUẬT (không đếm số — số-đếm tự lão-hoá, bài `PurchaseEvaluationWorkflowService.cs:1008`):
/// MỌI nhánh đưa phiếu sang `DaDuyet` BẮT BUỘC gọi helper này TRƯỚC khi gán Phase.
/// Bỏ sót một nhánh = phiếu "Đã duyệt" mà `ApprovedAmount` rỗng ⇒ GĐ3 sinh hợp đồng 0đ.
/// Tự kiểm 2 lệnh (chạy cả hai, cùng phải khớp nhau):
/// grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
/// grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
///
/// Vế "CHỈ dòng của phiếu này" (acceptance `AND PlanId = @id`) do QUAN HỆ bảo đảm:
/// `plan.Lines` nạp bằng `Include` trên đúng một phiếu, nên không có đường nào chạm dòng
/// của phiếu hàng xóm — cố ý KHÔNG viết `db.ContractSigningPlanLines.Where(...)` để không
/// tồn tại chỗ cho phép quên vế lọc.
///
/// `??=` — GIỮ giá trị đã chốt, KHÔNG đè: số do người có thẩm quyền sửa tay phải thắng.
private static void ApplyApprovedValuesOnFinalize(ContractSigningPlan plan)
{
foreach (var line in plan.Lines)
line.ApprovedAmount ??= line.ProposedAmount;
}
// =====================================================================
// return / reject — trả lại người soạn hoặc từ chối hẳn
// =====================================================================
private async Task ReturnOrRejectAsync(
ContractSigningPlan plan,
Guid actorId,
bool isAdmin,
string? comment,
ContractSigningPlanPhase targetPhase,
CancellationToken ct)
{
var isReturn = targetPhase == ContractSigningPlanPhase.TraLai;
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
throw new ConflictException(isReturn
? "Chỉ trả lại được phiếu đang ở trạng thái Đã gửi duyệt."
: "Chỉ từ chối được phiếu đang ở trạng thái Đã gửi duyệt.");
if (plan.ApprovalWorkflowId is not Guid awId)
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
var steps = await LoadStepsAsync(awId, ct);
var (currentIdx, currentStep, currentLevelOrder, levelGroups, _) = ResolvePointer(plan, steps);
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
// Cùng vế quyền với `approve`: chỉ NV của Cấp ĐANG CHỜ (OR-of-N) hoặc Admin.
// Rào bằng CHÍNH hàm của approve ⇒ không thể lệch nhau về sau.
var actingLevel = ResolveActingLevel(
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
// 🔴 KHÔNG UPSERT LevelOpinion ở đây: bảng ý kiến là chữ ký DUYỆT
// (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync").
// Lý do trả lại đã nằm ở Approvals + Changelogs ngay dưới.
var fromPhase = plan.Phase;
plan.Phase = targetPhase;
// Trả lại TOÀN PHẦN / từ chối: xoá con-trỏ. Để sót thì phiếu vừa nằm ở tay người soạn
// vừa nằm trong hộp thư người duyệt (`ResolveV2InboxIdsAsync` lọc theo con-trỏ).
plan.CurrentWorkflowStepIndex = null;
plan.CurrentApprovalLevelOrder = null;
await LogSigningPlanTransitionAsync(
plan, fromPhase, targetPhase, actorId, ApprovalDecision.Reject, comment,
actingLevel.Id,
isReturn
? $"Trả lại người soạn tại Bước {currentIdx + 1} Cấp {currentLevelOrder}"
: $"Từ chối tại Bước {currentIdx + 1} Cấp {currentLevelOrder}",
ct);
}
// =====================================================================
// Helpers dùng chung
// =====================================================================
/// Nạp cây quy trình đã SORT sẵn (Bước theo Order, Cấp theo Order) — mọi nhánh đọc CÙNG
/// một thứ tự, nên INDEX con-trỏ luôn hiểu như nhau (khuôn `ContractWorkflowService.cs:228-236`).
private async Task<List<ApprovalWorkflowStep>> LoadStepsAsync(Guid awId, CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException($"Quy trình duyệt {awId} không tồn tại.");
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
if (steps.Count == 0)
throw new ConflictException("Quy trình duyệt chưa có Bước nào.");
return steps;
}
/// Đọc con-trỏ ĐÔI + kiểm biên. 🔴 `CurrentWorkflowStepIndex` là INDEX vào list đã sort,
/// KHÔNG phải `Step.Order` — đọc nhầm thì phiếu nhảy sang Bước của người khác trong im lặng
/// (W2 đã chốt semantic INDEX ở `ContractSigningPlanFeatures.cs:256-260`).
private static (int Idx,
ApprovalWorkflowStep Step,
int LevelOrder,
List<IGrouping<int, ApprovalWorkflowLevel>> LevelGroups,
int MaxLevelOrder)
ResolvePointer(ContractSigningPlan plan, List<ApprovalWorkflowStep> steps)
{
var idx = plan.CurrentWorkflowStepIndex ?? 0;
if (idx < 0 || idx >= steps.Count)
throw new ConflictException(
$"CurrentWorkflowStepIndex={idx} không hợp lệ (max={steps.Count - 1}).");
var step = steps[idx];
var levelOrder = plan.CurrentApprovalLevelOrder ?? 1;
// Cấp = nhóm Level CÙNG Order (khuôn `ContractWorkflowService.cs:246`). Giữ GroupBy —
// flatten kiểu Proposal sẽ biến OR-of-N thành AND mà build vẫn sạch.
var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
if (levelOrder < 1 || levelOrder > maxLevelOrder)
throw new ConflictException(
$"CurrentApprovalLevelOrder={levelOrder} không hợp lệ (max={maxLevelOrder}).");
return (idx, step, levelOrder, levelGroups, maxLevelOrder);
}
/// OR-of-N (khuôn `ContractWorkflowService.cs:255-266`): BẤT KỲ ai trong Cấp đang chờ là
/// duyệt được — trả về ĐÚNG row Level của người ký để ý kiến treo dưới đúng tên.
/// Admin duyệt thay: miễn vế "có tên trong Cấp", ý kiến rơi vào row đầu Cấp và
/// `SignedByUserId` vẫn là Admin ⇒ FE phân biệt được (`SignedByUserId != ApproverUserId`).
private static ApprovalWorkflowLevel ResolveActingLevel(
IGrouping<int, ApprovalWorkflowLevel> pendingLevelGroup,
ApprovalWorkflowStep step,
int stepIdx,
int levelOrder,
Guid actorId,
bool isAdmin)
{
var own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId);
if (own is not null) return own;
if (isAdmin) return pendingLevelGroup.First();
throw new ForbiddenException(
$"Bước {stepIdx + 1} ({step.Name}) — Cấp {levelOrder}: bạn không có trong danh sách NV duyệt của cấp này.");
}
private static List<ApprovalWorkflowLevel> LevelsOf(ApprovalWorkflowStep step, int levelOrder)
=> step.Levels.Where(l => l.Order == levelOrder).ToList();
/// UPSERT 1 row / cặp (Plan × Level) — UNIQUE `(PlanId, LevelId)`
/// (`ContractSigningPlanLevelOpinionConfiguration.cs:36`). Duyệt lại cùng Cấp (sau khi bị
/// trả lại) = CẬP NHẬT dòng cũ; `Add` thẳng sẽ nổ UNIQUE hoặc đẻ dòng thứ hai.
private async Task UpsertLevelOpinionAsync(
ContractSigningPlan plan,
ApprovalWorkflowLevel actingLevel,
Guid actorId,
string? comment,
CancellationToken ct)
{
var text = string.IsNullOrWhiteSpace(comment) ? NoOpinionPlaceholder : comment;
var signedByFullName = await ResolveActorFullNameAsync(actorId, ct);
var now = dateTime.UtcNow;
var existing = await db.ContractSigningPlanLevelOpinions
.FirstOrDefaultAsync(o => o.ContractSigningPlanId == plan.Id
&& o.ApprovalWorkflowLevelId == actingLevel.Id, ct);
if (existing is null)
{
db.ContractSigningPlanLevelOpinions.Add(new ContractSigningPlanLevelOpinion
{
ContractSigningPlanId = plan.Id,
ApprovalWorkflowLevelId = actingLevel.Id,
Comment = text,
SignedAt = now,
SignedByUserId = actorId,
SignedByFullName = signedByFullName,
});
}
else
{
// latest-write-wins TRỌN BỘ 4 field: giữ lại chữ/người/giờ cũ = hồ sơ nói sai
// sự thật về lần ký mới.
existing.Comment = text;
existing.SignedAt = now;
existing.SignedByUserId = actorId;
existing.SignedByFullName = signedByFullName;
}
}
/// Method ghi lịch sử KHKK-RIÊNG (spec §②-4). Ghi CẢ HAI bảng trong cùng unit-of-work:
/// • `ContractSigningPlanApprovals` — 1 row / transition (ai, Cấp nào, quyết định gì)
/// • `ContractSigningPlanChangelogs` — 1 row / transition (nhật ký đọc-được cho người dùng)
/// 🔴 KHÔNG gọi `IChangelogService.LogWorkflowTransitionAsync` — xem lý do FK-547 đầu file.
/// Kèm notify NGƯỜI SOẠN (mirror `ContractWorkflowService.cs:407-427`); notify approver
/// Cấp kế nằm riêng ở `NotifyPendingApproversAsync` (vế THÊM của spec §②-6).
private async Task LogSigningPlanTransitionAsync(
ContractSigningPlan plan,
ContractSigningPlanPhase fromPhase,
ContractSigningPlanPhase toPhase,
Guid actorId,
ApprovalDecision decision,
string? comment,
Guid? actingLevelId,
string summary,
CancellationToken ct)
{
var now = dateTime.UtcNow;
db.ContractSigningPlanApprovals.Add(new ContractSigningPlanApproval
{
ContractSigningPlanId = plan.Id,
FromPhase = fromPhase,
ToPhase = toPhase,
Decision = decision,
Comment = comment,
ApprovalWorkflowLevelId = actingLevelId,
ApprovedByUserId = actorId,
ApprovedAt = now,
});
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Workflow,
EntityId = actingLevelId,
Action = ChangelogAction.Transition,
PhaseAtChange = toPhase,
UserId = actorId,
UserName = Truncate(await ResolveActorFullNameAsync(actorId, ct), 200),
Summary = Truncate(summary, 1000),
ContextNote = comment,
});
// Notify NGƯỜI SOẠN — trừ khi chính họ vừa bấm (khỏi tự báo cho mình).
if (plan.DrafterUserId != Guid.Empty && plan.DrafterUserId != actorId)
{
var label = plan.MaKeHoach ?? "Kế hoạch ký kết";
var (title, type) = toPhase switch
{
ContractSigningPlanPhase.DaDuyet =>
($"Kế hoạch ký kết {label} đã được duyệt", NotificationType.ContractPublished),
ContractSigningPlanPhase.TuChoi =>
($"Kế hoạch ký kết {label} bị từ chối", NotificationType.ContractRejected),
ContractSigningPlanPhase.TraLai =>
($"Kế hoạch ký kết {label} bị trả lại — vui lòng sửa và trình lại",
NotificationType.ContractRejected),
_ => ($"Kế hoạch ký kết {label} chuyển trạng thái", NotificationType.ContractPhaseTransition),
};
await notifications.NotifyAsync(
plan.DrafterUserId, type, title,
description: summary + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
href: $"/khkk/{plan.Id}",
refId: plan.Id,
ct: ct);
}
}
/// Vế THÊM của spec §②-6 — báo ĐÍCH DANH NV của Cấp đang chờ (khuôn đã chạy thật ở PE
/// `PurchaseEvaluationWorkflowService.cs:1243-1289`), vá lỗ "chỉ người soạn được báo".
/// Bỏ qua: chính người vừa bấm, và NGƯỜI SOẠN — họ đã thấy phiếu ở màn "phiếu của tôi"
/// và nhận chuông KẾT QUẢ ở trên (bài UAT S86).
private async Task NotifyPendingApproversAsync(
ContractSigningPlan plan,
IEnumerable<ApprovalWorkflowLevel> pendingLevels,
Guid actorId,
CancellationToken ct)
{
var approverIds = pendingLevels
.Select(l => l.ApproverUserId)
.Where(id => id != Guid.Empty && id != actorId && id != plan.DrafterUserId)
.Distinct()
.ToList();
if (approverIds.Count == 0) return;
await notifications.NotifyManyAsync(
approverIds,
NotificationType.Generic,
$"Kế hoạch ký kết cần bạn duyệt: {plan.MaKeHoach ?? "(chưa )"}",
"Có kế hoạch ký kết HĐ đang chờ bạn duyệt.",
$"/khkk/{plan.Id}",
plan.Id,
ct);
}
/// Snapshot tên người ký (`SignedByFullName` là cột NOT NULL 200 ký tự —
/// `ContractSigningPlanLevelOpinionConfiguration.cs:24`). Khuôn
/// `ContractWorkflowService.cs:433-442`; thêm vế cuối lấy từ token vì service này lấy
/// actor qua `ICurrentUser` chứ không qua `UserManager`.
private async Task<string> ResolveActorFullNameAsync(Guid actorId, CancellationToken ct)
{
var user = await db.Users.AsNoTracking()
.Where(u => u.Id == actorId)
.Select(u => new { u.FullName, u.UserName })
.FirstOrDefaultAsync(ct);
if (user is not null && !string.IsNullOrWhiteSpace(user.FullName)) return Truncate(user.FullName, 200);
if (user is not null && !string.IsNullOrWhiteSpace(user.UserName)) return Truncate(user.UserName, 200);
if (!string.IsNullOrWhiteSpace(currentUser.FullName)) return Truncate(currentUser.FullName!, 200);
if (!string.IsNullOrWhiteSpace(currentUser.Email)) return Truncate(currentUser.Email!, 200);
return "(unknown)";
}
/// Cắt AN TOÀN cho các chuỗi service TỰ DỰNG (tên Bước do admin đặt có thể dài).
/// Ý kiến của người dùng KHÔNG đi qua đây — chuỗi đó bị chặn ở cổng
/// (`MaxCommentLength`) để không bao giờ bị cắt ngầm mất chữ.
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..max];
}