[CLAUDE] PurchaseEvaluation: Chunk B Service V2 hook UPSERT opinion + DTO + GET include

Service `ApproveV2Async` sau khi log approval (Decision=Approve) → UPSERT
row `PurchaseEvaluationLevelOpinions` cho Cấp hiện tại (auto sync ý kiến
từ comment khi duyệt). Reject KHÔNG sync.

Match level theo ApproverUserId của actor (multi-NV cùng Cấp OR-of-N).
Admin override (actor.Id KHÔNG match) → fallback first level — FE detect
SignedByUserId !== Level.ApproverUserId hiển thị "Admin duyệt thay".

Empty/whitespace comment → "(duyệt — không ý kiến)" placeholder (Q4 bonus).

Helper `ResolveActorFullNameAsync(actorUserId, isSystem, ct)` lookup
denorm SignedByFullName từ Users (fallback "(System)" / "(unknown)").

DTO `PurchaseEvaluationLevelOpinionDto` (15 fields):
- StepOrder/StepName/StepDepartmentId/StepDepartmentName (Bước Phòng)
- LevelOrder/LevelName/ApproverUserId/ApproverFullName (Cấp NV)
- Comment/SignedAt/SignedByUserId/SignedByFullName (sign-off)

GetPurchaseEvaluationQueryHandler:
- Include LevelOpinions
- helper BuildLevelOpinionsAsync JOIN ApprovalWorkflows.Steps.Levels +
  Departments + Users → denorm DTO. Empty list cho phiếu V1 / V2 chưa
  có cấp nào duyệt → FE fallback message.

Verify: dotnet build pass + dotnet test 81 pass (no regression).

Chunk C kế tiếp: FE Section 5 dynamic mirror 2 app.
This commit is contained in:
pqhuy1987
2026-05-09 11:00:01 +07:00
parent 77a30584fc
commit 90baa8e73c
3 changed files with 136 additions and 0 deletions

View File

@ -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 <ApproverFullName>".
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<PurchaseEvaluationApprovalDto> Approvals,
List<PurchaseEvaluationAttachmentDto> Attachments,
List<PurchaseEvaluationDepartmentOpinionDto> 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<PurchaseEvaluationLevelOpinionDto> LevelOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow);

View File

@ -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<List<PurchaseEvaluationLevelOpinionDto>> BuildLevelOpinionsAsync(
PurchaseEvaluation e, CancellationToken ct)
{
var result = new List<PurchaseEvaluationLevelOpinionDto>();
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<Guid>(
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",