Skip to main content

บัตรสะสมแต้มและระบบสมาชิก

ภาพรวม​

แอปเสริม บัตรสะสมแสตมป์และแต้ม ที่ออกแบบมาใช้แทนระบบสะสมแต้มของ LINE เอง ตารางทั้งหมดอยู่ใน schema loyalty ไม่มีนิยามใน Prisma และ apply ด้วย psql เช่นเดียวกับแอป appointment และ bulletin

มีแนวคิดหลักสองข้อที่อธิบายรูปร่างของ schema เกือบทั้งหมด และถูกระบุไว้ชัดเจนในหัวไฟล์ migration

  1. บัญชีแยกประเภทคือความจริง — ระบบไม่เก็บยอดคงเหลือเป็นคอลัมน์ แต่รวมสดจาก lot ในตาราง transaction ที่ยังไม่ถูกใช้และยังไม่หมดอายุ เหตุผลคือยอดที่ cache ไว้จะผิดทันที ที่ lot ใด lot หนึ่งหมดอายุ เพราะการหมดอายุเป็นฟังก์ชันของเวลา ไม่ใช่ของการเขียนข้อมูล
  2. บัตรในกระเป๋าต้องรักษาข้อตกลงเดิม — card_instance เก็บ snapshot ของ program_version และ card_size ไว้ ทำให้ร้านแก้ไขเงื่อนไขบัตรทีหลังได้โดยไม่กระทบบัตรที่ลูกค้ากำลังสะสมอยู่

โครงสร้างข้อมูลหลัก​

loyalty.program — คอนฟิกบัตร​

  • mode (stamp หรือ point), name, unit_label (ค่าเริ่มต้น "ดวง") และ version
  • card_size คือจำนวนช่องแสตมป์ (1–20) ส่วน baht_per_point ใช้ในโหมดแต้ม
  • รูปภาพ cover_path, logo_path และ stamp_icon_path เก็บเป็น object path ไม่ใช่ URL เพราะการเก็บ URL จะผูกข้อมูลไว้กับ layout ของ bucket แล้วพังเมื่อ config เปลี่ยน
  • การหมดอายุ: expiry_mode (from_first, from_last หรือ none) และ expiry_months
  • คูลดาวน์: cooldown_mode (unlimited, per_hours หรือ per_day) โดยมีค่าเริ่มต้นเป็น unlimited ซึ่งต่างจาก LINE เพราะโค้ดของระบบนี้ออกโดยพนักงานที่ล็อกอินแล้วและหมดอายุใน 60 วินาที
  • status (draft, active, paused, superseded) พร้อม partial unique index (line_oa_id) WHERE status IN ('draft','active','paused') ซึ่งบังคับว่าแต่ละช่องทาง มีโปรแกรมที่ยังไม่ถูกแทนที่ได้ครั้งละหนึ่งโปรแกรม
  • คอลัมน์ที่เกี่ยวกับระดับสมาชิก อธิบายไว้ที่ ระดับสมาชิก Loyalty

loyalty.branch และ loyalty.staff​

loyalty.branch เก็บข้อมูลสาขา ส่วน loyalty.staff คือ allowlist ของพนักงานที่ออกแสตมป์ได้ แบบเชิญเข้าเท่านั้น

  • วงจรชีวิตของพนักงาน: invited → pending → active → revoked
  • display_name คือชื่อที่เจ้าของร้านตั้งและจะไม่ถูกเขียนทับ ส่วนโปรไฟล์ LINE ของคนที่มา claim เก็บแยกไว้ที่ line_display_name และ line_picture_url เพื่อให้ผู้อนุมัติเห็นความไม่ตรงกันได้
  • invite_token ใช้ partial unique และถูกเคลียร์เมื่อ claim สำเร็จ จึงใช้ได้ครั้งเดียว คู่กับ invite_expires_at
  • ใช้หลัก deny by default คือมีเพียงสถานะ active เท่านั้นที่ออกแสตมป์ได้

loyalty.account และ loyalty.card_instance​

loyalty.account คือบัญชีลูกค้าหนึ่งคนต่อหนึ่ง OA จุดออกแบบที่สำคัญคือ line_user_id เป็น nullable ตั้งแต่วันแรก เพื่อรองรับการให้แต้มด้วยเบอร์โทรก่อนที่ลูกค้าจะมาผูกบัญชี LINE โดยมี partial unique สองตัวคู่กัน

  • (line_oa_id, line_user_id) WHERE line_user_id IS NOT NULL
  • (line_oa_id, mobile_no) WHERE mobile_no IS NOT NULL AND line_user_id IS NULL

ดีไซน์นี้เองที่ทำให้การรวมบัญชีเบอร์โทรเข้ากับบัญชี LINE ทำได้ในภายหลัง

