Private Docs

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 ไปแล้ว

repobranchcommitผล
Backend_NotificationServicefeature/sprint-08/SA-1380-fx-inapp2dba35dconsumer + dispatcher + message contract + DI 2 จุด + 35 test · build 0 error · 934/935 ผ่าน (1 skip เดิม)
Backend_FxOrchestratorServicefeature/sprint-08/SA-1380-fx-inapp-publisher0e4ae57publisher ใหม่ + scheduler rewire + DI guard + ลบ publisher เดิม + 14 test · 713/713 ผ่าน
Backend_Iacfeature/sprint-08/SA-1380-fx-inapp-topicb535159FxInAppTopicName ครบ 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

สิ่งที่เปลี่ยนจากแผนตอนเขียนครั้งแรก หลังตรวจโค้ดจริง:

  1. guard ของ topic key ย้ายจาก constructor ไป DI registration — scheduler resolve publisher ข้างใน tick แล้ว catch-and-log ดังนั้น throw ที่ ctor จะกลายเป็น retry loop เงียบ ๆ แทนที่จะเป็น startup failure · house pattern ของ FxEmailPublisher ก็ guard ที่ DI
  2. IaC ต้องครบ dev/sit/uat ไม่ใช่แค่ dev/sit — เหตุผลใน architecture §8 (guard ไม่เคยรันให้ sit/uat เลย)
  3. 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_NotificationServiceconsumer + dispatcher + message contract + DI + tests🔵 งานหลัก
Backend_FxOrchestratorServicepublisher ตัวใหม่ + rewire scheduler ให้ส่ง data แทนข้อความ + config + tests🔵 งานหลัก
Backend_UserServiceไม่มีงาน — endpoint cust-codes merge เข้า development แล้วพร้อม test⚪️ ไม่แตะ
Backend_Iacprovision topic + subscription + config parity🟡 นอก scope 3 repo — เป็น gate
Backend_ThirdPartyFXService / FX_400near-expiry endpoint🟡 นอก scope — เป็น gate

2. ตารางงาน (เรียงตาม dependency)

idงานrepoblocked 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 ทุก envIaCG-1topic มีอยู่จริง เห็นใน 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 ตอน startupIaCkey อยู่ครบ 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 นั้นเติมIaCcheck-config-parity.py dev exit 0
T-01message contract FxOutstandingNotificationMessageNSคอมไพล์ผ่าน
T-02FxInAppNotificationDispatcher + FxInAppDispatchResult + FxInAppDispatchActionNST-01unit test ผ่าน (T-05)
T-03FxInAppEventConsumerNST-01, T-02, G-2 (ชื่อ topic เป็น const ในคลาส เปลี่ยนทีหลังไม่ได้ฟรี)unit test ผ่าน (T-06)
T-04DI register consumer ใน ทั้ง 2 branchNST-03grep เจอ 2 ครั้ง
T-05unit tests ของ dispatcherNST-02dotnet test เขียว
T-06unit tests ของ consumerNST-03dotnet test เขียว
T-07message record + FxOutstandingNotificationPublisher + port ฝั่ง FXFXT-01 (contract ต้องตรงกัน), G-2คอมไพล์ผ่าน
T-08rewire scheduler + config key + DIFXT-07, G-3unit test ผ่าน (T-09)
T-09unit tests ของ publisher + schedulerFXT-07, T-08dotnet test เขียว
T-10regression suite พิสูจน์ว่า path เดิมไม่เปลี่ยนNST-04dotnet test เขียวทั้ง suite เดิม
T-11E2E บน 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 + sourceServiceWorkflowMakerDispatcher — ตัวนี้เรียก SendPersonalAsync(..., sourceService: "Workflow.Maker", externalMessageId: …) จริงFxEmailDispatcher (ไม่เคยแตะ IInAppNotificationService เลย เป็นสาย email ล้วน)
การตัดสิน Complete / DeadLetter / AbandonFxEmailDispatcher — return DispatchResult แบบ DeadLetter สำหรับ payload ที่ retry แล้วไม่ช่วย, ปล่อย exception ขึ้นไปให้ consumer abandon สำหรับ transient

