feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛
- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度 - 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment - 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单 - 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径 - 文档:同步迁移基线、合并评估、代码复核与上线说明
This commit is contained in:
+382
@@ -1,3 +1,169 @@
|
||||
-- 103_finance_foundation.sql
|
||||
-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 103_product_catalog.sql —— 商品目录与商品映射基础
|
||||
-- ===========================================================================
|
||||
|
||||
-- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。
|
||||
--
|
||||
-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、
|
||||
-- 店铺日报和订单毛利都只认 canonical_product_id。
|
||||
--
|
||||
-- 设计要点(2026-10-23 重写):
|
||||
-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定,
|
||||
-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 /
|
||||
-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。
|
||||
-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
|
||||
-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1,
|
||||
-- 必须由回填脚本按 SKU 名称解析后写入。
|
||||
--
|
||||
-- 约束:
|
||||
-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名;
|
||||
-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先;
|
||||
-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_catalog (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
canonical_sku TEXT NOT NULL,
|
||||
canonical_name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
unit_name TEXT NOT NULL DEFAULT '件',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku
|
||||
ON product_catalog (canonical_sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category
|
||||
ON product_catalog (status, category, canonical_name);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_status
|
||||
CHECK (status IN ('active', 'archived'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_sku
|
||||
CHECK (length(trim(canonical_sku)) > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_name
|
||||
CHECK (length(trim(canonical_name)) > 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度';
|
||||
COMMENT ON COLUMN product_catalog.canonical_sku IS
|
||||
'内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工';
|
||||
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_product_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_code TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_name TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_name_key TEXT NOT NULL DEFAULT '',
|
||||
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
match_type TEXT NOT NULL DEFAULT 'sku_code',
|
||||
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
|
||||
confidence TEXT NOT NULL DEFAULT 'manual',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key
|
||||
ON channel_product_mappings (
|
||||
provider,
|
||||
shop_id,
|
||||
platform_sku_code,
|
||||
match_type,
|
||||
platform_sku_name_key
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
|
||||
ON channel_product_mappings (canonical_product_id, enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup
|
||||
ON channel_product_mappings (
|
||||
provider,
|
||||
shop_id,
|
||||
platform_sku_code,
|
||||
match_type,
|
||||
platform_sku_name_key,
|
||||
enabled
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_confidence
|
||||
CHECK (confidence IN ('exact', 'rule', 'manual'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_source
|
||||
CHECK (source IN ('manual', 'import', 'rule'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_multiplier
|
||||
CHECK (quantity_multiplier > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_match_type
|
||||
CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_match_key
|
||||
CHECK (platform_sku_code <> '' OR platform_sku_name_key <> '');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射';
|
||||
COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先';
|
||||
COMMENT ON COLUMN channel_product_mappings.match_type IS
|
||||
'匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series';
|
||||
COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS
|
||||
'归一后的 SKU 名称或数量系列键;sku_code 模式为空串';
|
||||
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product
|
||||
ON order_items (canonical_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup
|
||||
ON order_items (normalized_sku_name, normalized_sku_series);
|
||||
|
||||
COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定';
|
||||
COMMENT ON COLUMN order_items.normalized_quantity IS
|
||||
'归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL';
|
||||
COMMENT ON COLUMN order_items.normalized_sku_name IS
|
||||
'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧';
|
||||
COMMENT ON COLUMN order_items.normalized_sku_series IS
|
||||
'剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配';
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 104_cost_center.sql —— 成本中心、成本项、成本版本与快照
|
||||
-- ===========================================================================
|
||||
|
||||
-- 104_cost_center.sql —— 成本项、成本版本、成本快照、成本异常与订单项收入固化。
|
||||
--
|
||||
-- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存快照行,
|
||||
@@ -477,3 +643,219 @@ WHERE ci.id = v.cost_item_id
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
VALUES ('finance', 'finance.config')
|
||||
ON CONFLICT (role_key, permission_key) DO NOTHING;
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 105_sales_daily_facts.sql —— 销售事实与订单项收入口径
|
||||
-- ===========================================================================
|
||||
|
||||
-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
|
||||
--
|
||||
-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode,
|
||||
-- 不再各自用 provider / voucher / task 临时推断。
|
||||
--
|
||||
-- 设计要点(2026-10-23 重写):
|
||||
-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode;
|
||||
-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉;
|
||||
-- 3. 核销码只是状态,不参与履约判定;
|
||||
-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务),
|
||||
-- 由统一决策服务 + 定时兜底在 24 小时内补齐。
|
||||
--
|
||||
-- 销售事实口径:
|
||||
-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at;
|
||||
-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额;
|
||||
-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash /
|
||||
-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐;
|
||||
-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平;
|
||||
-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count;
|
||||
-- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。
|
||||
--
|
||||
-- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的
|
||||
-- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 履约方式:唯一真源
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode'
|
||||
) THEN
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_mode
|
||||
CHECK (
|
||||
fulfillment_mode IS NULL
|
||||
OR fulfillment_mode IN (
|
||||
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode
|
||||
ON order_items (fulfillment_mode, order_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending
|
||||
ON order_items (order_id)
|
||||
WHERE fulfillment_mode IS NULL;
|
||||
|
||||
-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id
|
||||
ON order_items (fulfillment_mode, id);
|
||||
|
||||
COMMENT ON COLUMN order_items.fulfillment_mode IS
|
||||
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段';
|
||||
COMMENT ON COLUMN order_items.fulfillment_decided_at IS
|
||||
'履约方式最后一次判定时间';
|
||||
COMMENT ON COLUMN order_items.fulfillment_reason IS
|
||||
'履约方式判定来源:订单同步 / 定时归集 / 历史回填等';
|
||||
|
||||
-- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 销售事实
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sales_daily_facts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
stat_date DATE NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
fulfillment_mode TEXT NOT NULL,
|
||||
order_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count INTEGER NOT NULL DEFAULT 0,
|
||||
quantity NUMERIC(14, 3) NOT NULL DEFAULT 0,
|
||||
gross_revenue_fen BIGINT NOT NULL DEFAULT 0,
|
||||
refund_amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
net_revenue_fen BIGINT NOT NULL DEFAULT 0,
|
||||
cost_amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
cost_missing_count INTEGER NOT NULL DEFAULT 0,
|
||||
cost_stage TEXT NOT NULL DEFAULT 'missing',
|
||||
gross_profit_fen BIGINT NOT NULL DEFAULT 0,
|
||||
computed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key
|
||||
ON sales_daily_facts (
|
||||
stat_date,
|
||||
provider,
|
||||
shop_id,
|
||||
COALESCE(canonical_product_id, 0),
|
||||
fulfillment_mode
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date
|
||||
ON sales_daily_facts (stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop
|
||||
ON sales_daily_facts (provider, shop_id, stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product
|
||||
ON sales_daily_facts (canonical_product_id, stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode
|
||||
ON sales_daily_facts (fulfillment_mode, stat_date DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode
|
||||
CHECK (fulfillment_mode IN (
|
||||
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
|
||||
));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_cost_stage
|
||||
CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_amounts
|
||||
CHECK (
|
||||
order_count >= 0
|
||||
AND item_count >= 0
|
||||
AND quantity >= 0
|
||||
AND gross_revenue_fen >= 0
|
||||
AND refund_amount_fen >= 0
|
||||
AND net_revenue_fen >= 0
|
||||
AND cost_amount_fen >= 0
|
||||
AND cost_missing_count >= 0
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_profit
|
||||
CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE sales_daily_facts IS
|
||||
'店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表';
|
||||
COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS
|
||||
'标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比';
|
||||
COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS
|
||||
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工';
|
||||
COMMENT ON COLUMN sales_daily_facts.cost_stage IS
|
||||
'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际';
|
||||
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS
|
||||
'没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策';
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 106_order_refunds.sql —— 退款单与退款外部单号
|
||||
-- ===========================================================================
|
||||
|
||||
-- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。
|
||||
--
|
||||
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
|
||||
-- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。
|
||||
--
|
||||
-- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104
|
||||
-- 落到异常表定义里,这里只保留退款主体。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_refunds (
|
||||
id bigserial PRIMARY KEY,
|
||||
refund_no text NOT NULL UNIQUE,
|
||||
batch_no text NOT NULL DEFAULT '',
|
||||
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
|
||||
reason text NOT NULL DEFAULT '',
|
||||
note text NOT NULL DEFAULT '',
|
||||
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
|
||||
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
|
||||
refunded_at timestamp with time zone NOT NULL,
|
||||
external_ref text NOT NULL DEFAULT '',
|
||||
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
voided_at timestamp with time zone,
|
||||
void_reason text NOT NULL DEFAULT '',
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
|
||||
ON order_refunds (order_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
|
||||
ON order_refunds (order_item_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
|
||||
ON order_refunds (refunded_at DESC);
|
||||
|
||||
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
|
||||
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
|
||||
COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入';
|
||||
@@ -1,152 +0,0 @@
|
||||
-- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。
|
||||
--
|
||||
-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、
|
||||
-- 店铺日报和订单毛利都只认 canonical_product_id。
|
||||
--
|
||||
-- 设计要点(2026-10-23 重写):
|
||||
-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定,
|
||||
-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 /
|
||||
-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。
|
||||
-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
|
||||
-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1,
|
||||
-- 必须由回填脚本按 SKU 名称解析后写入。
|
||||
--
|
||||
-- 约束:
|
||||
-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名;
|
||||
-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先;
|
||||
-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_catalog (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
canonical_sku TEXT NOT NULL,
|
||||
canonical_name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
unit_name TEXT NOT NULL DEFAULT '件',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku
|
||||
ON product_catalog (canonical_sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category
|
||||
ON product_catalog (status, category, canonical_name);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_status
|
||||
CHECK (status IN ('active', 'archived'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_sku
|
||||
CHECK (length(trim(canonical_sku)) > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_name
|
||||
CHECK (length(trim(canonical_name)) > 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度';
|
||||
COMMENT ON COLUMN product_catalog.canonical_sku IS
|
||||
'内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工';
|
||||
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_product_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_code TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_name TEXT NOT NULL DEFAULT '',
|
||||
platform_sku_name_key TEXT NOT NULL DEFAULT '',
|
||||
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
match_type TEXT NOT NULL DEFAULT 'sku_code',
|
||||
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
|
||||
confidence TEXT NOT NULL DEFAULT 'manual',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key
|
||||
ON channel_product_mappings (
|
||||
provider,
|
||||
shop_id,
|
||||
platform_sku_code,
|
||||
match_type,
|
||||
platform_sku_name_key
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
|
||||
ON channel_product_mappings (canonical_product_id, enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup
|
||||
ON channel_product_mappings (
|
||||
provider,
|
||||
shop_id,
|
||||
platform_sku_code,
|
||||
match_type,
|
||||
platform_sku_name_key,
|
||||
enabled
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_confidence
|
||||
CHECK (confidence IN ('exact', 'rule', 'manual'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_source
|
||||
CHECK (source IN ('manual', 'import', 'rule'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_multiplier
|
||||
CHECK (quantity_multiplier > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_match_type
|
||||
CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN
|
||||
ALTER TABLE channel_product_mappings
|
||||
ADD CONSTRAINT chk_channel_product_mappings_match_key
|
||||
CHECK (platform_sku_code <> '' OR platform_sku_name_key <> '');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射';
|
||||
COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先';
|
||||
COMMENT ON COLUMN channel_product_mappings.match_type IS
|
||||
'匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series';
|
||||
COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS
|
||||
'归一后的 SKU 名称或数量系列键;sku_code 模式为空串';
|
||||
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product
|
||||
ON order_items (canonical_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup
|
||||
ON order_items (normalized_sku_name, normalized_sku_series);
|
||||
|
||||
COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定';
|
||||
COMMENT ON COLUMN order_items.normalized_quantity IS
|
||||
'归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL';
|
||||
COMMENT ON COLUMN order_items.normalized_sku_name IS
|
||||
'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧';
|
||||
COMMENT ON COLUMN order_items.normalized_sku_series IS
|
||||
'剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
|
||||
--
|
||||
-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode,
|
||||
-- 不再各自用 provider / voucher / task 临时推断。
|
||||
--
|
||||
-- 设计要点(2026-10-23 重写):
|
||||
-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode;
|
||||
-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉;
|
||||
-- 3. 核销码只是状态,不参与履约判定;
|
||||
-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务),
|
||||
-- 由统一决策服务 + 定时兜底在 24 小时内补齐。
|
||||
--
|
||||
-- 销售事实口径:
|
||||
-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at;
|
||||
-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额;
|
||||
-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash /
|
||||
-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐;
|
||||
-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平;
|
||||
-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count;
|
||||
-- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。
|
||||
--
|
||||
-- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的
|
||||
-- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 履约方式:唯一真源
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode'
|
||||
) THEN
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_fulfillment_mode
|
||||
CHECK (
|
||||
fulfillment_mode IS NULL
|
||||
OR fulfillment_mode IN (
|
||||
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode
|
||||
ON order_items (fulfillment_mode, order_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending
|
||||
ON order_items (order_id)
|
||||
WHERE fulfillment_mode IS NULL;
|
||||
|
||||
-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id
|
||||
ON order_items (fulfillment_mode, id);
|
||||
|
||||
COMMENT ON COLUMN order_items.fulfillment_mode IS
|
||||
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段';
|
||||
COMMENT ON COLUMN order_items.fulfillment_decided_at IS
|
||||
'履约方式最后一次判定时间';
|
||||
COMMENT ON COLUMN order_items.fulfillment_reason IS
|
||||
'履约方式判定来源:订单同步 / 定时归集 / 历史回填等';
|
||||
|
||||
-- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 销售事实
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sales_daily_facts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
stat_date DATE NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
|
||||
fulfillment_mode TEXT NOT NULL,
|
||||
order_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count INTEGER NOT NULL DEFAULT 0,
|
||||
quantity NUMERIC(14, 3) NOT NULL DEFAULT 0,
|
||||
gross_revenue_fen BIGINT NOT NULL DEFAULT 0,
|
||||
refund_amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
net_revenue_fen BIGINT NOT NULL DEFAULT 0,
|
||||
cost_amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
cost_missing_count INTEGER NOT NULL DEFAULT 0,
|
||||
cost_stage TEXT NOT NULL DEFAULT 'missing',
|
||||
gross_profit_fen BIGINT NOT NULL DEFAULT 0,
|
||||
computed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key
|
||||
ON sales_daily_facts (
|
||||
stat_date,
|
||||
provider,
|
||||
shop_id,
|
||||
COALESCE(canonical_product_id, 0),
|
||||
fulfillment_mode
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date
|
||||
ON sales_daily_facts (stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop
|
||||
ON sales_daily_facts (provider, shop_id, stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product
|
||||
ON sales_daily_facts (canonical_product_id, stat_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode
|
||||
ON sales_daily_facts (fulfillment_mode, stat_date DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode
|
||||
CHECK (fulfillment_mode IN (
|
||||
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
|
||||
));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_cost_stage
|
||||
CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_amounts
|
||||
CHECK (
|
||||
order_count >= 0
|
||||
AND item_count >= 0
|
||||
AND quantity >= 0
|
||||
AND gross_revenue_fen >= 0
|
||||
AND refund_amount_fen >= 0
|
||||
AND net_revenue_fen >= 0
|
||||
AND cost_amount_fen >= 0
|
||||
AND cost_missing_count >= 0
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_profit
|
||||
CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE sales_daily_facts IS
|
||||
'店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表';
|
||||
COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS
|
||||
'标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比';
|
||||
COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS
|
||||
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工';
|
||||
COMMENT ON COLUMN sales_daily_facts.cost_stage IS
|
||||
'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际';
|
||||
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS
|
||||
'没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策';
|
||||
+96
@@ -1,3 +1,11 @@
|
||||
-- 105_work_order_consignment.sql
|
||||
-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 109_work_order_share_participation.sql —— 拼单多次参与与份额字段
|
||||
-- ===========================================================================
|
||||
|
||||
-- 109_work_order_share_participation.sql
|
||||
-- 工单拼单份额模型:多次参与 + 份额级问题标记 + 撤单原因/份数。
|
||||
-- (由原 110/111/113/114 四个增量迁移合并重写,纯新增列/索引,不改历史结构。)
|
||||
@@ -99,3 +107,91 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker
|
||||
ON work_order_shares (worker_id, work_order_id)
|
||||
WHERE problem_marked_at IS NOT NULL;
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 110_work_order_hall_and_shop_indexes.sql —— 大厅/店铺查询索引
|
||||
-- ===========================================================================
|
||||
|
||||
-- 110_work_order_hall_and_shop_indexes.sql
|
||||
-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。
|
||||
--
|
||||
-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引:
|
||||
-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]),
|
||||
-- 普通 btree 无法命中表达式,必须建同表达式索引;
|
||||
-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单,
|
||||
-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。
|
||||
-- 全部 IF NOT EXISTS,可安全重跑。
|
||||
|
||||
-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
|
||||
ON orders (BTRIM(shop_name))
|
||||
WHERE BTRIM(shop_name) <> '';
|
||||
|
||||
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
|
||||
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
|
||||
|
||||
-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
|
||||
ON work_orders (id)
|
||||
WHERE status = 'open' AND pinned_at IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_work_orders_hall_pinned IS
|
||||
'抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 111_work_order_share_cancel_reason_comment.sql —— 撤份额原因注释
|
||||
-- ===========================================================================
|
||||
|
||||
-- 111_work_order_share_cancel_reason_comment.sql
|
||||
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
|
||||
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
|
||||
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement';
|
||||
|
||||
|
||||
-- ===========================================================================
|
||||
-- 112_consignment_contact_supporter.sql —— 寄卖对接客服名单
|
||||
-- ===========================================================================
|
||||
|
||||
-- 112_consignment_contact_supporter.sql
|
||||
-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。
|
||||
-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。
|
||||
-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。
|
||||
-- 全部语句幂等,可重复执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consignment_contact_supporters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order
|
||||
ON consignment_contact_supporters (enabled, sort_order, id);
|
||||
|
||||
ALTER TABLE consignment_submissions
|
||||
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
|
||||
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
|
||||
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter
|
||||
ON consignment_submissions (contact_supporter_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter
|
||||
ON consignment_inventories (contact_supporter_id);
|
||||
|
||||
-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。
|
||||
-- 后台可直接改名/补充备注,不会重复插入。
|
||||
INSERT INTO consignment_contact_supporters (name, note, sort_order)
|
||||
SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters);
|
||||
@@ -1,38 +0,0 @@
|
||||
-- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。
|
||||
--
|
||||
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
|
||||
-- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。
|
||||
--
|
||||
-- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104
|
||||
-- 落到异常表定义里,这里只保留退款主体。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_refunds (
|
||||
id bigserial PRIMARY KEY,
|
||||
refund_no text NOT NULL UNIQUE,
|
||||
batch_no text NOT NULL DEFAULT '',
|
||||
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
|
||||
reason text NOT NULL DEFAULT '',
|
||||
note text NOT NULL DEFAULT '',
|
||||
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
|
||||
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
|
||||
refunded_at timestamp with time zone NOT NULL,
|
||||
external_ref text NOT NULL DEFAULT '',
|
||||
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
voided_at timestamp with time zone,
|
||||
void_reason text NOT NULL DEFAULT '',
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
|
||||
ON order_refunds (order_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
|
||||
ON order_refunds (order_item_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
|
||||
ON order_refunds (refunded_at DESC);
|
||||
|
||||
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
|
||||
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
|
||||
COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入';
|
||||
@@ -1,292 +0,0 @@
|
||||
-- 107_manual_fulfillment.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包 + 备用金编号。
|
||||
--
|
||||
-- 背景(2026-10-24 确认):
|
||||
-- 客服人工履约有四个资金/额度钱包,每个客服各一个:
|
||||
-- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金
|
||||
-- 三个积分平台的积分**互不通用**,所以每个平台一套账户。
|
||||
-- 换算率按平台不同,真源是履约档案 config_json.rechargeUnitPriceFen:
|
||||
-- 南航 1 积分 = 1 分钱;乐玩 1 积分 = 1 分钱;ROCK 1 积分 = 1 元(100 分)。
|
||||
--
|
||||
-- 配置链路(无匹配引擎、无打分):
|
||||
-- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry;
|
||||
-- 2. 运营在成本中心配置每个 SKU 的「履约渠道 + 消耗积分 + 成本模式」;
|
||||
-- 3. 点「重新匹配」把配置回填到历史人工订单项;
|
||||
-- 4. 客服确认履约时直接读配置,固定成本零输入。
|
||||
--
|
||||
-- 关键口径:
|
||||
-- * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),不是商品标题;
|
||||
-- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用);
|
||||
-- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
|
||||
-- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。
|
||||
--
|
||||
-- 2026-10-25 合并:原 106(备用金账户编号可读化)并入本迁移。编号规则与人工履约
|
||||
-- 共用同一套钱包语义,单独成文件会让「资金台账」散落在两个迁移里。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 人工 SKU 登记表:扫描到的「走到人工履约」的内部 SKU
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_sku_registry (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sku_key TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
shop_count INTEGER NOT NULL DEFAULT 0,
|
||||
order_count INTEGER NOT NULL DEFAULT 0,
|
||||
item_count INTEGER NOT NULL DEFAULT 0,
|
||||
revenue_fen BIGINT NOT NULL DEFAULT 0,
|
||||
first_seen_date DATE,
|
||||
last_seen_date DATE,
|
||||
scan_from_date DATE,
|
||||
scan_to_date DATE,
|
||||
config_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_sku_registry_sku_key
|
||||
ON manual_sku_registry (sku_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_config
|
||||
ON manual_sku_registry (config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_item_count
|
||||
ON manual_sku_registry (item_count DESC);
|
||||
|
||||
COMMENT ON TABLE manual_sku_registry IS
|
||||
'人工履约 SKU 登记表:扫描到的内部 SKU(快手 skuNick / 拼多多 outSkuSn),供运营集中配置';
|
||||
COMMENT ON COLUMN manual_sku_registry.sku_key IS
|
||||
'内部 SKU,配置主键;同名 SKU 视为同一商品,跨渠道共用一份配置';
|
||||
COMMENT ON COLUMN manual_sku_registry.display_name IS
|
||||
'商品名称,仅用于展示识别,不是配置键';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 人工履约配置:每个内部 SKU 的渠道、积分与成本模式
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_fulfillment_configs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sku_key TEXT NOT NULL,
|
||||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
|
||||
platform_key TEXT NOT NULL,
|
||||
credit_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
cost_mode TEXT NOT NULL DEFAULT 'fixed',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_fulfillment_configs_sku_key
|
||||
ON manual_fulfillment_configs (sku_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_platform
|
||||
ON manual_fulfillment_configs (platform_key);
|
||||
|
||||
COMMENT ON TABLE manual_fulfillment_configs IS
|
||||
'人工履约配置:内部 SKU → 履约渠道 + 消耗积分 + 成本模式';
|
||||
COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS
|
||||
'每单消耗积分(整数)。换算率按平台配置;0 表示成本不固定,客服确认履约时手填';
|
||||
COMMENT ON COLUMN manual_fulfillment_configs.cost_mode IS
|
||||
'fixed=成本按积分固定(cost_fen = credit_quantity × 平台换算率);variable=成本不固定,客服确认履约时手填';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_platform'
|
||||
) THEN
|
||||
ALTER TABLE manual_fulfillment_configs
|
||||
ADD CONSTRAINT chk_manual_fulfillment_configs_platform
|
||||
CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_cost_mode'
|
||||
) THEN
|
||||
ALTER TABLE manual_fulfillment_configs
|
||||
ADD CONSTRAINT chk_manual_fulfillment_configs_cost_mode
|
||||
CHECK (cost_mode IN ('fixed', 'variable'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_credit'
|
||||
) THEN
|
||||
ALTER TABLE manual_fulfillment_configs
|
||||
ADD CONSTRAINT chk_manual_fulfillment_configs_credit
|
||||
CHECK (credit_quantity >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 三平台积分钱包:每个客服在每个积分平台一个账户
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_credit_accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform_key TEXT NOT NULL,
|
||||
owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
|
||||
balance_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
unit_name TEXT NOT NULL DEFAULT '积分',
|
||||
recharge_unit_price_fen BIGINT NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_credit_accounts_owner_platform
|
||||
ON platform_credit_accounts (owner_admin_user_id, platform_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_credit_accounts_platform_status
|
||||
ON platform_credit_accounts (platform_key, status);
|
||||
|
||||
COMMENT ON TABLE platform_credit_accounts IS
|
||||
'客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)';
|
||||
COMMENT ON COLUMN platform_credit_accounts.recharge_unit_price_fen IS
|
||||
'积分充值单价(分):1 积分 = ? 分。按平台不同,默认取档案 config_json.rechargeUnitPriceFen';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_credit_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES platform_credit_accounts(id) ON DELETE RESTRICT,
|
||||
entry_type TEXT NOT NULL,
|
||||
quantity_delta BIGINT NOT NULL,
|
||||
balance_after BIGINT NOT NULL,
|
||||
amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
platform_ref TEXT NOT NULL DEFAULT '',
|
||||
related_task_id BIGINT,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_account_time
|
||||
ON platform_credit_entries (account_id, created_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_type_time
|
||||
ON platform_credit_entries (entry_type, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_task
|
||||
ON platform_credit_entries (related_task_id)
|
||||
WHERE related_task_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE platform_credit_entries IS
|
||||
'平台积分流水:充值/消耗/调整/退回,只追加,不修改不删除';
|
||||
COMMENT ON COLUMN platform_credit_entries.amount_fen IS
|
||||
'对应人民币金额(分)= quantity_delta × recharge_unit_price_fen';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_platform'
|
||||
) THEN
|
||||
ALTER TABLE platform_credit_accounts
|
||||
ADD CONSTRAINT chk_platform_credit_accounts_platform
|
||||
CHECK (platform_key IN ('nanhang', 'lewan', 'rock'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_status'
|
||||
) THEN
|
||||
ALTER TABLE platform_credit_accounts
|
||||
ADD CONSTRAINT chk_platform_credit_accounts_status
|
||||
CHECK (status IN ('active', 'frozen', 'closed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_balance'
|
||||
) THEN
|
||||
ALTER TABLE platform_credit_accounts
|
||||
ADD CONSTRAINT chk_platform_credit_accounts_balance
|
||||
CHECK (balance_quantity >= 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_type'
|
||||
) THEN
|
||||
ALTER TABLE platform_credit_entries
|
||||
ADD CONSTRAINT chk_platform_credit_entries_type
|
||||
CHECK (entry_type IN ('grant', 'consume', 'adjust', 'return'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_delta'
|
||||
) THEN
|
||||
ALTER TABLE platform_credit_entries
|
||||
ADD CONSTRAINT chk_platform_credit_entries_delta
|
||||
CHECK (quantity_delta <> 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 订单项挂配置:客服确认履约时直接读这里
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT
|
||||
REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_config
|
||||
ON order_items (fulfillment_config_id)
|
||||
WHERE fulfillment_config_id IS NOT NULL;
|
||||
|
||||
-- 内部 SKU 表达式索引:人工 SKU 扫描与「重新匹配」回填都靠它。
|
||||
-- 没有这个索引时两处都是全表扫(实测扫描 2.4s / 回填 5.4s),
|
||||
-- 建索引后回填降到 1.7s。
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_manual_internal_sku
|
||||
ON order_items (COALESCE(NULLIF(spec_json->>'skuNick', ''), NULLIF(spec_json->>'outSkuSn', '')))
|
||||
WHERE fulfillment_mode = 'manual';
|
||||
|
||||
COMMENT ON COLUMN order_items.fulfillment_config_id IS
|
||||
'人工履约配置(人工渠道+消耗积分+成本模式);由「重新匹配」回填';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. 四个客服钱包的履约档案
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 三个积分平台 + 备用金现金。executor_key 统一 manual_dispatch,
|
||||
-- 靠 config_json.platformKey 区分平台;**不复用** kuaishou_ct_assisted /
|
||||
-- kuaishou_feifei(那是已下线的历史自动发货通道)。
|
||||
|
||||
INSERT INTO fulfillment_profiles (
|
||||
profile_key, name, executor_key, requires_claim, auto_dispatch,
|
||||
inventory_strategy, config_json, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
('manual_nanhang', '南航平台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
|
||||
'{"platformKey":"nanhang","platformName":"南航平台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb,
|
||||
NOW(), NOW()),
|
||||
('manual_lewan', '乐玩后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
|
||||
'{"platformKey":"lewan","platformName":"乐玩后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb,
|
||||
NOW(), NOW()),
|
||||
('manual_rock', 'ROCK 后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
|
||||
'{"platformKey":"rock","platformName":"ROCK 后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":100}'::jsonb,
|
||||
NOW(), NOW()),
|
||||
('manual_fund', '备用金现金人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
|
||||
'{"platformKey":"fund_cash","platformName":"备用金现金","unitName":"元","creditManaged":false,"cashAdapter":true,"rechargeUnitPriceFen":100}'::jsonb,
|
||||
NOW(), NOW())
|
||||
ON CONFLICT (profile_key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
config_json = EXCLUDED.config_json,
|
||||
updated_at = NOW();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. 备用金账户编号可读化
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
|
||||
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
|
||||
--
|
||||
-- 幂等:编号由 admin_users.username 重新推导,与当前 account_no 无关,
|
||||
-- 重复执行结果一致。
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fa.id,
|
||||
'FA-' || COALESCE(
|
||||
NULLIF(
|
||||
trim(
|
||||
both '_' FROM upper(
|
||||
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
|
||||
)
|
||||
),
|
||||
''
|
||||
),
|
||||
'ACCOUNT-' || fa.id
|
||||
) AS base_no
|
||||
FROM fund_accounts fa
|
||||
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
|
||||
),
|
||||
numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
base_no,
|
||||
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
|
||||
FROM base
|
||||
)
|
||||
UPDATE fund_accounts fa
|
||||
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
|
||||
FROM numbered n
|
||||
WHERE fa.id = n.id;
|
||||
@@ -1,301 +0,0 @@
|
||||
-- 108_finance_cost_integrity.sql
|
||||
-- 财务/成本完整性与影子总账(合并原 108/111/112/113/114)。
|
||||
--
|
||||
-- 合并原因:2026-10-26 之前这几块是分批补丁,生产基线 101 升级时会产生
|
||||
-- 5 个只差索引/约束/注释的迁移,顺序和回滚都难维护。合并后:
|
||||
-- 1. 成本异常归档表
|
||||
-- 2. 人工成本订单项级唯一与 subject 语义
|
||||
-- 3. 影子总账/期间/对账
|
||||
-- 4. 商品页/模板预览查询索引
|
||||
-- 全部语句保持幂等;新库从 101 升级与空库初始化同一路径。
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 成本异常归档(原 108_cost_exception_archive.sql)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS cost_exceptions_archive (
|
||||
id BIGINT PRIMARY KEY,
|
||||
subject_type TEXT NOT NULL,
|
||||
subject_id BIGINT NOT NULL,
|
||||
order_item_id BIGINT,
|
||||
work_order_id BIGINT,
|
||||
reason TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by_admin_user_id BIGINT,
|
||||
resolution_note TEXT NOT NULL DEFAULT '',
|
||||
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_archived_at
|
||||
ON cost_exceptions_archive (archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_subject
|
||||
ON cost_exceptions_archive (subject_type, subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_reason
|
||||
ON cost_exceptions_archive (reason, archived_at DESC);
|
||||
|
||||
COMMENT ON TABLE cost_exceptions_archive IS
|
||||
'已处理成本异常的归档:data-retention 任务把超过保留期(默认 60 天)的 resolved/ignored 行搬到这里,open 行不归档';
|
||||
COMMENT ON COLUMN cost_exceptions_archive.archived_at IS
|
||||
'归档时间;原表 created_at / resolved_at 原样保留,便于按原时间追溯';
|
||||
|
||||
-- 热表上补一个「待归档」用的部分索引,避免每日任务扫全表。
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archivable
|
||||
ON cost_exceptions (COALESCE(resolved_at, created_at))
|
||||
WHERE status <> 'open';
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 人工成本完整性(原 111_manual_cost_integrity.sql)
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE cost_snapshots
|
||||
SET subject_id = order_item_id
|
||||
WHERE subject_type = 'manual_case'
|
||||
AND order_item_id IS NOT NULL
|
||||
AND subject_id > 0
|
||||
AND subject_id <> order_item_id;
|
||||
|
||||
-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。
|
||||
UPDATE cost_snapshots c
|
||||
SET superseded_at = NOW()
|
||||
WHERE c.subject_type = 'manual_case'
|
||||
AND c.superseded_at IS NULL
|
||||
AND c.subject_id > 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_snapshots newer
|
||||
WHERE newer.subject_type = 'manual_case'
|
||||
AND newer.order_item_id = c.order_item_id
|
||||
AND newer.superseded_at IS NULL
|
||||
AND newer.id > c.id
|
||||
);
|
||||
|
||||
-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item
|
||||
ON cost_snapshots (order_item_id)
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0;
|
||||
|
||||
-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。
|
||||
-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject'
|
||||
) THEN
|
||||
ALTER TABLE cost_snapshots
|
||||
ADD CONSTRAINT chk_cost_snapshots_manual_subject
|
||||
CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id)
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 5. 人工成本监控 / 对账按订单项反查备用金支出。
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item
|
||||
ON manual_expenses (order_item_id)
|
||||
WHERE order_item_id IS NOT NULL;
|
||||
|
||||
-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。
|
||||
-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。
|
||||
DROP INDEX IF EXISTS idx_order_items_manual_internal_sku;
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key
|
||||
ON order_items (
|
||||
COALESCE(
|
||||
NULLIF(spec_json->>'skuNick', ''),
|
||||
NULLIF(spec_json->>'outSkuSn', ''),
|
||||
NULLIF(sku_code, ''),
|
||||
NULLIF(sku_name, '')
|
||||
)
|
||||
)
|
||||
WHERE fulfillment_mode = 'manual';
|
||||
|
||||
COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS
|
||||
'人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)';
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 影子总账(原 112_finance_ledger.sql)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||
account_code TEXT PRIMARY KEY,
|
||||
account_name TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL
|
||||
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
|
||||
normal_side TEXT NOT NULL CHECK (normal_side IN ('debit', 'credit')),
|
||||
parent_code TEXT REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_journal_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entry_no TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
ref_type TEXT NOT NULL,
|
||||
ref_id TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'reversed')),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (event_type, ref_type, ref_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_entries_period
|
||||
ON finance_journal_entries (period_key, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_entries_ref
|
||||
ON finance_journal_entries (ref_type, ref_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_journal_lines (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entry_id BIGINT NOT NULL REFERENCES finance_journal_entries(id) ON DELETE CASCADE,
|
||||
line_no INTEGER NOT NULL,
|
||||
account_code TEXT NOT NULL REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
|
||||
debit_fen BIGINT NOT NULL DEFAULT 0,
|
||||
credit_fen BIGINT NOT NULL DEFAULT 0,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
dimensions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
CHECK (debit_fen >= 0 AND credit_fen >= 0),
|
||||
CHECK ((debit_fen > 0) <> (credit_fen > 0)),
|
||||
UNIQUE (entry_id, line_no)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_lines_account
|
||||
ON finance_journal_lines (account_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_periods (
|
||||
period_key TEXT PRIMARY KEY,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
|
||||
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
closed_at TIMESTAMPTZ,
|
||||
closed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_reconciliations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
period_from DATE NOT NULL,
|
||||
period_to DATE NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual_import',
|
||||
status TEXT NOT NULL DEFAULT 'imported'
|
||||
CHECK (status IN ('imported', 'matched', 'differences', 'resolved')),
|
||||
expected_fen BIGINT NOT NULL DEFAULT 0,
|
||||
actual_fen BIGINT NOT NULL DEFAULT 0,
|
||||
diff_fen BIGINT NOT NULL DEFAULT 0,
|
||||
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
imported_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_reconciliations_period
|
||||
ON finance_reconciliations (provider, period_from DESC, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_reconciliation_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reconciliation_id BIGINT NOT NULL
|
||||
REFERENCES finance_reconciliations(id) ON DELETE CASCADE,
|
||||
line_no INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
platform_order_id TEXT NOT NULL DEFAULT '',
|
||||
external_ref TEXT NOT NULL DEFAULT '',
|
||||
entry_type TEXT NOT NULL CHECK (entry_type IN ('sale', 'refund')),
|
||||
amount_fen BIGINT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ,
|
||||
matched_order_id BIGINT,
|
||||
matched_refund_id BIGINT,
|
||||
expected_fen BIGINT,
|
||||
diff_fen BIGINT,
|
||||
match_status TEXT NOT NULL
|
||||
CHECK (match_status IN (
|
||||
'matched', 'amount_mismatch', 'missing_order', 'missing_refund', 'duplicate'
|
||||
)),
|
||||
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (reconciliation_id, line_no)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_status
|
||||
ON finance_reconciliation_items (reconciliation_id, match_status);
|
||||
|
||||
-- 科目表(可按业务裁剪;借贷方向先定死)
|
||||
INSERT INTO finance_accounts (
|
||||
account_code, account_name, account_type, normal_side, description, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
('1001', '银行存款', 'asset', 'debit', '公司银行/支付通道资金', NOW(), NOW()),
|
||||
('1002', '客服备用金', 'asset', 'debit', '已拨付给客服的备用金', NOW(), NOW()),
|
||||
('1101', '应收账款-平台', 'asset', 'debit', '平台待结算/待收款', NOW(), NOW()),
|
||||
('1201', '预付账款-客服积分', 'asset', 'debit', '已充值到平台的客服积分', NOW(), NOW()),
|
||||
('2001', '应付履约款', 'liability', 'credit', '应付打手/人工履约成本', NOW(), NOW()),
|
||||
('2005', '其他应付-垫付人', 'liability', 'credit', '客服垫付待核销', NOW(), NOW()),
|
||||
('3001', '投入资金/权益', 'equity', 'credit', '股东投入与期初权益', NOW(), NOW()),
|
||||
('4001', '商品销售收入', 'revenue', 'credit', '按用户实付确认的收入', NOW(), NOW()),
|
||||
('4101', '退款冲减', 'revenue', 'debit', '退款冲减收入(借方)', NOW(), NOW()),
|
||||
('5001', '履约成本', 'expense', 'debit', '打手报酬/人工/自动发货成本', NOW(), NOW()),
|
||||
('5101', '人工履约成本', 'expense', 'debit', '人工客服实际垫付成本', NOW(), NOW())
|
||||
ON CONFLICT (account_code) DO UPDATE
|
||||
SET account_name = EXCLUDED.account_name,
|
||||
account_type = EXCLUDED.account_type,
|
||||
normal_side = EXCLUDED.normal_side,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
COMMENT ON TABLE finance_accounts IS '会计科目表;影子总账使用,不接管业务钱包余额';
|
||||
COMMENT ON TABLE finance_journal_entries IS '凭证头;event_type + ref_type + ref_id 保证业务幂等';
|
||||
COMMENT ON TABLE finance_journal_lines IS '凭证行;每行只能借或贷,单张凭证借贷必须平衡';
|
||||
COMMENT ON TABLE finance_periods IS '会计期间;closed 期间禁止新增凭证';
|
||||
COMMENT ON TABLE finance_reconciliations IS '平台对账批次:导入平台账单后与订单/退款匹配';
|
||||
COMMENT ON TABLE finance_reconciliation_items IS '对账明细:matched / amount_mismatch / missing_* / duplicate';
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 成本中心查询索引(原 113_cost_center_query_indexes.sql)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_mode
|
||||
ON order_items (fulfillment_mode)
|
||||
WHERE canonical_product_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_affiliate_recent
|
||||
ON fulfillment_tasks (created_at)
|
||||
WHERE executor_key = 'affiliate_dash';
|
||||
|
||||
COMMENT ON INDEX idx_order_items_unmapped_mode IS
|
||||
'成本中心总览:按履约方式统计未归一订单项,避免全表扫 order_items';
|
||||
COMMENT ON INDEX idx_fulfillment_tasks_affiliate_recent IS
|
||||
'自动发货任务 30 天聚合:只索引 affiliate_dash,减少商品列表聚合扫描';
|
||||
|
||||
-- 新索引建好后立刻更新统计信息,避免参数化查询在旧统计下继续选全表扫描。
|
||||
ANALYZE order_items;
|
||||
ANALYZE product_catalog;
|
||||
ANALYZE cost_exceptions;
|
||||
ANALYZE fulfillment_tasks;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 模板证据索引(原 114_template_evidence_indexes.sql)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_basis_date
|
||||
ON orders (((COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_order_sku_cover
|
||||
ON order_items (order_id) INCLUDE (id, sku_code, sku_name);
|
||||
|
||||
COMMENT ON INDEX idx_orders_basis_date IS
|
||||
'模板初始化/时间筛选:按 Asia/Shanghai 订单日期过滤,避免全表扫 orders';
|
||||
COMMENT ON INDEX idx_order_items_order_sku_cover IS
|
||||
'模板初始化的 SKU 证据覆盖索引,避免回表取 sku_code/sku_name';
|
||||
|
||||
ANALYZE orders;
|
||||
ANALYZE order_items;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 110_work_order_hall_and_shop_indexes.sql
|
||||
-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。
|
||||
--
|
||||
-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引:
|
||||
-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]),
|
||||
-- 普通 btree 无法命中表达式,必须建同表达式索引;
|
||||
-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单,
|
||||
-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。
|
||||
-- 全部 IF NOT EXISTS,可安全重跑。
|
||||
|
||||
-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
|
||||
ON orders (BTRIM(shop_name))
|
||||
WHERE BTRIM(shop_name) <> '';
|
||||
|
||||
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
|
||||
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
|
||||
|
||||
-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
|
||||
ON work_orders (id)
|
||||
WHERE status = 'open' AND pinned_at IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_work_orders_hall_pinned IS
|
||||
'抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 111_work_order_share_cancel_reason_comment.sql
|
||||
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
|
||||
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
|
||||
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement';
|
||||
@@ -1,39 +0,0 @@
|
||||
-- 112_consignment_contact_supporter.sql
|
||||
-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。
|
||||
-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。
|
||||
-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。
|
||||
-- 全部语句幂等,可重复执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consignment_contact_supporters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order
|
||||
ON consignment_contact_supporters (enabled, sort_order, id);
|
||||
|
||||
ALTER TABLE consignment_submissions
|
||||
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
|
||||
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
|
||||
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter
|
||||
ON consignment_submissions (contact_supporter_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter
|
||||
ON consignment_inventories (contact_supporter_id);
|
||||
|
||||
-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。
|
||||
-- 后台可直接改名/补充备注,不会重复插入。
|
||||
INSERT INTO consignment_contact_supporters (name, note, sort_order)
|
||||
SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters);
|
||||
Reference in New Issue
Block a user