[CLAUDE] PurchaseEvaluation: mark-read noti "cần duyệt" cũ khi phiếu rời cấp (fix chuông stale — anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m3s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-01 15:47:08 +07:00
parent e657faa74a
commit 36cbf8b1cc
2 changed files with 68 additions and 0 deletions

View File

@ -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

View File

@ -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<string>())).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<string>(),
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)");
}
}
}