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', ]) /** 成本项自动编码的基础名:按成本类型生成,重复时追加 _2、_3。 */ const COST_ITEM_CODE_BASE: Record = { auto_purchase: 'auto_purchase', work_order_reward: 'work_order_reward', manual_service: 'manual_service', platform_fee: 'platform_fee', payment_fee: 'payment_fee', after_sales_loss: 'after_sales_loss', shipping_other: 'shipping_other', other: 'other_cost', } 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 carrier_type: 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 cost_item_carrier_type: 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 basisAt: string 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] const [productCarrierResult, costItemCarrierResult] = await Promise.all([ query<{ carrier_type: string; total: number }>( `SELECT carrier_type, COUNT(*)::int AS total FROM product_catalog WHERE status = 'active' GROUP BY carrier_type`, ), query<{ carrier_type: string; total: number }>( `SELECT carrier_type, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY carrier_type`, ), ]) 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), productCarrierCounts: carrierCountsOf(productCarrierResult.rows), costItemCarrierCounts: carrierCountsOf(costItemCarrierResult.rows), } } function carrierCountsOf(rows: Array<{ carrier_type: string; total: number }>) { const counts: Record = {} for (const carrierType of CARRIER_TYPES) counts[carrierType] = 0 for (const row of rows) { if (!row.carrier_type) continue counts[row.carrier_type] = toNumber(row.total) } return counts } export async function getAdminCostCenterOptions() { const [productsResult, costItemsResult, rulesResult] = await Promise.all([ query<{ id: number canonical_sku: string canonical_name: string unit_name: string carrier_type: string }>( ` SELECT id, canonical_sku, canonical_name, unit_name, carrier_type 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, carrierType: row.carrier_type, })), 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 carrierType = text(payload.carrierType ?? payload.carrier_type) if (carrierType) { assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效') baseParams.push(carrierType) baseClauses.push(`p.carrier_type = $${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, CASE p.carrier_type WHEN 'affiliate_dash' THEN 1 WHEN 'work_order' THEN 2 WHEN 'manual' THEN 3 ELSE 4 END, 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 carrierType = normalizeStatus( payload.carrierType ?? payload.carrier_type, CARRIER_TYPES, 'work_order', '商品载体无效', ) 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, carrier_type, attributes_json, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8) ON CONFLICT (canonical_sku) DO NOTHING RETURNING * `, [ canonicalSku, canonicalName, category, unitName, status, carrierType, 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 carrierType = payload.carrierType === undefined && payload.carrier_type === undefined ? current.carrier_type : normalizeStatus( payload.carrierType ?? payload.carrier_type, CARRIER_TYPES, current.carrier_type, '商品载体无效', ) 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, carrier_type = $5, attributes_json = $6::jsonb, updated_at = $7 WHERE id = $8 RETURNING * `, [ canonicalName, category, unitName, status, carrierType, JSON.stringify(attributes), nowIso(), id, ], ) return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) } } /** * 删除标准商品:只允许删除没有任何引用的商品。 * 有渠道映射/成本版本/成本快照/订单项时返回 409,提示改为归档。 */ export async function deleteAdminProduct(productId: number | string) { const id = normalizeId(productId, '商品 ID') const current = await getProductRow(id) const usageResult = await query<{ mapping_count: number version_count: number snapshot_count: number order_item_count: number }>( ` SELECT (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count, (SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count, (SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count, (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count `, [id], ) const usage = usageResult.rows[0] const blockers: string[] = [] if (toNumber(usage?.mapping_count) > 0) blockers.push(`${toNumber(usage?.mapping_count)} 条渠道映射`) if (toNumber(usage?.version_count) > 0) blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) if (toNumber(usage?.snapshot_count) > 0) blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) if (toNumber(usage?.order_item_count) > 0) blockers.push(`${toNumber(usage?.order_item_count)} 个订单项`) if (blockers.length > 0) { throw createHttpError(`商品仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { statusCode: 409, errorCode: 'cost_product_in_use', }) } await query('DELETE FROM product_catalog WHERE id = $1', [id]) return { deleted: true, product: mapProduct(current) } } type MappingProductGroupRow = { canonical_product_id: number canonical_sku: string canonical_name: string category: string unit_name: string carrier_type: 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}`) } const carrierType = text(payload.carrierType ?? payload.carrier_type) if (carrierType) { assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效') params.push(carrierType) clauses.push(`p.carrier_type = $${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.carrier_type, 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.carrier_type, 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 || '件', carrierType: row.carrier_type, 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 deleteAdminChannelMapping(mappingId: number | string) { const id = normalizeId(mappingId, '映射 ID') const result = await query<{ id: number }>( 'DELETE FROM channel_product_mappings WHERE id = $1 RETURNING id', [id], ) if (!result.rows[0]) { throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) } return { deleted: true, mappingId: Number(result.rows[0].id) } } 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, } } /** * 按自动发货履约证据同步标准商品。 * * 只有真正走 affiliate-dash 执行器的订单项才算自动发货;渠道映射只是 * 平台 SKU 归一,不能作为自动发货依据。SKU 固定为 `aff:`, * 重复同步只更新名称/证据,不会重复建商品。 */ export async function syncAdminAffiliateDashProducts() { const now = nowIso() const result = await query<{ inserted: boolean }>( ` WITH auto_task AS ( SELECT btrim(context_json->>'skuCode') AS sku_code, btrim(context_json->>'skuName') AS sku_name, created_at FROM fulfillment_tasks WHERE executor_key = 'affiliate_dash' AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' AND btrim(COALESCE(context_json->>'skuName', '')) <> '' ), agg AS ( SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at FROM auto_task GROUP BY sku_code ), ranked AS ( SELECT sku_code, sku_name, row_number() OVER ( PARTITION BY sku_code ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name ) AS rn FROM auto_task GROUP BY sku_code, sku_name ) INSERT INTO product_catalog ( canonical_sku, canonical_name, category, unit_name, carrier_type, status, attributes_json, created_at, updated_at ) SELECT 'aff:' || ranked.sku_code, ranked.sku_name, '', CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END, 'affiliate_dash', 'active', jsonb_build_object( 'source', 'affiliate_dash_task', 'skuCode', ranked.sku_code, 'taskCount', agg.task_count, 'lastFulfilledAt', agg.last_fulfilled_at ), $1, $1 FROM ranked JOIN agg ON agg.sku_code = ranked.sku_code WHERE ranked.rn = 1 ON CONFLICT (canonical_sku) DO UPDATE SET carrier_type = 'affiliate_dash', canonical_name = EXCLUDED.canonical_name, unit_name = EXCLUDED.unit_name, attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = $1 RETURNING (xmax = 0) AS inserted `, [now], ) const createdCount = result.rows.filter((row) => row.inserted).length return { createdCount, updatedCount: result.rows.length - createdCount, productCount: result.rows.length, } } 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 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 code = normalizeOptionalText(payload.code, 64) || (await generateCostItemCode(costType)) 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) } } async function generateCostItemCode(costType: string) { const base = COST_ITEM_CODE_BASE[costType] || 'cost_item' let candidate = base let index = 2 for (let attempt = 0; attempt < 100; attempt += 1) { const existing = await query<{ id: number }>( 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', [candidate], ) if (!existing.rows[0]) return candidate candidate = `${base}_${index}` index += 1 } return `${base}_${Date.now()}` } 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 deleteAdminCostItem(costItemId: number | string) { const id = normalizeId(costItemId, '成本项 ID') const current = await getCostItemRow(id) const usageResult = await query<{ version_count: number snapshot_count: number rule_count: number }>( ` SELECT (SELECT COUNT(*)::int FROM cost_versions WHERE cost_item_id = $1) AS version_count, (SELECT COUNT(*)::int FROM cost_snapshots WHERE cost_item_id = $1) AS snapshot_count, (SELECT COUNT(*)::int FROM work_product_rules WHERE cost_item_id = $1) AS rule_count `, [id], ) const usage = usageResult.rows[0] const blockers: string[] = [] if (toNumber(usage?.version_count) > 0) blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) if (toNumber(usage?.snapshot_count) > 0) blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) if (toNumber(usage?.rule_count) > 0) blockers.push(`${toNumber(usage?.rule_count)} 个接单规则`) if (blockers.length > 0) { throw createHttpError(`成本项仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { statusCode: 409, errorCode: 'cost_item_in_use', }) } await query('DELETE FROM cost_items WHERE id = $1', [id]) return { deleted: true, costItem: mapCostItem(current) } } 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 carrierType = text(payload.carrierType ?? payload.carrier_type) if (carrierType) { assertOneOf(carrierType, CARRIER_TYPES, '成本载体无效') params.push(carrierType) clauses.push(`ci.carrier_type = $${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.carrier_type AS cost_item_carrier_type, 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 deleteAdminCostVersion(versionId: number | string) { const id = normalizeId(versionId, '成本版本 ID') const current = await getCostVersionById(id) if (current.status === 'active') { throw createHttpError('生效中的成本版本不能删除,请先停用', { statusCode: 409, errorCode: 'cost_version_active', }) } const snapshotResult = await query<{ total: number }>( 'SELECT COUNT(*)::int AS total FROM cost_snapshots WHERE cost_version_id = $1', [id], ) const snapshotCount = toNumber(snapshotResult.rows[0]?.total) if (snapshotCount > 0) { throw createHttpError(`该成本版本已被 ${snapshotCount} 条成本快照引用,不能删除`, { statusCode: 409, errorCode: 'cost_version_in_use', }) } await query('DELETE FROM cost_versions WHERE id = $1', [id]) return { deleted: true, version: current } } 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 carrierType = text(payload.carrierType ?? payload.carrier_type) if (carrierType) { assertOneOf(carrierType, CARRIER_TYPES, '异常载体无效') params.push(carrierType) clauses.push(`ce.carrier_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 asOf = normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt const resolution: CostResolution = productId ? await resolveCostLines({ provider: context.provider, shopId: context.shopId, carrierType, canonicalProductId: productId, workProductRuleId: context.workProductRuleId, quantity: context.normalizedQuantity, revenueFen: context.revenueFen, asOf, }) : { 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, basisAt: asOf, 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 stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated' const snapshots: Array> = [] let exceptionCreatedCount = 0 let supersededCount = 0 await withTransaction(async (client) => { for (const line of preview.lines) { const currentResult = await client.query<{ id: number snapshot_stage: string total_amount_fen: number }>( ` SELECT id, snapshot_stage, total_amount_fen FROM cost_snapshots WHERE subject_type = 'order_item' AND subject_id = $1 AND cost_item_id = $2 AND superseded_at IS NULL FOR UPDATE `, [orderItemId, line.costItemId], ) const current = currentResult.rows[0] if (current) { // 预估快照一旦生成就代表支付时点的成本,不因后续改价重写; // 只有实际成本允许替代当前有效行。 if (stage === 'estimated') continue if ( current.snapshot_stage === 'actual' && Number(current.total_amount_fen) === line.totalAmountFen ) { continue } await client.query( `UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL`, [now, current.id], ) supersededCount += 1 } 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, cost_basis_at, resolved_at, created_at ) VALUES ( 'order_item', $1, $2, $1, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'resolved', $12::jsonb, $13, $14, $14 ) ON CONFLICT (subject_type, subject_id, cost_item_id) WHERE superseded_at IS NULL 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, stage, JSON.stringify({ skuCode: preview.context.skuCode, skuName: preview.context.skuName, provider: preview.context.provider, shopId: preview.context.shopId, normalizedQuantity: preview.context.normalizedQuantity, basisAt: preview.context.basisAt, }), preview.context.basisAt, 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, supersededCount, 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 basis_at: string 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, oi.revenue_amount_fen AS revenue_fen, COALESCE(o.paid_at, o.created_at) AS basis_at, 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 LATERAL ( SELECT id, product_rule_id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1 ) wo ON TRUE 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), basisAt: new Date(row.basis_at).toISOString(), 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)), basisAt: normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(), 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 asOf?: string }): 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 `, [ input.asOf || 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.carrier_type AS cost_item_carrier_type, 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 || '', carrierType: row.carrier_type || 'work_order', 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, carrierType: row.cost_item_carrier_type || 'shared', 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 }