[CLAUDE] Infra: Plan B Chunk B2 — UPSERT ContractLevelOpinion + ResolveActorFullName helper

Replace TODO marker trong Chunk B 138469d (line 257-262) bằng UPSERT block
mirror PE Mig 26 line 512-546.

Changes:
- ApproveV2Async: move matchingLevel computation UP (trước UPSERT block)
- +UPSERT ContractLevelOpinion ~25 LOC:
  - Match level theo ApproverUserId (OR-of-N) + fallback first (admin override)
  - Empty comment → "(duyệt — không ý kiến)" placeholder
  - Insert mới hoặc update existing (UPSERT semantic)
  - SignedByUserId + SignedByFullName denormalized cho Section 5 FE
- skipToFinal block reuse matchingLevel (KHÔNG re-compute)
- +ResolveActorFullNameAsync helper (mirror PE line 774-783)

Section 5 FE (Chunk E) sẽ render dynamic theo flow.steps[].levels[] với
opinion data từ table này. Admin override → FE detect SignedByUserId !==
Level.ApproverUserId → banner "Admin duyệt thay".

Verify:
- dotnet build SolutionErp.slnx PASS 0 err, 2 pre-existing DocxRenderer warn
- dotnet test 111/111 PASS — 0 regression
- V1 legacy path UNCHANGED (7 prod contract giữ behavior)

Plan B chain status:
- A1 58898e8  Entity +2 fields
- A2 a85e437  Mig 32 + Config + Seed
- B 138469d  Service ApproveV2Async branch (UPSERT TODO)
- C 26c98d3  Mig 33 ContractLevelOpinions
- B2 (this)  UPSERT block (resolve TODO Chunk B)
- D FE Workspace V2 (Implementer, next)
- E FE Section 5 V2 (Implementer, pending)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-22 12:27:46 +07:00
parent 26c98d3c11
commit 1f199b01a5

View File

@ -279,18 +279,46 @@ public class ContractWorkflowService(
ApprovedAt = dateTime.UtcNow,
});
// TODO Plan B Chunk C: UPSERT ContractLevelOpinion vào row Level chính
// chủ (mirror PE Mig 26 / line 512-546). Bảng + entity chưa có (Chunk C
// Implementer sẽ scaffold Mig 33). Block UPSERT add ở đây sau Chunk C
// done. Hiện tại Section 5 FE sẽ KHÔNG hiển thị opinion comment per
// Level — sẽ wire khi Chunk E (FE Section 5 LevelOpinionsV2).
// [Plan B Chunk B2 S29 2026-05-22] UPSERT ContractLevelOpinion vào row
// Level chính chủ (mirror PE Mig 26 line 512-546). Section 5 FE render
// dynamic theo flow.steps[].levels[]. Comment khi duyệt auto sync sang
// Section 5 (read-only summary). Empty comment → "(duyệt — không ý kiến)"
// placeholder. 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.ContractLevelOpinions
.FirstOrDefaultAsync(o => o.ContractId == contract.Id
&& o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
var normalizedComment = string.IsNullOrWhiteSpace(comment)
? "(duyệt — không ý kiến)"
: comment.Trim();
if (existingOpinion is null)
{
db.ContractLevelOpinions.Add(new ContractLevelOpinion
{
ContractId = contract.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;
}
// skipToFinal F2 (Mig 31 Plan K S23) — Approver scope ChoDuyet skip
// thẳng Cấp cuối. Admin opt-in per slot tại AllowApproverSkipToFinal.
if (skipToFinal)
{
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
?? pendingLevelGroup.First();
if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
{
throw new ConflictException(
@ -398,4 +426,18 @@ public class ContractWorkflowService(
ct: ct);
}
}
// [Plan B Chunk B2 S29 2026-05-22] Resolve actor full name for
// ContractLevelOpinion.SignedByFullName denormalized field (Section 5 FE
// display). Mirror PE PurchaseEvaluationWorkflowService.cs:774-783.
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)");
}
}