From 1f199b01a5c70ac54f346a8f4fb6413df0fb9543 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 22 May 2026 12:27:46 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Infra:=20Plan=20B=20Chunk=20B2=20?= =?UTF-8?q?=E2=80=94=20UPSERT=20ContractLevelOpinion=20+=20ResolveActorFul?= =?UTF-8?q?lName=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Services/ContractWorkflowService.cs | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) 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)"); + } }