Backend Code Walkthrough — เจาะลึก Lego Engine (Sprint)
อ่านครั้งเดียวเข้าใจทั้ง concept + architecture + แต่ละ class + code จริง + DB fields ทุก column — เขียนสำหรับคนที่ยังไม่เคยเห็น Lego Engine มาก่อน
อัปเดต: 2026-06-05
เอกสารนี้พาคุณเดินจาก “ไม่รู้เลย” → “เข้าใจว่า engine นี้ทำงานอย่างไร เขียนขึ้นมาทำไม และ code จริงเดินอย่างไร” — ก่อนลงรายละเอียด code ต้องเข้าใจ ทำไม ก่อน
1. ทำไมถึงเรียกว่า “Lego”? — แนวคิดพื้นฐาน
ปัญหาแบบดั้งเดิม: Hardcode Flow
ลองนึกถึงระบบ onboarding แบบเก่า — ถ้าต้องเพิ่ม “ขั้นตอนยืนยันที่อยู่” เข้าไปในกระบวนการสมัคร:
- แก้ Controller ของ flow นั้น
- เพิ่ม
if step == 3ใน logic - deploy ระบบใหม่
- พอ Business บอกให้สลับลำดับ → refactor ใหม่ทั้งหมด
ทุก flow มี logic การ navigate กระจายอยู่ใน code — ซ้ำซ้อน ทดสอบยาก ขยายยาก
แนวคิด Lego: Data-Driven Engine
นึกถึงตัวต่อ Lego จริงๆ:
| ใน Lego | ใน System |
|---|---|
| กล่อง Lego (ชิ้นส่วนทุกแบบ) | StepCatalog — ทะเบียน step type ทั้งหมด |
| พิมพ์เขียว (บอกว่าใช้ชิ้นไหน ลำดับไหน) | FlowDefinition + FlowStep |
| ชิ้นส่วนแต่ละอัน (ทำงาน 1 อย่าง) | IFlowStepHandler — handler 1 ตัวต่อ 1 step type |
| มือที่ต่อชิ้นตามพิมพ์เขียว | FlowEngine — orchestrator |
| ผลลัพธ์ที่ประกอบเสร็จ (กำลังวิ่งอยู่) | FlowInstance — 1 execution จริง |
3 กฎหลักของ Engine
ผลที่ได้:
- ทุก flow (STANDARD_CUSTOMER, CUSTOMER_FIXED, NEW_REQUESTOR, …) วิ่งผ่าน engine เดียวกัน
- Handler unit test ง่าย — ทดสอบแค่ “input นี้ → output นี้”
- Business ขอเพิ่ม/เปลี่ยน step sequence ได้โดยไม่ต้องรอ engineer
2. สถาปัตยกรรม — Clean Architecture ใน 1 นาที
UserService แบ่งเป็น 4 โปรเจกต์ — ชั้นในไม่รู้จักชั้นนอก:
04 · Domain (Entities + Interfaces) = แกนใน ทุกชั้นอ้างเข้าหา ไม่ออกนอก
หลักการ Controller ใน Lego Engine:
// ✅ ทำอย่างนี้เท่านั้น
var result = await mediator.Send(new ExecuteStepActionCommand(...));
return ApiMatch(result); // ไม่มี business logic ใน Controller
3. แผนที่ส่วนประกอบหลัก — แต่ละตัวมีไว้ทำอะไร
ก่อนดู code ให้รู้จัก “ตัวละคร” ทุกตัวก่อน:
| ตัวละคร | Type | ไฟล์ | หน้าที่ |
|---|---|---|---|
| FlowEngine | Class | Engine/FlowEngine.cs | orchestrator — Start / ExecuteAction / AdvanceAutomatic / BuildNavigation |
| IFlowStepHandler | Interface | 04.Domain/Ports/ | contract ของทุก handler: string StepType + ExecuteAsync(ctx) |
| StepHandlerRegistry | Class | Engine/StepHandlerRegistry.cs | resolve handler ตาม stepType จาก DI |
| FlowSnapshot + Builder | Class | Engine/FlowSnapshot*.cs | แช่แข็ง FlowDefinition → JSON ใน instance ตอน Start |
| FlowSnapshotNavigator | Class | Engine/FlowSnapshotNavigator.cs | navigation แบบ pure: nextInteractive, prev, stepAt, isLast |
| FlowAccessGuard | Class | Engine/FlowAccessGuard.cs | ตรวจสิทธิ์ 2 ทาง: JWT owner-match หรือ EditSession cookie |
| FlowDefinition | Entity | 04.Domain/Entities/Onboarding/ | template ของ flow (admin config ใน DB) |
| FlowInstance | Entity | 04.Domain/Entities/Onboarding/ | 1 execution จริง มี state machine |
| FlowInstanceStepData | Entity | 04.Domain/Entities/Onboarding/ | output ของแต่ละ step (1 row ต่อ instance+stepType) |
| EditSession | Entity | 04.Domain/Entities/Onboarding/ | anonymous session ก่อน bind owner (cookie __Host-onboarding) |
| CorrectionRequest | Entity | 04.Domain/Entities/Onboarding/ | ประวัติคำขอแก้ไขจาก approver (append-only) |
| InvitationToken | Entity | 04.Domain/Entities/Onboarding/ | one-time token สำหรับ invite customer ผ่าน link |
| AutoApproveWorker | Worker | 02.Infrastructure/BackgroundJobs/ | subscribe flow-submitted → auto-approve ถ้า flowCode อยู่ใน whitelist |
| Workers (×3) | Workers | 02.Infrastructure/BackgroundJobs/ | AutoAbandon (6h) · RefNoRetry (5m) · StuckChainRecovery (15m) |
4. Endpoints ทั้งหมด (OnboardingV2Controller)
| Method · Path | ทำอะไร | Command/Query |
|---|---|---|
GET /onboarding/instances | ใบสมัครของฉัน (ตาม JWT owner) | ListMyFlowInstancesQuery |
GET /onboarding/instances/{id} | ดู instance เดียว | GetFlowInstanceQuery |
GET /onboarding/session | resume จาก cookie __Host-onboarding | GetSessionNavQuery |
POST /onboarding/instances | เริ่ม flow (anonymous ได้) → set cookie | StartFlowInstanceCommand |
POST /onboarding/instances/{id}/open | เปิด instance เดิม | OpenFlowInstanceCommand |
POST /onboarding/instances/{id}/steps/{stepType}/actions/{action} | ขับ step (Next/Back/Submit…) | ExecuteStepActionCommand |
GET /admin/flow-instances · …/retry-stuck · …/abandon | admin ops | Admin* |
ทุก endpoint รับ request → mediator.Send(...) → return ApiMatch(result) — Controller ไม่มี business logic
5. Code Walkthrough — กด “ถัดไป” 1 ครั้ง
POST /api/user-service/v1/onboarding/instances/{id}/steps/PersonalInfoStep/actions/Next
sequenceDiagram
autonumber
participant FE as Frontend
participant C as OnboardingV2Controller
participant P as MediatR pipeline
participant H as ExecuteStepActionHandler
participant E as FlowEngine
participant SH as PersonalInfoStepHandler
participant R as Repository (EF)
participant DB as PostgreSQL
FE->>C: POST .../actions/Next { stepData }
C->>P: Send(ExecuteStepActionCommand)
P->>H: behaviors (Performance/Audit/Validation/Tx…)
H->>H: FlowAccessGuard (JWT owner หรือ EditSession cookie)
H->>E: ExecuteActionAsync(instance, "PersonalInfoStep", Next, json)
E->>SH: handler.ExecuteAsync(ctx)
SH-->>E: FlowStepExecutionResult.Ok(output)
E->>R: UpsertAsync(FlowInstanceStepData)
R->>DB: INSERT/UPDATE DataJson (jsonb)
E->>E: AdvanceTo(next interactive) + AdvanceAutomaticAsync
E->>R: UpdateAsync(instance) — Status/CurrentStepIndex
E-->>H: StepNavigationV2Dto (step ถัดไป)
H-->>C: Result.Success(nav)
C-->>FE: 200 ApiOk(nav)
ชั้นที่ 1 — Controller (รับ + ส่งต่อ)
[HttpPost("onboarding/instances/{id:guid}/steps/{stepType}/actions/{actionCode}")]
public async Task<IActionResult> Action(Guid id, string stepType, string actionCode, ExecuteActionRequest? body)
{
if (!Enum.TryParse<StepActionCode>(actionCode, true, out var parsed))
return BadRequest(new { errorCode = "Session.InvalidAction" });
var inputJson = body?.StepData?.GetRawText(); // เก็บเป็น raw JSON ส่งต่อ
var editSessionId = ReadSessionCookie(); // __Host-onboarding
var result = await mediator.Send(new ExecuteStepActionCommand(id, stepType, parsed, inputJson, editSessionId));
if (result.IsFailure) return ApiMatch(result);
// sliding refresh cookie...
return ApiOk(result.Value.Nav);
}
ชั้นที่ 2 — Application Handler (guard + เรียก engine + map error)
public async Task<Result<ExecuteStepActionResult>> Handle(ExecuteStepActionCommand cmd, CancellationToken ct)
{
var instance = await repo.GetByIdAsync(cmd.InstanceId, ct);
if (instance is null) return Result.Failure(Error.NotFound("Application.NotFound", "ไม่พบใบสมัคร"));
// สิทธิ์ 2 ทาง: JWT owner-match หรือ EditSession cookie ที่ผูก instance นี้ (กัน IDOR)
var auth = await FlowAccessGuard.AuthorizeAsync(instance, currentUser, cmd.EditSessionId, editSessionRepo, ct);
if (auth.IsFailure) return Result.Failure(auth.Error);
try
{
var nav = await engine.ExecuteActionAsync(instance, cmd.StepType, cmd.ActionCode, cmd.InputJson, auth.Value, ct);
// sliding refresh: เลื่อน ExpiresAt ของ EditSession ทุก action
return Result.Success(new ExecuteStepActionResult(nav, cookieSessionId, ttlSeconds));
}
catch (FlowEngineException ex)
{
return Result.Failure(Error.Validation(ex.ErrorCode, ex.Message)); // → 400 + ข้อความที่สื่อความหมาย
}
}
ชั้นที่ 3 — FlowEngine (หัวใจ runtime)
public async Task<StepNavigationV2Dto> ExecuteActionAsync(
FlowInstance instance, string stepType, StepActionCode action, string? inputJson, string actor, CancellationToken ct)
{
var snapshot = FlowSnapshotBuilder.Deserialize(instance.FlowSnapshotJson); // อ่าน flow ที่แช่แข็งไว้
var curStep = FlowSnapshotNavigator.StepAt(snapshot, instance.CurrentStepIndex);
if (curStep.StepType != stepType) // กันยิงผิด step
throw new FlowEngineException("Session.InvalidStep", "stepType ไม่ตรงกับ currentStep");
var handler = handlerRegistry.Resolve(stepType); // หา IFlowStepHandler ตาม stepType
var result = await handler.ExecuteAsync(new FlowStepExecutionContext {
Instance = instance, StepType = stepType, InputJson = inputJson, StepData = existing, ActionCode = action }, ct);
if (!result.Success) // validate ไม่ผ่าน
throw new FlowEngineException(result.ErrorCode ?? "Step.Failed", result.ErrorMessage);
await stepDataRepo.UpsertAsync( // เก็บ output ของ step ลง DB
FlowInstanceStepData.CreateExecuted(instance.Id, stepType, result.OutputJson ?? inputJson, actor), ct);
// Next → เลื่อนไป interactive ถัดไป แล้วรัน Automatic chain ที่คั่น
var next = FlowSnapshotNavigator.NextInteractiveIndex(snapshot, instance.CurrentStepIndex);
instance.AdvanceTo(next >= 0 ? next : instance.CurrentStepIndex + 1, actor);
await AdvanceAutomaticAsync(instance, snapshot, actor, ct);
await instanceRepo.UpdateAsync(instance, ct);
return await BuildNavigationAsync(instance, snapshot, ct);
}
:::details Automatic chain — AdvanceAutomaticAsync (engine รัน step เองจนเจอ Interactive หรือจบ)
while (true)
{
var step = FlowSnapshotNavigator.StepAt(snapshot, instance.CurrentStepIndex);
if (step is null) { instance.TransitionToFinalized(actor); return; } // จบ flow
if (step.ExecutionMode != StepExecutionMode.Automatic) return; // หยุดที่ Interactive
var result = await handlerRegistry.Resolve(step.StepType)
.ExecuteAsync(new FlowStepExecutionContext { Instance = instance, StepType = step.StepType, InputJson = null, ... }, ct);
if (!result.Success) { instance.MarkStuck(step.StepType, actor); return; } // ค้าง → admin retry
await stepDataRepo.UpsertAsync(FlowInstanceStepData.CreateExecuted(...), ct);
instance.AdvanceTo(instance.CurrentStepIndex + 1, actor);
}
:::
ชั้นที่ 4 — Handler (โค้ดที่รัน 1 step)
PersonalInfoStepHandler — parse JSON → validate → คืน output:
public Task<FlowStepExecutionResult> ExecuteAsync(FlowStepExecutionContext ctx, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(ctx.InputJson))
return Fail("Step.NoInput", "กรุณากรอกข้อมูลส่วนตัว");
using var doc = JsonDocument.Parse(ctx.InputJson);
// root = view ที่ชี้เข้า doc — ต้องอ่านค่าทั้งหมด "ก่อน" doc ถูก dispose
var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
var firstNameTH = GetStr(root, "firstNameTH");
// ... validate required ...
var output = JsonSerializer.Serialize(new { firstNameTH, lastNameTH, phone, ... });
return Ok(output);
}
6. Handler เจาะลึก: OtpVerificationStep (identity boundary)
Step นี้คือจุดเปลี่ยน anonymous → authenticated — 1 step มี 2 action:
sequenceDiagram
autonumber
participant FE as Frontend
participant H as OtpVerificationStepHandler
participant OTP as OtpStateClient
participant EN as Entra (CIAM)
participant U as CreateUserInternalService
participant DB as PostgreSQL
FE->>H: Next (verify) { otp, refCode }
H->>OTP: VerifyOtpAsync(otp)
H->>EN: SignUpContinueWithOtp → id_token (oid)
H->>EN: DeactivateIdentity(oid) %% กัน orphan จน ActivateEntraUser
H->>U: CreateAsync(minimal User: email+oid)
U->>DB: INSERT Users (+ UserRestrictedProfile ค่าว่าง)
H->>H: instance.BindOwner(userId) %% anonymous → owner
H->>DB: rotate EditSession (UserId set + sessionId ใหม่)
H-->>FE: Ok(status=VERIFIED)
var verify = await otpState.VerifyOtpAsync(OtpModule.Onboarding, email, otp, continuationToken, ct);
if (verify.IsFailure) return Fail(verify.Error.Code, verify.Error.Description);
var auth = await entraSignUp.SignUpContinueWithOtpAsync(_tenantKey, continuationToken, otp, ...);
var oid = ExtractOidFromIdToken(auth.IdToken); // CIAM v2: ใช้ oid ไม่ใช่ sub
await graphIdentity.DeactivateIdentityAsync(_tenantKey, oid, ct);
var create = await createUser.CreateAsync(new CreateUserCommand(oid, email, ...), ct);
ctx.Instance.BindOwner(create.Value.Id, "otp-identity"); // engine persist instance หลัง handler
// rotate EditSession: deactivate เก่า + สร้างใหม่ (stamp UserId, anti session-fixation)
7. Attribute ของ Step — มีอะไรให้กำหนดได้บ้าง
ก่อนดูว่าระบบมี step อะไรบ้าง ต้องเข้าใจก่อนว่า step 1 ตัวมี “ลักษณะ” อะไรที่ตั้งได้บ้าง — ทั้งหมดนี้เก็บอยู่ใน StepCatalog (ทะเบียน step type) และ FlowStep (การผูก step เข้ากับ flow)
ExecutionMode — ใครขับ step นี้?
| Mode | ค่า | Engine ทำอะไร | ใครเป็นคนกระตุ้น |
|---|---|---|---|
| Interactive | 0 | หยุดรอที่ step นี้ ส่ง currentStep กลับให้ Frontend render | User กรอกข้อมูล + กด action (Next/Back/Submit) |
| Automatic | 1 | engine รัน handler ทันทีแล้วเดินหน้าต่อ — FE ไม่เห็น step นี้เลย | Engine เอง (หลัง user กด Next ที่ Interactive step ก่อนหน้า) |
ExecutionMode กำหนดที่ StepCatalog ต่อ step type — Interactive step ทุกตัวจะมี component บน FE ส่วน Automatic จะไม่อยู่ใน STEP_REGISTRY เลย
CanGoBack — ย้อนกลับได้ไหม?
กำหนดที่ FlowStep (ต่อ step ต่อ flow — ไม่ใช่ต่อ step type) เพราะ step เดียวกันอาจ canGoBack ต่างกันในแต่ละ flow
canGoBack = true → user กด Back กลับมา step นี้ได้
canGoBack = false → step นี้ผ่านแล้วย้อนไม่ได้ (เช่น OTP ที่ bind identity แล้ว)
MaxRetries + RetryBackoffSeconds — Automatic step ล้มเหลวจัดยังไง?
เฉพาะ Automatic step เท่านั้น — ถ้า handler คืน Fail():
retry ทุก 15 นาที
ปัจจุบัน: engine ไม่ retry inline — Fail() ครั้งเดียวก็ MarkStuck ทันที แล้วปล่อยให้ Worker จัดการ
| Attribute | Default | สถานะ |
|---|---|---|
MaxRetries | 0 | ประกาศใน DB แต่ engine ยังไม่ใช้ (Phase 2) |
RetryBackoffSeconds | 30 | ประกาศใน DB แต่ engine ยังไม่ใช้ (Phase 2) |
DependenciesJson — step นี้ต้องการ step อื่นก่อนไหม?
เก็บเป็น JSON array บอกว่า step type ไหนต้องทำก่อน และ mode ว่าบังคับหรือแนะนำ:
[
{ "StepTypeCode": "JuristicBindingStep", "Mode": "Required" },
{ "StepTypeCode": "UserModeStep", "Mode": "Optional" }
]
| Mode | ความหมาย |
|---|---|
Required | ถ้าไม่มี step นี้ใน flow → FlowStudio แสดง error · handler เช็คเองและ return Fail() ถ้าข้อมูลขาด |
Optional | แนะนำให้มี แต่ไม่บังคับ |
SkipScope — ข้ามซ้ำได้ไหม?
กำหนดว่า user ที่เคยทำ step นี้ไปแล้วในอดีตจะ “ข้าม” step ได้ไหม (copy-on-skip pattern):
| Value | ความหมาย |
|---|---|
Never (0) | ทำซ้ำทุกครั้ง ไม่ข้าม |
PerUser (1) | user คนนี้เคยทำแล้ว → ข้ามได้ (copy data เก่ามา) |
PerUserPerCompany (2) | user + บริษัทเดิม → ข้าม |
PerUserPerCompanyPerSvc (3) | user + บริษัท + service เดิม → ข้าม |
LockedAfterFirstExecution — ทำซ้ำไม่ได้
true → step นี้ execute ได้ครั้งเดียว เช่น OTP, NDID (กัน replay attack)
false → ทำซ้ำได้ (เช่น กรอกข้อมูลส่วนตัวแก้ได้หลายครั้ง)
ctx.StepData — handler อ่านข้อมูลจาก step อื่น
ทุก handler รับ FlowStepExecutionContext ซึ่งมี StepData เป็น dictionary ของ output ทุก step ที่ผ่านมาแล้ว:
// อ่านข้อมูลจาก step อื่นผ่าน StepType — ไม่ใช่ index
var juristicData = ctx.StepData.GetValueOrDefault("JuristicBindingStep");
ทำให้ handler สามารถ cross-reference ข้อมูลจาก step อื่น โดยไม่ต้องรู้ตำแหน่งของ step นั้นในลำดับ
การ Parse InputJson — Pattern และข้อควรระวัง
ctx.InputJson คือ raw JSON string จาก client — codebase ทั้งหมดอ่านผ่าน JsonDocument + TryGetProperty แบบ manual ไม่ใช้ Deserialize<T> โดยตรง
Pattern มาตรฐาน (ทุก handler ใช้แบบนี้):
using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
// รองรับทั้ง { "stepData": { ... } } และ flat { ... }
var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
// อ่านทีละ field ภายใน using scope
var firstName = root.TryGetProperty("firstNameTH", out var v1) ? v1.GetString() ?? "" : "";
var phone = root.TryGetProperty("phone", out var v2) ? v2.GetString() ?? "" : "";
ทำไมไม่ Deserialize<T> ตรงๆ? เพราะ JsonElement ยังผูกกับ doc — ถ้าอยาก map ใส่ model มี 2 ทางที่ปลอดภัย:
// ทาง 1: Deserialize จาก GetRawText() ภายใน using (doc) — ยังอยู่ใน scope
using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
var model = JsonSerializer.Deserialize<MyInputModel>(root.GetRawText()); // ✅
// ทาง 2: ข้าม JsonDocument ทั้งหมด — Deserialize จาก InputJson ตรงๆ
var wrapper = JsonSerializer.Deserialize<StepDataWrapper>(ctx.InputJson ?? "{}");
var model = wrapper?.StepData ?? wrapper; // ✅
บาง handler สร้าง private sealed record เพื่อรับค่า แต่ก็ยัง map ด้วยมือ ผ่าน TryGetProperty:
// PersonalInfoStepHandler — มี record แต่ map เอง ไม่มี auto-deserialize
private sealed record PersonalInfoRecord(string FirstNameTH, string LastNameTH, string Phone, ...);
var pi = new PersonalInfoRecord(
FirstNameTH: root.TryGetProperty("firstNameTH", out var v1) ? v1.GetString() ?? "" : "",
LastNameTH: root.TryGetProperty("lastNameTH", out var v2) ? v2.GetString() ?? "" : "",
Phone: root.TryGetProperty("phone", out var v5) ? v5.GetString() ?? "" : ""
);
8. ประเภท Step และ Handler ทั้งหมด
Lego Engine มี handler 20 ตัว แบ่งเป็น Interactive (รอ user) และ Automatic (engine run เอง) เข้าใจแต่ละตัวก่อนจะทำให้เห็นภาพว่า “เลโก้” แต่ละชิ้นมี spec อะไร
ตารางสรุป StepCatalog ทั้งหมด
| StepType | Mode | Flow ที่ใช้ | Validates | External Service |
|---|---|---|---|---|
OtpVerificationStep | Interactive | ทุก flow ที่ anonymous start | Email + OTP | Entra CIAM, OtpState |
JuristicBindingStep | Interactive | NEW/RETURNING_REQUESTOR | juristicId, CRM eligibility | DBD, CRM |
RequesterInfoStep | Interactive | Requestor flows | idType + idNo format | — |
CompanyInfoStep | Interactive | NEW/RETURNING_REQUESTOR | (เก็บ input ดิบ) | — |
UserModeStep | Interactive | Requestor flows | mode = “Single” เท่านั้น | — |
DesignateCustomersStep | Interactive | Requestor flows | ID/email format, limit ≤ cap | — |
ServiceTcConsentStep | Interactive | Requestor flows | agreed = true | — |
SignatoryFormStep | Interactive | Requestor flows | count 1-4, formDownloaded=true | — |
SubmitApplicationStep | Interactive (gate) | Requestor flows | required steps ครบ 6 ตัว | — |
ConsentStep | Interactive | Customer flows | agreed = true | — |
NdidStep | Interactive (stub) | STANDARD_CUSTOMER | always passes | — |
PersonalInfoStep | Interactive | Customer flows | firstName, lastName, phone | — |
SubmitRegistrationStep | Interactive (gate) | Customer flows | Consent + PersonalInfo ครบ | — |
CreatePlatformUser | Automatic | Customer flows | User exists, PersonalInfo ครบ | User repo |
ActivateEntraUser | Automatic (stub) | Customer flows | always passes | — |
CreateUserCompanyMapping | Automatic | CUSTOMER_FIXED | InvitationToken valid | InvitationToken repo |
ProvisionCustomerAccess | Automatic | CUSTOMER_FIXED | token lineage, designee match | CompanyRole, UserCompanyMapping |
CreateCompanyApplication | Automatic | Requestor flows | CompanyId + ServiceId | CompanyApplication repo |
GenerateCustomerInvitations | Automatic | Requestor flows | (list อาจว่าง) | InvitationToken repo |
SendInvitationEmails | Automatic | Requestor flows | (ใช้ GenerateCustomerInvitations) | IInvitationEventPublisher |
Interactive Steps — เจาะลึกทีละตัว
OtpVerificationStep — Identity Boundary (2-action)
Step นี้พิเศษกว่าตัวอื่น: 1 step มี 2 action แตกต่างกันโดยสิ้นเชิง
| Action | ทำอะไร | Output |
|---|---|---|
SaveDraft | ส่ง OTP → Entra SignUpStart | {refCode, status:"OTP_SENT", expirySeconds:300} |
Next | verify OTP → สร้าง User → BindOwner → rotate session | {status:"VERIFIED", userId:"<guid>"} |
SaveDraft (ส่ง OTP) flow:
- อ่าน email จาก EditSession (server-side — ไม่รับจาก client เพื่อป้องกัน account takeover)
- ค้นหา orphan identity ใน Entra (email เดิม) → deactivate ทิ้ง
- สร้าง OTP refCode ผ่าน
OtpStateClient - เรียก Entra
SignUpStart→ บันทึก continuation token
Next (verify OTP) flow:
- Verify OTP ผ่าน
OtpStateClient - เรียก Entra
SignUpContinueWithOtp→ ได้id_token→ แยกoid - Deactivate Entra identity ชั่วคราว (ป้องกัน orphan ก่อน
ActivateEntraUser) - สร้าง User minimal (email + oid) →
CreatePlatformUserจะ fill profile ทีหลัง ctx.Instance.BindOwner(userId)— instance เปลี่ยนจาก anonymous → owner-bound- Rotate EditSession (สร้างใหม่ + invalidate เก่า — anti session-fixation per ADR-0023)
JuristicBindingStep — CRM + DBD + Company Uniqueness
Step ที่ซับซ้อนที่สุดใน Requestor flow — มี 3 external calls + 3 uniqueness checks:
juristicId → DBD lookup → company info
→ CRM eligibility check → block ถ้าไม่ eligible
→ find-or-create Company (sync from DBD)
→ uniqueness check 1: same-requestor duplicate?
→ uniqueness check 2: cross-requestor in-progress?
→ uniqueness check 3: requestor already bound to company?
→ ctx.Instance.SetCompanyId(companyId)
OutputJson: {juristicId, companyId, nameTH, nameEN, juristicType, juristicStatus, address, creditLineCap}
DesignateCustomersStep — Validation หนักที่สุด
Validate ทุก customer ที่ Requestor ระบุมา:
| Rule | รายละเอียด |
|---|---|
| ≥1 customer | list ว่างไม่ได้ |
| idType + idNo | nationalId = 13 digits · passport = 6-9 chars |
| email format | regex validate |
| transactionLimit | > 0 และ ≤ creditLineCap จาก JuristicBindingStep |
| no duplicate | ไม่ซ้ำ idNo และ email ใน payload เดียวกัน |
| Single mode | force companyRole = "Maker" (อ่านจาก UserModeStep) |
Reads from ctx.StepData: JuristicBindingStep (creditLineCap), UserModeStep (mode)
OutputJson: Array ของ {idType, idNo, nameTH, nameEN, position, email, companyRole, transactionLimit}
SubmitApplicationStep — Dependency Gate (Requestor)
ตรวจว่า 6 step ครบทุกตัว ก่อน Submit:
required = ["JuristicBindingStep", "CompanyInfoStep", "UserModeStep",
"DesignateCustomersStep", "ServiceTcConsentStep", "SignatoryFormStep"]
ถ้าขาด step ใด → Fail("Step.DependencyMissing", "กรุณาทำ ... ให้ครบก่อน")
SubmitRegistrationStep — Dependency Gate (Customer)
required = ["ConsentStep", "PersonalInfoStep"]
PersonalInfoStep — ข้อมูลส่วนตัว (Customer)
Required: firstNameTH, lastNameTH, phone
Optional: firstNameEN, lastNameEN, nameTitleTH, nameTitleEN
NdidStep — Stub (ยังไม่ integrate จริง)
ปัจจุบัน stub: always return {status:"verified", verifiedAt:"<timestamp>"} ไม่ว่า input จะเป็นอะไร — จะ replace ด้วย NDID service จริงในอนาคต
Automatic Steps — เจาะลึกทีละตัว
Automatic step รับ InputJson = null (ไม่มี user input) — อ่านข้อมูลจาก ctx.StepData ของ step ก่อนหน้าแทน
CreatePlatformUser — Fill User Profile
ctx.StepData["PersonalInfoStep"] → firstName, lastName, phone, nameTitle
User minimal (สร้างตอน OTP) → update profile
Idempotent: ถ้า User ถูก create แล้วก็แค่ update profile — ไม่ duplicate
ActivateEntraUser — Stub
ปัจจุบัน stub: return {entraActivated:false, stub:true} — จะ replace ด้วย Microsoft Graph call เพื่อ activate identity ที่ถูก deactivate ตอน OTP
CreateUserCompanyMapping — ผูก Customer กับ Company (CUSTOMER_FIXED)
InvitationToken.CompanyId → สร้าง UserCompanyMapping (isDefault=true)
ผูก Customer user เข้ากับบริษัทที่ Requestor เชิญมา
CreateCompanyApplication — สร้าง CompanyApplication (Requestor)
instance.CompanyId + instance.ServiceId → สร้าง CompanyApplication
→ Lock Requestor ผ่าน CompanyRequestor junction table
→ Auto-reject competing in-progress application สำหรับ company+service เดิม (ถ้ามี)
Idempotent: ถ้ามี CompanyApplication อยู่แล้ว → skip ไม่ error
GenerateCustomerInvitations — สร้าง InvitationToken
ctx.StepData["DesignateCustomersStep"] → array of customers (email, role, limit)
FlowDefinition.SpawnsCustomerFlowCode → flow code สำหรับ customer
FlowDefinition.InvitationTtlDays → TTL ของ token (default 7 วัน)
→ สร้าง InvitationToken per customer (CompanyId + OriginatingFlowInstanceId)
OutputJson: {invitations:[{email, tokenId}]}
SendInvitationEmails — ส่งอีเมล
ctx.StepData["GenerateCustomerInvitations"] → list of {email, tokenId}
FlowDefinition.TemplateKey → email template
FlowDefinition.FrontendBaseUrl → base URL สำหรับ link
→ publish invitation event per email → NotificationService
Link format: {FrontendBaseUrl}/onboarding?code={tokenId}
ProvisionCustomerAccess — Set Role + Limit (CUSTOMER_FIXED)
Step สุดท้ายที่ซับซ้อนที่สุดใน Customer Fixed flow — ต้อง cross-reference ไป Requestor flow:
InvitationToken.OriginatingFlowInstanceId → Requestor's FlowInstance
→ อ่าน DesignateCustomersStep ของ Requestor
→ match customer by email
→ resolve companyRole enum
→ update UserCompanyMapping (role + transactionLimit)
8. การตั้งค่า FlowDefinition — ทุก Field มีผลอะไรที่ Runtime
Admin ตั้งค่าเหล่านี้ใน FlowStudio (หรือ DB โดยตรง) และ engine จะ อ่านค่าเหล่านี้ตอน runtime เพื่อปรับพฤติกรรม:
| Setting | ค่าตัวอย่าง | Runtime Effect |
|---|---|---|
RefNoIssueAt = "OnEnterDraft" | NEW_REQUESTOR | ออกเลขที่ใบสมัครตอน SaveDraft ครั้งแรก — ถ้า external service ล้มเหลว → RefNoPending=true → RefNoRetryWorker retry ทุก 5 นาที |
RefNoIssueAt = "OnEnterSubmitted" | — | ออกเลขที่ตอน Submit — ถ้าไม่มี RefNo ตอน Submit (และ RefNoIssueAt != null) → throw Application.RefNoUnavailable → 400 |
RefNoIssueAt = null | CUSTOMER_FIXED | ไม่ออกเลขที่เลย — Submit gate ข้ามการ check |
StaleDaysBeforeAbandon = 90 | — | AutoAbandonWorker (ทุก 6 ชม.) scan Draft instances ที่ไม่มีการแก้ >90 วัน → Abandon() |
StaleDaysBeforeAbandon = null | — | ปิด auto-abandon |
SpawnsCustomerFlowCode = "CUSTOMER_FIXED_ONBOARDING" | NEW_REQUESTOR | GenerateCustomerInvitations อ่านค่านี้เพื่อกำหนด FlowCode ใน InvitationToken |
SpawnsCustomerFlowCode = null | — | GenerateCustomerInvitations จะสร้าง token แต่ไม่รู้ว่าต้องให้ customer เริ่ม flow อะไร |
SessionTtlSeconds = 86400 | ทุก flow | EditSession cookie หมดอายุใน 1 วัน — sliding refresh ทุก action จะ reset ExpiresAt |
InvitationTtlDays = 7 | CUSTOMER_FIXED | InvitationToken หมดอายุใน 7 วัน — link ที่ส่งใน email ใช้ได้แค่ 7 วัน |
TemplateKey = "invitation_fx" | CUSTOMER_FIXED | SendInvitationEmailsHandler ส่ง event ไป NotificationService พร้อม template key นี้ |
FrontendBaseUrl = "https://app.exim.go.th" | — | ใช้ build invitation link: {FrontendBaseUrl}/onboarding?code={tokenId} |
IsActive = false | — | engine block การ Start instance ใหม่ — instance เก่ายังวิ่งต่อได้ |
9. Database — ทุก Table ทุก Field
FlowInstance เป็นศูนย์กลาง ผูกกับทุกตารางอื่น section นี้อธิบายทุก field ว่าเก็บอะไร เพื่ออะไร และ null ได้ไหม
FlowDefinitions — template ของ flow (admin config)
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัสระบุ FlowDefinition — ใช้ FK ใน FlowSteps + FlowInstances |
Code | text | ❌ | ชื่อย่อของ flow เช่น "STANDARD_CUSTOMER_ONBOARDING" — Frontend ส่ง code นี้ใน POST /onboarding/instances เพื่อบอกว่าจะเริ่ม flow ไหน |
Name | text | ❌ | ชื่อเต็มสำหรับแสดงใน Admin UI |
ServiceId | uuid | ✅ | null = platform-level (ทุก service ใช้ได้) · มีค่า = ผูกกับ Service เฉพาะเช่น FX |
RefNoIssueAt | text | ✅ | บอกว่าต้องออกเลขที่เอกสารเมื่อไร: null = ไม่ออกเลย · "OnEnterDraft" = ออกตอน SaveDraft ครั้งแรก · "OnEnterSubmitted" = ออกตอน Submit |
StaleDaysBeforeAbandon | int | ✅ | กี่วันถ้า instance ถูกทิ้งไว้แล้ว AutoAbandonWorker จะเปลี่ยนเป็น Abandoned อัตโนมัติ · null = ปิด feature นี้ |
SpawnsCustomerFlowCode | text | ✅ | หลัง approve แล้วให้สร้าง flow อีกตัวให้ customer อัตโนมัติ เช่น NEW_REQUESTOR approve → spawn CUSTOMER_FIXED_ONBOARDING ให้ customer แต่ละคน |
TemplateKey | text | ✅ | template key สำหรับ Email service ใช้ส่งอีเมลคำเชิญ |
FrontendBaseUrl | text | ✅ | Base URL ของ Frontend เพื่อ build invitation link ในอีเมล เช่น https://app.exim.go.th |
InvitationTtlDays | int | ✅ | invitation token มีอายุกี่วัน |
SessionTtlSeconds | int | ❌ | EditSession cookie หมดอายุในกี่วินาที (default 86400 = 1 วัน) |
IsActive | bool | ❌ | false = ปิดรับ instance ใหม่ (instance เก่ายังวิ่งต่อได้) |
StaleSubmittedWarnDays | int | ✅ | เตือน admin ถ้า instance ค้าง Submitted นาน N วัน |
StepCatalogs — ทะเบียน step type ทั้งหมด
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัส |
StepTypeCode | text | ❌ | ชื่อที่เป็น contract เช่น "PersonalInfoStep" — ต้องตรงกัน 3 ที่: ที่นี่ = handler.StepType = FE STEP_REGISTRY key (ผิดตัวอักษรเดียว engine หา handler ไม่เจอ) |
DisplayName | text | ❌ | ชื่อสำหรับแสดงใน Admin UI |
Description | text | ✅ | คำอธิบายสั้นๆ |
ExecutionMode | int | ❌ | 0=Interactive รอ user input · 1=Automatic engine run เอง ไม่ผ่าน user |
SkipScope | int | ❌ | skip eligibility: 0=Never · 1=PerUser · 2=PerUserPerCompany · 3=PerUserPerCompanyPerSvc ⚠️ ยังไม่ enforce (Phase 2) |
LockedAfterFirstExecution | bool | ❌ | true = ทำซ้ำไม่ได้ เช่น OTP, NDID ⚠️ ยังไม่ enforce (Phase 2) |
DependenciesJson | text | ✅ | JSON array ของ step ที่ต้องทำก่อน เช่น [{"StepTypeCode":"JuristicBindingStep","Mode":"Required"}] ⚠️ ยังไม่ validate (Phase 2) |
MaxRetries | int | ❌ | Automatic step: retry กี่ครั้งก่อนถือว่า stuck (default 0) |
RetryBackoffSeconds | int | ❌ | รอกี่วินาทีระหว่าง retry (default 30) |
FlowSteps — ลำดับ step ใน flow
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัส |
FlowDefinitionId | uuid FK | ❌ | ผูกกับ FlowDefinition ไหน |
StepCatalogId | uuid FK | ❌ | ผูกกับ StepCatalog ไหน |
Order | int | ❌ | ลำดับใน flow — engine เรียงตาม Order นี้ ห้ามซ้ำกันใน flow เดียวกัน เปลี่ยน Order = เปลี่ยนลำดับ step ใน DB โดยไม่ต้อง deploy |
StepType | text | ❌ | denormalize จาก StepCatalog.StepTypeCode — เก็บซ้ำเพื่อให้ Snapshot build เร็วโดยไม่ต้อง join |
CanGoBack | bool | ❌ | true = user กด Back กลับมา step นี้ได้ |
FlowInstances — 1 execution จริงของ flow
ตารางนี้สำคัญที่สุด — ทุก action ของ user อ่านและเขียนที่นี่:
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัสที่ Frontend เก็บไว้ใช้ทุก API call ตลอด session |
FlowDefinitionId | uuid FK | ❌ | สร้างมาจาก FlowDefinition ไหน |
Status | varchar | ❌ | สถานะปัจจุบัน — state machine: Draft → Submitted → Approved → Finalized / Rejected / CorrectionRequested / Abandoned |
OwnerUserId | uuid | ✅ | nullable — null = anonymous (ยังไม่ผ่าน OTP) · มีค่า = หลัง OtpVerificationStep เรียก instance.BindOwner() — เป็นหลักประกันว่า auth ผ่านแล้ว ถ้า null ต้องใช้ EditSession cookie ยืนยันแทน |
CurrentStepIndex | int | ❌ | เหมือน bookmark — index ใน FlowSnapshotJson.steps array ที่กำลัง active อยู่ · engine อ่านตัวนี้ทุก request เพื่อรู้ว่าต้อง execute step ไหนต่อ |
FlowSnapshotJson | jsonb | ❌ | สำคัญที่สุด — สำเนา FlowDefinition+Steps แช่แข็งตอนที่ instance ถูกสร้าง ทำให้แก้ FlowDefinition ทีหลังไม่กระทบ instance ที่วิ่งอยู่ — instance ต้องจบตาม “สัญญาเดิม” |
CompanyId | uuid | ✅ | บริษัทที่ผูกกับ instance — null ก่อน JuristicBindingStep · ใช้ uniqueness check: ไม่ให้ apply ซ้ำใน company+service เดิม |
ServiceId | uuid | ✅ | Service ที่ instance สังกัด เช่น FX service Id |
StuckAtStepType | varchar | ✅ | เมื่อ Automatic step fail: engine เขียน StepTypeCode ไว้ที่นี่ แทนที่จะ crash ทั้งระบบ · StuckChainRecoveryWorker scan ทุก 15 นาทีแล้ว retry |
RefNo | varchar | ✅ | เลขที่ใบสมัคร/เอกสาร ออกโดย Centralize Document Service · null จนกว่าถึงเวลา issue ตาม FlowDefinition.RefNoIssueAt |
RefNoPending | bool | ❌ | true = request ออก RefNo แล้วแต่ external service ล้มเหลว → RefNoRetryWorker retry ทุก 5 นาทีโดยอัตโนมัติ |
Archived | bool | ❌ | soft-delete flag — ซ่อนจาก list แต่ไม่ลบ record จริงเพื่อ audit trail |
SubmittedAt | timestamp | ✅ | เวลา Submit — ใช้ track SLA ว่าแต่ละ flow ใช้เวลาเท่าไร |
FinalizedAt | timestamp | ✅ | เวลา Finalized — ใช้ analytics |
FlowInstanceStepData — output ของแต่ละ step
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
FlowInstanceId | uuid | ❌ | Composite PK ส่วนที่ 1 |
StepType | varchar | ❌ | Composite PK ส่วนที่ 2 — ผลคือ 1 row ต่อ (instance, stepType) handler UpsertAsync ทำให้ทำซ้ำได้ |
DataJson | jsonb | ✅ | output ที่ handler คืนมา เก็บเป็น JSON raw · schema เป็นของแต่ละ handler (engine ไม่รู้ structure) · handler อื่นอ่านผ่าน ctx.StepData["StepType"] |
IsSkipped | bool | ❌ | true = ข้อมูล copy มาจาก record เก่า (skip pattern Phase 2) |
CompletedAt | timestamp | ✅ | เวลาที่ step execute สำเร็จ |
EditSessions — anonymous session ก่อน bind owner
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัส |
SessionId | varchar | ❌ | random token — เก็บใน __Host-onboarding cookie ของ browser · ใช้ยืนยันตัวตนแทน JWT สำหรับ anonymous user ก่อน OTP (กัน IDOR โดยไม่ต้องมี JWT) |
FlowInstanceId | uuid FK | ❌ | session นี้ผูกกับ instance ไหน |
UserId | uuid | ✅ | null = anonymous · มีค่า = หลัง OTP verify สำเร็จ (OtpVerificationStep rotate session + stamp UserId) |
Email | varchar | ❌ | email ที่ user กรอกตอนเริ่ม flow — OtpVerificationStep ใช้ส่ง OTP |
IsActive | bool | ❌ | false = หมดอายุหรือถูก rotate (สร้าง session ใหม่หลัง OTP สำเร็จ — anti session-fixation) |
ExpiresAt | timestamp | ❌ | เวลาหมดอายุ ตาม FlowDefinition.SessionTtlSeconds |
InvitationTokens — one-time token สำหรับ invite customer
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | text PK | ❌ | opaque token string — ส่งใน URL ?code=<token> ของ invitation link |
Email | text | ❌ | email ของ customer ที่ถูก invite |
FlowCode | text | ❌ | flow ที่ customer จะเริ่มเมื่อ click invitation link เช่น CUSTOMER_FIXED_ONBOARDING |
Status | text | ❌ | PENDING → CONSUMED / REVOKED / EXPIRED |
CreatedByAdminId | uuid | ❌ | Admin ที่สร้าง invitation |
ExpiresAt | timestamp | ❌ | วันหมดอายุ — link เก่ากว่านี้ใช้ไม่ได้ |
ConsumedAt | timestamp | ✅ | เมื่อ customer ใช้ token สำเร็จ |
CompanyId | uuid | ✅ | บริษัทที่ customer จะ join |
OriginatingFlowInstanceId | uuid | ✅ | FlowInstance ของ Requestor ที่สร้าง invitation — ProvisionCustomerAccess (Automatic step) ใช้อ่าน DesignateCustomersStep ของ Requestor เพื่อ set role/limit ให้ customer |
ConsumedByFlowInstanceId | uuid | ✅ | FlowInstance ของ customer ที่ใช้ token นี้แล้ว — ใช้ track lineage |
CorrectionRequests — ประวัติคำขอแก้ไข (append-only)
| Field | Type | ค่าว่างได้? | อธิบาย |
|---|---|---|---|
Id | uuid PK | ❌ | รหัส |
FlowInstanceId | uuid FK | ❌ | instance ที่ถูกขอให้แก้ไข |
ApprovalDecisionId | uuid | ❌ | รหัสจาก approval workflow — unique index ป้องกันเพิ่ม correction request ซ้ำ (idempotent) |
NoteText | varchar | ✅ | ข้อความอธิบายจาก approver ว่าต้องการให้แก้อะไร |
TargetStepsJson | jsonb | ❌ | รายชื่อ StepTypeCode ที่ปลดล็อกให้แก้ได้ · step อื่นที่ไม่อยู่ใน list จะเป็น read-only ระหว่างแก้ไข |
RequestedAt | timestamp | ❌ | เวลาที่ approver ส่ง correction request |
RequestedFromStatus | varchar | ❌ | status ณ ตอนที่ correction ถูกขอ |
8. Use Cases / Scenarios
A · Happy path (STANDARD_CUSTOMER_ONBOARDING) — เดินครบจน Finalized
B · Validation fail → field ว่าง → handler Fail("Step.RequiredField", "...") → engine throw FlowEngineException → catch → 400 + ข้อความไทย (ผู้ใช้เข้าใจ ไม่ใช่ SYS001)
C · Unhandled exception → ObjectDisposedException (เคสที่แก้ใน sprint) → หลุดถึง GlobalExceptionHandler → 500 SYS001 generic — แต่ stack trace ถูก log ฝั่ง server (มี traceId ใน problem+json)
D · Resume จาก cookie → เปิด /onboarding ใหม่ → GET /onboarding/session:
// resume เฉพาะ flow ที่ยัง in-progress; instance ที่จบ/ส่งแล้ว = "ไม่มี session" → 404 → FE เริ่มใหม่
if (instance.Status is not (FlowInstanceStatus.Draft or FlowInstanceStatus.CorrectionRequested))
return NoSession();
9. วิธีเพิ่ม Step ใหม่ — สรุปย่อ
การเพิ่ม step ใหม่แตะ 4 จุด backend + 2 จุด frontend และทั้งหมดผูกกันด้วย string เดียวคือ stepType:
// Template handler — StepType ต้องตรงกับ StepCatalog.StepTypeCode และ FE STEP_REGISTRY key เป๊ะ
public sealed class MyNewStepHandler : IFlowStepHandler
{
public string StepType => "MyNewStep"; // ← string นี้คือ contract ทั้งหมด
public Task<FlowStepExecutionResult> ExecuteAsync(FlowStepExecutionContext ctx, CancellationToken ct = default)
{
using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
// validate ที่นี่ — อ่านค่าให้เสร็จก่อน doc ถูก dispose
var val = GetStr(root, "requiredField");
if (string.IsNullOrWhiteSpace(val))
return Task.FromResult(FlowStepExecutionResult.Fail("Step.RequiredField", "กรุณากรอกข้อมูล"));
// อ่าน output ของ step อื่นผ่าน StepType (ไม่ใช่ index)
var prev = ctx.StepData.GetValueOrDefault("JuristicBindingStep");
var output = JsonSerializer.Serialize(new { val });
return Task.FromResult(FlowStepExecutionResult.Ok(output));
}
}
10. สรุปสิ่งที่ทำใน Sprint นี้
- engine generic (
FlowEngine) — Start / ExecuteAction / AdvanceAutomatic + state machine (Draft→Submitted→Approved→Finalized/…) - endpoint เดียวขับทุก step (
POST .../steps/{stepType}/actions/{action}) + guard 2 ทาง (owner / EditSession) - handler ต่อ step (Consent / NDID / PersonalInfo / Otp identity / Automatic chain)
- error model — Fail()→FlowEngineException→400 ไทย · unhandled→500 SYS001 (+log)
- resume guard — instance ที่จบแล้วไม่ resume (404)
- บั๊กที่แก้ — PersonalInfoStep
ObjectDisposedException(JsonElement หลุด using scope) → 500 หาย