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
@@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout'
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
||||
@@ -145,6 +146,7 @@ function AdminApplication() {
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="cost-center" element={<AdminCostCenterPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="fund" element={<AdminFundPage />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AuditOutlined,
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
BugOutlined,
|
||||
DashboardOutlined,
|
||||
@@ -131,6 +132,7 @@ export default function AdminLayout() {
|
||||
role === 'finance'
|
||||
? [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
||||
]
|
||||
@@ -144,7 +146,14 @@ export default function AdminLayout() {
|
||||
},
|
||||
...(role === 'support'
|
||||
? []
|
||||
: [{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' }]),
|
||||
: [
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||
{
|
||||
key: '/admin/cost-center',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '商品与成本',
|
||||
},
|
||||
]),
|
||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
||||
{
|
||||
@@ -747,6 +756,7 @@ function playNotificationSound(kind: NotificationAlertKind) {
|
||||
function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
|
||||
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
|
||||
if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center'
|
||||
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
|
||||
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
|
||||
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,15 +55,6 @@ type OrderFilterForm = {
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
/** 默认展示近 7 天(含今天)。 */
|
||||
function resolveDefaultOrderDateRange() {
|
||||
const today = dayjs()
|
||||
return {
|
||||
dateFrom: today.subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
dateTo: today.format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
@@ -72,20 +63,6 @@ export default function AdminOrdersPage() {
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
|
||||
|
||||
// 首次进入(仅有分页参数)时写入默认近 7 天,避免一把拉出全库。
|
||||
useEffect(() => {
|
||||
const meaningfulKeys = [...searchParams.keys()].filter(
|
||||
(key) => key !== 'page' && key !== 'pageSize',
|
||||
)
|
||||
if (meaningfulKeys.length > 0) {
|
||||
return
|
||||
}
|
||||
const { dateFrom, dateTo } = resolveDefaultOrderDateRange()
|
||||
setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo }), { replace: true })
|
||||
// 仅首屏引导默认日期,避免与后续筛选互相打架。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const filters = {
|
||||
keyword: searchParams.get('keyword') || '',
|
||||
provider: searchParams.get('provider') || '',
|
||||
@@ -111,17 +88,9 @@ export default function AdminOrdersPage() {
|
||||
],
|
||||
)
|
||||
|
||||
// 等待默认日期写入 URL 后再请求,避免首屏无日期打出全库。
|
||||
const hasBootstrappedFilters =
|
||||
Boolean(filters.dateFrom && filters.dateTo) ||
|
||||
Boolean(filters.keyword) ||
|
||||
searchParams.get('allTime') === '1' ||
|
||||
Boolean(filters.provider || filters.executorKey || filters.payStatus || filters.taskStatus)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-orders', queryParams],
|
||||
queryFn: () => fetchAdminOrders(queryParams),
|
||||
enabled: hasBootstrappedFilters,
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
@@ -410,7 +379,6 @@ export default function AdminOrdersPage() {
|
||||
// 有关键词时不强制日期(后端也会忽略日期);清空日期且无关键词则查全部时间。
|
||||
const dateFrom = rawFrom || ''
|
||||
const dateTo = rawTo || ''
|
||||
const allTime = !dateFrom && !dateTo && !keyword ? '1' : ''
|
||||
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
@@ -418,7 +386,6 @@ export default function AdminOrdersPage() {
|
||||
keyword,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
allTime,
|
||||
page: 1,
|
||||
pageSize,
|
||||
}),
|
||||
@@ -426,16 +393,15 @@ export default function AdminOrdersPage() {
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
const { dateFrom, dateTo } = resolveDefaultOrderDateRange()
|
||||
form.setFieldsValue({
|
||||
keyword: undefined,
|
||||
provider: undefined,
|
||||
executorKey: undefined,
|
||||
payStatus: undefined,
|
||||
taskStatus: undefined,
|
||||
dateRange: [dayjs(dateFrom), dayjs(dateTo)],
|
||||
dateRange: undefined,
|
||||
})
|
||||
setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo }))
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
const currentSegmentedProvider = filters.provider || ''
|
||||
@@ -537,7 +503,7 @@ export default function AdminOrdersPage() {
|
||||
<Card>
|
||||
<Table<AdminOrderListItem>
|
||||
rowKey="orderId"
|
||||
loading={query.isLoading || query.isFetching || !hasBootstrappedFilters}
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
scroll={{ x: 960 }}
|
||||
@@ -549,7 +515,6 @@ export default function AdminOrdersPage() {
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
...filters,
|
||||
allTime: searchParams.get('allTime') || '',
|
||||
page: nextPage,
|
||||
pageSize: nextPageSize,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { apiGet, apiPatch, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
ChannelProductMapping,
|
||||
ChannelProductMappingListResult,
|
||||
CostCenterOptions,
|
||||
CostCenterOverview,
|
||||
CostException,
|
||||
CostExceptionListResult,
|
||||
CostItem,
|
||||
CostItemListResult,
|
||||
CostPreview,
|
||||
CostVersion,
|
||||
CostVersionListResult,
|
||||
ProductCatalogItem,
|
||||
ProductCatalogListResult,
|
||||
TemplateProductBootstrapPreview,
|
||||
TemplateProductBootstrapResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminCostCenterOverview() {
|
||||
return apiGet<CostCenterOverview>('/api/v1/admin/cost-center/overview')
|
||||
}
|
||||
|
||||
export function fetchAdminCostCenterOptions() {
|
||||
return apiGet<CostCenterOptions>('/api/v1/admin/cost-center/options')
|
||||
}
|
||||
|
||||
export function previewAdminTemplateProductBootstrap() {
|
||||
return apiGet<TemplateProductBootstrapPreview>(
|
||||
'/api/v1/admin/cost-center/template-bootstrap/preview',
|
||||
)
|
||||
}
|
||||
|
||||
export function applyAdminTemplateProductBootstrap() {
|
||||
return apiPost<TemplateProductBootstrapResult>(
|
||||
'/api/v1/admin/cost-center/template-bootstrap/apply',
|
||||
{ repair: true },
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminProductCatalog(params?: Record<string, unknown>) {
|
||||
return apiGet<ProductCatalogListResult>('/api/v1/admin/cost-center/products', params)
|
||||
}
|
||||
|
||||
export function createAdminProductCatalog(payload: Record<string, unknown>) {
|
||||
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
|
||||
}
|
||||
|
||||
export function updateAdminProductCatalog(productId: number, payload: Record<string, unknown>) {
|
||||
return apiPatch<{ product: ProductCatalogItem }>(
|
||||
`/api/v1/admin/cost-center/products/${productId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminChannelProductMappings(params?: Record<string, unknown>) {
|
||||
return apiGet<ChannelProductMappingListResult>('/api/v1/admin/cost-center/mappings', params)
|
||||
}
|
||||
|
||||
export function createAdminChannelProductMapping(payload: Record<string, unknown>) {
|
||||
return apiPost<{ mapping: ChannelProductMapping }>('/api/v1/admin/cost-center/mappings', payload)
|
||||
}
|
||||
|
||||
export function updateAdminChannelProductMapping(
|
||||
mappingId: number,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
return apiPatch<{ mapping: ChannelProductMapping }>(
|
||||
`/api/v1/admin/cost-center/mappings/${mappingId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function backfillAdminProductMappings(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
updatedCount: number
|
||||
limit: number
|
||||
batches: number
|
||||
remainingCount: number
|
||||
}>('/api/v1/admin/cost-center/mappings/backfill', payload)
|
||||
}
|
||||
|
||||
export function backfillAdminNormalizedQuantities(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
updatedCount: number
|
||||
limit: number
|
||||
batches: number
|
||||
remainingCount: number
|
||||
}>('/api/v1/admin/cost-center/normalized-quantities/backfill', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminCostItems(params?: Record<string, unknown>) {
|
||||
return apiGet<CostItemListResult>('/api/v1/admin/cost-center/cost-items', params)
|
||||
}
|
||||
|
||||
export function createAdminCostItem(payload: Record<string, unknown>) {
|
||||
return apiPost<{ costItem: CostItem }>('/api/v1/admin/cost-center/cost-items', payload)
|
||||
}
|
||||
|
||||
export function updateAdminCostItem(costItemId: number, payload: Record<string, unknown>) {
|
||||
return apiPatch<{ costItem: CostItem }>(
|
||||
`/api/v1/admin/cost-center/cost-items/${costItemId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCostVersions(params?: Record<string, unknown>) {
|
||||
return apiGet<CostVersionListResult>('/api/v1/admin/cost-center/cost-versions', params)
|
||||
}
|
||||
|
||||
export function createAdminCostVersion(payload: Record<string, unknown>) {
|
||||
return apiPost<{ version: CostVersion }>('/api/v1/admin/cost-center/cost-versions', payload)
|
||||
}
|
||||
|
||||
export function retireAdminCostVersion(versionId: number, payload: Record<string, unknown>) {
|
||||
return apiPatch<{ version: CostVersion }>(
|
||||
`/api/v1/admin/cost-center/cost-versions/${versionId}/retire`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminCostExceptions(params?: Record<string, unknown>) {
|
||||
return apiGet<CostExceptionListResult>('/api/v1/admin/cost-center/exceptions', params)
|
||||
}
|
||||
|
||||
export function resolveAdminCostException(exceptionId: number, payload: Record<string, unknown>) {
|
||||
return apiPatch<{ exception: CostException }>(
|
||||
`/api/v1/admin/cost-center/exceptions/${exceptionId}`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function previewAdminCost(payload: Record<string, unknown>) {
|
||||
return apiPost<CostPreview>('/api/v1/admin/cost-center/preview', payload)
|
||||
}
|
||||
|
||||
export function createAdminCostSnapshot(payload: Record<string, unknown>) {
|
||||
return apiPost<{
|
||||
preview: CostPreview
|
||||
snapshots: Array<Record<string, unknown>>
|
||||
createdCount: number
|
||||
exceptionCreatedCount: number
|
||||
}>('/api/v1/admin/cost-center/snapshots', payload)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './cost-center'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
export * from './audit-logs'
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
export interface CostCenterOverview {
|
||||
productCount: number
|
||||
mappingCount: number
|
||||
costItemCount: number
|
||||
activeVersionCount: number
|
||||
openExceptionCount: number
|
||||
unmappedOrderItemCount: number
|
||||
}
|
||||
|
||||
export interface ProductCatalogItem {
|
||||
id: number
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
category: string
|
||||
rootCategoryId: number | null
|
||||
rootCategoryName: string
|
||||
subCategoryId: number | null
|
||||
subCategoryName: string
|
||||
unitName: string
|
||||
status: 'active' | 'archived'
|
||||
attributes: Record<string, unknown>
|
||||
mappingCount: number
|
||||
costVersionCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChannelProductMapping {
|
||||
id: number
|
||||
provider: string
|
||||
shopId: string
|
||||
platformSkuCode: string
|
||||
platformSkuName: string
|
||||
platformSkuNameKey: string
|
||||
matchType: 'sku_code' | 'sku_code_name' | 'sku_code_series' | 'sku_name_series'
|
||||
canonicalProductId: number
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
quantityMultiplier: number
|
||||
confidence: 'exact' | 'rule' | 'manual'
|
||||
source: 'manual' | 'import' | 'rule'
|
||||
enabled: boolean
|
||||
note: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CostItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
costType:
|
||||
| 'auto_purchase'
|
||||
| 'work_order_reward'
|
||||
| 'manual_service'
|
||||
| 'platform_fee'
|
||||
| 'payment_fee'
|
||||
| 'after_sales_loss'
|
||||
| 'shipping_other'
|
||||
| 'other'
|
||||
carrierType: 'affiliate_dash' | 'work_order' | 'manual' | 'shared'
|
||||
pricingMode: 'per_item' | 'per_order' | 'per_hour' | 'percent_of_revenue' | 'fixed_daily'
|
||||
unitName: string
|
||||
currency: string
|
||||
status: 'active' | 'archived'
|
||||
note: string
|
||||
versionCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CostVersion {
|
||||
id: number
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
pricingMode: CostItem['pricingMode']
|
||||
scopeType: 'global' | 'provider' | 'shop' | 'product' | 'rule'
|
||||
provider: string
|
||||
shopId: string
|
||||
canonicalProductId: number | null
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
workProductRuleId: number | null
|
||||
amountFen: number
|
||||
rateBps: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
source: 'manual' | 'upstream_sync' | 'import'
|
||||
status: 'draft' | 'active' | 'retired'
|
||||
changeReason: string
|
||||
createdByAdminUserId: number | null
|
||||
approvedByAdminUserId: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CostException {
|
||||
id: number
|
||||
subjectType: 'order_item' | 'work_order' | 'manual_case'
|
||||
subjectId: number
|
||||
orderItemId: number | null
|
||||
workOrderId: number | null
|
||||
carrierType: string
|
||||
reason: string
|
||||
status: 'open' | 'resolved' | 'ignored'
|
||||
detail: Record<string, unknown>
|
||||
createdAt: string
|
||||
resolvedAt: string | null
|
||||
resolvedByAdminUserId: number | null
|
||||
}
|
||||
|
||||
export interface CostCenterOptions {
|
||||
providers: string[]
|
||||
products: Array<{
|
||||
id: number
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
unitName: string
|
||||
}>
|
||||
costItems: Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
pricingMode: CostItem['pricingMode']
|
||||
carrierType: CostItem['carrierType']
|
||||
}>
|
||||
rules: Array<{
|
||||
id: number
|
||||
ruleKey: string
|
||||
productName: string
|
||||
skuCode: string
|
||||
provider: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TemplateProductBootstrapPreview {
|
||||
templateCount: number
|
||||
productExistingCount: number
|
||||
productCreateCount: number
|
||||
mappingCandidateCount: number
|
||||
mappingExistingCount: number
|
||||
mappingCreateCount: number
|
||||
mappingCreateByMatchType: Record<
|
||||
'sku_code' | 'sku_code_name' | 'sku_code_series' | 'sku_name_series',
|
||||
number
|
||||
>
|
||||
evidenceItemCount: number
|
||||
orderItemCoverCount: number
|
||||
mappingConflictCount: number
|
||||
mappingConflictItemCount: number
|
||||
multiRuleKeyCount: number
|
||||
seriesFallbackConflictCount: number
|
||||
templatesWithoutWorkOrders: number
|
||||
samples: Array<{
|
||||
ruleKey: string
|
||||
productName: string
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
matchType: ChannelProductMapping['matchType']
|
||||
itemCount: number
|
||||
}>
|
||||
conflicts: Array<{
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
matchType: 'sku_code_name'
|
||||
itemCount: number
|
||||
ruleKeys: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TemplateProductBootstrapResult {
|
||||
templateCount: number
|
||||
createdProductCount: number
|
||||
reusedProductCount: number
|
||||
createdMappingCount: number
|
||||
skippedMappingCount: number
|
||||
mappedOrderItemCount: number
|
||||
deletedImportedMappingCount: number
|
||||
resetOrderItemCount: number
|
||||
mappingConflictCount: number
|
||||
mappingConflictItemCount: number
|
||||
}
|
||||
|
||||
export interface CostLine {
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
costType: string
|
||||
carrierType: string
|
||||
pricingMode: string
|
||||
costVersionId: number
|
||||
scopeType: string
|
||||
amountFen: number
|
||||
rateBps: number
|
||||
quantity: number
|
||||
unitAmountFen: number
|
||||
totalAmountFen: number
|
||||
}
|
||||
|
||||
export interface CostPreview {
|
||||
context: {
|
||||
orderItemId: number | null
|
||||
orderId: number | null
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
carrierType: string
|
||||
quantity: number
|
||||
normalizedQuantity: number
|
||||
revenueFen: number
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
}
|
||||
mapping: ChannelProductMapping | null
|
||||
product: ProductCatalogItem | null
|
||||
lines: CostLine[]
|
||||
missingCostItems: Array<{
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
carrierType: string
|
||||
}>
|
||||
totalCostFen: number
|
||||
exceptions: Array<{ reason: string; message: string; detail: Record<string, unknown> }>
|
||||
}
|
||||
|
||||
export interface ProductCategoryNode {
|
||||
categoryId: number
|
||||
name: string
|
||||
total: number
|
||||
directTotal: number
|
||||
children: Array<{
|
||||
categoryId: number
|
||||
name: string
|
||||
total: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ProductCatalogListResult {
|
||||
items: ProductCatalogItem[]
|
||||
categoryTree: ProductCategoryNode[]
|
||||
uncategorizedTotal: number
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export interface ChannelProductMappingGroup {
|
||||
canonicalProductId: number
|
||||
canonicalSku: string
|
||||
canonicalName: string
|
||||
category: string
|
||||
unitName: string
|
||||
status: 'active' | 'archived'
|
||||
mappings: ChannelProductMapping[]
|
||||
}
|
||||
|
||||
export interface ChannelProductMappingListResult {
|
||||
groups: ChannelProductMappingGroup[]
|
||||
pagination: { page: number; pageSize: number; total: number; mappingTotal: number }
|
||||
}
|
||||
|
||||
export interface CostItemListResult {
|
||||
items: CostItem[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export interface CostVersionListResult {
|
||||
items: CostVersion[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export interface CostExceptionListResult {
|
||||
items: CostException[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
@@ -122,3 +122,5 @@ export type {
|
||||
AdminAffiliateDashProductListResult,
|
||||
AdminAffiliateDashWallet,
|
||||
} from './platform-config'
|
||||
|
||||
export type * from './cost-center'
|
||||
|
||||
+72
-21
@@ -1,9 +1,9 @@
|
||||
# order_site 文档索引
|
||||
|
||||
> 最后校准:2026-10-17,代码基线 `main @ 6850bc64`。
|
||||
> 最后校准:2026-10-18,代码基线 `main @ 7542d547`。
|
||||
> 本文是 `docs/` 的唯一入口。新增文档必须在上表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
|
||||
|
||||
## 一、先记住四条口径
|
||||
## 一、先记住五条口径
|
||||
|
||||
### 1. 来源只有三个,不是四个
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| `pdd` | 拼多多订单同步 | 两段式 `pdd_raw_orders → orders` |
|
||||
| `douyin-mashida` | 抖音码速达推送 | 抖店订单、DYC 券、H5/兑换页 |
|
||||
|
||||
**不要把 91 与快手 send-code 拆成两个来源、两个适配器或两条业务线。** 代码中的唯一来源键是 `kuaishou-91`(`apps/backend/src/services/product-match/types.ts`),适配器是 `adapters/kuaishou-91.ts`。
|
||||
**不要把 91 与快手 send-code 拆成两个来源、两个适配器或两条业务线。** 产品匹配层的唯一来源键是 `kuaishou-91`(`apps/backend/src/services/product-match/types.ts`),适配器是 `adapters/kuaishou-91.ts`。但订单表与成本映射里存储的 `provider` 仍是 `orders.provider = 91kaquan`;成本中心会把 `91 / open_91 / kuaishou-91` 这些别名归一为 `91kaquan`,映射键必须按落库值建。
|
||||
|
||||
### 2. 一个订单 = 骨架 + 买家凭证 + 一个执行载体
|
||||
|
||||
@@ -30,24 +30,73 @@
|
||||
- 订单/工单/人工单只保存 `cost_snapshot_id`,历史成本永不回写
|
||||
- 自动发货、接单平台、人工客服共用同一个成本解析器;匹配不到成本进入 `cost_missing`,不默认 0
|
||||
|
||||
### 4. 文档状态
|
||||
### 4. 两个“人工支出”不是一回事
|
||||
|
||||
| 名称 | 业务含义 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100) |
|
||||
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱、付款人、收款人、凭证、审批 | 仅草案,未实施 |
|
||||
|
||||
后续做财务系统时,**不要把 `manual_expenses` 直接改名或复用为 `manual_expense_records`**。两者业务含义、审批链和报表口径不同,应通过 `fund_expense_id` 建立关联。
|
||||
|
||||
### 5. 文档状态
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| 现行 | 与当前代码一致,改动代码时必须同步更新 |
|
||||
| 参考 | 厂商协议、外部接口文档,按平台版本更新 |
|
||||
| 方案 | 尚未实施,但作为下一阶段实现依据 |
|
||||
| 参考 | 外部协议、历史数据或解释性材料,不作为实现依据 |
|
||||
| 历史 | 只用于追溯,不得作为实现依据;位于 `docs/archive/` |
|
||||
|
||||
## 二、现行文档
|
||||
## 二、财务系统当前进度
|
||||
|
||||
| 模块 | 状态 | 事实 |
|
||||
| --- | --- | --- |
|
||||
| 客服备用金台账 | **已实施 P0** | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations`;后台页面、权限、审计已完成 |
|
||||
| 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 |
|
||||
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0** | 迁移 `102`;`product_catalog` / `channel_product_mappings`,后台「商品与成本」页支持维护与历史订单项回填 |
|
||||
| 成本中心 | **已实施 P0** | 迁移 `103` + 修正 `104`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions`,支持版本、预览、快照、异常队列 |
|
||||
| 销售事实 / 订单毛利 | 未实施 | `sales_daily_facts` 仍是 DDL 草案;现有财务页不能按商品/店铺/载体算真实毛利 |
|
||||
| 公司人工支出登记 | 未实施 | 与已实施的客服备用金支出不是同一张表 |
|
||||
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
|
||||
| 平台对账 / 期间关账 | 未实施 | 仅方案 |
|
||||
|
||||
## 三、统一实施顺序
|
||||
|
||||
```text
|
||||
1. 商品主数据 + 渠道 SKU 映射 已完成 P0
|
||||
2. 成本中心 + 销售事实(先接收入与标准成本) 成本中心已完成 P0;销售事实未实施
|
||||
3. 订单毛利 / 店铺日报 未实施
|
||||
4. 公司人工支出登记(桥接现有客服备用金支出) 未实施
|
||||
5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施
|
||||
6. 平台对账、报表、期间关账 未实施
|
||||
```
|
||||
|
||||
**不要跳过 1–3 直接做影子总账。** 没有商品归一、成本版本和订单损益,影子账只能记“总额”,无法回答“哪个店铺、哪个商品、哪条履约链路赚钱”。
|
||||
|
||||
## 四、当前必须拍板
|
||||
|
||||
| # | 问题 | 当前结论 | 影响 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 收入金额口径 | ✅ 用户实付(`orders.total_amount`) | 收入基数 |
|
||||
| 2 | 收入确认时点 | ⏳ 待定:付款 / 发货 / 验收 / 售后期结束 | 利润表与预收账款 |
|
||||
| 3 | 打手报酬 | ✅ 支出/成本,总额法 | 履约成本 |
|
||||
| 4 | 退款/仲裁 | ✅ 暂不实现,只预留反向凭证 | 后续冲减 |
|
||||
| 5 | 人工支出如何入账 | ✅ 以人工支出登记为入口;需与客服备用金支出桥接 | 人工成本与应付垫付人 |
|
||||
| 6 | 平台服务费/支付通道费 | ⏳ 待定 | 费用科目与对账 |
|
||||
| 7 | 寄卖货款成本确认时点 | ⏳ 待定 | 存货/采购成本 |
|
||||
| 8 | 是否需要发票/税务、是否多币种 | ⏳ 待定 | 当前按 CNY、无税务实现 |
|
||||
|
||||
## 五、现行与参考文档
|
||||
|
||||
| 文档 | 状态 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 现行 | 当前架构、业务链路、风险与改进路线 |
|
||||
| [载体判定与成本中心落地-复核与草案-2026-10-17.md](载体判定与成本中心落地-复核与草案-2026-10-17.md) | 现行(复核+草案) | 只读克隆量化载体分布与人工块、对上一轮结论的复核与修正、两份 SQL 草案说明 |
|
||||
| [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md) | 现行(方案) | 以订单为入口的支出登记:字段级预填分工、交互入口、状态机与权限、校验与异常队列、分阶段落地 |
|
||||
| [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(方案) | 自动发货/接单平台/人工客服三块、统一成本中心、销售事实与人工监管 |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | 现行(方案) | 财务系统边界、复式记账模型、分期落地 |
|
||||
| [影子总账说明.md](影子总账说明.md) | 现行(方案) | 影子总账的定义、运行方式、对账与转正标准 |
|
||||
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 参考 | 当前架构、业务链路、风险与改进路线;代码基线早于备用金实现,涉及财务部分以本索引为准 |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(未实施) | 财务系统边界、复式记账模型、核心表、API、分期落地 |
|
||||
| [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(P0 已实施) | 自动发货/接单平台/人工客服三块、商品映射与成本中心;销售事实仍为方案 |
|
||||
| [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md) | 现行方案(未实施) | 公司人工支出登记:字段、入口、状态机、权限、与备用金/成本中心/总账的衔接 |
|
||||
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
|
||||
| [客服备用金台账实施方案-2026-10-18.md](客服备用金台账实施方案-2026-10-18.md) | **已实施 P0** | 客服备用金账户、流水、支出、对账、权限与上线记录 |
|
||||
| [接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md) | 现行(P0/P1 已实施) | `kuaishou-91 / pdd / douyin-mashida` 三来源匹配引擎 |
|
||||
| [履约配置/多发货平台与电子凭证整体链路.md](履约配置/多发货平台与电子凭证整体链路.md) | 现行 | 进单、履约、领取、电子凭证的整体链路 |
|
||||
| [affiliate-dash-发货平台/affiliate-dash对接.md](affiliate-dash-发货平台/affiliate-dash对接.md) | 现行 | affiliate-dash 自动履约接口与字段 |
|
||||
@@ -63,13 +112,13 @@
|
||||
|
||||
草案只作设计与复盘依据;接入 `apps/backend/src/db/migrations/` 时再拆成 `NNN_slug.sql`,并先跑 `npm run check:sql`。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填、守恒校验、人工成本区间 |
|
||||
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` 建表草案 |
|
||||
| [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`(付款项目/金额/订单号/付款人/凭证)、`manual_expense_events`;依赖成本中心草案,须在其后应用 |
|
||||
| 文件 | 用途 | 接入前必须处理 |
|
||||
| --- | --- | --- |
|
||||
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | 只读查询可直接用;DDL 部分按当前迁移号顺延,不占用 100/101 |
|
||||
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` | 商品与成本部分已落地为 `102/103/104`;剩余销售事实按新迁移号继续,草案不再作为建表依据 |
|
||||
| [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | 已改用 `*_manual_expense_records_*` 命名,并补 `fund_expense_id`;接入前仍需复核与迁移号 |
|
||||
|
||||
## 三、厂商协议与外部资料
|
||||
## 六、厂商协议与外部资料
|
||||
|
||||
这些目录是平台方文档或抓取资料,**不作为本项目实现说明**:
|
||||
|
||||
@@ -81,13 +130,14 @@
|
||||
| `抖音-码速达/接口API/` | 抖音码速达出网接口 |
|
||||
| `抖音-码速达/消息推送/` | 抖音码速达推送消息字段与签名 |
|
||||
|
||||
## 四、历史文档
|
||||
## 七、历史文档
|
||||
|
||||
| 位置 | 说明 |
|
||||
| --- | --- |
|
||||
| [archive/README.md](archive/README.md) | 2026-10-08 及更早的阶段分析、寄卖方案、性能快照、一次性迁移检查脚本 |
|
||||
| [archive/README.md](archive/README.md) | 2026-10-08 及更早的阶段分析、寄卖方案、性能快照、一次性迁移检查脚本;2026-10-17 复核文档 |
|
||||
| [archive/2026-10-17/载体判定与成本中心落地-复核与草案.md](archive/2026-10-17/载体判定与成本中心落地-复核与草案.md) | 2026-10-17 第二轮复核与数据量化;结论已吸收进现行成本中心文档,仅作历史追溯 |
|
||||
|
||||
## 五、维护规则
|
||||
## 八、维护规则
|
||||
|
||||
1. 改代码影响业务口径时,先改现行文档,再提交代码。
|
||||
2. 文档头部必须写「状态 + 最后校准日期 + 代码基线」。
|
||||
@@ -97,3 +147,4 @@
|
||||
6. 成本金额只改 `cost_versions`,不要在多个业务表直接改价;历史订单只读 `cost_snapshots`。
|
||||
7. 报表维度统一用 `canonical_product_id + shop_id + stat_date`,不要直接用原始 `sku_code` 或 `orders.total_amount`。
|
||||
8. 新增 SQL 草案放 `docs/drafts/`,命名 `<日期>-<主题>.sql`;**不得**直接放进 `apps/backend/src/db/migrations/`(后端启动会自动迁移),接入时再拆编号并先跑 `npm run check:sql`。
|
||||
9. 财务系统实现顺序以本索引 §三 为准;单篇文档里的分期若与本索引冲突,以本索引为准。
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# 载体判定与成本中心落地:复核与草案(2026-10-17 第二轮)
|
||||
|
||||
> 状态:现行(复核 + 草案);基线:`main @ ccd5ed2a`;代码未改动。
|
||||
> 状态:**历史(2026-10-17 第二轮复核,已归档)**;基线:`main @ ccd5ed2a`;代码未改动。
|
||||
> 说明:本文的现行结论已吸收进 [成本中心与三块履约模型.md](../../成本中心与三块履约模型.md) 与 [财务系统集成方案.md](../../财务系统集成方案.md);本文件只用于追溯 2026-10-17 的数据量化与 DDL 验证过程,不再作为实现依据。
|
||||
> 数据来源:**本地只读克隆**(生产快照,`orders` 316,597 行,数据截至 2026-10-14 14:52),全程只读 SELECT,未连生产、未写入任何业务库。
|
||||
> 金额单位:**分**(`orders.total_amount` / `work_orders.reward_amount` / `source_order_amount_fen` 均为分)。
|
||||
> 上游文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md)、[项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md)。
|
||||
> 上游文档:[成本中心与三块履约模型.md](../../成本中心与三块履约模型.md)、[项目全量分析报告-2026-10-17.md](../../项目全量分析报告-2026-10-17.md)。
|
||||
|
||||
本轮任务是把上一轮「只分析」的结论**复核到底、量化到数、落成可执行草案**。三件事:① 提交并推送待提交文档;② 收敛本机生产备份暴露面;③ 用只读 SQL 量化载体分布与人工块,并产出载体判定 + 成本中心两份 SQL 草案。
|
||||
|
||||
@@ -128,9 +129,9 @@ Top 店铺(人工块):向日葵游戏服务店 14,903 项 / ¥183.9 万;
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| [载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | §1 八组只读量化查询(含实测值注释);§2 判定规则与优先级(含 `legacy` 与冲突处理);§3 加列 + 回填 + 索引 + 回滚;§4 守恒校验与异常队列查询;§5 人工成本区间 |
|
||||
| [成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items`、`cost_versions`、`cost_snapshots`、`cost_exceptions`、`sales_daily_facts`;订单项口径补齐(`normalized_quantity`/`revenue_amount_fen`/`revenue_basis`/`refunded_at`);`work_product_rules.cost_item_id` 过渡列;§5 对平校验;§6 已确认口径与剩余待确认 |
|
||||
| [人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`(轻量处理记录)+ `manual_expense_records`(付款项目/金额/订单号/付款人/收款人/凭证/审批)+ `manual_expense_events` 审计;§3 校验查询;§4 与影子总账的分录约定 |
|
||||
| [载体判定与人工块量化.sql](../../drafts/2026-10-17-载体判定与人工块量化.sql) | §1 八组只读量化查询(含实测值注释);§2 判定规则与优先级(含 `legacy` 与冲突处理);§3 加列 + 回填 + 索引 + 回滚;§4 守恒校验与异常队列查询;§5 人工成本区间 |
|
||||
| [成本中心与销售事实-DDL草案.sql](../../drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items`、`cost_versions`、`cost_snapshots`、`cost_exceptions`、`sales_daily_facts`;订单项口径补齐(`normalized_quantity`/`revenue_amount_fen`/`revenue_basis`/`refunded_at`);`work_product_rules.cost_item_id` 过渡列;§5 对平校验;§6 已确认口径与剩余待确认 |
|
||||
| [人工支出登记-DDL草案.sql](../../drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`(轻量处理记录)+ `manual_expense_records`(付款项目/金额/订单号/付款人/收款人/凭证/审批)+ `manual_expense_events` 审计;§3 校验查询;§4 与影子总账的分录约定 |
|
||||
|
||||
**验证方式**:在本地副本上 `pg_dump --schema-only` 建了一个一次性沙箱库(55 张表),按依赖顺序(载体判定 → 成本中心 → 人工支出登记)用 `psql -v ON_ERROR_STOP=1` 应用**三份草案 —— 零错误**:生成 10 张新表(共 65 张)、`order_items` 由 9 列增至 19 列、27 个 `CHECK` 约束、169 个索引;校验查询与回填语句均可执行。验证后沙箱库已 `DROP`。
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
| `近期性能与稳定性专项优化分析报告.md` | 2026-10-01 / 10-02 的生产快照与专项复盘,保留作性能证据,不代表当前容量 |
|
||||
| `寄卖系统迁移079上库前检查.sql` | 迁移 079 的一次性上库前检查脚本,生产已执行;保留作审计追溯 |
|
||||
|
||||
## 2026-10-17
|
||||
|
||||
| 文档 | 归档原因 |
|
||||
| --- | --- |
|
||||
| `2026-10-17/载体判定与成本中心落地-复核与草案.md` | 2026-10-17 第二轮复核与数据量化;现行结论已吸收进 `成本中心与三块履约模型.md` 与 `财务系统集成方案.md`,仅保留作数据与 DDL 验证追溯 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 需要当前架构与风险:看 `docs/项目全量分析报告-2026-10-17.md`。
|
||||
- 需要财务系统设计:看 `docs/财务系统集成方案.md`。
|
||||
- 需要当前架构与风险:看 `docs/项目全量分析报告-2026-10-17.md`(参考)和 `docs/README.md`(状态索引)。
|
||||
- 需要财务系统设计:看 `docs/财务系统集成方案.md`;实施顺序以 `docs/README.md` §三 为准。
|
||||
- 需要某个平台协议:看对应的厂商文档目录(`91卡券/`、`行业电子凭证/`、`发货平台-2/`、`抖音-码速达/接口API/`、`抖音-码速达/消息推送/`)。
|
||||
- 历史文档中的代码行号、测试数量、迁移数量不要直接引用。
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
-- 人工支出登记 + 轻量人工处理记录(建表草案)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
|
||||
-- 依赖:本文引用 product_catalog / cost_items / cost_snapshots,
|
||||
-- 必须**在** `2026-10-17-成本中心与销售事实-DDL草案.sql` **之后**应用。
|
||||
-- 接入时应拆成 `104_manual_cases.sql`、`105_manual_expenses.sql`,并先跑 `npm run check:sql`。
|
||||
-- 基线:main @ 801bfa0b;依据:2026-10-17 业务口径确认(见 docs/载体判定与成本中心落地-复核与草案-2026-10-17.md §九)。
|
||||
-- 依赖:本文引用 product_catalog / cost_items / cost_snapshots / manual_expenses,
|
||||
-- 必须**在** `2026-10-17-成本中心与销售事实-DDL草案.sql` **之后**应用;
|
||||
-- `manual_expenses` 已由迁移 100 创建(客服备用金支出)。
|
||||
-- 当前已使用迁移 100/101;成本中心草案拆为 102–104,本文建议顺延为
|
||||
-- `105_manual_cases.sql`、`106_manual_expenses.sql`,接入时按实际最大号调整并先跑 `npm run check:sql`。
|
||||
-- 基线:main @ 7542d547;依据:2026-10-17 业务口径确认 + 2026-10-18 备用金落地复核。
|
||||
--
|
||||
-- 重要命名边界:
|
||||
-- * `manual_expenses`(迁移 100)= 客服备用金支出明细,已有索引/约束名 `*_manual_expenses_*`;
|
||||
-- * `manual_expense_records`(本草案)= 公司人工支出/垫付登记;
|
||||
-- * 本文件所有索引和约束必须使用 `*_manual_expense_records_*`,禁止复用 `manual_expenses` 的名字。
|
||||
-- * 两者通过 `manual_expense_records.fund_expense_id` 关联,不能合并成一张表。
|
||||
--
|
||||
-- 业务原话(定优先级):
|
||||
-- 「人工块现在意义不大,主要是把流程搞清楚:哪个人处理了哪些东西,付了多少钱。
|
||||
@@ -98,6 +106,8 @@ CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||||
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,
|
||||
-- 可选:这笔公司支出来源于客服备用金支出时填写;公司直付留空
|
||||
fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL,
|
||||
cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
|
||||
expense_category TEXT NOT NULL DEFAULT 'other',
|
||||
amount_fen BIGINT NOT NULL,
|
||||
@@ -124,55 +134,59 @@ CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_expense_no ON manual_expense_records (expense_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order ON manual_expense_records (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_item ON manual_expense_records (order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_case ON manual_expense_records (case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_paid_at ON manual_expense_records (paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_status ON manual_expense_records (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_cost_item ON manual_expense_records (cost_item_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_expense_no ON manual_expense_records (expense_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_order ON manual_expense_records (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_item ON manual_expense_records (order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_case ON manual_expense_records (case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_paid_at ON manual_expense_records (paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_status ON manual_expense_records (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_cost_item ON manual_expense_records (cost_item_id);
|
||||
-- 同一笔客服备用金支出最多生成一条公司人工支出,避免重复入账
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_fund_expense
|
||||
ON manual_expense_records (fund_expense_id) WHERE fund_expense_id IS NOT NULL;
|
||||
-- 付款人日结对账用
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_payer ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_payer ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||||
-- 批量归组查询用
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_batch
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_batch
|
||||
ON manual_expense_records (expense_batch_no) WHERE expense_batch_no <> '';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_amount') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_amount') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_amount CHECK (amount_fen <> 0);
|
||||
ADD CONSTRAINT chk_manual_expense_records_amount CHECK (amount_fen <> 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_status') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_status') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_status
|
||||
ADD CONSTRAINT chk_manual_expense_records_status
|
||||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_payee_type') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_payee_type') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_payee_type
|
||||
ADD CONSTRAINT chk_manual_expense_records_payee_type
|
||||
CHECK (payee_type IN ('worker', 'upstream', 'customer', 'platform', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_method') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_method') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_method
|
||||
ADD CONSTRAINT chk_manual_expense_records_method
|
||||
CHECK (payment_method IN ('wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_category') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_category') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_category
|
||||
ADD CONSTRAINT chk_manual_expense_records_category
|
||||
CHECK (expense_category IN ('goods_purchase', 'compensation', 'refund_advance', 'service_fee',
|
||||
'freight', 'recharge', 'other'));
|
||||
END IF;
|
||||
-- 已通过/已报销必须有审批信息
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_approved') THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_approved') THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expenses_approved
|
||||
ADD CONSTRAINT chk_manual_expense_records_approved
|
||||
CHECK (status NOT IN ('approved', 'reimbursed') OR (approved_at IS NOT NULL AND approved_by <> ''));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE manual_expense_records IS '人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证,解决人工付款不可追溯';
|
||||
COMMENT ON TABLE manual_expense_records IS '公司人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证;与客服备用金 manual_expenses 通过 fund_expense_id 关联';
|
||||
COMMENT ON COLUMN manual_expense_records.fund_expense_id IS '来源客服备用金支出(manual_expenses.id);公司直付为空';
|
||||
COMMENT ON COLUMN manual_expense_records.cost_item_id IS '付款项目 -> cost_items;为空表示未归类,必须进异常队列而不是默认其他';
|
||||
COMMENT ON COLUMN manual_expense_records.expense_category IS '兜底分类:cost_item_id 未填时的粗分类,报表需同时暴露未归类占比';
|
||||
COMMENT ON COLUMN manual_expense_records.paid_by_admin_user_id IS '实际垫付/付款的后台用户;用于“谁付了多少钱”对账';
|
||||
@@ -249,7 +263,7 @@ COMMENT ON TABLE manual_expense_events IS '人工支出状态变更审计:只
|
||||
-- manual_expense:{expense_no}:reversed 冲正(退款/仲裁接入后启用,本轮仅预留)
|
||||
--
|
||||
-- 建议分录(approved 时,金额 A):
|
||||
-- 借:5101 履约成本-人工(或按 cost_items 映射的科目) A
|
||||
-- 借:5102 履约成本-人工客服(或按 cost_items 映射的科目) A
|
||||
-- 贷:2005 其他应付-垫付人 A
|
||||
-- reimbursed 时:
|
||||
-- 借:2005 其他应付-垫付人 A
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
-- ============================================================================
|
||||
-- 通用成本中心 + 标准商品 + 销售事实(建表草案)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
|
||||
-- 接入时应拆成 `101_product_catalog.sql`、`102_cost_center.sql`、`103_sales_daily_facts.sql`,
|
||||
-- 并先跑 `npm run check:sql`(SQL 防退化护栏)。
|
||||
-- 基线:main @ ccd5ed2a;数据依据:本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14)。
|
||||
-- 状态:部分落地。商品主数据 + 渠道映射 + 成本中心已接入 `102_product_catalog.sql`、
|
||||
-- `103_cost_center.sql`、`104_cost_center_bootstrap_fix.sql`;本文件仅保留设计追溯。
|
||||
-- 尚未实施的 `sales_daily_facts` 等后续内容,接入时从新迁移号继续,并先跑 `npm run check:sql`。
|
||||
-- 基线:main @ 7542d547;数据依据:本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14)。
|
||||
-- 金额单位:分(BIGINT)。本文件所有 *_fen 列均为分。
|
||||
-- 设计依据:docs/成本中心与三块履约模型.md(三块载体、成本版本与快照)、docs/财务系统集成方案.md。
|
||||
-- 当前状态:商品主数据、渠道映射、成本中心已实施 P0;销售事实未实施。客服备用金支出已实施,但不是本文件的成本中心。
|
||||
--
|
||||
-- 目标(业务原话):以后改成本只在一个地方。
|
||||
-- -> 所有成本金额只写 cost_versions;业务表只保存 cost_snapshot_id 与派生金额,永不回写历史。
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
-- 载体判定与人工块量化(只读量化 + 回填草案)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
|
||||
-- 接入时应拆成 `100_order_item_carrier.sql`(见 §3),并先跑 `npm run check:sql`。
|
||||
-- 基线:main @ ccd5ed2a;数据来源 = 本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14 14:52)
|
||||
-- 当前已使用迁移 100/101;接入时按当前最大迁移号顺延,不占用 100/101,并先跑 `npm run check:sql`。
|
||||
-- 基线:main @ 7542d547;数据来源 = 本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14 14:52)
|
||||
-- 金额单位:分(BIGINT)。orders.total_amount / work_orders.reward_amount / source_order_amount_fen 均为分。
|
||||
-- 口径基准:§1 所有查询以「已支付订单项」为单位;人工块 = 既无 fulfillment_tasks 也无 work_orders。
|
||||
-- 用途:§1 是可重复执行的只读量化;§2–§4 是载体判定/回填草案,不作为现行迁移。
|
||||
-- 设计依据:docs/成本中心与三块履约模型.md、docs/README.md 第 2 条口径。
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
# 人工支出登记落地方案(2026-10-17)
|
||||
# 人工支出登记落地方案(2026-10-18 修订)
|
||||
|
||||
> 状态:现行(方案,未实施);基线:`main @ 7bacb7d5`;**本文只做分析与设计,不含代码改动**。
|
||||
> 状态:**现行方案(未实施)**
|
||||
> 最后校准:2026-10-18
|
||||
> 代码基线:`main @ 7542d547`
|
||||
> 依据:业务口径「人工块当前不以 SLA 监管为目标,先把流程搞清楚:哪个客服处理了哪些东西、付了多少钱、什么项目、关联哪个订单」。
|
||||
> 数据来源:本地只读克隆(生产快照,数据截至 2026-10-14)+ 现有代码盘点。
|
||||
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 ·
|
||||
> 重要:**客服备用金支出已经实施**(`manual_expenses`),但本文的**公司人工支出登记**(`manual_expense_records`)尚未实施;两张表不是一回事,不能直接复用或改名。
|
||||
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) ·
|
||||
> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)
|
||||
|
||||
---
|
||||
|
||||
## 〇、先分清两张表
|
||||
|
||||
| 表 | 业务含义 | 当前状态 | 关键字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | **已实施** | `account_id` / `fund_entry_id` / `category` / `payee_*` / `evidence_json` |
|
||||
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱 | 仅草案 | `order_id` / `order_item_id` / `cost_item_id` / `paid_by_admin_user_id` / `status` / `cost_snapshot_id` |
|
||||
|
||||
结论:
|
||||
|
||||
1. 客服侧已经收口的是“备用金余额与用途”,解决“客服手里有多少钱、这笔钱花到哪”。
|
||||
2. 财务侧还缺的是“公司口径的实际支出/垫付”,解决“公司为哪个订单、哪个成本项、付给谁、谁垫的、有没有审批和凭证”。
|
||||
3. 两张表通过 `fund_expense_id` 关联:如果这笔公司支出来源于客服备用金,就填;公司直付则留空。
|
||||
4. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 一、结论先行
|
||||
|
||||
1. **订单里的数据足够预填 90%**:店铺、商品、规格、数量、用户实付、支付时间、平台单号、履约标签、状态,后台订单列表/详情已经全部具备。客服实际只需要补 **4 件事**:渠道/项目、金额、收款方、凭证(外加默认带出的「谁、什么时候」)。
|
||||
2. **现有财务流水没有"支出"这一侧**。财务页现有 7 个账目类别(订单收款、订单退款、打手报酬、打手提现、打手充值、打手资金回收、售后追缴退还)**全部是收款或打手侧资金**;我方真实付出的钱(人工垫付、上游拿货、赔偿、运费)**一类都没有** —— 这就是"付了多少钱、什么项目"查不到的根本原因。
|
||||
3. **落地成本低、风险小**:不需要改订单主表,不需要新角色体系(现有 `admin/finance/operator/support` 四角色 + `finance.*` 权限即可覆盖),只需新增「支出登记」表 + 订单侧入口 + 财务侧一个新类别。
|
||||
4. **登记必须"发生在垫付当天"**才有监管价值;事后补记只能算账,不能算监管。
|
||||
2. **现有财务页没有“支出”这一侧**。财务页现有 7 个账目类别(订单收款、订单退款、打手报酬、打手提现、打手充值、打手资金回收、售后追缴退还)**全部是收款或打手侧资金**;客服备用金支出已经落在 `fund` 模块,但还没有汇总进财务页,也没有公司口径的人工支出/垫付登记。
|
||||
3. **落地成本低、风险小**:不需要改订单主表,不需要新角色体系(现有 `admin/finance/operator/support` 四角色 + `finance.*` 权限即可覆盖),只需新增「公司人工支出登记」表 + 订单侧入口 + 财务侧新类别。
|
||||
4. **登记必须“发生在垫付当天”**才有监管价值;事后补记只能算账,不能算监管。
|
||||
5. **本轮只补公司口径**:客服备用金台账已完成,不重复做一套客服余额系统;通过 `fund_expense_id` 关联即可。
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +83,7 @@
|
||||
打手充值(recharge) · 打手资金回收(worker_recovery) · 售后追缴退还(after_sales_refund)
|
||||
```
|
||||
|
||||
**没有任何一类描述"我方支出"**。后端 `admin-finance-service.ts` 的 `allowedCategories` 同样只有这 7 类,且方向判定只把 `order_paid / recharge / worker_recovery` 视为流入。也就是说:即使今天记了人工支出,财务页也没有地方落。
|
||||
**没有任何一类描述“我方公司支出”**。后端 `admin-finance-service.ts` 的 `allowedCategories` 同样只有这 7 类,且方向判定只把 `order_paid / recharge / worker_recovery` 视为流入。客服备用金页已有自己的 `manual_expenses` 明细,但那是 fund 口径,不会自动进入财务页;即使今天记了公司人工支出,财务页也没有地方落。
|
||||
|
||||
**结论**:人工支出登记必须同时补「财务流水类别」,否则只是把数据存进了另一张看不见的表。
|
||||
|
||||
@@ -75,6 +96,7 @@
|
||||
| 字段 | 谁提供 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 订单号 / 订单项 | 自动(订单) | 是(无订单支出除外) | 从订单详情/列表发起时锁定 |
|
||||
| 客服备用金来源 | 人工关联 | 否 | 若这笔钱从客服备用金出,关联 `manual_expenses.id`;公司直付留空 |
|
||||
| 平台 / 店铺 / 商品 / 规格 / 数量 | 自动(订单) | 是 | 只读展示,避免手抄错 |
|
||||
| 用户实付 | 自动(订单) | 是 | 只读,用于毛利 |
|
||||
| 支付时间 | 自动(订单) | 是 | 校验支出时间不早于它 |
|
||||
@@ -212,7 +234,7 @@ draft ──提交──> submitted ──审批──> approved ──报销─
|
||||
### 7.3 影子总账(分录)
|
||||
|
||||
```text
|
||||
approved(垫付) 借:5101 履约成本-人工 贷:2005 其他应付-垫付人
|
||||
approved(垫付) 借:5102 履约成本-人工客服 贷:2005 其他应付-垫付人
|
||||
reimbursed(报销) 借:2005 其他应付-垫付人 贷:1001 银行存款
|
||||
reversed(预留) 反向登记 + 反向凭证,原凭证不动
|
||||
```
|
||||
@@ -226,16 +248,18 @@ reversed(预留) 反向登记 + 反向凭证,原凭证不动
|
||||
|
||||
---
|
||||
|
||||
## 八、分阶段落地
|
||||
## 八、在全局路线中的位置
|
||||
|
||||
| 阶段 | 内容 | 产出价值 | 量级 |
|
||||
| --- | --- | --- | --- |
|
||||
| **P0** | 支出登记表 + 订单详情「登记支出」卡片 + 订单列表入口与标签 | **能记录**:钱花在哪、谁付的、有凭证 | 1~2 天 |
|
||||
| **P1** | 审批流 + 财务页新类别 + 「支出登记」页签 + 异常队列 | **能审、能统计**:谁付了多少、还有多少没报销 | 2~3 天 |
|
||||
| **P2** | 成本项归类 + `cost_snapshots` + 订单损益毛利预览 | **能算清**:每单真实毛利、渠道对比 | 3~5 天 |
|
||||
| **P3** | 影子总账入账 + 退款/仲裁冲正(字段已预留) | **能对账**:总账与业务账一致 | 后续 |
|
||||
> 全局顺序以 [README.md](README.md) §三 为准。本文只负责“公司人工支出登记”,不重复商品、成本中心、销售事实和影子总账的完整设计。
|
||||
|
||||
先做 P0 的理由:业务当前最痛的是"查不到",而不是"算不准"。能记录就已经解决 80% 的问题,且 P0 不依赖成本中心与总账。
|
||||
| 本文内部阶段 | 内容 | 前置 |
|
||||
| --- | --- | --- |
|
||||
| **A. 登记表 + 入口** | `manual_expense_records` / `manual_expense_events`、订单详情「登记支出」卡片、订单列表入口与标签 | 成本中心基础表已建;`cost_item_id` 可为空,但空值进异常队列 |
|
||||
| **B. 审批 + 财务页** | 状态机、审批/驳回/报销、财务页新类别、「支出登记」页签、异常队列 | A 完成 |
|
||||
| **C. 成本快照 + 毛利** | `approved` 写 `cost_snapshots`,订单损益预览 | 成本中心 `cost_versions` / `cost_snapshots` 可用 |
|
||||
| **D. 总账入账** | `approved` / `reimbursed` / `reversed` 生成影子总账事件 | 影子总账已落地 |
|
||||
|
||||
**客服备用金支出已经完成 A 的一部分(记录钱和用途),但没有审批、成本项、成本快照和总账事件。** 因此本轮不是重做客服台账,而是把公司口径补上并桥接。
|
||||
|
||||
---
|
||||
|
||||
@@ -251,6 +275,9 @@ reversed(预留) 反向登记 + 反向凭证,原凭证不动
|
||||
| 6 | 凭证必须吗? | **必须**(且支持多张) |
|
||||
| 7 | 迟登记如何处理? | 24h 内正常,超时标记,超 7 天需审批 |
|
||||
| 8 | 是否与现有 7 类财务流水合并展示? | 合并展示、分类筛选 |
|
||||
| 9 | 客服备用金支出是否自动生成公司人工支出? | **不自动**;提供“从备用金支出带入”,人工确认后生成,避免重复入账 |
|
||||
| 10 | 实际付款人必须等于登录人吗? | 允许代付;实际付款人必填,默认登录人,便于按人日结对账 |
|
||||
| 11 | `cost_item_id` 为空能否提交? | 可暂存/提交,但不得 `approved`;进“未归类”异常队列 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
# 客服备用金台账实施方案(2026-10-18)
|
||||
|
||||
> 状态:方案,未实施。
|
||||
> 状态:**已实施 P0**(迁移 100/101,前端/后端已上线到代码库;未部署生产)。
|
||||
> 最后校准:2026-10-18
|
||||
> 代码基线:`main @ 7542d547`
|
||||
> 背景:两位主管向客服发放备用金,客服用于充值、采购、赔偿等;目前全部走人工,无法监控每笔资金用途和每个客服余额。
|
||||
> 结论:这不是报销系统,而是**客服备用金(周转金)台账**。先做“账户 + 流水 + 对账”,不做复杂审批和总账。
|
||||
> 结论:这不是报销系统,而是**客服备用金(周转金)台账**。P0 已完成“账户 + 流水 + 支出 + 对账”,不做复杂审批和总账。
|
||||
|
||||
---
|
||||
|
||||
## 〇、实际落地结果
|
||||
|
||||
| 项 | 实际实现 |
|
||||
| --- | --- |
|
||||
| 数据库 | `100_fund_accounts.sql`:`fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations` |
|
||||
| 显示名 | `101_admin_display_name.sql`:`admin_users.display_name`,备用金 `owner_name` 由触发器同步 |
|
||||
| 后端 | `admin-fund-service.ts`、`routes/admin/fund.ts`;金额全部用分,写操作使用事务与行锁 |
|
||||
| 前端 | `AdminFundPage.tsx`、`FundManagementView.tsx`、`FundSupportView.tsx`;管理端与客服端分流 |
|
||||
| 权限 | `fund.view / fund.issue / fund.expense / fund.reconcile / fund.adjust / fund.export` |
|
||||
| 审计 | 所有写接口接入 `createJsonHandler({ audit })` |
|
||||
| 测试 | 后端 738 通过 / 3 跳过;前端 143 通过;两端 typecheck、lint、build 通过 |
|
||||
|
||||
**与本文原方案的差异:**
|
||||
|
||||
1. 实际表名 `manual_expenses` 是**客服备用金支出明细**,不是后来财务方案里的公司人工支出表 `manual_expense_records`。
|
||||
2. P0 未实现异常队列、批量发放/对账、导出、低余额提醒、大额支出主管确认;这些仍在 P1。
|
||||
3. 客服账户姓名使用 `owner_name` 快照 + `admin_users.display_name` 触发器同步;历史审计 payload 不重写。
|
||||
4. 支出凭证目前只校验数组非空,未校验对象存储 key 是否真实存在。
|
||||
|
||||
**后续衔接:**
|
||||
|
||||
- 财务侧公司人工支出登记应新建 `manual_expense_records`,通过 `fund_expense_id` 关联本表的 `manual_expenses.id`,不要复用或改名。
|
||||
- 备用金支出可作为“钱从客服备用金出”的来源证据;公司直付、垫付报销仍走公司人工支出登记。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 多发货平台与电子凭证整体链路
|
||||
|
||||
> 状态:现行。最后校准:2026-10-17(`main @ 6850bc64`)。
|
||||
> 状态:现行。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。
|
||||
> 来源口径:本系统只有三个订单来源——`kuaishou-91`、`pdd`、`douyin-mashida`。**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
+13
-9
@@ -1,12 +1,16 @@
|
||||
# 影子总账到底是什么意思
|
||||
|
||||
> 状态:**财务系统设计说明**
|
||||
> 代码基线:`main @ 6850bc64`
|
||||
> 状态:**现行说明(未实施)**
|
||||
> 最后校准:2026-10-18
|
||||
> 代码基线:`main @ 7542d547`
|
||||
> 配套方案:[财务系统集成方案.md](财务系统集成方案.md)
|
||||
> 前置:商品主数据、渠道映射、成本中心与销售事实;实施顺序以 [README.md](README.md) §三 为准。
|
||||
> 一句话:**影子总账是一套“跟着真实业务跑、但暂时不接管余额”的复式会计账。** 它先把每一笔业务翻译成借贷分录,和现有钱包/订单/提现数据逐日对账;确认长期对得平之后,再让它成为正式总账。
|
||||
>
|
||||
> 边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md),人工履约见同一文档第八节。
|
||||
|
||||
> 当前进度:客服备用金已实施;`finance_*` 表、分录、对账、转正均未实施。
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么叫“影子”
|
||||
@@ -309,13 +313,13 @@ consignment:abc:payout
|
||||
|
||||
## 九、在本项目落地的最小步骤
|
||||
|
||||
1. **定科目与口径**:收入确认时点、打手报酬是成本还是代付、平台费归属。
|
||||
2. **建表**:`finance_accounts`、`finance_business_events`、`finance_journal_entries`、`finance_journal_lines`、`finance_periods`、`finance_reconciliations`。
|
||||
3. **接 4 个高频事件**:订单支付、工单结算、押金冻结/释放/没收、提现。
|
||||
4. **做日对账**:钱包余额 vs 总账余额;先只报差异,不自动改账。
|
||||
5. **补事件**:寄卖押金/货款、售后欠款、充值。
|
||||
6. **做后台页面**:凭证簿、试算平衡、差异清单。
|
||||
7. **连续对平后切换**:总账成为权威,钱包改为投影。
|
||||
> 表结构、API 与分期细节见 [财务系统集成方案.md](财务系统集成方案.md) §三–§六;全局顺序以 [README.md](README.md) §三 为准。
|
||||
|
||||
1. **先完成前置**:商品主数据、渠道 SKU 映射、成本中心、销售事实。
|
||||
2. **建总账表**:`finance_accounts`、`finance_business_events`、`finance_journal_entries`、`finance_journal_lines`、`finance_periods`、`finance_reconciliations`。
|
||||
3. **先接 4 类高频事件**:订单支付、工单结算、押金冻结/释放/没收、提现;影子期只观察,不阻断业务。
|
||||
4. **日对账 + 差异清单**:钱包余额 vs 总账余额;先只报差异,不自动改账。
|
||||
5. **连续对平后转正**:总账成为权威,钱包改为投影;保留回滚开关。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+83
-14
@@ -1,12 +1,49 @@
|
||||
# 成本中心与三块履约模型
|
||||
|
||||
> 状态:**现行分析 / 设计基线(未写代码)**
|
||||
> 代码基线:`main @ 6850bc64`
|
||||
> 状态:**现行(P0 已实施)**。商品主数据、渠道映射、成本项目/版本/快照/异常已落地;销售事实仍为后续方案。
|
||||
> 最后校准:2026-10-18
|
||||
> 代码基线:`main @ 7542d547` + 未提交的商品/成本中心 P0
|
||||
> 数据基线:2026-10-17 生产库只读聚合(`default_transaction_read_only=on`,仅 SELECT,无写入)
|
||||
> 实施顺序以 [README.md](README.md) §三 为准;本文负责“商品、成本、销售事实”的数据模型,不重复总账与页面设计。
|
||||
> 关联:
|
||||
> - [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md)
|
||||
> - [影子总账说明.md](影子总账说明.md)
|
||||
> - [财务系统集成方案.md](财务系统集成方案.md)
|
||||
> - [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md)
|
||||
|
||||
---
|
||||
|
||||
## 〇、当前进度与边界
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 客服备用金支出(`manual_expenses`) | **已实施**;只解决客服备用金余额与用途,不等于公司人工支出登记 |
|
||||
| 商品主数据 / 渠道 SKU 映射 | **已实施 P0**;迁移 `102` + 匹配键修正 `105`,后台「商品与成本」页可批量初始化、维护与回填历史订单项 |
|
||||
| `cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` | **已实施 P0**;迁移 `103` + 修正 `104`,支持版本解析、预览、快照、异常队列 |
|
||||
| 销售事实 / 订单毛利 | 未实施;`sales_daily_facts` 仍为草案 |
|
||||
| 公司人工支出登记(`manual_expense_records`) | 未实施 |
|
||||
| 影子总账 | 未实施;必须在本文件的成本与销售事实之后 |
|
||||
|
||||
### 0.1 本期已实现边界
|
||||
|
||||
已实现:
|
||||
|
||||
- `product_catalog`、`channel_product_mappings`,以及 `order_items.canonical_product_id` / `normalized_quantity`;
|
||||
- 按 `provider + shop_id + platform_sku_code` 建映射,空 `shop_id` 为来源级映射、具体店铺优先;`provider` 必须与 `orders.provider` 一致(91 为 `91kaquan`,不是匹配层的 `kuaishou-91`,服务层会归一别名);
|
||||
- 映射回填与 SKU 文本数量回填;
|
||||
- `cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions`;
|
||||
- 成本版本优先级 `rule > product > shop > provider > global`;
|
||||
- 订单项成本预览、成本快照、缺失成本异常;
|
||||
- 后台「商品与成本」页、`finance.view` / `finance.config` 权限与审计。
|
||||
|
||||
本期不做:
|
||||
|
||||
- `sales_daily_facts` 与订单毛利报表;
|
||||
- 订单进单时自动写 `canonical_product_id` / `normalized_quantity`;
|
||||
- 自动发货、工单验收、人工 case 在业务事务内自动生成成本快照;
|
||||
- 上游 `cost_amount` 自动同步为 `cost_versions`;
|
||||
- 多订单项订单的收入分摊(当前按订单总额传给成本解析器);
|
||||
- 公司人工支出登记与影子总账。
|
||||
|
||||
---
|
||||
|
||||
@@ -104,27 +141,55 @@ product_catalog
|
||||
```text
|
||||
channel_product_mappings
|
||||
id
|
||||
provider -- kuaishou-91 / pdd / douyin-mashida
|
||||
shop_id
|
||||
external_sku_code -- 平台原始 sku_code / productNo / code
|
||||
external_sku_name
|
||||
provider -- 落库值:91kaquan / pdd / douyin_mashida
|
||||
shop_id -- 空串 = 来源级;具体店铺优先
|
||||
platform_sku_code -- 平台原始 sku_code / productNo / code
|
||||
platform_sku_name
|
||||
match_type -- sku_code / sku_code_name / sku_code_series / sku_name_series
|
||||
platform_sku_name_key -- 归一后的精确名称或数量系列键
|
||||
canonical_product_id
|
||||
valid_from / valid_to
|
||||
confidence
|
||||
quantity_multiplier -- 数量已在归一数量中体现,系列 SKU 固定为 1
|
||||
confidence -- exact / rule / manual
|
||||
source -- manual / import / rule
|
||||
enabled
|
||||
```
|
||||
|
||||
匹配优先级:
|
||||
|
||||
```text
|
||||
shop_id + external_sku_code
|
||||
> provider + external_sku_code
|
||||
> external_sku_name 归一化
|
||||
> 人工映射
|
||||
店铺 + SKU 编码
|
||||
> 来源 + SKU 编码
|
||||
> 店铺 + SKU 编码 + 精确名称
|
||||
> 来源 + SKU 编码 + 精确名称
|
||||
> 店铺 + SKU 编码 + 名称系列
|
||||
> 来源 + SKU 编码 + 名称系列
|
||||
> 店铺 + 名称系列
|
||||
> 来源 + 名称系列
|
||||
```
|
||||
|
||||
匹配不到的商品进入 `product_mapping_missing` 异常队列,**不能直接拿原始 `sku_code` 当报表维度**。
|
||||
|
||||
#### 3.2.1 接单模板批量初始化(2026-10-18 修正)
|
||||
|
||||
662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成:
|
||||
|
||||
1. **每个启用模板生成 1 个标准商品**,内部编码 `tpl:<rule_key>`;暂时没有历史工单的模板也先建商品。
|
||||
2. **91 与快手 send-code 的历史工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不再只看原始 `sku_code`。
|
||||
3. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。
|
||||
4. 同一编码被多个模板复用时,继续用“编码 + 归一名称系列”和“编码 + 精确名称”消歧;只有唯一归属才生成映射。
|
||||
5. 同一编码 + 同一名称在历史上仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单,前端在初始化预览中展示候选模板与涉及订单项数。
|
||||
6. 同一来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,供后续出现新编码时使用;它永远排在编码类映射之后。
|
||||
7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一个标准商品;数量写入 `order_items.normalized_quantity`,不能把“5个”再写成 `quantity_multiplier=5`,否则会重复乘算。
|
||||
8. 重跑初始化时只清理 `source='import'` 且备注为“接单模板 … 自动初始化”的映射,并重置由这些映射带出的订单项 `canonical_product_id`;人工映射、成本版本、快照和真实订单/工单不删除。
|
||||
|
||||
本地生产快照验证结果(2026-10-18):
|
||||
|
||||
- 启用模板 662 个,自动生成/复用标准商品 662 个;
|
||||
- 历史工单证据 112,451 个订单项;
|
||||
- 分层映射 4,311 条,其中编码精确 2,302、编码 + 精确名称 325、编码 + 名称系列 160、名称系列兜底 1,524;
|
||||
- 可唯一归一 86,533 个有证据订单项;另有 25,918 个订单项属于 32 组真实冲突,保持未映射;
|
||||
- 全库实际归一 103,143 个订单项(包含可由名称系列兜底命中的无工单订单项)。
|
||||
|
||||
### 3.3 店铺维度
|
||||
|
||||
`shop_id` 是稳定键;`shop_name` 会变。报表以 `shop_id` 为准,展示名取最新或按时间快照。
|
||||
@@ -337,6 +402,9 @@ orders + order_items + channel_product_mappings + cost_snapshots
|
||||
> 很多地方需要人工付款,也不知道付款项目、金额、订单号」。
|
||||
> 因此本章顺序调整为:**① 人工支出登记 → ② 处理人可见(轻量 case) → ③ SLA 与监管指标(延后)**。
|
||||
> DDL 见 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)。
|
||||
>
|
||||
> **2026-10-18 进度**:客服备用金支出已实施(`fund.manual_expenses`),解决“客服手里有多少钱、这笔钱花到哪”;
|
||||
> 但公司口径的人工支出登记(`manual_expense_records`)仍未实施。后者通过 `fund_expense_id` 关联前者,不重复做客服余额系统。
|
||||
|
||||
### 8.1 问题本质
|
||||
|
||||
@@ -490,11 +558,12 @@ flowchart LR
|
||||
- 人工先接按单/按小时成本
|
||||
- 缺失成本进异常队列
|
||||
|
||||
### P2:人工支出登记(先行)+ 轻量处理记录
|
||||
### P2:公司人工支出登记 + 轻量处理记录
|
||||
|
||||
- 载体判定(`carrier_type`)
|
||||
- 无载体自动建轻量 case(谁在处理哪个订单项)
|
||||
- **人工支出登记**:付款项目 / 金额 / 订单号 / 付款人 / 收款人 / 付款方式 / 凭证 / 审批
|
||||
- **公司人工支出登记**:付款项目 / 金额 / 订单号 / 付款人 / 收款人 / 付款方式 / 凭证 / 审批
|
||||
- 通过 `fund_expense_id` 关联客服备用金支出,避免重复记账
|
||||
- 人工支出进成本快照与影子总账(借:履约成本-人工,贷:其他应付-垫付人)
|
||||
- 延后(阶段③):SLA、超时升级、主管复核阈值、监管指标
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 抖音码速达 · 三层分流设计(皮肤自动发货 / 接单平台 / 人工)
|
||||
|
||||
> 状态:**已实施(抖音侧,2026-10-14);后续项见 §8 未决问题**。最后校准:2026-10-17(`main @ 6850bc64`)。
|
||||
> 状态:**已实施(抖音侧,2026-10-14);后续项见 §8 未决问题**。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。
|
||||
> 依据:生产库只读实测(接单模板 662 条)+ 全链路代码走查
|
||||
> 关联:[09-皮肤领取流程.md](09-皮肤领取流程.md)、[11-接单模板待补清单.md](11-接单模板待补清单.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 抖音码速达 · 按 SKU 分类改造总设计(对齐 kuaishou-91 三层)
|
||||
|
||||
> 状态:**抖音侧已实施(2026-10-14);P2 / P3 后续项未实施**。最后校准:2026-10-17(`main @ 6850bc64`)。
|
||||
> 状态:**抖音侧已实施(2026-10-14);P2 / P3 后续项未实施**。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。
|
||||
> 依据:全链路代码走查 + 生产库只读实测(模板 662 条 / `kuaishou-91` 订单 32.9 万 / 抖音 11 笔)
|
||||
> 前置:[10-三层分流设计.md](10-三层分流设计.md)、[11-接单模板待补清单.md](11-接单模板待补清单.md)
|
||||
> 前提:抖音侧实现可自由重构(无历史兼容包袱);**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道,不拆成两个来源**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 接单模板匹配引擎重构设计
|
||||
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-17(`main @ 6850bc64`)。
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。
|
||||
>
|
||||
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
|
||||
>
|
||||
|
||||
+30
-8
@@ -1,7 +1,9 @@
|
||||
# 财务系统集成方案
|
||||
|
||||
> 状态:**方案(待实施)**
|
||||
> 代码基线:`main @ 6850bc64`
|
||||
> 状态:**现行方案(未实施)**。本文描述 `finance_*` 总账层;当前代码尚未建表,现有财务页仍是 `UNION ALL` 业务事件拼装。
|
||||
> 最后校准:2026-10-18
|
||||
> 代码基线:`main @ 7542d547`
|
||||
> 前置:商品主数据、渠道 SKU 映射、成本中心与销售事实必须先完成;商品映射与成本中心已完成 P0,销售事实仍待实施。实施顺序以 [README.md](README.md) §三 为准。
|
||||
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,为 order_site 增加一套可对账、可关账、可追溯的财务系统。
|
||||
> 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。
|
||||
>
|
||||
@@ -9,6 +11,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 〇、当前实施状态
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 客服备用金台账(`fund_*` / 备用金 `manual_expenses`) | **已实施 P0**,见 [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) |
|
||||
| 商品主数据 / 渠道 SKU 映射 / 成本中心 | **已实施 P0**,迁移 `102/103/104`,见 [成本中心与三块履约模型](成本中心与三块履约模型.md) |
|
||||
| `finance_accounts` / `finance_business_events` / `finance_journal_*` | 未实施 |
|
||||
| `finance_settlements` / `finance_reconciliations` | 未实施 |
|
||||
| 订单级收入/成本/毛利 | 未实施;成本中心已完成,仍需销售事实与收入分摊 |
|
||||
| 现有 `admin-finance-service.ts` | 仍是业务事件 `UNION ALL`,不是总账 |
|
||||
|
||||
**不要复用客服备用金的 `manual_expenses` 作为公司人工支出表。** 两者口径不同;公司人工支出应使用 `manual_expense_records`,并通过 `fund_expense_id` 关联备用金支出。
|
||||
|
||||
---
|
||||
|
||||
## 一、现有财务能力盘点
|
||||
|
||||
### 1.1 已有表
|
||||
@@ -120,6 +137,7 @@ flowchart LR
|
||||
| 2002 | 应付卖家货款 | 负债 | 贷 |
|
||||
| 2003 | 打手押金 | 负债 | 贷 |
|
||||
| 2004 | 打手可提现余额 | 负债 | 贷 |
|
||||
| 2005 | 其他应付-垫付人 | 负债 | 贷 |
|
||||
| 2101 | 应付平台服务费 | 负债 | 贷 |
|
||||
| 2201 | 预收账款 | 负债 | 贷 |
|
||||
| 3001 | 本年利润 | 权益 | 贷 |
|
||||
@@ -128,6 +146,7 @@ flowchart LR
|
||||
| 4201 | 违约金/没收押金收入 | 收入 | 贷 |
|
||||
| 5001 | 商品采购成本 | 成本 | 借 |
|
||||
| 5101 | 履约成本-打手报酬 | 成本 | 借 |
|
||||
| 5102 | 履约成本-人工客服 | 成本 | 借 |
|
||||
| 5201 | 支付通道费 | 费用 | 借 |
|
||||
| 5301 | 售后损失 | 费用 | 借 |
|
||||
| 5401 | 平台服务费 | 费用 | 借 |
|
||||
@@ -288,7 +307,7 @@ CREATE TABLE finance_reconciliation_items (
|
||||
| 寄卖验收货款 | 5001 商品采购成本 | 2002 应付卖家货款 | 货款金额取 `cost_price_fen || asking_price_fen` |
|
||||
| 支付卖家货款 | 2002 应付卖家货款 | 1001 银行存款 | 需结算单 |
|
||||
| 平台服务费 | 5401 平台服务费 | 2101 应付平台服务费 | 从平台账单导入 |
|
||||
| 人工支出登记通过(垫付) | 5101 履约成本-人工(按 `cost_item_id` 映射科目) | 2005 其他应付-垫付人 | 付款项目/订单号/付款人必填;见 [人工支出登记草案](drafts/2026-10-17-人工支出登记-DDL草案.sql) |
|
||||
| 人工支出登记通过(垫付) | 5102 履约成本-人工客服(按 `cost_item_id` 映射科目) | 2005 其他应付-垫付人 | 付款项目/订单号/付款人必填;见 [人工支出登记草案](drafts/2026-10-17-人工支出登记-DDL草案.sql) |
|
||||
| 人工垫付报销 | 2005 其他应付-垫付人 | 1001 银行存款 | 需凭证与审批留痕 |
|
||||
| 退款冲正(预留) | 反向凭证 `reversal_of_id` 指向原凭证 | — | 退款/仲裁暂未接入,先预留不实现 |
|
||||
|
||||
@@ -334,6 +353,7 @@ postBusinessEvent({
|
||||
| `after-sales-repo.ts` | 售后扣款/欠款抵扣发事件 |
|
||||
| `worker-finance-request-service.ts` | 充值/提现审核发事件 |
|
||||
| `consignment-deposit-repo.ts` | 发布押金、货款结算发事件 |
|
||||
| 新增 `services/finance/manual-expense-service.ts`(未来) | 公司人工支出 `approved / reimbursed / reversed` 发事件;通过 `fund_expense_id` 关联备用金支出 |
|
||||
| `admin-finance-service.ts` | 改为读总账/试算平衡,不再用 `UNION ALL` 拼报表 |
|
||||
|
||||
### 4.3 钱包投影一致性
|
||||
@@ -394,22 +414,24 @@ POST /periods/:key/close
|
||||
|
||||
---
|
||||
|
||||
## 六、分期落地
|
||||
## 六、总账层分期
|
||||
|
||||
> 项目级顺序以 [README.md](README.md) §三 为准;本节只描述“总账层”内部阶段。
|
||||
> 进入 P0 前应已完成:商品主数据、渠道 SKU 映射、成本中心、销售事实、公司人工支出登记(`manual_expense_records`)。
|
||||
|
||||
### P0:影子总账(1–2 周)
|
||||
|
||||
> 前置:先完成商品主数据、渠道 SKU 映射、成本中心与销售事实(见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md))。否则影子总账只能记“总额”,无法按店铺/皮肤/成本版本出毛利。
|
||||
|
||||
- 建 `finance_accounts / business_events / journal_entries / journal_lines / periods`。
|
||||
- 先接 4 类高频事件:订单支付、工单结算、押金冻结/释放/没收、提现。
|
||||
- 先接高频且核心的事件:订单支付、工单结算、押金冻结/释放/没收、提现,以及公司人工支出的 `approved / reimbursed`。
|
||||
- 写总账但不改现有钱包逻辑。
|
||||
- 每日对账:钱包余额 vs 总账余额。
|
||||
- 补金额守恒与幂等测试。
|
||||
|
||||
### P1:寄卖、售后与人工成本(1–2 周)
|
||||
### P1:寄卖、售后、充值与结算(1–2 周)
|
||||
|
||||
- 接寄卖发布押金、货款结算、售后欠款/抵扣。
|
||||
- 接人工 case 的人工成本快照(按单/按小时)。
|
||||
- 接寄卖发布押金、货款结算、售后欠款/抵扣、打手充值。
|
||||
- 建 `finance_settlements` / `finance_settlement_items`。
|
||||
- 后台增加凭证簿与试算平衡。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# order_site 项目全量分析报告(2026-10-17)
|
||||
|
||||
> 状态:**现行**
|
||||
> 代码基线:`main @ 6850bc64`(工作区干净)
|
||||
> 状态:**参考(2026-10-17 全量分析快照)**。架构与风险结论可参考;财务、备用金、显示名等实施状态以 [README.md](README.md) 为准。
|
||||
> 代码基线:`main @ 6850bc64`(分析当时;当前基线见 README)
|
||||
> 复核范围:后端、前端、数据库迁移、部署脚本、现行文档
|
||||
> 复核方式:源码走查 + 类型检查 + Lint + 格式检查 + 全量测试 + 生产构建 + 2026-10-17 生产库只读聚合(`default_transaction_read_only=on`)
|
||||
> 说明:本次没有做全量数据克隆;历史数据量引用自生产库只读聚合,已在文中标注。
|
||||
@@ -156,7 +156,7 @@ pending_material → unassigned → open → in_progress
|
||||
- 91 无载体订单 42,391、PDD 2,258、抖音 8;全部 `claim_status=active`。
|
||||
- `manual_dispatch` 任务全库只有 6 条,说明人工块基本没进任务系统。
|
||||
- 已验收工单的 `orders.order_status` 仍为 `paid`,订单主状态不能代表履约完成。
|
||||
- 最近 30 天已验收工单:91 报酬/订单金额约 86.0%、PDD 80.0%、抖音 96.5%。**口径已确认(2026-10-17):`total_amount` 即用户付款金额、计收入,打手报酬计支出(总额法)**;91 的 `total_amount` 与快手 `paymentFen` 数据核验 100% 一致。详见 [载体判定与成本中心落地-复核与草案-2026-10-17.md](载体判定与成本中心落地-复核与草案-2026-10-17.md) §九。
|
||||
- 最近 30 天已验收工单:91 报酬/订单金额约 86.0%、PDD 80.0%、抖音 96.5%。**口径已确认(2026-10-17):`total_amount` 即用户付款金额、计收入,打手报酬计支出(总额法)**;91 的 `total_amount` 与快手 `paymentFen` 数据核验 100% 一致。详见 [载体判定与成本中心落地-复核与草案.md](archive/2026-10-17/载体判定与成本中心落地-复核与草案.md) §九。
|
||||
- 同一个商品名对应多个 `sku_code`:「指挥官密钥1个」27 个、「苏尔南钻石100颗」35 个,不能直接按 `sku_code` 做销量统计。
|
||||
- 当前 30 天订单基本是一单一品,但模型必须按订单项设计。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user