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

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