Skip to main content

บัตรสะสมแต้ม (พนักงานหน้าร้าน)

ภาพรวม

หน้านี้เป็นเครื่องมือสำหรับพนักงานหน้าเคาน์เตอร์ ซึ่งเปิดจากบัญชี LINE ของตัวเอง ใช้ทำสองอย่าง:

  1. ออก QR ให้ลูกค้าสแกนรับแต้ม โดยระบุจำนวนดวงหรือยอดเงินที่ลูกค้าจ่าย
  2. สแกนคูปองของลูกค้าเพื่อตัดการใช้รางวัล

จุดออกแบบที่สำคัญที่สุดคือ การกันสิทธิ์แบบ fail closed แผงออก QR จะแสดงเฉพาะ เมื่อสถานะพนักงานเป็น "ใช้งานได้" เท่านั้น สถานะอื่นทั้งหมด รวมถึงสถานะใหม่ที่ แอปเวอร์ชันเก่ายังไม่รู้จัก จะถูกส่งไปหน้าขอสิทธิ์ แนวทางนี้ป้องกันไม่ให้ build เก่า ที่ค้างอยู่บนมือถือของใครเปิดสิทธิ์ให้โดยไม่ตั้งใจ

Business Flow

การเข้าถึงและการรับสิทธิ์ (เชิญเท่านั้น)

  1. พนักงานเปิด /{hash}/loyalty-staff ซึ่งบางครั้งมีพารามิเตอร์ลิงก์เชิญติดมาด้วย

  2. ระบบยืนยันตัวตน แล้วสอบถามสถานะพนักงานของบัญชีนี้จาก API

  3. แสดงหน้าจอตามสถานะ:

    สถานะหน้าจอที่แสดง
    ใช้งานได้แผงออก QR พร้อมใช้งาน
    รออนุมัติข้อความว่ากำลังรอผู้ดูแลอนุมัติ
    ถูกยกเลิกสิทธิ์ข้อความว่าสิทธิ์ถูกยกเลิก โดยไม่มีปุ่มขอใหม่ ต้องติดต่อเจ้าของร้าน
    ไม่พบสิทธิ์ หรือสถานะอื่นข้อความว่าต้องใช้ลิงก์เชิญ
  4. หากมีพารามิเตอร์ลิงก์เชิญและยังไม่พบสิทธิ์ ระบบจะ ขอรับสิทธิ์ให้อัตโนมัติทันที ไม่ต้องให้พนักงานกดยืนยันซ้ำอีกครั้ง — หากบัญชีนี้เป็นพนักงานอยู่แล้ว หรือลิงก์ใช้ไม่ได้แล้ว จะมีข้อความแจ้งแยกกรณี

  5. token ของลิงก์เชิญอยู่รอดผ่านการนำทางไปเข้าสู่ระบบ LINE ได้ เพราะหน้าเก็บ URL แบบเต็มรวม query ไว้เป็นปลายทางย้อนกลับ

การออก QR ให้ลูกค้า

  1. เลือกสาขา — ตัวเลือกนี้แสดงเฉพาะเมื่อร้านมีมากกว่าหนึ่งสาขา
  2. ใส่ค่าตามโหมดของโปรแกรม:
    • โหมดแสตมป์ — ใช้ปุ่มเพิ่ม/ลดเพื่อกำหนดจำนวนดวง (1 ถึง 20 ดวง)
    • โหมดแต้ม — ใช้แป้นตัวเลขใส่ยอดที่ลูกค้าจ่าย (ไม่เกิน 1,000,000) แล้วระบบแสดงตัวอย่างว่าลูกค้าจะได้กี่แต้ม โดยปัดลงให้ตรงกับวิธีคำนวณ ฝั่ง server หากร้านยังไม่ได้ตั้งอัตราแลกเปลี่ยน จะเตือนให้แจ้งเจ้าของร้าน
  3. กดสร้าง QR ระบบขอ token จาก API โดยส่งสาขาพร้อมจำนวนดวงหรือยอดเงิน — ในโหมดแต้ม ฝั่งหน้าเว็บไม่เคยส่งจำนวนแต้มไปเอง server เป็นผู้แปลงยอดเงิน เป็นแต้ม เพราะค่าที่คำนวณฝั่งหน้าเว็บถูกแก้ได้
  4. แสดง QR โดยค่าที่บรรจุใน QR คือลิงก์ LIFF ที่พาไปหน้าบัตรพร้อม token หากทราบ LIFF ID — ข้อดีคือกล้องมือถือทั่วไปเปิดได้เลย ลูกค้าใหม่ไม่ต้องเพิ่ม OA เป็นเพื่อนก่อน หากไม่ทราบ LIFF ID จะบรรจุ token เปล่าซึ่งสแกนได้จากในแอปบัตรเท่านั้น และข้อความใต้ QR จะเปลี่ยนตามให้สอดคล้อง
  5. เวลานับถอยหลังอ้างจากเวลาหมดอายุที่ server ส่งมา ไม่ใช่นาฬิกาของเครื่อง เพื่อไม่ให้เครื่องที่หลับไประหว่างธุรกรรมแสดงเวลาเหลือของโค้ดที่หมดอายุแล้ว