เนื้อใน (โครง):

public async Task<FxInAppDispatchResult> DispatchAsync(
    FxOutstandingNotificationMessage? message,
    IInAppNotificationService inAppNotifications,
    CancellationToken ct)
  1. Validate(message) — ถ้าไม่ผ่าน → DeadLetter(reason) ห้าม Abandon (retry ไม่ช่วย)
  2. ประกอบข้อความ (§3.1)
  3. await inAppNotifications.SendPersonalAsync(userId, payload, sourceService: message.SourceService, externalMessageId: message.MessageId, ct)
  4. Complete()
  5. แยก exception 2 ชนิด — ห้ามเหมารวม:
    • unique-violation (Postgres SQLSTATE 23505) ที่ index IX_in_app_notifications_external_message_dedupComplete() เพราะแปลว่า ส่งไปแล้วจริง ไม่ใช่ความผิดพลาด
    • transient อื่น (connection, timeout, deadlock) → ปล่อย exception ขึ้นไปให้ consumer abandon เพื่อ retry

ทำไมต้องแยก: 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 == nullNullMessage
SchemaVersion != 1UnsupportedSchemaVersion
MessageId ว่างMissingMessageId
Recipient.UserId parse เป็น Guid ไม่ได้InvalidRecipient
Contract.ContractNumber ว่างMissingContractNumber
AppCode ว่างMissingAppCode
SourceService ว่างMissingSourceService — ถ้าไม่เช็ค ค่าจะถูกเขียนลงคอลัมน์เป็น NULL เงียบ ๆ (ทางเลือก: pin เป็น const "FxOrchestrator" ใน dispatcher แทนที่จะรับจาก payload)
NotificationType ไม่อยู่ใน NotificationTypes.AllInvalidNotificationType — ใช้ 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 พฤติกรรมนี้จากต้นแบบ:

  1. StartProcessingAsync ห่อ try/catch ที่ LogCritical แล้ว returnห้าม throw (ไม่งั้น BackgroundServiceExceptionBehavior.StopHost จะทำให้ทั้ง service ตายเพราะ consumer ตัวเดียว)
  2. deserialize ไม่ได้ → DeadLetterMessageAsync("DeserializationFailed", …) ไม่ใช่ throw
  3. result.Action == DeadLetterDeadLetterMessageAsync(result.Reason, result.Description) · CompleteCompleteMessageAsync · 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 + record FxOutstandingNotification (mirror ของ T-01 เป๊ะ)
  • src/FxOrchestrator02.Infrastructure/Messaging/AsbFxOutstandingNotificationPublisher.cs
  • src/FxOrchestrator02.Infrastructure/Messaging/NullFxOutstandingNotificationPublisher.cs

ลอกโครงจาก AsbPersonalNotificationPublisher ทั้งชุด แล้วเปลี่ยน 4 อย่าง:

  1. topic จาก config key ใหม่ ServiceBus:FxInAppTopicName
  2. body เป็น FxOutstandingNotificationMessage (โครง §4 ของ architecture) แทน {userId, eventName, payload}
  3. คง JavaScriptEncoder.Create(UnicodeRanges.All) ไว้ ไม่งั้น custCode/ชื่อสัญญาที่มีอักขระไทยจะกลายเป็น \uXXXX
  4. 🔴 ตัด 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.cs guard ServiceBus:NotificationTopicName ตอน register IForwardContractEmailGateway ⇒ ใส่ 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, LogRunCompleteresolve 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-01message ครบถ้วนถูกต้องpositiveเรียก SendPersonalAsync 1 ครั้ง ด้วย sourceService และ externalMessageId ที่ไม่ใช่ null · ผลลัพธ์ Complete
