diff --git a/apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql b/apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql new file mode 100644 index 00000000..82e1243c --- /dev/null +++ b/apps/backend/src/db/migrations/110_manual_fulfillment_platform_credits.sql @@ -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 元)'; diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index cd61af59..3cc7afff 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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), { + 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 diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts new file mode 100644 index 00000000..7177c884 --- /dev/null +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -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 = { + nanhang: 1, + lewan: 1, + rock: 100, + fund_cash: 100, +} + +const PLATFORM_LABELS: Record = { + nanhang: '南航平台', + lewan: '乐玩后台', + rock: 'ROCK 后台', + fund_cash: '备用金现金', +} + +/** 读取平台档案里配置的换算率;档案不存在时用默认表。 */ +async function loadPlatformUnitPriceFen(): Promise> { + const result = await query<{ profile_key: string; config_json: Record }>( + `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`, + ) + const prices: Record = { ...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 = {}) { + 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( + ` + 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 = {}) { + 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) { + 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, +) { + 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(row: T | undefined, message: string): T { + if (!row) { + throw createHttpError(message, { + statusCode: 404, + errorCode: 'manual_config_not_found', + }) + } + return row +} diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index c58d2cda..1d6331f4 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -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: , }, + { + key: 'manual-skus', + label: '人工 SKU 配置', + children: , + }, { key: 'cost-items', label: '成本项目', diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx new file mode 100644 index 00000000..f3f64be1 --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -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 = { + 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() + + 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(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 = [ + { + title: '内部 SKU(配置主键)', + dataIndex: 'skuKey', + width: 280, + render: (_, row) => ( +
+ + {row.skuKey} + + + {row.displayName || '(无名称)'} + +
+ ), + }, + { 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 ? ( + + + {row.config.platformLabel || row.config.platformKey} + + + {row.config.creditQuantity}{' '} + {unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)} + + + ) : ( + 未配置 + ), + }, + { + title: '成本', + key: 'cost', + width: 110, + align: 'right', + render: (_, row) => + row.config ? ( + row.config.costMode === 'fixed' ? ( + ¥{(row.config.costFen / 100).toFixed(2)} + ) : ( + 不固定·客服填 + ) + ) : ( + '-' + ), + }, + { + title: '操作', + key: 'actions', + width: 130, + fixed: 'right', + render: (_, row) => ( + + + {row.configured ? ( + deleteMutation.mutate(row.skuKey)} + > + + + { + setKeyword(value.trim()) + setPage(1) + }} + /> + { + setPlatformKey(value || '') + setPage(1) + }} + options={platforms.map((item) => ({ value: item.key, label: item.label }))} + /> + + + + { + setPage(nextPage) + if (nextPageSize !== pageSize) setPageSize(nextPageSize) + }, + })} + /> + + { + void form.validateFields().then((values) => saveMutation.mutate(values)) + }} + onCancel={() => setModalOpen(false)} + destroyOnHidden + width={560} + > + + form={form} layout="vertical"> + +