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