feat(finance): 人工履约 SKU 配置清单 + 四钱包(南航/乐玩/ROCK积分 + 备用金现金)

客服人工履约有四个钱包,成本与渠道在后台配置好,客服确认履约时零输入。

口径(2026-10-24 确认):
- 四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。三平台积分互不通用,
  每客服每平台一个积分账户;现金复用现有 fund_accounts / fund_entries。
- 积分换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,
  ROCK 1 积分 = 1 元(100 分)。真源是履约档案 config_json.rechargeUnitPriceFen。
- 积分是货币,幸运币是商品;消耗积分与成本金额按换算率一一对应。
- 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
  不是商品标题(标题是引流)。同名 SKU 算同一商品,跨渠道共用一份配置。
- 配置链路没有匹配引擎、没有打分:扫描 → 配置 → 重新匹配。
  10 月口径下人工单 17,469 单 / 357 个内部 SKU,Top 50 覆盖 88% 单量。
  更早的老单没配置意义,不纳入统计口径。
- 消耗积分填 0 = 成本不固定,客服在确认履约时手填实际成本;
  渠道始终提前配好,客服不选。

迁移 110:
- manual_sku_registry:扫描到的内部 SKU 登记表(统计 + 配置状态)。
- manual_fulfillment_configs:内部 SKU → 渠道 + 消耗积分 + 成本模式。
- platform_credit_accounts / platform_credit_entries:三平台积分钱包与流水。
- order_items.fulfillment_config_id:订单项挂配置,重新匹配时回填。
- 4 条 manual_* 履约档案(manual_dispatch 执行器 + config_json 区分平台)。
  不复用 kuaishou_ct_assisted / kuaishou_feifei——那是已下线的历史自动发货通道。

性能:spec_json->>'skuNick' 无索引时扫描 2.4s / 回填 5.4s;
新增 idx_order_items_manual_internal_sku 表达式索引后回填降到 1.7s。

后端:manual-fulfillment-config-service.ts(扫描/清单/配置CRUD/重新匹配),
成本中心挂 7 个接口。
前端:成本中心新增「人工 SKU 配置」Tab(扫描、清单、配置弹窗、重新匹配)。

校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测扫描 357 个 SKU、配置四平台换算正确、重新匹配回填 7,604 个订单项;
本地库迁移 110 执行后数据量与改造前一致。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-10-24 13:12:46 +08:00
co-authored by Claude Opus 4.5
parent fb679d76f6
commit 4bcb4e6b57
7 changed files with 1693 additions and 0 deletions
@@ -0,0 +1,260 @@
-- 110_manual_fulfillment_platform_credits.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包。
--
-- 背景(2026-10-24 确认):
-- 客服人工履约有四个资金/额度钱包,每个客服各一个:
-- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金
-- 三个积分平台的积分**互不通用**,所以每个平台一套账户。
-- 换算率固定:1 积分 = 1 分钱(= 1 fen),三平台统一。
--
-- 配置链路(无匹配引擎、无打分):
-- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry;
-- 2. 运营在成本中心配置每个 SKU 的「履约渠道 + 消耗积分 + 成本模式」;
-- 3. 点「重新匹配」把配置回填到历史人工订单项;
-- 4. 客服确认履约时直接读配置,固定成本零输入。
--
-- 关键口径:
-- * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),不是商品标题;
-- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用);
-- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
-- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。
-- ---------------------------------------------------------------------------
-- 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
'每单消耗积分(整数)。换算率固定 1 积分 = 1 分钱;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 三平台积分互不通用,每平台每客服一个账户';
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. 积分充值价口径:**按平台不同**
-- 南航 1 积分 = 1 分;乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元(100 分)。
-- 真源是各档案的 config_json.rechargeUnitPriceFen,
-- 成本折算与积分流水都从这里读,改价只改档案一处。
-- ---------------------------------------------------------------------------
COMMENT ON TABLE platform_credit_accounts IS
'客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)';