Skip to main content

บัตรสะสมแต้ม - ระบบระดับชั้น

ภาพรวม

กลไกจัดระดับลูกค้า (เช่น Bronze / Silver / Gold) ที่ตัดสินจาก ชุดกฎ (rule set) แทนที่จะเป็นตัวเลข threshold เพียงค่าเดียว แต่ละชั้นมีเงื่อนไขของตัวเอง วัดจาก 4 metric ภายในหน้าต่างเวลาที่กำหนดแยกได้ ต่อชั้น

ระบบนี้ใช้เฉพาะโหมด point ที่เปิด tier_enabled และเป็น logic ล้วนที่ไม่มี endpoint ของตัวเอง ถูกเรียก จาก 3 จุด ได้แก่ ตอน earn (เพื่อเลื่อนชั้นทันที), ตอนโหลดหน้าบัตร (เพื่อแสดงชั้นและระยะที่เหลือ) และ job รายคืนซึ่งอยู่ฝั่งอื่น

Business Flow

Metric ที่กฎวัดได้

  • spend — ยอดเงิน (บาท) ภายในหน้าต่างเวลา
  • orders — จำนวน earn transaction ภายในหน้าต่างเวลา
  • points — units ที่ได้รับภายในหน้าต่างเวลา
  • member_months — อายุสมาชิก โดยหน้าต่างเวลาไม่มีผลกับ metric นี้

การประเมิน (Qualifies / PickTierByRules)

  1. ParseTierConditions อ่านคอลัมน์ jsonb conditions เป็นโครงสร้าง {join:"and"|"or", rules:[{metric,op,value}]} โดยค่าเริ่มต้นของ join คือ "and" และค่าใดก็ตาม ที่ไม่ใช่ "or" จะถูกบังคับเป็น "and"
  2. กฎที่ parse ไม่สำเร็จถือว่าไม่ตรงกับใครเลย ไม่ใช่ตรงกับทุกคน เพราะการ fail open จะเลื่อนชั้น ลูกค้าทั้งฐานเพียงเพราะแอดมินพิมพ์ผิด ซึ่งเป็นความผิดพลาดที่ถอนคืนไม่ได้เมื่อผู้คนเห็นชั้นใหม่ของตัวเอง ไปแล้ว การ implement ทำโดย inject rule metric:"__invalid__" ที่ไม่มีอะไร satisfy ได้
  3. rule set ที่ว่างเปล่าก็ไม่ตรงกับใคร เพราะชั้นที่ยังไม่ใส่เงื่อนไขคือชั้นที่ทำค้างไว้ครึ่งทาง การตีความว่า "ทุกคน" จะย้ายฐานลูกค้าทั้งหมดไปยัง rung ที่แอดมินกำลังพิมพ์อยู่
  4. metric ที่ไม่รู้จัก และ operator ที่ไม่รู้จัก (รองรับเฉพาะ gte, lte, eq) ถือว่าไม่ผ่าน
  5. PickTierByRules เดินบันได จากล่างขึ้นบนแล้วเก็บ match ตัวสุดท้าย จึงได้ rank สูงสุดที่ผ่าน ไม่ใช่ match ตัวแรก เพราะแอดมินเขียนกฎที่ทับซ้อนกันได้ และคนที่ผ่านทั้ง Silver และ Gold ต้องได้ Gold
  6. ชั้น default (is_default) ถูกข้ามในการเดินบันได กฎของมันไม่ถูกประเมินเลย และจะถูกคืนเฉพาะเมื่อ ไม่มีชั้นอื่นผ่าน ชั้น default จึงเป็น "พื้น" ที่แท้จริงซึ่งไม่สามารถชนะชั้นที่ลูกค้าหามาได้ ไม่ว่าจะตั้ง rank ไว้เท่าไรก็ตาม

การเรียงบันได (SortTiers)

เรียงด้วยค่า rank เท่านั้น ไม่เคยเรียงด้วย threshold เพราะแอดมินที่กำลังแก้บันไดอาจปล่อยให้สองชั้นมี threshold เท่ากันชั่วขณะ และลำดับที่อิงตัวเลขจะสลับตำแหน่งตัวเองใต้มือของเขา

หน้าต่างเวลา (WindowFor)

ใช้ค่าของชั้นก่อนถ้าตั้งไว้ (window_months) ถ้าไม่มีจึงใช้ของโปรแกรม (tier_window_months) และถ้า ไม่มีอีกจะใช้ค่าเริ่มต้น 3 เดือน เนื่องจากแต่ละชั้นวัดในหน้าต่างของตัวเอง maybePromote จึงเก็บสถิติ แยกต่อชั้น ไม่ใช่เก็บครั้งเดียวต่อ account

การขึ้นและลงชั้น (ResolveFromEarned)

