feat(cost-center): 成本中心与商品映射开发草案 (含文档与订单页调整 WIP)
This commit is contained in:
@@ -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,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
Reference in New Issue
Block a user