loyalty.card_instance แทนใบบัตรแต่ละใบ เมื่อสะสมเต็มจะขึ้นใบใหม่เหมือนบัตรตอกกระดาษ เก็บ sequence_no (unique ร่วมกับบัญชี), slots_filled, card_size, program_version และ status (active, complete หรือ expired)

loyalty.milestone — รางวัลบนโปรแกรม​

รางวัลถูกออกแบบเป็น รายการอิสระ ไม่ใช่โครงสร้าง "รางวัลหลักและรางวัลรอง" แบบ LINE

  • required_units, title, image_path, reward_expiry_days และ min_tier_rank
  • unique (program_id, program_version, required_units) ป้องกันรางวัลซ้ำที่ระดับเดียวกัน

loyalty.transaction — บัญชีแยกประเภท​

ตารางนี้เป็น append-only มีเพียง consumed_units เท่านั้นที่ถูกอัปเดตภายหลัง

  • kind (earn, burn, expire, adjust), units, mode (stamp หรือ point) และ amount_spent
  • expires_date ใช้เฉพาะ lot ฝั่ง earn คู่กับ consumed_units
  • idempotency_key พร้อม partial unique (line_oa_id, idempotency_key) ป้องกันการกดซ้ำ หรือการ retry เมื่อเน็ตหลุด โดยบังคับที่ระดับฐานข้อมูลไม่ใช่ที่โค้ด
  • index (account_id, mode, kind, expires_date, id) WHERE kind='earn' ใช้ทั้งการรวมยอดคงเหลือ และการหักแบบ FIFO
  • การ scope ด้วย mode ทำให้เมื่อร้านเปลี่ยนโปรแกรมจากแสตมป์เป็นแต้ม lot ของโหมดเดิม จะถูกพักไว้ไม่ถูกนับ แต่ไม่ถูกลบ หากสลับกลับมาก็ได้ยอดเดิมคืน

ตารางประกอบ​

  • loyalty.reward — รางวัลที่ได้แล้วรอใช้ code VARCHAR(16) ใช้ชุดตัวอักษรที่ตัด I, L, O, U และเลข 0 กับ 1 ออก เพื่อลดการอ่านหรือพิมพ์ผิด พร้อม status (unclaimed, claimed, expired), claimed_by_staff_id และ claimed_branch_id
  • loyalty.earn_token — QR แบบหมุนเวียน ใช้ครั้งเดียว อายุสั้น โดย staff_id และ branch_id เป็น NOT NULL เพราะทุกการออกแสตมป์ต้องระบุตัวบุคคลและสาขาได้เสมอ
  • loyalty.bonus_rule และ loyalty.bonus_grant — ระบบโบนัส โดย welcome bonus เป็นเพียง หนึ่ง instance ของกฎทั่วไป trigger_type รองรับ join, birthday, first_earn, time_window, spend_threshold และ audience ส่วน effect เป็น grant_units หรือ multiply และ cadence เป็น once, yearly หรือ daily การควบคุมทุก cadence ทำผ่านคอลัมน์เดียว คือ bonus_grant.period_key คู่กับ unique (rule_id, account_id, period_key)
  • loyalty.audit_log — บันทึกว่าใครแก้โปรแกรม อนุมัติพนักงาน หรือปรับยอดด้วยมือ

ไฟล์ที่เกี่ยวข้อง​

  • apps/loyalty/migrations/001_create_schema.sql — คำสั่ง CREATE SCHEMA loyalty
  • apps/loyalty/migrations/002_create_tables.sql — ตารางหลักทั้งหมด พร้อมคอมเมนต์อธิบายเหตุผลอย่างละเอียด
  • apps/loyalty/migrations/003_staff_invites.sql — เปลี่ยนระบบพนักงานเป็นแบบเชิญเข้าเท่านั้น
  • apps/loyalty/migrations/004_stamp_icon.sql — คอลัมน์ stamp_icon_path
  • apps/loyalty/migrations/005_transaction_mode.sql — scope ledger ด้วย mode
  • apps/loyalty/migrations/006 ถึง 009 — ระบบระดับสมาชิก ดู ระดับสมาชิก Loyalty
  • docs/superpowers/specs/2026-07-28-loyalty-tier-design.md และ docs/superpowers/plans/2026-07-28-loyalty-tier.md
  • docs/ROLLBACK-tier.md — จุด rollback ที่ครอบคลุมหลาย repo

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

  • client-api-go ให้บริการ LIFF ฝั่งลูกค้า (ดูบัตร สแกนรับแสตมป์ แลกรางวัล) และ LIFF ฝั่งพนักงาน (ออก QR รับโค้ดรางวัล)
  • cms-api-go ดูแลการตั้งค่าโปรแกรม รางวัล สาขา การเชิญและอนุมัติพนักงาน การปรับยอดด้วยมือ และรายงาน
  • ผูกกับผู้ใช้ผ่าน LINE userId ของ เพื่อน LINE และเปิดปิดแอปด้วย line_oa_app ใน ช่องทาง LINE OA