diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index a4cce4f..1da58ad 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -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 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)"); + } }