การรับรางวัลของลูกค้า

พนักงานกดสแกนคูปองของลูกค้า ระบบเปิดสแกนเนอร์ แปลงโค้ดเป็นตัวพิมพ์ใหญ่ แล้วส่งไปตัดการใช้ กรณีที่พบบ่อยมีข้อความแยกไว้ชัดเจน คือรางวัลนี้ถูกใช้ไปแล้ว รางวัลหมดอายุแล้ว และไม่พบรางวัลนี้

หน้าจอและองค์ประกอบหลัก

หน้านี้อยู่ที่ /{hash}/loyalty-staff โดยองค์ประกอบอยู่ใต้ src/app/[hash]/loyalty-staff/:

  • ตัวควบคุมหน้า (staff.container.tsx) — ตรวจสถานะพนักงาน เลือกหน้าจอที่จะแสดง จัดการการขอรับสิทธิ์อัตโนมัติ และมีตัวช่วยปิดหน้าต่าง LIFF
  • แผงออก QR (components/IssuePanel.tsx) — เลือกสาขา ใส่จำนวนดวงหรือยอดเงิน สร้าง QR แสดงเวลานับถอยหลัง และปุ่มสแกนคูปองลูกค้า พร้อมค่าเพดานของจำนวนดวง และยอดเงิน
  • หน้าขอสิทธิ์ (components/RequestAccess.tsx) — ใช้กับทุกสถานะที่ยังไม่ผ่านการกัน
  • องค์ประกอบร่วมกับหน้าบัตรลูกค้า — หน้าจอเข้าสู่ระบบใหม่ และตัวช่วยสแกน

การเรียก API รวมอยู่ใน src/service/loyalty.service.ts ชุดเดียวกับหน้าบัตรลูกค้า

Endpoint ที่ใช้

MethodPath
GET/loyalty/{hash}/staff/me
POST/loyalty/{hash}/staff/claim
POST/loyalty/{hash}/staff/tokens
POST/loyalty/{hash}/staff/redeem
GET/loyalty/{hash}/staff/activity
note

Endpoint ประวัติการทำรายการมี service รองรับไว้แล้ว แต่ยังไม่มีหน้าจอที่เรียกใช้

จุดเชื่อมต่อกับฟีเจอร์อื่น

  • Ant Design — ใช้คอมโพเนนต์ QRCode สำหรับสร้าง QR ที่ขนาด 220 พิกเซล
  • สแกนเนอร์ของ LIFF — ใช้ผ่านตัวช่วยตัวเดียวกับหน้าบัตรลูกค้า จึงได้การจัดการข้อผิดพลาดแบบเดียวกัน
  • ระบบยืนยันตัวตนของบอร์ดประกาศ — นำมาใช้สร้าง header สำหรับเรียก API (ดู bulletin-board)
  • หน้าบัตรฝั่งลูกค้า เป็นคู่ที่ทำงานร่วมกัน token ที่ออกจากที่นี่ถูกใช้ที่นั่น และคูปองที่สร้างที่นั่นถูกตัดการใช้ที่นี่ (ดู loyalty-card)
  • มีชุดทดสอบตรวจการกันสิทธิ์ตามสถานะพนักงานโดยเฉพาะ

รายละเอียดฝั่ง Backend (Client API)

