Skip to main content

บัตรสะสมแต้ม - เครื่องมือพนักงาน

ภาพรวม

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

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

Business Flow

สถานะพนักงาน — GET /api/loyalty/:hash/staff/me

endpoint ที่ตัดสินว่าแอปฝั่งพนักงานจะแสดงหน้าจอไหน

  1. resolve(c) verify LIFF token กับ channel ของ OA แล้วค้นแถว loyalty.staff จาก LINE user
  2. หากไม่พบ คืน {status:"unknown", displayName: จาก token, branches:[]}
  3. หากพบ คืน status, branchId และ displayName
  4. ข้อมูล 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 นี้มาแทนรูปแบบเดิมที่เปิดให้ "ขอเป็น พนักงานเอง" ปัจจุบันเจ้าของร้านต้องระบุตัวบุคคลก่อน แล้วจึงส่งลิงก์ที่ใช้ได้ครั้งเดียวให้

  1. token ว่างตอบ 400 LOYALTY_INVITE_INVALID
  2. ผู้ที่เป็นพนักงานอยู่แล้วตอบ 409 LOYALTY_ALREADY_STAFF โดยไม่เผาคำเชิญทิ้ง เพราะสิ่งที่เขาต้องทำ คือไม่ต้องทำอะไร
  3. เรียก ClaimInvite(oaID, token, lineUserID, name, "")
  4. กรณี token ไม่รู้จัก, หมดอายุ และถูก claim ไปแล้ว ยุบเป็นโค้ดเดียวกัน คือ LOYALTY_INVITE_INVALID เพราะสำหรับคนที่ถือลิงก์อยู่ ทั้งสามคือสถานการณ์เดียวกัน และการแยกกรณี จะเป็นการยืนยันว่า token ใดมีอยู่จริง
  5. เมื่อสำเร็จคืน {status} ซึ่งเป็นสถานะของแถว staff ที่ได้

ออก QR — POST /api/loyalty/:hash/staff/tokens (rate limit 30/60s)

body {branchId, units, amountSpent?}

  1. ค้นแถว staff และโปรแกรมที่ live
  2. หาก units เป็น 0 และไม่ใช่โหมด point ระบบตั้งค่าเริ่มต้นเป็น 1
  3. IssueToken ตรวจตามลำดับดังนี้
    • โปรแกรมไม่ live ตอบ 400 LOYALTY_PROGRAM_INACTIVE และ staff ที่ไม่ active ตอบ 403 LOYALTY_STAFF_NOT_ACTIVE
    • โหมด point ต้องส่ง amountSpent มาด้วย (ไม่ส่งตอบ 400 LOYALTY_AMOUNT_REQUIRED) แล้ว server แปลงเป็นแต้มเอง ด้วย UnitsForSpend(bahtPerPoint, amount) ไม่ยอมให้เครื่องพนักงาน คำนวณ เพราะ client ที่ส่ง units มาตรงๆ จะส่งเลขอะไรก็ได้ และอัตราแลกเปลี่ยนเป็น setting ของร้าน ไม่ใช่ของโทรศัพท์ กรณีไม่มี rate หรือจำนวนเงินไม่มากกว่า 0 ตอบ 400 LOYALTY_RATE_NOT_SET ส่วนการซื้อจริงที่ไม่ถึง 1 แต้มตอบ 400 LOYALTY_SPEND_TOO_SMALL ซึ่งมีโค้ดของตัวเองเพราะ "ซื้อเพิ่มอีกนิด" เป็นข้อความคนละเรื่องกับ "ระบบพัง"
    • โหมดแสตมป์ จะ ignore amountSpent และใช้ค่า units ที่พนักงานพิมพ์ เพราะชาหนึ่งแก้วคือ 1 แสตมป์ ไม่ว่าราคาจะเท่าไร
    • ขอบบนต่างกันตามความหมายของหน่วย โหมดแสตมป์ใช้ MaxCardSize (20 ซึ่งเป็นกริดที่พอดีกับ หน้าจอมือถือ หากพิมพ์ผิดเป็น 500 จะแจกบัตรไป 50 ใบในการสแกนครั้งเดียว) ส่วนโหมด point ใช้ MaxEarnUnits (10000 เพราะที่อัตรา 20 บาทต่อแต้ม ยอด 2,000 บาทเท่ากับ 100 แต้ม ซึ่งเป็นตะกร้า ปกติ) ค่าที่อยู่นอกช่วงตอบ 400 LOYALTY_TOKEN_INVALID
    • staff ที่ไม่มีสิทธิ์ในสาขานั้น (MayUseBranch) ตอบ 403 LOYALTY_BRANCH_INVALID ส่วนสาขาที่ ไม่มีอยู่จริงตอบ 400 LOYALTY_BRANCH_INVALID
  4. InsertToken(..., TokenTTL) สร้าง QR ที่มีอายุ 60 วินาที ซึ่งสั้นพอที่ภาพหน้าจอจะไม่มีค่า
  5. คืน {token, units, amountSpent, expiresAt}

