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)"); + } + } }