[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:
@ -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
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user