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:
co-authored by
Claude Opus 4.5
parent
fb679d76f6
commit
4bcb4e6b57
@@ -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 元)';
|
||||
@@ -31,6 +31,16 @@ import {
|
||||
updateAdminChannelMapping,
|
||||
updateAdminProduct,
|
||||
} from '../../services/cost-center/product-catalog-service.js'
|
||||
import {
|
||||
deleteManualFulfillmentConfig,
|
||||
getManualFulfillmentConfig,
|
||||
getManualSkuOverview,
|
||||
listManualPlatformOptions,
|
||||
listManualSkus,
|
||||
rematchManualFulfillments,
|
||||
saveManualFulfillmentConfig,
|
||||
scanManualSkus,
|
||||
} from '../../services/cost-center/manual-fulfillment-config-service.js'
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
backfillAdminAffiliateDashSkus,
|
||||
@@ -597,4 +607,142 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 人工履约 SKU 配置:扫描 → 配置 → 重新匹配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/cost-center/manual-skus',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => listManualSkus(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工 SKU 清单失败',
|
||||
scope: '[admin/cost-center/manual-skus]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/manual-skus/overview',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(() => getManualSkuOverview(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工 SKU 概览失败',
|
||||
scope: '[admin/cost-center/manual-skus/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/manual-platforms',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(() => listManualPlatformOptions(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工履约渠道失败',
|
||||
scope: '[admin/cost-center/manual-platforms]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/manual-configs/:skuKey',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => getManualFulfillmentConfig(String(req.params.skuKey || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工履约配置失败',
|
||||
scope: '[admin/cost-center/manual-configs/:skuKey]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/manual-skus/scan',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => scanManualSkus(bodyOf(req) as never), {
|
||||
successMessage: '扫描完成',
|
||||
errorMessage: '扫描人工 SKU 失败',
|
||||
scope: '[admin/cost-center/manual-skus/scan]',
|
||||
audit: (req, data) => {
|
||||
const result = data as { scanned?: number; fromDate?: string; toDate?: string }
|
||||
return {
|
||||
action: 'manual_skus_scanned',
|
||||
targetType: 'manual_sku_registry',
|
||||
targetId: '',
|
||||
data: {
|
||||
scanned: result.scanned || 0,
|
||||
fromDate: result.fromDate || '',
|
||||
toDate: result.toDate || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/manual-configs',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => saveManualFulfillmentConfig(bodyOf(req)), {
|
||||
successMessage: '人工履约配置已保存',
|
||||
errorMessage: '保存人工履约配置失败',
|
||||
scope: '[admin/cost-center/manual-configs]',
|
||||
audit: (req, data) => {
|
||||
const config = data as {
|
||||
id?: number
|
||||
skuKey?: string
|
||||
platformKey?: string
|
||||
creditQuantity?: number
|
||||
costFen?: number
|
||||
}
|
||||
return {
|
||||
action: 'manual_fulfillment_config_saved',
|
||||
targetType: 'manual_fulfillment_config',
|
||||
targetId: String(config.id || ''),
|
||||
data: {
|
||||
skuKey: config.skuKey || '',
|
||||
platformKey: config.platformKey || '',
|
||||
creditQuantity: config.creditQuantity || 0,
|
||||
costFen: config.costFen || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/cost-center/manual-configs/:skuKey',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => deleteManualFulfillmentConfig(String(req.params.skuKey || '')), {
|
||||
successMessage: '人工履约配置已删除',
|
||||
errorMessage: '删除人工履约配置失败',
|
||||
scope: '[admin/cost-center/manual-configs/:skuKey]',
|
||||
audit: (req, data) => {
|
||||
const result = data as { skuKey?: string; deleted?: number }
|
||||
return {
|
||||
action: 'manual_fulfillment_config_deleted',
|
||||
targetType: 'manual_fulfillment_config',
|
||||
targetId: String(req.params.skuKey || ''),
|
||||
data: { skuKey: result.skuKey || '', deleted: result.deleted || 0 },
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/manual-configs/rematch',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => rematchManualFulfillments(bodyOf(req)), {
|
||||
successMessage: '重新匹配完成',
|
||||
errorMessage: '重新匹配失败',
|
||||
scope: '[admin/cost-center/manual-configs/rematch]',
|
||||
audit: (req, data) => {
|
||||
const result = data as { updatedCount?: number; skuKey?: string }
|
||||
return {
|
||||
action: 'manual_fulfillment_rematched',
|
||||
targetType: 'order_items',
|
||||
targetId: '',
|
||||
data: {
|
||||
updatedCount: result.updatedCount || 0,
|
||||
skuKey: result.skuKey || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
/**
|
||||
* 人工履约 SKU 配置服务。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
|
||||
* 不是商品标题;同名 SKU 算同一个商品,跨渠道共用一份配置;
|
||||
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式;
|
||||
* * 换算率固定:1 积分 = 1 分钱;
|
||||
* * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
|
||||
* * 「重新匹配」= 把配置回填到历史人工订单项,幂等可反复执行。
|
||||
*
|
||||
* 没有匹配引擎、没有打分:这就是一张带统计的 SKU 配置清单。
|
||||
*/
|
||||
|
||||
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash'] as const
|
||||
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
|
||||
|
||||
/**
|
||||
* 积分换算率(分/积分),**按平台不同**:
|
||||
* 南航 1 积分 = 1 分;乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元(100 分)。
|
||||
* 备用金现金不涉及积分,换算率恒为 100(1 元 = 100 分)。
|
||||
*
|
||||
* 真源是 fulfillment_profiles.config_json.rechargeUnitPriceFen,
|
||||
* 这里只是平台档案缺失时的兜底默认值。
|
||||
*/
|
||||
export const DEFAULT_CREDIT_UNIT_PRICE_FEN = 1
|
||||
|
||||
/** 平台 → 默认换算率(分/积分)。 */
|
||||
const PLATFORM_UNIT_PRICE_FEN: Record<ManualPlatformKey, number> = {
|
||||
nanhang: 1,
|
||||
lewan: 1,
|
||||
rock: 100,
|
||||
fund_cash: 100,
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
|
||||
nanhang: '南航平台',
|
||||
lewan: '乐玩后台',
|
||||
rock: 'ROCK 后台',
|
||||
fund_cash: '备用金现金',
|
||||
}
|
||||
|
||||
/** 读取平台档案里配置的换算率;档案不存在时用默认表。 */
|
||||
async function loadPlatformUnitPriceFen(): Promise<Record<ManualPlatformKey, number>> {
|
||||
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
|
||||
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
|
||||
)
|
||||
const prices: Record<ManualPlatformKey, number> = { ...PLATFORM_UNIT_PRICE_FEN }
|
||||
for (const row of result.rows) {
|
||||
const raw = Number(row.config_json?.rechargeUnitPriceFen)
|
||||
const key = row.profile_key.replace(/^manual_/, '') as ManualPlatformKey
|
||||
if (key in prices && Number.isInteger(raw) && raw > 0) {
|
||||
prices[key] = raw
|
||||
}
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
type ScanOptions = {
|
||||
fromDate: string
|
||||
toDate: string
|
||||
}
|
||||
|
||||
type ManualSkuRow = {
|
||||
id: number
|
||||
sku_key: string
|
||||
display_name: string
|
||||
shop_count: number
|
||||
order_count: number
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_date: string | null
|
||||
last_seen_date: string | null
|
||||
scan_from_date: string | null
|
||||
scan_to_date: string | null
|
||||
config_id: number | null
|
||||
config_platform_key: string | null
|
||||
config_credit_quantity: number | null
|
||||
config_cost_mode: string | null
|
||||
config_canonical_product_id: number | null
|
||||
config_enabled: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry。
|
||||
*
|
||||
* 幂等:重复扫描按 sku_key 覆盖统计,不产生重复行。
|
||||
* 已有配置的 SKU 不受影响(配置存在 manual_fulfillment_configs 里)。
|
||||
*/
|
||||
export async function scanManualSkus(options: ScanOptions) {
|
||||
const fromDate = normalizeDate(options.fromDate, '开始日期')
|
||||
const toDate = normalizeDate(options.toDate, '结束日期')
|
||||
if (fromDate > toDate) {
|
||||
throw createHttpError('开始日期不能晚于结束日期', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_sku_scan_range_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
const now = nowIso()
|
||||
const result = await query<{
|
||||
sku_key: string
|
||||
display_name: string
|
||||
shop_count: number
|
||||
order_count: number
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_date: string
|
||||
last_seen_date: string
|
||||
}>(
|
||||
`
|
||||
WITH scoped AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.shop_id,
|
||||
oi.sku_name,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn', '')
|
||||
) AS sku_key
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
),
|
||||
keyed AS (
|
||||
SELECT * FROM scoped WHERE sku_key IS NOT NULL AND sku_key <> ''
|
||||
)
|
||||
SELECT
|
||||
sku_key,
|
||||
MAX(sku_name) AS display_name,
|
||||
COUNT(DISTINCT shop_id)::int AS shop_count,
|
||||
COUNT(DISTINCT order_id)::int AS order_count,
|
||||
COUNT(*)::int AS item_count,
|
||||
SUM(revenue_fen)::bigint AS revenue_fen,
|
||||
MIN(stat_date) AS first_seen_date,
|
||||
MAX(stat_date) AS last_seen_date
|
||||
FROM keyed
|
||||
GROUP BY sku_key
|
||||
`,
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
const rows = result.rows
|
||||
if (rows.length === 0) {
|
||||
return { scanned: 0, registered: 0, fromDate, toDate }
|
||||
}
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const row of rows) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO manual_sku_registry (
|
||||
sku_key, display_name, shop_count, order_count, item_count, revenue_fen,
|
||||
first_seen_date, last_seen_date, scan_from_date, scan_to_date,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7::date, $8::date, $9::date, $10::date, $11, $11
|
||||
)
|
||||
ON CONFLICT (sku_key) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
shop_count = EXCLUDED.shop_count,
|
||||
order_count = EXCLUDED.order_count,
|
||||
item_count = EXCLUDED.item_count,
|
||||
revenue_fen = EXCLUDED.revenue_fen,
|
||||
first_seen_date = LEAST(
|
||||
COALESCE(manual_sku_registry.first_seen_date, EXCLUDED.first_seen_date),
|
||||
EXCLUDED.first_seen_date
|
||||
),
|
||||
last_seen_date = GREATEST(
|
||||
COALESCE(manual_sku_registry.last_seen_date, EXCLUDED.last_seen_date),
|
||||
EXCLUDED.last_seen_date
|
||||
),
|
||||
scan_from_date = EXCLUDED.scan_from_date,
|
||||
scan_to_date = EXCLUDED.scan_to_date,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[
|
||||
row.sku_key,
|
||||
row.display_name || '',
|
||||
row.shop_count,
|
||||
row.order_count,
|
||||
row.item_count,
|
||||
row.revenue_fen,
|
||||
row.first_seen_date,
|
||||
row.last_seen_date,
|
||||
fromDate,
|
||||
toDate,
|
||||
now,
|
||||
],
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return { scanned: rows.length, registered: rows.length, fromDate, toDate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出人工 SKU 清单(登记表 + 配置状态)。
|
||||
*/
|
||||
export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||||
const pageSize = Math.min(
|
||||
200,
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const keyword = String(input.keyword || input.q || '').trim()
|
||||
const status = String(input.status || '').trim()
|
||||
const platformKey = String(input.platformKey || input.platform_key || '').trim()
|
||||
const provider = String(input.provider || '').trim()
|
||||
|
||||
const conditions: string[] = ['1 = 1']
|
||||
const params: unknown[] = []
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${escapeLike(keyword)}%`)
|
||||
const placeholder = `$${params.length}`
|
||||
conditions.push(`(r.sku_key ILIKE ${placeholder} OR r.display_name ILIKE ${placeholder})`)
|
||||
}
|
||||
if (status === 'configured') {
|
||||
conditions.push('cfg.id IS NOT NULL')
|
||||
} else if (status === 'unconfigured') {
|
||||
conditions.push('cfg.id IS NULL')
|
||||
}
|
||||
if (platformKey) {
|
||||
params.push(platformKey)
|
||||
conditions.push(`cfg.platform_key = $${params.length}`)
|
||||
}
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND o.provider = $${params.length}
|
||||
AND COALESCE(NULLIF(oi.spec_json->>'skuNick', ''), NULLIF(oi.spec_json->>'outSkuSn', '')) = r.sku_key
|
||||
)`)
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ')
|
||||
const countResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM manual_sku_registry r
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
|
||||
WHERE ${where}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<ManualSkuRow>(
|
||||
`
|
||||
SELECT
|
||||
r.id, r.sku_key, r.display_name, r.shop_count, r.order_count, r.item_count,
|
||||
r.revenue_fen, r.first_seen_date, r.last_seen_date,
|
||||
r.scan_from_date, r.scan_to_date,
|
||||
cfg.id AS config_id,
|
||||
cfg.platform_key AS config_platform_key,
|
||||
cfg.credit_quantity AS config_credit_quantity,
|
||||
cfg.cost_mode AS config_cost_mode,
|
||||
cfg.canonical_product_id AS config_canonical_product_id,
|
||||
cfg.enabled AS config_enabled
|
||||
FROM manual_sku_registry r
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
|
||||
WHERE ${where}
|
||||
ORDER BY r.item_count DESC, r.sku_key ASC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map((row) => mapManualSku(row, unitPrices)),
|
||||
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
|
||||
}
|
||||
}
|
||||
|
||||
/** 清单总览:已配置 / 未配置 / 覆盖单量。 */
|
||||
export async function getManualSkuOverview() {
|
||||
const result = await query<{
|
||||
total_sku: number
|
||||
configured_sku: number
|
||||
unconfigured_sku: number
|
||||
total_items: number
|
||||
configured_items: number
|
||||
unconfigured_items: number
|
||||
last_scan_at: string | null
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_sku,
|
||||
COUNT(*) FILTER (WHERE cfg.id IS NOT NULL)::int AS configured_sku,
|
||||
COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_sku,
|
||||
COALESCE(SUM(r.item_count), 0)::bigint AS total_items,
|
||||
COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NOT NULL), 0)::bigint
|
||||
AS configured_items,
|
||||
COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NULL), 0)::bigint
|
||||
AS unconfigured_items,
|
||||
MAX(r.updated_at) AS last_scan_at
|
||||
FROM manual_sku_registry r
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key
|
||||
`,
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
totalSku: Number(row?.total_sku || 0),
|
||||
configuredSku: Number(row?.configured_sku || 0),
|
||||
unconfiguredSku: Number(row?.unconfigured_sku || 0),
|
||||
totalItems: Number(row?.total_items || 0),
|
||||
configuredItems: Number(row?.configured_items || 0),
|
||||
unconfiguredItems: Number(row?.unconfigured_items || 0),
|
||||
lastScanAt: row?.last_scan_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存(新增或更新)一个人工 SKU 的履约配置。 */
|
||||
export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
const skuKey = String(payload.skuKey ?? payload.sku_key ?? '').trim()
|
||||
if (!skuKey) {
|
||||
throw createHttpError('内部 SKU 不能为空', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_sku_key_required',
|
||||
})
|
||||
}
|
||||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||||
const creditQuantity = normalizeCreditQuantity(payload.creditQuantity ?? payload.credit_quantity)
|
||||
const canonicalProductId = normalizeOptionalId(
|
||||
payload.canonicalProductId ?? payload.canonical_product_id,
|
||||
)
|
||||
const enabled = payload.enabled === false ? false : true
|
||||
const note = optionalText(payload.note, 300)
|
||||
|
||||
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
|
||||
const costMode = creditQuantity > 0 ? 'fixed' : 'variable'
|
||||
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
const now = nowIso()
|
||||
const result = await query<{
|
||||
id: number
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: number
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>(
|
||||
`
|
||||
INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, canonical_product_id, platform_key, credit_quantity,
|
||||
cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $8
|
||||
)
|
||||
ON CONFLICT (sku_key) DO UPDATE
|
||||
SET canonical_product_id = EXCLUDED.canonical_product_id,
|
||||
platform_key = EXCLUDED.platform_key,
|
||||
credit_quantity = EXCLUDED.credit_quantity,
|
||||
cost_mode = EXCLUDED.cost_mode,
|
||||
enabled = EXCLUDED.enabled,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
|
||||
)
|
||||
|
||||
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
|
||||
await syncRegistryConfigId(config.sku_key, config.id)
|
||||
return mapManualConfig(config, unitPrices)
|
||||
}
|
||||
|
||||
/** 删除一份人工履约配置(SKU 回到未配置状态)。 */
|
||||
export async function deleteManualFulfillmentConfig(skuKey: string) {
|
||||
const key = String(skuKey || '').trim()
|
||||
if (!key) {
|
||||
throw createHttpError('内部 SKU 不能为空', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_sku_key_required',
|
||||
})
|
||||
}
|
||||
const deleted = await query(
|
||||
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
|
||||
[key],
|
||||
)
|
||||
await syncRegistryConfigId(key, null)
|
||||
return { skuKey: key, deleted: deleted.rowCount || 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新匹配:把已配置项回填到历史人工订单项。
|
||||
*
|
||||
* 幂等:只填空值,已回填的不动。
|
||||
*/
|
||||
export async function rematchManualFulfillments(input: Record<string, unknown> = {}) {
|
||||
const skuKey = String(input.skuKey ?? input.sku_key ?? '').trim()
|
||||
const onlyUnmatched = input.onlyUnmatched !== false
|
||||
|
||||
const now = nowIso()
|
||||
const result = await query<{ updated_count: number }>(
|
||||
`
|
||||
WITH target AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
cfg.id AS config_id,
|
||||
cfg.canonical_product_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON cfg.sku_key = COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn', '')
|
||||
)
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND cfg.enabled = TRUE
|
||||
AND ($1 = '' OR cfg.sku_key = $1)
|
||||
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET fulfillment_config_id = target.config_id,
|
||||
canonical_product_id = COALESCE(target.canonical_product_id, oi.canonical_product_id),
|
||||
updated_at = $3
|
||||
FROM target
|
||||
WHERE oi.id = target.order_item_id
|
||||
RETURNING 1
|
||||
`,
|
||||
[skuKey, onlyUnmatched, now],
|
||||
)
|
||||
|
||||
// 配置生效后,把 SKU 对应的成本版本挂上(若已配置商品)。
|
||||
await attachCostVersionsForManualConfigs()
|
||||
|
||||
return {
|
||||
updatedCount: result.rowCount || 0,
|
||||
skuKey,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为已配置的人工 SKU 生成/刷新成本版本(按 1 积分 = 1 分钱换算)。
|
||||
*
|
||||
* 成本版本是「成本金额」的唯一真源;积分消耗量记在配置里,
|
||||
* 两者按固定换算率保持一致。
|
||||
*/
|
||||
async function attachCostVersionsForManualConfigs() {
|
||||
const costItemResult = await query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
|
||||
)
|
||||
const costItemId = Number(costItemResult.rows[0]?.id || 0)
|
||||
if (!costItemId) return
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO cost_versions (
|
||||
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
|
||||
work_product_rule_id, amount_fen, rate_bps, effective_from, source,
|
||||
status, change_reason, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
$1, 'product', '', '', cfg.canonical_product_id, NULL,
|
||||
(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0,
|
||||
'1970-01-01T00:00:00+08:00', 'manual', 'active',
|
||||
'人工履约配置:积分按平台换算率折算',
|
||||
NOW(), NOW()
|
||||
FROM manual_fulfillment_configs cfg
|
||||
JOIN fulfillment_profiles fp
|
||||
ON fp.profile_key = 'manual_' || cfg.platform_key
|
||||
WHERE cfg.enabled = TRUE
|
||||
AND cfg.credit_quantity > 0
|
||||
AND cfg.canonical_product_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_versions v
|
||||
WHERE v.cost_item_id = $1
|
||||
AND v.scope_type = 'product'
|
||||
AND v.canonical_product_id = cfg.canonical_product_id
|
||||
AND v.status = 'active'
|
||||
)
|
||||
`,
|
||||
[costItemId],
|
||||
)
|
||||
}
|
||||
|
||||
/** 配置保存/删除后同步登记表上的 config_id。 */
|
||||
async function syncRegistryConfigId(skuKey: string, configId: number | null) {
|
||||
await query(`UPDATE manual_sku_registry SET config_id = $2, updated_at = $3 WHERE sku_key = $1`, [
|
||||
skuKey,
|
||||
configId,
|
||||
nowIso(),
|
||||
])
|
||||
}
|
||||
|
||||
/** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */
|
||||
export async function getManualFulfillmentConfig(skuKey: string) {
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
const key = String(skuKey || '').trim()
|
||||
const result = await query<{
|
||||
id: number
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: number
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>(`SELECT * FROM manual_fulfillment_configs WHERE sku_key = $1 LIMIT 1`, [key])
|
||||
const row = result.rows[0]
|
||||
if (!row) return null
|
||||
return mapManualConfig(row, unitPrices)
|
||||
}
|
||||
|
||||
/** 平台选项(前端下拉)。 */
|
||||
export async function listManualPlatformOptions() {
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
return {
|
||||
items: MANUAL_PLATFORM_KEYS.map((key) => ({
|
||||
key,
|
||||
label: PLATFORM_LABELS[key],
|
||||
isCredit: key !== 'fund_cash',
|
||||
creditUnitPriceFen: unitPrices[key],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, number>) {
|
||||
const creditQuantity = Number(row.config_credit_quantity || 0)
|
||||
const platformKey = (row.config_platform_key || '') as ManualPlatformKey
|
||||
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
|
||||
return {
|
||||
id: Number(row.id),
|
||||
skuKey: row.sku_key,
|
||||
displayName: row.display_name || '',
|
||||
shopCount: Number(row.shop_count || 0),
|
||||
orderCount: Number(row.order_count || 0),
|
||||
itemCount: Number(row.item_count || 0),
|
||||
revenueFen: Number(row.revenue_fen || 0),
|
||||
firstSeenDate: row.first_seen_date || null,
|
||||
lastSeenDate: row.last_seen_date || null,
|
||||
scanFromDate: row.scan_from_date || null,
|
||||
scanToDate: row.scan_to_date || null,
|
||||
configured: row.config_id !== null,
|
||||
config: row.config_id
|
||||
? {
|
||||
id: Number(row.config_id),
|
||||
skuKey: row.sku_key,
|
||||
platformKey: row.config_platform_key || '',
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || '',
|
||||
creditQuantity,
|
||||
costMode: row.config_cost_mode || 'variable',
|
||||
costFen: creditQuantity * unitPriceFen,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
canonicalProductId: row.config_canonical_product_id
|
||||
? Number(row.config_canonical_product_id)
|
||||
: null,
|
||||
enabled: row.config_enabled !== false,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapManualConfig(
|
||||
row: {
|
||||
id: number
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: number
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
},
|
||||
unitPrices: Record<ManualPlatformKey, number>,
|
||||
) {
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const platformKey = row.platform_key as ManualPlatformKey
|
||||
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
|
||||
return {
|
||||
id: Number(row.id),
|
||||
skuKey: row.sku_key,
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
platformKey: row.platform_key,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
|
||||
creditQuantity,
|
||||
costMode: row.cost_mode,
|
||||
costFen: creditQuantity * unitPriceFen,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
enabled: row.enabled,
|
||||
note: row.note || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlatformKey(value: unknown): ManualPlatformKey {
|
||||
const text = String(value || '').trim()
|
||||
if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) {
|
||||
throw createHttpError('履约渠道无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_platform_invalid',
|
||||
})
|
||||
}
|
||||
return text as ManualPlatformKey
|
||||
}
|
||||
|
||||
function normalizeCreditQuantity(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
|
||||
throw createHttpError('消耗积分必须是非负整数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_credit_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown, label: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||||
throw createHttpError(`${label}格式无效(应为 YYYY-MM-DD)`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_sku_date_invalid',
|
||||
})
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
|
||||
const parsed = Number(value)
|
||||
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
|
||||
if (!value) return fallback
|
||||
throw createHttpError(`${label}必须是正整数`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_sku_pagination_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength: number) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
function escapeLike(value: string) {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||||
}
|
||||
|
||||
function requiredRow<T>(row: T | undefined, message: string): T {
|
||||
if (!row) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_config_not_found',
|
||||
})
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { CostExceptionPanel } from './CostExceptionPanel'
|
||||
import { CostItemPanel } from './CostItemPanel'
|
||||
import { CostPreviewPanel } from './CostPreviewPanel'
|
||||
import { CostVersionPanel } from './CostVersionPanel'
|
||||
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
|
||||
import { ProductCatalogPanel } from './ProductCatalogPanel'
|
||||
import { CostCenterContext } from './CostCenterContext'
|
||||
import type { CostScope } from './shared'
|
||||
@@ -58,6 +59,7 @@ function tabKeysForScope(scope: CostScope) {
|
||||
return [
|
||||
...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []),
|
||||
...(scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
|
||||
...(scope === 'all' || scope === 'manual' ? ['manual-skus'] : []),
|
||||
'cost-items',
|
||||
'cost-versions',
|
||||
'exceptions',
|
||||
@@ -231,6 +233,11 @@ export default function CostCenterShell() {
|
||||
label: '自动发货 SKU',
|
||||
children: <AffiliateSkuPanel />,
|
||||
},
|
||||
{
|
||||
key: 'manual-skus',
|
||||
label: '人工 SKU 配置',
|
||||
children: <ManualSkuConfigPanel />,
|
||||
},
|
||||
{
|
||||
key: 'cost-items',
|
||||
label: '成本项目',
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 人工 SKU 配置 Tab:扫描 → 配置 → 重新匹配。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),商品名称仅供识别;
|
||||
* * 同名 SKU 算同一个商品,跨渠道共用一份配置;
|
||||
* * 客服人工有四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金;
|
||||
* * 积分换算率按平台不同(南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元);
|
||||
* * 消耗积分填 0 = 成本不固定,客服确认履约时手填实际成本。
|
||||
*
|
||||
* 这里没有匹配引擎、没有打分:就是一张带统计的 SKU 配置清单。
|
||||
*/
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ReloadOutlined,
|
||||
ScanOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
deleteAdminManualConfig,
|
||||
fetchAdminManualPlatforms,
|
||||
fetchAdminManualSkuOverview,
|
||||
fetchAdminManualSkus,
|
||||
rematchAdminManualFulfillments,
|
||||
saveAdminManualConfig,
|
||||
scanAdminManualSkus,
|
||||
} from '@/services/admin'
|
||||
import type { ManualSkuItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
type ConfigFormValues = {
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
fund_cash: 'orange',
|
||||
}
|
||||
|
||||
/** 按换算率把「消耗单位」翻译成人话。 */
|
||||
function unitLabel(platformKey: string, creditUnitPriceFen: number) {
|
||||
if (platformKey === 'fund_cash') return '元'
|
||||
if (creditUnitPriceFen >= 100) return '积分(1积分=1元)'
|
||||
return '积分(1积分=1分)'
|
||||
}
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ConfigFormValues>()
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
|
||||
const [platformKey, setPlatformKey] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [scanRange, setScanRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().startOf('month'),
|
||||
dayjs().endOf('month'),
|
||||
])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ManualSkuItem | null>(null)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-manual-skus', keyword, status, platformKey, page, pageSize],
|
||||
queryFn: () =>
|
||||
fetchAdminManualSkus({
|
||||
keyword: keyword || undefined,
|
||||
status: status === 'all' ? '' : status,
|
||||
platformKey: platformKey || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
})
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||
queryFn: fetchAdminManualSkuOverview,
|
||||
})
|
||||
const platformQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-manual-platforms'],
|
||||
queryFn: fetchAdminManualPlatforms,
|
||||
})
|
||||
|
||||
const overview = overviewQuery.data?.data
|
||||
const platforms = platformQuery.data?.data.items || []
|
||||
const platformMap = new Map(platforms.map((item) => [item.key, item]))
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
scanAdminManualSkus({
|
||||
fromDate: scanRange[0].format('YYYY-MM-DD'),
|
||||
toDate: scanRange[1].format('YYYY-MM-DD'),
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(`扫描完成,登记 ${result.registered} 个内部 SKU`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '扫描失败')
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
platformKey: values.platformKey,
|
||||
creditQuantity: values.creditQuantity,
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('配置已保存')
|
||||
setModalOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (skuKey: string) => deleteAdminManualConfig(skuKey),
|
||||
onSuccess: () => {
|
||||
message.success('配置已删除')
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '删除失败')
|
||||
},
|
||||
})
|
||||
|
||||
const rematchMutation = useMutation({
|
||||
mutationFn: () => rematchAdminManualFulfillments(),
|
||||
onSuccess: (response) => {
|
||||
message.success(`重新匹配完成,回填 ${response.data.updatedCount} 个订单项`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '重新匹配失败')
|
||||
},
|
||||
})
|
||||
|
||||
function openConfig(item: ManualSkuItem) {
|
||||
setEditing(item)
|
||||
form.setFieldsValue({
|
||||
platformKey: item.config?.platformKey || undefined,
|
||||
creditQuantity: item.config?.creditQuantity ?? undefined,
|
||||
note: item.config?.note || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<ManualSkuItem> = [
|
||||
{
|
||||
title: '内部 SKU(配置主键)',
|
||||
dataIndex: 'skuKey',
|
||||
width: 280,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong copyable={{ text: row.skuKey }}>
|
||||
{row.skuKey}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.displayName || '(无名称)'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '店铺', dataIndex: 'shopCount', width: 70, align: 'right' },
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 90, align: 'right' },
|
||||
{ title: '订单项', dataIndex: 'itemCount', width: 90, align: 'right' },
|
||||
{
|
||||
title: '收入',
|
||||
dataIndex: 'revenueFen',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(0)}`,
|
||||
},
|
||||
{
|
||||
title: '履约渠道',
|
||||
key: 'platform',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
<Space size={4} direction="vertical">
|
||||
<Tag color={PLATFORM_TAG_COLORS[row.config.platformKey] || 'default'}>
|
||||
{row.config.platformLabel || row.config.platformKey}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.config.creditQuantity}{' '}
|
||||
{unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未配置</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
key: 'cost',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
row.config.costMode === 'fixed' ? (
|
||||
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
|
||||
) : (
|
||||
<Tag color="orange">不固定·客服填</Tag>
|
||||
)
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||
{row.configured ? '编辑' : '配置'}
|
||||
</Button>
|
||||
{row.configured ? (
|
||||
<Popconfirm
|
||||
title="删除该 SKU 的配置?"
|
||||
description="删除后该 SKU 回到未配置状态,已回填的订单项不再带配置。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const selectedPlatform = form.getFieldValue('platformKey')
|
||||
const selectedCredit = form.getFieldValue('creditQuantity')
|
||||
const previewUnitPrice = platformMap.get(selectedPlatform)?.creditUnitPriceFen ?? 0
|
||||
const previewCostFen =
|
||||
selectedPlatform && Number(selectedCredit) > 0 ? Number(selectedCredit) * previewUnitPrice : 0
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="配置主键是内部 SKU,不是商品名称"
|
||||
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。客服确认履约时直接读配置,固定成本零输入。消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
<Col span={6}>
|
||||
<Statistic title="内部 SKU 总数" value={overview?.totalSku ?? 0} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="已配置"
|
||||
value={overview?.configuredSku ?? 0}
|
||||
valueStyle={{ color: '#389e0d' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="未配置"
|
||||
value={overview?.unconfiguredSku ?? 0}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="未配置覆盖的订单项"
|
||||
value={overview?.unconfiguredItems ?? 0}
|
||||
suffix="单"
|
||||
valueStyle={{ color: '#d46b08' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<DatePicker.RangePicker
|
||||
value={scanRange}
|
||||
onChange={(value) => {
|
||||
if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]])
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ScanOutlined />}
|
||||
loading={scanMutation.isPending}
|
||||
onClick={() => scanMutation.mutate()}
|
||||
>
|
||||
扫描内部 SKU
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={rematchMutation.isPending}
|
||||
onClick={() => rematchMutation.mutate()}
|
||||
>
|
||||
重新匹配已配置
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="搜索内部 SKU 或名称"
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value.trim())
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'unconfigured', label: '未配置' },
|
||||
{ value: 'configured', label: '已配置' },
|
||||
{ value: 'all', label: '全部' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={platformKey}
|
||||
allowClear
|
||||
placeholder="全部渠道"
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setPlatformKey(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
options={platforms.map((item) => ({ value: item.key, label: item.label }))}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={listQuery.data?.data.items || []}
|
||||
loading={listQuery.isLoading}
|
||||
scroll={{ x: 1020 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
page,
|
||||
pageSize,
|
||||
total: listQuery.data?.data.pagination.total ?? 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
if (nextPageSize !== pageSize) setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={`配置人工履约 · ${editing?.skuKey || ''}`}
|
||||
okText="保存配置"
|
||||
cancelText="取消"
|
||||
confirmLoading={saveMutation.isPending}
|
||||
onOk={() => {
|
||||
void form.validateFields().then((values) => saveMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
description="渠道提前配置好,客服确认履约时不需要选择。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`,
|
||||
}))}
|
||||
onChange={() => form.validateFields(['creditQuantity'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="creditQuantity"
|
||||
label="每单消耗积分"
|
||||
rules={[{ required: true, message: '请输入消耗积分' }]}
|
||||
extra="填 0 表示成本不固定,客服确认履约时手填实际成本。"
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="例如 10" />
|
||||
</Form.Item>
|
||||
<Form.Item label="折算成本">
|
||||
<Typography.Text strong>
|
||||
{previewCostFen > 0
|
||||
? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)`
|
||||
: '成本不固定,由客服手填'}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import type {
|
||||
CostPreview,
|
||||
CostVersion,
|
||||
CostVersionListResult,
|
||||
ManualFulfillmentConfig,
|
||||
ManualPlatformOption,
|
||||
ManualRematchResult,
|
||||
ManualSkuListResult,
|
||||
ManualSkuOverview,
|
||||
ManualSkuScanResult,
|
||||
ProductCatalogItem,
|
||||
ProductCatalogListResult,
|
||||
TemplateProductBootstrapPreview,
|
||||
@@ -221,3 +227,59 @@ export function createAdminCostSnapshot(payload: Record<string, unknown>) {
|
||||
exceptionCreatedCount: number
|
||||
}>('/api/v1/admin/cost-center/snapshots', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约 SKU 配置:扫描 → 配置 → 重新匹配。
|
||||
* 配置主键是内部 SKU(快手 skuNick / 拼多多 outSkuSn),同名 SKU 算同一商品。
|
||||
*/
|
||||
export function fetchAdminManualSkus(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: 'configured' | 'unconfigured' | ''
|
||||
platformKey?: string
|
||||
provider?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<ManualSkuListResult>('/api/v1/admin/cost-center/manual-skus', params)
|
||||
}
|
||||
|
||||
export function fetchAdminManualSkuOverview() {
|
||||
return apiGet<ManualSkuOverview>('/api/v1/admin/cost-center/manual-skus/overview')
|
||||
}
|
||||
|
||||
export function fetchAdminManualPlatforms() {
|
||||
return apiGet<{ items: ManualPlatformOption[] }>('/api/v1/admin/cost-center/manual-platforms')
|
||||
}
|
||||
|
||||
export function fetchAdminManualConfig(skuKey: string) {
|
||||
return apiGet<ManualFulfillmentConfig | null>(
|
||||
`/api/v1/admin/cost-center/manual-configs/${encodeURIComponent(skuKey)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function scanAdminManualSkus(payload: { fromDate: string; toDate: string }) {
|
||||
return apiPost<ManualSkuScanResult>('/api/v1/admin/cost-center/manual-skus/scan', payload)
|
||||
}
|
||||
|
||||
export function saveAdminManualConfig(payload: {
|
||||
skuKey: string
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
canonicalProductId?: number | null
|
||||
enabled?: boolean
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<ManualFulfillmentConfig>('/api/v1/admin/cost-center/manual-configs', payload)
|
||||
}
|
||||
|
||||
export function deleteAdminManualConfig(skuKey: string) {
|
||||
return apiDelete<{ skuKey: string; deleted: number }>(
|
||||
`/api/v1/admin/cost-center/manual-configs/${encodeURIComponent(skuKey)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) {
|
||||
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
|
||||
}
|
||||
|
||||
@@ -311,3 +311,81 @@ export interface CostExceptionListResult {
|
||||
items: CostException[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约渠道:客服人工有四个钱包。
|
||||
* 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。
|
||||
*/
|
||||
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash'
|
||||
|
||||
/** 成本模式:fixed=按积分固定;variable=成本不固定,客服确认履约时手填。 */
|
||||
export type ManualCostMode = 'fixed' | 'variable'
|
||||
|
||||
export interface ManualPlatformOption {
|
||||
key: ManualPlatformKey
|
||||
label: string
|
||||
isCredit: boolean
|
||||
creditUnitPriceFen: number
|
||||
}
|
||||
|
||||
/** 人工 SKU 清单行:内部 SKU 是配置主键,名称仅供识别。 */
|
||||
export interface ManualSkuItem {
|
||||
id: number
|
||||
skuKey: string
|
||||
displayName: string
|
||||
shopCount: number
|
||||
orderCount: number
|
||||
itemCount: number
|
||||
revenueFen: number
|
||||
firstSeenDate: string | null
|
||||
lastSeenDate: string | null
|
||||
scanFromDate: string | null
|
||||
scanToDate: string | null
|
||||
configured: boolean
|
||||
config: ManualFulfillmentConfig | null
|
||||
}
|
||||
|
||||
export interface ManualFulfillmentConfig {
|
||||
id: number
|
||||
skuKey: string
|
||||
canonicalProductId: number | null
|
||||
platformKey: ManualPlatformKey
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
costMode: ManualCostMode
|
||||
/** 成本(分)= 消耗积分 × 平台换算率(分/积分)。 */
|
||||
costFen: number
|
||||
/** 平台换算率(分/积分):南航/乐玩 1,ROCK 100(1 积分 = 1 元)。 */
|
||||
creditUnitPriceFen: number
|
||||
enabled: boolean
|
||||
note?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface ManualSkuListResult {
|
||||
items: ManualSkuItem[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export interface ManualSkuOverview {
|
||||
totalSku: number
|
||||
configuredSku: number
|
||||
unconfiguredSku: number
|
||||
totalItems: number
|
||||
configuredItems: number
|
||||
unconfiguredItems: number
|
||||
lastScanAt: string | null
|
||||
}
|
||||
|
||||
export interface ManualSkuScanResult {
|
||||
scanned: number
|
||||
registered: number
|
||||
fromDate: string
|
||||
toDate: string
|
||||
}
|
||||
|
||||
export interface ManualRematchResult {
|
||||
updatedCount: number
|
||||
skuKey: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user