From fcd9b607d4ab32bc602d600abfbe8d07c1049860 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 12:02:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cost-center):=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=B1=82=E6=8C=89=E8=81=8C=E8=B4=A3=E6=8B=86=E4=B8=BA=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cost-center-shared.ts 804 行同时装枚举、行类型、映射、归一化,任何一处改动都会 让 8 个引用方重新编译。按职责拆开,本文件退化为统一出口(export *): - cost-center-constants.ts 131 枚举、常量、SQL 片段 - cost-center-types.ts 220 行结构与 DTO 类型 - cost-center-mappers.ts 250 行映射与口径判断(含 CostPreview) - cost-center-normalizers.ts 254 入参归一化与断言 同时消掉一个类型环:CostPreview 用 ReturnType 反推 mapper 的 返回类型,导致 types → mappers → types 互指;CostPreview 随 mapper 一起放进 mappers,依赖恢复单向。 出口保持向后兼容,8 个引用方无需改动;71 个导出逐一比对无缺失。 验证:typecheck / lint / 773 测试通过。 --- .../cost-center/cost-center-constants.ts | 131 +++ .../cost-center/cost-center-mappers.ts | 251 ++++++ .../cost-center/cost-center-normalizers.ts | 248 ++++++ .../cost-center/cost-center-shared.ts | 808 +----------------- .../services/cost-center/cost-center-types.ts | 197 +++++ 5 files changed, 840 insertions(+), 795 deletions(-) create mode 100644 apps/backend/src/services/cost-center/cost-center-constants.ts create mode 100644 apps/backend/src/services/cost-center/cost-center-mappers.ts create mode 100644 apps/backend/src/services/cost-center/cost-center-normalizers.ts create mode 100644 apps/backend/src/services/cost-center/cost-center-types.ts diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts new file mode 100644 index 00000000..7eb97a74 --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -0,0 +1,131 @@ +/** + * 成本中心共享层 · 枚举、常量与 SQL 片段。 + * + * 这些值是数据库约束与接口枚举的唯一真源,改动前先看迁移与 API 契约。 + */ + +// --------------------------------------------------------------------------- +// 枚举 +// --------------------------------------------------------------------------- + +export const PRODUCT_STATUSES = new Set(['active', 'archived']) +export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual']) +export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule']) +export const MAPPING_MATCH_TYPES = new Set([ + 'sku_code', + 'sku_code_name', + 'sku_code_series', + 'sku_name_series', +]) + +export type TemplateMappingMatchType = + | 'sku_code' + | 'sku_code_name' + | 'sku_code_series' + | 'sku_name_series' + +/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */ +export type CostModel = + | 'affiliate_purchase' + | 'work_order_reward' + | 'manual_actual' + | 'platform_fee' + | 'payment_fee' + | 'after_sales_loss' + | 'other' + +export const COST_MODELS = new Set([ + 'affiliate_purchase', + 'work_order_reward', + 'manual_actual', + 'platform_fee', + 'payment_fee', + 'after_sales_loss', + 'other', +]) + +/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */ +export const COST_MODEL_CODE_BASE: Record = { + affiliate_purchase: 'affiliate_purchase', + work_order_reward: 'work_order_reward', + manual_actual: 'manual_actual', + platform_fee: 'platform_fee', + payment_fee: 'payment_fee', + after_sales_loss: 'after_sales_loss', + other: 'other_cost', +} + +/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */ +export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual' + +export const FULFILLMENT_MODES = new Set([ + 'work_order', + 'affiliate_dash', + 'legacy_platform', + 'manual', +]) + +/** + * 与履约方式无关的成本模型:对所有履约方式生效。 + * 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。 + */ +export const GENERIC_COST_MODELS = [ + 'platform_fee', + 'payment_fee', + 'after_sales_loss', + 'other', +] as const + +export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue']) +export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule']) +export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import']) +export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired']) +export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored']) +export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case']) +export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida'] + +/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */ +export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00' + +/** + * 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。 + * 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。 + */ +export const PRODUCT_SOURCE_PREFIXES: Array<{ + prefix: string + source: 'affiliate_dash' | 'work_order' | 'manual' +}> = [ + { prefix: 'aff:', source: 'affiliate_dash' }, + { prefix: 'tpl:', source: 'work_order' }, + { prefix: 'wo:', source: 'work_order' }, + { prefix: 'man:', source: 'manual' }, +] + +export 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) +` + +/** + * 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff: 商品上。 + * 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。 + */ +export const AFFILIATE_TASK_AGG_JOIN = ` + LEFT JOIN ( + SELECT + btrim(context_json->>'skuCode') AS sku_code, + COUNT(*)::int AS task_count, + MAX(created_at) AS last_fulfilled_at + FROM fulfillment_tasks + WHERE executor_key = 'affiliate_dash' + AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' + GROUP BY 1 + ) aff ON p.canonical_sku LIKE 'aff:%' + AND aff.sku_code = substring(p.canonical_sku from 5) +` diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts new file mode 100644 index 00000000..a2219f8e --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -0,0 +1,251 @@ +/** + * 成本中心共享层 · 行映射与口径判断。 + * + * 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。 + */ +import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js' +import type { FulfillmentMode } from './cost-center-constants.js' +import { parseJsonObject } from './cost-center-normalizers.js' +import type { + CostExceptionDraft, + CostExceptionRow, + CostItemRow, + CostLine, + CostVersionRow, + MappingRow, + MissingCostItem, + ProductRow, +} from './cost-center-types.js' + +// --------------------------------------------------------------------------- +// 履约方式 / 商品来源 / 成本模型 +// --------------------------------------------------------------------------- + +/** + * 由 canonical_sku 前缀推导商品来源。 + * 返回空串表示未分类;未分类商品不允许反推履约方式。 + */ +export function productSourceOf(canonicalSku: string): string { + const sku = String(canonicalSku || '').trim() + for (const item of PRODUCT_SOURCE_PREFIXES) { + if (sku.startsWith(item.prefix)) return item.source + } + return '' +} + +/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ +export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { + return `( + ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') + OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) + OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') + )` +} + +/** + * 成本模型是否适用于该履约方式。 + * + * 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他) + * 对所有履约方式生效。 + */ +export function costModelAppliesTo(costModel: string, fulfillmentMode: string) { + if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true + if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash' + if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order' + if (costModel === 'manual_actual') return fulfillmentMode === 'manual' + return false +} + +/** + * 成本模型适用范围的 SQL 片段。 + * 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。 + */ +export function costModelScopeSql(fulfillmentModeExpression: string): string { + return `( + ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')}) + OR (ci.cost_model = 'affiliate_purchase' + AND ${fulfillmentModeExpression} = 'affiliate_dash') + OR (ci.cost_model = 'work_order_reward' + AND ${fulfillmentModeExpression} = 'work_order') + OR (ci.cost_model = 'manual_actual' + AND ${fulfillmentModeExpression} = 'manual') + )` +} + +/** 人工支出分类的默认推断:按成本模型归类。 */ +export function inferExpenseCategory(costModel: string | undefined) { + if (costModel === 'affiliate_purchase') return 'goods_purchase' + if (costModel === 'manual_actual') return 'service_fee' + if (costModel === 'other') return 'freight' + if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee' + return 'other' +} + +// --------------------------------------------------------------------------- +// 行映射 +// --------------------------------------------------------------------------- + +export function mapProduct(row: ProductRow) { + return { + id: Number(row.id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + category: row.category || '', + source: productSourceOf(row.canonical_sku), + 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), + affiliateTaskCount: Number(row.affiliate_task_count || 0), + affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export 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, + } +} + +export function mapCostItem(row: CostItemRow) { + return { + id: Number(row.id), + code: row.code, + name: row.name, + costModel: row.cost_model, + 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, + } +} + +export function mapCostVersion(row: CostVersionRow) { + const revenueItemCount = Number(row.revenue_item_count || 0) + const revenueFen = Number(row.revenue_fen || 0) + const rewardAvgFen = Number(row.reward_avg_fen || 0) + const rewardFen = Number(row.reward_fen || 0) + const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0) + const ruleRewardAmountFen = Number(row.rule_reward_amount || 0) + const amountKind = + row.cost_item_code === 'sys_work_order_reward' && + row.scope_type === 'rule' && + ruleUnitPriceFen > 0 && + ruleRewardAmountFen === 0 + ? 'unit_price' + : 'fixed' + return { + id: Number(row.id), + costItemId: Number(row.cost_item_id), + costItemCode: row.cost_item_code, + costItemName: row.cost_item_name, + costItemCostModel: row.cost_item_cost_model || 'other', + 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, + workProductRuleName: row.work_product_rule_name || '', + workProductRuleKey: row.work_product_rule_key || '', + ruleRewardAmountFen, + ruleUnitPriceFen, + revenueItemCount, + revenueFen, + revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0, + rewardAvgFen, + rewardFen, + amountKind, + isSystemPlaceholder: + row.cost_item_code === 'sys_work_order_reward' && + row.scope_type === 'global' && + Number(row.amount_fen) === 0, + 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, + } +} + +export 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, + fulfillmentMode: row.fulfillment_mode || '', + 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, + resolutionNote: row.resolution_note || '', + } +} + +export type CostPreview = { + context: { + orderItemId: number | null + orderId: number | null + provider: string + shopId: string + skuCode: string + skuName: string + fulfillmentMode: FulfillmentMode + 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[] +} diff --git a/apps/backend/src/services/cost-center/cost-center-normalizers.ts b/apps/backend/src/services/cost-center/cost-center-normalizers.ts new file mode 100644 index 00000000..31449fa1 --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-normalizers.ts @@ -0,0 +1,248 @@ +/** + * 成本中心共享层 · 入参归一化与断言。 + */ +import { createHttpError } from '../../utils/http.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' +import { MAPPING_MATCH_TYPES, PROVIDERS } from './cost-center-constants.js' +import type { TemplateMappingMatchType } from './cost-center-constants.js' + +// --------------------------------------------------------------------------- +// 归一化 +// --------------------------------------------------------------------------- + +export function normalizePage(value: unknown) { + return Math.max(1, Math.trunc(Number(value) || 1)) +} + +export function normalizePageSize(value: unknown) { + return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20))) +} + +export 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 +} + +export function normalizeOptionalId(value: unknown) { + if (value === undefined || value === null || value === '') return null + const id = Number(value) + return Number.isInteger(id) && id > 0 ? id : null +} + +export 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 +} + +export 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 +} + +export function normalizeOptionalText(value: unknown, maxLength: number) { + return text(value).slice(0, maxLength) +} + +export function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed > 0 ? parsed : fallback +} + +export function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed >= 0 ? parsed : fallback +} + +export function normalizeBoolean(value: unknown) { + return value === true || value === 'true' || value === 1 || value === '1' +} + +export 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 +} + +export function assertOneOf(value: string, allowed: Set, message: string) { + if (!allowed.has(value)) { + throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) + } +} + +export 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() +} + +export function normalizeJsonObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +export 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 {} +} + +export function text(value: unknown) { + return String(value ?? '').trim() +} + +/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */ +export function candidateKey( + provider: string, + shopId: string, + skuCode: string, + matchType: string, + skuNameKey: string, +) { + return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}` +} + +export function mappingKey( + provider: string, + shopId: string, + skuCode: string, + matchType = 'sku_code', + skuNameKey = '', +) { + return candidateKey(provider, shopId, skuCode, matchType, skuNameKey) +} + +export function mappingMatchTypeLabel(matchType: string) { + 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 名称系列' + default: + return matchType + } +} + +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) +} + +/** 成本映射的 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 +} + +export function assertKnownProvider(provider: string) { + if (provider && !PROVIDERS.includes(provider)) { + throw createHttpError('来源无效', { + statusCode: 400, + errorCode: 'cost_center_provider_invalid', + }) + } +} + +export function toNumber(value: unknown) { + return Number(value || 0) || 0 +} + +export 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/backend/src/services/cost-center/cost-center-shared.ts b/apps/backend/src/services/cost-center/cost-center-shared.ts index 50a5d0bc..d20b0511 100644 --- a/apps/backend/src/services/cost-center/cost-center-shared.ts +++ b/apps/backend/src/services/cost-center/cost-center-shared.ts @@ -1,804 +1,22 @@ /** - * 成本中心共享层:枚举、类型、归一化函数与行映射。 + * 成本中心共享层入口。 * * 口径(2026-10-23 重写): * 1. 履约方式只有 order_items.fulfillment_mode 一个真源; * 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交; * 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体; * 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。 - */ -import { createHttpError } from '../../utils/http.js' -import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' - -// --------------------------------------------------------------------------- -// 枚举 -// --------------------------------------------------------------------------- - -export const PRODUCT_STATUSES = new Set(['active', 'archived']) -export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual']) -export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule']) -export const MAPPING_MATCH_TYPES = new Set([ - 'sku_code', - 'sku_code_name', - 'sku_code_series', - 'sku_name_series', -]) - -export type TemplateMappingMatchType = - | 'sku_code' - | 'sku_code_name' - | 'sku_code_series' - | 'sku_name_series' - -/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */ -export type CostModel = - | 'affiliate_purchase' - | 'work_order_reward' - | 'manual_actual' - | 'platform_fee' - | 'payment_fee' - | 'after_sales_loss' - | 'other' - -export const COST_MODELS = new Set([ - 'affiliate_purchase', - 'work_order_reward', - 'manual_actual', - 'platform_fee', - 'payment_fee', - 'after_sales_loss', - 'other', -]) - -/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */ -export const COST_MODEL_CODE_BASE: Record = { - affiliate_purchase: 'affiliate_purchase', - work_order_reward: 'work_order_reward', - manual_actual: 'manual_actual', - platform_fee: 'platform_fee', - payment_fee: 'payment_fee', - after_sales_loss: 'after_sales_loss', - other: 'other_cost', -} - -/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */ -export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual' - -export const FULFILLMENT_MODES = new Set([ - 'work_order', - 'affiliate_dash', - 'legacy_platform', - 'manual', -]) - -/** - * 与履约方式无关的成本模型:对所有履约方式生效。 - * 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。 - */ -export const GENERIC_COST_MODELS = [ - 'platform_fee', - 'payment_fee', - 'after_sales_loss', - 'other', -] as const - -export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue']) -export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule']) -export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import']) -export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired']) -export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored']) -export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case']) -export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida'] - -/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */ -export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00' - -/** - * 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。 - * 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。 - */ -export const PRODUCT_SOURCE_PREFIXES: Array<{ - prefix: string - source: 'affiliate_dash' | 'work_order' | 'manual' -}> = [ - { prefix: 'aff:', source: 'affiliate_dash' }, - { prefix: 'tpl:', source: 'work_order' }, - { prefix: 'wo:', source: 'work_order' }, - { prefix: 'man:', source: 'manual' }, -] - -export 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) -` - -/** - * 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff: 商品上。 - * 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。 - */ -export const AFFILIATE_TASK_AGG_JOIN = ` - LEFT JOIN ( - SELECT - btrim(context_json->>'skuCode') AS sku_code, - COUNT(*)::int AS task_count, - MAX(created_at) AS last_fulfilled_at - FROM fulfillment_tasks - WHERE executor_key = 'affiliate_dash' - AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' - GROUP BY 1 - ) aff ON p.canonical_sku LIKE 'aff:%' - AND aff.sku_code = substring(p.canonical_sku from 5) -` - -// --------------------------------------------------------------------------- -// 行类型 -// --------------------------------------------------------------------------- - -export 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 - affiliate_task_count?: number - affiliate_last_fulfilled_at?: string | null -} - -export type ProductCategoryStatRow = { - category_id: number | null - name: string | null - parent_id: number | null - total: number -} - -export type WorkCategoryRow = { - id: number - name: string - parent_id: number | null -} - -export type ProductCategoryNode = { - categoryId: number - name: string - total: number - directTotal: number - children: Array<{ - categoryId: number - name: string - total: number - }> -} - -export 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 -} - -export type CostItemRow = { - [column: string]: unknown - id: number - code: string - name: string - cost_model: string - pricing_mode: string - unit_name: string - currency: string - status: string - note: string - created_at: string - updated_at: string - version_count?: number -} - -export type CostVersionRow = { - [column: string]: unknown - id: number - cost_item_id: number - cost_item_code: string - cost_item_name: string - cost_item_cost_model: 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 - work_product_rule_name: string | null - work_product_rule_key: string | null - rule_reward_amount: number | null - rule_unit_price_fen: number | null - revenue_item_count: number | null - revenue_fen: number | null - reward_avg_fen: number | null - reward_fen: 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 -} - -export type CostExceptionRow = { - [column: string]: unknown - id: number - subject_type: string - subject_id: number - order_item_id: number | null - work_order_id: number | null - fulfillment_mode: string | null - reason: string - status: string - detail_json: unknown - created_at: string - resolved_at: string | null - resolved_by_admin_user_id: number | null - resolution_note: string -} - -export 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 - basisAt: string - workOrderId: number | null - workProductRuleId: number | null - fulfillmentMode: FulfillmentMode -} - -export type CostLine = { - costItemId: number - costItemCode: string - costItemName: string - costModel: CostModel - pricingMode: string - costVersionId: number - scopeType: string - amountFen: number - rateBps: number - quantity: number - unitAmountFen: number - totalAmountFen: number -} - -export type CostExceptionDraft = { - reason: string - message: string - detail: Record -} - -export type MissingCostItem = { - costItemId: number - costItemCode: string - costItemName: string - costModel: CostModel -} - -export type CostResolution = { - lines: CostLine[] - missingCostItems: MissingCostItem[] - applicableCostItemCount: number -} - -export type CostPreview = { - context: { - orderItemId: number | null - orderId: number | null - provider: string - shopId: string - skuCode: string - skuName: string - fulfillmentMode: FulfillmentMode - 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[] -} - -// --------------------------------------------------------------------------- -// 履约方式 / 商品来源 / 成本模型 -// --------------------------------------------------------------------------- - -/** - * 由 canonical_sku 前缀推导商品来源。 - * 返回空串表示未分类;未分类商品不允许反推履约方式。 - */ -export function productSourceOf(canonicalSku: string): string { - const sku = String(canonicalSku || '').trim() - for (const item of PRODUCT_SOURCE_PREFIXES) { - if (sku.startsWith(item.prefix)) return item.source - } - return '' -} - -/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ -export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { - return `( - ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') - OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) - OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') - )` -} - -/** - * 成本模型是否适用于该履约方式。 * - * 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他) - * 对所有履约方式生效。 + * 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口, + * 既有 import 保持不变: + * - cost-center-constants.ts 枚举、常量、SQL 片段 + * - cost-center-types.ts 行结构与 DTO 类型 + * - cost-center-mappers.ts 行映射与口径判断 + * - cost-center-normalizers.ts 入参归一化与断言 + * + * 新代码可以直接从对应模块导入;跨模块批量引用时才用本入口。 */ -export function costModelAppliesTo(costModel: string, fulfillmentMode: string) { - if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true - if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash' - if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order' - if (costModel === 'manual_actual') return fulfillmentMode === 'manual' - return false -} - -/** - * 成本模型适用范围的 SQL 片段。 - * 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。 - */ -export function costModelScopeSql(fulfillmentModeExpression: string): string { - return `( - ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')}) - OR (ci.cost_model = 'affiliate_purchase' - AND ${fulfillmentModeExpression} = 'affiliate_dash') - OR (ci.cost_model = 'work_order_reward' - AND ${fulfillmentModeExpression} = 'work_order') - OR (ci.cost_model = 'manual_actual' - AND ${fulfillmentModeExpression} = 'manual') - )` -} - -/** 人工支出分类的默认推断:按成本模型归类。 */ -export function inferExpenseCategory(costModel: string | undefined) { - if (costModel === 'affiliate_purchase') return 'goods_purchase' - if (costModel === 'manual_actual') return 'service_fee' - if (costModel === 'other') return 'freight' - if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee' - return 'other' -} - -// --------------------------------------------------------------------------- -// 行映射 -// --------------------------------------------------------------------------- - -export function mapProduct(row: ProductRow) { - return { - id: Number(row.id), - canonicalSku: row.canonical_sku, - canonicalName: row.canonical_name, - category: row.category || '', - source: productSourceOf(row.canonical_sku), - 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), - affiliateTaskCount: Number(row.affiliate_task_count || 0), - affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -export 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, - } -} - -export function mapCostItem(row: CostItemRow) { - return { - id: Number(row.id), - code: row.code, - name: row.name, - costModel: row.cost_model, - 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, - } -} - -export function mapCostVersion(row: CostVersionRow) { - const revenueItemCount = Number(row.revenue_item_count || 0) - const revenueFen = Number(row.revenue_fen || 0) - const rewardAvgFen = Number(row.reward_avg_fen || 0) - const rewardFen = Number(row.reward_fen || 0) - const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0) - const ruleRewardAmountFen = Number(row.rule_reward_amount || 0) - const amountKind = - row.cost_item_code === 'sys_work_order_reward' && - row.scope_type === 'rule' && - ruleUnitPriceFen > 0 && - ruleRewardAmountFen === 0 - ? 'unit_price' - : 'fixed' - return { - id: Number(row.id), - costItemId: Number(row.cost_item_id), - costItemCode: row.cost_item_code, - costItemName: row.cost_item_name, - costItemCostModel: row.cost_item_cost_model || 'other', - 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, - workProductRuleName: row.work_product_rule_name || '', - workProductRuleKey: row.work_product_rule_key || '', - ruleRewardAmountFen, - ruleUnitPriceFen, - revenueItemCount, - revenueFen, - revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0, - rewardAvgFen, - rewardFen, - amountKind, - isSystemPlaceholder: - row.cost_item_code === 'sys_work_order_reward' && - row.scope_type === 'global' && - Number(row.amount_fen) === 0, - 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, - } -} - -export 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, - fulfillmentMode: row.fulfillment_mode || '', - 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, - resolutionNote: row.resolution_note || '', - } -} - -// --------------------------------------------------------------------------- -// 归一化 -// --------------------------------------------------------------------------- - -export function normalizePage(value: unknown) { - return Math.max(1, Math.trunc(Number(value) || 1)) -} - -export function normalizePageSize(value: unknown) { - return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20))) -} - -export 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 -} - -export function normalizeOptionalId(value: unknown) { - if (value === undefined || value === null || value === '') return null - const id = Number(value) - return Number.isInteger(id) && id > 0 ? id : null -} - -export 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 -} - -export 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 -} - -export function normalizeOptionalText(value: unknown, maxLength: number) { - return text(value).slice(0, maxLength) -} - -export function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Math.trunc(Number(value) || fallback) - return parsed > 0 ? parsed : fallback -} - -export function normalizeNonNegativeInteger(value: unknown, fallback: number) { - const parsed = Math.trunc(Number(value) || fallback) - return parsed >= 0 ? parsed : fallback -} - -export function normalizeBoolean(value: unknown) { - return value === true || value === 'true' || value === 1 || value === '1' -} - -export 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 -} - -export function assertOneOf(value: string, allowed: Set, message: string) { - if (!allowed.has(value)) { - throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) - } -} - -export 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() -} - -export function normalizeJsonObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record -} - -export 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 {} -} - -export function text(value: unknown) { - return String(value ?? '').trim() -} - -/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */ -export function candidateKey( - provider: string, - shopId: string, - skuCode: string, - matchType: string, - skuNameKey: string, -) { - return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}` -} - -export function mappingKey( - provider: string, - shopId: string, - skuCode: string, - matchType = 'sku_code', - skuNameKey = '', -) { - return candidateKey(provider, shopId, skuCode, matchType, skuNameKey) -} - -export function mappingMatchTypeLabel(matchType: string) { - 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 名称系列' - default: - return matchType - } -} - -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) -} - -/** 成本映射的 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 -} - -export function assertKnownProvider(provider: string) { - if (provider && !PROVIDERS.includes(provider)) { - throw createHttpError('来源无效', { - statusCode: 400, - errorCode: 'cost_center_provider_invalid', - }) - } -} - -export function toNumber(value: unknown) { - return Number(value || 0) || 0 -} - -export function requiredRow(row: T | undefined | null, message: string): T { - if (!row) { - throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' }) - } - return row -} +export * from './cost-center-constants.js' +export * from './cost-center-mappers.js' +export * from './cost-center-normalizers.js' +export * from './cost-center-types.js' diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts new file mode 100644 index 00000000..07af9260 --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -0,0 +1,197 @@ +/** + * 成本中心共享层 · 行结构与 DTO 类型。 + */ +import type { CostModel, FulfillmentMode } from './cost-center-constants.js' + +// --------------------------------------------------------------------------- +// 行类型 +// --------------------------------------------------------------------------- + +export 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 + affiliate_task_count?: number + affiliate_last_fulfilled_at?: string | null +} + +export type ProductCategoryStatRow = { + category_id: number | null + name: string | null + parent_id: number | null + total: number +} + +export type WorkCategoryRow = { + id: number + name: string + parent_id: number | null +} + +export type ProductCategoryNode = { + categoryId: number + name: string + total: number + directTotal: number + children: Array<{ + categoryId: number + name: string + total: number + }> +} + +export 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 +} + +export type CostItemRow = { + [column: string]: unknown + id: number + code: string + name: string + cost_model: string + pricing_mode: string + unit_name: string + currency: string + status: string + note: string + created_at: string + updated_at: string + version_count?: number +} + +export type CostVersionRow = { + [column: string]: unknown + id: number + cost_item_id: number + cost_item_code: string + cost_item_name: string + cost_item_cost_model: 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 + work_product_rule_name: string | null + work_product_rule_key: string | null + rule_reward_amount: number | null + rule_unit_price_fen: number | null + revenue_item_count: number | null + revenue_fen: number | null + reward_avg_fen: number | null + reward_fen: 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 +} + +export type CostExceptionRow = { + [column: string]: unknown + id: number + subject_type: string + subject_id: number + order_item_id: number | null + work_order_id: number | null + fulfillment_mode: string | null + reason: string + status: string + detail_json: unknown + created_at: string + resolved_at: string | null + resolved_by_admin_user_id: number | null + resolution_note: string +} + +export 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 + basisAt: string + workOrderId: number | null + workProductRuleId: number | null + fulfillmentMode: FulfillmentMode +} + +export type CostLine = { + costItemId: number + costItemCode: string + costItemName: string + costModel: CostModel + pricingMode: string + costVersionId: number + scopeType: string + amountFen: number + rateBps: number + quantity: number + unitAmountFen: number + totalAmountFen: number +} + +export type CostExceptionDraft = { + reason: string + message: string + detail: Record +} + +export type MissingCostItem = { + costItemId: number + costItemCode: string + costItemName: string + costModel: CostModel +} + +export type CostResolution = { + lines: CostLine[] + missingCostItems: MissingCostItem[] + applicableCostItemCount: number +}