จองนัดหมาย
ภาพรวม
ระบบจองนัดหมายเป็น flow แบบหลายขั้นตอนที่แอดมินออกแบบไว้จาก CMS ในรูปของ "journey" ลำดับมาตรฐานคือ เลือกบริการ เลือกผู้ให้บริการ เลือกวันและเวลา กรอกข้อมูลติดต่อ แล้วยืนยัน โดยแต่ละขั้นตอนสามารถเปิดปิดและจัดลำดับได้อิสระ หน้าเว็บอ่านค่าตั้งค่า เหล่านี้แล้วประกอบหน้าจอตามที่กำหนด จึงใช้ได้กับหลายรูปแบบธุรกิจ เช่น คลินิก ร้านเสริมสวย หรือบริการที่ต้องนัดล่วงหน้า
หน้าตาเป็นแบบ accordion ขั้นตอนที่ทำเสร็จแล้วจะยุบลงเป็นบรรทัดสรุปสีเขียวซึ่งกด กลับไปแก้ได้ ขั้นตอนปัจจุบันขยายอยู่ และขั้นตอนถัดไปแสดงเป็นสีจาง ทำให้ผู้ใช้เห็นภาพรวมทั้ง flow ตลอดเวลา
Business Flow
- ผู้ใช้เปิด
/{hash}/booking/{token}โดยtokenคือรหัสของ journey - ระบบดึงข้อมูล OA จาก hash แล้วเริ่มต้น LIFF เข้าสู่ระบบหากจำเป็น และดึง ID token, access token พร้อมโปรไฟล์ผู้ใช้
- โหลดค่าตั้งค่า journey จาก API — โค้ดรองรับทั้งโครงสร้างแบบซ้อนชั้น (มี journey, สถานที่, รายการบริการ และผู้ให้บริการแยกกัน) และแบบแบนราบ เพื่อความยืดหยุ่นระหว่างที่รูปแบบข้อมูลฝั่ง API ยังปรับอยู่
- คำนวณลำดับขั้นตอนที่จะแสดง: หากค่าตั้งค่าให้มาเป็นรายการอ็อบเจ็กต์ ระบบจะคัดเฉพาะขั้นตอนที่เปิดใช้งานแล้วเรียงตามลำดับที่กำหนด หากให้มาเป็นรายการ ชื่อขั้นตอนก็ใช้ตามนั้น และหากไม่ได้กำหนดเลย จะใช้ลำดับพื้นฐานคือ เลือกบริการ เลือกวันเวลา กรอกข้อมูล และยืนยัน
- รายละเอียดของแต่ละขั้นตอน:
- เลือกบริการ — เลือกจากรายการบริการที่ journey กำหนดไว้
- เลือกผู้ให้บริการ — เลือกพนักงานที่ต้องการ หรือปล่อยว่างเพื่อให้ระบบ จัดคนที่ว่างให้
- เลือกวันและเวลา — ระบบดึงรายการวันที่ยังว่าง แล้วดึงช่วงเวลาของวันที่เลือก เมื่อเลือกครบทั้งวันและเวลา ระบบจะเลื่อนไปขั้นตอนถัดไปให้อัตโนมัติ โดยหน่วงสั้น ๆ เพื่อให้ผู้ใช้เห็นว่าเลือกอะไรไป
- กรอกข้อมูล — ฟอร์มที่สร้างขึ้นตาม field ที่กำหนดใน journey และเติมค่าเริ่มต้นจากโปรไฟล์ LINE ได้ ปุ่มไปต่อจะปิดไว้จนกรอกช่องที่บังคับครบ
- ยืนยัน — สรุปทุกอย่างที่เลือกไว้พร้อมปุ่มยืนยันการจอง
- เมื่อกดยืนยัน ระบบส่งคำขอจองพร้อม header ยืนยันตัวตน โดยแนบบริการ ผู้ให้บริการ วันที่ เวลาเริ่ม และข้อมูลจากฟอร์ม
- หากสำเร็จ จะแสดงหน้ายืนยันการจองซึ่งสรุปรายละเอียดพร้อมปุ่มปิดหน้าต่าง LIFF
- หากช่วงเวลาที่เลือกถูกจองไปก่อนแล้ว (สถานะ 409) ระบบจะล้างวันและเวลาที่เลือก แล้วพากลับไปขั้นตอนเลือกวันเวลาพร้อมข้อความแจ้ง — ข้อผิดพลาดอื่นจะพาไปหน้าแสดงข้อผิดพลาด
หน้าจอและองค์ประกอบหลัก
หน้าจองอยู่ที่ /{hash}/booking/{token} โดยองค์ประกอบอยู่ใต้
src/app/[hash]/booking/[token]/:
- หน้าหลัก (
page.tsx) — เป็นตัวควบคุมทั้ง flow ทำหน้าที่คำนวณลำดับขั้นตอน ตรวจว่าขั้นตอนใดเสร็จแล้ว สร้างข้อความสรุปของแต่ละขั้นตอน เลือกคอมโพเนนต์ที่จะเรนเดอร์ และจัดการการยืนยัน สถานะของหน้าแบ่งเป็นกำลังโหลด พร้อมใช้งาน สำเร็จ และผิดพลาด - แถบความคืบหน้า (
BookingProgress) — แสดงว่าผู้ใช้อยู่ขั้นตอนที่เท่าไร - คอมโพเนนต์ของแต่ละขั้นตอน —
ServiceStep,StaffStep,DateTimeStep,InfoStepและConfirmStep - หน้ายืนยันผลการจอง (
BookingConfirmation) — สรุปการจองและปิดหน้าต่าง LIFF
การเรียก API รวมอยู่ที่ src/service/appointment-booking.service.ts
ครอบคลุมการดึง journey ดึงวันที่ว่าง ดึงช่วงเวลา และสร้างการจอง
Endpoint ที่ใช้
| Method | Path |
|---|---|
| GET | /appointment/public/{token} |
| GET | /appointment/public/{token}/dates?serviceId=&staffId=&daysAhead= |
| GET | /appointment/public/{token}/slots?date=&serviceId=&staffId= |
| POST | /appointment/public/{token}/book |
จุดเชื่อมต่อกับฟีเจอร์อื่น
- LIFF SDK — ฟีเจอร์นี้เรียก LIFF โดยตรง ไม่ได้ผ่าน hook กลางของโปรเจกต์ เช่นเดียวกับฟีเจอร์ติดตามการเพิ่มเพื่อน
- Ant Design — ใช้
Spinและชุดไอคอนของ Ant Design - สไตล์ของหน้านี้เขียนด้วย inline style เป็นหลัก และใช้สีแบรนด์ LINE เป็นสีหลัก เพื่อให้กลืนกับบริบทที่เปิดอยู่ใน LINE
- ยังไม่มีการกำหนด type ที่ชัดเจนสำหรับค่าตั้งค่า journey เพราะรูปแบบข้อมูลฝั่ง API ยังเปลี่ยนแปลงอยู่ — จุดนี้เป็นหนี้ทางเทคนิคที่ควรตามเก็บเมื่อ API นิ่งแล้ว
- หน้ายืนยันการจอง เป็นต้นแบบของรูปแบบการปิดหน้าต่าง LIFF ที่ถูกนำไปใช้ต่อ ในหน้าขอบคุณของระบบฟอร์ม (ดู form-thank-you)
รายละเอียดฝั่ง Backend (Client API)
3 endpoint แรกไม่ต้องยืนยันตัวตน
GET /appointment/public/:token, /slots และ /dates เป็นแบบ อ่านอย่างเดียวและไม่ต้อง
ยืนยันตัวตน ต่างจาก endpoint จองที่ต้องมี token ทุกตัวเริ่มด้วยการ resolve journey ที่ยัง
active จาก public_token ก่อน ไม่พบ → 404 "Journey not found" แล้วโหลดสาขา (ไม่พบ →
404 "Location not found") พร้อมรายการบริการและพนักงานที่ active ของสาขานั้น
serviceId ที่ parse ไม่ได้จะกลายเป็น 0 ซึ่งไม่ match แถวใด และคืนรายการว่างแทนที่จะเป็น error
ส่วน daysAhead ของ endpoint รายการวันว่างมีค่า default 30 วัน โดยนับ รวมวันนี้ และใช้
รูปแบบวันที่ YYYY-MM-DD แบบ UTC
เครื่องคำนวณช่วงเวลาว่าง (SlotEngine)
นี่คือหัวใจของฝั่งอ่าน เป็น pure logic ที่ทดสอบแยกจาก HTTP ได้ ทำงาน 7 ขั้น:
- โหลดค่าตั้งค่าของสาขา (เวลาทำการ, วันที่ปิด) — ไม่มีแถว → คืนรายการว่าง
- วันนั้นอยู่ในรายการวันที่ปิด → คืนรายการว่าง
- หา weekday จากวันที่โดย parse เป็นเที่ยงคืน UTC
- อ่านเวลาเปิด/ปิดจากค่าตั้งค่าซึ่งรองรับ 2 รูปแบบ ทั้งแบบ array รายวันและแบบ object ที่ใช้ชื่อวันเป็น key และรองรับชื่อ field ที่ต่างกันได้ — วันที่ไม่มีค่าตั้งค่าหรือถูกปิดไว้ → คืนรายการว่าง
- โหลดระยะเวลาบริการและจำนวนคิวสูงสุดต่อช่วง — ไม่มี → คืนรายการว่าง
- สร้างช่วงเวลาโดยก้าวจากเวลาเปิดทีละความยาวของบริการ และ emit เฉพาะช่วงที่จบก่อนเวลาปิด (บริการ 90 นาทีจึงไม่มีช่วงที่คร่อมเวลาปิดร้าน)
- นับการจองที่ ซ้อนทับ กับแต่ละช่วง (ไม่ใช่แค่ที่เริ่มตรงกัน) เต็มแล้วส่งกลับเป็นช่วงที่ ไม่ว่างพร้อมเหตุผล ว่างก็ส่งจำนวนคิวที่เหลือมาด้วย
ข้อสังเกตสำคัญ: engine ไม่สนใจพนักงานเลย พารามิเตอร์ staffId ถูกส่งเข้ามาแต่ไม่ถูกใช้
การเช็คว่าพนักงานว่างหรือไม่เกิดขึ้นที่ขั้น auto-assign ตอนจองเท่านั้น — ดังนั้นช่วงเวลาที่หน้าเว็บ
เห็นเป็น "ว่าง" คือว่างในระดับสาขา ไม่ใช่ว่างของพนักงานคนที่เลือก
POST /appointment/public/:token/book (rate limit 5/60s ต่อ IP)
- การยืนยันตัวตน — ถ้ามี
x-liff-tokenใช้การ verify access token (ลองค่าที่มาจาก header access token ก่อน แล้ว fallback เป็นค่าในx-liff-token); ถ้ามีแต่x-liff-access-tokenก็ใช้ตัวนั้น; ไม่มีทั้งคู่ → 401"No authentication token provided"- ข้อควรรู้ด้านความปลอดภัย: เส้นทางนี้ไม่มี channel binding ต่างจากบอร์ดประกาศและ บัตรสะสมแต้มที่ผูก token กับ LINE Login channel ของ OA ก่อนเสมอ ที่นี่ยัง verify กับ LINE Platform ตรงๆ ตามพฤติกรรมของระบบเดิม
- body ที่ว่างไม่ถือเป็น error (ทุก field เป็นค่าว่าง) แต่ JSON ที่พังรูป → 400
"Invalid request body"— route นี้ไม่มี DTO validation ตามระบบเดิม - resolve journey (ไม่พบ → 404) แล้วโหลดบริการตาม
serviceId(ไม่พบ → 404"Service not found") - จัดพนักงานอัตโนมัติ เมื่อผู้จองไม่ได้เลือกมาและบริการนั้นกำหนดว่าต้องมีพนักงาน: ดึงพนักงานที่มีสิทธิ์ในสาขา กรองเฉพาะคนที่ทำบริการนี้ได้ (การเช็ครายการบริการรับทั้งค่า ตัวเลขและสตริง เพราะข้อมูลเดิมปนกันทั้งสองแบบ) แล้วเลือกคนแรกที่ไม่มีคิวซ้อนในวันนั้น — ถ้าไม่มีใครว่างเลย การจองยังเกิดขึ้นโดยไม่มีพนักงานผูก ไม่ใช่ปฏิเสธการจอง
- ตรวจว่าช่วงเวลายังว่างอยู่จริงอีกครั้ง ด้วย engine เดียวกัน — ไม่ว่าง → 409
"Selected time slot is no longer available"นี่คือด่านกันสองคนกดจองพร้อมกัน และเป็นที่มาของสถานะ 409 ที่หน้าเว็บใช้พากลับไปเลือกเวลาใหม่ - คำนวณเวลาสิ้นสุดจากเวลาเริ่มบวกระยะเวลาบริการ
- สถานะเริ่มต้นขึ้นกับค่า auto-confirm ของ journey: เปิด →
confirmedไม่งั้นpending - บันทึกการจองพร้อมบริบททั้งหมด (journey, สาขา, บริการ, พนักงาน, ผู้ใช้, OA, องค์กร, วันที่, เวลาเริ่ม/จบ, สถานะ, หมายเหตุ และข้อมูลจากฟอร์ม)
ผลข้างเคียง — event 2 ตัวเข้า RabbitMQ
หลังบันทึกสำเร็จ ระบบ publish message 2 ตัว แบบ best-effort โดยกลืน error ทั้งหมด RabbitMQ ล่มจึงไม่ทำให้การจองล้ม:
- คิวแจ้งเตือนการจอง — พา id ของการจองและบริบทหลักไปให้ระบบส่งข้อความแจ้งลูกค้า
- คิว trigger event — พาชนิดเหตุการณ์
booking_createdพร้อมรายละเอียดที่มากกว่า (รวมชื่อบริการและช่วงเวลา) ไปให้ระบบ automation ที่ผูก workflow ไว้กับการจอง
ผู้บริโภคทั้งสองคิวคือ worker service ดังนั้นการจองที่สำเร็จแต่ลูกค้าไม่ได้รับข้อความ เป็นอาการที่ต้องไปตามที่ฝั่ง worker ไม่ใช่ที่ endpoint นี้
ข้อสังเกตอื่น
- route จองนัดไม่มี middleware เปิด/ปิดแอปรายองค์กร ต่างจากบอร์ดประกาศและบัตรสะสมแต้ม
เพราะ route ใช้
:tokenของ journey ไม่ใช่:hashของ OA จึงไม่มีจุดให้ middleware resolve องค์กร — การปิดแอป appointment จากฝั่ง platform admin จึงไม่ได้ปิดลิงก์จองสาธารณะ - endpoint จองคืน entity ของการจองที่บันทึกแล้วด้วยสถานะ 201