D-02AppCode ถูกส่งต่อลง payloadpositiveInAppNotificationPayload.AppCode == "FX"
D-03DaysLeft = 3positiveTitle มี "ภายใน 3 วัน"
D-04DaysLeft = 0boundaryTitle มี "ภายในวันนี้" ไม่ใช่ "ภายใน 0 วัน"
D-05DaysLeft = -1 (สัญญาเลยกำหนด)boundaryไม่ crash · ใช้ถ้อยคำ "ภายในวันนี้"
D-06MaturityDate ต่างจาก DaysLeft อย่างจงใจnegativedispatcher ใช้ DaysLeft จาก payload ไม่คำนวณใหม่ (เคสนี้คือ regression guard ของกฎข้อสำคัญที่สุด)
D-07message == nullnegativeDeadLetter("NullMessage") · ไม่เรียก SendPersonalAsync
D-08SchemaVersion = 2negativeDeadLetter("UnsupportedSchemaVersion")
D-09MessageId ว่างnegativeDeadLetter("MissingMessageId")
D-10UserId ไม่ใช่ GuidnegativeDeadLetter("InvalidRecipient")
D-11ContractNumber ว่างnegativeDeadLetter("MissingContractNumber")
D-12NotificationType = "Critical"negativeDeadLetter("InvalidNotificationType")
D-13aSendPersonalAsync โยน DbUpdateException แบบ transient (connection/timeout)negativeexception หลุดออกมา (ไม่ถูกกลืน) เพื่อให้ consumer abandon
D-13bSendPersonalAsync โยน DbUpdateException ที่มี inner PostgresException SQLSTATE 23505negativeComplete ไม่ใช่ให้ exception หลุด — แปลว่าส่งไปแล้วจริง กัน dead-letter ของข้อความที่สำเร็จ
D-14SourceService ว่างnegativeDeadLetter("MissingSourceService") — ไม่ปล่อยให้เขียน NULL ลงคอลัมน์เงียบ ๆ
D-15ข้อความไทยไม่ถูก escape เป็น \uXXXXpositiveassert ตัวอักษรไทยจริงใน Title
D-16ContractNumber เป็น "-" (ค่าที่ normalize มาจากต้นทาง)boundaryผ่าน validate ปกติ · Subtitle มี "สัญญาเลขที่ -" — ยืนยันว่า contract ข้ามฝั่งตรงกันตามที่ตกลงใน T-02

Consumer — FxInAppEventConsumerTests

#เคสชนิดคาดหวัง
C-01body JSON ถูกต้องpositiveเรียก dispatcher 1 ครั้ง แล้ว CompleteMessageAsync
C-02body ไม่ใช่ JSONnegativeDeadLetterMessageAsync("DeserializationFailed", …) · ไม่ throw
C-03body เป็น null literalnegativeDeadLetterMessageAsync("NullMessage")
C-04dispatcher คืน DeadLetter(reason)negativeDeadLetterMessageAsync ด้วย reason เดียวกัน
C-05dispatcher โยน exceptionnegativeAbandonMessageAsync ไม่ใช่ dead-letter
C-06StartProcessingAsync โยนnegativelog 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-01FxInAppEventConsumer ถูก register ครบทั้ง Mode A และ Mode Bsource-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-01publish ปกติServiceBusMessage.MessageId == MessageId ที่ส่งเข้าไป
P-02body ที่ serialize ออกมาdeserialize กลับเป็น FxOutstandingNotificationMessage ได้ครบทุก field
P-03contract ที่มีอักขระไทยไม่ถูก escape เป็น \uXXXX
P-04topic name จาก configอ่าน ServiceBus:FxInAppTopicName — ถ้าไม่ตั้ง ต้อง fail ชัดเจน ไม่ใช่เงียบไปใช้ topic ผิด
P-05decimal ของ 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/RunUntilAsync helper) · scheduler เป็น internal sealed partial จึงเข้าถึงได้เพราะ InternalsVisibleTo ที่ FxOrchestrator01.API.csproj:39 — ถ้า test มองไม่เห็นคลาส ให้ตรวจบรรทัดนั้นก่อน อย่าเปลี่ยน accessibility ของ production code

