บัตรสะสมแต้ม (พนักงานหน้าร้าน)
ภาพรวม
หน้านี้เป็นเครื่องมือสำหรับพนักงานหน้าเคาน์เตอร์ ซึ่งเปิดจากบัญชี LINE ของตัวเอง ใช้ทำสองอย่าง:
- ออก QR ให้ลูกค้าสแกนรับแต้ม โดยระบุจำนวนดวงหรือยอดเงินที่ลูกค้าจ่าย
- สแกนคูปองของลูกค้าเพื่อตัดการใช้รางวัล
จุดออกแบบที่สำคัญที่สุดคือ การกันสิทธิ์แบบ fail closed แผงออก QR จะแสดงเฉพาะ เมื่อสถานะพนักงานเป็น "ใช้งานได้" เท่านั้น สถานะอื่นทั้งหมด รวมถึงสถานะใหม่ที่ แอปเวอร์ชันเก่ายังไม่รู้จัก จะถูกส่งไปหน้าขอสิทธิ์ แนวทางนี้ป้องกันไม่ให้ build เก่า ที่ค้างอยู่บนมือถือของใครเปิดสิทธิ์ให้โดยไม่ตั้งใจ
Business Flow
การเข้าถึงและการรับสิทธิ์ (เชิญเท่านั้น)
-
พนักงานเปิด
/{hash}/loyalty-staffซึ่งบางครั้งมีพารามิเตอร์ลิงก์เชิญติดมาด้วย -
ระบบยืนยันตัวตน แล้วสอบถามสถานะพนักงานของบัญชีนี้จาก API
-
แสดงหน้าจอตามสถานะ:
สถานะ หน้าจอที่แสดง ใช้งานได้ แผงออก QR พร้อมใช้งาน รออนุมัติ ข้อความว่ากำลังรอผู้ดูแลอนุมัติ ถูกยกเลิกสิทธิ์ ข้อความว่าสิทธิ์ถูกยกเลิก โดยไม่มีปุ่มขอใหม่ ต้องติดต่อเจ้าของร้าน ไม่พบสิทธิ์ หรือสถานะอื่น ข้อความว่าต้องใช้ลิงก์เชิญ -
หากมีพารามิเตอร์ลิงก์เชิญและยังไม่พบสิทธิ์ ระบบจะ ขอรับสิทธิ์ให้อัตโนมัติทันที ไม่ต้องให้พนักงานกดยืนยันซ้ำอีกครั้ง — หากบัญชีนี้เป็นพนักงานอยู่แล้ว หรือลิงก์ใช้ไม่ได้แล้ว จะมีข้อความแจ้งแยกกรณี
-
token ของลิงก์เชิญอยู่รอดผ่านการนำทางไปเข้าสู่ระบบ LINE ได้ เพราะหน้าเก็บ URL แบบเต็มรวม query ไว้เป็นปลายทางย้อนกลับ
การออก QR ให้ลูกค้า
- เลือกสาขา — ตัวเลือกนี้แสดงเฉพาะเมื่อร้านมีมากกว่าหนึ่งสาขา
- ใส่ค่าตามโหมดของโปรแกรม:
- โหมดแสตมป์ — ใช้ปุ่มเพิ่ม/ลดเพื่อกำหนดจำนวนดวง (1 ถึง 20 ดวง)
- โหมดแต้ม — ใช้แป้นตัวเลขใส่ยอดที่ลูกค้าจ่าย (ไม่เกิน 1,000,000) แล้วระบบแสดงตัวอย่างว่าลูกค้าจะได้กี่แต้ม โดยปัดลงให้ตรงกับวิธีคำนวณ ฝั่ง server หากร้านยังไม่ได้ตั้งอัตราแลกเปลี่ยน จะเตือนให้แจ้งเจ้าของร้าน
- กดสร้าง QR ระบบขอ token จาก API โดยส่งสาขาพร้อมจำนวนดวงหรือยอดเงิน — ในโหมดแต้ม ฝั่งหน้าเว็บไม่เคยส่งจำนวนแต้มไปเอง server เป็นผู้แปลงยอดเงิน เป็นแต้ม เพราะค่าที่คำนวณฝั่งหน้าเว็บถูกแก้ได้
- แสดง QR โดยค่าที่บรรจุใน QR คือลิงก์ LIFF ที่พาไปหน้าบัตรพร้อม token หากทราบ LIFF ID — ข้อดีคือกล้องมือถือทั่วไปเปิดได้เลย ลูกค้าใหม่ไม่ต้องเพิ่ม OA เป็นเพื่อนก่อน หากไม่ทราบ LIFF ID จะบรรจุ token เปล่าซึ่งสแกนได้จากในแอปบัตรเท่านั้น และข้อความใต้ QR จะเปลี่ยนตามให้สอดคล้อง
- เวลานับถอยหลังอ้างจากเวลาหมดอายุที่ 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 ที่ใช้
| Method | Path |
|---|---|
| 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 |
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 ที่ตัดสินว่าจะแสดงหน้าจอไหน
- verify LIFF token กับ channel ของ OA แล้วค้นแถวพนักงานจาก LINE user
- ไม่พบ → คืนสถานะ
unknownพร้อมชื่อที่ได้จาก token และรายการสาขาว่าง - พบ → คืนสถานะ, สาขาที่สังกัด และชื่อที่แสดง
- รายการสาขา, โหมดโปรแกรม, 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)
ตรวจตามลำดับ:
- โปรแกรมไม่ live → 400
LOYALTY_PROGRAM_INACTIVE; พนักงานไม่ active → 403LOYALTY_STAFF_NOT_ACTIVE - โหมดแต้ม — ต้องส่งยอดเงินมา (ไม่ส่ง → 400
LOYALTY_AMOUNT_REQUIRED) แล้ว server เป็นผู้แปลงยอดเงินเป็นแต้มเอง ไม่รับจำนวนแต้มจากเครื่องพนักงาน เพราะ client ที่ส่งแต้มมาตรงๆ ส่งเลขอะไรก็ได้ และอัตราแลกเปลี่ยนเป็น setting ของร้าน ไม่ใช่ของโทรศัพท์ — ไม่ได้ตั้งอัตรา หรือยอดเงินไม่เกิน 0 → 400LOYALTY_RATE_NOT_SET; ซื้อจริงแต่คำนวณ ได้ไม่ถึง 1 แต้ม → 400LOYALTY_SPEND_TOO_SMALLซึ่งมีรหัสของตัวเองเพราะ "ซื้อเพิ่มอีกนิด" เป็นข้อความคนละเรื่องกับ "ระบบพัง" - โหมดแสตมป์ — ยอดเงินถูก ignore และใช้จำนวนดวงที่พนักงานพิมพ์ (ชาแก้วหนึ่งคือ 1 ดวง ไม่ว่าราคาเท่าไร); ถ้าไม่ได้ส่งจำนวนมาและไม่ใช่โหมดแต้ม จะ default เป็น 1
- ขอบบนต่างกันตามความหมายของ "หน่วย" — โหมดแสตมป์ใช้เพดานขนาดบัตร (20 ซึ่งเป็นกริด
ที่ยังพอแสดงบนจอมือถือ; ถ้าพิมพ์ผิดเป็น 500 จะแจกบัตรไป 50 ใบในการสแกนครั้งเดียว)
ส่วนโหมดแต้มใช้เพดาน 10,000 (ที่อัตรา 20 บาทต่อแต้ม ยอด 2,000 บาท = 100 แต้ม
ซึ่งเป็นตะกร้าธรรมดา) — นอกช่วง → 400
LOYALTY_TOKEN_INVALID - พนักงานไม่มีสิทธิ์ในสาขานั้น → 403
LOYALTY_BRANCH_INVALID; สาขาไม่มีอยู่จริง → 400 ด้วยรหัสเดียวกัน - บันทึก token ที่ อายุ 60 วินาที — สั้นพอที่ภาพหน้าจอของ QR จะไม่มีค่า แล้วคืน token, จำนวนหน่วย, ยอดเงิน และเวลาหมดอายุ (ซึ่งเป็นค่าที่หน้าเว็บใช้นับถอยหลัง แทนนาฬิกาเครื่อง)
token ที่ออกที่นี่จะถูกใช้โดย endpoint รับแต้มฝั่งลูกค้า ซึ่งเป็นฝ่ายตัดสินการแข่งกัน ด้วย UPDATE แบบมีเงื่อนไข (ดู loyalty-card)
POST /staff/redeem — สแกนคูปองเพื่อตัดการใช้ (rate limit 30/60s)
- พนักงานต้องมีสิทธิ์ออกแต้ม ไม่งั้น 403
LOYALTY_STAFF_NOT_ACTIVE - โค้ดถูกแปลงเป็นตัวพิมพ์ใหญ่และ trim; ว่าง → 400
LOYALTY_REWARD_INVALID - การตัดใช้เป็น UPDATE แบบมีเงื่อนไขใน transaction ไม่ใช่การอ่านแล้วค่อยเขียน — สำเร็จคือได้รางวัลกลับมา
- เมื่อไม่ match ระบบจะอธิบายสาเหตุ แต่เฉพาะรางวัลที่เป็นของ OA นี้เท่านั้น เพื่อให้โค้ด
จาก tenant อื่นตอบว่า "ไม่รู้จัก" ไม่ใช่ยืนยันว่ามีอยู่ที่ไหนสักแห่ง: ไม่มี → 404
LOYALTY_REWARD_INVALID; ถูกใช้แล้ว → 409LOYALTY_REWARD_CLAIMED; อื่นๆ → 409LOYALTY_REWARD_EXPIRED
GET /staff/activity — กิจกรรมวันนี้
ต้องมีสิทธิ์ออกแต้ม ไม่งั้น 403 LOYALTY_STAFF_NOT_ACTIVE และคืน บันทึกของพนักงานคนนั้นเอง
เท่านั้น ไม่ใช่ของทั้งสาขา — เป็นเครื่องมือเพื่อความรับผิดชอบต่อหน้าที่ของแต่ละคน
(endpoint นี้พร้อมใช้แล้ว แม้ฝั่งเว็บจะยังไม่มีหน้าจอที่เรียก)