From 36cbf8b1ccf5e1ce9d2390042c6391c032baf0a9 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 1 Jul 2026 15:47:08 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20mark-read=20no?= =?UTF-8?q?ti=20"c=E1=BA=A7n=20duy=E1=BB=87t"=20c=C5=A9=20khi=20phi?= =?UTF-8?q?=E1=BA=BFu=20r=E1=BB=9Di=20c=E1=BA=A5p=20(fix=20chu=C3=B4ng=20s?= =?UTF-8?q?tale=20=E2=80=94=20anh=20Ki=E1=BB=87t=20FDC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anh Kiệt "chuông không bỏ ra được" — noti "Phiếu cần bạn duyệt" của approver (kể cả CEO) KHÔNG tự xóa khi phiếu đã xong/chuyển bởi người khác (duyệt-thay tại Cấp CEO, co-approver OR-of-N, finalize) → chuông đầy phiếu thừa. Fix: trong LogTransitionAsync (chạy MỌI transition), TRƯỚC block notify cấp mới → mark-read (ReadAt) tất cả noti Generic + RefId=phiếu còn unread. Phiếu rời cấp (finalize/advance/return/reject) → noti cũ tự sạch; approver cấp MỚI nhận noti fresh (tạo sau mark-read). Best-effort try/catch, V2-only, ExecuteUpdateAsync. KHÔNG đổi target (noti vẫn báo đúng lúc), chỉ thu-hồi khi stale. Test: +test #7 (finalize → noti CEO cũ mark-read). 435→436 PASS. BE-only, no migration. Co-Authored-By: Claude Opus 4.8 --- .../PurchaseEvaluationWorkflowService.cs | 22 +++++++++ .../Services/PeApproverFinalizeTests.cs | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index c68a5a6..0a7ae9a 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -1110,6 +1110,28 @@ public class PurchaseEvaluationWorkflowService( ct: ct); } + // [S96 2026-07-01 — anh Kiệt FDC "chuông không bỏ ra được"] Phiếu RỜI một Cấp (finalize/ + // advance/return/reject) → noti "cần bạn duyệt" CŨ (Generic + RefId=phiếu) của những approver + // chưa kịp duyệt = STALE (phiếu đã xong/chuyển, họ không cần duyệt nữa) → mark-read (ReadAt) để + // chuông tự sạch. Chạy MỌI transition (kể cả toPhase=DaDuyet/TuChoi/TraLai + duyệt-thay hoàn-tất + // hộ tại Cấp CEO). Approver Cấp MỚI nhận noti FRESH ở block dưới (tạo SAU mark-read → giữ unread). + // Best-effort — fail KHÔNG rollback transition. V2-only (có ApprovalWorkflowId). + if (evaluation.ApprovalWorkflowId is not null) + { + try + { + await db.Notifications + .Where(n => n.RefId == evaluation.Id + && n.Type == NotificationType.Generic + && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, dateTime.UtcNow), ct); + } + catch + { + // best-effort — mark-read fail KHÔNG fail transition đã lưu. + } + } + // ===== Notify approver(s) cấp hiện tại khi phiếu vào ChoDuyet ===== // Tra Sol (Zalo): approver KHÔNG nhận chuông "có hồ sơ cần duyệt" — chỉ // drafter được báo (block ↑). Bổ sung: mỗi lần phiếu ENTER hoặc ADVANCE diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs index 6b04dd8..99f3272 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs @@ -5,6 +5,7 @@ using SolutionErp.Application.Common.Exceptions; using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Infrastructure.Services; using SolutionErp.Infrastructure.Tests.Common; @@ -366,4 +367,49 @@ public class PeApproverFinalizeTests opinions.Should().ContainSingle("approver ký cấp mình rồi mới đẩy lên CEO"); } } + + // ===================================================================== + // 7. ⭐ STALE-NOTI CLEAR (S96 2026-07-01 — anh Kiệt FDC "chuông không bỏ ra được") — + // phiếu tới Cấp CEO → CEO có noti "cần bạn duyệt" (Generic + RefId=phiếu). Khi phiếu + // FINALIZE (duyệt-thay/kết-thúc hộ tại đây) → noti CŨ của CEO = stale → TỰ mark-read + // (ReadAt set) để chuông sạch. Trực-tiếp vá "noti cần-duyệt không tự bỏ khi phiếu xong". + // ===================================================================== + [Fact] + public async Task ApproveV2_Finalize_MarksStalePendingNotificationRead() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("appr7@af.test", "Approver", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo7@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, true) }, // slot cấu hình KẾT THÚC — approver duyệt-thay hộ CEO + new[] { (ceo, false) }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-007"); + db.PurchaseEvaluations.Add(pe); + // Giả-lập noti CŨ "cần bạn duyệt" của CEO cho phiếu này (RefId=phiếu, Generic, chưa đọc). + db.Notifications.Add(new Notification + { + UserId = ceo, + Type = NotificationType.Generic, + Title = "Phiếu cần bạn duyệt: PE-AF-007", + RefId = pe.Id, + ReadAt = null, + }); + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveAsync(svc, pe, approver, Array.Empty(), + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "finalize"); + var ceoNotif = await db.Notifications.AsNoTracking() + .FirstAsync(n => n.UserId == ceo && n.RefId == pe.Id); + ceoNotif.ReadAt.Should().NotBeNull( + "phiếu finalize → noti 'cần bạn duyệt' cũ của CEO tự mark-read, chuông sạch (không còn phiếu thừa)"); + } + } }