[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
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:
@ -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>();
|
||||
|
||||
@ -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ó mã)"}",
|
||||
"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];
|
||||
}
|
||||
Reference in New Issue
Block a user