[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

@ -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<string> 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)");
}
}