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 ของพนักงานที่ออกแสตมป์ได้ แบบเชิญเข้าเท่านั้น

  • วงจรชีวิตของพนักงาน: invitedpendingactiverevoked
  • 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