diff --git a/apps/backend/src/db/migrations/102_product_catalog.sql b/apps/backend/src/db/migrations/102_product_catalog.sql new file mode 100644 index 00000000..c4eb6d99 --- /dev/null +++ b/apps/backend/src/db/migrations/102_product_catalog.sql @@ -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; diff --git a/apps/backend/src/db/migrations/103_cost_center.sql b/apps/backend/src/db/migrations/103_cost_center.sql new file mode 100644 index 00000000..ef3843fa --- /dev/null +++ b/apps/backend/src/db/migrations/103_cost_center.sql @@ -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=旧规则金额'; diff --git a/apps/backend/src/db/migrations/104_cost_center_bootstrap_fix.sql b/apps/backend/src/db/migrations/104_cost_center_bootstrap_fix.sql new file mode 100644 index 00000000..b8d08a83 --- /dev/null +++ b/apps/backend/src/db/migrations/104_cost_center_bootstrap_fix.sql @@ -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; diff --git a/apps/backend/src/db/migrations/105_product_mapping_match_keys.sql b/apps/backend/src/db/migrations/105_product_mapping_match_keys.sql new file mode 100644 index 00000000..09ecbaf4 --- /dev/null +++ b/apps/backend/src/db/migrations/105_product_mapping_match_keys.sql @@ -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 名称系列,用于数量系列匹配'; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 68a3027d..0f54400b 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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) diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts new file mode 100644 index 00000000..ad9a6c78 --- /dev/null +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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, + }), + }), +) + +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, + }), + }), +) + +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, + }), + }), +) + +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 diff --git a/apps/backend/src/services/admin/admin-cost-center-service.test.ts b/apps/backend/src/services/admin/admin-cost-center-service.test.ts new file mode 100644 index 00000000..87749da5 --- /dev/null +++ b/apps/backend/src/services/admin/admin-cost-center-service.test.ts @@ -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([ + [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, + ) +}) diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts new file mode 100644 index 00000000..11fde248 --- /dev/null +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -0,0 +1,3183 @@ +import { query, withTransaction } from '../../db/client.js' +import type { AdminSession } from './admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' +import { resolveSkuNameQuantity } from '../product-match/pricing.js' + +const PRODUCT_STATUSES = new Set(['active', 'archived']) +const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual']) +const MAPPING_SOURCES = new Set(['manual', 'import', 'rule']) +const MAPPING_MATCH_TYPES = new Set([ + 'sku_code', + 'sku_code_name', + 'sku_code_series', + 'sku_name_series', +]) +const COST_TYPES = new Set([ + 'auto_purchase', + 'work_order_reward', + 'manual_service', + 'platform_fee', + 'payment_fee', + 'after_sales_loss', + 'shipping_other', + 'other', +]) +const CARRIER_TYPES = new Set(['affiliate_dash', 'work_order', 'manual', 'shared']) +const PRICING_MODES = new Set([ + 'per_item', + 'per_order', + 'per_hour', + 'percent_of_revenue', + 'fixed_daily', +]) +const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule']) +const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import']) +const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired']) +const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored']) +const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida'] +const PRODUCT_CATEGORY_JOINS = ` + LEFT JOIN LATERAL ( + SELECT wc.id, wc.name, wc.parent_id + FROM work_categories wc + WHERE wc.name = p.category + ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC + LIMIT 1 + ) c ON TRUE + LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id) +` + +type ProductRow = { + [column: string]: unknown + id: number + canonical_sku: string + canonical_name: string + category: string + unit_name: string + status: string + root_category_id: number | null + root_category_name: string + sub_category_id: number | null + sub_category_name: string + attributes_json: unknown + created_at: string + updated_at: string + mapping_count?: number + cost_version_count?: number +} + +type ProductCategoryStatRow = { + category_id: number | null + name: string | null + parent_id: number | null + total: number +} + +type WorkCategoryRow = { + id: number + name: string + parent_id: number | null +} + +type ProductCategoryNode = { + categoryId: number + name: string + total: number + directTotal: number + children: Array<{ + categoryId: number + name: string + total: number + }> +} + +type MappingRow = { + [column: string]: unknown + id: number + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name: string + platform_sku_name_key: string + match_type: string + canonical_product_id: number + canonical_sku: string + canonical_name: string + quantity_multiplier: number + confidence: string + source: string + enabled: boolean + note: string + created_at: string + updated_at: string +} + +type CostItemRow = { + [column: string]: unknown + id: number + code: string + name: string + cost_type: string + carrier_type: string + pricing_mode: string + unit_name: string + currency: string + status: string + note: string + created_at: string + updated_at: string + version_count?: number +} + +type CostVersionRow = { + [column: string]: unknown + id: number + cost_item_id: number + cost_item_code: string + cost_item_name: string + pricing_mode: string + scope_type: string + provider: string + shop_id: string + canonical_product_id: number | null + canonical_sku: string | null + canonical_name: string | null + work_product_rule_id: number | null + amount_fen: number + rate_bps: number + effective_from: string + effective_to: string | null + source: string + status: string + change_reason: string + created_by_admin_user_id: number | null + approved_by_admin_user_id: number | null + created_at: string + updated_at: string +} + +type CostExceptionRow = { + [column: string]: unknown + id: number + subject_type: string + subject_id: number + order_item_id: number | null + work_order_id: number | null + carrier_type: string + reason: string + status: string + detail_json: unknown + created_at: string + resolved_at: string | null + resolved_by_admin_user_id: number | null +} + +type OrderItemCostContext = { + orderItemId: number + orderId: number + provider: string + shopId: string + skuCode: string + skuName: string + normalizedSkuName: string + normalizedSkuSeries: string + quantity: number + normalizedQuantity: number + canonicalProductId: number | null + revenueFen: number + workOrderId: number | null + workProductRuleId: number | null + hasAffiliateDashTask: boolean +} + +type 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 +} + +type CostExceptionDraft = { + reason: string + message: string + detail: Record +} + +type MissingCostItem = { + costItemId: number + costItemCode: string + costItemName: string + carrierType: string +} + +type CostResolution = { + lines: CostLine[] + missingCostItems: MissingCostItem[] + applicableCostItemCount: number +} + +type 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: ReturnType | null + product: ReturnType | null + lines: CostLine[] + missingCostItems: MissingCostItem[] + totalCostFen: number + exceptions: CostExceptionDraft[] +} + +export async function getAdminCostCenterOverview() { + const result = await query<{ + product_count: number + mapping_count: number + cost_item_count: number + active_version_count: number + open_exception_count: number + unmapped_order_item_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM product_catalog WHERE status = 'active') AS product_count, + (SELECT COUNT(*)::int FROM channel_product_mappings WHERE enabled = TRUE) AS mapping_count, + (SELECT COUNT(*)::int FROM cost_items WHERE status = 'active') AS cost_item_count, + (SELECT COUNT(*)::int FROM cost_versions WHERE status = 'active') AS active_version_count, + (SELECT COUNT(*)::int FROM cost_exceptions WHERE status = 'open') AS open_exception_count, + (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id IS NULL) AS unmapped_order_item_count + `, + ) + const row = result.rows[0] + return { + productCount: toNumber(row?.product_count), + mappingCount: toNumber(row?.mapping_count), + costItemCount: toNumber(row?.cost_item_count), + activeVersionCount: toNumber(row?.active_version_count), + openExceptionCount: toNumber(row?.open_exception_count), + unmappedOrderItemCount: toNumber(row?.unmapped_order_item_count), + } +} + +export async function getAdminCostCenterOptions() { + const [productsResult, costItemsResult, rulesResult] = await Promise.all([ + query<{ id: number; canonical_sku: string; canonical_name: string; unit_name: string }>( + ` + SELECT id, canonical_sku, canonical_name, unit_name + FROM product_catalog + WHERE status = 'active' + ORDER BY canonical_name, id + LIMIT 5000 + `, + ), + query<{ id: number; code: string; name: string; pricing_mode: string; carrier_type: string }>( + ` + SELECT id, code, name, pricing_mode, carrier_type + FROM cost_items + WHERE status = 'active' + ORDER BY name, id + LIMIT 1000 + `, + ), + query<{ + id: number + rule_key: string + product_name: string + sku_code: string + provider: string + }>( + ` + SELECT id, rule_key, product_name, sku_code, provider + FROM work_product_rules + WHERE enabled = TRUE + ORDER BY sort_order, id + LIMIT 1000 + `, + ), + ]) + + return { + providers: PROVIDERS, + products: productsResult.rows.map((row) => ({ + id: Number(row.id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + unitName: row.unit_name, + })), + costItems: costItemsResult.rows.map((row) => ({ + id: Number(row.id), + code: row.code, + name: row.name, + pricingMode: row.pricing_mode, + carrierType: row.carrier_type, + })), + rules: rulesResult.rows.map((row) => ({ + id: Number(row.id), + ruleKey: row.rule_key, + productName: row.product_name, + skuCode: row.sku_code, + provider: row.provider, + })), + } +} + +export async function listAdminProducts(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const baseParams: unknown[] = [] + const baseClauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + baseParams.push(`%${keyword}%`) + baseClauses.push( + `(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`, + ) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') + baseParams.push(status) + baseClauses.push(`p.status = $${baseParams.length}`) + } + + const categoryId = normalizeCategoryFilter(payload.categoryId ?? payload.category_id) + const rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id) + const listParams = [...baseParams] + const listClauses = [...baseClauses] + if (categoryId === -1) { + listClauses.push('c.id IS NULL') + } else if (categoryId) { + listParams.push(categoryId) + listClauses.push(`c.id = $${listParams.length}`) + } else if (rootCategoryId === -1) { + listClauses.push('c.id IS NULL') + } else if (rootCategoryId) { + listParams.push(rootCategoryId) + listClauses.push(`COALESCE(c.parent_id, c.id) = $${listParams.length}`) + } + + const baseWhere = baseClauses.length ? `WHERE ${baseClauses.join(' AND ')}` : '' + const where = listClauses.length ? `WHERE ${listClauses.join(' AND ')}` : '' + const pageParams = [...listParams, pageSize, offset] + + const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([ + query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`, + listParams, + ), + query( + ` + SELECT + p.*, + CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, + CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, + root.id AS root_category_id, + root.name AS root_category_name, + (SELECT COUNT(*)::int FROM channel_product_mappings m WHERE m.canonical_product_id = p.id) AS mapping_count, + (SELECT COUNT(*)::int FROM cost_versions v WHERE v.canonical_product_id = p.id AND v.status = 'active') AS cost_version_count + FROM product_catalog p + ${PRODUCT_CATEGORY_JOINS} + ${where} + ORDER BY p.status ASC, p.canonical_name ASC, p.id ASC + LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} + `, + pageParams, + ), + query( + ` + SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total + FROM product_catalog p + ${PRODUCT_CATEGORY_JOINS} + ${baseWhere} + GROUP BY c.id, c.name, c.parent_id + `, + baseParams, + ), + query( + ` + SELECT id, name, parent_id + FROM work_categories + ORDER BY sort_order ASC, id ASC + `, + ), + ]) + + const categoryCounts = new Map() + let uncategorizedTotal = 0 + for (const row of categoryStatResult.rows) { + const total = toNumber(row.total) + if (!row.category_id) { + uncategorizedTotal += total + continue + } + categoryCounts.set(Number(row.category_id), total) + } + + const categoryTree: ProductCategoryNode[] = [] + const rootById = new Map() + for (const category of categoryResult.rows) { + if (category.parent_id) continue + const categoryId = Number(category.id) + const total = categoryCounts.get(categoryId) || 0 + const node: ProductCategoryNode = { + categoryId, + name: category.name, + total, + directTotal: total, + children: [], + } + rootById.set(categoryId, node) + categoryTree.push(node) + } + for (const category of categoryResult.rows) { + if (!category.parent_id) continue + const parent = rootById.get(Number(category.parent_id)) + if (!parent) continue + const total = categoryCounts.get(Number(category.id)) || 0 + parent.children.push({ + categoryId: Number(category.id), + name: category.name, + total, + }) + parent.total += total + } + + return { + items: result.rows.map(mapProduct), + categoryTree, + uncategorizedTotal, + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminProduct(payload: JsonObject, session: AdminSession) { + const canonicalSku = normalizeRequiredText(payload.canonicalSku, '标准 SKU', 64) + if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) { + throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', { + statusCode: 400, + errorCode: 'cost_product_sku_invalid', + }) + } + const canonicalName = normalizeRequiredText( + payload.canonicalName ?? payload.canonical_name, + '商品名称', + 120, + ) + const category = normalizeOptionalText(payload.category, 64) + const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效') + const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson) + const now = nowIso() + + const result = await query( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7) + ON CONFLICT (canonical_sku) DO NOTHING + RETURNING * + `, + [canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('标准 SKU 已存在', { + statusCode: 409, + errorCode: 'cost_product_sku_exists', + }) + } + return { product: mapProduct(inserted) } +} + +export async function updateAdminProduct(productId: number | string, payload: JsonObject) { + const id = normalizeId(productId, '商品 ID') + const current = await getProductRow(id) + const canonicalName = normalizeRequiredText( + payload.canonicalName ?? payload.canonical_name ?? current.canonical_name, + '商品名称', + 120, + ) + const category = + payload.category === undefined ? current.category : normalizeOptionalText(payload.category, 64) + const unitName = + payload.unitName === undefined && payload.unit_name === undefined + ? current.unit_name + : normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' + const status = + payload.status === undefined + ? current.status + : normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效') + const attributes = + payload.attributes === undefined && payload.attributesJson === undefined + ? parseJsonObject(current.attributes_json) + : normalizeJsonObject(payload.attributes ?? payload.attributesJson) + + const result = await query( + ` + UPDATE product_catalog + SET canonical_name = $1, category = $2, unit_name = $3, status = $4, + attributes_json = $5::jsonb, updated_at = $6 + WHERE id = $7 + RETURNING * + `, + [canonicalName, category, unitName, status, JSON.stringify(attributes), nowIso(), id], + ) + return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) } +} + +type MappingProductGroupRow = { + canonical_product_id: number + canonical_sku: string + canonical_name: string + category: string + unit_name: string + status: string +} + +export async function listAdminChannelMappings(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(m.platform_sku_code ILIKE $${params.length} OR m.platform_sku_name ILIKE $${params.length} + OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length})`, + ) + } + const provider = text(payload.provider) + if (provider) { + params.push(normalizeProviderKey(provider)) + clauses.push(`m.provider = $${params.length}`) + } + const canonicalProductId = normalizeOptionalId(payload.canonicalProductId) + if (canonicalProductId) { + params.push(canonicalProductId) + clauses.push(`m.canonical_product_id = $${params.length}`) + } + if (payload.enabled !== undefined && payload.enabled !== '') { + params.push(normalizeBoolean(payload.enabled)) + clauses.push(`m.enabled = $${params.length}`) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const groupParams = [...params, pageSize, offset] + const [groupResult, statsResult] = await Promise.all([ + query( + ` + SELECT + p.id AS canonical_product_id, + p.canonical_sku, + p.canonical_name, + p.category, + p.unit_name, + p.status + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + GROUP BY + p.id, + p.canonical_sku, + p.canonical_name, + p.category, + p.unit_name, + p.status + ORDER BY p.canonical_name ASC, p.id ASC + LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length} + `, + groupParams, + ), + query<{ total_count: number; mapping_total: number }>( + ` + SELECT + COUNT(DISTINCT p.id)::int AS total_count, + COUNT(*)::int AS mapping_total + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + `, + params, + ), + ]) + + const groups = groupResult.rows + const totalCount = toNumber(statsResult.rows[0]?.total_count) + const mappingTotal = toNumber(statsResult.rows[0]?.mapping_total) + if (groups.length === 0) { + return { + groups: [], + pagination: { page, pageSize, total: totalCount, mappingTotal }, + } + } + + const productIds = groups.map((row) => Number(row.canonical_product_id)) + const mappingParams = [...params, productIds] + const mappingsResult = await query( + ` + SELECT + m.*, + p.canonical_sku, + p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + ${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[]) + ORDER BY + p.canonical_name ASC, + p.id ASC, + m.provider ASC, + m.shop_id ASC, + m.match_type ASC, + m.platform_sku_code ASC, + m.id DESC + `, + mappingParams, + ) + + const mappingsByProduct = new Map[]>() + for (const row of mappingsResult.rows) { + const productId = Number(row.canonical_product_id) + const list = mappingsByProduct.get(productId) || [] + list.push(mapMapping(row)) + mappingsByProduct.set(productId, list) + } + + return { + groups: groups.map((row) => ({ + canonicalProductId: Number(row.canonical_product_id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + category: row.category || '', + unitName: row.unit_name || '件', + status: row.status, + mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [], + })), + pagination: { + page, + pageSize, + total: totalCount, + mappingTotal, + }, + } +} + +export async function createAdminChannelMapping(payload: JsonObject, _session: AdminSession) { + const provider = normalizeProviderKey(normalizeRequiredText(payload.provider, '来源', 64)) + assertKnownProvider(provider) + const shopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const matchType = normalizeMappingMatchType(payload.matchType ?? payload.match_type) + const platformSkuCode = normalizeOptionalText( + payload.platformSkuCode ?? payload.platform_sku_code, + 120, + ) + const platformSkuName = normalizeOptionalText( + payload.platformSkuName ?? payload.platform_sku_name, + 200, + ) + if (!platformSkuCode && matchType !== 'sku_name_series') { + throw createHttpError('该匹配方式必须填写平台 SKU', { + statusCode: 400, + errorCode: 'cost_mapping_sku_required', + }) + } + const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) + if (matchType !== 'sku_code' && !platformSkuNameKey) { + throw createHttpError('该匹配方式必须填写平台 SKU 名称', { + statusCode: 400, + errorCode: 'cost_mapping_sku_name_required', + }) + } + const canonicalProductId = normalizeId( + payload.canonicalProductId ?? payload.canonical_product_id, + '标准商品 ID', + ) + await getProductRow(canonicalProductId) + const quantityMultiplier = normalizePositiveInteger( + payload.quantityMultiplier ?? payload.quantity_multiplier, + 1, + ) + const confidence = normalizeStatus( + payload.confidence, + MAPPING_CONFIDENCES, + 'manual', + '映射置信度无效', + ) + const source = normalizeStatus(payload.source, MAPPING_SOURCES, 'manual', '映射来源无效') + const enabled = payload.enabled === undefined ? true : normalizeBoolean(payload.enabled) + const note = normalizeOptionalText(payload.note, 500) + const now = nowIso() + + const result = await query( + ` + INSERT INTO channel_product_mappings ( + provider, shop_id, platform_sku_code, platform_sku_name, + platform_sku_name_key, match_type, + canonical_product_id, quantity_multiplier, confidence, source, + enabled, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13) + ON CONFLICT ( + provider, shop_id, platform_sku_code, match_type, platform_sku_name_key + ) DO NOTHING + RETURNING * + `, + [ + provider, + shopId, + platformSkuCode, + platformSkuName, + platformSkuNameKey, + matchType, + canonicalProductId, + quantityMultiplier, + confidence, + source, + enabled, + note, + now, + ], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { + statusCode: 409, + errorCode: 'cost_mapping_exists', + }) + } + const mapping = await getMappingRow(Number(inserted.id)) + return { mapping: mapMapping(mapping) } +} + +export async function updateAdminChannelMapping(mappingId: number | string, payload: JsonObject) { + const id = normalizeId(mappingId, '映射 ID') + const current = await getMappingRow(id) + const provider = normalizeProviderKey( + normalizeRequiredText(payload.provider ?? current.provider, '来源', 64), + ) + assertKnownProvider(provider) + const shopId = + payload.shopId === undefined && payload.shop_id === undefined + ? current.shop_id + : normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const matchType = normalizeMappingMatchType( + payload.matchType ?? payload.match_type ?? current.match_type, + ) + const platformSkuCode = normalizeOptionalText( + payload.platformSkuCode ?? payload.platform_sku_code ?? current.platform_sku_code, + 120, + ) + if (!platformSkuCode && matchType !== 'sku_name_series') { + throw createHttpError('该匹配方式必须填写平台 SKU', { + statusCode: 400, + errorCode: 'cost_mapping_sku_required', + }) + } + const platformSkuName = + payload.platformSkuName === undefined && payload.platform_sku_name === undefined + ? current.platform_sku_name + : normalizeOptionalText(payload.platformSkuName ?? payload.platform_sku_name, 200) + const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) + if (matchType !== 'sku_code' && !platformSkuNameKey) { + throw createHttpError('该匹配方式必须填写平台 SKU 名称', { + statusCode: 400, + errorCode: 'cost_mapping_sku_name_required', + }) + } + const canonicalProductId = normalizeId( + payload.canonicalProductId ?? payload.canonical_product_id ?? current.canonical_product_id, + '标准商品 ID', + ) + await getProductRow(canonicalProductId) + await assertMappingKeyAvailable( + provider, + shopId, + platformSkuCode, + matchType, + platformSkuNameKey, + id, + ) + const quantityMultiplier = normalizePositiveInteger( + payload.quantityMultiplier ?? payload.quantity_multiplier ?? current.quantity_multiplier, + 1, + ) + const confidence = normalizeStatus( + payload.confidence, + MAPPING_CONFIDENCES, + current.confidence, + '映射置信度无效', + ) + const source = normalizeStatus(payload.source, MAPPING_SOURCES, current.source, '映射来源无效') + const enabled = + payload.enabled === undefined ? current.enabled : normalizeBoolean(payload.enabled) + const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) + + const result = await query( + ` + UPDATE channel_product_mappings + SET provider = $1, shop_id = $2, platform_sku_code = $3, platform_sku_name = $4, + platform_sku_name_key = $5, match_type = $6, + canonical_product_id = $7, quantity_multiplier = $8, confidence = $9, + source = $10, enabled = $11, note = $12, updated_at = $13 + WHERE id = $14 + RETURNING * + `, + [ + provider, + shopId, + platformSkuCode, + platformSkuName, + platformSkuNameKey, + matchType, + canonicalProductId, + quantityMultiplier, + confidence, + source, + enabled, + note, + nowIso(), + id, + ], + ) + const updated = result.rows[0] + if (!updated) { + throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) + } + const mapping = await getMappingRow(id) + return { mapping: mapMapping(mapping) } +} + +export async function backfillAdminProductMappings(payload: JsonObject = {}) { + const provider = normalizeProviderKey(text(payload.provider)) + const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000))) + const all = payload.all === true || payload.all === 'true' + const maxBatches = all ? 40 : 1 + let updatedCount = 0 + let batches = 0 + let lastId = 0 + + const mappingsResult = await query( + ` + SELECT + m.provider, + m.shop_id, + m.platform_sku_code, + m.platform_sku_name_key, + m.match_type, + m.canonical_product_id + FROM channel_product_mappings m + JOIN product_catalog p + ON p.id = m.canonical_product_id + AND p.status = 'active' + WHERE m.enabled = TRUE + `, + ) + const lookup = buildMappingLookup(mappingsResult.rows) + + for (let batch = 0; batch < maxBatches; batch += 1) { + const itemsResult = await query( + ` + SELECT + oi.id, + o.provider, + o.shop_id, + oi.sku_code, + oi.normalized_sku_name, + oi.normalized_sku_series + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.canonical_product_id IS NULL + AND oi.id > $1 + AND ($2 = '' OR o.provider = $2) + ORDER BY oi.id + LIMIT $3 + `, + [lastId, provider, limit], + ) + if (itemsResult.rows.length === 0) break + + lastId = Number(itemsResult.rows[itemsResult.rows.length - 1]?.id || lastId) + const ids: number[] = [] + const productIds: number[] = [] + for (const row of itemsResult.rows) { + const productId = resolveMappingProductId(lookup, row) + if (!productId) continue + ids.push(Number(row.id)) + productIds.push(productId) + } + + if (ids.length > 0) { + const updated = await query( + ` + UPDATE order_items oi + SET canonical_product_id = input.canonical_product_id, + updated_at = NOW() + FROM unnest($1::bigint[], $2::bigint[]) + AS input(id, canonical_product_id) + WHERE oi.id = input.id + AND oi.canonical_product_id IS NULL + `, + [ids, productIds], + ) + updatedCount += updated.rowCount || 0 + } + batches += 1 + if (!all || itemsResult.rows.length < limit) break + } + + const remainingResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.canonical_product_id IS NULL + AND ($1 = '' OR o.provider = $1) + `, + [provider], + ) + return { + updatedCount, + limit, + batches, + remainingCount: Number(remainingResult.rows[0]?.total || 0), + } +} + +type MappingLookupRow = { + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name_key: string + match_type: string + canonical_product_id: number +} + +export type MappingLookupItemRow = { + id: number + provider: string + shop_id: string + sku_code: string + normalized_sku_name: string | null + normalized_sku_series: string | null +} + +export type MappingLookup = { + byKey: Map +} + +function buildMappingLookup(rows: MappingLookupRow[]): MappingLookup { + const byKey = new Map() + for (const row of rows) { + const key = mappingKey( + row.provider, + row.shop_id, + row.platform_sku_code, + row.match_type, + row.platform_sku_name_key, + ) + if (!byKey.has(key)) byKey.set(key, Number(row.canonical_product_id)) + } + return { byKey } +} + +export function resolveMappingProductId( + lookup: MappingLookup, + item: MappingLookupItemRow, +): number | null { + const shopId = item.shop_id || '' + const nameKey = item.normalized_sku_name || normalizeMatchText(item.sku_code) + const seriesKey = item.normalized_sku_series || normalizeSkuSeriesEquivalence(item.sku_code) + const candidates = [ + mappingKey(item.provider, shopId, item.sku_code, 'sku_code', ''), + mappingKey(item.provider, '', item.sku_code, 'sku_code', ''), + mappingKey(item.provider, shopId, item.sku_code, 'sku_code_name', nameKey), + mappingKey(item.provider, '', item.sku_code, 'sku_code_name', nameKey), + mappingKey(item.provider, shopId, item.sku_code, 'sku_code_series', seriesKey), + mappingKey(item.provider, '', item.sku_code, 'sku_code_series', seriesKey), + mappingKey(item.provider, shopId, '', 'sku_name_series', seriesKey), + mappingKey(item.provider, '', '', 'sku_name_series', seriesKey), + ] + for (const key of candidates) { + const productId = lookup.byKey.get(key) + if (productId) return productId + } + return null +} + +export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}) { + const limit = Math.min(50000, Math.max(1, Math.trunc(Number(payload.limit) || 20000))) + const all = payload.all === true || payload.all === 'true' + const maxBatches = all ? 40 : 1 + let updatedCount = 0 + let batches = 0 + + for (let batch = 0; batch < maxBatches; batch += 1) { + const result = await query<{ + id: number + sku_name: string + sku_code: string + normalized_sku_name: string | null + normalized_sku_series: string | null + normalized_quantity: number | null + }>( + ` + SELECT id, sku_name, sku_code, normalized_sku_name, normalized_sku_series, + normalized_quantity + FROM order_items + WHERE normalized_quantity IS NULL + OR normalized_sku_name IS NULL + OR normalized_sku_series IS NULL + ORDER BY id + LIMIT $1 + `, + [limit], + ) + if (result.rows.length === 0) break + + const ids: number[] = [] + const quantities: number[] = [] + const skuNames: string[] = [] + const skuSeries: string[] = [] + for (const row of result.rows) { + const skuText = row.sku_name || row.sku_code + ids.push(Number(row.id)) + quantities.push(Math.max(1, resolveSkuNameQuantity(skuText))) + skuNames.push(normalizeMatchText(skuText)) + skuSeries.push(normalizeSkuSeriesEquivalence(skuText)) + } + + const updated = await query( + ` + UPDATE order_items oi + SET normalized_quantity = input.quantity, + normalized_sku_name = input.sku_name_key, + normalized_sku_series = input.sku_series_key, + updated_at = NOW() + FROM unnest($1::bigint[], $2::int[], $3::text[], $4::text[]) + AS input(id, quantity, sku_name_key, sku_series_key) + WHERE oi.id = input.id + AND ( + oi.normalized_quantity IS NULL + OR oi.normalized_sku_name IS NULL + OR oi.normalized_sku_series IS NULL + ) + `, + [ids, quantities, skuNames, skuSeries], + ) + updatedCount += updated.rowCount || 0 + batches += 1 + if (!all || result.rows.length < limit) break + } + + const remainingResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM order_items + WHERE normalized_quantity IS NULL + OR normalized_sku_name IS NULL + OR normalized_sku_series IS NULL`, + ) + return { + updatedCount, + limit, + batches, + remainingCount: Number(remainingResult.rows[0]?.total || 0), + } +} + +export async function previewAdminTemplateProductBootstrap() { + const data = await loadTemplateProductBootstrapData() + const plan = buildTemplateMappingPlan(data.evidenceRows) + const templateRuleKeys = new Set(data.templates.map((row) => row.rule_key)) + const candidates = plan.candidates.filter( + (row) => + templateRuleKeys.has(row.ruleKey) && + !data.existingMappingKeys.has( + mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey), + ), + ) + const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id))) + const candidatesByMatchType = countMappingCandidates(candidates) + return { + templateCount: data.templates.length, + productExistingCount: data.templates.length - data.productsToCreate.length, + productCreateCount: data.productsToCreate.length, + mappingCandidateCount: plan.candidates.length, + mappingExistingCount: plan.candidates.length - candidates.length, + mappingCreateCount: candidates.length, + mappingCreateByMatchType: candidatesByMatchType, + evidenceItemCount: plan.evidenceItemCount, + orderItemCoverCount: Math.max(0, plan.evidenceItemCount - plan.unresolvedItemCount), + mappingConflictCount: plan.conflicts.length, + mappingConflictItemCount: plan.unresolvedItemCount, + multiRuleKeyCount: plan.codeConflictKeyCount, + seriesFallbackConflictCount: plan.seriesFallbackConflictCount, + templatesWithoutWorkOrders: data.templates.filter((row) => !usedRuleIds.has(Number(row.id))) + .length, + samples: candidates.slice(0, 30).map((row) => ({ + ruleKey: row.ruleKey, + productName: data.templates.find((item) => item.rule_key === row.ruleKey)?.product_name || '', + provider: row.provider, + shopId: row.shopId, + skuCode: row.skuCode, + skuName: row.skuName, + matchType: row.matchType, + itemCount: row.itemCount, + })), + conflicts: plan.conflicts.slice(0, 20).map((row) => ({ + provider: row.provider, + shopId: row.shopId, + skuCode: row.skuCode, + skuName: row.skuName, + matchType: row.matchType, + itemCount: row.itemCount, + ruleKeys: row.ruleKeys, + })), + } +} + +export async function applyAdminTemplateProductBootstrap( + payload: JsonObject = {}, + _session?: AdminSession, +) { + const repair = payload.repair !== false + const resetResult = repair + ? await resetImportedTemplateProductBootstrap() + : { deletedMappingCount: 0, resetOrderItemCount: 0 } + const data = await loadTemplateProductBootstrapData() + if (data.templates.length === 0) { + throw createHttpError('没有可导入的接单模板', { + statusCode: 400, + errorCode: 'cost_template_bootstrap_empty', + }) + } + + const plan = buildTemplateMappingPlan(data.evidenceRows) + const now = nowIso() + let createdProductCount = 0 + let reusedProductCount = 0 + let createdMappingCount = 0 + let skippedMappingCount = 0 + let mappedOrderItemCount = 0 + + await withTransaction(async (client) => { + const productIdByRuleKey = new Map() + for (const template of data.templates) { + const existing = data.productByRuleKey.get(template.rule_key) + if (existing) { + productIdByRuleKey.set(template.rule_key, existing) + reusedProductCount += 1 + continue + } + + const canonicalSku = buildTemplateProductSku(template.rule_key) + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) + ON CONFLICT (canonical_sku) DO NOTHING + RETURNING id + `, + [ + canonicalSku, + template.product_name, + template.category, + inferTemplateUnitName(template.product_name), + JSON.stringify({ + source: 'work_product_rule', + ruleId: Number(template.id), + ruleKey: template.rule_key, + mappingId: template.mapping_id ? Number(template.mapping_id) : null, + mappingType: template.mapping_type, + skuNick: template.sku_nick, + relSkuId: template.rel_sku_id, + sellerIds: template.seller_ids_json, + unitPriceFen: Number(template.unit_price_fen || 0), + rewardAmountFen: Number(template.reward_amount || 0), + }), + now, + ], + ) + let productId = inserted.rows[0]?.id ? Number(inserted.rows[0].id) : 0 + if (productId) { + createdProductCount += 1 + } else { + const existingResult = await client.query<{ id: number }>( + 'SELECT id FROM product_catalog WHERE canonical_sku = $1 LIMIT 1', + [canonicalSku], + ) + productId = Number(existingResult.rows[0]?.id || 0) + if (productId) reusedProductCount += 1 + } + if (productId) productIdByRuleKey.set(template.rule_key, productId) + } + + for (const row of plan.candidates) { + const productId = productIdByRuleKey.get(row.ruleKey) + if (!productId) continue + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO channel_product_mappings ( + provider, shop_id, platform_sku_code, platform_sku_name, + platform_sku_name_key, match_type, + canonical_product_id, quantity_multiplier, confidence, source, + enabled, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 'rule', 'import', TRUE, $8, $9, $9) + ON CONFLICT ( + provider, shop_id, platform_sku_code, match_type, platform_sku_name_key + ) DO NOTHING + RETURNING id + `, + [ + row.provider, + row.shopId, + row.skuCode, + row.skuName, + row.skuNameKey, + row.matchType, + productId, + `接单模板 ${row.ruleKey} 自动初始化(${mappingMatchTypeLabel(row.matchType)})`, + now, + ], + ) + if (inserted.rows[0]) { + createdMappingCount += 1 + } else { + skippedMappingCount += 1 + } + } + }) + + // 名称/系列键必须先于映射回填;所有批次都放在事务外,避免语句超时。 + await backfillAdminNormalizedQuantities({ limit: 50000, all: true }) + const mappingBackfill = await backfillAdminProductMappings({ limit: 20000, all: true }) + mappedOrderItemCount = mappingBackfill.updatedCount + + return { + templateCount: data.templates.length, + createdProductCount, + reusedProductCount, + createdMappingCount, + skippedMappingCount, + mappedOrderItemCount, + deletedImportedMappingCount: resetResult.deletedMappingCount, + resetOrderItemCount: resetResult.resetOrderItemCount, + mappingConflictCount: plan.conflicts.length, + mappingConflictItemCount: plan.unresolvedItemCount, + } +} + +type TemplateBootstrapTemplateRow = { + id: number + rule_key: string + product_name: string + category: string + unit_price_fen: number + reward_amount: number + mapping_id: number | null + mapping_type: string + sku_nick: string + rel_sku_id: string + seller_ids_json: unknown +} + +export type TemplateBootstrapEvidenceRow = { + provider: string + shop_id: string + sku_code: string + sku_name: string + product_rule_id: number + rule_key: string + item_count: number +} + +export type TemplateMappingMatchType = + | 'sku_code' + | 'sku_code_name' + | 'sku_code_series' + | 'sku_name_series' + +export type TemplateMappingCandidate = { + provider: string + shopId: string + skuCode: string + skuName: string + skuNameKey: string + matchType: TemplateMappingMatchType + ruleId: number + ruleKey: string + itemCount: number +} + +export type TemplateMappingConflict = { + provider: string + shopId: string + skuCode: string + skuName: string + matchType: 'sku_code_name' + itemCount: number + ruleIds: number[] + ruleKeys: string[] +} + +export type TemplateMappingPlan = { + candidates: TemplateMappingCandidate[] + conflicts: TemplateMappingConflict[] + evidenceItemCount: number + unresolvedItemCount: number + codeConflictKeyCount: number + seriesFallbackConflictCount: number +} + +/** + * 将历史工单的直接 rule_id 证据转换为渠道映射候选。 + * + * 编码唯一时直接按编码映射;编码被复用(91 常见)时,只有 + * 编码 + SKU 名称系列 / 编码 + 精确名称唯一才生成映射。 + * 多层仍无法唯一归属的组合进入 conflicts,绝不按“最新工单”猜测。 + */ +export function buildTemplateMappingPlan( + rows: TemplateBootstrapEvidenceRow[], +): TemplateMappingPlan { + const candidates = new Map() + const conflicts = new Map() + const evidenceItemCount = sumItemCount(rows) + let unresolvedItemCount = 0 + let codeConflictKeyCount = 0 + let seriesFallbackConflictCount = 0 + + function addCandidate( + input: Omit & { + itemCount: number + sampleSkuName: string + }, + ) { + const key = candidateKey( + input.provider, + input.shopId, + input.skuCode, + input.matchType, + input.skuNameKey, + ) + const current = candidates.get(key) + if (current) { + current.itemCount += input.itemCount + if (input.itemCount > current.bestSampleCount) { + current.skuName = input.sampleSkuName + current.bestSampleCount = input.itemCount + } + return + } + candidates.set(key, { + provider: input.provider, + shopId: input.shopId, + skuCode: input.skuCode, + skuName: input.sampleSkuName, + skuNameKey: input.skuNameKey, + matchType: input.matchType, + ruleId: input.ruleId, + ruleKey: input.ruleKey, + itemCount: input.itemCount, + bestSampleCount: input.itemCount, + }) + } + + function addConflict(input: { + provider: string + shopId: string + skuCode: string + skuName: string + nameKey: string + itemCount: number + ruleIds: number[] + ruleKeys: string[] + }) { + const key = candidateKey( + input.provider, + input.shopId, + input.skuCode, + 'sku_code_name', + input.nameKey, + ) + const current = conflicts.get(key) + if (current) { + current.itemCount += input.itemCount + current.ruleIds = Array.from(new Set([...current.ruleIds, ...input.ruleIds])).sort( + (left, right) => left - right, + ) + current.ruleKeys = Array.from(new Set([...current.ruleKeys, ...input.ruleKeys])).sort() + return + } + conflicts.set(key, { + provider: input.provider, + shopId: input.shopId, + skuCode: input.skuCode, + skuName: input.skuName, + matchType: 'sku_code_name', + itemCount: input.itemCount, + ruleIds: [...input.ruleIds].sort((left, right) => left - right), + ruleKeys: [...input.ruleKeys].sort(), + }) + } + + const codeGroups = groupRows(rows, (row) => [row.provider, row.shop_id, row.sku_code]) + for (const codeRows of codeGroups.values()) { + const codeRuleIds = uniqueRuleIds(codeRows) + if (codeRuleIds.length === 1) { + const sample = representativeRow(codeRows) + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: '', + matchType: 'sku_code', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(codeRows), + sampleSkuName: sample.sku_name, + }) + continue + } + + codeConflictKeyCount += 1 + const nameGroups = groupRows(codeRows, (row) => [ + normalizeSkuEvidenceName(row.sku_name, row.sku_code), + ]) + for (const nameRows of nameGroups.values()) { + const nameRuleIds = uniqueRuleIds(nameRows) + const sample = representativeRow(nameRows) + const nameKey = normalizeSkuEvidenceName(sample.sku_name, sample.sku_code) + if (nameRuleIds.length === 1) { + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: nameKey, + matchType: 'sku_code_name', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(nameRows), + sampleSkuName: sample.sku_name, + }) + } else { + const itemCount = sumItemCount(nameRows) + unresolvedItemCount += itemCount + addConflict({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + nameKey, + itemCount, + ruleIds: nameRuleIds, + ruleKeys: uniqueRuleKeys(nameRows), + }) + } + } + + const seriesGroups = groupRows(codeRows, (row) => [ + normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), + ]) + for (const seriesRows of seriesGroups.values()) { + const seriesRuleIds = uniqueRuleIds(seriesRows) + if (seriesRuleIds.length !== 1) { + seriesFallbackConflictCount += 1 + continue + } + const sample = representativeRow(seriesRows) + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code), + matchType: 'sku_code_series', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(seriesRows), + sampleSkuName: sample.sku_name, + }) + } + } + + // 商品名系列兜底:仅在同一来源 + 店铺内唯一归属时生成,供后续新编码使用。 + const globalSeriesGroups = groupRows(rows, (row) => [ + row.provider, + row.shop_id, + normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), + ]) + for (const seriesRows of globalSeriesGroups.values()) { + const seriesRuleIds = uniqueRuleIds(seriesRows) + const sample = representativeRow(seriesRows) + const seriesKey = normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code) + if (!seriesKey) continue + if (seriesRuleIds.length !== 1) { + seriesFallbackConflictCount += 1 + continue + } + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: '', + skuName: sample.sku_name, + skuNameKey: seriesKey, + matchType: 'sku_name_series', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(seriesRows), + sampleSkuName: sample.sku_name, + }) + } + + return { + candidates: Array.from(candidates.values()) + .map(({ bestSampleCount: _bestSampleCount, ...row }) => row) + .sort(compareMappingCandidates), + conflicts: Array.from(conflicts.values()).sort( + (left, right) => + right.itemCount - left.itemCount || + left.provider.localeCompare(right.provider) || + left.shopId.localeCompare(right.shopId) || + left.skuCode.localeCompare(right.skuCode), + ), + evidenceItemCount, + unresolvedItemCount, + codeConflictKeyCount, + seriesFallbackConflictCount, + } +} + +function countMappingCandidates(rows: TemplateMappingCandidate[]) { + const counts: Record = { + sku_code: 0, + sku_code_name: 0, + sku_code_series: 0, + sku_name_series: 0, + } + for (const row of rows) counts[row.matchType] += 1 + return counts +} + +function groupRows(rows: T[], keyOf: (row: T) => Array) { + const groups = new Map() + for (const row of rows) { + const key = keyOf(row).map(String).join('\u0000') + const list = groups.get(key) + if (list) list.push(row) + else groups.set(key, [row]) + } + return groups +} + +function uniqueRuleIds(rows: TemplateBootstrapEvidenceRow[]) { + return Array.from(new Set(rows.map((row) => Number(row.product_rule_id)))).sort( + (left, right) => left - right, + ) +} + +function uniqueRuleKeys(rows: TemplateBootstrapEvidenceRow[]) { + return Array.from(new Set(rows.map((row) => row.rule_key).filter(Boolean))).sort() +} + +function sumItemCount(rows: TemplateBootstrapEvidenceRow[]) { + return rows.reduce((sum, row) => sum + Math.max(0, Number(row.item_count) || 0), 0) +} + +function representativeRow(rows: TemplateBootstrapEvidenceRow[]) { + return rows.reduce((best, row) => (Number(row.item_count) > Number(best.item_count) ? row : best)) +} + +function normalizeSkuEvidenceName(skuName: string, skuCode: string) { + return normalizeMatchText(skuName || skuCode) +} + +function normalizeSkuEvidenceSeries(skuName: string, skuCode: string) { + return normalizeSkuSeriesEquivalence(skuName || skuCode) +} + +function compareMappingCandidates(left: TemplateMappingCandidate, right: TemplateMappingCandidate) { + const typeOrder: Record = { + sku_code: 1, + sku_code_name: 2, + sku_code_series: 3, + sku_name_series: 4, + } + return ( + right.itemCount - left.itemCount || + typeOrder[left.matchType] - typeOrder[right.matchType] || + left.provider.localeCompare(right.provider) || + left.shopId.localeCompare(right.shopId) || + left.skuCode.localeCompare(right.skuCode) || + left.skuNameKey.localeCompare(right.skuNameKey) + ) +} + +async function resetImportedTemplateProductBootstrap() { + let resetOrderItemCount = 0 + for (let batch = 0; batch < 20; batch += 1) { + const result = await query<{ id: number }>( + ` + WITH candidates AS ( + SELECT oi.id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN channel_product_mappings m + ON m.provider = o.provider + AND (m.shop_id = '' OR m.shop_id = o.shop_id) + AND oi.canonical_product_id = m.canonical_product_id + AND m.source = 'import' + AND m.note LIKE '接单模板 %自动初始化%' + AND ( + (m.match_type = 'sku_code' AND m.platform_sku_code = oi.sku_code) + OR ( + m.match_type = 'sku_code_name' + AND m.platform_sku_code = oi.sku_code + AND m.platform_sku_name_key = oi.normalized_sku_name + ) + OR ( + m.match_type = 'sku_code_series' + AND m.platform_sku_code = oi.sku_code + AND m.platform_sku_name_key = oi.normalized_sku_series + ) + OR ( + m.match_type = 'sku_name_series' + AND m.platform_sku_name_key = oi.normalized_sku_series + ) + ) + WHERE oi.canonical_product_id IS NOT NULL + LIMIT 20000 + ) + UPDATE order_items oi + SET canonical_product_id = NULL, + updated_at = NOW() + FROM candidates c + WHERE oi.id = c.id + RETURNING oi.id + `, + ) + resetOrderItemCount += result.rowCount || 0 + if ((result.rowCount || 0) < 20000) break + } + const deleted = await query( + ` + DELETE FROM channel_product_mappings + WHERE source = 'import' + AND note LIKE '接单模板 %自动初始化%' + `, + ) + return { deletedMappingCount: deleted.rowCount || 0, resetOrderItemCount } +} + +async function loadTemplateProductBootstrapData() { + const [templatesResult, evidenceResult, productsResult, mappingsResult] = await Promise.all([ + query( + ` + SELECT + r.id, + r.rule_key, + r.product_name, + COALESCE(c.name, '') AS category, + r.unit_price_fen, + r.reward_amount, + m.id AS mapping_id, + COALESCE(m.mapping_type, '') AS mapping_type, + COALESCE(m.sku_nick, '') AS sku_nick, + COALESCE(m.rel_sku_id, '') AS rel_sku_id, + COALESCE(m.seller_ids_json, '[]'::jsonb) AS seller_ids_json + FROM work_product_rules r + LEFT JOIN work_categories c ON c.id = r.category_id + LEFT JOIN LATERAL ( + SELECT wm.* + FROM work_product_rule_mappings wm + WHERE wm.rule_id = r.id AND wm.enabled = TRUE + ORDER BY wm.id + LIMIT 1 + ) m ON TRUE + WHERE r.enabled = TRUE + ORDER BY r.id + `, + ), + query( + ` + SELECT + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + wo.product_rule_id, + r.rule_key, + COUNT(DISTINCT oi.id)::int AS item_count + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN work_orders wo ON wo.order_item_id = oi.id + JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE + WHERE wo.product_rule_id IS NOT NULL + GROUP BY + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + wo.product_rule_id, + r.rule_key + ORDER BY o.provider, o.shop_id, oi.sku_code, oi.sku_name, wo.product_rule_id + `, + ), + query<{ id: number; canonical_sku: string; canonical_name: string }>( + 'SELECT id, canonical_sku, canonical_name FROM product_catalog', + ), + query<{ + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name_key: string + match_type: string + }>( + `SELECT provider, shop_id, platform_sku_code, platform_sku_name_key, match_type + FROM channel_product_mappings`, + ), + ]) + + const templates = templatesResult.rows + const productByCanonicalSku = new Map( + productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]), + ) + const productByCanonicalName = new Map() + for (const row of productsResult.rows) { + const key = normalizeProductNameKey(row.canonical_name) + if (key && !productByCanonicalName.has(key)) { + productByCanonicalName.set(key, Number(row.id)) + } + } + const productByRuleKey = new Map() + const productsToCreate: TemplateBootstrapTemplateRow[] = [] + for (const template of templates) { + const existing = + productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) || + productByCanonicalName.get(normalizeProductNameKey(template.product_name)) + if (existing) { + productByRuleKey.set(template.rule_key, existing) + } else { + productsToCreate.push(template) + } + } + + return { + templates, + evidenceRows: evidenceResult.rows, + productsToCreate, + productByRuleKey, + existingMappingKeys: new Set( + mappingsResult.rows.map((row) => + mappingKey( + row.provider, + row.shop_id, + row.platform_sku_code, + row.match_type, + row.platform_sku_name_key, + ), + ), + ), + } +} + +export function buildTemplateProductSku(ruleKey: string) { + return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64) +} + +export function inferTemplateUnitName(productName: string) { + const name = String(productName || '').trim() + if (/钻石|颗/.test(name)) return '颗' + if (/枪|把/.test(name)) return '把' + if (/套/.test(name)) return '套' + if (/密钥|秘钥/.test(name)) return '个' + return '件' +} + +function normalizeProductNameKey(value: unknown) { + return String(value || '') + .normalize('NFKC') + .trim() + .toLowerCase() +} + +function candidateKey( + provider: string, + shopId: string, + skuCode: string, + matchType: string, + skuNameKey: string, +) { + return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}` +} + +function mappingKey( + provider: string, + shopId: string, + skuCode: string, + matchType = 'sku_code', + skuNameKey = '', +) { + return candidateKey(provider, shopId, skuCode, matchType, skuNameKey) +} + +function mappingMatchTypeLabel(matchType: TemplateMappingMatchType) { + switch (matchType) { + case 'sku_code': + return 'SKU 编码' + case 'sku_code_name': + return 'SKU 编码 + 精确名称' + case 'sku_code_series': + return 'SKU 编码 + 名称系列' + case 'sku_name_series': + return 'SKU 名称系列' + } +} + +export function normalizeMappingMatchType( + value: unknown, + fallback: TemplateMappingMatchType = 'sku_code', +): TemplateMappingMatchType { + const normalized = text(value) + if (!normalized) return fallback + if (!MAPPING_MATCH_TYPES.has(normalized)) { + throw createHttpError('映射匹配方式无效', { + statusCode: 400, + errorCode: 'cost_mapping_match_type_invalid', + }) + } + return normalized as TemplateMappingMatchType +} + +export function resolveMappingNameKey( + matchType: TemplateMappingMatchType, + platformSkuName: string, +) { + if (matchType === 'sku_code') return '' + if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName) + return normalizeSkuSeriesEquivalence(platformSkuName) +} + +export async function listAdminCostItems(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push(`(ci.code ILIKE $${params.length} OR ci.name ILIKE $${params.length})`) + } + const costType = text(payload.costType) + if (costType) { + assertOneOf(costType, COST_TYPES, '成本类型无效') + params.push(costType) + clauses.push(`ci.cost_type = $${params.length}`) + } + const carrierType = text(payload.carrierType) + if (carrierType) { + assertOneOf(carrierType, CARRIER_TYPES, '成本载体无效') + params.push(carrierType) + clauses.push(`ci.carrier_type = $${params.length}`) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, PRODUCT_STATUSES, '成本项状态无效') + params.push(status) + clauses.push(`ci.status = $${params.length}`) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM cost_items ci ${where}`, + params, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + ci.*, + (SELECT COUNT(*)::int FROM cost_versions v WHERE v.cost_item_id = ci.id) AS version_count + FROM cost_items ci + ${where} + ORDER BY ci.status ASC, ci.carrier_type ASC, ci.name ASC, ci.id ASC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapCostItem), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminCostItem(payload: JsonObject, _session: AdminSession) { + const code = normalizeRequiredText(payload.code, '成本项编码', 64) + const name = normalizeRequiredText(payload.name, '成本项名称', 120) + const costType = normalizeStatus(payload.costType, COST_TYPES, null, '成本类型无效') + const carrierType = normalizeStatus(payload.carrierType, CARRIER_TYPES, null, '成本载体无效') + const pricingMode = normalizeStatus(payload.pricingMode, PRICING_MODES, null, '计价方式无效') + const unitName = normalizeOptionalText(payload.unitName, 16) || '件' + const currency = normalizeOptionalText(payload.currency, 8) || 'CNY' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '成本项状态无效') + const note = normalizeOptionalText(payload.note, 500) + const now = nowIso() + + const result = await query( + ` + INSERT INTO cost_items ( + code, name, cost_type, carrier_type, pricing_mode, unit_name, + currency, status, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) + ON CONFLICT (code) DO NOTHING + RETURNING * + `, + [code, name, costType, carrierType, pricingMode, unitName, currency, status, note, now], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('成本项编码已存在', { + statusCode: 409, + errorCode: 'cost_item_code_exists', + }) + } + return { costItem: mapCostItem(inserted) } +} + +export async function updateAdminCostItem(costItemId: number | string, payload: JsonObject) { + const id = normalizeId(costItemId, '成本项 ID') + const current = await getCostItemRow(id) + const name = normalizeRequiredText(payload.name ?? current.name, '成本项名称', 120) + const costType = normalizeStatus(payload.costType, COST_TYPES, current.cost_type, '成本类型无效') + const carrierType = normalizeStatus( + payload.carrierType, + CARRIER_TYPES, + current.carrier_type, + '成本载体无效', + ) + const pricingMode = normalizeStatus( + payload.pricingMode, + PRICING_MODES, + current.pricing_mode, + '计价方式无效', + ) + const unitName = + payload.unitName === undefined + ? current.unit_name + : normalizeOptionalText(payload.unitName, 16) || '件' + const currency = + payload.currency === undefined + ? current.currency + : normalizeOptionalText(payload.currency, 8) || 'CNY' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '成本项状态无效') + const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) + + const result = await query( + ` + UPDATE cost_items + SET name = $1, cost_type = $2, carrier_type = $3, pricing_mode = $4, + unit_name = $5, currency = $6, status = $7, note = $8, updated_at = $9 + WHERE id = $10 + RETURNING * + `, + [name, costType, carrierType, pricingMode, unitName, currency, status, note, nowIso(), id], + ) + return { costItem: mapCostItem(requiredRow(result.rows[0], '成本项不存在')) } +} + +export async function listAdminCostVersions(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const costItemId = normalizeOptionalId(payload.costItemId) + if (costItemId) { + params.push(costItemId) + clauses.push(`v.cost_item_id = $${params.length}`) + } + const productId = normalizeOptionalId(payload.canonicalProductId) + if (productId) { + params.push(productId) + clauses.push(`v.canonical_product_id = $${params.length}`) + } + const provider = text(payload.provider) + if (provider) { + params.push(normalizeProviderKey(provider)) + clauses.push(`v.provider = $${params.length}`) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, COST_VERSION_STATUSES, '成本版本状态无效') + params.push(status) + clauses.push(`v.status = $${params.length}`) + } + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(ci.code ILIKE $${params.length} OR ci.name ILIKE $${params.length} + OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length})`, + ) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM cost_versions v + JOIN cost_items ci ON ci.id = v.cost_item_id + LEFT JOIN product_catalog p ON p.id = v.canonical_product_id + ${where} + `, + params, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + v.*, + ci.code AS cost_item_code, + ci.name AS cost_item_name, + ci.pricing_mode, + p.canonical_sku, + p.canonical_name + FROM cost_versions v + JOIN cost_items ci ON ci.id = v.cost_item_id + LEFT JOIN product_catalog p ON p.id = v.canonical_product_id + ${where} + ORDER BY v.status ASC, v.effective_from DESC, v.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapCostVersion), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminCostVersion(payload: JsonObject, session: AdminSession) { + const costItemId = normalizeId(payload.costItemId ?? payload.cost_item_id, '成本项 ID') + const costItem = await getCostItemRow(costItemId) + const scopeType = normalizeStatus( + payload.scopeType, + COST_VERSION_SCOPES, + 'global', + '成本范围无效', + ) + const rawProvider = normalizeProviderKey(normalizeOptionalText(payload.provider, 64)) + if (rawProvider) { + assertKnownProvider(rawProvider) + } + const rawShopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const rawCanonicalProductId = normalizeOptionalId( + payload.canonicalProductId ?? payload.canonical_product_id, + ) + const rawWorkProductRuleId = normalizeOptionalId( + payload.workProductRuleId ?? payload.work_product_rule_id, + ) + const { provider, shopId, canonicalProductId, workProductRuleId } = + normalizeCostVersionScopeFields(scopeType, { + provider: rawProvider, + shopId: rawShopId, + canonicalProductId: rawCanonicalProductId, + workProductRuleId: rawWorkProductRuleId, + }) + validateCostVersionScope(scopeType, { + provider, + shopId, + canonicalProductId, + workProductRuleId, + }) + + const amountFen = normalizeNonNegativeInteger(payload.amountFen ?? payload.amount_fen, 0) + const rateBps = normalizeNonNegativeInteger(payload.rateBps ?? payload.rate_bps, 0) + if (rateBps > 10000) { + throw createHttpError('比例不能超过 10000 个基点(100%)', { + statusCode: 400, + errorCode: 'cost_version_rate_invalid', + }) + } + if (costItem.pricing_mode === 'percent_of_revenue' && rateBps <= 0) { + throw createHttpError('比例计价必须填写大于 0 的 rateBps', { + statusCode: 400, + errorCode: 'cost_version_rate_required', + }) + } + if (costItem.pricing_mode !== 'percent_of_revenue' && amountFen <= 0) { + throw createHttpError('非比例计价必须填写大于 0 的金额', { + statusCode: 400, + errorCode: 'cost_version_amount_required', + }) + } + + const effectiveFrom = normalizeIsoDate(payload.effectiveFrom, '生效时间') || nowIso() + const effectiveTo = normalizeIsoDate(payload.effectiveTo, '失效时间', true) + if (effectiveTo && Date.parse(effectiveTo) <= Date.parse(effectiveFrom)) { + throw createHttpError('失效时间必须晚于生效时间', { + statusCode: 400, + errorCode: 'cost_version_period_invalid', + }) + } + const source = normalizeStatus(payload.source, COST_VERSION_SOURCES, 'manual', '成本版本来源无效') + const status = normalizeStatus( + payload.status, + COST_VERSION_STATUSES, + 'active', + '成本版本状态无效', + ) + const changeReason = normalizeOptionalText(payload.changeReason ?? payload.change_reason, 500) + const now = nowIso() + + const result = await query( + ` + INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $14, $15, $15 + ) + ON CONFLICT DO NOTHING + RETURNING * + `, + [ + costItemId, + scopeType, + provider, + shopId, + canonicalProductId, + workProductRuleId, + amountFen, + rateBps, + effectiveFrom, + effectiveTo, + source, + status, + changeReason, + session.userId, + now, + ], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('同一成本项 + 范围 + 生效时间已有版本', { + statusCode: 409, + errorCode: 'cost_version_exists', + }) + } + return { version: await getCostVersionById(Number(inserted.id)) } +} + +export async function retireAdminCostVersion(versionId: number | string, payload: JsonObject = {}) { + const id = normalizeId(versionId, '成本版本 ID') + const reason = normalizeRequiredText( + payload.changeReason ?? payload.change_reason ?? payload.reason, + '停用原因', + 500, + ) + const result = await query<{ id: number }>( + ` + UPDATE cost_versions + SET status = 'retired', change_reason = $1, updated_at = $2 + WHERE id = $3 + RETURNING id + `, + [reason, nowIso(), id], + ) + if (!result.rows[0]) { + throw createHttpError('成本版本不存在', { + statusCode: 404, + errorCode: 'cost_version_not_found', + }) + } + return { version: await getCostVersionById(id) } +} + +export async function listAdminCostExceptions(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const status = text(payload.status) + if (status) { + assertOneOf(status, COST_EXCEPTION_STATUSES, '异常状态无效') + params.push(status) + clauses.push(`ce.status = $${params.length}`) + } + const subjectType = text(payload.subjectType) + if (subjectType) { + assertOneOf(subjectType, new Set(['order_item', 'work_order', 'manual_case']), '异常主体无效') + params.push(subjectType) + clauses.push(`ce.subject_type = $${params.length}`) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM cost_exceptions ce ${where}`, + params, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT ce.* + FROM cost_exceptions ce + ${where} + ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapCostException), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function resolveAdminCostException( + exceptionId: number | string, + payload: JsonObject, + session: AdminSession, +) { + const id = normalizeId(exceptionId, '异常 ID') + const status = normalizeStatus( + payload.status, + COST_EXCEPTION_STATUSES, + 'resolved', + '异常状态无效', + ) + if (status === 'open') { + throw createHttpError('不能把异常重新置为 open', { + statusCode: 400, + errorCode: 'cost_exception_status_invalid', + }) + } + const now = nowIso() + const result = await query( + ` + UPDATE cost_exceptions + SET status = $1, resolved_at = $2, resolved_by_admin_user_id = $3 + WHERE id = $4 + RETURNING * + `, + [status, now, session.userId, id], + ) + return { exception: mapCostException(requiredRow(result.rows[0], '成本异常不存在')) } +} + +export async function previewAdminCost(payload: JsonObject = {}) { + const context = await resolveCostContext(payload) + const carrierType = normalizeCarrierType(payload.carrierType, context) + const mapping = await findMappingForContext(context) + const productId = context.canonicalProductId || mapping?.canonical_product_id || null + const product = productId ? mapProduct(await getProductRow(productId)) : null + const resolution: CostResolution = productId + ? await resolveCostLines({ + provider: context.provider, + shopId: context.shopId, + carrierType, + canonicalProductId: productId, + workProductRuleId: context.workProductRuleId, + quantity: context.normalizedQuantity, + revenueFen: context.revenueFen, + }) + : { lines: [], missingCostItems: [], applicableCostItemCount: 0 } + + const exceptions: CostExceptionDraft[] = [] + if (!productId) { + exceptions.push({ + reason: 'product_mapping_missing', + message: `未找到 ${context.provider} / ${context.shopId || '全局'} / ${context.skuCode} 的标准商品映射`, + detail: { + provider: context.provider, + shopId: context.shopId, + skuCode: context.skuCode, + }, + }) + } else if (resolution.missingCostItems.length > 0) { + exceptions.push({ + reason: 'cost_missing', + message: `以下成本项没有匹配到有效成本版本:${resolution.missingCostItems + .map((item) => `${item.costItemName}(${item.costItemCode})`) + .join('、')}`, + detail: { + carrierType, + costItemIds: resolution.missingCostItems.map((item) => item.costItemId), + }, + }) + } else if (resolution.applicableCostItemCount === 0) { + exceptions.push({ + reason: 'cost_missing', + message: `没有配置适用于「${carrierType}」载体的成本项`, + detail: { carrierType }, + }) + } + + return { + context: { + ...context, + carrierType, + }, + mapping: mapping ? mapMapping(mapping) : null, + product, + lines: resolution.lines, + missingCostItems: resolution.missingCostItems, + totalCostFen: resolution.lines.reduce((sum, line) => sum + line.totalAmountFen, 0), + exceptions, + } +} + +export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) { + const preview = await previewAdminCost(payload) + const orderItemId = preview.context.orderItemId + if (!orderItemId) { + throw createHttpError('生成成本快照必须提供订单项 ID', { + statusCode: 400, + errorCode: 'cost_snapshot_order_item_required', + }) + } + const now = nowIso() + const snapshots: Array> = [] + let exceptionCreatedCount = 0 + + await withTransaction(async (client) => { + for (const line of preview.lines) { + const result = await client.query<{ [column: string]: unknown }>( + ` + INSERT INTO cost_snapshots ( + subject_type, subject_id, order_id, order_item_id, work_order_id, + carrier_type, cost_item_id, cost_version_id, canonical_product_id, + quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, + metadata_json, resolved_at, created_at + ) VALUES ( + 'order_item', $1, $2, $1, $3, $4, $5, $6, $7, + $8, $9, $10, $11, 'resolved', $12::jsonb, $13, $13 + ) + ON CONFLICT (subject_type, subject_id, cost_item_id) DO NOTHING + RETURNING * + `, + [ + orderItemId, + preview.context.orderId, + preview.context.workOrderId, + preview.context.carrierType, + line.costItemId, + line.costVersionId, + preview.product?.id || null, + line.quantity, + line.unitAmountFen, + line.totalAmountFen, + payload.snapshotStage === 'actual' ? 'actual' : 'estimated', + JSON.stringify({ + skuCode: preview.context.skuCode, + skuName: preview.context.skuName, + provider: preview.context.provider, + shopId: preview.context.shopId, + normalizedQuantity: preview.context.normalizedQuantity, + }), + now, + ], + ) + if (result.rows[0]) snapshots.push(result.rows[0]) + } + + for (const exception of preview.exceptions) { + const result = await client.query( + ` + INSERT INTO cost_exceptions ( + subject_type, subject_id, order_item_id, work_order_id, + carrier_type, reason, status, detail_json, created_at + ) VALUES ('order_item', $1, $1, $2, $3, $4, 'open', $5::jsonb, $6) + ON CONFLICT (subject_type, subject_id, reason) DO NOTHING + `, + [ + orderItemId, + preview.context.workOrderId, + preview.context.carrierType, + exception.reason, + JSON.stringify({ + ...exception.detail, + message: exception.message, + skuCode: preview.context.skuCode, + }), + now, + ], + ) + exceptionCreatedCount += result.rowCount || 0 + } + }) + + return { preview, snapshots, createdCount: snapshots.length, exceptionCreatedCount } +} + +async function resolveCostContext(payload: JsonObject): Promise { + const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) + if (orderItemId) { + const result = await query<{ + order_item_id: number + order_id: number + provider: string + shop_id: string + sku_code: string + sku_name: string + normalized_sku_name: string | null + normalized_sku_series: string | null + quantity: number + normalized_quantity: number | null + canonical_product_id: number | null + revenue_fen: number + work_order_id: number | null + work_product_rule_id: number | null + has_affiliate_dash_task: boolean + }>( + ` + SELECT + oi.id AS order_item_id, + oi.order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + oi.normalized_sku_name, + oi.normalized_sku_series, + oi.quantity, + oi.normalized_quantity, + oi.canonical_product_id, + o.total_amount AS revenue_fen, + wo.id AS work_order_id, + wo.product_rule_id AS work_product_rule_id, + EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ) AS has_affiliate_dash_task + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + WHERE oi.id = $1 + LIMIT 1 + `, + [orderItemId], + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('订单项不存在', { + statusCode: 404, + errorCode: 'cost_order_item_not_found', + }) + } + return { + orderItemId: Number(row.order_item_id), + orderId: Number(row.order_id), + provider: row.provider, + shopId: row.shop_id, + skuCode: row.sku_code, + skuName: row.sku_name, + normalizedSkuName: + row.normalized_sku_name || normalizeMatchText(row.sku_name || row.sku_code), + normalizedSkuSeries: + row.normalized_sku_series || normalizeSkuSeriesEquivalence(row.sku_name || row.sku_code), + quantity: Math.max(1, Number(row.quantity) || 1), + normalizedQuantity: + row.normalized_quantity && Number(row.normalized_quantity) > 0 + ? Number(row.normalized_quantity) + : Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)), + canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, + revenueFen: Math.max(0, Number(row.revenue_fen) || 0), + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null, + hasAffiliateDashTask: Boolean(row.has_affiliate_dash_task), + } + } + + const provider = normalizeRequiredText(payload.provider, '来源', 64) + const skuCode = normalizeRequiredText(payload.skuCode ?? payload.sku_code, '平台 SKU', 120) + const quantity = Math.max(1, Math.trunc(Number(payload.quantity) || 1)) + const skuName = normalizeOptionalText(payload.skuName ?? payload.sku_name, 200) + return { + orderItemId: 0, + orderId: 0, + provider, + shopId: normalizeOptionalText(payload.shopId ?? payload.shop_id, 120), + skuCode, + skuName, + normalizedSkuName: normalizeMatchText(skuName || skuCode), + normalizedSkuSeries: normalizeSkuSeriesEquivalence(skuName || skuCode), + quantity, + normalizedQuantity: quantity, + canonicalProductId: normalizeOptionalId( + payload.canonicalProductId ?? payload.canonical_product_id, + ), + revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)), + workOrderId: null, + workProductRuleId: normalizeOptionalId( + payload.workProductRuleId ?? payload.work_product_rule_id, + ), + hasAffiliateDashTask: false, + } +} + +async function findMappingForContext(context: OrderItemCostContext) { + const result = await query( + ` + SELECT m.*, p.canonical_sku, p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id AND p.status = 'active' + WHERE m.provider = $1 + AND m.enabled = TRUE + AND (m.shop_id = '' OR m.shop_id = $3) + AND ( + (m.match_type = 'sku_code' AND m.platform_sku_code = $2) + OR ( + m.match_type = 'sku_code_name' + AND m.platform_sku_code = $2 + AND m.platform_sku_name_key = $4 + ) + OR ( + m.match_type = 'sku_code_series' + AND m.platform_sku_code = $2 + AND m.platform_sku_name_key = $5 + ) + OR (m.match_type = 'sku_name_series' AND m.platform_sku_name_key = $5) + ) + ORDER BY + CASE + WHEN m.match_type = 'sku_code' AND m.shop_id = $3 THEN 1 + WHEN m.match_type = 'sku_code' AND m.shop_id = '' THEN 2 + WHEN m.match_type = 'sku_code_name' AND m.shop_id = $3 THEN 3 + WHEN m.match_type = 'sku_code_name' AND m.shop_id = '' THEN 4 + WHEN m.match_type = 'sku_code_series' AND m.shop_id = $3 THEN 5 + WHEN m.match_type = 'sku_code_series' AND m.shop_id = '' THEN 6 + WHEN m.match_type = 'sku_name_series' AND m.shop_id = $3 THEN 7 + ELSE 8 + END, + m.id DESC + LIMIT 1 + `, + [ + context.provider, + context.skuCode, + context.shopId, + context.normalizedSkuName, + context.normalizedSkuSeries, + ], + ) + return result.rows[0] || null +} + +async function resolveCostLines(input: { + provider: string + shopId: string + carrierType: string + canonicalProductId: number + workProductRuleId: number | null + quantity: number + revenueFen: number +}): Promise { + const result = await query< + CostItemRow & { + version_id: number | null + version_scope: string | null + version_amount_fen: number | null + version_rate_bps: number | null + } + >( + ` + SELECT + ci.*, + v.id AS version_id, + v.scope_type AS version_scope, + v.amount_fen AS version_amount_fen, + v.rate_bps AS version_rate_bps + FROM cost_items ci + LEFT JOIN LATERAL ( + SELECT v.* + FROM cost_versions v + WHERE v.cost_item_id = ci.id + AND v.status = 'active' + AND v.effective_from <= $1::timestamptz + AND (v.effective_to IS NULL OR v.effective_to > $1::timestamptz) + AND ( + (v.scope_type = 'rule' AND $5::bigint IS NOT NULL AND v.work_product_rule_id = $5) + OR (v.scope_type = 'product' AND v.canonical_product_id = $4) + OR (v.scope_type = 'shop' AND v.provider = $2 AND v.shop_id = $3) + OR (v.scope_type = 'provider' AND v.provider = $2) + OR (v.scope_type = 'global') + ) + ORDER BY + CASE v.scope_type + WHEN 'rule' THEN 1 + WHEN 'product' THEN 2 + WHEN 'shop' THEN 3 + WHEN 'provider' THEN 4 + ELSE 5 + END, + v.effective_from DESC, + v.id DESC + LIMIT 1 + ) v ON TRUE + WHERE ci.status = 'active' + AND ci.carrier_type IN ($6, 'shared') + ORDER BY ci.id + `, + [ + nowIso(), + input.provider, + input.shopId, + input.canonicalProductId, + input.workProductRuleId, + input.carrierType, + ], + ) + + const lines: CostLine[] = [] + const missingCostItems: MissingCostItem[] = [] + for (const row of result.rows) { + if (!row.version_id) { + missingCostItems.push({ + costItemId: Number(row.id), + costItemCode: row.code, + costItemName: row.name, + carrierType: row.carrier_type, + }) + continue + } + const pricingMode = row.pricing_mode + const amountFen = Number(row.version_amount_fen || 0) + const rateBps = Number(row.version_rate_bps || 0) + const quantity = Math.max(0, Number(input.quantity) || 0) + const unitAmountFen = amountFen + const totalAmountFen = calculateCostTotal({ + pricingMode, + amountFen, + rateBps, + quantity, + revenueFen: input.revenueFen, + }) + lines.push({ + costItemId: Number(row.id), + costItemCode: row.code, + costItemName: row.name, + costType: row.cost_type, + carrierType: row.carrier_type, + pricingMode, + costVersionId: Number(row.version_id), + scopeType: String(row.version_scope || ''), + amountFen, + rateBps, + quantity, + unitAmountFen, + totalAmountFen, + }) + } + + return { + lines, + missingCostItems, + applicableCostItemCount: result.rows.length, + } +} + +export function calculateCostTotal(input: { + pricingMode: string + amountFen: number + rateBps: number + quantity: number + revenueFen: number +}) { + const amountFen = Math.max(0, Math.trunc(Number(input.amountFen) || 0)) + const rateBps = Math.max(0, Math.min(10000, Math.trunc(Number(input.rateBps) || 0))) + const quantity = Math.max(0, Number(input.quantity) || 0) + const revenueFen = Math.max(0, Math.trunc(Number(input.revenueFen) || 0)) + let total = amountFen + if (input.pricingMode === 'per_item') { + total = Math.round(amountFen * quantity) + } else if (input.pricingMode === 'percent_of_revenue') { + total = Math.round((revenueFen * rateBps) / 10000) + } else if ( + input.pricingMode === 'per_order' || + input.pricingMode === 'per_hour' || + input.pricingMode === 'fixed_daily' + ) { + total = amountFen + } + return Math.max(0, total) +} + +export function validateCostVersionScope( + scopeType: string, + input: { + provider: string + shopId: string + canonicalProductId: number | null + workProductRuleId: number | null + }, +) { + if (scopeType === 'provider' && !input.provider) { + throw createHttpError('来源范围必须填写来源', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'shop' && (!input.provider || !input.shopId)) { + throw createHttpError('店铺范围必须填写来源和店铺', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'product' && !input.canonicalProductId) { + throw createHttpError('商品范围必须选择标准商品', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'rule' && !input.workProductRuleId) { + throw createHttpError('规则范围必须选择接单规则', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } +} + +function normalizeCostVersionScopeFields( + scopeType: string, + input: { + provider: string + shopId: string + canonicalProductId: number | null + workProductRuleId: number | null + }, +) { + const empty = { + provider: '', + shopId: '', + canonicalProductId: null as number | null, + workProductRuleId: null as number | null, + } + if (scopeType === 'provider') { + return { ...empty, provider: input.provider } + } + if (scopeType === 'shop') { + return { ...empty, provider: input.provider, shopId: input.shopId } + } + if (scopeType === 'product') { + return { ...empty, canonicalProductId: input.canonicalProductId } + } + if (scopeType === 'rule') { + return { ...empty, workProductRuleId: input.workProductRuleId } + } + return empty +} + +function normalizeCarrierType(value: unknown, context: OrderItemCostContext) { + const normalized = text(value) + if (normalized) { + assertOneOf(normalized, CARRIER_TYPES, '载体类型无效') + return normalized + } + if (context.workOrderId) return 'work_order' + if (context.hasAffiliateDashTask) return 'affiliate_dash' + return 'manual' +} + +async function getProductRow(id: number) { + const result = await query('SELECT * FROM product_catalog WHERE id = $1 LIMIT 1', [ + id, + ]) + return requiredRow(result.rows[0], '标准商品不存在') +} + +async function getMappingRow(id: number) { + const result = await query( + ` + SELECT m.*, p.canonical_sku, p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + WHERE m.id = $1 + LIMIT 1 + `, + [id], + ) + return requiredRow(result.rows[0], '渠道映射不存在') +} + +async function assertMappingKeyAvailable( + provider: string, + shopId: string, + platformSkuCode: string, + matchType: string, + platformSkuNameKey: string, + excludeId: number, +) { + const result = await query<{ id: number }>( + ` + SELECT id + FROM channel_product_mappings + WHERE provider = $1 + AND shop_id = $2 + AND platform_sku_code = $3 + AND match_type = $4 + AND platform_sku_name_key = $5 + AND id <> $6 + LIMIT 1 + `, + [provider, shopId, platformSkuCode, matchType, platformSkuNameKey, excludeId], + ) + if (result.rows[0]) { + throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { + statusCode: 409, + errorCode: 'cost_mapping_exists', + }) + } +} + +async function getCostItemRow(id: number) { + const result = await query('SELECT * FROM cost_items WHERE id = $1 LIMIT 1', [id]) + return requiredRow(result.rows[0], '成本项不存在') +} + +async function getCostVersionById(id: number) { + const result = await query( + ` + SELECT + v.*, + ci.code AS cost_item_code, + ci.name AS cost_item_name, + ci.pricing_mode, + p.canonical_sku, + p.canonical_name + FROM cost_versions v + JOIN cost_items ci ON ci.id = v.cost_item_id + LEFT JOIN product_catalog p ON p.id = v.canonical_product_id + WHERE v.id = $1 + LIMIT 1 + `, + [id], + ) + return mapCostVersion(requiredRow(result.rows[0], '成本版本不存在')) +} + +function mapProduct(row: ProductRow) { + return { + id: Number(row.id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + category: row.category || '', + rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null, + rootCategoryName: row.root_category_name || row.category || '', + subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null, + subCategoryName: row.sub_category_name || '', + unitName: row.unit_name, + status: row.status, + attributes: parseJsonObject(row.attributes_json), + mappingCount: Number(row.mapping_count || 0), + costVersionCount: Number(row.cost_version_count || 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapMapping(row: MappingRow) { + return { + id: Number(row.id), + provider: row.provider, + shopId: row.shop_id, + platformSkuCode: row.platform_sku_code, + platformSkuName: row.platform_sku_name, + platformSkuNameKey: row.platform_sku_name_key || '', + matchType: row.match_type || 'sku_code', + canonicalProductId: Number(row.canonical_product_id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + quantityMultiplier: Number(row.quantity_multiplier), + confidence: row.confidence, + source: row.source, + enabled: row.enabled !== false, + note: row.note || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapCostItem(row: CostItemRow) { + return { + id: Number(row.id), + code: row.code, + name: row.name, + costType: row.cost_type, + carrierType: row.carrier_type, + pricingMode: row.pricing_mode, + unitName: row.unit_name, + currency: row.currency, + status: row.status, + note: row.note || '', + versionCount: Number(row.version_count || 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapCostVersion(row: CostVersionRow) { + return { + id: Number(row.id), + costItemId: Number(row.cost_item_id), + costItemCode: row.cost_item_code, + costItemName: row.cost_item_name, + pricingMode: row.pricing_mode, + scopeType: row.scope_type, + provider: row.provider || '', + shopId: row.shop_id || '', + canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, + canonicalSku: row.canonical_sku || '', + canonicalName: row.canonical_name || '', + workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null, + amountFen: Number(row.amount_fen), + rateBps: Number(row.rate_bps), + effectiveFrom: row.effective_from, + effectiveTo: row.effective_to, + source: row.source, + status: row.status, + changeReason: row.change_reason || '', + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + approvedByAdminUserId: row.approved_by_admin_user_id + ? Number(row.approved_by_admin_user_id) + : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapCostException(row: CostExceptionRow) { + return { + id: Number(row.id), + subjectType: row.subject_type, + subjectId: Number(row.subject_id), + orderItemId: row.order_item_id ? Number(row.order_item_id) : null, + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + carrierType: row.carrier_type || '', + reason: row.reason, + status: row.status, + detail: parseJsonObject(row.detail_json), + createdAt: row.created_at, + resolvedAt: row.resolved_at, + resolvedByAdminUserId: row.resolved_by_admin_user_id + ? Number(row.resolved_by_admin_user_id) + : null, + } +} + +function normalizePage(value: unknown) { + return Math.max(1, Math.trunc(Number(value) || 1)) +} + +function normalizePageSize(value: unknown) { + return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20))) +} + +function normalizeId(value: unknown, label: string) { + const id = Number(value) + if (!Number.isInteger(id) || id <= 0) { + throw createHttpError(`${label}无效`, { + statusCode: 400, + errorCode: 'cost_center_id_invalid', + }) + } + return id +} + +function normalizeOptionalId(value: unknown) { + if (value === undefined || value === null || value === '') return null + const id = Number(value) + return Number.isInteger(id) && id > 0 ? id : null +} + +function normalizeCategoryFilter(value: unknown) { + if (value === undefined || value === null || value === '') return null + const id = Number(value) + if (!Number.isInteger(id) || (id <= 0 && id !== -1)) { + throw createHttpError('分类筛选无效', { + statusCode: 400, + errorCode: 'cost_center_category_invalid', + }) + } + return id +} + +function normalizeRequiredText(value: unknown, label: string, maxLength: number) { + const normalized = text(value) + if (!normalized) { + throw createHttpError(`${label}不能为空`, { + statusCode: 400, + errorCode: 'cost_center_field_required', + }) + } + if (normalized.length > maxLength) { + throw createHttpError(`${label}最多 ${maxLength} 个字符`, { + statusCode: 400, + errorCode: 'cost_center_field_too_long', + }) + } + return normalized +} + +function normalizeOptionalText(value: unknown, maxLength: number) { + return text(value).slice(0, maxLength) +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed > 0 ? parsed : fallback +} + +function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed >= 0 ? parsed : fallback +} + +function normalizeBoolean(value: unknown) { + return value === true || value === 'true' || value === 1 || value === '1' +} + +function normalizeStatus( + value: unknown, + allowed: Set, + fallback: string | null, + message: string, +) { + const normalized = text(value) + if (!normalized) { + if (fallback !== null) return fallback + throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) + } + assertOneOf(normalized, allowed, message) + return normalized +} + +function assertOneOf(value: string, allowed: Set, message: string) { + if (!allowed.has(value)) { + throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) + } +} + +function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) { + const raw = text(value) + if (!raw) { + if (allowEmpty) return null + throw createHttpError(`${label}不能为空`, { + statusCode: 400, + errorCode: 'cost_center_date_required', + }) + } + let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T') + if (!normalized.includes('T')) { + normalized = `${normalized}T00:00:00` + } + // 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。 + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) { + normalized = `${normalized}+08:00` + } + const parsed = new Date(normalized) + if (Number.isNaN(parsed.getTime())) { + throw createHttpError(`${label}格式无效`, { + statusCode: 400, + errorCode: 'cost_center_date_invalid', + }) + } + return parsed.toISOString() +} + +function normalizeJsonObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +function parseJsonObject(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } + } + return {} +} + +function text(value: unknown) { + return String(value ?? '').trim() +} + +/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */ +export function normalizeProviderKey(value: unknown) { + const normalized = text(value) + if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') { + return '91kaquan' + } + if (normalized === 'douyin-mashida') { + return 'douyin_mashida' + } + return normalized +} + +function assertKnownProvider(provider: string) { + if (provider && !PROVIDERS.includes(provider)) { + throw createHttpError('来源无效', { + statusCode: 400, + errorCode: 'cost_center_provider_invalid', + }) + } +} + +function toNumber(value: unknown) { + return Number(value || 0) || 0 +} + +function requiredRow(row: T | undefined | null, message: string): T { + if (!row) { + throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' }) + } + return row +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index a37d5d0d..db61e781 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { }> } /> + } /> }> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index babc4e64..7b743e65 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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: , label: '财务' }, + { key: '/admin/cost-center', icon: , label: '商品与成本' }, { key: '/admin/fund', icon: , label: '备用金' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, ] @@ -144,7 +146,14 @@ export default function AdminLayout() { }, ...(role === 'support' ? [] - : [{ key: '/admin/finance', icon: , label: '财务' }]), + : [ + { key: '/admin/finance', icon: , label: '财务' }, + { + key: '/admin/cost-center', + icon: , + label: '商品与成本', + }, + ]), { key: '/admin/tasks', icon: , label: '任务' }, { key: '/admin/worker-platform', icon: , 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' diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx new file mode 100644 index 00000000..06c0bc49 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -0,0 +1,2002 @@ +import { + EditOutlined, + FolderOpenOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, + SyncOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Col, + DatePicker, + Descriptions, + Empty, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import type { Dayjs } from 'dayjs' + +import PageHeader from '@/components/admin/PageHeader' +import dayjs from '@/lib/dayjs' +import { + applyAdminTemplateProductBootstrap, + backfillAdminNormalizedQuantities, + backfillAdminProductMappings, + createAdminChannelProductMapping, + createAdminCostItem, + createAdminCostSnapshot, + createAdminCostVersion, + createAdminProductCatalog, + fetchAdminChannelProductMappings, + fetchAdminCostCenterOptions, + fetchAdminCostCenterOverview, + fetchAdminCostExceptions, + fetchAdminCostItems, + fetchAdminCostVersions, + fetchAdminProductCatalog, + previewAdminCost, + previewAdminTemplateProductBootstrap, + resolveAdminCostException, + retireAdminCostVersion, + updateAdminChannelProductMapping, + updateAdminCostItem, + updateAdminProductCatalog, +} from '@/services/admin' +import type { + ChannelProductMapping, + ChannelProductMappingGroup, + CostException, + CostItem, + CostPreview, + CostVersion, + ProductCatalogItem, +} from '@/types/admin' +import { hasAdminPermission } from '@/utils/admin-auth' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from './panels/shared' + +type ProductFormValues = { + canonicalSku: string + canonicalName: string + category?: string + unitName?: string + status?: 'active' | 'archived' +} + +type MappingFormValues = { + provider: string + shopId?: string + platformSkuCode?: string + platformSkuName?: string + matchType?: ChannelProductMapping['matchType'] + canonicalProductId: number + quantityMultiplier?: number + confidence?: 'exact' | 'rule' | 'manual' + source?: 'manual' | 'import' | 'rule' + enabled?: boolean + note?: string +} + +type CostItemFormValues = { + code: string + name: string + costType: CostItem['costType'] + carrierType: CostItem['carrierType'] + pricingMode: CostItem['pricingMode'] + unitName?: string + status?: 'active' | 'archived' + note?: string +} + +type CostVersionFormValues = { + costItemId: number + scopeType: CostVersion['scopeType'] + provider?: string + shopId?: string + canonicalProductId?: number + workProductRuleId?: number + amountYuan?: number + rateBps?: number + effectiveFrom?: Dayjs + effectiveTo?: Dayjs + status?: 'draft' | 'active' + changeReason?: string +} + +const COST_TYPE_OPTIONS = [ + { value: 'auto_purchase', label: '自动发货采购成本' }, + { value: 'work_order_reward', label: '接单平台打手报酬' }, + { value: 'manual_service', label: '人工客服成本' }, + { value: 'platform_fee', label: '平台服务费' }, + { value: 'payment_fee', label: '支付通道费' }, + { value: 'after_sales_loss', label: '售后损失' }, + { value: 'shipping_other', label: '其他履约费用' }, + { value: 'other', label: '其他' }, +] + +const CARRIER_OPTIONS = [ + { value: 'affiliate_dash', label: '自动发货' }, + { value: 'work_order', label: '接单平台' }, + { value: 'manual', label: '人工客服' }, + { value: 'shared', label: '通用' }, +] + +const PRICING_OPTIONS = [ + { value: 'per_item', label: '按件' }, + { value: 'per_order', label: '按单' }, + { value: 'per_hour', label: '按小时' }, + { value: 'percent_of_revenue', label: '按收入比例' }, + { value: 'fixed_daily', label: '按天固定' }, +] + +const SCOPE_OPTIONS = [ + { value: 'global', label: '全局' }, + { value: 'provider', label: '按来源' }, + { value: 'shop', label: '按店铺' }, + { value: 'product', label: '按标准商品' }, + { value: 'rule', label: '按接单规则' }, +] + +const PROVIDER_LABELS: Record = { + '91kaquan': '91 卡券(快手)', + pdd: '拼多多', + douyin_mashida: '抖音码速达', +} + +const MAPPING_MATCH_TYPE_OPTIONS: Array<{ + value: ChannelProductMapping['matchType'] + label: string +}> = [ + { value: 'sku_code', label: 'SKU 编码(唯一)' }, + { value: 'sku_code_name', label: 'SKU 编码 + 精确名称' }, + { value: 'sku_code_series', label: 'SKU 编码 + 名称系列' }, + { value: 'sku_name_series', label: 'SKU 名称系列兜底' }, +] + +function mappingMatchTypeLabel(value: ChannelProductMapping['matchType']) { + return MAPPING_MATCH_TYPE_OPTIONS.find((item) => item.value === value)?.label || value || '-' +} + +function labelOf(options: Array<{ value: string; label: string }>, value: string) { + return options.find((item) => item.value === value)?.label || value || '-' +} + +function providerLabel(value: string) { + return PROVIDER_LABELS[value] || value || '-' +} + +function formatRate(rateBps: number) { + return `${((Number(rateBps || 0) || 0) / 100).toFixed(2)}%` +} + +const PAGE_SIZE = 20 + +export default function AdminCostCenterPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const canConfig = hasAdminPermission('finance.config') + + const [productForm] = Form.useForm() + const [mappingForm] = Form.useForm() + const [costItemForm] = Form.useForm() + const [versionForm] = Form.useForm() + + const [productKeyword, setProductKeyword] = useState('') + const [productRootCategoryId, setProductRootCategoryId] = useState(null) + const [productSubCategoryId, setProductSubCategoryId] = useState(null) + const [productStatusUpdatingId, setProductStatusUpdatingId] = useState(null) + const [mappingKeyword, setMappingKeyword] = useState('') + const [mappingProvider, setMappingProvider] = useState('') + const [costItemKeyword, setCostItemKeyword] = useState('') + const [versionKeyword, setVersionKeyword] = useState('') + const [versionStatus, setVersionStatus] = useState('') + const [exceptionStatus, setExceptionStatus] = useState('open') + const [productPage, setProductPage] = useState(1) + const [mappingPage, setMappingPage] = useState(1) + const [expandedMappingProductIds, setExpandedMappingProductIds] = useState([]) + const [costItemPage, setCostItemPage] = useState(1) + const [versionPage, setVersionPage] = useState(1) + const [exceptionPage, setExceptionPage] = useState(1) + const [previewOrderItemId, setPreviewOrderItemId] = useState(null) + const [preview, setPreview] = useState(null) + const [previewing, setPreviewing] = useState(false) + const [snapshotting, setSnapshotting] = useState(false) + const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) + const [bootstrapApplying, setBootstrapApplying] = useState(false) + const versionScopeType = Form.useWatch('scopeType', versionForm) + const versionCostItemId = Form.useWatch('costItemId', versionForm) + + const [productModalOpen, setProductModalOpen] = useState(false) + const [mappingModalOpen, setMappingModalOpen] = useState(false) + const [costItemModalOpen, setCostItemModalOpen] = useState(false) + const [versionModalOpen, setVersionModalOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [editingMapping, setEditingMapping] = useState(null) + const [editingCostItem, setEditingCostItem] = useState(null) + + const overviewQuery = useQuery({ + queryKey: ['admin-cost-center-overview'], + queryFn: fetchAdminCostCenterOverview, + }) + const optionsQuery = useQuery({ + queryKey: ['admin-cost-center-options'], + queryFn: fetchAdminCostCenterOptions, + }) + const productsQuery = useQuery({ + queryKey: [ + 'admin-cost-center-products', + productKeyword, + productRootCategoryId, + productSubCategoryId, + productPage, + ], + queryFn: () => + fetchAdminProductCatalog({ + keyword: productKeyword || undefined, + rootCategoryId: productRootCategoryId ?? undefined, + categoryId: productSubCategoryId ?? undefined, + page: productPage, + pageSize: PAGE_SIZE, + }), + }) + const mappingsQuery = useQuery({ + queryKey: ['admin-cost-center-mappings', mappingKeyword, mappingProvider, mappingPage], + queryFn: () => + fetchAdminChannelProductMappings({ + keyword: mappingKeyword || undefined, + provider: mappingProvider || undefined, + page: mappingPage, + pageSize: PAGE_SIZE, + }), + }) + const costItemsQuery = useQuery({ + queryKey: ['admin-cost-center-cost-items', costItemKeyword, costItemPage], + queryFn: () => + fetchAdminCostItems({ + keyword: costItemKeyword || undefined, + page: costItemPage, + pageSize: PAGE_SIZE, + }), + }) + const versionsQuery = useQuery({ + queryKey: ['admin-cost-center-cost-versions', versionKeyword, versionStatus, versionPage], + queryFn: () => + fetchAdminCostVersions({ + keyword: versionKeyword || undefined, + status: versionStatus || undefined, + page: versionPage, + pageSize: PAGE_SIZE, + }), + }) + const exceptionsQuery = useQuery({ + queryKey: ['admin-cost-center-exceptions', exceptionStatus, exceptionPage], + queryFn: () => + fetchAdminCostExceptions({ + status: exceptionStatus || undefined, + page: exceptionPage, + pageSize: PAGE_SIZE, + }), + }) + + const overview = overviewQuery.data?.data + const options = optionsQuery.data?.data + const products = productsQuery.data?.data.items || [] + const mappingGroups = mappingsQuery.data?.data.groups || [] + const costItems = costItemsQuery.data?.data.items || [] + const versions = versionsQuery.data?.data.items || [] + const exceptions = exceptionsQuery.data?.data.items || [] + const productTotal = productsQuery.data?.data.pagination.total || 0 + const productCategoryTree = productsQuery.data?.data.categoryTree || [] + const productUncategorizedTotal = productsQuery.data?.data.uncategorizedTotal || 0 + const productCatalogTotal = productsQuery.data + ? productCategoryTree.reduce((sum, root) => sum + root.total, 0) + productUncategorizedTotal + : productTotal + const selectedProductRoot = + productRootCategoryId && productRootCategoryId > 0 + ? productCategoryTree.find((root) => root.categoryId === productRootCategoryId) || null + : null + const productSubCategories = selectedProductRoot?.children || [] + const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0 + const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0 + const costItemTotal = costItemsQuery.data?.data.pagination.total || 0 + const versionTotal = versionsQuery.data?.data.pagination.total || 0 + const exceptionTotal = exceptionsQuery.data?.data.pagination.total || 0 + const versionPricingMode = options?.costItems.find( + (item) => item.id === versionCostItemId, + )?.pricingMode + const providers = options?.providers || [] + const productOptions = + options?.products.map((item) => ({ + value: item.id, + label: `${item.canonicalName}(${item.canonicalSku})`, + })) || [] + const costItemOptions = + options?.costItems.map((item) => ({ + value: item.id, + label: `${item.name}(${item.code})`, + })) || [] + const ruleOptions = + options?.rules.map((item) => ({ + value: item.id, + label: `${item.productName || item.ruleKey} · #${item.id}`, + })) || [] + + function refreshAll() { + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-items'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-versions'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions'] }) + } + + function openCreateProduct() { + setEditingProduct(null) + productForm.resetFields() + productForm.setFieldsValue({ unitName: '件', status: 'active' }) + setProductModalOpen(true) + } + + async function runTemplateBootstrap() { + setBootstrapPreviewing(true) + try { + const response = await previewAdminTemplateProductBootstrap() + const data = response.data + modal.confirm({ + title: '从接单模板初始化商品与映射', + width: 720, + content: ( + + + 启用接单模板 {data.templateCount} 个:将新建{' '} + {data.productCreateCount} 个标准商品,复用{' '} + {data.productExistingCount} 个已有商品。 + + + 根据历史工单中已命中的模板,将建立 {data.mappingCreateCount}{' '} + 条渠道映射:编码精确 {data.mappingCreateByMatchType.sku_code}{' '} + 条、编码 + 名称系列 {data.mappingCreateByMatchType.sku_code_series}{' '} + 条、 编码 + 精确名称 {data.mappingCreateByMatchType.sku_code_name}{' '} + 条、名称系列兜底 {data.mappingCreateByMatchType.sku_name_series} 条。 + + + 预计可归一 {data.orderItemCoverCount} / {data.evidenceItemCount}{' '} + 个有工单证据的订单项。 + + + {data.templatesWithoutWorkOrders} 个模板暂无历史工单,仍会先建商品。 + + {data.mappingConflictCount > 0 ? ( + + + 这些组合不会自动选择,也不会按最新工单覆盖;未列出的冲突可在再次预览时查看。 + + {data.conflicts.slice(0, 4).map((item) => ( + + {providerLabel(item.provider)} / {item.shopId || '全局'} ·{' '} + {item.skuName || item.skuCode} → {item.ruleKeys.join(' / ')}( + {item.itemCount} 单) + + ))} + + } + /> + ) : ( + + )} + + 数量系列(1个/2个/5个/10个)归为同一标准商品,数量写入 + normalized_quantity,不会拆成多个商品,也不会重复乘倍率。 + + + ), + okText: '开始初始化并修复旧导入', + cancelText: '取消', + onOk: async () => { + setBootstrapApplying(true) + try { + const result = await applyAdminTemplateProductBootstrap() + const applied = result.data + const rebuildText = + applied.deletedImportedMappingCount > 0 + ? `,重建 ${applied.deletedImportedMappingCount} 条旧映射、重置 ${applied.resetOrderItemCount} 个订单项` + : '' + const conflictText = + applied.mappingConflictCount > 0 + ? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理` + : '' + message.success( + `已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '接单模板初始化失败') + throw error + } finally { + setBootstrapApplying(false) + } + }, + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '读取接单模板初始化预览失败') + } finally { + setBootstrapPreviewing(false) + } + } + + function openEditProduct(item: ProductCatalogItem) { + setEditingProduct(item) + productForm.setFieldsValue({ + canonicalSku: item.canonicalSku, + canonicalName: item.canonicalName, + category: item.category, + unitName: item.unitName, + status: item.status, + }) + setProductModalOpen(true) + } + + async function submitProduct(values: ProductFormValues) { + try { + if (editingProduct) { + await updateAdminProductCatalog(editingProduct.id, { + canonicalName: values.canonicalName, + category: values.category || '', + unitName: values.unitName || '件', + status: values.status || 'active', + }) + message.success('标准商品已更新') + } else { + await createAdminProductCatalog(values) + message.success('标准商品已创建') + } + setProductModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存标准商品失败') + } + } + + async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) { + if (!canConfig) return + setProductStatusUpdatingId(row.id) + try { + await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' }) + message.success(checked ? '标准商品已启用' : '标准商品已归档') + await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '更新商品状态失败') + } finally { + setProductStatusUpdatingId(null) + } + } + + function openCreateMapping() { + setEditingMapping(null) + mappingForm.resetFields() + mappingForm.setFieldsValue({ + provider: providers[0] || '91kaquan', + matchType: 'sku_code', + quantityMultiplier: 1, + confidence: 'manual', + source: 'manual', + enabled: true, + }) + setMappingModalOpen(true) + } + + function openEditMapping(item: ChannelProductMapping) { + setEditingMapping(item) + mappingForm.setFieldsValue({ + provider: item.provider, + shopId: item.shopId, + platformSkuCode: item.platformSkuCode, + platformSkuName: item.platformSkuName, + matchType: item.matchType, + canonicalProductId: item.canonicalProductId, + quantityMultiplier: item.quantityMultiplier, + confidence: item.confidence, + source: item.source, + enabled: item.enabled, + note: item.note, + }) + setMappingModalOpen(true) + } + + async function submitMapping(values: MappingFormValues) { + try { + if (editingMapping) { + await updateAdminChannelProductMapping(editingMapping.id, values) + message.success('渠道映射已更新') + } else { + await createAdminChannelProductMapping(values) + message.success('渠道映射已创建') + } + setMappingModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存渠道映射失败') + } + } + + function openCreateCostItem() { + setEditingCostItem(null) + costItemForm.resetFields() + costItemForm.setFieldsValue({ + carrierType: 'work_order', + pricingMode: 'per_item', + unitName: '件', + status: 'active', + }) + setCostItemModalOpen(true) + } + + function openEditCostItem(item: CostItem) { + setEditingCostItem(item) + costItemForm.setFieldsValue({ + code: item.code, + name: item.name, + costType: item.costType, + carrierType: item.carrierType, + pricingMode: item.pricingMode, + unitName: item.unitName, + status: item.status, + note: item.note, + }) + setCostItemModalOpen(true) + } + + async function submitCostItem(values: CostItemFormValues) { + try { + if (editingCostItem) { + await updateAdminCostItem(editingCostItem.id, { + name: values.name, + costType: values.costType, + carrierType: values.carrierType, + pricingMode: values.pricingMode, + unitName: values.unitName || '件', + status: values.status || 'active', + note: values.note || '', + }) + message.success('成本项目已更新') + } else { + await createAdminCostItem(values) + message.success('成本项目已创建') + } + setCostItemModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存成本项目失败') + } + } + + function openCreateVersion() { + versionForm.resetFields() + versionForm.setFieldsValue({ + scopeType: 'global', + effectiveFrom: dayjs(), + status: 'active', + }) + setVersionModalOpen(true) + } + + async function submitVersion(values: CostVersionFormValues) { + try { + const isPercent = versionPricingMode === 'percent_of_revenue' + const usesProvider = values.scopeType === 'provider' || values.scopeType === 'shop' + await createAdminCostVersion({ + costItemId: values.costItemId, + scopeType: values.scopeType, + provider: usesProvider ? values.provider || '' : '', + shopId: values.scopeType === 'shop' ? values.shopId || '' : '', + canonicalProductId: values.scopeType === 'product' ? values.canonicalProductId : undefined, + workProductRuleId: values.scopeType === 'rule' ? values.workProductRuleId : undefined, + amountFen: isPercent ? 0 : Math.max(0, Math.round(Number(values.amountYuan || 0) * 100)), + rateBps: isPercent ? values.rateBps || 0 : 0, + effectiveFrom: values.effectiveFrom?.format('YYYY-MM-DD HH:mm:ss'), + effectiveTo: values.effectiveTo?.format('YYYY-MM-DD HH:mm:ss'), + status: values.status || 'active', + changeReason: values.changeReason || '', + }) + message.success('成本版本已创建') + setVersionModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存成本版本失败') + } + } + + function retireVersion(item: CostVersion) { + modal.confirm({ + title: '停用成本版本', + content: `确认停用「${item.costItemName} · ${item.scopeType}」这个版本?历史快照不会改变。`, + okText: '停用', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await retireAdminCostVersion(item.id, { changeReason: '后台手动停用' }) + message.success('成本版本已停用') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '停用成本版本失败') + } + }, + }) + } + + function resolveException(item: CostException, status: 'resolved' | 'ignored') { + modal.confirm({ + title: status === 'resolved' ? '标记异常已处理' : '忽略异常', + content: `确认处理「${item.reason}」?`, + okText: '确认', + cancelText: '取消', + onOk: async () => { + try { + await resolveAdminCostException(item.id, { status }) + message.success('成本异常已处理') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '处理成本异常失败') + } + }, + }) + } + + function backfillMappings() { + modal.confirm({ + title: '回填订单项商品映射', + content: + '按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。', + okText: '开始回填', + cancelText: '取消', + onOk: async () => { + try { + const response = await backfillAdminProductMappings({ limit: 20000, all: true }) + const { updatedCount, batches, remainingCount } = response.data + message.success( + `已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填商品映射失败') + } + }, + }) + } + + function backfillQuantities() { + modal.confirm({ + title: '回填归一数量', + content: '按 SKU 名称/编码解析数量,自动分批处理全部订单项;解析不到时按 1 件记录。', + okText: '开始回填', + cancelText: '取消', + onOk: async () => { + try { + const response = await backfillAdminNormalizedQuantities({ limit: 20000, all: true }) + const { updatedCount, remainingCount, batches } = response.data + message.success( + `已回填 ${updatedCount} 条归一数量(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条` : ''}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填归一数量失败') + } + }, + }) + } + + async function runPreview() { + if (!previewOrderItemId) { + message.warning('请输入订单项 ID') + return + } + setPreviewing(true) + setPreview(null) + try { + const response = await previewAdminCost({ orderItemId: previewOrderItemId }) + setPreview(response.data) + } catch (error) { + message.error(error instanceof Error ? error.message : '成本预览失败') + } finally { + setPreviewing(false) + } + } + + async function createSnapshot() { + const orderItemId = preview?.context.orderItemId + if (!orderItemId) return + setSnapshotting(true) + try { + const response = await createAdminCostSnapshot({ orderItemId }) + const created = response.data.createdCount + const exceptionCreated = response.data.exceptionCreatedCount + message.success( + created > 0 + ? `已生成 ${created} 条成本快照${exceptionCreated > 0 ? `,新增 ${exceptionCreated} 条异常` : ''}` + : `没有新增快照${exceptionCreated > 0 ? `,已记录 ${exceptionCreated} 条异常` : ',已有快照保持不变'}`, + ) + setPreview(response.data.preview) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '生成成本快照失败') + } finally { + setSnapshotting(false) + } + } + + const productColumns: TableColumnsType = [ + { + title: '商品 / 分类', + dataIndex: 'canonicalName', + render: (value: string, row) => ( +
+ + {value} + + + + {row.canonicalSku} + + + {row.rootCategoryId + ? `${row.rootCategoryName}${row.subCategoryName ? ` / ${row.subCategoryName}` : ''}` + : '未分类'} + + + 单位:{row.unitName || '件'} + + +
+ ), + }, + { + title: '已绑定渠道', + dataIndex: 'mappingCount', + width: 130, + render: (value: number) => + value > 0 ? ( + 已匹配 {value} 条 + ) : ( + 未绑定 + ), + }, + { + title: '成本版本', + dataIndex: 'costVersionCount', + width: 110, + render: (value: number) => + value > 0 ? ( + {value} 个版本 + ) : ( + 未配置 + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: ProductCatalogItem['status'], row) => ( + + void toggleProductStatus(row, checked)} + /> + + {value === 'active' ? '启用' : '归档'} + + + ), + }, + { + title: '操作', + width: 70, + fixed: 'right', + render: (_, row) => + canConfig ? ( + + ) : null, + }, + ] + + const mappingGroupColumns: TableColumnsType = [ + { + title: '标准商品', + dataIndex: 'canonicalName', + width: 260, + render: (value: string, row) => ( + + + + {value} + + {row.canonicalSku} · {row.unitName} + + + + ), + }, + { + title: '分类', + dataIndex: 'category', + width: 120, + render: (value: string) => + value || 未分类, + }, + { + title: '覆盖范围', + width: 220, + render: (_, row) => { + const providers = Array.from(new Set(row.mappings.map((item) => item.provider))) + const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`)) + .size + return ( + + {providers.map((provider) => ( + {providerLabel(provider)} + ))} + {shops} 个店铺键 + + ) + }, + }, + { + title: '映射', + width: 150, + render: (_, row) => { + const enabledCount = row.mappings.filter((item) => item.enabled).length + return ( + + {row.mappings.length} 条 + 启用 {enabledCount} + + ) + }, + }, + { + title: '匹配方式', + width: 280, + render: (_, row) => { + const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType))) + return ( + + {matchTypes.map((matchType) => ( + + {mappingMatchTypeLabel(matchType)} + + ))} + + ) + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (value: string) => ( + + {value === 'active' ? '启用' : '归档'} + + ), + }, + { + title: '目录', + width: 90, + fixed: 'right', + render: (_, row) => { + const key = String(row.canonicalProductId) + const expanded = expandedMappingProductIds.includes(key) + return ( + + ) + }, + }, + ] + + const costItemColumns: TableColumnsType = [ + { title: '编码', dataIndex: 'code', width: 180 }, + { title: '名称', dataIndex: 'name', minWidth: 160 }, + { + title: '类型', + dataIndex: 'costType', + width: 150, + render: (value: string) => labelOf(COST_TYPE_OPTIONS, value), + }, + { + title: '载体', + dataIndex: 'carrierType', + width: 110, + render: (value: string) => labelOf(CARRIER_OPTIONS, value), + }, + { + title: '计价', + dataIndex: 'pricingMode', + width: 120, + render: (value: string) => labelOf(PRICING_OPTIONS, value), + }, + { title: '版本数', dataIndex: 'versionCount', width: 90 }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'active' ? '启用' : '归档'} + + ), + }, + { + title: '操作', + width: 90, + fixed: 'right', + render: (_, row) => + canConfig ? ( + + ) : null, + }, + ] + + const versionColumns: TableColumnsType = [ + { + title: '成本项', + dataIndex: 'costItemName', + minWidth: 160, + render: (value: string, row) => ( + + {value} + {row.costItemCode} + + ), + }, + { + title: '范围', + dataIndex: 'scopeType', + width: 110, + render: (value: string, row) => { + if (value === 'global') return '全局' + if (value === 'provider') return providerLabel(row.provider) + if (value === 'shop') return `${providerLabel(row.provider)} / ${row.shopId}` + if (value === 'product') return row.canonicalName || `商品 #${row.canonicalProductId}` + return `规则 #${row.workProductRuleId}` + }, + }, + { + title: '金额/比例', + width: 130, + render: (_, row) => + row.pricingMode === 'percent_of_revenue' + ? formatRate(row.rateBps) + : formatMoney(row.amountFen), + }, + { + title: '生效时间', + dataIndex: 'effectiveFrom', + width: 190, + render: (value: string, row) => ( + + {formatAdminDateTime(value)} + + {row.effectiveTo ? `至 ${formatAdminDateTime(row.effectiveTo)}` : '长期'} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'} + + ), + }, + { + title: '操作', + width: 90, + fixed: 'right', + render: (_, row) => + canConfig && row.status === 'active' ? ( + + ) : null, + }, + ] + + const exceptionColumns: TableColumnsType = [ + { title: '主体', dataIndex: 'subjectType', width: 110 }, + { title: '订单项', dataIndex: 'orderItemId', width: 100, render: (v) => (v ? `#${v}` : '-') }, + { title: '载体', dataIndex: 'carrierType', width: 110 }, + { + title: '原因', + dataIndex: 'reason', + minWidth: 220, + render: (value: string, row) => ( + + {value} + {String(row.detail?.message || '')} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'open' ? '待处理' : value === 'resolved' ? '已处理' : '已忽略'} + + ), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 180, + render: (value: string) => formatAdminDateTime(value), + }, + { + title: '操作', + width: 170, + render: (_, row) => + canConfig && row.status === 'open' ? ( + + + + + ) : null, + }, + ] + + return ( +
+ } onClick={refreshAll} loading={overviewQuery.isFetching}> + 刷新 + + } + /> + + + {[ + ['标准商品', overview?.productCount || 0], + ['渠道映射', overview?.mappingCount || 0], + ['成本项目', overview?.costItemCount || 0], + ['生效版本', overview?.activeVersionCount || 0], + ['待处理异常', overview?.openExceptionCount || 0], + ['未映射订单项', overview?.unmappedOrderItemCount || 0], + ].map(([label, value]) => ( + + + + + + ))} + + + {!canConfig ? ( + + ) : null} + + + 标准商品目录 + {productCatalogTotal} + {productRootCategoryId ? 当前 {productTotal} : null} + + } + extra={ + + { + setProductKeyword(event.target.value) + setProductPage(1) + }} + onSearch={() => setProductPage(1)} + /> + + + + ) : null} + + } + > +
+ { + if (key === 'all') { + setProductRootCategoryId(null) + setProductSubCategoryId(null) + } else if (key === 'uncategorized') { + setProductRootCategoryId(-1) + setProductSubCategoryId(null) + } else { + setProductRootCategoryId(Number(key)) + setProductSubCategoryId(null) + } + setProductPage(1) + }} + items={[ + { key: 'all', label: `全部 (${productCatalogTotal})` }, + ...productCategoryTree.map((root) => ({ + key: String(root.categoryId), + label: `${root.name} (${root.total})`, + })), + ...(productUncategorizedTotal > 0 + ? [ + { + key: 'uncategorized', + label: `未分类 (${productUncategorizedTotal})`, + }, + ] + : []), + ]} + /> +
+ + {selectedProductRoot && productSubCategories.length > 0 ? ( +
+
+ + 子分类: +
+
+ + {productSubCategories.map((child) => ( + + ))} +
+
+ ) : null} + + (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')} + scroll={{ x: 960 }} + pagination={{ + current: productPage, + pageSize: PAGE_SIZE, + total: productTotal, + showSizeChanger: false, + showTotal: (total) => `共 ${total} 个商品`, + onChange: setProductPage, + }} + /> + + ), + }, + { + key: 'mappings', + label: '渠道映射', + children: ( + + 渠道映射目录 + {mappingTotal} 条映射 + {mappingGroupTotal} 个标准商品 + + } + extra={ + + {canConfig ? ( + <> + + + + + ) : null} + + } + > + + } + placeholder="标准商品 / 平台 SKU" + value={mappingKeyword} + onChange={(event) => { + setMappingKeyword(event.target.value) + setMappingPage(1) + setExpandedMappingProductIds([]) + }} + style={{ width: 260 }} + /> + } + placeholder="编码 / 名称" + value={costItemKeyword} + onChange={(event) => { + setCostItemKeyword(event.target.value) + setCostItemPage(1) + }} + style={{ width: 260 }} + /> + +
+ + ), + }, + { + key: 'cost-versions', + label: '成本版本', + children: ( + } onClick={openCreateVersion}> + 新建版本 + + ) : null + } + > + + } + placeholder="成本项 / 商品" + value={versionKeyword} + onChange={(event) => { + setVersionKeyword(event.target.value) + setVersionPage(1) + }} + style={{ width: 260 }} + /> +
+ + ), + }, + { + key: 'exceptions', + label: '成本异常', + children: ( + { + setExceptionStatus(value) + setExceptionPage(1) + }} + options={[ + { value: 'open', label: '待处理' }, + { value: 'resolved', label: '已处理' }, + { value: 'ignored', label: '已忽略' }, + { value: '', label: '全部' }, + ]} + style={{ width: 140 }} + /> + } + > +
+ + ), + }, + { + key: 'preview', + label: '成本预览', + children: ( + + + { + const nextId = value ? Number(value) : null + setPreviewOrderItemId(nextId) + if (nextId !== preview?.context.orderItemId) { + setPreview(null) + } + }} + style={{ width: 180 }} + /> + + {canConfig ? ( + + ) : null} + + + {!preview ? ( + + ) : ( + + + + #{preview.context.orderItemId} + + + {providerLabel(preview.context.provider)} /{' '} + {preview.context.shopId || '全局'} + + + {preview.context.skuCode} · {preview.context.skuName || '-'} + + + {Number(preview.context.normalizedQuantity || 0).toFixed(3)} + + + {formatMoney(preview.context.revenueFen)} + + + {preview.context.carrierType} + + + {preview.product + ? `${preview.product.canonicalName}(${preview.product.canonicalSku})` + : '未映射'} + + + + {formatMoney(preview.totalCostFen)} + + + + + {preview.exceptions.length > 0 ? ( + item.message).join(';')} + /> + ) : null} + +
`${row.costItemId}-${row.costVersionId}`} + size="small" + pagination={false} + dataSource={preview.lines} + columns={[ + { title: '成本项', dataIndex: 'costItemName' }, + { title: '类型', dataIndex: 'costType' }, + { title: '范围', dataIndex: 'scopeType' }, + { + title: '数量', + dataIndex: 'quantity', + render: (value: number) => Number(value || 0).toFixed(3), + }, + { + title: '单位成本', + dataIndex: 'unitAmountFen', + render: (value: number) => formatMoney(value), + }, + { + title: '合计', + dataIndex: 'totalAmountFen', + render: (value: number) => formatMoney(value), + }, + ]} + /> + + )} + + ), + }, + ]} + /> + + setProductModalOpen(false)} + onOk={() => productForm.submit()} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + + + + + ({ + value: provider, + label: providerLabel(provider), + }))} + /> + + + + + + + + + + + + ) + }} + + + + + + + + + + + + + + + + + + + + + ({ + value: provider, + label: providerLabel(provider), + }))} + /> + + ) : null} + {versionScopeType === 'shop' ? ( + + + + ) : null} + {versionScopeType === 'product' ? ( + + + + ) : null} + {versionPricingMode === 'percent_of_revenue' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + +
+ + ) +} diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index 2c0ad06d..ac07cbe1 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -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() { 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, }), diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts new file mode 100644 index 00000000..0f200a84 --- /dev/null +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -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('/api/v1/admin/cost-center/overview') +} + +export function fetchAdminCostCenterOptions() { + return apiGet('/api/v1/admin/cost-center/options') +} + +export function previewAdminTemplateProductBootstrap() { + return apiGet( + '/api/v1/admin/cost-center/template-bootstrap/preview', + ) +} + +export function applyAdminTemplateProductBootstrap() { + return apiPost( + '/api/v1/admin/cost-center/template-bootstrap/apply', + { repair: true }, + ) +} + +export function fetchAdminProductCatalog(params?: Record) { + return apiGet('/api/v1/admin/cost-center/products', params) +} + +export function createAdminProductCatalog(payload: Record) { + return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload) +} + +export function updateAdminProductCatalog(productId: number, payload: Record) { + return apiPatch<{ product: ProductCatalogItem }>( + `/api/v1/admin/cost-center/products/${productId}`, + payload, + ) +} + +export function fetchAdminChannelProductMappings(params?: Record) { + return apiGet('/api/v1/admin/cost-center/mappings', params) +} + +export function createAdminChannelProductMapping(payload: Record) { + return apiPost<{ mapping: ChannelProductMapping }>('/api/v1/admin/cost-center/mappings', payload) +} + +export function updateAdminChannelProductMapping( + mappingId: number, + payload: Record, +) { + return apiPatch<{ mapping: ChannelProductMapping }>( + `/api/v1/admin/cost-center/mappings/${mappingId}`, + payload, + ) +} + +export function backfillAdminProductMappings(payload: Record) { + return apiPost<{ + updatedCount: number + limit: number + batches: number + remainingCount: number + }>('/api/v1/admin/cost-center/mappings/backfill', payload) +} + +export function backfillAdminNormalizedQuantities(payload: Record) { + return apiPost<{ + updatedCount: number + limit: number + batches: number + remainingCount: number + }>('/api/v1/admin/cost-center/normalized-quantities/backfill', payload) +} + +export function fetchAdminCostItems(params?: Record) { + return apiGet('/api/v1/admin/cost-center/cost-items', params) +} + +export function createAdminCostItem(payload: Record) { + return apiPost<{ costItem: CostItem }>('/api/v1/admin/cost-center/cost-items', payload) +} + +export function updateAdminCostItem(costItemId: number, payload: Record) { + return apiPatch<{ costItem: CostItem }>( + `/api/v1/admin/cost-center/cost-items/${costItemId}`, + payload, + ) +} + +export function fetchAdminCostVersions(params?: Record) { + return apiGet('/api/v1/admin/cost-center/cost-versions', params) +} + +export function createAdminCostVersion(payload: Record) { + return apiPost<{ version: CostVersion }>('/api/v1/admin/cost-center/cost-versions', payload) +} + +export function retireAdminCostVersion(versionId: number, payload: Record) { + return apiPatch<{ version: CostVersion }>( + `/api/v1/admin/cost-center/cost-versions/${versionId}/retire`, + payload, + ) +} + +export function fetchAdminCostExceptions(params?: Record) { + return apiGet('/api/v1/admin/cost-center/exceptions', params) +} + +export function resolveAdminCostException(exceptionId: number, payload: Record) { + return apiPatch<{ exception: CostException }>( + `/api/v1/admin/cost-center/exceptions/${exceptionId}`, + payload, + ) +} + +export function previewAdminCost(payload: Record) { + return apiPost('/api/v1/admin/cost-center/preview', payload) +} + +export function createAdminCostSnapshot(payload: Record) { + return apiPost<{ + preview: CostPreview + snapshots: Array> + createdCount: number + exceptionCreatedCount: number + }>('/api/v1/admin/cost-center/snapshots', payload) +} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 1c560172..962dcab5 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -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' diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts new file mode 100644 index 00000000..bb1f0f75 --- /dev/null +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -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 + 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 + 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 }> +} + +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 } +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index ec6c944f..747827b2 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -122,3 +122,5 @@ export type { AdminAffiliateDashProductListResult, AdminAffiliateDashWallet, } from './platform-config' + +export type * from './cost-center' diff --git a/docs/README.md b/docs/README.md index 2deba47a..6e035ac5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. 财务系统实现顺序以本索引 §三 为准;单篇文档里的分期若与本索引冲突,以本索引为准。 diff --git a/docs/载体判定与成本中心落地-复核与草案-2026-10-17.md b/docs/archive/2026-10-17/载体判定与成本中心落地-复核与草案.md similarity index 87% rename from docs/载体判定与成本中心落地-复核与草案-2026-10-17.md rename to docs/archive/2026-10-17/载体判定与成本中心落地-复核与草案.md index e121e90c..9e68c6a7 100644 --- a/docs/载体判定与成本中心落地-复核与草案-2026-10-17.md +++ b/docs/archive/2026-10-17/载体判定与成本中心落地-复核与草案.md @@ -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`。 diff --git a/docs/archive/README.md b/docs/archive/README.md index 900cbce6..1e07b7e2 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -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/`、`抖音-码速达/消息推送/`)。 - 历史文档中的代码行号、测试数量、迁移数量不要直接引用。 diff --git a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql index decf59d2..c096c0a5 100644 --- a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql +++ b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql @@ -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 diff --git a/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql index 784d3810..a59607be 100644 --- a/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql +++ b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql @@ -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 与派生金额,永不回写历史。 diff --git a/docs/drafts/2026-10-17-载体判定与人工块量化.sql b/docs/drafts/2026-10-17-载体判定与人工块量化.sql index ca9d276f..a609e48e 100644 --- a/docs/drafts/2026-10-17-载体判定与人工块量化.sql +++ b/docs/drafts/2026-10-17-载体判定与人工块量化.sql @@ -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 条口径。 -- ============================================================================ diff --git a/docs/人工支出登记落地方案-2026-10-17.md b/docs/人工支出登记落地方案-2026-10-17.md index 5a466ab6..b9f6deee 100644 --- a/docs/人工支出登记落地方案-2026-10-17.md +++ b/docs/人工支出登记落地方案-2026-10-17.md @@ -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`;进“未归类”异常队列 | --- diff --git a/docs/客服备用金台账实施方案-2026-10-18.md b/docs/客服备用金台账实施方案-2026-10-18.md index 1f7a3e61..c45efae9 100644 --- a/docs/客服备用金台账实施方案-2026-10-18.md +++ b/docs/客服备用金台账实施方案-2026-10-18.md @@ -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`,不要复用或改名。 +- 备用金支出可作为“钱从客服备用金出”的来源证据;公司直付、垫付报销仍走公司人工支出登记。 --- diff --git a/docs/履约配置/多发货平台与电子凭证整体链路.md b/docs/履约配置/多发货平台与电子凭证整体链路.md index 0f099c84..7697cccd 100644 --- a/docs/履约配置/多发货平台与电子凭证整体链路.md +++ b/docs/履约配置/多发货平台与电子凭证整体链路.md @@ -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. 当前结论 diff --git a/docs/影子总账说明.md b/docs/影子总账说明.md index 4caf0048..73270a78 100644 --- a/docs/影子总账说明.md +++ b/docs/影子总账说明.md @@ -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. **连续对平后转正**:总账成为权威,钱包改为投影;保留回滚开关。 --- diff --git a/docs/成本中心与三块履约模型.md b/docs/成本中心与三块履约模型.md index 9371117d..303122d4 100644 --- a/docs/成本中心与三块履约模型.md +++ b/docs/成本中心与三块履约模型.md @@ -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:`;暂时没有历史工单的模板也先建商品。 +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、超时升级、主管复核阈值、监管指标 diff --git a/docs/抖音-码速达/10-三层分流设计.md b/docs/抖音-码速达/10-三层分流设计.md index 7a4d0c8a..3e9fde0c 100644 --- a/docs/抖音-码速达/10-三层分流设计.md +++ b/docs/抖音-码速达/10-三层分流设计.md @@ -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) diff --git a/docs/抖音-码速达/12-按SKU分类改造总设计.md b/docs/抖音-码速达/12-按SKU分类改造总设计.md index a2dc8a1d..3c185447 100644 --- a/docs/抖音-码速达/12-按SKU分类改造总设计.md +++ b/docs/抖音-码速达/12-按SKU分类改造总设计.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` 来源的两条数据通道,不拆成两个来源** diff --git a/docs/接单模板匹配引擎重构设计.md b/docs/接单模板匹配引擎重构设计.md index 08fdcdac..5e97e6db 100644 --- a/docs/接单模板匹配引擎重构设计.md +++ b/docs/接单模板匹配引擎重构设计.md @@ -1,6 +1,6 @@ # 接单模板匹配引擎重构设计 -> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-17(`main @ 6850bc64`)。 +> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-18(`main @ 7542d547`;仅基线更新,业务逻辑未变)。 > > 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。 > diff --git a/docs/财务系统集成方案.md b/docs/财务系统集成方案.md index b254a7c9..c9f89ebb 100644 --- a/docs/财务系统集成方案.md +++ b/docs/财务系统集成方案.md @@ -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`。 - 后台增加凭证簿与试算平衡。 diff --git a/docs/项目全量分析报告-2026-10-17.md b/docs/项目全量分析报告-2026-10-17.md index 7ed6459a..66994e5d 100644 --- a/docs/项目全量分析报告-2026-10-17.md +++ b/docs/项目全量分析报告-2026-10-17.md @@ -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 天订单基本是一单一品,但模型必须按订单项设计。