ความเป็นพนักงานคืออะไรในสายตา backend

ทั้ง 5 endpoint ของฝั่งพนักงานใช้ LIFF app เดียวกับลูกค้าและ ยืนยันตัวตนแบบเดียวกันทั้งหมด ความเป็นพนักงาน ไม่ใช่ token คนละชนิด แต่คือการมีแถวใน loyalty.staff ที่ผูกกับ LINE user นั้น โดยแต่ละ handler เช็ค allowlist ของตัวเองแยกกัน ไม่มี middleware กลาง

middleware เปิด/ปิดแอปรายองค์กรครอบฝั่งพนักงานด้วย — เจ้าของแพลตฟอร์มปิดแอป loyalty ขององค์กรใด เครื่องพนักงานขององค์กรนั้นก็ใช้ไม่ได้ทันที

GET /staff/me — endpoint ที่ตัดสินว่าจะแสดงหน้าจอไหน

  1. verify LIFF token กับ channel ของ OA แล้วค้นแถวพนักงานจาก LINE user
  2. ไม่พบ → คืนสถานะ unknown พร้อมชื่อที่ได้จาก token และรายการสาขาว่าง
  3. พบ → คืนสถานะ, สาขาที่สังกัด และชื่อที่แสดง
  4. รายการสาขา, โหมดโปรแกรม, unit label และอัตราแลกเปลี่ยน ถูกส่งเฉพาะเมื่อพนักงานคนนั้น ออกแต้มได้จริงเท่านั้น — รายการสาขาเป็นการเปิดเผยขนาดกิจการของร้านให้คนที่ไม่มีสิทธิ์ และค่าโหมด/อัตราแลกเปลี่ยนมีไว้ให้หน้าจอออก QR ซึ่งมีอยู่เฉพาะกับพนักงานที่ออกแต้มได้ นี่คือเหตุผลว่าทำไมหน้าเว็บถึงเลือกหน้าจอจากสถานะได้อย่างเดียว โดยไม่มีข้อมูลอื่นให้เห็น

POST /staff/claim — รับคำเชิญ (rate limit 5/60s เข้มที่สุดในกลุ่ม)

เป็น endpoint เดียวที่คนที่ ยังไม่ใช่พนักงาน เข้าถึงได้ จึงเป็นจุดที่ token จะถูกเดา และมาแทน endpoint "ขอเป็นพนักงานเอง" แบบเปิดของระบบเดิม: ปัจจุบันเจ้าของร้าน ระบุตัวคนก่อน แล้วส่งลิงก์ใช้ครั้งเดียวให้

  • token ว่าง → 400 LOYALTY_INVITE_INVALID
  • เป็นพนักงานอยู่แล้ว → 409 LOYALTY_ALREADY_STAFF โดยไม่เผาคำเชิญทิ้ง สิ่งที่เขาต้องทำ คือไม่ต้องทำอะไร
  • token ไม่รู้จัก / หมดอายุ / ถูก claim ไปแล้ว ถูกยุบเป็นโค้ดเดียว LOYALTY_INVITE_INVALID เพราะสำหรับคนที่ถือลิงก์อยู่ ทั้งสามคือสถานการณ์เดียวกัน และการแยกกรณีจะเป็นการยืนยันว่า token ใดมีอยู่จริง (ซึ่งช่วยคนที่กำลังเดา token)
  • สำเร็จ → คืนสถานะของแถวพนักงานที่เพิ่งได้

POST /staff/tokens — ออก QR (rate limit 30/60s)

