diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 1445025..272a462 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -142,6 +142,27 @@ public record PurchaseEvaluationDepartmentOpinionDto( Guid? UserId, string? UserName); +// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel. +// FE Section 5 render dynamic: forEach Step → forEach Level → 1 OpinionBox. +// Service ApproveV2Async UPSERT tự động khi NV duyệt (Q1=1B). Comment empty +// fallback "(duyệt — không ý kiến)". `SignedByUserId !== ApproverUserId` → +// FE show banner "Admin duyệt thay ". +public record PurchaseEvaluationLevelOpinionDto( + Guid Id, + Guid ApprovalWorkflowLevelId, + int StepOrder, + string StepName, + Guid? StepDepartmentId, + string? StepDepartmentName, + int LevelOrder, + string? LevelName, + Guid ApproverUserId, + string? ApproverFullName, + string Comment, + DateTime SignedAt, + Guid SignedByUserId, + string SignedByFullName); + public record PurchaseEvaluationDetailBundleDto( Guid Id, string? MaPhieu, @@ -180,4 +201,7 @@ public record PurchaseEvaluationDetailBundleDto( List Approvals, List Attachments, List DepartmentOpinions, + // Mig 26 (Session 19) — Section 5 V2 dynamic. Empty list cho phiếu V1 + // legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message. + List LevelOpinions, PurchaseEvaluationWorkflowSummaryDto Workflow); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index ababf43..5381ef3 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -447,6 +447,7 @@ public class GetPurchaseEvaluationQueryHandler( .Include(x => x.Approvals) .Include(x => x.Attachments) .Include(x => x.DepartmentOpinions) + .Include(x => x.LevelOpinions) // Mig 26 — Section 5 V2 dynamic .FirstOrDefaultAsync(x => x.Id == request.Id, ct) ?? throw new NotFoundException("PurchaseEvaluation", request.Id); @@ -687,12 +688,73 @@ public class GetPurchaseEvaluationQueryHandler( o.Id, o.Kind, KindLabel(o.Kind), o.Opinion, o.SignedAt, o.UserId, o.UserName)) .ToList(), + await BuildLevelOpinionsAsync(e, ct), new PurchaseEvaluationWorkflowSummaryDto( policy.Name, policy.Description, policy.ActivePhases.ToList(), policy.NextPhasesFrom(e.Phase).ToList())); } + // Mig 26 (Session 19) — Build LevelOpinionDto[] cho Section 5 dynamic. + // Phiếu V1 (no ApprovalWorkflowId) hoặc chưa có cấp duyệt nào → empty list, + // FE hiển thị fallback message. JOIN Steps/Levels lấy meta (StepOrder, name, + // DepartmentName, ApproverFullName) — denorm vào DTO để FE render trực tiếp. + private async Task> BuildLevelOpinionsAsync( + PurchaseEvaluation e, CancellationToken ct) + { + var result = new List(); + if (e.LevelOpinions.Count == 0 || e.ApprovalWorkflowId is not Guid wfV2Id) + return result; + + var aw = await db.ApprovalWorkflows.AsNoTracking() + .Include(w => w.Steps).ThenInclude(s => s.Levels) + .FirstOrDefaultAsync(w => w.Id == wfV2Id, ct); + if (aw is null) return result; + + var levelMap = aw.Steps + .SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l })) + .ToDictionary(x => x.Level.Id); + + var deptIds = aw.Steps.Where(s => s.DepartmentId.HasValue) + .Select(s => s.DepartmentId!.Value).Distinct().ToList(); + var depts = await db.Departments.AsNoTracking() + .Where(d => deptIds.Contains(d.Id)) + .ToDictionaryAsync(d => d.Id, d => d.Name, ct); + + var userIds = new HashSet( + aw.Steps.SelectMany(s => s.Levels.Select(l => l.ApproverUserId))); + // SignedByUserId có thể KHÁC ApproverUserId (Admin override) — load thêm. + foreach (var op in e.LevelOpinions) userIds.Add(op.SignedByUserId); + var users = await userManager.Users.AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, ct); + + foreach (var op in e.LevelOpinions.OrderBy(o => o.SignedAt)) + { + if (!levelMap.TryGetValue(op.ApprovalWorkflowLevelId, out var meta)) continue; + string? deptName = meta.Step.DepartmentId.HasValue + && depts.TryGetValue(meta.Step.DepartmentId.Value, out var dn) + ? dn : null; + string? approverFullName = users.TryGetValue(meta.Level.ApproverUserId, out var an) ? an : null; + result.Add(new PurchaseEvaluationLevelOpinionDto( + op.Id, + op.ApprovalWorkflowLevelId, + meta.Step.Order, + meta.Step.Name, + meta.Step.DepartmentId, + deptName, + meta.Level.Order, + meta.Level.Name, + meta.Level.ApproverUserId, + approverFullName, + op.Comment ?? "", + op.SignedAt, + op.SignedByUserId, + op.SignedByFullName)); + } + return result; + } + private static string KindLabel(PeDepartmentKind k) => k switch { PeDepartmentKind.PheDuyet => "Phê duyệt", diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 57b3a0b..6ba9a5c 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -187,6 +187,42 @@ public class PurchaseEvaluationWorkflowService( ApprovedAt = dateTime.UtcNow, }); + // Mig 26 (Session 19) — UPSERT opinion vào row Level chính chủ. Section 5 + // FE render dynamic theo flow.steps[].levels[]. Q1=1B chốt: comment khi + // duyệt auto sync sang Section 5 (read-only summary). Empty comment → + // "(duyệt — không ý kiến)" placeholder Q4 bonus. + // Multi-NV cùng Cấp (OR-of-N): match level theo ApproverUserId. Admin + // override → fallback first level group; FE detect SignedByUserId !== + // Level.ApproverUserId → banner "Admin duyệt thay". + var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value) + ?? pendingLevelGroup.First(); + var actorFullName = await ResolveActorFullNameAsync(actorUserId, isSystem, ct); + var existingOpinion = await db.PurchaseEvaluationLevelOpinions + .FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id + && o.ApprovalWorkflowLevelId == matchingLevel.Id, ct); + var normalizedComment = string.IsNullOrWhiteSpace(comment) + ? "(duyệt — không ý kiến)" + : comment.Trim(); + if (existingOpinion is null) + { + db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion + { + PurchaseEvaluationId = evaluation.Id, + ApprovalWorkflowLevelId = matchingLevel.Id, + Comment = normalizedComment, + SignedAt = dateTime.UtcNow, + SignedByUserId = actorUserId ?? Guid.Empty, + SignedByFullName = actorFullName, + }); + } + else + { + existingOpinion.Comment = normalizedComment; + existingOpinion.SignedAt = dateTime.UtcNow; + existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty; + existingOpinion.SignedByFullName = actorFullName; + } + // Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1 if (currentLevelOrder < maxLevelOrder) { @@ -353,4 +389,18 @@ public class PurchaseEvaluationWorkflowService( ct: ct); } } + + // Mig 26 (Session 19) — helper resolve FullName cho denorm `SignedByFullName`. + // System auto-approve (actorUserId null + isSystem) → "(System)". User không + // tồn tại / xóa → fallback UserName / "(unknown)". + private async Task ResolveActorFullNameAsync(Guid? actorUserId, bool isSystem, CancellationToken ct) + { + if (isSystem || actorUserId is null) return "(System)"; + var user = await db.Users.AsNoTracking() + .Where(u => u.Id == actorUserId.Value) + .Select(u => new { u.FullName, u.UserName }) + .FirstOrDefaultAsync(ct); + if (user is null) return "(unknown)"; + return !string.IsNullOrWhiteSpace(user.FullName) ? user.FullName : (user.UserName ?? "(unknown)"); + } }