Skip to main content

จองนัดหมาย

ภาพรวม

ระบบจองนัดหมายเป็น flow แบบหลายขั้นตอนที่แอดมินออกแบบไว้จาก CMS ในรูปของ "journey" ลำดับมาตรฐานคือ เลือกบริการ เลือกผู้ให้บริการ เลือกวันและเวลา กรอกข้อมูลติดต่อ แล้วยืนยัน โดยแต่ละขั้นตอนสามารถเปิดปิดและจัดลำดับได้อิสระ หน้าเว็บอ่านค่าตั้งค่า เหล่านี้แล้วประกอบหน้าจอตามที่กำหนด จึงใช้ได้กับหลายรูปแบบธุรกิจ เช่น คลินิก ร้านเสริมสวย หรือบริการที่ต้องนัดล่วงหน้า

หน้าตาเป็นแบบ accordion ขั้นตอนที่ทำเสร็จแล้วจะยุบลงเป็นบรรทัดสรุปสีเขียวซึ่งกด กลับไปแก้ได้ ขั้นตอนปัจจุบันขยายอยู่ และขั้นตอนถัดไปแสดงเป็นสีจาง ทำให้ผู้ใช้เห็นภาพรวมทั้ง flow ตลอดเวลา

Business Flow

  1. ผู้ใช้เปิด /{hash}/booking/{token} โดย token คือรหัสของ journey
  2. ระบบดึงข้อมูล OA จาก hash แล้วเริ่มต้น LIFF เข้าสู่ระบบหากจำเป็น และดึง ID token, access token พร้อมโปรไฟล์ผู้ใช้
  3. โหลดค่าตั้งค่า journey จาก API — โค้ดรองรับทั้งโครงสร้างแบบซ้อนชั้น (มี journey, สถานที่, รายการบริการ และผู้ให้บริการแยกกัน) และแบบแบนราบ เพื่อความยืดหยุ่นระหว่างที่รูปแบบข้อมูลฝั่ง API ยังปรับอยู่
  4. คำนวณลำดับขั้นตอนที่จะแสดง: หากค่าตั้งค่าให้มาเป็นรายการอ็อบเจ็กต์ ระบบจะคัดเฉพาะขั้นตอนที่เปิดใช้งานแล้วเรียงตามลำดับที่กำหนด หากให้มาเป็นรายการ ชื่อขั้นตอนก็ใช้ตามนั้น และหากไม่ได้กำหนดเลย จะใช้ลำดับพื้นฐานคือ เลือกบริการ เลือกวันเวลา กรอกข้อมูล และยืนยัน
  5. รายละเอียดของแต่ละขั้นตอน:
    • เลือกบริการ — เลือกจากรายการบริการที่ journey กำหนดไว้
    • เลือกผู้ให้บริการ — เลือกพนักงานที่ต้องการ หรือปล่อยว่างเพื่อให้ระบบ จัดคนที่ว่างให้
    • เลือกวันและเวลา — ระบบดึงรายการวันที่ยังว่าง แล้วดึงช่วงเวลาของวันที่เลือก เมื่อเลือกครบทั้งวันและเวลา ระบบจะเลื่อนไปขั้นตอนถัดไปให้อัตโนมัติ โดยหน่วงสั้น ๆ เพื่อให้ผู้ใช้เห็นว่าเลือกอะไรไป
    • กรอกข้อมูล — ฟอร์มที่สร้างขึ้นตาม field ที่กำหนดใน journey และเติมค่าเริ่มต้นจากโปรไฟล์ LINE ได้ ปุ่มไปต่อจะปิดไว้จนกรอกช่องที่บังคับครบ
    • ยืนยัน — สรุปทุกอย่างที่เลือกไว้พร้อมปุ่มยืนยันการจอง
  6. เมื่อกดยืนยัน ระบบส่งคำขอจองพร้อม header ยืนยันตัวตน โดยแนบบริการ ผู้ให้บริการ วันที่ เวลาเริ่ม และข้อมูลจากฟอร์ม
  7. หากสำเร็จ จะแสดงหน้ายืนยันการจองซึ่งสรุปรายละเอียดพร้อมปุ่มปิดหน้าต่าง LIFF
  8. หากช่วงเวลาที่เลือกถูกจองไปก่อนแล้ว (สถานะ 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 ที่ใช้

MethodPath
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 ขั้น:

  1. โหลดค่าตั้งค่าของสาขา (เวลาทำการ, วันที่ปิด) — ไม่มีแถว → คืนรายการว่าง
  2. วันนั้นอยู่ในรายการวันที่ปิด → คืนรายการว่าง
  3. หา weekday จากวันที่โดย parse เป็นเที่ยงคืน UTC
  4. อ่านเวลาเปิด/ปิดจากค่าตั้งค่าซึ่งรองรับ 2 รูปแบบ ทั้งแบบ array รายวันและแบบ object ที่ใช้ชื่อวันเป็น key และรองรับชื่อ field ที่ต่างกันได้ — วันที่ไม่มีค่าตั้งค่าหรือถูกปิดไว้ → คืนรายการว่าง
  5. โหลดระยะเวลาบริการและจำนวนคิวสูงสุดต่อช่วง — ไม่มี → คืนรายการว่าง
  6. สร้างช่วงเวลาโดยก้าวจากเวลาเปิดทีละความยาวของบริการ และ emit เฉพาะช่วงที่จบก่อนเวลาปิด (บริการ 90 นาทีจึงไม่มีช่วงที่คร่อมเวลาปิดร้าน)
  7. นับการจองที่ ซ้อนทับ กับแต่ละช่วง (ไม่ใช่แค่ที่เริ่มตรงกัน) เต็มแล้วส่งกลับเป็นช่วงที่ ไม่ว่างพร้อมเหตุผล ว่างก็ส่งจำนวนคิวที่เหลือมาด้วย

ข้อสังเกตสำคัญ: engine ไม่สนใจพนักงานเลย พารามิเตอร์ staffId ถูกส่งเข้ามาแต่ไม่ถูกใช้ การเช็คว่าพนักงานว่างหรือไม่เกิดขึ้นที่ขั้น auto-assign ตอนจองเท่านั้น — ดังนั้นช่วงเวลาที่หน้าเว็บ เห็นเป็น "ว่าง" คือว่างในระดับสาขา ไม่ใช่ว่างของพนักงานคนที่เลือก

POST /appointment/public/:token/book (rate limit 5/60s ต่อ IP)

  1. การยืนยันตัวตน — ถ้ามี 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 ตรงๆ ตามพฤติกรรมของระบบเดิม
  2. body ที่ว่างไม่ถือเป็น error (ทุก field เป็นค่าว่าง) แต่ JSON ที่พังรูป → 400 "Invalid request body" — route นี้ไม่มี DTO validation ตามระบบเดิม
  3. resolve journey (ไม่พบ → 404) แล้วโหลดบริการตาม serviceId (ไม่พบ → 404 "Service not found")
  4. จัดพนักงานอัตโนมัติ เมื่อผู้จองไม่ได้เลือกมาและบริการนั้นกำหนดว่าต้องมีพนักงาน: ดึงพนักงานที่มีสิทธิ์ในสาขา กรองเฉพาะคนที่ทำบริการนี้ได้ (การเช็ครายการบริการรับทั้งค่า ตัวเลขและสตริง เพราะข้อมูลเดิมปนกันทั้งสองแบบ) แล้วเลือกคนแรกที่ไม่มีคิวซ้อนในวันนั้น — ถ้าไม่มีใครว่างเลย การจองยังเกิดขึ้นโดยไม่มีพนักงานผูก ไม่ใช่ปฏิเสธการจอง
  5. ตรวจว่าช่วงเวลายังว่างอยู่จริงอีกครั้ง ด้วย engine เดียวกัน — ไม่ว่าง → 409 "Selected time slot is no longer available" นี่คือด่านกันสองคนกดจองพร้อมกัน และเป็นที่มาของสถานะ 409 ที่หน้าเว็บใช้พากลับไปเลือกเวลาใหม่
  6. คำนวณเวลาสิ้นสุดจากเวลาเริ่มบวกระยะเวลาบริการ
  7. สถานะเริ่มต้นขึ้นกับค่า auto-confirm ของ journey: เปิด → confirmed ไม่งั้น pending
  8. บันทึกการจองพร้อมบริบททั้งหมด (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