ฟังก์ชันนี้ถูกแยกออกจากการ "เลือกชั้น" เพื่อให้กฎการเคลื่อนไหวไม่เปลี่ยนไปเมื่อ threshold ถูกแทนที่ด้วย rule set กล่าวคือเปลี่ยนเฉพาะวิธีได้ชั้น ไม่ใช่สิ่งที่เกิดขึ้นเมื่อขึ้นหรือลง

สถานะผลลัพธ์
ยังไม่มีชั้น และมี earnedTierInitial
ยังไม่มีชั้น และไม่มี earnedไม่เขียนอะไร (reason ว่าง)
earned rank สูงกว่าปัจจุบันTierPromote
earned rank เท่ากับปัจจุบันไม่เขียนอะไร เพื่อกัน tier_history เต็มไปด้วยแถวที่บอกว่า "อยู่ที่เดิม"
earned rank ต่ำกว่าปัจจุบัน และ fallback เป็น step_downลง 1 ขั้น (byRank(rank-1)) ไม่ใช่กระโดดไปยังชั้นที่ earned เพื่อให้คนที่เงียบไปเดือนหนึ่งเสียเพียง 1 rung และได้คืนด้วยการกลับมาเพียงครั้งเดียว
earned rank ต่ำกว่าปัจจุบัน และ fallback เป็น specificลงไปยังชั้นที่กำหนด ยกเว้นกรณีที่ชั้นนั้นมี rank ไม่ต่ำกว่าปัจจุบัน มิฉะนั้นคนที่นั่งอยู่บนชั้น fallback อยู่แล้วจะถูก "ลดชั้น" ไปที่เดิมทุกคืน

ในบริบทของ earn (maybePromote)

รับเฉพาะผลลัพธ์ TierPromote และ TierInitial เท่านั้น ResolveFromEarned สามารถคืนการลดชั้นได้เมื่อ หน้าต่างเวลาเลื่อน แต่การกระทำตามนั้นกลางธุรกรรมหน้าร้านเป็นสิ่งที่ห้ามทำ

nextSpendTarget

คำนวณตัวเลขเงินบาทของชั้นถัดไปสำหรับข้อความ "อีก ฿X" บนหน้าบัตร โดยหา rule ที่ metric เป็น spend และ operator เป็น gte หรือ eq หากไม่พบจะได้ค่า 0 ซึ่งหมายถึงไม่แสดงตัวเลข เนื่องจากบันไดที่สร้าง จากจำนวนออเดอร์หรืออายุสมาชิกไม่มีจำนวนเงินให้นับถอยหลัง การคิดตัวเลขขึ้นมาเองคือการใส่ค่าที่ไม่มี ความหมายลงบนบัตรของลูกค้า

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

ฟีเจอร์นี้ไม่มี route ของตัวเอง

ไฟล์ฟังก์ชัน
internal/loyalty/tier.goTier, SortTiers, NextTier, byRank, ResolveFromEarned และค่าคงที่ FallbackStepDown, FallbackSpecific, TierPromote, TierDemote, TierInitial
internal/loyalty/tier_rules.goTierRule, TierConditions, AccountStats, ParseTierConditions, statFor, matchRule, Qualifies, PickTierByRules, WindowFor, nextSpendTarget และ metric MetricSpend/MetricOrders/MetricPoints/MetricMemberMonths
internal/loyalty/service.go(*Service).maybePromote ซึ่งเป็นจุดเรียกในเส้นทาง earn
internal/loyalty/handler.goบล็อก tier ใน GetCard (GetTierByID, ListTiersByLineOa, NextTier, SpendInWindow)
internal/loyalty/repository.goListTiers, ListTiersByLineOa, GetTierByID, StatsInWindow, SpendInWindow, SetTier
internal/loyalty/view.goNewTierView (สี, สีตัวอักษร, รูปพื้นหลังต่อชั้น)

จุดเชื่อมต่อกับ Service อื่น

  • ตารางฐานข้อมูล loyalty.tier (jsonb conditions, rank, window_months, is_default, status, color, text_color, bg_image_path), loyalty.tier_history, loyalty.account (tier_id), loyalty.transaction ซึ่งเป็นแหล่งของสถิติ และ loyalty.program (tier_enabled, tier_window_months, tier_fallback)
  • ถูกเรียกจาก loyalty-earn และ loyalty-card และเป็นด่าน ของ loyalty-reward-redeem ผ่าน min_tier_rank
  • Tier ไม่ได้ version ตามโปรแกรม แต่ผูกกับ OA เหตุผลอธิบายไว้ที่ loyalty-card
  • job ประเมินชั้นใหม่และลดชั้นรายคืนอยู่นอก service นี้ (ฝั่ง worker/CMS) แต่ต้องใช้กฎชุดเดียวกัน
  • ตรงกับ client-web feature: loyalty-card