refactor(finance): 成本模型与履约方式正交,拆分成本中心巨型文件
后端: - 删除 services/admin/admin-cost-center-service.ts(4,248 行),拆为 services/cost-center/ 下 5 个模块: cost-center-shared(类型/枚举/归一化/映射/costModelScopeSql)、 cost-center-overview(总览与选项)、 product-catalog(商品与渠道映射)、 template-bootstrap(模板引导、自动发货同步、工单证据回填)、 cost-definition(成本项/版本/异常/预览/快照) - cost_items.carrier_type / cost_type 改为单一 cost_model;专属模型只对 对应履约方式生效,通用模型对所有履约方式生效 - cost_snapshots / cost_exceptions 删除载体列;按履约方式筛异常改用 IN 半连接(248k 异常下 EXISTS 逐行回表 2.7s -> 170ms) - sales_daily_facts.carrier_type -> fulfillment_mode - 人工支出按 cost_model 归类;履约方式只读 order_items.fulfillment_mode - 移除 syncOpenCostExceptionCarrierTypes:异常表已无载体列可同步 前端: - AdminCostCenterPage.tsx(2,887 行 -> 18 行转发),拆为 pages/admin/cost-center/ 下 8 个 Panel + shared + CostCenterContext - CostCarrierType 删除;新增 FulfillmentMode / ProductSource / CostModel - 销售毛利页 carrierType -> fulfillmentMode 同步补两个索引:idx_order_items_fulfillment_id(异常半连接)、 idx_work_categories_name(商品目录分类 lateral join)。
This commit is contained in:
@@ -2,37 +2,43 @@ import type { Request } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
backfillAdminAffiliateDashSkus,
|
||||
backfillAdminNormalizedQuantities,
|
||||
backfillAdminProductMappings,
|
||||
backfillAdminWorkOrderProductSkus,
|
||||
createAdminChannelMapping,
|
||||
getAdminCostCenterOptions,
|
||||
getAdminCostCenterOverview,
|
||||
} from '../../services/cost-center/cost-center-overview-service.js'
|
||||
import {
|
||||
createAdminCostItem,
|
||||
createAdminCostSnapshot,
|
||||
createAdminCostVersion,
|
||||
createAdminProduct,
|
||||
deleteAdminChannelMapping,
|
||||
deleteAdminCostItem,
|
||||
deleteAdminCostVersion,
|
||||
deleteAdminProduct,
|
||||
getAdminCostCenterOptions,
|
||||
getAdminCostCenterOverview,
|
||||
listAdminChannelMappings,
|
||||
listAdminCostExceptions,
|
||||
listAdminCostItems,
|
||||
listAdminCostVersions,
|
||||
listAdminProducts,
|
||||
previewAdminCost,
|
||||
previewAdminTemplateProductBootstrap,
|
||||
resolveAdminCostException,
|
||||
retireAdminCostVersion,
|
||||
updateAdminCostItem,
|
||||
} from '../../services/cost-center/cost-definition-service.js'
|
||||
import {
|
||||
backfillAdminNormalizedQuantities,
|
||||
backfillAdminProductMappings,
|
||||
createAdminChannelMapping,
|
||||
createAdminProduct,
|
||||
deleteAdminChannelMapping,
|
||||
deleteAdminProduct,
|
||||
listAdminChannelMappings,
|
||||
listAdminProducts,
|
||||
updateAdminChannelMapping,
|
||||
updateAdminProduct,
|
||||
} from '../../services/cost-center/product-catalog-service.js'
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
backfillAdminAffiliateDashSkus,
|
||||
backfillAdminWorkOrderProductSkus,
|
||||
previewAdminTemplateProductBootstrap,
|
||||
syncAdminAffiliateDashProducts,
|
||||
syncAdminWorkOrderTemplateCostVersions,
|
||||
updateAdminChannelMapping,
|
||||
updateAdminCostItem,
|
||||
updateAdminProduct,
|
||||
} from '../../services/admin/admin-cost-center-service.js'
|
||||
} from '../../services/cost-center/template-bootstrap-service.js'
|
||||
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
||||
import {
|
||||
syncActualWorkOrderCostSnapshots,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { setHttpCache } from '../../../middleware/http-cache.js'
|
||||
import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/admin/admin-cost-center-service.js'
|
||||
import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/cost-center/template-bootstrap-service.js'
|
||||
|
||||
import {
|
||||
deleteAdminWorkCategory,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 成本中心总览与选项。
|
||||
*
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 商品来源统计按 canonical_sku 前缀分组,不再读 product_catalog.carrier_type;
|
||||
* 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。
|
||||
*/
|
||||
import { query } from '../../db/client.js'
|
||||
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
|
||||
|
||||
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 [productSourceResult, costModelResult] = await Promise.all([
|
||||
query<{ canonical_sku: string; total: number }>(
|
||||
`SELECT canonical_sku, COUNT(*)::int AS total
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
GROUP BY canonical_sku`,
|
||||
),
|
||||
query<{ cost_model: string; total: number }>(
|
||||
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
|
||||
),
|
||||
])
|
||||
|
||||
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),
|
||||
productSourceCounts: productSourceCountsOf(productSourceResult.rows),
|
||||
costModelCounts: costModelCountsOf(costModelResult.rows),
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
|
||||
function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) {
|
||||
const counts: Record<string, number> = {
|
||||
affiliate_dash: 0,
|
||||
work_order: 0,
|
||||
manual: 0,
|
||||
uncategorized: 0,
|
||||
}
|
||||
for (const row of rows) {
|
||||
const source = productSourceOf(row.canonical_sku)
|
||||
const key = source || 'uncategorized'
|
||||
counts[key] = (counts[key] || 0) + toNumber(row.total)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
function costModelCountsOf(rows: Array<{ cost_model: string; total: number }>) {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
if (!row.cost_model) continue
|
||||
counts[row.cost_model] = 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
|
||||
}>(
|
||||
`
|
||||
SELECT id, canonical_sku, canonical_name, unit_name
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
ORDER BY canonical_name, id
|
||||
LIMIT 5000
|
||||
`,
|
||||
),
|
||||
query<{ id: number; code: string; name: string; pricing_mode: string; cost_model: string }>(
|
||||
`
|
||||
SELECT id, code, name, pricing_mode, cost_model
|
||||
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,
|
||||
source: productSourceOf(row.canonical_sku),
|
||||
})),
|
||||
costItems: costItemsResult.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
pricingMode: row.pricing_mode,
|
||||
costModel: row.cost_model,
|
||||
})),
|
||||
rules: rulesResult.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
ruleKey: row.rule_key,
|
||||
productName: row.product_name,
|
||||
skuCode: row.sku_code,
|
||||
provider: row.provider,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
/**
|
||||
* 成本中心共享层:枚举、类型、归一化函数与行映射。
|
||||
*
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
|
||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||
*/
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 枚举
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PRODUCT_STATUSES = new Set(['active', 'archived'])
|
||||
export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual'])
|
||||
export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule'])
|
||||
export const MAPPING_MATCH_TYPES = new Set([
|
||||
'sku_code',
|
||||
'sku_code_name',
|
||||
'sku_code_series',
|
||||
'sku_name_series',
|
||||
])
|
||||
|
||||
export type TemplateMappingMatchType =
|
||||
| 'sku_code'
|
||||
| 'sku_code_name'
|
||||
| 'sku_code_series'
|
||||
| 'sku_name_series'
|
||||
|
||||
/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */
|
||||
export type CostModel =
|
||||
| 'affiliate_purchase'
|
||||
| 'work_order_reward'
|
||||
| 'manual_actual'
|
||||
| 'platform_fee'
|
||||
| 'payment_fee'
|
||||
| 'after_sales_loss'
|
||||
| 'other'
|
||||
|
||||
export const COST_MODELS = new Set<string>([
|
||||
'affiliate_purchase',
|
||||
'work_order_reward',
|
||||
'manual_actual',
|
||||
'platform_fee',
|
||||
'payment_fee',
|
||||
'after_sales_loss',
|
||||
'other',
|
||||
])
|
||||
|
||||
/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */
|
||||
export const COST_MODEL_CODE_BASE: Record<string, string> = {
|
||||
affiliate_purchase: 'affiliate_purchase',
|
||||
work_order_reward: 'work_order_reward',
|
||||
manual_actual: 'manual_actual',
|
||||
platform_fee: 'platform_fee',
|
||||
payment_fee: 'payment_fee',
|
||||
after_sales_loss: 'after_sales_loss',
|
||||
other: 'other_cost',
|
||||
}
|
||||
|
||||
/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */
|
||||
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
||||
|
||||
export const FULFILLMENT_MODES = new Set<string>([
|
||||
'work_order',
|
||||
'affiliate_dash',
|
||||
'legacy_platform',
|
||||
'manual',
|
||||
])
|
||||
|
||||
/**
|
||||
* 与履约方式无关的成本模型:对所有履约方式生效。
|
||||
* 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。
|
||||
*/
|
||||
export const GENERIC_COST_MODELS = [
|
||||
'platform_fee',
|
||||
'payment_fee',
|
||||
'after_sales_loss',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue'])
|
||||
export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule'])
|
||||
export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import'])
|
||||
export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired'])
|
||||
export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored'])
|
||||
export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case'])
|
||||
export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
|
||||
|
||||
/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */
|
||||
export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
|
||||
|
||||
/**
|
||||
* 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。
|
||||
* 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。
|
||||
*/
|
||||
export const PRODUCT_SOURCE_PREFIXES: Array<{
|
||||
prefix: string
|
||||
source: 'affiliate_dash' | 'work_order' | 'manual'
|
||||
}> = [
|
||||
{ prefix: 'aff:', source: 'affiliate_dash' },
|
||||
{ prefix: 'tpl:', source: 'work_order' },
|
||||
{ prefix: 'wo:', source: 'work_order' },
|
||||
{ prefix: 'man:', source: 'manual' },
|
||||
]
|
||||
|
||||
export const PRODUCT_CATEGORY_JOINS = `
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT wc.id, wc.name, wc.parent_id
|
||||
FROM work_categories wc
|
||||
WHERE wc.name = p.category
|
||||
ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC
|
||||
LIMIT 1
|
||||
) c ON TRUE
|
||||
LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id)
|
||||
`
|
||||
|
||||
/**
|
||||
* 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff:<skuCode> 商品上。
|
||||
* 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。
|
||||
*/
|
||||
export const AFFILIATE_TASK_AGG_JOIN = `
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
btrim(context_json->>'skuCode') AS sku_code,
|
||||
COUNT(*)::int AS task_count,
|
||||
MAX(created_at) AS last_fulfilled_at
|
||||
FROM fulfillment_tasks
|
||||
WHERE executor_key = 'affiliate_dash'
|
||||
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
|
||||
GROUP BY 1
|
||||
) aff ON p.canonical_sku LIKE 'aff:%'
|
||||
AND aff.sku_code = substring(p.canonical_sku from 5)
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 行类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ProductRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
category: string
|
||||
unit_name: string
|
||||
status: string
|
||||
root_category_id: number | null
|
||||
root_category_name: string
|
||||
sub_category_id: number | null
|
||||
sub_category_name: string
|
||||
attributes_json: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
mapping_count?: number
|
||||
cost_version_count?: number
|
||||
affiliate_task_count?: number
|
||||
affiliate_last_fulfilled_at?: string | null
|
||||
}
|
||||
|
||||
export type ProductCategoryStatRow = {
|
||||
category_id: number | null
|
||||
name: string | null
|
||||
parent_id: number | null
|
||||
total: number
|
||||
}
|
||||
|
||||
export type WorkCategoryRow = {
|
||||
id: number
|
||||
name: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
export type ProductCategoryNode = {
|
||||
categoryId: number
|
||||
name: string
|
||||
total: number
|
||||
directTotal: number
|
||||
children: Array<{
|
||||
categoryId: number
|
||||
name: string
|
||||
total: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type MappingRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
platform_sku_code: string
|
||||
platform_sku_name: string
|
||||
platform_sku_name_key: string
|
||||
match_type: string
|
||||
canonical_product_id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
quantity_multiplier: number
|
||||
confidence: string
|
||||
source: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CostItemRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
cost_model: string
|
||||
pricing_mode: string
|
||||
unit_name: string
|
||||
currency: string
|
||||
status: string
|
||||
note: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
version_count?: number
|
||||
}
|
||||
|
||||
export type CostVersionRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
cost_item_id: number
|
||||
cost_item_code: string
|
||||
cost_item_name: string
|
||||
cost_item_cost_model: string
|
||||
pricing_mode: string
|
||||
scope_type: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
canonical_name: string | null
|
||||
work_product_rule_id: number | null
|
||||
work_product_rule_name: string | null
|
||||
work_product_rule_key: string | null
|
||||
rule_reward_amount: number | null
|
||||
rule_unit_price_fen: number | null
|
||||
revenue_item_count: number | null
|
||||
revenue_fen: number | null
|
||||
reward_avg_fen: number | null
|
||||
reward_fen: number | null
|
||||
amount_fen: number
|
||||
rate_bps: number
|
||||
effective_from: string
|
||||
effective_to: string | null
|
||||
source: string
|
||||
status: string
|
||||
change_reason: string
|
||||
created_by_admin_user_id: number | null
|
||||
approved_by_admin_user_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CostExceptionRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
subject_type: string
|
||||
subject_id: number
|
||||
order_item_id: number | null
|
||||
work_order_id: number | null
|
||||
fulfillment_mode: string | null
|
||||
reason: string
|
||||
status: string
|
||||
detail_json: unknown
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
resolved_by_admin_user_id: number | null
|
||||
}
|
||||
|
||||
export type OrderItemCostContext = {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
normalizedSkuName: string
|
||||
normalizedSkuSeries: string
|
||||
quantity: number
|
||||
normalizedQuantity: number
|
||||
canonicalProductId: number | null
|
||||
revenueFen: number
|
||||
basisAt: string
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
fulfillmentMode: FulfillmentMode
|
||||
}
|
||||
|
||||
export type CostLine = {
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
costModel: CostModel
|
||||
pricingMode: string
|
||||
costVersionId: number
|
||||
scopeType: string
|
||||
amountFen: number
|
||||
rateBps: number
|
||||
quantity: number
|
||||
unitAmountFen: number
|
||||
totalAmountFen: number
|
||||
}
|
||||
|
||||
export type CostExceptionDraft = {
|
||||
reason: string
|
||||
message: string
|
||||
detail: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type MissingCostItem = {
|
||||
costItemId: number
|
||||
costItemCode: string
|
||||
costItemName: string
|
||||
costModel: CostModel
|
||||
}
|
||||
|
||||
export type CostResolution = {
|
||||
lines: CostLine[]
|
||||
missingCostItems: MissingCostItem[]
|
||||
applicableCostItemCount: number
|
||||
}
|
||||
|
||||
export type CostPreview = {
|
||||
context: {
|
||||
orderItemId: number | null
|
||||
orderId: number | null
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
fulfillmentMode: FulfillmentMode
|
||||
quantity: number
|
||||
normalizedQuantity: number
|
||||
revenueFen: number
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
}
|
||||
mapping: ReturnType<typeof mapMapping> | null
|
||||
product: ReturnType<typeof mapProduct> | null
|
||||
lines: CostLine[]
|
||||
missingCostItems: MissingCostItem[]
|
||||
totalCostFen: number
|
||||
exceptions: CostExceptionDraft[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 履约方式 / 商品来源 / 成本模型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 由 canonical_sku 前缀推导商品来源。
|
||||
* 返回空串表示未分类;未分类商品不允许反推履约方式。
|
||||
*/
|
||||
export function productSourceOf(canonicalSku: string): string {
|
||||
const sku = String(canonicalSku || '').trim()
|
||||
for (const item of PRODUCT_SOURCE_PREFIXES) {
|
||||
if (sku.startsWith(item.prefix)) return item.source
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
||||
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
|
||||
return `(
|
||||
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
||||
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
||||
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
||||
)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本模型是否适用于该履约方式。
|
||||
*
|
||||
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
|
||||
* 对所有履约方式生效。
|
||||
*/
|
||||
export function costModelAppliesTo(costModel: string, fulfillmentMode: string) {
|
||||
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
|
||||
if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
|
||||
if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
|
||||
if (costModel === 'manual_actual') return fulfillmentMode === 'manual'
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 成本模型适用范围的 SQL 片段。
|
||||
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
|
||||
*/
|
||||
export function costModelScopeSql(fulfillmentModeExpression: string): string {
|
||||
return `(
|
||||
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
|
||||
OR (ci.cost_model = 'affiliate_purchase'
|
||||
AND ${fulfillmentModeExpression} = 'affiliate_dash')
|
||||
OR (ci.cost_model = 'work_order_reward'
|
||||
AND ${fulfillmentModeExpression} = 'work_order')
|
||||
OR (ci.cost_model = 'manual_actual'
|
||||
AND ${fulfillmentModeExpression} = 'manual')
|
||||
)`
|
||||
}
|
||||
|
||||
/** 人工支出分类的默认推断:按成本模型归类。 */
|
||||
export function inferExpenseCategory(costModel: string | undefined) {
|
||||
if (costModel === 'affiliate_purchase') return 'goods_purchase'
|
||||
if (costModel === 'manual_actual') return 'service_fee'
|
||||
if (costModel === 'other') return 'freight'
|
||||
if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 行映射
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mapProduct(row: ProductRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
canonicalSku: row.canonical_sku,
|
||||
canonicalName: row.canonical_name,
|
||||
category: row.category || '',
|
||||
source: productSourceOf(row.canonical_sku),
|
||||
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
|
||||
rootCategoryName: row.root_category_name || row.category || '',
|
||||
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
|
||||
subCategoryName: row.sub_category_name || '',
|
||||
unitName: row.unit_name,
|
||||
status: row.status,
|
||||
attributes: parseJsonObject(row.attributes_json),
|
||||
mappingCount: Number(row.mapping_count || 0),
|
||||
costVersionCount: Number(row.cost_version_count || 0),
|
||||
affiliateTaskCount: Number(row.affiliate_task_count || 0),
|
||||
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapMapping(row: MappingRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
provider: row.provider,
|
||||
shopId: row.shop_id,
|
||||
platformSkuCode: row.platform_sku_code,
|
||||
platformSkuName: row.platform_sku_name,
|
||||
platformSkuNameKey: row.platform_sku_name_key || '',
|
||||
matchType: row.match_type || 'sku_code',
|
||||
canonicalProductId: Number(row.canonical_product_id),
|
||||
canonicalSku: row.canonical_sku,
|
||||
canonicalName: row.canonical_name,
|
||||
quantityMultiplier: Number(row.quantity_multiplier),
|
||||
confidence: row.confidence,
|
||||
source: row.source,
|
||||
enabled: row.enabled !== false,
|
||||
note: row.note || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCostItem(row: CostItemRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
costModel: row.cost_model,
|
||||
pricingMode: row.pricing_mode,
|
||||
unitName: row.unit_name,
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
note: row.note || '',
|
||||
versionCount: Number(row.version_count || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCostVersion(row: CostVersionRow) {
|
||||
const revenueItemCount = Number(row.revenue_item_count || 0)
|
||||
const revenueFen = Number(row.revenue_fen || 0)
|
||||
const rewardAvgFen = Number(row.reward_avg_fen || 0)
|
||||
const rewardFen = Number(row.reward_fen || 0)
|
||||
const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
|
||||
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
|
||||
const amountKind =
|
||||
row.cost_item_code === 'sys_work_order_reward' &&
|
||||
row.scope_type === 'rule' &&
|
||||
ruleUnitPriceFen > 0 &&
|
||||
ruleRewardAmountFen === 0
|
||||
? 'unit_price'
|
||||
: 'fixed'
|
||||
return {
|
||||
id: Number(row.id),
|
||||
costItemId: Number(row.cost_item_id),
|
||||
costItemCode: row.cost_item_code,
|
||||
costItemName: row.cost_item_name,
|
||||
costItemCostModel: row.cost_item_cost_model || 'other',
|
||||
pricingMode: row.pricing_mode,
|
||||
scopeType: row.scope_type,
|
||||
provider: row.provider || '',
|
||||
shopId: row.shop_id || '',
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
canonicalSku: row.canonical_sku || '',
|
||||
canonicalName: row.canonical_name || '',
|
||||
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
|
||||
workProductRuleName: row.work_product_rule_name || '',
|
||||
workProductRuleKey: row.work_product_rule_key || '',
|
||||
ruleRewardAmountFen,
|
||||
ruleUnitPriceFen,
|
||||
revenueItemCount,
|
||||
revenueFen,
|
||||
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
|
||||
rewardAvgFen,
|
||||
rewardFen,
|
||||
amountKind,
|
||||
isSystemPlaceholder:
|
||||
row.cost_item_code === 'sys_work_order_reward' &&
|
||||
row.scope_type === 'global' &&
|
||||
Number(row.amount_fen) === 0,
|
||||
amountFen: Number(row.amount_fen),
|
||||
rateBps: Number(row.rate_bps),
|
||||
effectiveFrom: row.effective_from,
|
||||
effectiveTo: row.effective_to,
|
||||
source: row.source,
|
||||
status: row.status,
|
||||
changeReason: row.change_reason || '',
|
||||
createdByAdminUserId: row.created_by_admin_user_id
|
||||
? Number(row.created_by_admin_user_id)
|
||||
: null,
|
||||
approvedByAdminUserId: row.approved_by_admin_user_id
|
||||
? Number(row.approved_by_admin_user_id)
|
||||
: null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCostException(row: CostExceptionRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
subjectType: row.subject_type,
|
||||
subjectId: Number(row.subject_id),
|
||||
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
|
||||
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
||||
fulfillmentMode: row.fulfillment_mode || '',
|
||||
reason: row.reason,
|
||||
status: row.status,
|
||||
detail: parseJsonObject(row.detail_json),
|
||||
createdAt: row.created_at,
|
||||
resolvedAt: row.resolved_at,
|
||||
resolvedByAdminUserId: row.resolved_by_admin_user_id
|
||||
? Number(row.resolved_by_admin_user_id)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 归一化
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizePage(value: unknown) {
|
||||
return Math.max(1, Math.trunc(Number(value) || 1))
|
||||
}
|
||||
|
||||
export function normalizePageSize(value: unknown) {
|
||||
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20)))
|
||||
}
|
||||
|
||||
export function normalizeId(value: unknown, label: string) {
|
||||
const id = Number(value)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw createHttpError(`${label}无效`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_id_invalid',
|
||||
})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function normalizeOptionalId(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
const id = Number(value)
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
export function normalizeCategoryFilter(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
const id = Number(value)
|
||||
if (!Number.isInteger(id) || (id <= 0 && id !== -1)) {
|
||||
throw createHttpError('分类筛选无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_category_invalid',
|
||||
})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function normalizeRequiredText(value: unknown, label: string, maxLength: number) {
|
||||
const normalized = text(value)
|
||||
if (!normalized) {
|
||||
throw createHttpError(`${label}不能为空`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_field_required',
|
||||
})
|
||||
}
|
||||
if (normalized.length > maxLength) {
|
||||
throw createHttpError(`${label}最多 ${maxLength} 个字符`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_field_too_long',
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeOptionalText(value: unknown, maxLength: number) {
|
||||
return text(value).slice(0, maxLength)
|
||||
}
|
||||
|
||||
export function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Math.trunc(Number(value) || fallback)
|
||||
return parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||
const parsed = Math.trunc(Number(value) || fallback)
|
||||
return parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function normalizeBoolean(value: unknown) {
|
||||
return value === true || value === 'true' || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
export function normalizeStatus(
|
||||
value: unknown,
|
||||
allowed: Set<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
|
||||
}
|
||||
|
||||
export function assertOneOf(value: string, allowed: Set<string>, message: string) {
|
||||
if (!allowed.has(value)) {
|
||||
throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) {
|
||||
const raw = text(value)
|
||||
if (!raw) {
|
||||
if (allowEmpty) return null
|
||||
throw createHttpError(`${label}不能为空`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_date_required',
|
||||
})
|
||||
}
|
||||
let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T')
|
||||
if (!normalized.includes('T')) {
|
||||
normalized = `${normalized}T00:00:00`
|
||||
}
|
||||
// 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。
|
||||
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) {
|
||||
normalized = `${normalized}+08:00`
|
||||
}
|
||||
const parsed = new Date(normalized)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw createHttpError(`${label}格式无效`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_date_invalid',
|
||||
})
|
||||
}
|
||||
return parsed.toISOString()
|
||||
}
|
||||
|
||||
export function normalizeJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
export 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 {}
|
||||
}
|
||||
|
||||
export function text(value: unknown) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */
|
||||
export function candidateKey(
|
||||
provider: string,
|
||||
shopId: string,
|
||||
skuCode: string,
|
||||
matchType: string,
|
||||
skuNameKey: string,
|
||||
) {
|
||||
return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}`
|
||||
}
|
||||
|
||||
export function mappingKey(
|
||||
provider: string,
|
||||
shopId: string,
|
||||
skuCode: string,
|
||||
matchType = 'sku_code',
|
||||
skuNameKey = '',
|
||||
) {
|
||||
return candidateKey(provider, shopId, skuCode, matchType, skuNameKey)
|
||||
}
|
||||
|
||||
export function mappingMatchTypeLabel(matchType: string) {
|
||||
switch (matchType) {
|
||||
case 'sku_code':
|
||||
return 'SKU 编码'
|
||||
case 'sku_code_name':
|
||||
return 'SKU 编码 + 精确名称'
|
||||
case 'sku_code_series':
|
||||
return 'SKU 编码 + 名称系列'
|
||||
case 'sku_name_series':
|
||||
return 'SKU 名称系列'
|
||||
default:
|
||||
return matchType
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMappingMatchType(
|
||||
value: unknown,
|
||||
fallback: TemplateMappingMatchType = 'sku_code',
|
||||
): TemplateMappingMatchType {
|
||||
const normalized = text(value)
|
||||
if (!normalized) return fallback
|
||||
if (!MAPPING_MATCH_TYPES.has(normalized)) {
|
||||
throw createHttpError('映射匹配方式无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_mapping_match_type_invalid',
|
||||
})
|
||||
}
|
||||
return normalized as TemplateMappingMatchType
|
||||
}
|
||||
|
||||
/** 由匹配方式决定使用精确名称键还是数量系列键。 */
|
||||
export function resolveMappingNameKey(
|
||||
matchType: TemplateMappingMatchType,
|
||||
platformSkuName: string,
|
||||
) {
|
||||
if (matchType === 'sku_code') return ''
|
||||
if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName)
|
||||
return normalizeSkuSeriesEquivalence(platformSkuName)
|
||||
}
|
||||
|
||||
/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */
|
||||
export function normalizeProviderKey(value: unknown) {
|
||||
const normalized = text(value)
|
||||
if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') {
|
||||
return '91kaquan'
|
||||
}
|
||||
if (normalized === 'douyin-mashida') {
|
||||
return 'douyin_mashida'
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function assertKnownProvider(provider: string) {
|
||||
if (provider && !PROVIDERS.includes(provider)) {
|
||||
throw createHttpError('来源无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_center_provider_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown) {
|
||||
return Number(value || 0) || 0
|
||||
}
|
||||
|
||||
export function requiredRow<T>(row: T | undefined | null, message: string): T {
|
||||
if (!row) {
|
||||
throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' })
|
||||
}
|
||||
return row
|
||||
}
|
||||
+8
-7
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildTemplateMappingPlan,
|
||||
buildTemplateProductSku,
|
||||
calculateCostTotal,
|
||||
inferTemplateUnitName,
|
||||
normalizeMappingMatchType,
|
||||
normalizeProviderKey,
|
||||
resolveMappingNameKey,
|
||||
resolveMappingProductId,
|
||||
validateCostVersionScope,
|
||||
} from './admin-cost-center-service.js'
|
||||
} from './cost-center-shared.js'
|
||||
import { calculateCostTotal, validateCostVersionScope } from './cost-definition-service.js'
|
||||
import { resolveMappingProductId } from './product-catalog-service.js'
|
||||
import {
|
||||
buildTemplateMappingPlan,
|
||||
buildTemplateProductSku,
|
||||
inferTemplateUnitName,
|
||||
} from './template-bootstrap-service.js'
|
||||
|
||||
test('calculateCostTotal supports per_item, per_order and percent_of_revenue', () => {
|
||||
assert.equal(
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
|
||||
import { costModelScopeSql } from './cost-center-shared.js'
|
||||
|
||||
export type CostSnapshotSyncInput = {
|
||||
dateFrom?: unknown
|
||||
@@ -53,7 +54,7 @@ export async function syncEstimatedOrderItemCostSnapshots(
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -77,7 +78,7 @@ export async function syncEstimatedOrderItemCostSnapshots(
|
||||
v.rate_bps
|
||||
FROM item_scope s
|
||||
JOIN cost_items ci ON ci.status = 'active'
|
||||
AND ci.carrier_type IN (s.carrier_type, 'shared')
|
||||
AND ${costModelScopeSql('s.fulfillment_mode')}
|
||||
AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')
|
||||
AND ci.code <> 'sys_work_order_reward'
|
||||
JOIN LATERAL (
|
||||
@@ -124,13 +125,13 @@ export async function syncEstimatedOrderItemCostSnapshots(
|
||||
)
|
||||
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,
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
'order_item', c.order_item_id, c.order_id, c.order_item_id, c.work_order_id,
|
||||
c.carrier_type, c.cost_item_id, c.cost_version_id, c.canonical_product_id,
|
||||
c.cost_item_id, c.cost_version_id, c.canonical_product_id,
|
||||
CASE WHEN c.pricing_mode = 'per_item' THEN c.quantity ELSE 1 END,
|
||||
CASE WHEN c.pricing_mode = 'percent_of_revenue' THEN c.total_amount_fen ELSE c.amount_fen END,
|
||||
c.total_amount_fen,
|
||||
@@ -143,6 +144,7 @@ export async function syncEstimatedOrderItemCostSnapshots(
|
||||
'pricingMode', c.pricing_mode,
|
||||
'revenueFen', c.revenue_fen,
|
||||
'normalizedQuantity', c.quantity,
|
||||
'fulfillmentMode', c.fulfillment_mode,
|
||||
'basisAt', c.basis_at
|
||||
),
|
||||
c.basis_at, NOW(), NOW()
|
||||
@@ -219,7 +221,7 @@ export async function syncActualWorkOrderCostSnapshots(
|
||||
a.work_order_id AS actual_work_order_id,
|
||||
a.reward_amount,
|
||||
a.accepted_at,
|
||||
ci.cost_type
|
||||
ci.cost_model
|
||||
FROM cost_snapshots cs
|
||||
JOIN accepted a ON cs.subject_type = 'order_item' AND cs.subject_id = a.order_item_id
|
||||
JOIN cost_items ci ON ci.id = cs.cost_item_id
|
||||
@@ -230,9 +232,9 @@ export async function syncActualWorkOrderCostSnapshots(
|
||||
target AS (
|
||||
SELECT *
|
||||
FROM active
|
||||
WHERE snapshot_stage = 'estimated'
|
||||
WHERE snapshot_stage = 'estimated'
|
||||
OR total_amount_fen <> CASE
|
||||
WHEN cost_type = 'work_order_reward' AND reward_amount > 0 THEN reward_amount
|
||||
WHEN cost_model = 'work_order_reward' AND reward_amount > 0 THEN reward_amount
|
||||
ELSE total_amount_fen
|
||||
END
|
||||
),
|
||||
@@ -243,24 +245,24 @@ export async function syncActualWorkOrderCostSnapshots(
|
||||
WHERE cs.id = t.id
|
||||
AND cs.superseded_at IS NULL
|
||||
RETURNING
|
||||
cs.id, cs.subject_id, cs.order_id, cs.order_item_id, cs.carrier_type,
|
||||
cs.id, cs.subject_id, cs.order_id, cs.order_item_id,
|
||||
cs.cost_item_id, cs.cost_version_id, cs.canonical_product_id,
|
||||
cs.quantity, cs.unit_amount_fen, cs.total_amount_fen, cs.metadata_json
|
||||
)
|
||||
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,
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.subject_id, s.order_id, s.order_item_id, a.work_order_id,
|
||||
s.carrier_type, s.cost_item_id, s.cost_version_id, s.canonical_product_id,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 THEN 1
|
||||
s.cost_item_id, s.cost_version_id, s.canonical_product_id,
|
||||
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0 THEN 1
|
||||
ELSE s.quantity END,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
|
||||
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0
|
||||
THEN a.reward_amount ELSE s.unit_amount_fen END,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
|
||||
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0
|
||||
THEN a.reward_amount ELSE s.total_amount_fen END,
|
||||
'actual', 'resolved',
|
||||
s.metadata_json || jsonb_build_object(
|
||||
@@ -389,13 +391,13 @@ async function syncDirectWorkOrderRewardSnapshots(
|
||||
)
|
||||
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,
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
'order_item', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id,
|
||||
'work_order', si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
|
||||
si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
|
||||
oi.canonical_product_id,
|
||||
1, a.reward_amount, a.reward_amount, 'actual', 'resolved',
|
||||
jsonb_build_object(
|
||||
@@ -531,14 +533,14 @@ async function syncCommittedWorkOrderRewardSnapshots(
|
||||
ORDER BY wo.order_item_id, wo.id DESC
|
||||
)
|
||||
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,
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
'order_item', l.order_item_id, oi.order_id, l.order_item_id, l.work_order_id,
|
||||
'work_order', si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
|
||||
'order_item', l.order_item_id, oi.order_id, l.work_order_id,
|
||||
si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
|
||||
oi.canonical_product_id,
|
||||
1, l.reward_amount, l.reward_amount, 'estimated', 'resolved',
|
||||
jsonb_build_object(
|
||||
@@ -605,7 +607,7 @@ async function insertAndResolveProductMappingExceptions(
|
||||
oi.canonical_product_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -617,16 +619,17 @@ async function insertAndResolveProductMappingExceptions(
|
||||
)
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
reason, status, detail_json, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
|
||||
s.carrier_type, 'product_mapping_missing', 'open',
|
||||
'product_mapping_missing', 'open',
|
||||
jsonb_build_object(
|
||||
'provider', s.provider,
|
||||
'shopId', s.shop_id,
|
||||
'skuCode', s.sku_code,
|
||||
'skuName', s.sku_name,
|
||||
'fulfillmentMode', s.fulfillment_mode,
|
||||
'basisAt', s.basis_at,
|
||||
'message', '订单项没有归一到标准商品,销售事实会进入未归一桶'
|
||||
),
|
||||
@@ -668,7 +671,7 @@ async function insertAndResolveCostMissingExceptions(
|
||||
oi.canonical_product_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -680,14 +683,14 @@ async function insertAndResolveCostMissingExceptions(
|
||||
)
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
reason, status, detail_json, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
|
||||
s.carrier_type, 'cost_missing', 'open',
|
||||
'cost_missing', 'open',
|
||||
jsonb_build_object(
|
||||
'canonicalProductId', s.canonical_product_id,
|
||||
'carrierType', s.carrier_type,
|
||||
'fulfillmentMode', s.fulfillment_mode,
|
||||
'basisAt', s.basis_at,
|
||||
'message', '订单项没有匹配到任何成本版本'
|
||||
),
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
/**
|
||||
* 标准商品与渠道映射服务。
|
||||
*
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 商品表不存履约载体,来源只由 canonical_sku 前缀表达;
|
||||
* 2. 渠道映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧;
|
||||
* 3. 归一数量必须由回填脚本按 SKU 名称解析后写入,不预填 quantity。
|
||||
*/
|
||||
import { query } from '../../db/client.js'
|
||||
import type { AdminSession } from '../admin/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'
|
||||
import {
|
||||
AFFILIATE_TASK_AGG_JOIN,
|
||||
MAPPING_CONFIDENCES,
|
||||
MAPPING_SOURCES,
|
||||
PRODUCT_CATEGORY_JOINS,
|
||||
PRODUCT_SOURCE_PREFIXES,
|
||||
PRODUCT_STATUSES,
|
||||
assertKnownProvider,
|
||||
assertOneOf,
|
||||
mapMapping,
|
||||
mapProduct,
|
||||
mappingKey,
|
||||
normalizeBoolean,
|
||||
normalizeCategoryFilter,
|
||||
normalizeId,
|
||||
normalizeJsonObject,
|
||||
normalizeMappingMatchType,
|
||||
normalizeOptionalId,
|
||||
normalizeOptionalText,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
normalizePositiveInteger,
|
||||
normalizeProviderKey,
|
||||
normalizeRequiredText,
|
||||
normalizeStatus,
|
||||
parseJsonObject,
|
||||
productSourceFilterSql,
|
||||
requiredRow,
|
||||
resolveMappingNameKey,
|
||||
text,
|
||||
toNumber,
|
||||
type MappingRow,
|
||||
type ProductCategoryNode,
|
||||
type ProductCategoryStatRow,
|
||||
type ProductRow,
|
||||
type WorkCategoryRow,
|
||||
} from './cost-center-shared.js'
|
||||
|
||||
/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */
|
||||
const PRODUCT_SOURCE_FILTERS = new Set(['affiliate_dash', 'work_order', 'manual'])
|
||||
|
||||
/**
|
||||
* 校验新建商品的 SKU 前缀。
|
||||
* 前缀是商品来源的唯一表达,不允许出现无法归类的商品。
|
||||
*/
|
||||
function assertKnownSourcePrefix(canonicalSku: string) {
|
||||
for (const item of PRODUCT_SOURCE_PREFIXES) {
|
||||
if (canonicalSku.startsWith(item.prefix)) return
|
||||
}
|
||||
throw createHttpError(
|
||||
`标准 SKU 必须以下列前缀开头:${PRODUCT_SOURCE_PREFIXES.map((item) => item.prefix).join(' / ')}`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'cost_product_sku_prefix_invalid',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
|
||||
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
|
||||
if (source) {
|
||||
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
|
||||
baseParams.push(source)
|
||||
baseClauses.push(productSourceFilterSql(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.status = 'active'
|
||||
AND v.canonical_product_id = p.id
|
||||
) + (
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_versions v
|
||||
JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id
|
||||
WHERE v.status = 'active'
|
||||
AND v.scope_type = 'rule'
|
||||
AND p.canonical_sku LIKE 'tpl:%'
|
||||
AND wpr.rule_key = substring(p.canonical_sku from 5)
|
||||
) AS cost_version_count,
|
||||
COALESCE(aff.task_count, 0) AS affiliate_task_count,
|
||||
aff.last_fulfilled_at AS affiliate_last_fulfilled_at
|
||||
FROM product_catalog p
|
||||
${PRODUCT_CATEGORY_JOINS}
|
||||
${AFFILIATE_TASK_AGG_JOIN}
|
||||
${where}
|
||||
ORDER BY
|
||||
p.status ASC,
|
||||
CASE
|
||||
WHEN p.canonical_sku LIKE 'aff:%' THEN 1
|
||||
WHEN p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%' THEN 2
|
||||
WHEN p.canonical_sku LIKE 'man:%' 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',
|
||||
})
|
||||
}
|
||||
assertKnownSourcePrefix(canonicalSku)
|
||||
const canonicalName = normalizeRequiredText(
|
||||
payload.canonicalName ?? payload.canonical_name,
|
||||
'商品名称',
|
||||
120,
|
||||
)
|
||||
const category = normalizeOptionalText(payload.category, 64)
|
||||
const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件'
|
||||
const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效')
|
||||
const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson)
|
||||
const now = nowIso()
|
||||
|
||||
const result = await query<ProductRow>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
|
||||
ON CONFLICT (canonical_sku) DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now],
|
||||
)
|
||||
const inserted = result.rows[0]
|
||||
if (!inserted) {
|
||||
throw createHttpError('标准 SKU 已存在', {
|
||||
statusCode: 409,
|
||||
errorCode: 'cost_product_sku_exists',
|
||||
})
|
||||
}
|
||||
return { product: mapProduct(inserted) }
|
||||
}
|
||||
|
||||
export async function updateAdminProduct(productId: number | string, payload: JsonObject) {
|
||||
const id = normalizeId(productId, '商品 ID')
|
||||
const current = await getProductRow(id)
|
||||
const canonicalName = normalizeRequiredText(
|
||||
payload.canonicalName ?? payload.canonical_name ?? current.canonical_name,
|
||||
'商品名称',
|
||||
120,
|
||||
)
|
||||
const category =
|
||||
payload.category === undefined ? current.category : normalizeOptionalText(payload.category, 64)
|
||||
const unitName =
|
||||
payload.unitName === undefined && payload.unit_name === undefined
|
||||
? current.unit_name
|
||||
: normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件'
|
||||
const status =
|
||||
payload.status === undefined
|
||||
? current.status
|
||||
: normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效')
|
||||
const attributes =
|
||||
payload.attributes === undefined && payload.attributesJson === undefined
|
||||
? parseJsonObject(current.attributes_json)
|
||||
: normalizeJsonObject(payload.attributes ?? payload.attributesJson)
|
||||
|
||||
const result = await query<ProductRow>(
|
||||
`
|
||||
UPDATE product_catalog
|
||||
SET canonical_name = $1, category = $2, unit_name = $3, status = $4,
|
||||
attributes_json = $5::jsonb, updated_at = $6
|
||||
WHERE id = $7
|
||||
RETURNING *
|
||||
`,
|
||||
[canonicalName, category, unitName, status, JSON.stringify(attributes), nowIso(), id],
|
||||
)
|
||||
return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标准商品:只允许删除没有任何引用的商品。
|
||||
* 有渠道映射/成本版本/成本快照/订单项时返回 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
|
||||
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}`)
|
||||
}
|
||||
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
|
||||
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
|
||||
if (source) {
|
||||
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
|
||||
params.push(source)
|
||||
clauses.push(productSourceFilterSql(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.status
|
||||
FROM channel_product_mappings m
|
||||
JOIN product_catalog p ON p.id = m.canonical_product_id
|
||||
${where}
|
||||
GROUP BY
|
||||
p.id,
|
||||
p.canonical_sku,
|
||||
p.canonical_name,
|
||||
p.category,
|
||||
p.unit_name,
|
||||
p.status
|
||||
ORDER BY p.canonical_name ASC, p.id ASC
|
||||
LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length}
|
||||
`,
|
||||
groupParams,
|
||||
),
|
||||
query<{ total_count: number; mapping_total: number }>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(DISTINCT p.id)::int AS total_count,
|
||||
COUNT(*)::int AS mapping_total
|
||||
FROM channel_product_mappings m
|
||||
JOIN product_catalog p ON p.id = m.canonical_product_id
|
||||
${where}
|
||||
`,
|
||||
params,
|
||||
),
|
||||
])
|
||||
|
||||
const groups = groupResult.rows
|
||||
const totalCount = toNumber(statsResult.rows[0]?.total_count)
|
||||
const mappingTotal = toNumber(statsResult.rows[0]?.mapping_total)
|
||||
if (groups.length === 0) {
|
||||
return {
|
||||
groups: [],
|
||||
pagination: { page, pageSize, total: totalCount, mappingTotal },
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = groups.map((row) => Number(row.canonical_product_id))
|
||||
const mappingParams = [...params, productIds]
|
||||
const mappingsResult = await query<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 || '件',
|
||||
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 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 ($6::bigint IS NULL OR id <> $6)
|
||||
LIMIT 1
|
||||
`,
|
||||
[provider, shopId, platformSkuCode, matchType, platformSkuNameKey, excludeId ?? null],
|
||||
)
|
||||
if (result.rows[0]) {
|
||||
throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', {
|
||||
statusCode: 409,
|
||||
errorCode: 'cost_mapping_exists',
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -180,18 +180,17 @@ export async function listManualExpenseCostItems() {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
cost_type: string
|
||||
carrier_type: string
|
||||
cost_model: string
|
||||
pricing_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT id, code, name, cost_type, carrier_type, pricing_mode
|
||||
SELECT id, code, name, cost_model, pricing_mode
|
||||
FROM cost_items
|
||||
WHERE status = 'active'
|
||||
AND code <> 'sys_work_order_reward'
|
||||
ORDER BY
|
||||
CASE WHEN code = 'sys_manual_service' THEN 0 ELSE 1 END,
|
||||
carrier_type,
|
||||
cost_model,
|
||||
id
|
||||
`,
|
||||
)
|
||||
@@ -200,8 +199,7 @@ export async function listManualExpenseCostItems() {
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
costType: row.cost_type,
|
||||
carrierType: row.carrier_type,
|
||||
costModel: row.cost_model,
|
||||
pricingMode: row.pricing_mode,
|
||||
})),
|
||||
}
|
||||
@@ -369,7 +367,7 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
|
||||
const expenseCategory = normalizeStatus(
|
||||
payload.expenseCategory ?? payload.expense_category,
|
||||
EXPENSE_CATEGORIES,
|
||||
inferExpenseCategory(costItem?.costType),
|
||||
inferExpenseCategory(costItem?.costModel),
|
||||
'支出分类无效',
|
||||
)
|
||||
const requestedFundAccountId = normalizeOptionalId(
|
||||
@@ -758,7 +756,7 @@ async function attachCostSnapshotForExpense(
|
||||
shop_id: string
|
||||
work_order_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
@@ -768,10 +766,9 @@ async function attachCostSnapshotForExpense(
|
||||
o.shop_id,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
ci.carrier_type
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN cost_items ci ON ci.id = $2
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id
|
||||
FROM work_orders
|
||||
@@ -838,16 +835,15 @@ async function attachCostSnapshotForExpense(
|
||||
`
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
reason, status, detail_json, created_at
|
||||
) VALUES (
|
||||
'order_item', $1, $1, $2, $3, 'manual_expense_cost_missing', 'open', $4::jsonb, NOW()
|
||||
'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[
|
||||
expense.order_item_id,
|
||||
context.work_order_id,
|
||||
context.carrier_type,
|
||||
JSON.stringify({
|
||||
expenseId: expense.id,
|
||||
expenseNo: expense.expense_no,
|
||||
@@ -862,14 +858,14 @@ async function attachCostSnapshotForExpense(
|
||||
`
|
||||
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,
|
||||
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 (
|
||||
'manual_case', $1, $2, $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
1, $9, $9, 'actual', 'manual_input',
|
||||
$10::jsonb, $11, $12, $12
|
||||
$5, $6, $7,
|
||||
1, $8, $8, 'actual', 'manual_input',
|
||||
$9::jsonb, $10, $11, $11
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
@@ -881,7 +877,6 @@ async function attachCostSnapshotForExpense(
|
||||
context.order_id,
|
||||
expense.order_item_id,
|
||||
context.work_order_id,
|
||||
context.carrier_type,
|
||||
expense.cost_item_id,
|
||||
versionId,
|
||||
context.canonical_product_id,
|
||||
@@ -893,6 +888,7 @@ async function attachCostSnapshotForExpense(
|
||||
paidByName: expense.paid_by_name,
|
||||
payeeName: expense.payee_name,
|
||||
approvedBy: session.displayName,
|
||||
fulfillmentMode: context.fulfillment_mode,
|
||||
}),
|
||||
expense.paid_at,
|
||||
nowIso(),
|
||||
@@ -1008,12 +1004,9 @@ async function resolveCostItem(costItemId: number | null) {
|
||||
const result = await query<{
|
||||
id: number
|
||||
code: string
|
||||
cost_type: string
|
||||
carrier_type: string
|
||||
cost_model: string
|
||||
status: string
|
||||
}>(`SELECT id, code, cost_type, carrier_type, status FROM cost_items WHERE id = $1 LIMIT 1`, [
|
||||
costItemId,
|
||||
])
|
||||
}>(`SELECT id, code, cost_model, status FROM cost_items WHERE id = $1 LIMIT 1`, [costItemId])
|
||||
const row = result.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('成本项目不存在', {
|
||||
@@ -1033,7 +1026,7 @@ async function resolveCostItem(costItemId: number | null) {
|
||||
errorCode: 'manual_expense_cost_item_archived',
|
||||
})
|
||||
}
|
||||
return { id: Number(row.id), costType: row.cost_type, carrierType: row.carrier_type }
|
||||
return { id: Number(row.id), costModel: row.cost_model }
|
||||
}
|
||||
|
||||
function mapManualExpense(row: ManualExpenseRow) {
|
||||
@@ -1085,11 +1078,11 @@ function mapManualExpense(row: ManualExpenseRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function inferExpenseCategory(costType: string | undefined) {
|
||||
if (costType === 'auto_purchase') return 'goods_purchase'
|
||||
if (costType === 'manual_service') return 'service_fee'
|
||||
if (costType === 'shipping_other') return 'freight'
|
||||
if (costType === 'payment_fee' || costType === 'platform_fee') return 'service_fee'
|
||||
function inferExpenseCategory(costModel: string | undefined) {
|
||||
if (costModel === 'affiliate_purchase') return 'goods_purchase'
|
||||
if (costModel === 'manual_actual') return 'service_fee'
|
||||
if (costModel === 'other') return 'freight'
|
||||
if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ type SalesFactRow = {
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
canonical_name: string | null
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
order_count: number
|
||||
item_count: number
|
||||
quantity: string | number
|
||||
@@ -64,7 +64,7 @@ export async function rebuildSalesDailyFacts(
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode,
|
||||
oi.order_id,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
CASE
|
||||
@@ -91,7 +91,7 @@ export async function rebuildSalesDailyFacts(
|
||||
BETWEEN $1::date AND $2::date
|
||||
)
|
||||
INSERT INTO sales_daily_facts (
|
||||
stat_date, provider, shop_id, canonical_product_id, carrier_type,
|
||||
stat_date, provider, shop_id, canonical_product_id, fulfillment_mode,
|
||||
order_count, item_count, quantity,
|
||||
gross_revenue_fen, refund_amount_fen, net_revenue_fen,
|
||||
cost_amount_fen, cost_missing_count, cost_stage, gross_profit_fen,
|
||||
@@ -102,7 +102,7 @@ export async function rebuildSalesDailyFacts(
|
||||
provider,
|
||||
shop_id,
|
||||
canonical_product_id,
|
||||
carrier_type,
|
||||
fulfillment_mode,
|
||||
COUNT(DISTINCT order_id)::int,
|
||||
COUNT(*)::int,
|
||||
SUM(quantity),
|
||||
@@ -123,7 +123,7 @@ export async function rebuildSalesDailyFacts(
|
||||
$3::timestamptz,
|
||||
$3::timestamptz
|
||||
FROM item_base
|
||||
GROUP BY stat_date, provider, shop_id, canonical_product_id, carrier_type
|
||||
GROUP BY stat_date, provider, shop_id, canonical_product_id, fulfillment_mode
|
||||
`,
|
||||
[range.fromDate, range.toDate, computedAt],
|
||||
)
|
||||
@@ -196,7 +196,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
@@ -205,7 +205,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
carrier_type,
|
||||
fulfillment_mode,
|
||||
SUM(item_count)::int AS item_count,
|
||||
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
|
||||
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
|
||||
@@ -213,7 +213,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
SUM(gross_profit_fen)::bigint AS gross_profit_fen
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
GROUP BY carrier_type
|
||||
GROUP BY fulfillment_mode
|
||||
ORDER BY gross_revenue_fen DESC
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
@@ -302,8 +302,8 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
})),
|
||||
byCarrier: carrierResult.rows.map((row) => ({
|
||||
carrierType: row.carrier_type,
|
||||
byFulfillmentMode: carrierResult.rows.map((row) => ({
|
||||
fulfillmentMode: row.fulfillment_mode,
|
||||
itemCount: Number(row.item_count || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
@@ -331,7 +331,9 @@ export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const provider = String(input.provider || '').trim()
|
||||
const carrierType = String(input.carrierType || input.carrier_type || '').trim()
|
||||
const fulfillmentMode = String(
|
||||
input.fulfillmentMode || input.fulfillment_mode || input.carrierType || '',
|
||||
).trim()
|
||||
const canonicalProductId = Number(input.canonicalProductId || input.canonical_product_id || 0)
|
||||
|
||||
const conditions = ['f.stat_date BETWEEN $1::date AND $2::date']
|
||||
@@ -340,9 +342,9 @@ export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
|
||||
params.push(provider)
|
||||
conditions.push(`f.provider = $${params.length}`)
|
||||
}
|
||||
if (carrierType) {
|
||||
params.push(carrierType)
|
||||
conditions.push(`f.carrier_type = $${params.length}`)
|
||||
if (fulfillmentMode) {
|
||||
params.push(fulfillmentMode)
|
||||
conditions.push(`f.fulfillment_mode = $${params.length}`)
|
||||
}
|
||||
if (Number.isInteger(canonicalProductId) && canonicalProductId > 0) {
|
||||
params.push(canonicalProductId)
|
||||
@@ -391,7 +393,7 @@ function mapSalesFact(row: SalesFactRow) {
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
canonicalSku: row.canonical_sku || '',
|
||||
canonicalName: row.canonical_name || '未归一',
|
||||
carrierType: row.carrier_type,
|
||||
fulfillmentMode: row.fulfillment_mode,
|
||||
orderCount: Number(row.order_count || 0),
|
||||
itemCount: Number(row.item_count || 0),
|
||||
quantity: Number(row.quantity || 0),
|
||||
|
||||
@@ -27,7 +27,7 @@ export type ReconcileFulfillmentModesResult = {
|
||||
* 统一履约判定(纯函数,供单测与文档对照)。
|
||||
*
|
||||
* 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。
|
||||
* 核销码、provider、商品 carrier_type 都不参与判定。
|
||||
* 核销码、provider、商品来源都不参与判定。
|
||||
*/
|
||||
export function classifyFulfillmentMode({
|
||||
hasWorkOrder,
|
||||
@@ -102,43 +102,6 @@ export async function decideOrderItemsFulfillmentModes(
|
||||
[ids, String(reason || 'order_sync')],
|
||||
)
|
||||
|
||||
// 已有的 open 成本异常必须跟着履约方式一起改,否则历史平台会继续显示成人工。
|
||||
if ((result.rowCount || 0) > 0) {
|
||||
await syncOpenCostExceptionCarrierTypes(ids)
|
||||
}
|
||||
|
||||
return { updatedCount: result.rowCount || 0 }
|
||||
}
|
||||
|
||||
export async function syncOpenCostExceptionCarrierTypes(
|
||||
orderItemIds?: Array<number | string>,
|
||||
): Promise<{ updatedCount: number }> {
|
||||
const ids = Array.isArray(orderItemIds)
|
||||
? Array.from(
|
||||
new Set(
|
||||
orderItemIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0),
|
||||
),
|
||||
)
|
||||
: null
|
||||
if (ids && ids.length === 0) return { updatedCount: 0 }
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET carrier_type = oi.fulfillment_mode
|
||||
FROM order_items oi
|
||||
WHERE ce.subject_type = 'order_item'
|
||||
AND ce.order_item_id = oi.id
|
||||
AND ce.status = 'open'
|
||||
AND oi.fulfillment_mode IS NOT NULL
|
||||
AND ce.carrier_type IS DISTINCT FROM oi.fulfillment_mode
|
||||
${ids ? 'AND oi.id = ANY($1::bigint[])' : ''}
|
||||
`,
|
||||
ids ? [ids] : [],
|
||||
)
|
||||
|
||||
return { updatedCount: result.rowCount || 0 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user