Files
order_site/apps/backend/src/services/admin/admin-cost-center-service.ts
T
yml2213 0889235870 feat(finance): 完成成本快照运行层、销售事实与人工支出登记
迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
2026-10-22 16:41:03 +08:00

3572 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<string, unknown>
}
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<typeof mapMapping> | null
product: ReturnType<typeof mapProduct> | 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<string, number> = {}
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<ProductRow>(
`
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<ProductCategoryStatRow>(
`
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<WorkCategoryRow>(
`
SELECT id, name, parent_id
FROM work_categories
ORDER BY sort_order ASC, id ASC
`,
),
])
const categoryCounts = new Map<number, number>()
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<number, ProductCategoryNode>()
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<ProductRow>(
`
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<ProductRow>(
`
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<MappingProductGroupRow>(
`
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<MappingRow>(
`
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<number, ReturnType<typeof mapMapping>[]>()
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<MappingRow>(
`
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<MappingRow>(
`
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<MappingLookupRow>(
`
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<MappingLookupItemRow>(
`
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<string, number>
}
function buildMappingLookup(rows: MappingLookupRow[]): MappingLookup {
const byKey = new Map<string, number>()
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<string, number>()
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:<skuCode>`,
* 重复同步只更新名称/证据,不会重复建商品。
*/
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<string, TemplateMappingCandidate & { bestSampleCount: number }>()
const conflicts = new Map<string, TemplateMappingConflict>()
const evidenceItemCount = sumItemCount(rows)
let unresolvedItemCount = 0
let codeConflictKeyCount = 0
let seriesFallbackConflictCount = 0
function addCandidate(
input: Omit<TemplateMappingCandidate, 'itemCount'> & {
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<TemplateMappingMatchType, number> = {
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<T>(rows: T[], keyOf: (row: T) => Array<string | number>) {
const groups = new Map<string, T[]>()
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<TemplateMappingMatchType, number> = {
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<TemplateBootstrapTemplateRow>(
`
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<TemplateBootstrapEvidenceRow>(
`
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<string, number>()
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<string, number>()
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<CostItemRow>(
`
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<CostItemRow>(
`
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<CostItemRow>(
`
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<CostVersionRow>(
`
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<CostVersionRow>(
`
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<CostExceptionRow>(
`
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<CostExceptionRow>(
`
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<Record<string, unknown>> = []
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<OrderItemCostContext> {
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<MappingRow>(
`
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<CostResolution> {
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<ProductRow>('SELECT * FROM product_catalog WHERE id = $1 LIMIT 1', [
id,
])
return requiredRow(result.rows[0], '标准商品不存在')
}
async function getMappingRow(id: number) {
const result = await query<MappingRow>(
`
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<CostItemRow>('SELECT * FROM cost_items WHERE id = $1 LIMIT 1', [id])
return requiredRow(result.rows[0], '成本项不存在')
}
async function getCostVersionById(id: number) {
const result = await query<CostVersionRow>(
`
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<string>,
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<string>, 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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
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<T>(row: T | undefined | null, message: string): T {
if (!row) {
throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' })
}
return row
}