สแกนคูปอง — POST /api/loyalty/:hash/staff/redeem (rate limit 30/60s)

body {code}

  1. staff ต้องผ่าน CanIssue() มิฉะนั้นตอบ 403 LOYALTY_STAFF_NOT_ACTIVE
  2. code ถูกแปลงเป็นตัวพิมพ์ใหญ่และ trim ค่าที่ว่างตอบ 400 LOYALTY_REWARD_INVALID
  3. ClaimReward เป็น UPDATE แบบมีเงื่อนไขภายใน transaction หากสำเร็จจะได้ข้อมูลรางวัลกลับมา
  4. หากไม่ match ระบบจะอธิบายสาเหตุ เฉพาะรางวัลที่เป็นของ OA นี้ เพื่อให้โค้ดจาก tenant อื่นได้รับ คำตอบว่า "ไม่รู้จัก" แทนที่จะเป็นการยืนยันว่ามีอยู่ที่ใดที่หนึ่ง โดยกรณีไม่มีตอบ 404 LOYALTY_REWARD_INVALID กรณีถูกใช้แล้วตอบ 409 LOYALTY_REWARD_CLAIMED และกรณีอื่นตอบ 409 LOYALTY_REWARD_EXPIRED
  5. คืน {redeemed:true, rewardId}

กิจกรรมวันนี้ — GET /api/loyalty/:hash/staff/activity

ต้องผ่าน CanIssue() มิฉะนั้นตอบ 403 LOYALTY_STAFF_NOT_ACTIVE จากนั้นคืน {items} จาก StaffActivityToday(staffID) ซึ่งเป็นบันทึกของพนักงานคนนั้นเองเพื่อความรับผิดชอบต่อหน้าที่

ไฟล์และฟังก์ชันหลัก

RouteRate limitHandler
GET /api/loyalty/:hash/staff/me(*Handler).StaffMe
POST /api/loyalty/:hash/staff/claim5/60s(*Handler).StaffClaimInvite
POST /api/loyalty/:hash/staff/tokens30/60s(*Handler).StaffToken
POST /api/loyalty/:hash/staff/redeem30/60s(*Handler).StaffRedeem
GET /api/loyalty/:hash/staff/activity(*Handler).StaffActivity
  • internal/loyalty/service.go(*Service).IssueToken, (*Service).RedeemReward
  • internal/loyalty/repository.goFindStaffByLineUser, ClaimInvite, ListBranches, FindBranch, InsertToken, ClaimReward, FindRewardByCode, StaffActivityToday
  • internal/loyalty/entity.goStaff.CanIssue(), Staff.MayUseBranch(), EarnToken, UnitsForSpend, TokenTTL, MaxCardSize, MaxEarnUnits และ error code LOYALTY_STAFF_NOT_ACTIVE, LOYALTY_INVITE_INVALID, LOYALTY_ALREADY_STAFF, LOYALTY_BRANCH_INVALID, LOYALTY_AMOUNT_REQUIRED, LOYALTY_RATE_NOT_SET, LOYALTY_SPEND_TOO_SMALL
  • internal/loyalty/view.goStaffView, 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