SA-1380 — FX Outstanding Notification: Implementation Plan
งานเรียงตาม dependency พร้อม acceptance criteria และ verification command ต่อ task, test charter ครบทุกระดับ, gate ที่ห้ามข้าม, Owner decision ที่ยังเปิดอยู่ และสถานะ implement จริง
อัปเดต: 2026-08-06
SA-1380 — FX Outstanding Notification: Implementation Plan
อ่าน FX_OUTSTANDING_NOTIFICATION_ARCHITECTURE.md ให้จบก่อน — เอกสารนี้ไม่อธิบายซ้ำว่า “ทำไม” อธิบายแค่ “ทำอะไร ตามลำดับไหน และรู้ได้ยังไงว่าถูก”
สถานะ ณ 06/08/2026 — แผนนี้ถูก implement ไปแล้ว
| repo | branch | commit | ผล |
|---|---|---|---|
Backend_NotificationService | feature/sprint-08/SA-1380-fx-inapp | 2dba35d | consumer + dispatcher + message contract + DI 2 จุด + 35 test · build 0 error · 934/935 ผ่าน (1 skip เดิม) |
Backend_FxOrchestratorService | feature/sprint-08/SA-1380-fx-inapp-publisher | 0e4ae57 | publisher ใหม่ + scheduler rewire + DI guard + ลบ publisher เดิม + 14 test · 713/713 ผ่าน |
Backend_Iac | feature/sprint-08/SA-1380-fx-inapp-topic | b535159 | FxInAppTopicName ครบ dev/sit/uat + 2 endpoint key ที่ feature นี้ต้องใช้ |
Backend_UserService | — | — | ไม่มีงาน (endpoint merge แล้ว) |
ยังไม่ได้ทำ (ติด gate): G-1 near-expiry endpoint ปลายทาง · G-2 provision topic จริงที่ ASB · E2E §5.4 — ทั้งหมดต้องรอของนอก 3 repo
สิ่งที่เปลี่ยนจากแผนตอนเขียนครั้งแรก หลังตรวจโค้ดจริง:
- guard ของ topic key ย้ายจาก constructor ไป DI registration — scheduler resolve publisher ข้างใน tick แล้ว catch-and-log ดังนั้น throw ที่ ctor จะกลายเป็น retry loop เงียบ ๆ แทนที่จะเป็น startup failure · house pattern ของ
FxEmailPublisherก็ guard ที่ DI - IaC ต้องครบ dev/sit/uat ไม่ใช่แค่ dev/sit — เหตุผลใน architecture §8 (guard ไม่เคยรันให้ sit/uat เลย)
- C-07 (cross-repo topic check) ยังไม่ได้เขียน —
TopicNameเป็นprivate constเข้าไม่ถึงจาก test project และค่าอีกฝั่งอยู่คนละ repo · ยังเป็นหนี้เปิดอยู่ ต้องเลือกระหว่างinternal const+InternalsVisibleToหรือ CI script ที่ diff 2 ไฟล์
0. กฎที่ใช้ตลอดแผนนี้
0.1 🛑 STOP rule
ถ้าเจอ gate ที่ยังไม่เขียว หรือเจอว่าโค้ดจริงไม่ตรงกับที่แผนนี้เขียน — หยุดแล้วรายงาน ห้ามเลือกทางที่ compile ผ่าน
แผนนี้เขียนจากโค้ดที่อ่าน ณ 06/08/2026 ถ้า repo ขยับไปแล้ว ข้อเท็จจริงในแผนอาจไม่จริง การเดาต่อทำให้ pull request รีวิวไม่ได้
0.2 🔴 ต้อง git fetch ก่อนเริ่มทุก repo
ตอนวางแผนพบว่า local checkout ของทั้ง NotificationService และ UserService ค้างเก่ากว่า origin/development และข้อเท็จจริงสำคัญที่สุด 2 ข้อของงานนี้ (endpoint cust-codes และคอลัมน์ AppCode) มองไม่เห็นจาก working tree เลย
for r in Backend_NotificationService Backend_UserService Backend_FxOrchestratorService; do
git -C "C:/Source/AzureDevOps_SuperAPP/$r" fetch origin
echo "$r: HEAD=$(git -C "C:/Source/AzureDevOps_SuperAPP/$r" rev-parse --short HEAD) behind=$(git -C "C:/Source/AzureDevOps_SuperAPP/$r" rev-list --count HEAD..origin/development)"
done
0.3 ⚠️ NotificationService และ UserService มี .worktrees/
ทุก grep/glob ต้อง exclude .worktrees/ ไม่งั้นจะเจอไฟล์เดียวกัน 6-8 เวอร์ชันแล้วแก้ผิดตัว
0.4 build/test ต้องมี FEED_TOKEN
export FEED_TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
0.5 ห้ามมี migration ในงานนี้
ถ้ารู้สึกว่าต้องเพิ่มคอลัมน์/ตาราง = ออกนอกแนวทางที่ตกลงไว้ ให้หยุดแล้วถาม ไม่ใช่ไปเปิด branch MIGRATION (ถ้าสุดท้ายจำเป็นจริง ต้อง author บน branch MIGRATION ตามกฎบ้าน แล้ว merge กลับเข้า feature — ห้าม author บน feature branch)
1. สรุปงานต่อ repo
| repo | ต้องทำอะไร | ขนาด |
|---|---|---|
| Backend_NotificationService | consumer + dispatcher + message contract + DI + tests | 🔵 งานหลัก |
| Backend_FxOrchestratorService | publisher ตัวใหม่ + rewire scheduler ให้ส่ง data แทนข้อความ + config + tests | 🔵 งานหลัก |
| Backend_UserService | ไม่มีงาน — endpoint cust-codes merge เข้า development แล้วพร้อม test | ⚪️ ไม่แตะ |
| Backend_Iac | provision topic + subscription + config parity | 🟡 นอก scope 3 repo — เป็น gate |
| Backend_ThirdPartyFXService / FX_400 | near-expiry endpoint | 🟡 นอก scope — เป็น gate |
2. ตารางงาน (เรียงตาม dependency)
| id | งาน | repo | blocked by | ผลลัพธ์ที่ตรวจได้ |
|---|---|---|---|---|
| G-1 | ยืนยัน near-expiry endpoint ปลายทางใช้งานได้จริง | — | — | ยิงจริงแล้วได้ 200 + data[]: curl -sS -X POST "$FXTP_BASE/api/thirdpartyfx-service/v1/contracts/outstanding/near-expiry" -H 'Content-Type: application/json' -d '{"custCode":["0107537002559"],"expireDate":7,"currency":null}' โดย $FXTP_BASE = ค่า FxThirdPartyApi:BaseUrl ของ env นั้น (บน cluster เป็น http://thirdpartyfx-service.superappdev.svc.cluster.local:5001 → ต้อง kubectl exec/port-forward ไม่ใช่ยิงจาก workstation) |
| G-2 | เคาะชื่อ topic แล้ว provision topic + subscription ทุก env | IaC | G-1 | topic มีอยู่จริง เห็นใน portal/inventory |
| G-3 | 🔴 เติม ServiceBus:FxInAppTopicName ที่ IaC ครบทั้ง dev / sit / uat — ไม่ใช่แค่ dev · เหตุผล: guard ไม่เคยรันให้ sit/uat เลย (case mismatch Sit vs SIT) และ overlay cp แบบ full-file ทำให้ IaC เป็นแหล่งเดียว ของ env file บน cluster (หลักฐานเต็มใน architecture §8) · ถ้าลืม env ใด service จะ ไม่ขึ้น ใน env นั้น เพราะ DI guard throw ตอน startup | IaC | — | key อยู่ครบ 3 env · check-config-parity.py สำหรับ dev ไม่รายงาน FxInAppTopicName เป็น missing อีก |
| G-3b | ⚠️ parity dev ยังแดงจาก 6 key ของ holiday-sync ที่ค้างมาก่อนงานนี้ (FxContractApi.ResetHolidaySync, HolidaySync.DailyCheck.MinDaysSinceFirstOfYear, ScheduledJobs.Jobs.HolidayDailyCheckSyncJob.* 4 ตัว) — ไม่ใช่ scope SA-1380 แต่ บล็อก build ของ FxOrchestrator dev อยู่ ต้องให้เจ้าของ feature นั้นเติม | IaC | — | check-config-parity.py dev exit 0 |
| T-01 | message contract FxOutstandingNotificationMessage | NS | — | คอมไพล์ผ่าน |
| T-02 | FxInAppNotificationDispatcher + FxInAppDispatchResult + FxInAppDispatchAction | NS | T-01 | unit test ผ่าน (T-05) |
| T-03 | FxInAppEventConsumer | NS | T-01, T-02, G-2 (ชื่อ topic เป็น const ในคลาส เปลี่ยนทีหลังไม่ได้ฟรี) | unit test ผ่าน (T-06) |
| T-04 | DI register consumer ใน ทั้ง 2 branch | NS | T-03 | grep เจอ 2 ครั้ง |
| T-05 | unit tests ของ dispatcher | NS | T-02 | dotnet test เขียว |
| T-06 | unit tests ของ consumer | NS | T-03 | dotnet test เขียว |
| T-07 | message record + FxOutstandingNotificationPublisher + port ฝั่ง FX | FX | T-01 (contract ต้องตรงกัน), G-2 | คอมไพล์ผ่าน |
| T-08 | rewire scheduler + config key + DI | FX | T-07, G-3 | unit test ผ่าน (T-09) |
| T-09 | unit tests ของ publisher + scheduler | FX | T-07, T-08 | dotnet test เขียว |
| T-10 | regression suite พิสูจน์ว่า path เดิมไม่เปลี่ยน | NS | T-04 | dotnet test เขียวทั้ง suite เดิม |
| T-11 | E2E บน dev | ทั้งคู่ | ทุกข้อข้างบน + G-2 | เห็นกระดิ่งจริงในแอป |
เส้นทางวิกฤต: G-1 → G-2 → (T-01 → T-02 → T-03 → T-04) ∥ (T-07 → T-08) → T-10 → T-11 งาน NS (T-01..T-06) กับงาน FX (T-07..T-09) ทำขนานกันได้หลัง T-01 ตกลง contract แล้ว · G-3 ต้องเสร็จก่อน T-08 (T-08 เพิ่ม config key)
🔑 ชื่อ topic/subscription ในเอกสารนี้เป็น placeholder จนกว่า G-2 จะปิด — ข้อเสนอคือ
fx-inapp-event/FxInApp-Sub(เหตุผลและหลักฐานใน architecture §7) แต่ ห้าม copy ลงโค้ดก่อน G-2 ยืนยันและ provision จริง เพราะ ASB เปลี่ยนชื่อ topic กับค่า provisioning ทีหลังไม่ได้
3. งานฝั่ง NotificationService
T-01 — Message contract
ไฟล์ใหม่: src/Notification04.Domain/Messages/FxOutstandingNotificationMessage.cs
namespace Notification04.Domain.Messages;
/// <summary>
/// สัญญาข้อความจาก FxOrchestrator สำหรับ FX outstanding near-expiry (in-app).
/// ต้นทางส่ง "ข้อมูลสัญญา" มา ไม่ใช่ข้อความสำเร็จรูป — ข้อความประกอบที่ dispatcher.
/// </summary>
public sealed record FxOutstandingNotificationMessage(
int SchemaVersion,
string MessageId,
string AppCode,
string SourceService,
string NotificationType,
FxOutstandingRecipient Recipient,
FxOutstandingContract Contract,
DateTimeOffset OccurredAt);
public sealed record FxOutstandingRecipient(string UserId);
public sealed record FxOutstandingContract(
string CustCode,
string ContractNumber,
string CurrencyCode,
decimal OutstandingAmount,
DateOnly MaturityDate,
int DaysLeft);
Acceptance: record อยู่ layer 04.Domain (ไม่ใช่ Infrastructure), ไม่มี dependency กับ Azure SDK, field ตรงกับที่ฝั่ง FX ส่ง (T-07) ทุกตัว
Verify: dotnet build src/Notification01.API
T-02 — Dispatcher
ไฟล์ใหม่: src/Notification02.Infrastructure/Messaging/FxInAppNotificationDispatcher.cs — ประกาศ 3 type ในไฟล์เดียวกัน ตาม precedent (FxEmailDispatcher.cs:13-29 ทำแบบนี้): คลาส FxInAppNotificationDispatcher, record FxInAppDispatchResult(FxInAppDispatchAction Action, string? Reason, string? Description), และ enum FxInAppDispatchAction { Complete, DeadLetter }
ลอกโครงจาก 2 ที่ อย่าลอกจากที่เดียว:
| ลอกอะไร | ลอกจาก | อย่าลอกจาก |
|---|---|---|
การเขียนลง DB + dedup + sourceService | WorkflowMakerDispatcher — ตัวนี้เรียก SendPersonalAsync(..., sourceService: "Workflow.Maker", externalMessageId: …) จริง | FxEmailDispatcher (ไม่เคยแตะ IInAppNotificationService เลย เป็นสาย email ล้วน) |
| การตัดสิน Complete / DeadLetter / Abandon | FxEmailDispatcher — return DispatchResult แบบ DeadLetter สำหรับ payload ที่ retry แล้วไม่ช่วย, ปล่อย exception ขึ้นไปให้ consumer abandon สำหรับ transient | — |
เนื้อใน (โครง):
public async Task<FxInAppDispatchResult> DispatchAsync(
FxOutstandingNotificationMessage? message,
IInAppNotificationService inAppNotifications,
CancellationToken ct)
Validate(message)— ถ้าไม่ผ่าน →DeadLetter(reason)ห้าม Abandon (retry ไม่ช่วย)- ประกอบข้อความ (§3.1)
await inAppNotifications.SendPersonalAsync(userId, payload, sourceService: message.SourceService, externalMessageId: message.MessageId, ct)Complete()- แยก exception 2 ชนิด — ห้ามเหมารวม:
- unique-violation (Postgres SQLSTATE
23505) ที่ indexIX_in_app_notifications_external_message_dedup→Complete()เพราะแปลว่า ส่งไปแล้วจริง ไม่ใช่ความผิดพลาด - transient อื่น (connection, timeout, deadlock) → ปล่อย exception ขึ้นไปให้ consumer abandon เพื่อ retry
- unique-violation (Postgres SQLSTATE
ทำไมต้องแยก:
InAppNotificationService.cs:33-39เช็คซ้ำแบบ read-then-write ซึ่ง ไม่ atomic — ถ้า ASB redeliver พร้อมกัน 2 ใบ ใบที่แพ้ race จะทะลุไปชน unique index แล้วโยนDbUpdateExceptionถ้าเหมาว่าเป็น transient แล้ว abandon มันจะ retry จนครบMaxDeliveryCountแล้ว dead-letter ข้อความที่ส่งสำเร็จไปแล้ว — precedent ในบ้านทำถูกอยู่แล้ว:FxEmailEventConsumer.cs:116-126จับAneDuplicateRequestExceptionแล้วCompleteMessageAsync
Validate ต้อง reject (→ DeadLetter) อย่างน้อย:
| เงื่อนไข | reason |
|---|---|
message == null | NullMessage |
SchemaVersion != 1 | UnsupportedSchemaVersion |
MessageId ว่าง | MissingMessageId |
Recipient.UserId parse เป็น Guid ไม่ได้ | InvalidRecipient |
Contract.ContractNumber ว่าง | MissingContractNumber |
AppCode ว่าง | MissingAppCode |
SourceService ว่าง | MissingSourceService — ถ้าไม่เช็ค ค่าจะถูกเขียนลงคอลัมน์เป็น NULL เงียบ ๆ (ทางเลือก: pin เป็น const "FxOrchestrator" ใน dispatcher แทนที่จะรับจาก payload) |
NotificationType ไม่อยู่ใน NotificationTypes.All | InvalidNotificationType — ใช้ Notification04.Domain/Constants/NotificationTypes.cs:10 ที่มีอยู่แล้ว ห้าม hardcode รายการใหม่ |
⚠️
ContractNumberว่าง — ต้องตกลงให้ตรงกันข้ามฝั่ง ปัจจุบัน scheduler แทนค่าว่างด้วย"-"(FxOutstandingNotificationScheduler.cs:167) แล้วใช้ค่าที่แทนแล้วนั้นในสูตรmessageIdแผนนี้กำหนดว่า: ส่งค่าที่ normalize แล้ว ("-") ลงContract.ContractNumberบนสาย เพื่อให้messageIdกับ payload ตรงกัน ⇒ ผลคือกฎMissingContractNumberจะไม่มีทางถูก trigger จาก FxOrchestrator แต่ยังต้องมีไว้กัน publisher อื่นในอนาคต — เคส D-11 จึงเป็น unit test ระดับ dispatcher เท่านั้น ไม่ใช่เคสที่คาดว่าจะเกิดบน production
Acceptance: เป็น plain class เรียกได้โดยไม่ต้อง mock ServiceBusClient; ไม่มี try/catch ครอบการประกอบ context นอก try (บทเรียนจาก InvitationEventConsumer ที่ประกอบ contextDict นอก try แล้ว input พังทำให้ throw แทนที่จะ dead-letter)
3.1 การประกอบข้อความ
var within = contract.DaysLeft <= 0 ? "ภายในวันนี้" : $"ภายใน {contract.DaysLeft} วัน";
var title = $"สัญญาใกล้ครบอายุ {within}";
var subtitle = $"สัญญาเลขที่ {contract.ContractNumber} ใกล้ครบอายุ {within} "
+ "กรุณาดำเนินการให้เสร็จสิ้นก่อนครบอายุสัญญา";
- ❌ ห้ามคำนวณ
DaysLeftใหม่ — ใช้ค่าจาก payload ตรง ๆ (เหตุผลใน architecture §4.1) - ❌ ห้ามต่อ
IEmailTemplateKeyRepository/IEmailMacroRepository— เป็นกลไกของสาย email - 🔴 ตัดสินแล้ว: ไม่ encode — in-app
Title/Subtitleเป็น plain text ไม่ใช่ HTML และไม่มี in-app writer ตัวไหนใน NotificationService encode วันนี้ (WorkflowMakerDispatcherก็ไม่ encode) ⇒ ใช้ interpolation ตรง ๆ และ FE ต้องไม่ render ค่านี้เป็น HTML · กฎ “escape ทุกค่า” ที่เขียนไว้ใน architecture §4 ใช้กับสาย email ซึ่ง render เป็น HTML จริง ไม่ใช่กับ in-app - ข้อจำกัดความยาวจริง:
TitleมีHasMaxLength(300)แต่สูตรข้างบนใส่แค่DaysLeftลงTitleจึงยาวไม่เกิน ~40 ตัวอักษรเสมอ ·Subtitleเป็นHasColumnType("text")ไม่มีเพดาน (InAppNotificationConfiguration.cs:23-25) ⇒ ไม่ต้องเขียน logic ตัดความยาว และ ห้ามเขียน test ที่พยายามทำให้Titleล้น เพราะสร้างเงื่อนไขนั้นไม่ได้จากสูตรนี้
T-03 — Consumer
ไฟล์ใหม่: src/Notification02.Infrastructure/Messaging/FxInAppEventConsumer.cs
ลอกโครง FxEmailEventConsumer ทั้งไฟล์ แล้วเปลี่ยน:
// 🛑 ห้ามเขียนค่าเหล่านี้จนกว่า G-2 จะปิด — ค่าที่ใส่ต้องเป็นค่าที่ provision จริงที่ ASB
// และต้องตรงกับค่าที่ publisher ฝั่ง FX อ่านจาก ServiceBus:FxInAppTopicName เป๊ะ
private const string TopicName = "<ค่าจาก G-2>";
private const string SubscriptionName = "<ค่าจาก G-2>";
ต้องคง 3 พฤติกรรมนี้จากต้นแบบ:
StartProcessingAsyncห่อ try/catch ที่LogCriticalแล้วreturn— ห้าม throw (ไม่งั้นBackgroundServiceExceptionBehavior.StopHostจะทำให้ทั้ง service ตายเพราะ consumer ตัวเดียว)- deserialize ไม่ได้ →
DeadLetterMessageAsync("DeserializationFailed", …)ไม่ใช่ throw result.Action == DeadLetter→DeadLetterMessageAsync(result.Reason, result.Description)·Complete→CompleteMessageAsync· exception หลุด →AbandonMessageAsync
⚠️ dispatcher เป็นคนตัดสินเรื่อง unique-violation แล้ว (T-02 ข้อ 5) — consumer ไม่ต้องรู้จัก 23505 เอง แค่ทำตาม result.Action และ abandon เมื่อมี exception หลุดออกมาเท่านั้น
T-04 — DI
ไฟล์: src/Notification02.Infrastructure/DependencyInjection.cs
🔴 register 2 จุด — branch Mode B (Entra RBAC) และ branch Mode A (connection string) ลงข้างหลัง AddHostedService<Messaging.FxEmailEventConsumer>() ทั้งคู่:
services.AddHostedService<Messaging.FxInAppEventConsumer>();
Acceptance: grep แล้วเจอ FxInAppEventConsumer 2 ครั้ง ในไฟล์นี้
Verify:
grep -c "FxInAppEventConsumer" src/Notification02.Infrastructure/DependencyInjection.cs # ต้องได้ 2
4. งานฝั่ง FxOrchestratorService
T-07 — Publisher ตัวใหม่
ไฟล์ใหม่:
src/FxOrchestrator04.Domain/Ports/Gateway/Notification/IFxOutstandingNotificationPublisher.cs+ recordFxOutstandingNotification(mirror ของ T-01 เป๊ะ)src/FxOrchestrator02.Infrastructure/Messaging/AsbFxOutstandingNotificationPublisher.cssrc/FxOrchestrator02.Infrastructure/Messaging/NullFxOutstandingNotificationPublisher.cs
ลอกโครงจาก AsbPersonalNotificationPublisher ทั้งชุด แล้วเปลี่ยน 4 อย่าง:
- topic จาก config key ใหม่
ServiceBus:FxInAppTopicName - body เป็น
FxOutstandingNotificationMessage(โครง §4 ของ architecture) แทน{userId, eventName, payload} - คง
JavaScriptEncoder.Create(UnicodeRanges.All)ไว้ ไม่งั้น custCode/ชื่อสัญญาที่มีอักขระไทยจะกลายเป็น\uXXXX - 🔴 ตัด default fallback ทิ้ง — ต้นแบบเขียน
configuration["…"] ?? DefaultTopicNameซึ่งถ้า key หายจะเงียบ ๆ ไปยิง topic ผิด ตัวใหม่ต้อง throw เมื่อ key ไม่มี (เคส P-04)
🔴 guard ต้องอยู่ที่ DI registration ไม่ใช่ที่ constructor — scheduler resolve publisher ข้างใน
RunTickAsyncและExecuteAsyncจับ exception แล้ว log อย่างเดียว ดังนั้น throw จาก ctor จะไม่ทำให้ app ตาย แต่จะกลายเป็น retry loop เงียบ ๆ ทุก tick แบบเดียวกับ G-1 พอดี · house pattern ทำถูกอยู่แล้ว:DependencyInjection.csguardServiceBus:NotificationTopicNameตอน registerIForwardContractEmailGateway⇒ ใส่ guard ในบล็อกif (hasAzureServiceBus)ให้ fail ตอน startup แล้วเก็บ throw ที่ ctor ไว้เป็น backstop
⚠️ คง sbMessage.MessageId = notification.MessageId ไว้ — เป็นทั้ง dedup ของ ASB (ถ้าเปิด) และค่าที่ปลายทางใช้เป็น ExternalMessageId
DI: ทำตาม pattern hasAzureServiceBus เดิมใน FxOrchestrator02.Infrastructure/DependencyInjection.cs — มี ASB → ตัวจริง, ไม่มี → Null
T-08 — Rewire scheduler
ไฟล์: src/FxOrchestrator01.API/BackgroundServices/FxOutstandingNotificationScheduler.cs
| คงไว้ (ห้ามแตะ) | เปลี่ยน |
|---|---|
ทั้ง ExecuteAsync, slot/dueSlots, TryAcquireLockAsync, re-check ใต้ lock, marker, ResolveTimeZone, LogRunComplete | resolve IFxOutstandingNotificationPublisher แทน IPersonalNotificationPublisher |
การคำนวณ today/daysLeft ด้วย TZ | ลบการประกอบ title/subtitle (บรรทัดที่สร้าง withinPhrase/title/subtitle) — ย้ายไป NS แล้ว |
การ map custCode → userIds | ส่ง FxOutstandingNotification ที่พก contract data + DaysLeft แทน |
| — | แปลง MaturityDate จาก DateTimeOffset เป็น DateOnly ก่อนใส่ลง message — ต้นทางได้เป็น DateTimeOffset (NearExpiryOutstandingViewModel.cs:12) แต่ contract T-01 เป็น DateOnly · โค้ดเดิมมีบรรทัดแปลงอยู่แล้วที่ FxOutstandingNotificationScheduler.cs:163 (DateOnly.FromDateTime(contract.MaturityDate.DateTime)) ใช้ค่าเดียวกันนั้น |
| — | ส่ง Contract.ContractNumber เป็นค่าที่ normalize แล้ว ("-" เมื่อว่าง) ให้ตรงกับที่ใช้ในสูตร messageId |
try/catch รอบ publish ต่อ user (1 คนพังไม่ล้มทั้ง run) | messageId ใช้สูตรเดิมทุกตัวอักษร — เป็น dedup key ที่ปลายทางพึ่ง |
คงสูตร messageId เดิม:
fx-outstanding:{custCode}:{contractNo}:{userId:D}:{yyyyMMdd}:{HHmm}
config: เพิ่ม ServiceBus:FxInAppTopicName ใน appsettings.json + appsettings.Development.json และ Backend_Iac/config/fxorchestrator-service/{env}/appsettings.json ตามผลของ G-3
ตัดสินใจได้ 2 ทางเรื่อง
AsbPersonalNotificationPublisherเดิม: (ก) ลบทิ้งพร้อมIPersonalNotificationPublisher/NullPersonalNotificationPublisherและ test ของมัน หรือ (ข) ปล่อยไว้เป็น dead code — แนะนำ (ก) เพราะเราเป็นคนทำให้มันกำพร้า (กฎ “ลบสิ่งที่เราทำให้ไม่ได้ใช้”) และการเหลือ publisher 2 ตัวที่ยิงคนละ topic คือกับดักรอคนถัดไป
5. Test coverage
Owner กำหนดว่า test ต้องครบ — ตารางนี้คือ definition of “ครบ” ทุกแถวต้องมีอยู่จริงก่อนปิดงาน
5.1 NotificationService — tests/Notification05.Tests
stack: xunit 2.9.3 + Moq 4.20.72 + EF Core InMemory · ต้นแบบ: Infrastructure/Messaging/PersonalSignalConsumerTests.cs
Dispatcher — FxInAppNotificationDispatcherTests
| # | เคส | ชนิด | คาดหวัง |
|---|---|---|---|
| D-01 | message ครบถ้วนถูกต้อง | positive | เรียก SendPersonalAsync 1 ครั้ง ด้วย sourceService และ externalMessageId ที่ไม่ใช่ null · ผลลัพธ์ Complete |
| D-02 | AppCode ถูกส่งต่อลง payload | positive | InAppNotificationPayload.AppCode == "FX" |
| D-03 | DaysLeft = 3 | positive | Title มี "ภายใน 3 วัน" |
| D-04 | DaysLeft = 0 | boundary | Title มี "ภายในวันนี้" ไม่ใช่ "ภายใน 0 วัน" |
| D-05 | DaysLeft = -1 (สัญญาเลยกำหนด) | boundary | ไม่ crash · ใช้ถ้อยคำ "ภายในวันนี้" |
| D-06 | MaturityDate ต่างจาก DaysLeft อย่างจงใจ | negative | dispatcher ใช้ DaysLeft จาก payload ไม่คำนวณใหม่ (เคสนี้คือ regression guard ของกฎข้อสำคัญที่สุด) |
| D-07 | message == null | negative | DeadLetter("NullMessage") · ไม่เรียก SendPersonalAsync |
| D-08 | SchemaVersion = 2 | negative | DeadLetter("UnsupportedSchemaVersion") |
| D-09 | MessageId ว่าง | negative | DeadLetter("MissingMessageId") |
| D-10 | UserId ไม่ใช่ Guid | negative | DeadLetter("InvalidRecipient") |
| D-11 | ContractNumber ว่าง | negative | DeadLetter("MissingContractNumber") |
| D-12 | NotificationType = "Critical" | negative | DeadLetter("InvalidNotificationType") |
| D-13a | SendPersonalAsync โยน DbUpdateException แบบ transient (connection/timeout) | negative | exception หลุดออกมา (ไม่ถูกกลืน) เพื่อให้ consumer abandon |
| D-13b | SendPersonalAsync โยน DbUpdateException ที่มี inner PostgresException SQLSTATE 23505 | negative | Complete ไม่ใช่ให้ exception หลุด — แปลว่าส่งไปแล้วจริง กัน dead-letter ของข้อความที่สำเร็จ |
| D-14 | SourceService ว่าง | negative | DeadLetter("MissingSourceService") — ไม่ปล่อยให้เขียน NULL ลงคอลัมน์เงียบ ๆ |
| D-15 | ข้อความไทยไม่ถูก escape เป็น \uXXXX | positive | assert ตัวอักษรไทยจริงใน Title |
| D-16 | ContractNumber เป็น "-" (ค่าที่ normalize มาจากต้นทาง) | boundary | ผ่าน validate ปกติ · Subtitle มี "สัญญาเลขที่ -" — ยืนยันว่า contract ข้ามฝั่งตรงกันตามที่ตกลงใน T-02 |
Consumer — FxInAppEventConsumerTests
| # | เคส | ชนิด | คาดหวัง |
|---|---|---|---|
| C-01 | body JSON ถูกต้อง | positive | เรียก dispatcher 1 ครั้ง แล้ว CompleteMessageAsync |
| C-02 | body ไม่ใช่ JSON | negative | DeadLetterMessageAsync("DeserializationFailed", …) · ไม่ throw |
| C-03 | body เป็น null literal | negative | DeadLetterMessageAsync("NullMessage") |
| C-04 | dispatcher คืน DeadLetter(reason) | negative | DeadLetterMessageAsync ด้วย reason เดียวกัน |
| C-05 | dispatcher โยน exception | negative | AbandonMessageAsync ไม่ใช่ dead-letter |
| C-06 | StartProcessingAsync โยน | negative | log critical แล้ว return — ไม่ throw ออกจาก ExecuteAsync |
| C-07 | 🔴 topic ของ consumer (const) ต้องตรงกับ topic ของ publisher (config) | integration | อ่านค่า ServiceBus:FxInAppTopicName จาก appsettings.json ของ FxOrchestrator (หรือจากค่าคงที่กลางที่ทั้งสองฝั่งอ้าง) แล้ว assert ว่าเท่ากับ FxInAppEventConsumer.TopicName — ห้ามใช้การมองด้วยตา เพราะ publisher อ่านจาก config ส่วน consumer เป็น compile-time const ค่าที่ไม่ตรงกันจะ build ผ่าน deploy ผ่าน แล้วเงียบไม่มี noti · ถ้าเขียน test ข้าม repo ไม่ได้ ให้ทำเป็น CI check ที่ diff ค่าจากสองไฟล์ และบันทึกว่าเลือกวิธีไหน |
DI — การยืนยันว่า register ครบ 2 branch
⚠️ ห้ามเขียนเป็น unit test ที่ build ServiceCollection จริง —
tests/Notification05.Tests/Infrastructure/DependencyInjectionTests.cs:13-14บันทึกไว้เองว่าAddInfrastructureต่อ Redis แบบ synchronous ทันทีหลัง guard จึงมีแค่ branch เดียวที่เรียกได้โดยไม่ต้องมี Redis/Postgres/ServiceBus จริง (DependencyInjection.cs:95,104เรียกConnectionMultiplexer.Connect) แม้AbortOnConnectFail = false(บรรทัด 261) จะทำให้ไม่ throw แต่จะ block ~5 วินาทีต่อ multiplexer กลายเป็น unit test ที่พึ่ง network และไม่มี precedent ใน repo
| # | เคส | วิธี |
|---|---|---|
| R-01 | FxInAppEventConsumer ถูก register ครบทั้ง Mode A และ Mode B | source-level assertion หรือ CI check: grep -c "FxInAppEventConsumer" src/Notification02.Infrastructure/DependencyInjection.cs ต้องได้ 2 |
| R-02 | ไม่มีการ register เกินหรือซ้ำใน branch ที่ 3 (else ที่ไม่มี ASB) | grep ยืนยันว่า 2 จุดนั้นอยู่ใน branch Mode B (206-226) และ Mode A (227-246) เท่านั้น |
5.2 FxOrchestratorService — tests/FxOrchestrator05.Tests
Publisher — AsbFxOutstandingNotificationPublisherTests
| # | เคส | คาดหวัง |
|---|---|---|
| P-01 | publish ปกติ | ServiceBusMessage.MessageId == MessageId ที่ส่งเข้าไป |
| P-02 | body ที่ serialize ออกมา | deserialize กลับเป็น FxOutstandingNotificationMessage ได้ครบทุก field |
| P-03 | contract ที่มีอักขระไทย | ไม่ถูก escape เป็น \uXXXX |
| P-04 | topic name จาก config | อ่าน ServiceBus:FxInAppTopicName — ถ้าไม่ตั้ง ต้อง fail ชัดเจน ไม่ใช่เงียบไปใช้ topic ผิด |
| P-05 | decimal ของ OutstandingAmount | ไม่เพี้ยนหลังผ่าน JSON (ทดสอบด้วยค่าที่มีทศนิยม) |
Scheduler — ⚠️ ต้องเขียนใหม่ทั้งหมด ไม่ใช่ “เพิ่มใน test เดิม”
ยืนยันแล้วว่า ไม่มี
FxOutstandingNotificationSchedulerTestsอยู่ในrepo — scheduler test ตัวเดียวที่มีคือtests/FxOrchestrator05.Tests/API/BackgroundServices/FxRateSyncSchedulerTests.csดังนั้น S-01..S-10 คือ test ใหม่ทั้งชุด (คำว่าregressionในคอลัมน์ “ชนิด” หมายถึง พฤติกรรมเดิมที่ต้องไม่เปลี่ยน ไม่ได้แปลว่ามี baseline test อยู่แล้ว)ต้นแบบที่ใช้ได้จริง:
FxRateSyncSchedulerTests.cs:26-60(RunForAsync/RunUntilAsynchelper) · scheduler เป็นinternal sealed partialจึงเข้าถึงได้เพราะInternalsVisibleToที่FxOrchestrator01.API.csproj:39— ถ้า test มองไม่เห็นคลาส ให้ตรวจบรรทัดนั้นก่อน อย่าเปลี่ยน accessibility ของ production codeเช่นเดียวกัน: T-08 ที่เขียนว่า “ลบ
AsbPersonalNotificationPublisher… และ test ของมัน” — ไม่มี test ของ publisher ตัวเดิมอยู่ ลบเฉพาะ production code
| # | เคส | ชนิด | คาดหวัง |
|---|---|---|---|
| S-01 | run ปกติ 1 สัญญา 2 user | positive | publish 2 ครั้ง · payload มี contract data · ไม่มี field title/subtitle |
| S-02 | สูตร messageId | behaviour-lock | ตรงกับรูปแบบเดิมทุกตัวอักษร |
| S-03 | daysLeft คำนวณด้วย TZ Asia/Bangkok | positive | ข้ามเที่ยงคืนแล้วยังถูก |
| S-04 | custCode ที่ไม่มีใน recipients | negative | นับเป็น skipped ไม่ publish |
| S-05 | recipients ว่าง | boundary | ไม่เรียก near-expiry เลย · ยัง mark slot |
| S-06 | publish ของ user คนหนึ่งพัง | negative | user คนอื่นยังได้ · run ไม่ล้ม |
| S-07 | 2 pod แย่ง lock | behaviour-lock | ได้ lock ตัวเดียว อีกตัว skip |
| S-08 | slot ที่ mark แล้ว | behaviour-lock | ไม่ส่งซ้ำ |
| S-09 | Enabled = false | behaviour-lock | ไม่ทำอะไรเลย |
| S-10 | near-expiry client โยน exception | negative | ยืนยันว่า marker ไม่ถูกตั้ง (ล็อกพฤติกรรม retry-storm ไว้เป็นสิ่งที่รู้ตัว ไม่ใช่เซอร์ไพรส์ — ดู architecture §8 G-1) |
| S-11 | MaturityDate ที่เป็น DateTimeOffset | boundary | ⚠️ อย่า assert ว่าเป็นวันตาม TZ Asia/Bangkok — DateTimeOffset.DateTime เป็น offset-naive และ T-08 สั่งให้คงบรรทัดแปลงเดิมไว้ ที่ถูกคือ assert ว่าได้วันตรงตามค่าที่ ThirdParty ส่งมา (ซึ่งเป็น date ล้วนที่ offset 0 อยู่แล้ว) การจะให้เป็น TZ Bangkok ต้องเปลี่ยนเป็น TimeZoneInfo.ConvertTime ซึ่ง ไม่ใช่สิ่งที่แผนนี้สั่ง |
5.3 Regression — พิสูจน์ว่า business logic อื่นไม่พัง (T-10)
นี่คือข้อที่ Owner เน้นที่สุด (“อย่าทำให้ Business Logic อื่นพังเด็ดขาด”) — ต้องมีหลักฐาน ไม่ใช่ความมั่นใจ
| # | เคส | วิธีพิสูจน์ |
|---|---|---|
| RG-01 | PersonalSignalConsumerTests เดิมทั้งชุดยังเขียว | dotnet test --filter PersonalSignalConsumer |
| RG-02 | FxEmailEventConsumer / BroadcastSignalConsumer / InvitationEventConsumer / FlowStatusNotificationConsumer / WorkflowMakerNotificationConsumer test เดิมยังเขียว | รัน suite เต็ม |
| RG-03 | ไม่มีไฟล์ของ consumer เดิมถูกแก้ | git diff --name-only origin/development...HEAD แล้วยืนยันว่าไม่มีชื่อไฟล์ในรายการ “ห้ามแตะ” (architecture §10) |
| RG-04 | ไม่มี migration ใหม่ | git diff --name-only … -- '*Migrations*' ต้องว่าง |
| RG-05 | schema ไม่เปลี่ยน | dotnet ef migrations has-pending-model-changes = none |
| RG-06 | read API เดิมยังคืนของเดิม | เรียก GET /notifications ไม่ใส่ appCode แล้วยังเห็น notification ของ feature อื่นครบ |
| RG-07 | unread-count ไม่ใส่ appCode ยังนับรวมทุก app | test เดิม + เพิ่มเคสที่มี row ของ FX ปนอยู่ |
| RG-08 | จำนวน AddHostedService ใน DependencyInjection.cs เพิ่มขึ้น 1 ต่อ branch ไม่ใช่มากกว่านั้น | source-level/CI grep ตาม R-01 (ห้ามใช้ ServiceCollection จริง — เหตุผลในกล่อง DI ข้างบน) |
| RG-09 | 🔴 FX bell ต้องไม่ใช้เลข unread จากที่ผิด | ยืนยันว่า unreadCount ที่มากับ SignalR push และ UnreadCount ใน envelope ของ GET /notifications?appCode=FX เป็นยอดรวมทุกแอป (InAppNotificationService.cs:56, GetNotificationsHandler.cs:21 เรียก overload ที่ไม่รับ appCode) — เขียน test ที่ seed noti ของ 2 appCode แล้ว assert ค่าทั้งสองจุดว่าเป็นยอดรวม เพื่อ ล็อกความจริงข้อนี้ไว้ และให้ FE รู้ว่าต้องเรียก /unread-count?appCode=FX เท่านั้น (ดู architecture §6) |
คำสั่งปิดงาน:
# NotificationService
dotnet build src/Notification01.API && dotnet test tests/Notification05.Tests
# FxOrchestratorService
dotnet build src/FxOrchestrator01.API && dotnet test tests/FxOrchestrator05.Tests
ทั้งสอง repo ต้อง exit code 0 และจำนวน test ที่ผ่านต้อง ไม่น้อยกว่า ก่อนเริ่มงาน
5.4 E2E บน dev (T-11)
- ยืนยัน G-1 เขียว (near-expiry คืนข้อมูลจริง)
- ตั้ง FX Codex config
FxNotificationOutstanding:Enabled=true,StartTimesเป็นเวลาที่กำลังจะถึง,ExpireDateที่ครอบสัญญาทดสอบ - รอ slot → ดู log
[FxOutstandingNotification] run complete — Slots=… Found=… Sent=… Skipped=… - เปิดเว็บจริง login เป็น user ที่อยู่ในบริษัทเจ้าของสัญญา → กระดิ่งต้องเด้งแบบ realtime
- refresh หน้า →
GET api/notification-service/v1/notifications?appCode=FXยังเห็นรายการเดิม (พิสูจน์ว่า persist ไม่ใช่แค่ push) - กดอ่าน → เรียก
GET api/notification-service/v1/notifications/unread-count?appCode=FXแล้วเลขลดลง — ⚠️ ต้องเช็คจาก endpoint นี้เท่านั้น ห้ามดูจากUnreadCountใน envelope ของ list หรือจากunreadCountใน SignalR push เพราะสองจุดนั้นเป็นยอดรวมทุกแอป (ดู G-6) · เพิ่มขั้นตอนยืนยัน: ให้ user คนเดียวกันมี noti ของแอปอื่นค้างอยู่ด้วย แล้วดูว่าเลขจากสองแหล่งต่างกันจริง - รัน slot เดิมซ้ำ → ต้องไม่มีรายการซ้ำ (พิสูจน์ dedup ด้วย
ExternalMessageId) - ตรวจว่า user ในบริษัทอื่น ไม่เห็น รายการนี้
⚠️ ตามกฎ E2E ของทีม: เปิด browser จริง (headed) กดผ่านหน้าจอทุก step ห้าม seed ข้อมูลด้วยการยิง API — ข้อ 2 (ตั้ง config) และการอ่าน log เป็น setup/observability ไม่ใช่การสร้างผลการทดสอบ
6. Owner decision ที่ยังเปิดอยู่
| id | เรื่อง | ถ้าไม่ตอบจะเกิดอะไร | ค่าเริ่มต้นที่แผนนี้ใช้ |
|---|---|---|---|
| G-2 | ยืนยันชื่อ topic/subscription + ค่า provisioning (dup-detection window, MaxDeliveryCount, TTL) | ASB เปลี่ยนชื่อ topic และค่า provisioning ทีหลังไม่ได้ ต้องสร้างใหม่ | ข้อเสนอ: fx-inapp-event / FxInApp-Sub — เป็นพี่น้องกับ fx-email-event / FxEmail-Sub ที่มีอยู่แล้ว ตรงตาม convention per-channel ของทั้ง namespace (ดู Backend_Iac/docs/operation/SERVICEBUS_TOPICS_INVENTORY.md) · ค่า provisioning ให้ลอกจาก fx-email-event (dup-detection ON, PT10M) เว้นแต่มีเหตุผลให้ต่าง · ⚠️ อย่าลืม update inventory file — แผน fx-email-channel เคยบันทึกว่า Invitation-Sub หายจาก inventory ทั้งที่ consumer มีจริง |
| G-5 | UserService cust-codes ควรเปลี่ยนจาก [AllowAnonymous] เป็น [RequireApiKey] ไหม | ปล่อยไว้ = endpoint ที่คืน user id ทั้งระบบเปิด anonymous · เปลี่ยน = ล้ม decision D5 ของแผนเดิม และ ต้องมีงานเพิ่มที่ไม่ได้อยู่ในตาราง §2: แนบ header X-API-Key ที่ typed HttpClient UserApi ของ FxOrchestrator + secret ใน KeyVault + key ใน IaC ทุก env | ไม่เปลี่ยนในงานนี้ — บันทึกเป็นหนี้ที่ควรทำเป็น story แยก เพื่อไม่ให้ SA-1380 บวมและไม่ให้เปลี่ยน auth เงียบ ๆ |
| G-6 | ปลายทางแยกต่อแอป ไม่ครบ 3 จุด — (1) PATCH /read-all ไม่รับ appCode (2) UnreadCount ใน envelope ของ GET /notifications?appCode=FX เป็นยอดรวมทุกแอป (3) unreadCount ใน SignalR push ก็เป็นยอดรวม | กด “อ่านทั้งหมด” ในแอป FX จะ mark ของทุกแอป · กระดิ่ง FX จะโชว์เลขของแอปอื่นปนถ้า FE หยิบจากสองจุดหลัง | ไม่แก้ในงานนี้ — ทั้งสามจุดแตะ shared read path · แผนนี้แก้ด้วยการบังคับว่า FE ต้องใช้ GET /notifications/unread-count?appCode=FX เท่านั้น และล็อกความจริงไว้ด้วย RG-09 |
| G-7 | NotificationType="Warning" ถูกต้องกับ FE ไหม | FE อาจ render เป็น icon default | ใช้ "Warning" ตามที่ต้นทางทำอยู่ ถ้า FE ยืนยันไม่รองรับ ให้ถอยเป็น "Info" |
7. งานที่แผนนี้ ไม่ ครอบคลุม (โดยตั้งใจ)
- Jira AC2 — highlight รายการใกล้ครบกำหนดในหน้า Outstanding (Frontend) · Owner ตัด FE ออก
- Jira AC3 — ส่ง email · เฟสถัดไป และ ไม่ต้องสร้าง topic/consumer ใหม่ — ใช้
fx-email-event+FxEmailEventConsumer/FxEmailDispatcherที่มีอยู่แล้ว แค่เพิ่ม template key (เส้นทางเต็มใน architecture §7) - SA-1556 — หน้าจอตั้งค่าจำนวนวัน · ค่าจริงตั้งได้แล้วผ่าน FX Codex
ExpireDateขาดแค่ UI - FX_400 / ThirdPartyFX near-expiry endpoint · เป็น gate G-1
- หนี้ที่พบระหว่างวางแผน แต่ไม่แก้ในงานนี้ (บันทึกไว้เพื่อไม่ให้หาย):
PersonalSignalConsumerไม่เคยส่งexternalMessageIdให้SendPersonalAsync→ path in-app ที่ใช้ร่วมกันทุกวันนี้ไม่มี dedup ระดับ DB เลย ทั้งที่ unique index มีอยู่แล้วNotificationDelivery.ChannelIdเป็นGuid?ที่ validator บังคับว่าต้องมี แต่ไม่มีตารางรองรับ
8. Definition of Done
- G-1 เขียวและมีหลักฐาน (ยิงจริง ได้ 200 + JSON) — ไม่ใช่ “น่าจะพร้อม”
- G-2 ปิดแล้ว: ชื่อ topic/subscription ถูกเคาะและ provision จริงทุก env — และค่าที่ใส่ในโค้ดคือค่านั้น ไม่ใช่ placeholder ในเอกสาร
- G-3 เขียว:
check-config-parity.pyexit 0 ทั้ง dev และ sit (รวม 8 key ที่ค้างอยู่เดิม + key ใหม่) -
dotnet testเขียวทั้ง 2 repo และจำนวน test ไม่ลดลง - ทุกเคสใน §5.1-5.3 มีอยู่จริงและผ่าน (รวม D-13b unique-violation → Complete และ RG-09 unread ยอดรวม)
- topic ของ publisher (config) กับของ consumer (const) ถูกตรวจว่าตรงกันด้วยกลไกอัตโนมัติ ไม่ใช่ด้วยตา (C-07)
-
git diff --name-only origin/development...HEADไม่มีชื่อไฟล์ในรายการห้ามแตะ (architecture §10) - ไม่มี migration ใหม่ ·
has-pending-model-changes= none -
FxInAppEventConsumerปรากฏ 2 ครั้งในDependencyInjection.cs - config key ใหม่อยู่ครบทั้ง service repo และ IaC ทุก env ที่กระทบ
- E2E §5.4 ผ่านครบ 8 ข้อ พร้อม screenshot
- G-5/G-6/G-7 ถูกบันทึกเป็นข้อเสนอ ไม่ถูกตัดสินเงียบ ๆ ในโค้ด
- FE ได้รับแจ้งว่ากระดิ่ง FX ต้องอ่าน unread จาก
/unread-count?appCode=FXเท่านั้น (ผลของ G-6)