ระดับสมาชิก Loyalty (Tier)
รายละเอียดคอลัมน์ของ loyalty.tier, tier_history
ภาพรวม
ฟีเจอร์ระดับสมาชิกเพิ่มเข้ามาใน migration 006-009 ของ schema loyalty ประกอบด้วย 2 ตารางใหม่คือ
tier (นิยามแต่ละขั้นบันไดพร้อมกติกา) และ tier_history (ประวัติการเลื่อน/ลดขั้นทุกครั้ง)
พร้อมคอลัมน์ที่เพิ่มเข้าไปในตารางเดิม 3 ตาราง (account, milestone, program)
ทุก migration ในกลุ่มนี้เป็นแบบ additive — สร้างตารางใหม่และเพิ่มคอลัมน์ที่ NULL ได้หรือมี default
เท่านั้น binary รุ่นเก่าจึงยังทำงานกับ schema ใหม่ได้ ทำให้ rollback แบบแก้เฉพาะโค้ดปลอดภัย
ฟีเจอร์นี้ใช้ได้กับ โหมด point เท่านั้น โดยตั้งใจ เพราะกติกาที่ร้านตั้งวัดจากยอดใช้จ่ายสะสม
ในกรอบเวลา แต่ amount_spent เป็น NULL ในทุกรายการโหมด stamp — แสตมป์หนึ่งดวงคือหนึ่งดวง
ไม่ว่าตะกร้าจะราคาเท่าไร การสลับไปโหมด stamp จึงพัก tier ไว้เหมือนที่พักยอดคงเหลือและบันไดรางวัล
ตาราง loyalty.tier
| 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 | - | องค์กรเจ้าของข้อมูล |
name | VARCHAR(60) | NO | - | ชื่อระดับสมาชิก เช่น Silver, Gold |
rank | INTEGER | NO | - | ลำดับขั้นบันได (ต้องมากกว่า 0) — เป็นอันดับ ไม่ใช่เกณฑ์ รายการจึงไม่สลับที่เองระหว่างที่ร้านกำลังแก้ |
threshold | NUMERIC(12,2) | YES | - | เกณฑ์ยอดใช้จ่ายแบบเดิม (บาท) — เลิกใช้แล้ว ถูกแทนที่ด้วย conditions คงไว้เพื่อรองรับการ rollback (migration 007) |
color | VARCHAR(9) | YES | - | สีประจำระดับ |
bg_image_path | TEXT | YES | - | path ของภาพพื้นหลังบัตรระดับนี้ |
status | VARCHAR(20) | NO | 'active' | สถานะ: active หรือ archived |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่สร้าง |
updated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่แก้ไขล่าสุด |
conditions | JSONB | NO | '{"join":"and","rules":[]}' | ชุดกติกาของระดับนี้ (migration 007) |
window_months | INTEGER | YES | - | กรอบเวลาที่ใช้รวมยอดของระดับนี้ (เดือน, 1-120) — กำหนดแยกรายระดับ (migration 007) |
text_color | VARCHAR(9) | YES | - | สีตัวอักษรบนบัตรระดับนี้ — NULL = ใช้ค่าของโปรแกรม (migration 008) |
is_default | BOOLEAN | NO | false | ระดับตั้งต้นที่ลูกค้าอยู่เมื่อยังไม่เข้าเกณฑ์ระดับใด — กติกาของระดับนี้ไม่ถูกประเมิน (migration 009) |
รูปแบบของ conditions
{
"join": "and",
"rules": [
{ "metric": "member_months", "op": "gte", "value": 6 },
{ "metric": "spend", "op": "gte", "value": 1000 },
{ "metric": "orders", "op": "gte", "value": 5 }
]
}
metric:spend|orders|points|member_monthsop:gte|lte|eqwindow_monthsใช้กับทุก metric ยกเว้นmember_monthsซึ่งเป็นอายุสมาชิก จึงเป็นระยะเวลา ไม่ใช่ยอดรวมภายในกรอบเวลา
ตาราง loyalty.tier_history
| 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 | - | องค์กรเจ้าของข้อมูล |
from_tier_id | INTEGER | YES | - | FK ไปยัง loyalty.tier(id) — ระดับก่อนเปลี่ยน (NULL เมื่อเป็นการกำหนดครั้งแรก) |
to_tier_id | INTEGER | YES | - | FK ไปยัง loyalty.tier(id) — ระดับหลังเปลี่ยน |
reason | VARCHAR(20) | NO | - | สาเหตุ: promote, demote, initial |
window_value | NUMERIC(12,2) | NO | 0 | ค่ารวมในกรอบเวลาที่ทำให้ตัดสินใจเช่นนั้น เก็บไว้เพื่อให้แถวอธิบายตัวเองได้ |
created_date | TIMESTAMPTZ(3) | NO | CURRENT_TIMESTAMP | วันเวลาที่เปลี่ยนระดับ |
คอลัมน์ที่เพิ่มในตารางเดิม
loyalty.account (migration 006)
| Column | Type | Nullable | Default | คำอธิบาย |
|---|---|---|---|---|
tier_id | INTEGER | YES | - | FK ไปยัง loyalty.tier(id) — ระดับที่ลูกค้าอยู่ในปัจจุบัน |
tier_since | TIMESTAMPTZ(3) | YES | - | วันเวลาที่เข้าสู่ระดับปัจจุบัน |
tier_evaluated_date | TIMESTAMPTZ(3) | YES | - | วันเวลาที่ประเมินระดับครั้งล่าสุด |
loyalty.milestone (migration 006)
| Column | Type | Nullable | Default | คำอธิบาย |
|---|---|---|---|---|
min_tier_rank | INTEGER | YES | - | ระดับขั้นต่ำที่รับรางวัลนี้ได้ — NULL = เปิดให้ทุกคน ซึ่งเป็นค่าที่รางวัลเดิมทุกใบต้องคงไว้ |
loyalty.program (migration 006)
| Column | Type | Nullable | Default | คำอธิบาย |
|---|---|---|---|---|
tier_enabled | BOOLEAN | NO | false | เปิด/ปิดฟีเจอร์ระดับสมาชิกของโปรแกรมนี้ |
tier_window_months | INTEGER | NO | 3 | กรอบเวลารวมยอดระดับโปรแกรม (เดือน, 1-120) |
tier_fallback | VARCHAR(20) | NO | 'step_down' | วิธีลดระดับเมื่อไม่เข้าเกณฑ์: step_down (ลดทีละขั้น) หรือ specific (ลงไประดับที่กำหนด) |
tier_fallback_tier_id | INTEGER | YES | - | FK ไปยัง loyalty.tier(id) — ระดับปลายทางเมื่อ tier_fallback = 'specific' |
หมายเหตุ
- Check constraint ของ
tier:chk_loy_tier_rank(rank > 0),chk_loy_tier_threshold(threshold >= 0),chk_loy_tier_status(status IN ('active','archived')) และchk_loy_tier_window(window_monthsเป็น NULL หรืออยู่ระหว่าง 1-120) - Check constraint อื่น:
chk_loy_tier_hist_reason(reason IN ('promote','demote','initial')),chk_loy_milestone_tier(min_tier_rankเป็น NULL หรือมากกว่า 0),chk_loy_program_tier_window(tier_window_monthsระหว่าง 1-120),chk_loy_program_tier_fallback(tier_fallback IN ('step_down','specific')) - Unique:
idx_loy_tier_rankบน (program_id,rank)WHERE status = 'active'— หนึ่งระดับ ต่อหนึ่งขั้น เป็น partial index จึงปลดล็อกให้ rank เดิมกลับมาใช้ใหม่ได้เมื่อ archive ระดับนั้นไป และidx_loy_tier_one_defaultบนprogram_idWHERE is_default AND status = 'active'— หนึ่งโปรแกรมมีระดับตั้งต้นได้ไม่เกินหนึ่งระดับ ถ้ามีสองระดับจะตอบไม่ได้ว่า "ระดับที่ตกลงมา" คือระดับไหน - Index อื่น:
idx_loy_tier_programบน (program_id,rank);idx_loy_tier_hist_acctบน (account_id,created_date DESC);idx_loy_account_tierบน (line_oa_id,tier_id)WHERE tier_id IS NOT NULL - เกณฑ์วัดจากยอดใช้จ่าย ไม่ใช่แต้ม:
thresholdและ metricspendคิดเป็นบาทของยอดใช้จ่าย ในกรอบเวลา ไม่ใช่แต้ม เพราะแต้มถูกหักเมื่อแลกรางวัล การใช้แต้มเป็นเกณฑ์จะทำให้ลูกค้าถูกลดระดับ เพราะแลกของรางวัล ซึ่งเป็นเหตุผลเดียวกับที่ไม่คำนวณระดับจากยอดคงเหลือ - ทำไม
tier_historyถึงจำเป็น: กรอบเวลาแบบ rolling ทำให้ลูกค้าหลุดระดับได้เองโดยไม่ได้ทำอะไร คำถาม "ทำไมฉันถึงถูกลดระดับ" จึงเป็นคำถามที่ฟีเจอร์นี้สร้างขึ้นแน่นอน ถ้าไม่มีค่าwindow_valueที่ทำให้เกิดการตัดสินใจนั้นเก็บไว้ ก็จะไม่มีใครตอบได้ - ทำไม
conditionsถึงเป็น JSONB ไม่ใช่ตารางลูก: กติกาถูกอ่านทั้งชุดพร้อมกับ tier เสมอ ไม่เคย query ข้าม tier การแยกเป็นตารางจะเพิ่ม join และเพิ่มหน้าจอ CRUD อีกชุดสำหรับสิ่งที่ เป็นออบเจ็กต์เดียว — แนวเดียวกับbonus_rule.conditionsและ audience filter - การย้ายข้อมูลของ migration 007: แปลง
thresholdเดิมของทุก tier เป็นกติกาspendหนึ่งข้อที่มีความหมายเท่าเดิม บันไดของร้านที่ตั้งไว้แล้วจึงไม่ถูกรีเซ็ต และคอลัมน์thresholdถูกเปลี่ยนเป็น nullable แทนการ drop เพื่อให้ binary รุ่นก่อนหน้ายังหาคอลัมน์ที่คาดหวังเจอ (มีCOMMENT ON COLUMNระบุไว้ว่าเป็น legacy และไม่ถูกอ่าน)