[CLAUDE] App: Plan B Chunk E2 — ContractDetailDto +ApprovalWorkflowId + LevelOpinions[] populate

Mirror PE PeDetailBundle pattern. Expose V2 workflow state cho FE Section 5
Chunk E3 (Implementer pending) render dynamic LevelOpinionsSectionV2.

Changes:
- ContractDtos.cs:
  - ContractDetailDto +3 fields (default null backward compat):
    - Guid? ApprovalWorkflowId (V2 pin)
    - int? CurrentApprovalLevelOrder
    - List<ContractLevelOpinionDto>? LevelOpinions
  - NEW record ContractLevelOpinionDto (mirror PE 12 fields)
- ContractFeatures.cs GetContractQueryHandler:
  - Load LevelOpinions via 3-step JOIN (ContractLevelOpinions + ApprovalWorkflowLevels.Include(Step) + Users)
  - Map to ContractLevelOpinionDto với StepOrder/Name + LevelOrder/Name + Approver/SignedBy resolve
  - OrderBy StepOrder + LevelOrder
  - Null fallback Comment "" (CS8604 silence)
  - Empty list khi V2 pin nhưng KHÔNG có opinion (workflow start lúc Drafter trình)
  - Skip load nếu V1 (ApprovalWorkflowId null) → null marker FE detect

FE Chunk E3 sẽ:
- Detect V2 mode qua `bundle.approvalWorkflowId != null`
- Fetch ApprovalFlow shape via existing /api/approval-workflows-v2/{ApprovalWorkflowId}
- Render Section 5 dynamic forEach Step → forEach Level → 1 OpinionBox với opinion data from LevelOpinions[]

Verify:
- dotnet build PASS 0 err, 0 warn (clean)
- dotnet test 111/111 PASS — 0 regression
- V1 legacy contract Detail unchanged (ApprovalWorkflowId=null + LevelOpinions=null)

Plan B chain status (8/9 chunks done):
- A1 58898e8  Entity
- A2 a85e437  Mig 32 + Seed
- B 138469d  Service ApproveV2 branch
- C 26c98d3  Mig 33 LevelOpinions
- B2 1f199b0  UPSERT block
- E1 ef23308  CreateContractCommand +V2
- D 62b50d1  FE Workspace V2
- E2 (this)  ContractDetailDto +V2 + LevelOpinions populate
- E3 FE Section 5 LevelOpinionsV2 (Implementer next)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-22 12:38:38 +07:00
parent 62b50d112b
commit 48f6d22b3d
2 changed files with 81 additions and 2 deletions

View File

@ -477,6 +477,54 @@ public class GetContractQueryHandler(
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides); workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
} }
// [Plan B S29 2026-05-22 Chunk E2] Load V2 LevelOpinions nếu pin ApprovalWorkflowId.
// JOIN ApprovalWorkflowLevel + Step + Approver User để build dynamic DTO
// cho FE Section 5 render dynamic theo flow shape. V1 legacy → empty list.
List<ContractLevelOpinionDto>? levelOpinionsDto = null;
if (c.ApprovalWorkflowId is Guid awIdLoad)
{
var opinions = await db.ContractLevelOpinions.AsNoTracking()
.Where(o => o.ContractId == c.Id)
.ToListAsync(ct);
if (opinions.Count > 0)
{
var levelIds = opinions.Select(o => o.ApprovalWorkflowLevelId).ToHashSet();
var levels = await db.ApprovalWorkflowLevels.AsNoTracking()
.Include(l => l.Step)
.Where(l => levelIds.Contains(l.Id))
.ToListAsync(ct);
var approverUserIds = levels.Select(l => l.ApproverUserId).ToHashSet();
var approverNames = await userManager.Users.AsNoTracking()
.Where(u => approverUserIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
levelOpinionsDto = opinions
.Select(o =>
{
var level = levels.FirstOrDefault(l => l.Id == o.ApprovalWorkflowLevelId);
var step = level?.Step;
return new ContractLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
StepOrder: step?.Order ?? 0,
StepName: step?.Name ?? "",
LevelOrder: level?.Order ?? 0,
LevelName: level?.Name,
ApproverUserId: level?.ApproverUserId ?? Guid.Empty,
ApproverFullName: level != null && approverNames.TryGetValue(level.ApproverUserId, out var afn) ? afn : null,
o.Comment ?? "",
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(d => d.StepOrder).ThenBy(d => d.LevelOrder)
.ToList();
}
else
{
levelOpinionsDto = new List<ContractLevelOpinionDto>();
}
}
// Resolve user names // Resolve user names
var userIds = new HashSet<Guid>(); var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did); if (c.DrafterUserId is Guid did) userIds.Add(did);
@ -516,7 +564,11 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize, att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt)) att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(), .ToList(),
BuildWorkflowSummary(c, workflowPolicy)); BuildWorkflowSummary(c, workflowPolicy),
// [Plan B S29 2026-05-22 Chunk E2] V2 fields
ApprovalWorkflowId: c.ApprovalWorkflowId,
CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder,
LevelOpinions: levelOpinionsDto);
} }
// FE uses this to render next-phase buttons dynamically — no more hardcoded // FE uses this to render next-phase buttons dynamically — no more hardcoded

View File

@ -46,7 +46,34 @@ public record ContractDetailDto(
List<ContractApprovalDto> Approvals, List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments, List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments, List<ContractAttachmentDto> Attachments,
WorkflowSummaryDto Workflow); WorkflowSummaryDto Workflow,
// [Plan B S29 2026-05-22 Chunk E2] V2 workflow fields — mirror PE pattern.
// ApprovalWorkflowId pin lúc create (Mig 32). FE Section 5 detect V2 mode
// qua field này: nếu Guid → render dynamic LevelOpinionsSectionV2;
// nếu null → V1 legacy KHÔNG Section 5 V2.
// FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}.
Guid? ApprovalWorkflowId = null,
int? CurrentApprovalLevelOrder = null,
List<ContractLevelOpinionDto>? LevelOpinions = null);
// [Plan B S29 2026-05-22 Chunk E2] Ý kiến cấp duyệt V2 dynamic theo
// ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern.
// Service ApproveV2Async UPSERT (Plan B Chunk B2 1f199b0). Comment empty
// fallback "(duyệt — không ý kiến)". SignedByUserId !== Level.ApproverUserId
// → FE show banner "Admin duyệt thay".
public record ContractLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int StepOrder,
string StepName,
int LevelOrder,
string? LevelName,
Guid ApproverUserId,
string? ApproverFullName,
string Comment,
DateTime SignedAt,
Guid SignedByUserId,
string? SignedByFullName);
// Policy snapshot for the FE — lets UI render next-phase buttons dynamically // Policy snapshot for the FE — lets UI render next-phase buttons dynamically
// without hardcoding the transition map (single source of truth in BE). // without hardcoding the transition map (single source of truth in BE).