บัตรสะสมแต้ม - เครื่องมือพนักงาน
ภาพรวม
ชุด 5 endpoint สำหรับพนักงานหน้าเคาน์เตอร์ ซึ่งใช้ LIFF app เดียวกับลูกค้าและใช้กลไก auth แบบเดียวกัน
ทั้งหมด ความเป็นพนักงานไม่ใช่ token คนละชนิด แต่คือการมีแถวใน loyalty.staff ที่ผูกกับ LINE user
รายนั้น โดยแต่ละ handler ตรวจ allowlist ของตัวเอง
ขอบเขตของฟีเจอร์ครอบคลุมการดูสถานะตัวเอง, การรับคำเชิญเป็นพนักงาน, การออก QR ให้ลูกค้าสแกน, การสแกนคูปองเพื่อใช้รางวัล และการดูกิจกรรมของตัวเองในวันนี้
Business Flow
สถานะพนักงาน — GET /api/loyalty/:hash/staff/me
endpoint ที่ตัดสินว่าแอปฝั่งพนักงานจะแสดงหน้าจอไหน
resolve(c)verify LIFF token กับ channel ของ OA แล้วค้นแถวloyalty.staffจาก LINE user- หากไม่พบ คืน
{status:"unknown", displayName: จาก token, branches:[]} - หากพบ คืน
status,branchIdและdisplayName - ข้อมูล
branches,mode,unitLabelและbahtPerPointจะถูกส่งเฉพาะเมื่อst.CanIssue()เป็นจริง เนื่องจากรายการสาขาเป็นการเปิดเผยขนาดกิจการของร้านให้คนที่ไม่มีสิทธิ์ออกแต้ม ส่วน mode และ rate มีไว้สำหรับหน้าจอ till ซึ่งมีอยู่เฉพาะกับพนักงานที่ออกแต้มได้เท่านั้น
รับคำเชิญ — POST /api/loyalty/:hash/staff/claim (rate limit 5/60s)
body {token} โดยเป็น endpoint ที่มี rate limit เข้มที่สุดในกลุ่ม เพราะเป็น endpoint เดียวที่คนซึ่งยัง
ไม่ใช่พนักงานเข้าถึงได้ จึงเป็นจุดที่ token จะถูกเดา endpoint นี้มาแทนรูปแบบเดิมที่เปิดให้ "ขอเป็น
พนักงานเอง" ปัจจุบันเจ้าของร้านต้องระบุตัวบุคคลก่อน แล้วจึงส่งลิงก์ที่ใช้ได้ครั้งเดียวให้
- token ว่างตอบ 400
LOYALTY_INVITE_INVALID - ผู้ที่เป็นพนักงานอยู่แล้วตอบ 409
LOYALTY_ALREADY_STAFFโดยไม่เผาคำเชิญทิ้ง เพราะสิ่งที่เขาต้องทำ คือไม่ต้องทำอะไร - เรียก
ClaimInvite(oaID, token, lineUserID, name, "") - กรณี token ไม่รู้จัก, หมดอายุ และถูก claim ไปแล้ว ยุบเป็นโค้ดเดียวกัน คือ
LOYALTY_INVITE_INVALIDเพราะสำหรับคนที่ถือลิงก์อยู่ ทั้งสามคือสถานการณ์เดียวกัน และการแยกกรณี จะเป็นการยืนยันว่า token ใดมีอยู่จริง - เมื่อสำเร็จคืน
{status}ซึ่งเป็นสถานะของแถว staff ที่ได้
ออก QR — POST /api/loyalty/:hash/staff/tokens (rate limit 30/60s)
body {branchId, units, amountSpent?}
- ค้นแถว staff และโปรแกรมที่ live
- หาก
unitsเป็น 0 และไม่ใช่โหมด point ระบบตั้งค่าเริ่มต้นเป็น 1 IssueTokenตรวจตามลำดับดังนี้- โปรแกรมไม่ live ตอบ 400
LOYALTY_PROGRAM_INACTIVEและ staff ที่ไม่ active ตอบ 403LOYALTY_STAFF_NOT_ACTIVE - โหมด point ต้องส่ง
amountSpentมาด้วย (ไม่ส่งตอบ 400LOYALTY_AMOUNT_REQUIRED) แล้ว server แปลงเป็นแต้มเอง ด้วยUnitsForSpend(bahtPerPoint, amount)ไม่ยอมให้เครื่องพนักงาน คำนวณ เพราะ client ที่ส่ง units มาตรงๆ จะส่งเลขอะไรก็ได้ และอัตราแลกเปลี่ยนเป็น setting ของร้าน ไม่ใช่ของโทรศัพท์ กรณีไม่มี rate หรือจำนวนเงินไม่มากกว่า 0 ตอบ 400LOYALTY_RATE_NOT_SETส่วนการซื้อจริงที่ไม่ถึง 1 แต้มตอบ 400LOYALTY_SPEND_TOO_SMALLซึ่งมีโค้ดของตัวเองเพราะ "ซื้อเพิ่มอีกนิด" เป็นข้อความคนละเรื่องกับ "ระบบพัง" - โหมดแสตมป์ จะ ignore
amountSpentและใช้ค่าunitsที่พนักงานพิมพ์ เพราะชาหนึ่งแก้วคือ 1 แสตมป์ ไม่ว่าราคาจะเท่าไร - ขอบบนต่างกันตามความหมายของหน่วย โหมดแสตมป์ใช้
MaxCardSize(20 ซึ่งเป็นกริดที่พอดีกับ หน้าจอมือถือ หากพิมพ์ผิดเป็น 500 จะแจกบัตรไป 50 ใบในการสแกนครั้งเดียว) ส่วนโหมด point ใช้MaxEarnUnits(10000 เพราะที่อัตรา 20 บาทต่อแต้ม ยอด 2,000 บาทเท่ากับ 100 แต้ม ซึ่งเป็นตะกร้า ปกติ) ค่าที่อยู่นอกช่วงตอบ 400LOYALTY_TOKEN_INVALID - staff ที่ไม่มีสิทธิ์ในสาขานั้น (
MayUseBranch) ตอบ 403LOYALTY_BRANCH_INVALIDส่วนสาขาที่ ไม่มีอยู่จริงตอบ 400LOYALTY_BRANCH_INVALID
- โปรแกรมไม่ live ตอบ 400
InsertToken(..., TokenTTL)สร้าง QR ที่มีอายุ 60 วินาที ซึ่งสั้นพอที่ภาพหน้าจอจะไม่มีค่า- คืน
{token, units, amountSpent, expiresAt}
สแกนคูปอง — POST /api/loyalty/:hash/staff/redeem (rate limit 30/60s)
body {code}
- staff ต้องผ่าน
CanIssue()มิฉะนั้นตอบ 403LOYALTY_STAFF_NOT_ACTIVE - code ถูกแปลงเป็นตัวพิมพ์ใหญ่และ trim ค่าที่ว่างตอบ 400
LOYALTY_REWARD_INVALID ClaimRewardเป็น UPDATE แบบมีเงื่อนไขภายใน transaction หากสำเร็จจะได้ข้อมูลรางวัลกลับมา- หากไม่ match ระบบจะอธิบายสาเหตุ เฉพาะรางวัลที่เป็นของ OA นี้ เพื่อให้โค้ดจาก tenant อื่นได้รับ
คำตอบว่า "ไม่รู้จัก" แทนที่จะเป็นการยืนยันว่ามีอยู่ที่ใดที่หนึ่ง โดยกรณีไม่มีตอบ 404
LOYALTY_REWARD_INVALIDกรณีถูกใช้แล้วตอบ 409LOYALTY_REWARD_CLAIMEDและกรณีอื่นตอบ 409LOYALTY_REWARD_EXPIRED - คืน
{redeemed:true, rewardId}
กิจกรรมวันนี้ — GET /api/loyalty/:hash/staff/activity
ต้องผ่าน CanIssue() มิฉะนั้นตอบ 403 LOYALTY_STAFF_NOT_ACTIVE จากนั้นคืน {items} จาก
StaffActivityToday(staffID) ซึ่งเป็นบันทึกของพนักงานคนนั้นเองเพื่อความรับผิดชอบต่อหน้าที่
ไฟล์และฟังก์ชันหลัก
| Route | Rate limit | Handler |
|---|---|---|
GET /api/loyalty/:hash/staff/me | — | (*Handler).StaffMe |
POST /api/loyalty/:hash/staff/claim | 5/60s | (*Handler).StaffClaimInvite |
POST /api/loyalty/:hash/staff/tokens | 30/60s | (*Handler).StaffToken |
POST /api/loyalty/:hash/staff/redeem | 30/60s | (*Handler).StaffRedeem |
GET /api/loyalty/:hash/staff/activity | — | (*Handler).StaffActivity |
internal/loyalty/service.go—(*Service).IssueToken,(*Service).RedeemRewardinternal/loyalty/repository.go—FindStaffByLineUser,ClaimInvite,ListBranches,FindBranch,InsertToken,ClaimReward,FindRewardByCode,StaffActivityTodayinternal/loyalty/entity.go—Staff.CanIssue(),Staff.MayUseBranch(),EarnToken,UnitsForSpend,TokenTTL,MaxCardSize,MaxEarnUnitsและ error codeLOYALTY_STAFF_NOT_ACTIVE,LOYALTY_INVITE_INVALID,LOYALTY_ALREADY_STAFF,LOYALTY_BRANCH_INVALID,LOYALTY_AMOUNT_REQUIRED,LOYALTY_RATE_NOT_SET,LOYALTY_SPEND_TOO_SMALLinternal/loyalty/view.go—StaffView,BranchView,TokenView,NewBranchViews
จุดเชื่อมต่อกับ Service อื่น
- ตารางฐานข้อมูล
loyalty.staff,loyalty.branch,loyalty.earn_token,loyalty.reward,loyalty.transaction,loyalty.program,line_oa - คำเชิญพนักงานถูกสร้างจากฝั่ง CMS (cms-api-go) endpoint ในหน้านี้เป็นเพียงฝั่ง claim เท่านั้น
- token ที่ออกที่นี่ถูกนำไปใช้ที่ loyalty-earn ส่วนโค้ดที่สแกนมาจาก loyalty-reward-redeem
- app-enabled-guard ครอบคลุมฝั่งพนักงานด้วยเช่นกัน
- ตรงกับ client-web feature:
loyalty-staff