迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
3572 lines
112 KiB
TypeScript
3572 lines
112 KiB
TypeScript
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
|
||
}
|