เช่นเดียวกัน: T-08 ที่เขียนว่า “ลบ AsbPersonalNotificationPublisher … และ test ของมัน” — ไม่มี test ของ publisher ตัวเดิมอยู่ ลบเฉพาะ production code

#เคสชนิดคาดหวัง
S-01run ปกติ 1 สัญญา 2 userpositivepublish 2 ครั้ง · payload มี contract data · ไม่มี field title/subtitle
S-02สูตร messageIdbehaviour-lockตรงกับรูปแบบเดิมทุกตัวอักษร
S-03daysLeft คำนวณด้วย TZ Asia/Bangkokpositiveข้ามเที่ยงคืนแล้วยังถูก
S-04custCode ที่ไม่มีใน recipientsnegativeนับเป็น skipped ไม่ publish
S-05recipients ว่างboundaryไม่เรียก near-expiry เลย · ยัง mark slot
S-06publish ของ user คนหนึ่งพังnegativeuser คนอื่นยังได้ · run ไม่ล้ม
S-072 pod แย่ง lockbehaviour-lockได้ lock ตัวเดียว อีกตัว skip
S-08slot ที่ mark แล้วbehaviour-lockไม่ส่งซ้ำ
S-09Enabled = falsebehaviour-lockไม่ทำอะไรเลย
S-10near-expiry client โยน exceptionnegativeยืนยันว่า marker ไม่ถูกตั้ง (ล็อกพฤติกรรม retry-storm ไว้เป็นสิ่งที่รู้ตัว ไม่ใช่เซอร์ไพรส์ — ดู architecture §8 G-1)
S-11MaturityDate ที่เป็น DateTimeOffsetboundary⚠️ อย่า assert ว่าเป็นวันตาม TZ Asia/BangkokDateTimeOffset.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-01PersonalSignalConsumerTests เดิมทั้งชุดยังเขียวdotnet test --filter PersonalSignalConsumer
RG-02FxEmailEventConsumer / 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-05schema ไม่เปลี่ยนdotnet ef migrations has-pending-model-changes = none
RG-06read API เดิมยังคืนของเดิมเรียก GET /notifications ไม่ใส่ appCode แล้วยังเห็น notification ของ feature อื่นครบ
RG-07unread-count ไม่ใส่ appCode ยังนับรวมทุก apptest เดิม + เพิ่มเคสที่มี 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)

  1. ยืนยัน G-1 เขียว (near-expiry คืนข้อมูลจริง)
  2. ตั้ง FX Codex config FxNotificationOutstanding: Enabled=true, StartTimes เป็นเวลาที่กำลังจะถึง, ExpireDate ที่ครอบสัญญาทดสอบ
  3. รอ slot → ดู log [FxOutstandingNotification] run complete — Slots=… Found=… Sent=… Skipped=…
  4. เปิดเว็บจริง login เป็น user ที่อยู่ในบริษัทเจ้าของสัญญา → กระดิ่งต้องเด้งแบบ realtime
  5. refresh หน้า → GET api/notification-service/v1/notifications?appCode=FX ยังเห็นรายการเดิม (พิสูจน์ว่า persist ไม่ใช่แค่ push)
  6. กดอ่าน → เรียก GET api/notification-service/v1/notifications/unread-count?appCode=FX แล้วเลขลดลง — ⚠️ ต้องเช็คจาก endpoint นี้เท่านั้น ห้ามดูจาก UnreadCount ใน envelope ของ list หรือจาก unreadCount ใน SignalR push เพราะสองจุดนั้นเป็นยอดรวมทุกแอป (ดู G-6) · เพิ่มขั้นตอนยืนยัน: ให้ user คนเดียวกันมี noti ของแอปอื่นค้างอยู่ด้วย แล้วดูว่าเลขจากสองแหล่งต่างกันจริง
  7. รัน slot เดิมซ้ำ → ต้องไม่มีรายการซ้ำ (พิสูจน์ dedup ด้วย ExternalMessageId)
  8. ตรวจว่า 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-5UserService 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-7NotificationType="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 ให้ SendPersonalAsyncpath 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.py exit 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)