feat(cost-center): 成本中心与商品映射开发草案 (含文档与订单页调整 WIP)

This commit is contained in:
yml2213
2026-10-20 10:01:52 +08:00
parent 7542d547bf
commit 111bc76357
32 changed files with 7088 additions and 156 deletions
@@ -0,0 +1,113 @@
-- 102_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。
--
-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、
-- 店铺日报和订单毛利都只认 canonical_product_id。
--
-- 约束:
-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名;
-- 2. 同一 provider + shop_id + platform_sku_code 只能有一条映射;
-- 3. shop_id 为空串表示该来源的全局映射,具体店铺映射优先;
-- 4. order_items.normalized_quantity 从 SKU 名称解析,解析失败先回填 quantity。
CREATE TABLE IF NOT EXISTS product_catalog (
id BIGSERIAL PRIMARY KEY,
canonical_sku TEXT NOT NULL,
canonical_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
unit_name TEXT NOT NULL DEFAULT '件',
status TEXT NOT NULL DEFAULT 'active',
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku
ON product_catalog (canonical_sku);
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category
ON product_catalog (status, category, canonical_name);
CREATE INDEX IF NOT EXISTS idx_product_catalog_name
ON product_catalog (canonical_name);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_status
CHECK (status IN ('active', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_sku
CHECK (length(trim(canonical_sku)) > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_name
CHECK (length(trim(canonical_name)) > 0);
END IF;
END $$;
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度';
COMMENT ON COLUMN product_catalog.canonical_sku IS '内部稳定编码;创建后不随平台改名';
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用';
CREATE TABLE IF NOT EXISTS channel_product_mappings (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
platform_sku_code TEXT NOT NULL,
platform_sku_name TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
confidence TEXT NOT NULL DEFAULT 'manual',
source TEXT NOT NULL DEFAULT 'manual',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_key
ON channel_product_mappings (provider, shop_id, platform_sku_code);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
ON channel_product_mappings (canonical_product_id, enabled);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup
ON channel_product_mappings (provider, shop_id, platform_sku_code, enabled);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_confidence
CHECK (confidence IN ('exact', 'rule', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_source
CHECK (source IN ('manual', 'import', 'rule'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_multiplier
CHECK (quantity_multiplier > 0);
END IF;
END $$;
COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射';
COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先';
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER;
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product
ON order_items (canonical_product_id);
COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定';
COMMENT ON COLUMN order_items.normalized_quantity IS '归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量';
-- 初始回填只保证不为空;后续由成本中心页面/脚本按 SKU 名称解析覆盖。
UPDATE order_items
SET normalized_quantity = GREATEST(COALESCE(quantity, 1), 1)
WHERE normalized_quantity IS NULL;
@@ -0,0 +1,244 @@
-- 103_cost_center.sql —— 成本项目、成本版本、成本快照与成本异常。
--
-- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存
-- cost_snapshot_id 或快照行,历史成本永不回写。
--
-- 口径(2026-10-17 已确认):
-- * 自动发货 = 上游采购成本;
-- * 接单平台 = 打手报酬,按总额法计履约成本;
-- * 人工客服 = 人工支出登记,按 5102 履约成本-人工客服;
-- * 匹配不到成本不得默认 0,必须进入 cost_exceptions。
CREATE TABLE IF NOT EXISTS cost_items (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
cost_type TEXT NOT NULL,
carrier_type TEXT NOT NULL,
pricing_mode TEXT NOT NULL,
unit_name TEXT NOT NULL DEFAULT '件',
currency TEXT NOT NULL DEFAULT 'CNY',
status TEXT NOT NULL DEFAULT 'active',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_items_code
ON cost_items (code);
CREATE INDEX IF NOT EXISTS idx_cost_items_status_carrier
ON cost_items (status, carrier_type, cost_type);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_cost_type') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_cost_type
CHECK (cost_type IN ('auto_purchase', 'work_order_reward', 'manual_service', 'platform_fee', 'payment_fee', 'after_sales_loss', 'shipping_other', 'other'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_carrier_type') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_carrier_type
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_pricing_mode') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_pricing_mode
CHECK (pricing_mode IN ('per_item', 'per_order', 'per_hour', 'percent_of_revenue', 'fixed_daily'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_items_status') THEN
ALTER TABLE cost_items
ADD CONSTRAINT chk_cost_items_status
CHECK (status IN ('active', 'archived'));
END IF;
END $$;
COMMENT ON TABLE cost_items IS '成本项定义:类型 + 载体 + 计价方式;金额一律放 cost_versions';
COMMENT ON COLUMN cost_items.carrier_type IS '适用执行载体;shared 表示与载体无关的通用成本';
COMMENT ON COLUMN cost_items.pricing_mode IS 'per_item/per_order/per_hour/percent_of_revenue/fixed_daily';
CREATE TABLE IF NOT EXISTS cost_versions (
id BIGSERIAL PRIMARY KEY,
cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT,
scope_type TEXT NOT NULL DEFAULT 'global',
provider TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
work_product_rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE RESTRICT,
amount_fen BIGINT NOT NULL DEFAULT 0,
rate_bps INTEGER NOT NULL DEFAULT 0,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
source TEXT NOT NULL DEFAULT 'manual',
status TEXT NOT NULL DEFAULT 'active',
change_reason TEXT NOT NULL DEFAULT '',
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
approved_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_versions_scope
ON cost_versions (
cost_item_id,
scope_type,
provider,
shop_id,
COALESCE(canonical_product_id, 0),
COALESCE(work_product_rule_id, 0),
effective_from
);
CREATE INDEX IF NOT EXISTS idx_cost_versions_lookup
ON cost_versions (cost_item_id, status, effective_from DESC);
CREATE INDEX IF NOT EXISTS idx_cost_versions_product
ON cost_versions (canonical_product_id, status, effective_from DESC);
CREATE INDEX IF NOT EXISTS idx_cost_versions_rule
ON cost_versions (work_product_rule_id, status, effective_from DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_amount') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_amount
CHECK (amount_fen >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_rate') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_rate
CHECK (rate_bps >= 0 AND rate_bps <= 10000);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_period') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_period
CHECK (effective_to IS NULL OR effective_to > effective_from);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_scope') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_scope
CHECK (scope_type IN ('global', 'provider', 'shop', 'product', 'rule'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_source') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_source
CHECK (source IN ('manual', 'upstream_sync', 'import'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_versions_status') THEN
ALTER TABLE cost_versions
ADD CONSTRAINT chk_cost_versions_status
CHECK (status IN ('draft', 'active', 'retired'));
END IF;
END $$;
COMMENT ON TABLE cost_versions IS '成本版本:改价只在这里新增版本;历史成本靠 cost_snapshots 固化';
COMMENT ON COLUMN cost_versions.amount_fen IS '固定金额(分);percent_of_revenue 时使用 rate_bps';
COMMENT ON COLUMN cost_versions.rate_bps IS '比例成本,单位万分比;10000=100%';
COMMENT ON COLUMN cost_versions.scope_type IS '匹配优先级:rule > product > shop > provider > global';
CREATE TABLE IF NOT EXISTS cost_snapshots (
id BIGSERIAL PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id BIGINT NOT NULL,
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
manual_case_id BIGINT,
carrier_type TEXT NOT NULL,
cost_item_id BIGINT NOT NULL REFERENCES cost_items(id) ON DELETE RESTRICT,
cost_version_id BIGINT NOT NULL REFERENCES cost_versions(id) ON DELETE RESTRICT,
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
quantity NUMERIC(14, 3) NOT NULL DEFAULT 1,
unit_amount_fen BIGINT NOT NULL DEFAULT 0,
total_amount_fen BIGINT NOT NULL DEFAULT 0,
snapshot_stage TEXT NOT NULL DEFAULT 'estimated',
source TEXT NOT NULL DEFAULT 'resolved',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
resolved_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_subject_item
ON cost_snapshots (subject_type, subject_id, cost_item_id);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_item
ON cost_snapshots (order_item_id);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_work_order
ON cost_snapshots (work_order_id);
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_product
ON cost_snapshots (canonical_product_id, resolved_at DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_subject_type') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_subject_type
CHECK (subject_type IN ('order_item', 'work_order', 'manual_case'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_carrier_type') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_carrier_type
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_stage') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_stage
CHECK (snapshot_stage IN ('estimated', 'actual', 'adjustment'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_source') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_source
CHECK (source IN ('resolved', 'legacy_rule', 'manual_input', 'missing'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_amount') THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_amount
CHECK (quantity >= 0 AND unit_amount_fen >= 0 AND total_amount_fen >= 0);
END IF;
END $$;
COMMENT ON TABLE cost_snapshots IS '成本快照:订单项/工单/人工单实际使用的成本版本与金额';
COMMENT ON COLUMN cost_snapshots.source IS 'resolved=成本中心命中;legacy_rule=旧规则回退;manual_input=人工;missing=未匹配';
CREATE TABLE IF NOT EXISTS cost_exceptions (
id BIGSERIAL PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id BIGINT NOT NULL,
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
carrier_type TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolved_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_exceptions_subject_reason
ON cost_exceptions (subject_type, subject_id, reason);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_status_created
ON cost_exceptions (status, created_at DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_exceptions_subject_type') THEN
ALTER TABLE cost_exceptions
ADD CONSTRAINT chk_cost_exceptions_subject_type
CHECK (subject_type IN ('order_item', 'work_order', 'manual_case'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_exceptions_status') THEN
ALTER TABLE cost_exceptions
ADD CONSTRAINT chk_cost_exceptions_status
CHECK (status IN ('open', 'resolved', 'ignored'));
END IF;
END $$;
COMMENT ON TABLE cost_exceptions IS '成本缺失/冲突异常队列;open 数量必须在后台暴露';
ALTER TABLE work_product_rules
ADD COLUMN IF NOT EXISTS cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS legacy_cost_source TEXT NOT NULL DEFAULT 'rule_amount';
CREATE INDEX IF NOT EXISTS idx_work_product_rules_cost_item
ON work_product_rules (cost_item_id);
COMMENT ON COLUMN work_product_rules.cost_item_id IS '过渡列:非空则金额优先走成本中心;空则回退 reward_amount/unit_price_fen';
COMMENT ON COLUMN work_product_rules.legacy_cost_source IS '旧成本回退标记:rule_amount=旧规则金额';
@@ -0,0 +1,19 @@
-- 104_cost_center_bootstrap_fix.sql
--
-- 背景:
-- 102_product_catalog 已执行,里面把 normalized_quantity 直接用 quantity 填充。
-- quantity 长期几乎恒为 1,会让「5个」类 SKU 的归一数量永远无法再通过解析器修正。
--
-- 处理:
-- 1. 清空初始化时写入的归一数量,由成本中心「回填数量」用 resolveSkuNameQuantity 重新解析;
-- 2. 给财务角色补 finance.config,允许维护商品映射与成本版本(管理员仍绕过权限检查)。
--
-- 该列是本期新列,清空不会影响既有订单字段;回填接口只处理 normalized_quantity IS NULL。
UPDATE order_items
SET normalized_quantity = NULL
WHERE normalized_quantity IS NOT NULL;
INSERT INTO admin_role_permissions (role_key, permission_key)
VALUES ('finance', 'finance.config')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,72 @@
-- 105_product_mapping_match_keys.sql —— 渠道商品映射支持 SKU 名称/系列消歧。
--
-- 背景:91 的 sku_code 会在同一店铺内被多个商品复用,单靠
-- provider + shop_id + platform_sku_code 无法唯一确定标准商品。
--
-- 规则:
-- 1. sku_code:编码唯一,直接命中;
-- 2. sku_code_name:编码冲突,但编码 + 精确 SKU 名称可唯一命中;
-- 3. sku_code_series:编码冲突,但编码 + SKU 名称系列可唯一命中;
-- 4. sku_name_series:编码无法提供时,用来源 + 店铺 + SKU 名称系列兜底;
-- 5. 任何一层出现多个候选都不自动选择,交由冲突清单人工处理。
ALTER TABLE channel_product_mappings
ADD COLUMN IF NOT EXISTS match_type TEXT NOT NULL DEFAULT 'sku_code',
ADD COLUMN IF NOT EXISTS platform_sku_name_key TEXT NOT NULL DEFAULT '';
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT,
ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type'
) THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_type
CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key'
) THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_key
CHECK (platform_sku_code <> '' OR platform_sku_name_key <> '');
END IF;
END $$;
DROP INDEX IF EXISTS uq_channel_product_mappings_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key
);
DROP INDEX IF EXISTS idx_channel_product_mappings_lookup;
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_match_lookup
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key,
enabled
);
CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup
ON order_items (normalized_sku_name, normalized_sku_series);
COMMENT ON COLUMN channel_product_mappings.match_type IS
'映射匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series';
COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS
'归一后的 SKU 名称或数量系列键;sku_code 模式为空串';
COMMENT ON COLUMN order_items.normalized_sku_name IS
'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧';
COMMENT ON COLUMN order_items.normalized_sku_series IS
'剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配';
+2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import authRouter from './admin/auth.js'
import auditLogsRouter from './admin/audit-logs.js'
import costCenterRouter from './admin/cost-center.js'
import dashboardRouter from './admin/dashboard.js'
import devMockRouter from './admin/dev-mock.js'
import douyinMashidaRouter from './admin/douyin-mashida.js'
@@ -27,6 +28,7 @@ router.use(requireAdminSession)
router.use(filesRouter)
router.use(dashboardRouter)
router.use(financeRouter)
router.use(costCenterRouter)
router.use(fundRouter)
router.use(usersRouter)
router.use(rbacRouter)
@@ -0,0 +1,396 @@
import type { Request } from 'express'
import { Router } from 'express'
import {
applyAdminTemplateProductBootstrap,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelMapping,
createAdminCostItem,
createAdminCostSnapshot,
createAdminCostVersion,
createAdminProduct,
getAdminCostCenterOptions,
getAdminCostCenterOverview,
listAdminChannelMappings,
listAdminCostExceptions,
listAdminCostItems,
listAdminCostVersions,
listAdminProducts,
previewAdminCost,
previewAdminTemplateProductBootstrap,
resolveAdminCostException,
retireAdminCostVersion,
updateAdminChannelMapping,
updateAdminCostItem,
updateAdminProduct,
} from '../../services/admin/admin-cost-center-service.js'
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
import type { JsonObject } from '../../types/json.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
const router = Router()
function sessionOf(req: Request): AdminSession {
return req.adminSession as AdminSession
}
function idOf(req: Request, key: string): number {
return Number(req.params[key])
}
function bodyOf(req: Request): JsonObject {
return req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {}
}
router.use('/cost-center', requireAdminRoles(['admin', 'operator', 'finance']))
router.get(
'/cost-center/overview',
requireAdminPermission('finance.view'),
createJsonHandler(() => getAdminCostCenterOverview(), {
successMessage: 'ok',
errorMessage: '读取成本中心总览失败',
scope: '[admin/cost-center/overview]',
}),
)
router.get(
'/cost-center/options',
requireAdminPermission('finance.view'),
createJsonHandler(() => getAdminCostCenterOptions(), {
successMessage: 'ok',
errorMessage: '读取成本中心选项失败',
scope: '[admin/cost-center/options]',
}),
)
router.get(
'/cost-center/template-bootstrap/preview',
requireAdminPermission('finance.view'),
createJsonHandler(() => previewAdminTemplateProductBootstrap(), {
successMessage: 'ok',
errorMessage: '读取接单模板初始化预览失败',
scope: '[admin/cost-center/template-bootstrap/preview]',
}),
)
router.post(
'/cost-center/template-bootstrap/apply',
requireAdminPermission('finance.config'),
createJsonHandler((req) => applyAdminTemplateProductBootstrap(bodyOf(req), sessionOf(req)), {
successMessage: '接单模板初始化完成',
errorMessage: '接单模板初始化失败',
scope: '[admin/cost-center/template-bootstrap/apply]',
audit: (_req, data) => ({
action: 'cost_template_bootstrap_applied',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.get(
'/cost-center/products',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listAdminProducts(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取标准商品失败',
scope: '[admin/cost-center/products]',
}),
)
router.post(
'/cost-center/products',
requireAdminPermission('finance.config'),
createJsonHandler((req) => createAdminProduct(bodyOf(req), sessionOf(req)), {
successMessage: '标准商品已创建',
errorMessage: '创建标准商品失败',
scope: '[admin/cost-center/products]',
audit: (_req, data) => {
const product = (data as { product?: { id?: number; canonicalSku?: string } })?.product
return {
action: 'cost_product_created',
targetType: 'product_catalog',
targetId: String(product?.id || ''),
data: { canonicalSku: product?.canonicalSku || '' },
}
},
}),
)
router.patch(
'/cost-center/products/:productId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => updateAdminProduct(idOf(req, 'productId'), bodyOf(req)), {
successMessage: '标准商品已更新',
errorMessage: '更新标准商品失败',
scope: '[admin/cost-center/products/:productId]',
audit: (req, data) => {
const product = (data as { product?: { id?: number; canonicalSku?: string } })?.product
return {
action: 'cost_product_updated',
targetType: 'product_catalog',
targetId: String(product?.id || req.params.productId || ''),
data: { canonicalSku: product?.canonicalSku || '' },
}
},
}),
)
router.get(
'/cost-center/mappings',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listAdminChannelMappings(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取渠道映射失败',
scope: '[admin/cost-center/mappings]',
}),
)
router.post(
'/cost-center/mappings',
requireAdminPermission('finance.config'),
createJsonHandler((req) => createAdminChannelMapping(bodyOf(req), sessionOf(req)), {
successMessage: '渠道映射已创建',
errorMessage: '创建渠道映射失败',
scope: '[admin/cost-center/mappings]',
audit: (_req, data) => {
const mapping = (
data as { mapping?: { id?: number; provider?: string; platformSkuCode?: string } }
)?.mapping
return {
action: 'cost_mapping_created',
targetType: 'channel_product_mapping',
targetId: String(mapping?.id || ''),
data: {
provider: mapping?.provider || '',
platformSkuCode: mapping?.platformSkuCode || '',
},
}
},
}),
)
router.patch(
'/cost-center/mappings/:mappingId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => updateAdminChannelMapping(idOf(req, 'mappingId'), bodyOf(req)), {
successMessage: '渠道映射已更新',
errorMessage: '更新渠道映射失败',
scope: '[admin/cost-center/mappings/:mappingId]',
audit: (req, data) => {
const mapping = (
data as { mapping?: { id?: number; provider?: string; platformSkuCode?: string } }
)?.mapping
return {
action: 'cost_mapping_updated',
targetType: 'channel_product_mapping',
targetId: String(mapping?.id || req.params.mappingId || ''),
data: {
provider: mapping?.provider || '',
platformSkuCode: mapping?.platformSkuCode || '',
},
}
},
}),
)
router.post(
'/cost-center/mappings/backfill',
requireAdminPermission('finance.config'),
createJsonHandler((req) => backfillAdminProductMappings(bodyOf(req)), {
successMessage: '商品映射回填完成',
errorMessage: '回填商品映射失败',
scope: '[admin/cost-center/mappings/backfill]',
audit: (_req, data) => ({
action: 'cost_mapping_backfilled',
targetType: 'order_item',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.post(
'/cost-center/normalized-quantities/backfill',
requireAdminPermission('finance.config'),
createJsonHandler((req) => backfillAdminNormalizedQuantities(bodyOf(req)), {
successMessage: '归一数量回填完成',
errorMessage: '回填归一数量失败',
scope: '[admin/cost-center/normalized-quantities/backfill]',
audit: (_req, data) => ({
action: 'order_item_quantity_backfilled',
targetType: 'order_item',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.get(
'/cost-center/cost-items',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listAdminCostItems(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取成本项目失败',
scope: '[admin/cost-center/cost-items]',
}),
)
router.post(
'/cost-center/cost-items',
requireAdminPermission('finance.config'),
createJsonHandler((req) => createAdminCostItem(bodyOf(req), sessionOf(req)), {
successMessage: '成本项目已创建',
errorMessage: '创建成本项目失败',
scope: '[admin/cost-center/cost-items]',
audit: (_req, data) => {
const item = (data as { costItem?: { id?: number; code?: string } })?.costItem
return {
action: 'cost_item_created',
targetType: 'cost_item',
targetId: String(item?.id || ''),
data: { code: item?.code || '' },
}
},
}),
)
router.patch(
'/cost-center/cost-items/:costItemId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => updateAdminCostItem(idOf(req, 'costItemId'), bodyOf(req)), {
successMessage: '成本项目已更新',
errorMessage: '更新成本项目失败',
scope: '[admin/cost-center/cost-items/:costItemId]',
audit: (req, data) => {
const item = (data as { costItem?: { id?: number; code?: string } })?.costItem
return {
action: 'cost_item_updated',
targetType: 'cost_item',
targetId: String(item?.id || req.params.costItemId || ''),
data: { code: item?.code || '' },
}
},
}),
)
router.get(
'/cost-center/cost-versions',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listAdminCostVersions(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取成本版本失败',
scope: '[admin/cost-center/cost-versions]',
}),
)
router.post(
'/cost-center/cost-versions',
requireAdminPermission('finance.config'),
createJsonHandler((req) => createAdminCostVersion(bodyOf(req), sessionOf(req)), {
successMessage: '成本版本已创建',
errorMessage: '创建成本版本失败',
scope: '[admin/cost-center/cost-versions]',
audit: (_req, data) => {
const version = (data as { version?: { id?: number; costItemId?: number } })?.version
return {
action: 'cost_version_created',
targetType: 'cost_version',
targetId: String(version?.id || ''),
data: { costItemId: version?.costItemId || 0 },
}
},
}),
)
router.patch(
'/cost-center/cost-versions/:versionId/retire',
requireAdminPermission('finance.config'),
createJsonHandler((req) => retireAdminCostVersion(idOf(req, 'versionId'), bodyOf(req)), {
successMessage: '成本版本已停用',
errorMessage: '停用成本版本失败',
scope: '[admin/cost-center/cost-versions/:versionId/retire]',
audit: (req, data) => {
const version = (data as { version?: { id?: number } })?.version
return {
action: 'cost_version_retired',
targetType: 'cost_version',
targetId: String(version?.id || req.params.versionId || ''),
data: {},
}
},
}),
)
router.get(
'/cost-center/exceptions',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listAdminCostExceptions(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取成本异常失败',
scope: '[admin/cost-center/exceptions]',
}),
)
router.patch(
'/cost-center/exceptions/:exceptionId',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => resolveAdminCostException(idOf(req, 'exceptionId'), bodyOf(req), sessionOf(req)),
{
successMessage: '成本异常已处理',
errorMessage: '处理成本异常失败',
scope: '[admin/cost-center/exceptions/:exceptionId]',
audit: (req, data) => {
const exception = (data as { exception?: { id?: number; status?: string } })?.exception
return {
action: 'cost_exception_resolved',
targetType: 'cost_exception',
targetId: String(exception?.id || req.params.exceptionId || ''),
data: { status: exception?.status || '' },
}
},
},
),
)
router.post(
'/cost-center/preview',
requireAdminPermission('finance.view'),
createJsonHandler((req) => previewAdminCost(bodyOf(req)), {
successMessage: 'ok',
errorMessage: '成本预览失败',
scope: '[admin/cost-center/preview]',
}),
)
router.post(
'/cost-center/snapshots',
requireAdminPermission('finance.config'),
createJsonHandler((req) => createAdminCostSnapshot(bodyOf(req), sessionOf(req)), {
successMessage: '成本快照已生成',
errorMessage: '生成成本快照失败',
scope: '[admin/cost-center/snapshots]',
audit: (req, data) => {
const result = data as {
createdCount?: number
exceptionCreatedCount?: number
preview?: { context?: { orderItemId?: number } }
}
return {
action: 'cost_snapshot_created',
targetType: 'order_item',
targetId: String(result.preview?.context?.orderItemId || req.body?.orderItemId || ''),
data: {
createdCount: result.createdCount || 0,
exceptionCreatedCount: result.exceptionCreatedCount || 0,
},
}
},
}),
)
export default router
@@ -0,0 +1,273 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildTemplateMappingPlan,
buildTemplateProductSku,
calculateCostTotal,
inferTemplateUnitName,
normalizeMappingMatchType,
normalizeProviderKey,
resolveMappingNameKey,
resolveMappingProductId,
validateCostVersionScope,
} from './admin-cost-center-service.js'
test('calculateCostTotal supports per_item, per_order and percent_of_revenue', () => {
assert.equal(
calculateCostTotal({
pricingMode: 'per_item',
amountFen: 250,
rateBps: 0,
quantity: 5,
revenueFen: 10000,
}),
1250,
)
assert.equal(
calculateCostTotal({
pricingMode: 'per_order',
amountFen: 500,
rateBps: 0,
quantity: 5,
revenueFen: 10000,
}),
500,
)
assert.equal(
calculateCostTotal({
pricingMode: 'percent_of_revenue',
amountFen: 0,
rateBps: 500,
quantity: 1,
revenueFen: 10000,
}),
500,
)
})
test('calculateCostTotal clamps invalid rate and negative amounts', () => {
assert.equal(
calculateCostTotal({
pricingMode: 'percent_of_revenue',
amountFen: -1,
rateBps: 20000,
quantity: 1,
revenueFen: 10000,
}),
10000,
)
assert.equal(
calculateCostTotal({
pricingMode: 'per_item',
amountFen: -100,
rateBps: 0,
quantity: 3,
revenueFen: 0,
}),
0,
)
})
test('validateCostVersionScope requires the fields that identify the scope', () => {
assert.doesNotThrow(() =>
validateCostVersionScope('global', {
provider: '',
shopId: '',
canonicalProductId: null,
workProductRuleId: null,
}),
)
assert.throws(() =>
validateCostVersionScope('shop', {
provider: 'kuaishou-91',
shopId: '',
canonicalProductId: null,
workProductRuleId: null,
}),
)
assert.throws(() =>
validateCostVersionScope('product', {
provider: '',
shopId: '',
canonicalProductId: null,
workProductRuleId: null,
}),
)
assert.throws(() =>
validateCostVersionScope('rule', {
provider: '',
shopId: '',
canonicalProductId: null,
workProductRuleId: null,
}),
)
})
test('normalizeProviderKey keeps mappings aligned with orders.provider', () => {
assert.equal(normalizeProviderKey('91kaquan'), '91kaquan')
assert.equal(normalizeProviderKey('91'), '91kaquan')
assert.equal(normalizeProviderKey('open_91'), '91kaquan')
assert.equal(normalizeProviderKey('kuaishou-91'), '91kaquan')
assert.equal(normalizeProviderKey('douyin-mashida'), 'douyin_mashida')
assert.equal(normalizeProviderKey('pdd'), 'pdd')
})
test('template bootstrap keeps rule keys stable and infers reasonable units', () => {
assert.equal(buildTemplateProductSku('bb'), 'tpl:bb')
assert.equal(buildTemplateProductSku('rule-123'), 'tpl:rule-123')
assert.equal(inferTemplateUnitName('指挥官密钥5个'), '个')
assert.equal(inferTemplateUnitName('苏尔南钻石100颗'), '颗')
assert.equal(inferTemplateUnitName('10把指定红枪'), '把')
assert.equal(inferTemplateUnitName('10套橙色七级套'), '套')
assert.equal(inferTemplateUnitName('碎梦浮光珍宝礼盒'), '件')
})
test('template mapping plan uses exact SKU code when it is unambiguous', () => {
const plan = buildTemplateMappingPlan([
{
provider: 'pdd',
shop_id: 'feimeng',
sku_code: 'A-1',
sku_name: '指挥官密钥1个',
product_rule_id: 9,
rule_key: 'bb',
item_count: 8,
},
{
provider: 'pdd',
shop_id: 'feimeng',
sku_code: 'A-2',
sku_name: '指挥官密钥5个',
product_rule_id: 9,
rule_key: 'bb',
item_count: 3,
},
])
assert.equal(plan.conflicts.length, 0)
assert.equal(plan.codeConflictKeyCount, 0)
assert.equal(plan.candidates.filter((row) => row.matchType === 'sku_code').length, 2)
assert.equal(plan.evidenceItemCount, 11)
assert.equal(plan.unresolvedItemCount, 0)
})
test('template mapping plan disambiguates reused 91 SKU codes by name and series', () => {
const plan = buildTemplateMappingPlan([
{
provider: '91kaquan',
shop_id: '141242642',
sku_code: '25873410102642',
sku_name: '指挥官密钥1个',
product_rule_id: 9,
rule_key: 'bb',
item_count: 10,
},
{
provider: '91kaquan',
shop_id: '141242642',
sku_code: '25873410102642',
sku_name: '指挥官密钥5个',
product_rule_id: 9,
rule_key: 'bb',
item_count: 5,
},
{
provider: '91kaquan',
shop_id: '141242642',
sku_code: '25873410102642',
sku_name: '1个精致荣耀专属礼盒',
product_rule_id: 16,
rule_key: 'dd',
item_count: 8,
},
])
assert.equal(plan.codeConflictKeyCount, 1)
assert.equal(plan.conflicts.length, 0)
assert.equal(plan.unresolvedItemCount, 0)
assert.ok(
plan.candidates.some(
(row) =>
row.matchType === 'sku_code_series' &&
row.skuNameKey === '指挥官密钥' &&
row.ruleKey === 'bb',
),
)
assert.ok(
plan.candidates.some(
(row) =>
row.matchType === 'sku_code_series' &&
row.skuNameKey === '精致荣耀专属礼盒' &&
row.ruleKey === 'dd',
),
)
assert.ok(
plan.candidates.some(
(row) =>
row.matchType === 'sku_code_name' &&
row.skuNameKey === '指挥官密钥5个' &&
row.ruleKey === 'bb',
),
)
})
test('template mapping plan reports same code and name mapping to multiple rules', () => {
const plan = buildTemplateMappingPlan([
{
provider: '91kaquan',
shop_id: '3676797936',
sku_code: '185470033055936',
sku_name: '指挥官密钥1个',
product_rule_id: 9,
rule_key: 'bb',
item_count: 20,
},
{
provider: '91kaquan',
shop_id: '3676797936',
sku_code: '185470033055936',
sku_name: '指挥官密钥1个',
product_rule_id: 20,
rule_key: 'ab',
item_count: 5,
},
])
assert.equal(plan.codeConflictKeyCount, 1)
assert.equal(plan.unresolvedItemCount, 25)
assert.equal(plan.conflicts.length, 1)
assert.deepEqual(plan.conflicts[0]?.ruleKeys, ['ab', 'bb'])
assert.equal(
plan.candidates.some((row) => row.matchType === 'sku_code_name'),
false,
)
})
test('mapping match keys normalize name and quantity series', () => {
assert.equal(normalizeMappingMatchType('sku_code_series'), 'sku_code_series')
assert.equal(resolveMappingNameKey('sku_code_name', ' 指挥官密钥5个 '), '指挥官密钥5个')
assert.equal(resolveMappingNameKey('sku_code_series', '指挥官秘钥5个'), '指挥官密钥')
assert.equal(resolveMappingNameKey('sku_name_series', '指挥官密钥10个'), '指挥官密钥')
})
test('mapping lookup follows specificity and concrete-shop priority', () => {
const key = (...parts: string[]) => parts.join('\u0000')
const byKey = new Map<string, number>([
[key('91kaquan', 'shop-a', 'C-1', 'sku_code', ''), 10],
[key('91kaquan', '', 'C-1', 'sku_code', ''), 20],
[key('91kaquan', '', 'C-1', 'sku_code_name', '指挥官密钥1个'), 30],
[key('91kaquan', '', 'C-1', 'sku_code_series', '指挥官密钥'), 40],
[key('91kaquan', '', '', 'sku_name_series', '指挥官密钥'), 50],
])
const item = {
id: 1,
provider: '91kaquan',
shop_id: 'shop-a',
sku_code: 'C-1',
normalized_sku_name: '指挥官密钥1个',
normalized_sku_series: '指挥官密钥',
}
assert.equal(resolveMappingProductId({ byKey }, item), 10)
assert.equal(
resolveMappingProductId({ byKey }, { ...item, shop_id: 'shop-b', sku_code: 'C-2' }),
50,
)
})
File diff suppressed because it is too large Load Diff