ตรวจตามลำดับ:

  1. โปรแกรมไม่ live → 400 LOYALTY_PROGRAM_INACTIVE; พนักงานไม่ active → 403 LOYALTY_STAFF_NOT_ACTIVE
  2. โหมดแต้ม — ต้องส่งยอดเงินมา (ไม่ส่ง → 400 LOYALTY_AMOUNT_REQUIRED) แล้ว server เป็นผู้แปลงยอดเงินเป็นแต้มเอง ไม่รับจำนวนแต้มจากเครื่องพนักงาน เพราะ client ที่ส่งแต้มมาตรงๆ ส่งเลขอะไรก็ได้ และอัตราแลกเปลี่ยนเป็น setting ของร้าน ไม่ใช่ของโทรศัพท์ — ไม่ได้ตั้งอัตรา หรือยอดเงินไม่เกิน 0 → 400 LOYALTY_RATE_NOT_SET; ซื้อจริงแต่คำนวณ ได้ไม่ถึง 1 แต้ม → 400 LOYALTY_SPEND_TOO_SMALL ซึ่งมีรหัสของตัวเองเพราะ "ซื้อเพิ่มอีกนิด" เป็นข้อความคนละเรื่องกับ "ระบบพัง"
  3. โหมดแสตมป์ — ยอดเงินถูก ignore และใช้จำนวนดวงที่พนักงานพิมพ์ (ชาแก้วหนึ่งคือ 1 ดวง ไม่ว่าราคาเท่าไร); ถ้าไม่ได้ส่งจำนวนมาและไม่ใช่โหมดแต้ม จะ default เป็น 1
  4. ขอบบนต่างกันตามความหมายของ "หน่วย" — โหมดแสตมป์ใช้เพดานขนาดบัตร (20 ซึ่งเป็นกริด ที่ยังพอแสดงบนจอมือถือ; ถ้าพิมพ์ผิดเป็น 500 จะแจกบัตรไป 50 ใบในการสแกนครั้งเดียว) ส่วนโหมดแต้มใช้เพดาน 10,000 (ที่อัตรา 20 บาทต่อแต้ม ยอด 2,000 บาท = 100 แต้ม ซึ่งเป็นตะกร้าธรรมดา) — นอกช่วง → 400 LOYALTY_TOKEN_INVALID
  5. พนักงานไม่มีสิทธิ์ในสาขานั้น → 403 LOYALTY_BRANCH_INVALID; สาขาไม่มีอยู่จริง → 400 ด้วยรหัสเดียวกัน
  6. บันทึก token ที่ อายุ 60 วินาที — สั้นพอที่ภาพหน้าจอของ QR จะไม่มีค่า แล้วคืน token, จำนวนหน่วย, ยอดเงิน และเวลาหมดอายุ (ซึ่งเป็นค่าที่หน้าเว็บใช้นับถอยหลัง แทนนาฬิกาเครื่อง)

token ที่ออกที่นี่จะถูกใช้โดย endpoint รับแต้มฝั่งลูกค้า ซึ่งเป็นฝ่ายตัดสินการแข่งกัน ด้วย UPDATE แบบมีเงื่อนไข (ดู loyalty-card)

POST /staff/redeem — สแกนคูปองเพื่อตัดการใช้ (rate limit 30/60s)

  1. พนักงานต้องมีสิทธิ์ออกแต้ม ไม่งั้น 403 LOYALTY_STAFF_NOT_ACTIVE
  2. โค้ดถูกแปลงเป็นตัวพิมพ์ใหญ่และ trim; ว่าง → 400 LOYALTY_REWARD_INVALID
  3. การตัดใช้เป็น UPDATE แบบมีเงื่อนไขใน transaction ไม่ใช่การอ่านแล้วค่อยเขียน — สำเร็จคือได้รางวัลกลับมา
  4. เมื่อไม่ match ระบบจะอธิบายสาเหตุ แต่เฉพาะรางวัลที่เป็นของ OA นี้เท่านั้น เพื่อให้โค้ด จาก tenant อื่นตอบว่า "ไม่รู้จัก" ไม่ใช่ยืนยันว่ามีอยู่ที่ไหนสักแห่ง: ไม่มี → 404 LOYALTY_REWARD_INVALID; ถูกใช้แล้ว → 409 LOYALTY_REWARD_CLAIMED; อื่นๆ → 409 LOYALTY_REWARD_EXPIRED

GET /staff/activity — กิจกรรมวันนี้

ต้องมีสิทธิ์ออกแต้ม ไม่งั้น 403 LOYALTY_STAFF_NOT_ACTIVE และคืน บันทึกของพนักงานคนนั้นเอง เท่านั้น ไม่ใช่ของทั้งสาขา — เป็นเครื่องมือเพื่อความรับผิดชอบต่อหน้าที่ของแต่ละคน (endpoint นี้พร้อมใช้แล้ว แม้ฝั่งเว็บจะยังไม่มีหน้าจอที่เรียก)