▸ 複製此程式碼至 Draw.io → Arrange → Insert → Advanced → PlantUML
▸ 即可得到圖形 ERD,之後匯出erd.png。
提示:導入 Supabase 後請
ALTER TABLE ... ENABLE RLS;並套用先前rls-policy.md。
若需多 SKU 支援,可將sku改成獨立product_variants表並把inventoryFK 指向之。
待釐清:
- 新增 public.order_status_logs 表,用於追蹤每次狀態變更,以檢視付款時間、發貨時間、回購率等指標
- 新增 view: order_with_owner,用於顯示 order 資料及 owner 資料,顯示 owner 的 role
- 需要手動更新初始化:agent_assignments
sql
insert into agent_assignments (user_id)
select user_id from agent_users
on conflict (user_id) do nothing;
- 有效訂單的條件為 status in ('paid', 'processing', 'shipped', 'delivered', 'completed')
supabase Edge Functions:
- sync-user-record: 用於同步 auth.users 資料到 public.users
- order-summary: 用於查詢訂單統計資料。與
order_summary_dailyview 有差異,查詢更長時間的訂單統計且整合多個指標專供前端使用- allocate-inventory: 用於調整商品庫存,處理 out 情況,成功調整後回傳當前商品的庫存
- order-create: 用於建立訂單並扣除庫存
supabase Realtime:
- channel:
orders-dashboard,用於訂單實時更新- 需設定 realtime on 的資料表:
orders- channel:
conversation-${conversationId},用於客服聊天室訊息實時更新- channel:
conversation,用於客服對話清單實時更新- 需設定 realtime on 的資料表:
conversations,messages
supabase Postgre View:
- view:
user_with_roles,用於顯示 user 資料及 role- view:
order_summary_daily,用於顯示每日訂單統計- view:
active_products,顯示未刪除的產品- view:
inventory_with_stock_detail,顯示庫存入庫狀況(並非 logs)- view:
product_with_current_stock,顯示商品詳細頁- view:
product_inventory_status,顯示商品庫存狀態,FOR inventory 頁面- view:
conversation_details,顯示對話詳細資訊,用於對話列表- view:
role_permissions_view,顯示角色權限- view:
user_rfm_lifecycle_metrics,顯示 RFM 指標、客戶生命周期、LTV
- view:
order_status_distribution- view:
product_sales_daily- view:
customer_purchase_summary_daily- view:
order_metrics_hourly- view:
order_amount_histogram_bins- view:
revenue_by_campaign- view:
revenue_ltv_distribution
- table:
dim_date- table:
holidays- table:
campaigns
supabase Postgre Functions:
- update_updated_at_column: 更新 updated_at 欄位
- allocate_stock_fifo: FIFO 分配庫存
- get_product_current_stock: 取得商品目前庫存
- create_order_with_items: 建立訂單並扣除庫存
- calculate_order_total: 計算訂單總額
- check_order_business_rules
- validate_order_status_transition
- generate_order_number
- sync_order_status_with_payment
- get_product_overview: 取得產品概覽
- get_inventory_overview: 取得庫存概覽
- get_customer_overview: 取得客戶概覽
- get_user_rfm_overview: 取得 RFM 概覽
- get_order_trend_analysis: 取得訂單趨勢分析
- get_product_sales_analysis: 取得產品銷售分析
- get_inventory_analysis: 取得庫存分析
- get_customer_analysis: 取得客戶分析
- trg_assign_this_conversation: 自動指派對話
supabase Postgre Trigger:
- set_updated_at_inventories: 更新 inventories 表的 updated_at 欄位
- sync_order_status_with_payment: 同步訂單狀態
- update_conversation_last_reply_timestamp: 更新對話的最後回覆時間
sql
create or replace view order_summary_daily as
select
o.created_at::date as order_date,
count(o.id) as total_orders,
count(case when o.status = 'pending' then 1 end) as pending_orders,
count(case when o.status = 'paid' then 1 end) as paid_orders,
count(case when o.status = 'shipped' then 1 end) as shipped_orders,
count(case when o.status = 'completed' then 1 end) as completed_orders,
count(case when o.status = 'cancelled' then 1 end) as cancelled_orders,
-- 金額統計
coalesce(sum(o.total_amount), 0) as total_amount,
-- 付款率 (%)
case
when count(o.id) > 0 then round(count(case when o.status = 'paid' then 1 end) * 100.0 / count(o.id), 2)
else 0
end as paid_rate_percentage,
-- 取消率 (%)
case
when count(o.id) > 0 then round(count(case when o.status = 'cancelled' then 1 end) * 100.0 / count(o.id), 2)
else 0
end as cancelled_rate_percentage,
-- 完成率 (%)
case
when count(o.id) > 0 then round(count(case when o.status = 'completed' then 1 end) * 100.0 / count(o.id), 2)
else 0
end as completed_rate_percentage
from orders o
group by o.created_at::date;sql
SELECT *
FROM order_summary_daily
WHERE order_date = CURRENT_DATE;
---
SELECT *
FROM order_summary_daily
WHERE order_date >= CURRENT_DATE - INTERVAL '7 days'
AND order_date <= CURRENT_DATE;
---
SELECT *
FROM order_summary_daily
WHERE order_date >= '2023-04-01'
AND order_date <= '2023-04-07';ts
const { data, error } = await supabase
.from('order_summary_daily')
.select('*')
.gte('order_date', '2023-04-01') // 這裡傳入起始日期
.lte('order_date', '2023-04-07'); // 這裡傳入結束日期
if (error) {
console.error(error);
} else {
console.log(data);
}sql
CREATE OR REPLACE VIEW public.user_with_roles AS
SELECT
u.id,
u.email,
u.full_name,
u.avatar_url,
u.created_at,
-- 取得所有角色名稱陣列
COALESCE(
ARRAY_AGG(r.name ORDER BY r.name) FILTER (WHERE r.name IS NOT NULL),
'{}'
) AS roles
FROM
public.users u
LEFT JOIN public.user_roles ur ON ur.user_id = u.id
LEFT JOIN public.roles r ON r.id = ur.role_id
GROUP BY
u.id, u.email, u.full_name, u.avatar_url, u.created_at;sql
CREATE OR REPLACE FUNCTION create_order_with_items(
order_data jsonb,
items jsonb[],
created_by uuid
)
RETURNS json AS $$
DECLARE
new_order_id uuid;
item jsonb;
in_created_by uuid := (order_data->>'created_by')::uuid; -- 新增:從 order_data 解析建立者
BEGIN
-- 驗證商品清單不為空
IF array_length(items, 1) IS NULL OR array_length(items, 1) = 0 THEN
RAISE EXCEPTION 'Order must contain at least one item';
END IF;
-- 開始交易
BEGIN
-- 建立訂單
INSERT INTO orders (
user_id, status, total_amount,
shipping_fee, discount_amount, coupon_discount, tax_amount
)
VALUES (
(order_data->>'user_id')::uuid,
'pending',
COALESCE((order_data->>'total_amount')::numeric, 0),
COALESCE((order_data->>'shipping_fee')::numeric, 0),
COALESCE((order_data->>'discount_amount')::numeric, 0),
COALESCE((order_data->>'coupon_discount')::numeric, 0),
COALESCE((order_data->>'tax_amount')::numeric, 0)
)
RETURNING id INTO new_order_id;
-- 建立每個訂單項目並執行 FIFO 庫存扣除
FOREACH item IN ARRAY items
LOOP
INSERT INTO order_items (
order_id, product_id, quantity, unit_price, total_price
)
VALUES (
new_order_id,
(item->>'product_id')::uuid,
(item->>'quantity')::integer,
(item->>'price')::numeric,
((item->>'quantity')::integer * (item->>'price')::numeric)
);
-- 執行 FIFO 庫存扣除
PERFORM allocate_stock_fifo(
in_created_by := in_created_by,
in_product_id := (item->>'product_id')::uuid,
in_quantity := (item->>'quantity')::integer,
in_ref_id := new_order_id,
in_source := 'order',
in_created_by := created_by
);
END LOOP;
-- 成功回傳
RETURN json_build_object('order_id', new_order_id);
EXCEPTION
WHEN OTHERS THEN
-- 若發生任何錯誤,則回滾並回傳錯誤訊息
RAISE EXCEPTION 'Failed to create order: %', SQLERRM;
END;
END;
$$ LANGUAGE plpgsql;sql
-- 1. 觸發器函數,根據 payments.status 更新 orders.status
CREATE OR REPLACE FUNCTION sync_order_status_with_payment()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'pending' THEN
UPDATE orders SET status = 'pending', updated_at = NOW() WHERE id = NEW.order_id;
ELSIF NEW.status = 'completed' THEN
UPDATE orders SET status = 'paid', updated_at = NOW() WHERE id = NEW.order_id;
ELSIF NEW.status = 'failed' THEN
-- 付款失敗,訂單仍維持 pending
UPDATE orders SET status = 'pending', updated_at = NOW() WHERE id = NEW.order_id;
ELSIF NEW.status = 'refunded' THEN
UPDATE orders SET status = 'refunded', updated_at = NOW() WHERE id = NEW.order_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. 觸發器,新增付款時執行同步
CREATE TRIGGER trg_sync_order_status_after_payment_insert
AFTER INSERT ON payments
FOR EACH ROW
EXECUTE FUNCTION sync_order_status_with_payment();
-- 3. 觸發器,更新付款狀態時同步訂單狀態
CREATE TRIGGER trg_sync_order_status_after_payment_update
AFTER UPDATE OF status ON payments
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION sync_order_status_with_payment();sql
create or replace view public.product_inventory_status as
with inventory_stock as (
select
product_id,
sum(current_stock) as total_available_stock
from
inventory_with_stock_detail
group by
product_id
),
reserved_stock as (
select
oi.product_id,
sum(oi.quantity) as reserved_quantity
from
order_items oi
join orders o on oi.order_id = o.id
where
o.status in ('pending', 'paid')
group by
oi.product_id
)
select
p.id as product_id,
p.name,
p.sku,
p.stock_threshold,
coalesce(i.total_available_stock, 0) + coalesce(r.reserved_quantity, 0) as total_stock,
coalesce(r.reserved_quantity, 0) as reserved_quantity,
coalesce(i.total_available_stock, 0) as free_stock,
coalesce(i.total_available_stock, 0) - coalesce(p.stock_threshold, 0) as stock_buffer,
case
when coalesce(i.total_available_stock, 0) = 0 then 'out_of_stock'
when coalesce(i.total_available_stock, 0) <= coalesce(p.stock_threshold, 0) then 'low_stock'
else 'in_stock'
end as stock_status,
case
when coalesce(i.total_available_stock, 0) = 0 then 0 -- out_of_stock
when coalesce(i.total_available_stock, 0) < coalesce(p.stock_threshold, 0) then 1 -- low_stock
else 2 -- in_stock
end as stock_status_order
from
products p
left join inventory_stock i on p.id = i.product_id
left join reserved_stock r on p.id = r.product_id;sql
CREATE OR REPLACE VIEW conversation_details AS
SELECT
c.id AS conversation_id,
c.user_id,
c.status,
c.title,
c.priority,
c.tags,
c.created_at AS conversation_created_at,
c.updated_at AS conversation_updated_at,
c.last_reply_at,
c.assigned_to,
-- 用戶信息
-- u.id AS user_id,
u.email AS user_email,
u.full_name AS user_full_name,
u.avatar_url AS user_avatar_url,
-- 最新消息
(
SELECT m.message
FROM messages m
WHERE m.conversation_id = c.id
ORDER BY m.created_at DESC
LIMIT 1
) AS latest_message,
-- 最新消息時間
(
SELECT m.created_at
FROM messages m
WHERE m.conversation_id = c.id
ORDER BY m.created_at DESC
LIMIT 1
) AS latest_message_time,
-- 未讀消息數量
(
SELECT COUNT(*)
FROM messages m
WHERE m.conversation_id = c.id
AND m.is_admin = false
AND m.read_at IS NULL
) AS unread_count,
-- 消息總數
(
SELECT COUNT(*)
FROM messages m
WHERE m.conversation_id = c.id
) AS message_count
FROM conversations c
LEFT JOIN users u ON c.user_id = u.id;sql
-- 創建一個函數來更新對話的最後回覆時間
CREATE OR REPLACE FUNCTION update_conversation_last_reply()
RETURNS TRIGGER AS $$
BEGIN
UPDATE conversations
SET last_reply_at = now()
WHERE id = NEW.conversation_id;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 為 messages 表添加觸發器,當新增消息時更新對話的最後回覆時間
CREATE TRIGGER update_conversation_last_reply_timestamp
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION update_conversation_last_reply();sql
create function is_me(input_user_id uuid)
returns boolean
language sql
as $$
select exists (
select 1
from public.users
where id = input_user_id
and auth_user_id = auth.uid()
);
$$;sql
create function mark_messages_as_read(
p_conversation_id uuid,
p_is_admin boolean
) returns void as $$
begin
update messages
set read_at = now()
where conversation_id = p_conversation_id
and is_admin = p_is_admin
and read_at is null;
end;
$$ language plpgsql security definer;建立角色權限相關資料表
sql
-- 1. 權限組(功能模塊)表
CREATE TABLE IF NOT EXISTS permission_groups (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
description TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. 權限表
CREATE TABLE IF NOT EXISTS permissions (
id SERIAL PRIMARY KEY,
group_id INT REFERENCES permission_groups(id),
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. 角色權限關聯表
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (role_id, permission_id)
);
-- 4. 索引以提高查詢性能
CREATE INDEX IF NOT EXISTS idx_permissions_group_id ON permissions(group_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);
-- 5. 視圖以方便查詢角色權限
CREATE OR REPLACE VIEW role_permissions_view AS
SELECT
r.id AS role_id,
r.name AS role_name,
p.id AS permission_id,
p.code AS permission_code,
p.name AS permission_name,
pg.id AS group_id,
pg.name AS group_name
FROM
roles r
JOIN
role_permissions rp ON r.id = rp.role_id
JOIN
permissions p ON rp.permission_id = p.id
JOIN
permission_groups pg ON p.group_id = pg.id;權限定義資料
sql
-- 建立權限組與權限(可根據實際需求調整)
INSERT INTO permission_groups (name, description, sort_order) VALUES
('user management', '用戶帳號、角色相關權限', 10),
('product management', '產品目錄、庫存相關權限', 20),
('order management', '訂單處理、發貨相關權限', 30),
('customer management', '客戶資料、支援相關權限', 40),
('setting management', '系統配置、參數相關權限', 50);
-- 插入基本權限
INSERT INTO permissions (group_id, code, name, description, sort_order) VALUES
-- 用戶管理權限
(1, 'user.view', '查看用戶', '允許查看用戶列表和詳情', 10),
(1, 'user.create', '建立用戶', '允許新增新用戶', 20),
(1, 'user.edit', '編輯用戶', '允許修改用戶信息', 30),
(1, 'user.delete', '刪除用戶', '允許刪除用戶', 40),
(1, 'role.view', '查看角色', '允許查看角色列表和詳情', 50),
(1, 'role.create', '建立角色', '允許新增新角色', 60),
(1, 'role.edit', '編輯角色', '允許修改角色信息', 70),
(1, 'role.delete', '刪除角色', '允許刪除角色', 80),
(1, 'role.assign', '分配角色', '允許為用戶分配角色', 90),
-- 產品管理權限
(2, 'product.view', '查看產品', '允許查看產品列表和詳情', 10),
(2, 'product.create', '建立產品', '允許新增新產品', 20),
(2, 'product.edit', '編輯產品', '允許修改產品信息', 30),
(2, 'product.delete', '刪除產品', '允許刪除產品', 40),
(2, 'inventory.view', '查看庫存', '允許查看庫存信息', 50),
(2, 'inventory.update', '更新庫存', '允許更新庫存數量', 60),
-- 訂單管理權限
(3, 'order.view', '查看訂單', '允許查看訂單列表和詳情', 10),
(3, 'order.create', '建立訂單', '允許新增新訂單', 20),
(3, 'order.edit', '編輯訂單', '允許修改訂單信息', 30),
(3, 'order.cancel', '取消訂單', '允許取消訂單', 40),
(3, 'order.process', '處理訂單', '允許處理訂單狀態', 50),
-- 客戶管理權限
(4, 'customer.view', '查看客戶', '允許查看客戶列表和詳情', 10),
(4, 'customer.create', '建立客戶', '允許新增新客戶', 20),
(4, 'customer.edit', '編輯客戶', '允許修改客戶信息', 30),
(4, 'customer.delete', '刪除客戶', '允許刪除客戶', 40),
(4, 'support.view', '查看支援', '允許查看客戶支援請求訊息', 50),
(4, 'support.respond', '回應支援', '允許回應客戶支援請求訊息', 60),
-- 系統設置權限
(5, 'settings.view', '查看設置', '允許查看系統設置', 10),
(5, 'settings.edit', '編輯設置', '允許修改系統設置', 20);
-- 為超級管理員角色分配所有權限(假設超級管理員角色ID為1)
-- 您需要調整角色ID以匹配實際情況
DO $$
DECLARE
admin_role_id INT := 1; -- 假設超級管理員角色ID為1,請根據實際情況調整
permission_id INT;
BEGIN
-- 檢查角色是否存在
IF EXISTS (SELECT 1 FROM roles WHERE id = admin_role_id) THEN
-- 為超級管理員分配所有權限
FOR permission_id IN SELECT id FROM permissions LOOP
INSERT INTO role_permissions (role_id, permission_id)
VALUES (admin_role_id, permission_id)
ON CONFLICT DO NOTHING;
END LOOP;
END IF;
END $$;更新 - 建立新訂單 supabase funciton,新增商品快照同步欄位 (以下為全部建議方法,實際資料庫未加入 variant、discount 快照)
sql
create or replace function public.create_order_with_items(
order_data jsonb,
items jsonb[],
created_by uuid
)
returns jsonb as $$
declare
new_order_id uuid;
item jsonb;
_product_name text;
_product_description text;
_product_image_url text;
_product_sku text;
begin
-- 驗證商品清單不為空
if array_length(items, 1) is null or array_length(items, 1) = 0 then
raise exception 'Order must contain at least one item';
end if;
-- 建立訂單
insert into orders (
user_id,
status,
total_amount,
shipping_fee,
discount_amount,
coupon_discount,
tax_amount
)
values (
(order_data->>'user_id')::uuid,
'pending',
coalesce((order_data->>'total_amount')::numeric, 0),
coalesce((order_data->>'shipping_fee')::numeric, 0),
coalesce((order_data->>'discount_amount')::numeric, 0),
coalesce((order_data->>'coupon_discount')::numeric, 0),
coalesce((order_data->>'tax_amount')::numeric, 0)
)
returning id into new_order_id;
-- 插入訂單項目
foreach item in array items loop
-- 撈取商品快照資料
select
name,
description,
image_url,
sku
into
_product_name,
_product_description,
_product_image_url,
_product_sku
from products
where id = (item->>'product_id')::uuid;
insert into order_items (
order_id,
product_id,
quantity,
unit_price,
total_price,
product_name,
product_description,
product_image_url,
product_sku,
variant_name,
variant_attributes,
discount_amount,
discount_reason,
created_at
)
values (
new_order_id,
(item->>'product_id')::uuid,
(item->>'quantity')::integer,
(item->>'price')::numeric,
((item->>'quantity')::integer * (item->>'price')::numeric),
_product_name,
_product_description,
_product_image_url,
_product_sku,
(item->>'variant_name')::text,
(item->>'variant_attributes')::jsonb,
coalesce((item->>'discount_amount')::numeric, 0),
(item->>'discount_reason')::text,
now()
);
-- FIFO 扣庫存
perform allocate_stock_fifo(
in_created_by := created_by,
in_product_id := (item->>'product_id')::uuid,
in_quantity := (item->>'quantity')::integer,
in_ref_id := new_order_id,
in_source := 'order'
);
end loop;
-- 回傳結果
return jsonb_build_object('order_id', new_order_id);
end;
$$ language plpgsql security definer;sql
-- 產品概覽函數 - 僅提供基本產品數據統計
CREATE OR REPLACE FUNCTION public.get_product_overview()
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
result jsonb;
BEGIN
-- 基本產品摘要
WITH product_summary AS (
SELECT
-- 總計數
count(distinct p.id) filter (where p.deleted_at is null) as total_products,
count(distinct p.category_id) filter (where p.deleted_at is null) as total_categories,
-- 依狀態分類的產品數量
count(case when p.status = 'active' and p.deleted_at is null then 1 else null end) as active_products,
count(case when p.status = 'draft' and p.deleted_at is null then 1 else null end) as draft_products,
count(case when p.status = 'inactive' and p.deleted_at is null then 1 else null end) as inactive_products,
count(case when p.status = 'archived' and p.deleted_at is null then 1 else null end) as archived_products,
-- 庫存水位低的產品
count(case when p.needs_restock and p.deleted_at is null then 1 else null end) as low_stock_products,
-- 平均價格
round(avg(p.price) filter (where p.deleted_at is null), 2) as average_price
from
product_with_current_stock p
)
-- 直接返回摘要數據,不包含分類和銷售詳情
SELECT row_to_json(s)
FROM product_summary s
INTO result;
RETURN result;
END;
$function$;sql
// 在 src/composables/useProduct.ts 中
export async function getProductOverview(): Promise<ApiResponse> {
try {
const { data, error: apiError } = await supabase.functions.invoke(
'product-overview',
{ method: 'GET' }
)
if (apiError) {
return { success: false, error: apiError.message }
}
return { success: true, data: data.data ? data.data : data }
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false, error: errorMessage }
}
}sql
CREATE OR REPLACE VIEW public.user_rfm_lifecycle_metrics AS
WITH user_purchase_data AS (
SELECT
o.user_id AS user_id,
MIN(o.created_at) AS first_purchase_date,
MAX(o.created_at) AS last_purchase_date,
COUNT(DISTINCT o.id) AS frequency,
SUM(o.total_amount) AS monetary,
COUNT(DISTINCT CASE WHEN o.created_at >= (CURRENT_DATE - INTERVAL '12 months') THEN o.id END) AS purchase_count_last_12m,
SUM(CASE WHEN o.created_at >= (CURRENT_DATE - INTERVAL '12 months') THEN o.total_amount ELSE 0 END) AS ltv_last_12m
FROM
orders o
WHERE
o.status = 'paid' -- 只計算已付款訂單
GROUP BY
o.user_id
),
recency_calc AS (
SELECT
user_id,
first_purchase_date,
last_purchase_date,
frequency,
monetary,
purchase_count_last_12m,
ltv_last_12m,
EXTRACT(DAY FROM (CURRENT_TIMESTAMP - last_purchase_date)) AS recency_days,
EXTRACT(DAY FROM (CURRENT_TIMESTAMP - first_purchase_date)) AS days_since_first_purchase,
EXTRACT(DAY FROM (CURRENT_TIMESTAMP - last_purchase_date)) AS days_since_last_purchase
FROM
user_purchase_data
),
rfm_scores AS (
SELECT
rc.*,
-- Recency score: 1 (老), 2, 3, 4, 5 (最新)
CASE
WHEN recency_days <= 7 THEN 5
WHEN recency_days <= 14 THEN 4
WHEN recency_days <= 30 THEN 3
WHEN recency_days <= 60 THEN 2
ELSE 1
END AS r_score,
-- Frequency score: 1 (低) 到 5 (高)
CASE
WHEN frequency >= 10 THEN 5
WHEN frequency >= 5 THEN 4
WHEN frequency >= 3 THEN 3
WHEN frequency >= 2 THEN 2
ELSE 1
END AS f_score,
-- Monetary score: 1 (低) 到 5 (高)
CASE
WHEN monetary >= 5000 THEN 5
WHEN monetary >= 3000 THEN 4
WHEN monetary >= 1000 THEN 3
WHEN monetary >= 500 THEN 2
ELSE 1
END AS m_score
FROM
recency_calc rc
),
rfm_segment AS (
SELECT
*,
(r_score::text || f_score::text || m_score::text) AS rfm_segment,
-- Lifecycle Stage 定義(範例邏輯,可調整)
CASE
WHEN recency_days <= 30 AND frequency >= 2 THEN 'Active'
WHEN recency_days BETWEEN 31 AND 90 AND frequency >= 2 THEN 'At Risk'
WHEN recency_days > 90 AND frequency >= 2 THEN 'Churned'
WHEN frequency = 1 AND recency_days <= 90 THEN 'New'
WHEN frequency = 1 AND recency_days > 90 THEN 'One-time Past'
ELSE 'Other'
END AS lifecycle_stage
FROM
rfm_scores
)
SELECT
rfm.user_id,
u.full_name,
u.email,
rfm.recency_days,
rfm.frequency,
rfm.monetary,
rfm.r_score,
rfm.f_score,
rfm.m_score,
rfm.rfm_segment,
rfm.first_purchase_date,
rfm.last_purchase_date,
rfm.days_since_first_purchase,
rfm.days_since_last_purchase,
rfm.purchase_count_last_12m,
rfm.ltv_last_12m,
rfm.monetary AS ltv_total,
rfm.lifecycle_stage
FROM
rfm_segment rfm
JOIN
users u ON rfm.user_id = u.id;sql
CREATE OR REPLACE FUNCTION public.get_user_rfm_overview(role text DEFAULT NULL)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
result jsonb;
active_users_count integer;
vip_users_count integer;
avg_ltv numeric;
at_risk_users_count integer;
user_ids uuid[];
BEGIN
-- 如果指定了角色,先獲取符合該角色的用戶ID
IF role IS NOT NULL THEN
SELECT array_agg(u.id)
INTO user_ids
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE r.name = role;
END IF;
-- 活躍用戶數 (lifecycle_stage = 'Active')
SELECT COUNT(*)
INTO active_users_count
FROM public.user_rfm_lifecycle_metrics rfm
WHERE lifecycle_stage = 'Active'
AND (role IS NULL OR rfm.user_id = ANY(user_ids));
-- VIP客戶數 (使用 segment_name = 'VIP' 識別,只要符合就計算)
SELECT COUNT(DISTINCT rfm.user_id)
INTO vip_users_count
FROM public.user_rfm_lifecycle_metrics rfm
JOIN public.rfm_segment_mapping map ON
rfm.rfm_segment SIMILAR TO REPLACE(REPLACE(map.rfm_pattern, 'X', '_'), '_', '[1-5]')
WHERE map.segment_name = 'VIP'
AND (role IS NULL OR rfm.user_id = ANY(user_ids));
-- 平均LTV
SELECT COALESCE(ROUND(AVG(ltv_total), 2), 0)
INTO avg_ltv
FROM public.user_rfm_lifecycle_metrics rfm
WHERE ltv_total > 0
AND (role IS NULL OR rfm.user_id = ANY(user_ids));
-- At Risk 客戶數 (lifecycle_stage = 'At Risk')
SELECT COUNT(*)
INTO at_risk_users_count
FROM public.user_rfm_lifecycle_metrics rfm
WHERE lifecycle_stage = 'At Risk'
AND (role IS NULL OR rfm.user_id = ANY(user_ids));
-- 組裝結果
SELECT jsonb_build_object(
'active_users_count', active_users_count,
'vip_users_count', vip_users_count,
'average_ltv', avg_ltv,
'at_risk_users_count', at_risk_users_count
) INTO result;
RETURN result;
END;
$function$;