บัตรสะสมแต้มและระบบสมาชิก
รายละเอียดคอลัมน์ของ schema loyalty ทั้งหมด (program/branch/staff/account/card_instance/milestone/transaction/reward/earn_token/bonus)
ภาพรวม
Schema loyalty (ไม่รวมส่วน tier ซึ่งอยู่ในหน้า ระดับสมาชิก Loyalty) มี 12 ตาราง
จาก migration 001-005 — ฝั่งการตั้งค่ามี program (บัตร), branch (สาขา), staff (พนักงาน),
milestone (ของรางวัลตามระดับ), bonus_rule; ฝั่งข้อมูลลูกค้ามี account, card_instance,
transaction (บัญชีแยกประเภท), reward, earn_token (QR หมุนเวียน), bonus_grant และ audit_log
schema นี้ apply แยกด้วย psql ไม่ผ่าน Prisma
หลักการสำคัญสองข้อที่กำหนดรูปร่าง schema นี้: บัญชีแยกประเภทคือความจริง — ยอดคงเหลือไม่ถูกเก็บ
เป็นคอลัมน์ แต่คำนวณจากผลรวมของ lot ใน transaction ที่ยังไม่ถูกใช้และยังไม่หมดอายุ ณ เวลาที่อ่าน
และ บัตรที่อยู่ในมือลูกค้ารักษาเงื่อนไขเดิม — card_instance snapshot เวอร์ชันโปรแกรมและ
ขนาดบัตรที่ออกให้ ร้านค้าจึงแก้บัตรได้โดยไม่กระทบบัตรที่ลูกค้าสะสมอยู่
ตาราง loyalty.program
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่โปรแกรมนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
version | INTEGER | NO | 1 | เวอร์ชันของโปรแกรม เพิ่มขึ้นเมื่อมีการแก้เชิงโครงสร้าง |
mode | VARCHAR(10) | NO | 'stamp' | รูปแบบการสะสม: stamp หรือ point |
name | VARCHAR(120) | NO | - | ชื่อโปรแกรม/บัตร |
unit_label | VARCHAR(30) | NO | 'ดวง' | คำเรียกหน่วยสะสมที่แสดงบนบัตร |
card_size | INTEGER | YES | - | จำนวนช่องต่อบัตร (โหมด stamp) จำกัดไม่เกิน 20 |
baht_per_point | NUMERIC(12,2) | YES | - | ยอดใช้จ่ายต่อ 1 แต้ม (โหมด point) |
cover_path | TEXT | YES | - | path ของรูปหน้าปกบัตรใน object storage (เก็บ path ไม่ใช่ URL) |
logo_path | TEXT | YES | - | path ของโลโก้ร้าน |
theme | JSONB | NO | '{}' | ธีม/สีของบัตร |
terms | TEXT | YES | - | เงื่อนไขการใช้บัตร |
expiry_mode | VARCHAR(20) | NO | 'from_first' | วิธีคิดวันหมดอายุ: from_first, from_last, none |
expiry_months | INTEGER | YES | - | อายุบัตร/แต้ม (เดือน) ใช้คู่กับ expiry_mode |
cooldown_mode | VARCHAR(20) | NO | 'unlimited' | ข้อจำกัดความถี่การรับแต้ม: unlimited, per_hours, per_day |
cooldown_hours | INTEGER | YES | - | ระยะเวลาเว้นช่วง (ชั่วโมง) เมื่อ cooldown_mode = 'per_hours' |
status | VARCHAR(20) | NO | 'draft' | สถานะ: draft, active, paused, superseded |
published_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่เผยแพร่โปรแกรม |
created_by | INTEGER | YES | - | ผู้สร้างเรกคอร์ด |
updated_by | INTEGER | YES | - | ผู้แก้ไขล่าสุด |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
stamp_icon_path | TEXT | YES | - | path ของไอคอนแสตมป์ที่ร้านอัปโหลด ถ้าไม่มีจะแสดง unit_label แทน (migration 004) |
ตาราง loyalty.branch
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่สาขานี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
name | VARCHAR(120) | NO | - | ชื่อสาขา |
code | VARCHAR(40) | YES | - | รหัสสาขาที่ร้านใช้เอง |
status | VARCHAR(20) | NO | 'active' | สถานะการใช้งาน |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
deleted_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่ soft delete |
ตาราง loyalty.staff
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่พนักงานคนนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
line_user_id | VARCHAR(255) | YES | - | LINE user id ของพนักงาน — NULL ได้ตั้งแต่ migration 003 เพราะแถวสถานะ invited ยังไม่มีตัวตน LINE |
display_name | VARCHAR(255) | YES | - | ชื่อที่เจ้าของร้านตั้งให้ และไม่ถูกเขียนทับตอนรับคำเชิญ |
picture_url | TEXT | YES | - | URL รูปประจำตัว |
branch_id | INTEGER | YES | - | FK ไปยัง loyalty.branch(id) — NULL = ประจำได้ทุกสาขา |
status | VARCHAR(20) | NO | 'pending' | สถานะ: invited, pending, active, revoked |
approved_by | INTEGER | YES | - | ผู้อนุมัติ |
approved_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่อนุมัติ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
line_display_name | VARCHAR(255) | YES | - | ชื่อจากโปรไฟล์ LINE ของผู้ที่กดรับคำเชิญ (migration 003) |
line_picture_url | TEXT | YES | - | รูปจากโปรไฟล์ LINE ของผู้ที่กดรับคำเชิญ (migration 003) |
invite_token | VARCHAR(64) | YES | - | โทเคนคำเชิญแบบใช้ครั้งเดียว ถูกล้างเมื่อมีผู้รับคำเชิญ (migration 003) |
invite_expires_at | TIMESTAMPTZ(3) | YES | - | วันเวลาที่คำเชิญหมดอายุ (migration 003) |
invited_by | INTEGER | YES | - | ผู้ส่งคำเชิญ (migration 003) |
ตาราง loyalty.account
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่บัญชีนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
line_user_id | VARCHAR(255) | YES | - | LINE user id ของลูกค้า — NULL ได้ เพราะบัญชีที่สร้างจากเบอร์โทรยังไม่มีตัวตน LINE |
mobile_no | VARCHAR(32) | YES | - | เบอร์โทรลูกค้า ใช้กับบัญชีที่สร้างก่อนลูกค้าลงทะเบียน |
status | VARCHAR(20) | NO | 'active' | สถานะบัญชี |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
คอลัมน์ tier_id, tier_since, tier_evaluated_date ที่เพิ่มใน migration 006
อธิบายไว้ในหน้า ระดับสมาชิก Loyalty
ตาราง loyalty.card_instance
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
account_id | INTEGER | NO | - | FK ไปยัง loyalty.account(id) ลบแบบ CASCADE |
program_id | INTEGER | NO | - | FK ไปยัง loyalty.program(id) |
line_oa_id | INTEGER | NO | - | LINE OA ที่บัตรใบนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
program_version | INTEGER | NO | - | snapshot เวอร์ชันโปรแกรมที่ออกบัตรใบนี้ |
card_size | INTEGER | NO | - | snapshot จำนวนช่องของบัตรใบนี้ ณ ตอนออกบัตร |
sequence_no | INTEGER | NO | - | ลำดับที่ของบัตรในบัญชีลูกค้า (ใบที่ 1, 2, 3 ...) |
slots_filled | INTEGER | NO | 0 | จำนวนช่องที่ประทับแล้ว |
status | VARCHAR(20) | NO | 'active' | สถานะบัตร: active, complete, expired |
expires_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่บัตรหมดอายุ |
completed_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่บัตรเต็ม |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่ออกบัตร |
ตาราง loyalty.milestone
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
program_id | INTEGER | NO | - | FK ไปยัง loyalty.program(id) ลบแบบ CASCADE |
program_version | INTEGER | NO | - | เวอร์ชันโปรแกรมที่ของรางวัลนี้ผูกอยู่ |
line_oa_id | INTEGER | NO | - | LINE OA ที่ของรางวัลนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
required_units | INTEGER | NO | - | จำนวนหน่วยสะสมที่ต้องมีเพื่อได้รางวัลนี้ (ต้องมากกว่า 0) |
title | VARCHAR(150) | NO | - | ชื่อของรางวัล |
description | TEXT | YES | - | คำอธิบายของรางวัล |
image_path | TEXT | YES | - | path ของรูปของรางวัลใน object storage |
reward_expiry_days | INTEGER | YES | - | อายุของรางวัลหลังได้รับ (วัน) — NULL = ใช้วันหมดอายุตามบัตร |
sort_order | INTEGER | NO | 0 | ลำดับการแสดงผล |
status | VARCHAR(20) | NO | 'active' | สถานะการใช้งาน |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
คอลัมน์ min_tier_rank ที่เพิ่มใน migration 006 อธิบายไว้ในหน้า
ระดับสมาชิก Loyalty
ตาราง loyalty.transaction
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | BIGSERIAL | NO | auto increment | Primary key |
account_id | INTEGER | NO | - | FK ไปยัง loyalty.account(id) ลบแบบ CASCADE |
line_oa_id | INTEGER | NO | - | LINE OA ที่รายการนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
program_id | INTEGER | YES | - | โปรแกรมที่รายการนี้เกิดขึ้นภายใต้ |
program_version | INTEGER | YES | - | เวอร์ชันโปรแกรม ณ เวลาที่บันทึกรายการ |
kind | VARCHAR(20) | NO | - | ชนิดรายการ: earn, burn, expire, adjust |
units | INTEGER | NO | - | จำนวนหน่วยของรายการนี้ |
amount_spent | NUMERIC(12,2) | YES | - | ยอดใช้จ่าย (โหมด point) — NULL สำหรับแสตมป์ |
branch_id | INTEGER | YES | - | FK ไปยัง loyalty.branch(id) — สาขาที่เกิดรายการ |
staff_id | INTEGER | YES | - | FK ไปยัง loyalty.staff(id) — พนักงานที่ทำรายการ |
source | VARCHAR(30) | NO | - | ที่มาของรายการ เช่น การสแกน QR, การปรับด้วยมือ, โบนัส |
source_ref | INTEGER | YES | - | id อ้างอิงไปยังต้นทางตาม source |
card_instance_id | INTEGER | YES | - | FK ไปยัง loyalty.card_instance(id) — บัตรที่รายการนี้ลง |
expires_date | TIMESTAMPTZ(3) | YES | - | วันหมดอายุของ lot (ใช้เฉพาะรายการ earn) |
consumed_units | INTEGER | NO | 0 | จำนวนหน่วยของ lot นี้ที่ถูกใช้ไปแล้ว — คอลัมน์เดียวในตารางที่ถูก UPDATE |
idempotency_key | VARCHAR(80) | YES | - | คีย์กันบันทึกซ้ำ ปกติคือ id ของ earn token |
note | TEXT | YES | - | หมายเหตุ |
created_by | INTEGER | YES | - | ผู้บันทึกรายการ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่บันทึก |
mode | VARCHAR(10) | NO | 'stamp' | โหมดของรายการ: stamp หรือ point — กันไม่ให้แสตมป์กับแต้มปนกัน (migration 005) |
ตาราง loyalty.reward
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
account_id | INTEGER | NO | - | FK ไปยัง loyalty.account(id) ลบแบบ CASCADE |
milestone_id | INTEGER | NO | - | FK ไปยัง loyalty.milestone(id) — ของรางวัลที่ได้ |
card_instance_id | INTEGER | YES | - | FK ไปยัง loyalty.card_instance(id) — บัตรที่ทำให้ได้รางวัลนี้ |
line_oa_id | INTEGER | NO | - | LINE OA ที่รางวัลนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
code | VARCHAR(16) | NO | - | รหัสรับรางวัล ตัดตัวอักษร I, L, O, U และเลข 0/1 ออกเพื่อกันอ่านผิด |
status | VARCHAR(20) | NO | 'unclaimed' | สถานะ: unclaimed, claimed, expired |
expires_date | TIMESTAMPTZ(3) | YES | - | วันหมดอายุของรางวัล |
claimed_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่รับรางวัล |
claimed_by_staff_id | INTEGER | YES | - | FK ไปยัง loyalty.staff(id) — พนักงานที่จ่ายรางวัล |
claimed_branch_id | INTEGER | YES | - | FK ไปยัง loyalty.branch(id) — สาขาที่รับรางวัล |
claimed_transaction_id | BIGINT | YES | - | id ของรายการ burn ที่คู่กับการรับรางวัลนี้ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่ได้รับรางวัล |
ตาราง loyalty.earn_token
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่โทเคนนี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
program_id | INTEGER | NO | - | FK ไปยัง loyalty.program(id) |
token | VARCHAR(64) | NO | - | ค่าโทเคนใน QR (unique) ใช้ได้ครั้งเดียวและอายุสั้น |
staff_id | INTEGER | NO | - | FK ไปยัง loyalty.staff(id) — พนักงานที่ออกโทเคน (บังคับ) |
branch_id | INTEGER | NO | - | FK ไปยัง loyalty.branch(id) — สาขาที่ออกโทเคน (บังคับ) |
units | INTEGER | NO | - | จำนวนหน่วยที่จะได้เมื่อสแกน (ต้องมากกว่า 0) |
amount_spent | NUMERIC(12,2) | YES | - | ยอดใช้จ่ายที่ผูกกับโทเคน (โหมด point) |
expires_at | TIMESTAMPTZ(3) | NO | - | วันเวลาที่โทเคนหมดอายุ |
consumed_by_account_id | INTEGER | YES | - | FK ไปยัง loyalty.account(id) — บัญชีที่สแกนโทเคนนี้ |
consumed_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่โทเคนถูกใช้ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่ออกโทเคน |
ตาราง loyalty.bonus_rule
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
program_id | INTEGER | NO | - | FK ไปยัง loyalty.program(id) ลบแบบ CASCADE |
line_oa_id | INTEGER | NO | - | LINE OA ที่กติกานี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
trigger_type | VARCHAR(30) | NO | - | เงื่อนไขกระตุ้น: join, birthday, first_earn, time_window, spend_threshold, audience |
effect | VARCHAR(20) | NO | - | ผลลัพธ์: grant_units (แจกหน่วย) หรือ multiply (คูณ) |
units | INTEGER | YES | - | จำนวนหน่วยที่แจก เมื่อ effect = 'grant_units' |
multiplier | NUMERIC(4,2) | YES | - | ตัวคูณ เมื่อ effect = 'multiply' |
conditions | JSONB | NO | '{}' | เงื่อนไขเพิ่มเติมของกติกา |
cadence | VARCHAR(20) | NO | 'once' | ความถี่ที่ให้ได้: once, yearly, daily |
valid_from | TIMESTAMPTZ(3) | YES | - | วันเวลาที่กติกาเริ่มมีผล |
valid_to | TIMESTAMPTZ(3) | YES | - | วันเวลาที่กติกาสิ้นผล |
status | VARCHAR(20) | NO | 'active' | สถานะการใช้งาน |
priority | INTEGER | NO | 0 | ลำดับความสำคัญเมื่อมีหลายกติกาเข้าเงื่อนไขพร้อมกัน |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
ตาราง loyalty.bonus_grant
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | SERIAL | NO | auto increment | Primary key |
rule_id | INTEGER | NO | - | FK ไปยัง loyalty.bonus_rule(id) ลบแบบ CASCADE |
account_id | INTEGER | NO | - | FK ไปยัง loyalty.account(id) ลบแบบ CASCADE |
transaction_id | BIGINT | YES | - | id ของรายการใน ledger ที่เกิดจากโบนัสนี้ |
line_oa_id | INTEGER | NO | - | LINE OA ที่การแจกโบนัสนี้สังกัด |
period_key | VARCHAR(20) | NO | - | คีย์รอบการแจก: '' = ครั้งเดียวตลอดกาล, '2026' = รายปี, '2026-07-27' = รายวัน |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่แจกโบนัส |
ตาราง loyalty.audit_log
| Column | Type | Nullable | Default | คำอธิบาย |
|---|
id | BIGSERIAL | NO | auto increment | Primary key |
line_oa_id | INTEGER | NO | - | LINE OA ที่เหตุการณ์นี้สังกัด |
organization_id | INTEGER | NO | - | องค์กรเจ้าของข้อมูล |
actor_type | VARCHAR(20) | NO | - | ชนิดผู้กระทำ เช่น admin / staff / system |
actor_id | VARCHAR(255) | YES | - | รหัสผู้กระทำ |
actor_name | VARCHAR(255) | YES | - | ชื่อผู้กระทำ ณ เวลาที่บันทึก |
action | VARCHAR(50) | NO | - | การกระทำ เช่น แก้โปรแกรม อนุมัติพนักงาน ปรับยอดด้วยมือ |
target_type | VARCHAR(20) | NO | - | ชนิดของสิ่งที่ถูกกระทำ |
target_id | INTEGER | YES | - | id ของสิ่งที่ถูกกระทำ |
before | JSONB | YES | - | ข้อมูลก่อนเปลี่ยนแปลง |
after | JSONB | YES | - | ข้อมูลหลังเปลี่ยนแปลง |
ip | VARCHAR(45) | YES | - | IP address ของผู้กระทำ |
user_agent | TEXT | YES | - | User agent ของผู้กระทำ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่บันทึก |
หมายเหตุ
- ไม่มีคอลัมน์ยอดคงเหลือ — ยอดคำนวณจากผลรวมของ lot ใน
transaction ที่ kind = 'earn'
ซึ่งยังไม่ถูกใช้และยังไม่หมดอายุ ณ เวลาที่อ่าน ถ้าเก็บเป็นคอลัมน์จะผิดทันทีที่ lot หมดอายุ
เพราะการหมดอายุเป็นฟังก์ชันของเวลา ไม่ใช่ของการเขียนข้อมูล
- Check constraint ของ
program: chk_loy_program_mode (mode IN ('stamp','point')),
chk_loy_program_expiry, chk_loy_program_cooldown, chk_loy_program_status,
chk_loy_program_size (โหมด stamp ต้องมี card_size ระหว่าง 1-20 มิฉะนั้นจะหารด้วยศูนย์),
chk_loy_program_cdhours (cooldown_mode = 'per_hours' ต้องมี cooldown_hours > 0)
- Unique ของ
program: idx_loy_program_active บน line_oa_id
WHERE status IN ('draft','active','paused') — หนึ่ง OA มีโปรแกรมที่ยังมีชีวิตได้เพียงตัวเดียว
ส่วนแถว superseded อยู่ร่วมกันได้เพื่อให้อ่านบัตรเก่าได้
- Check constraint ของ
staff: chk_loy_staff_status
(status IN ('invited','pending','active','revoked')) และ chk_loy_staff_invite ที่บังคับว่า
แถว invited ต้องไม่มี line_user_id แต่ต้องมี invite_token ส่วนแถวสถานะอื่นต้องมี
line_user_id และต้องไม่มี invite_token ค้าง
- เส้นทางสถานะพนักงาน:
invited → (รับคำเชิญ) → pending → (อนุมัติ) → active →
(เพิกถอน) → revoked ขั้น pending มีไว้โดยตั้งใจ เพราะลิงก์คำเชิญถูกส่งต่อได้
การค้างที่ pending ทำให้เจ้าของร้านเห็นว่าบัญชี LINE ใดเป็นผู้รับคำเชิญจริง
เทียบกับชื่อที่ตนพิมพ์ไว้ และปฏิเสธได้ถ้าไม่ตรง
- Deny by default: เฉพาะ
status = 'active' เท่านั้นที่ออกแสตมป์ได้ สถานะอื่นทั้งหมด
รวมถึงค่าที่ไม่รู้จักหรือว่างเปล่าถือว่าไม่ผ่าน
- Unique ของ
staff: idx_loy_staff_user บน (line_oa_id, line_user_id)
WHERE line_user_id IS NOT NULL (partial เพื่อให้คำเชิญที่ยังไม่ถูกรับหลายใบอยู่ร่วมกันได้)
และ idx_loy_staff_invite บน invite_token WHERE invite_token IS NOT NULL ซึ่งทำหน้าที่
บังคับ single-use ไปในตัว เพราะการรับคำเชิญจะล้าง invite_token ทิ้ง
- Unique ของ
account: idx_loy_account_line บน (line_oa_id, line_user_id)
WHERE line_user_id IS NOT NULL และ idx_loy_account_phone บน (line_oa_id, mobile_no)
WHERE mobile_no IS NOT NULL AND line_user_id IS NULL — เมื่อบัญชีเบอร์โทรรวมเข้ากับบัญชี LINE
index ตัวหลังจะเลิกมีผล ซึ่งเป็นสิ่งที่ทำให้การรวมบัญชีเกิดขึ้นได้
- Constraint ของ
card_instance: chk_loy_card_status, chk_loy_card_filled
(slots_filled ต้องอยู่ระหว่าง 0 ถึง card_size) และ unique idx_loy_card_seq บน
(account_id, sequence_no)
- Unique ของ
milestone: idx_loy_milestone_at บน (program_id, program_version,
required_units) — ห้ามมีสองรางวัลที่เกณฑ์เดียวกัน เพราะจะตอบไม่ได้ว่าครบ 10 ดวงแล้วได้อะไร
- Constraint ของ
transaction: chk_loy_txn_kind, chk_loy_txn_consumed
(consumed_units ต้องอยู่ระหว่าง 0 ถึง units), chk_loy_txn_mode (migration 005) และ
unique idx_loy_txn_idem บน (line_oa_id, idempotency_key)
WHERE idempotency_key IS NOT NULL — ลูกค้าที่กดซ้ำหรือเปิด LIFF ใหม่จึงไม่ถูกประทับสองครั้ง
โดยบังคับที่ระดับฐานข้อมูล ไม่ใช่ที่ตรรกะแอป
transaction.mode แยกสกุลหน่วย (migration 005): แสตมป์กับแต้มเป็นคนละสกุล การอ่านยอด
จึงกรองด้วย mode ด้วย lot ของโหมดเดิมไม่ถูกลบทิ้ง เพียงหยุดนับระหว่างที่โหมดอื่นทำงาน
และกลับมาใช้ได้เหมือนเดิมถ้าร้านสลับกลับ
- Unique ของ
reward / earn_token / bonus_grant: idx_loy_reward_code บน
(line_oa_id, code); idx_loy_token บน token; idx_loy_bonus_once บน
(rule_id, account_id, period_key) ซึ่งทำให้การแจกโบนัสซ้ำเป็นไปไม่ได้ที่ระดับฐานข้อมูล
ครอบคลุมทุก cadence ด้วยคอลัมน์เดียว
- Storage path ไม่ใช่ URL:
cover_path, logo_path, stamp_icon_path, image_path
เก็บเป็น object path เท่านั้น การเก็บ URL จะผูกติดกับ layout ของ bucket และพังเมื่อค่า
STORAGE_PUBLIC_URL_INCLUDE_BUCKET เปลี่ยน ฝั่ง client แปลงเป็น URL ตอนอ่านผ่าน storagex