Compare commits

...
2 Commits
Author SHA1 Message Date
yml2213 fcd9b607d4 refactor(cost-center): 共享层按职责拆为四个模块
cost-center-shared.ts 804 行同时装枚举、行类型、映射、归一化,任何一处改动都会
让 8 个引用方重新编译。按职责拆开,本文件退化为统一出口(export *):

- cost-center-constants.ts    131  枚举、常量、SQL 片段
- cost-center-types.ts        220  行结构与 DTO 类型
- cost-center-mappers.ts      250  行映射与口径判断(含 CostPreview)
- cost-center-normalizers.ts  254  入参归一化与断言

同时消掉一个类型环:CostPreview 用 ReturnType<typeof mapProduct> 反推 mapper 的
返回类型,导致 types → mappers → types 互指;CostPreview 随 mapper 一起放进
mappers,依赖恢复单向。

出口保持向后兼容,8 个引用方无需改动;71 个导出逐一比对无缺失。
验证:typecheck / lint / 773 测试通过。
2026-10-25 12:02:36 +08:00
yml2213 ad1e1bc884 perf(db): 收入口径回填改为分批脚本
迁移 104 里对 order_items 的全表 UPDATE 实测 34.4 秒(375,447 行),
远超默认语句超时 15s,单事务迁移会拖住启动甚至直接失败。

- 104 只保留 DDL 与索引/约束,删除全表 UPDATE;
- 新增 scripts/backfill-revenue-basis.ts:按 id 分批、幂等、支持
  --dry-run / --batch-size / --max-batches(kebab 与 camel 两种写法);
- package.json 增加 revenue:backfill;
- migrate.ts 启动时做一次带索引计数,仍有 unknown 时打印告警并给出命令,
  不阻塞启动;
- 文档补充回填步骤与「不执行的后果」。

验证:
- 实测 37.5 万行全表更新 34.4s → 脚本按批执行,单批 5000 行;
- 功能验证:单条订单项置 unknown → 脚本回填 → 金额与 basis 恢复正确;
- 启动告警验证:保留 1 条 unknown 重启 → 日志出现告警;回填后计数归零;
- 全新库从 102 迁移到 107 仍成功(2s)。
2026-10-25 12:02:26 +08:00
10 changed files with 1096 additions and 824 deletions
+1
View File
@@ -11,6 +11,7 @@
"db:migrate:status": "tsx scripts/migration-status.ts",
"finance:sync": "tsx scripts/sync-cost-finance.ts",
"fulfillment:backfill": "tsx scripts/backfill-fulfillment-mode.ts",
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
"storage:migrate": "tsx scripts/migrate-storage.ts",
"storage:migrate:built": "node dist/storage-migrate.js",
"check:sql": "tsx scripts/check-sql-guard.ts",
@@ -0,0 +1,207 @@
/**
* 历史订单项收入口径回填(迁移 104 只建列,不再做全表更新)。
*
* 背景:375,447 行的全表 UPDATE 实测约 34 秒,超过默认语句超时,放在单事务迁移里
* 会拖住启动;因此迁到本脚本,按 id 分批执行。
*
* 口径(与 order-item-repo 写入规则一致):
* item_count = 1 → revenue = 订单实付,revenue_basis = 'order_total'
* item_count > 1 → 等额拆分,余数给最后一项,revenue_basis = 'equal_split'
*
* 幂等:只处理 revenue_basis = 'unknown' 的行,重复执行不会二次拆分。
*
* 用法:
* npm run revenue:backfill # 默认每批 5000 行
* npm run revenue:backfill -- --batch-size=2000 --max-batches=100
* npm run revenue:backfill -- --dry-run # 只统计并抽样预览,不写库
*/
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
const options = parseOptions(process.argv.slice(2))
const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000)
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10_000)
const dryRun = options.dryRun === 'true'
const BATCH_SQL = `
WITH target AS (
SELECT id, order_id
FROM order_items
WHERE revenue_basis = 'unknown'
ORDER BY id
LIMIT $1
),
item_counts AS (
SELECT oi.order_id, COUNT(*)::int AS item_count
FROM order_items oi
WHERE oi.order_id IN (SELECT order_id FROM target)
GROUP BY oi.order_id
),
ranked AS (
SELECT
oi.id,
GREATEST(COALESCE(o.total_amount, 0), 0)::bigint AS order_revenue_fen,
ic.item_count,
ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id ASC) AS item_rank
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN item_counts ic ON ic.order_id = oi.order_id
WHERE oi.id IN (SELECT id FROM target)
)
UPDATE order_items oi
SET
revenue_amount_fen = CASE
WHEN r.item_count = 1 THEN r.order_revenue_fen
ELSE (r.order_revenue_fen / r.item_count)
+ CASE WHEN r.item_rank = r.item_count THEN (r.order_revenue_fen % r.item_count) ELSE 0 END
END,
revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END,
updated_at = NOW()
FROM ranked r
WHERE oi.id = r.id
AND oi.revenue_basis = 'unknown'
RETURNING oi.id
`
async function main() {
await assertColumnExists()
const before = await countUnknown()
const startedAt = Date.now()
if (dryRun) {
const samples = await previewSamples()
console.log(
JSON.stringify(
{
dryRun: true,
pendingCount: before,
batchSize,
estimatedBatches: Math.ceil(before / batchSize),
samples,
durationMs: Date.now() - startedAt,
},
null,
2,
),
)
return
}
let batches = 0
let updated = 0
while (batches < maxBatches) {
const result = await query(BATCH_SQL, [batchSize])
const affected = result.rowCount ?? 0
if (affected === 0) break
batches += 1
updated += affected
if (affected < batchSize) break
}
const remaining = await countUnknown()
console.log(
JSON.stringify(
{
dryRun: false,
batchSize,
maxBatches,
batches,
updatedCount: updated,
pendingBefore: before,
remainingCount: remaining,
durationMs: Date.now() - startedAt,
},
null,
2,
),
)
if (remaining > 0) {
console.error(`仍有 ${remaining} 个订单项未回填;请增大 --max-batches 后重跑(幂等)。`)
process.exitCode = 2
}
}
async function assertColumnExists() {
const { rows } = await query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'order_items'
AND column_name = 'revenue_basis'
) AS exists
`,
)
if (!rows[0]?.exists) {
throw new Error('order_items.revenue_basis 不存在,请先执行 npm run db:migrate(迁移 104)')
}
}
async function countUnknown() {
const { rows } = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM order_items WHERE revenue_basis = 'unknown'`,
)
return rows[0]?.count ?? 0
}
async function previewSamples() {
const { rows } = await query<{
order_id: number
item_count: number
order_total: number
item_ids: number[]
}>(
`
SELECT
oi.order_id,
COUNT(*)::int AS item_count,
MAX(GREATEST(COALESCE(o.total_amount, 0), 0))::bigint AS order_total,
array_agg(oi.id ORDER BY oi.id) AS item_ids
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.revenue_basis = 'unknown'
GROUP BY oi.order_id
ORDER BY oi.order_id
LIMIT 3
`,
)
return rows
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
const key = index === -1 ? body : body.slice(0, index)
const value = index === -1 ? 'true' : body.slice(index + 1)
// 同时接受 --batch-size 与 --batchSize 两种写法。
result[key] = value
result[toCamelCase(key)] = value
}
return result
}
function toCamelCase(value: string) {
return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
+26
View File
@@ -58,6 +58,32 @@ export async function runDatabaseMigrations() {
} satisfies RunnerOption)
await syncLegacyMigrationColumns()
await warnPendingRevenueBackfill()
}
/**
* 迁移 104 只建列、不再做全表收入回填(37.5 万行实测约 34 秒,会拖住启动)。
* 这里只做一次带索引的计数,发现仍有未固化的订单项就提示执行回填脚本,
* 不阻塞启动——脚本幂等,可以随时补跑。
*/
async function warnPendingRevenueBackfill() {
try {
const { rows } = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM order_items WHERE revenue_basis = 'unknown'`,
)
const pending = rows[0]?.count ?? 0
if (pending > 0) {
console.warn(
`[db:migrate] 还有 ${pending} 个订单项未固化收入口径,销售事实会偏低;` +
'请执行 npm run revenue:backfill(分批、幂等)',
)
}
} catch (error) {
// 列还不存在(迁移没跑到 104)或表缺失时跳过,不能影响启动。
console.warn(
`[db:migrate] 跳过收入口径检查: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
function assertMigrationFilesOrdered() {
@@ -317,35 +317,11 @@ BEGIN
END IF;
END $$;
-- 历史订单回填:当前生产快照全部是一单一品,仍按通用规则处理多订单项。
-- 幂等:只处理 revenue_basis 仍为 unknown 的行,重复执行不会二次拆分。
WITH item_counts AS (
SELECT order_id, COUNT(*)::int AS item_count
FROM order_items
GROUP BY order_id
),
ranked AS (
SELECT
oi.id,
GREATEST(COALESCE(o.total_amount, 0), 0)::bigint AS order_revenue_fen,
ic.item_count,
ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id ASC) AS item_rank
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN item_counts ic ON ic.order_id = oi.order_id
)
UPDATE order_items oi
SET
revenue_amount_fen = CASE
WHEN r.item_count = 1 THEN r.order_revenue_fen
ELSE (r.order_revenue_fen / r.item_count)
+ CASE WHEN r.item_rank = r.item_count THEN (r.order_revenue_fen % r.item_count) ELSE 0 END
END,
revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END,
updated_at = NOW()
FROM ranked r
WHERE oi.id = r.id
AND oi.revenue_basis = 'unknown';
-- 历史订单的收入回填**不在迁移里做**。
-- 实测:375,447 行的全表 UPDATE 需要约 34 秒,远超默认语句超时
-- (DATABASE_STATEMENT_TIMEOUT_MS=15s),放进单事务迁移会拖住启动甚至直接失败。
-- 改由 `npm run revenue:backfill` 分批执行(幂等、可续跑、支持 --dry-run);
-- 应用启动时若仍有 revenue_basis='unknown' 的行,会打印告警提示执行该命令。
CREATE INDEX IF NOT EXISTS idx_order_items_revenue_basis
ON order_items (revenue_basis);
@@ -0,0 +1,131 @@
/**
* 成本中心共享层 · 枚举、常量与 SQL 片段。
*
* 这些值是数据库约束与接口枚举的唯一真源,改动前先看迁移与 API 契约。
*/
// ---------------------------------------------------------------------------
// 枚举
// ---------------------------------------------------------------------------
export const PRODUCT_STATUSES = new Set(['active', 'archived'])
export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual'])
export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule'])
export const MAPPING_MATCH_TYPES = new Set([
'sku_code',
'sku_code_name',
'sku_code_series',
'sku_name_series',
])
export type TemplateMappingMatchType =
| 'sku_code'
| 'sku_code_name'
| 'sku_code_series'
| 'sku_name_series'
/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */
export type CostModel =
| 'affiliate_purchase'
| 'work_order_reward'
| 'manual_actual'
| 'platform_fee'
| 'payment_fee'
| 'after_sales_loss'
| 'other'
export const COST_MODELS = new Set<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)
`
@@ -0,0 +1,251 @@
/**
* 成本中心共享层 · 行映射与口径判断。
*
* 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。
*/
import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js'
import type { FulfillmentMode } from './cost-center-constants.js'
import { parseJsonObject } from './cost-center-normalizers.js'
import type {
CostExceptionDraft,
CostExceptionRow,
CostItemRow,
CostLine,
CostVersionRow,
MappingRow,
MissingCostItem,
ProductRow,
} from './cost-center-types.js'
// ---------------------------------------------------------------------------
// 履约方式 / 商品来源 / 成本模型
// ---------------------------------------------------------------------------
/**
* 由 canonical_sku 前缀推导商品来源。
* 返回空串表示未分类;未分类商品不允许反推履约方式。
*/
export function productSourceOf(canonicalSku: string): string {
const sku = String(canonicalSku || '').trim()
for (const item of PRODUCT_SOURCE_PREFIXES) {
if (sku.startsWith(item.prefix)) return item.source
}
return ''
}
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
return `(
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
)`
}
/**
* 成本模型是否适用于该履约方式。
*
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
* 对所有履约方式生效。
*/
export function costModelAppliesTo(costModel: string, fulfillmentMode: string) {
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
if (costModel === 'manual_actual') return fulfillmentMode === 'manual'
return false
}
/**
* 成本模型适用范围的 SQL 片段。
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
*/
export function costModelScopeSql(fulfillmentModeExpression: string): string {
return `(
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
OR (ci.cost_model = 'affiliate_purchase'
AND ${fulfillmentModeExpression} = 'affiliate_dash')
OR (ci.cost_model = 'work_order_reward'
AND ${fulfillmentModeExpression} = 'work_order')
OR (ci.cost_model = 'manual_actual'
AND ${fulfillmentModeExpression} = 'manual')
)`
}
/** 人工支出分类的默认推断:按成本模型归类。 */
export function inferExpenseCategory(costModel: string | undefined) {
if (costModel === 'affiliate_purchase') return 'goods_purchase'
if (costModel === 'manual_actual') return 'service_fee'
if (costModel === 'other') return 'freight'
if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee'
return 'other'
}
// ---------------------------------------------------------------------------
// 行映射
// ---------------------------------------------------------------------------
export function mapProduct(row: ProductRow) {
return {
id: Number(row.id),
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
category: row.category || '',
source: productSourceOf(row.canonical_sku),
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
subCategoryName: row.sub_category_name || '',
unitName: row.unit_name,
status: row.status,
attributes: parseJsonObject(row.attributes_json),
mappingCount: Number(row.mapping_count || 0),
costVersionCount: Number(row.cost_version_count || 0),
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapMapping(row: MappingRow) {
return {
id: Number(row.id),
provider: row.provider,
shopId: row.shop_id,
platformSkuCode: row.platform_sku_code,
platformSkuName: row.platform_sku_name,
platformSkuNameKey: row.platform_sku_name_key || '',
matchType: row.match_type || 'sku_code',
canonicalProductId: Number(row.canonical_product_id),
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
quantityMultiplier: Number(row.quantity_multiplier),
confidence: row.confidence,
source: row.source,
enabled: row.enabled !== false,
note: row.note || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostItem(row: CostItemRow) {
return {
id: Number(row.id),
code: row.code,
name: row.name,
costModel: row.cost_model,
pricingMode: row.pricing_mode,
unitName: row.unit_name,
currency: row.currency,
status: row.status,
note: row.note || '',
versionCount: Number(row.version_count || 0),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostVersion(row: CostVersionRow) {
const revenueItemCount = Number(row.revenue_item_count || 0)
const revenueFen = Number(row.revenue_fen || 0)
const rewardAvgFen = Number(row.reward_avg_fen || 0)
const rewardFen = Number(row.reward_fen || 0)
const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
const amountKind =
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'rule' &&
ruleUnitPriceFen > 0 &&
ruleRewardAmountFen === 0
? 'unit_price'
: 'fixed'
return {
id: Number(row.id),
costItemId: Number(row.cost_item_id),
costItemCode: row.cost_item_code,
costItemName: row.cost_item_name,
costItemCostModel: row.cost_item_cost_model || 'other',
pricingMode: row.pricing_mode,
scopeType: row.scope_type,
provider: row.provider || '',
shopId: row.shop_id || '',
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
canonicalSku: row.canonical_sku || '',
canonicalName: row.canonical_name || '',
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
workProductRuleName: row.work_product_rule_name || '',
workProductRuleKey: row.work_product_rule_key || '',
ruleRewardAmountFen,
ruleUnitPriceFen,
revenueItemCount,
revenueFen,
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
rewardAvgFen,
rewardFen,
amountKind,
isSystemPlaceholder:
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'global' &&
Number(row.amount_fen) === 0,
amountFen: Number(row.amount_fen),
rateBps: Number(row.rate_bps),
effectiveFrom: row.effective_from,
effectiveTo: row.effective_to,
source: row.source,
status: row.status,
changeReason: row.change_reason || '',
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
approvedByAdminUserId: row.approved_by_admin_user_id
? Number(row.approved_by_admin_user_id)
: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostException(row: CostExceptionRow) {
return {
id: Number(row.id),
subjectType: row.subject_type,
subjectId: Number(row.subject_id),
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
fulfillmentMode: row.fulfillment_mode || '',
reason: row.reason,
status: row.status,
detail: parseJsonObject(row.detail_json),
createdAt: row.created_at,
resolvedAt: row.resolved_at,
resolvedByAdminUserId: row.resolved_by_admin_user_id
? Number(row.resolved_by_admin_user_id)
: null,
resolutionNote: row.resolution_note || '',
}
}
export type CostPreview = {
context: {
orderItemId: number | null
orderId: number | null
provider: string
shopId: string
skuCode: string
skuName: string
fulfillmentMode: FulfillmentMode
quantity: number
normalizedQuantity: number
revenueFen: number
workOrderId: number | null
workProductRuleId: number | null
}
mapping: ReturnType<typeof mapMapping> | null
product: ReturnType<typeof mapProduct> | null
lines: CostLine[]
missingCostItems: MissingCostItem[]
totalCostFen: number
exceptions: CostExceptionDraft[]
}
@@ -0,0 +1,248 @@
/**
* 成本中心共享层 · 入参归一化与断言。
*/
import { createHttpError } from '../../utils/http.js'
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
import { MAPPING_MATCH_TYPES, PROVIDERS } from './cost-center-constants.js'
import type { TemplateMappingMatchType } from './cost-center-constants.js'
// ---------------------------------------------------------------------------
// 归一化
// ---------------------------------------------------------------------------
export function normalizePage(value: unknown) {
return Math.max(1, Math.trunc(Number(value) || 1))
}
export function normalizePageSize(value: unknown) {
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20)))
}
export function normalizeId(value: unknown, label: string) {
const id = Number(value)
if (!Number.isInteger(id) || id <= 0) {
throw createHttpError(`${label}无效`, {
statusCode: 400,
errorCode: 'cost_center_id_invalid',
})
}
return id
}
export function normalizeOptionalId(value: unknown) {
if (value === undefined || value === null || value === '') return null
const id = Number(value)
return Number.isInteger(id) && id > 0 ? id : null
}
export function normalizeCategoryFilter(value: unknown) {
if (value === undefined || value === null || value === '') return null
const id = Number(value)
if (!Number.isInteger(id) || (id <= 0 && id !== -1)) {
throw createHttpError('分类筛选无效', {
statusCode: 400,
errorCode: 'cost_center_category_invalid',
})
}
return id
}
export function normalizeRequiredText(value: unknown, label: string, maxLength: number) {
const normalized = text(value)
if (!normalized) {
throw createHttpError(`${label}不能为空`, {
statusCode: 400,
errorCode: 'cost_center_field_required',
})
}
if (normalized.length > maxLength) {
throw createHttpError(`${label}最多 ${maxLength} 个字符`, {
statusCode: 400,
errorCode: 'cost_center_field_too_long',
})
}
return normalized
}
export function normalizeOptionalText(value: unknown, maxLength: number) {
return text(value).slice(0, maxLength)
}
export function normalizePositiveInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(Number(value) || fallback)
return parsed > 0 ? parsed : fallback
}
export function normalizeNonNegativeInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(Number(value) || fallback)
return parsed >= 0 ? parsed : fallback
}
export function normalizeBoolean(value: unknown) {
return value === true || value === 'true' || value === 1 || value === '1'
}
export function normalizeStatus(
value: unknown,
allowed: Set<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
}
@@ -1,804 +1,22 @@
/**
* 成本中心共享层:枚举、类型、归一化函数与行映射。
* 成本中心共享层入口。
*
* 口径(2026-10-23 重写):
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
*/
import { createHttpError } from '../../utils/http.js'
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
// ---------------------------------------------------------------------------
// 枚举
// ---------------------------------------------------------------------------
export const PRODUCT_STATUSES = new Set(['active', 'archived'])
export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual'])
export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule'])
export const MAPPING_MATCH_TYPES = new Set([
'sku_code',
'sku_code_name',
'sku_code_series',
'sku_name_series',
])
export type TemplateMappingMatchType =
| 'sku_code'
| 'sku_code_name'
| 'sku_code_series'
| 'sku_name_series'
/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */
export type CostModel =
| 'affiliate_purchase'
| 'work_order_reward'
| 'manual_actual'
| 'platform_fee'
| 'payment_fee'
| 'after_sales_loss'
| 'other'
export const COST_MODELS = new Set<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
resolution_note: string
}
export type OrderItemCostContext = {
orderItemId: number
orderId: number
provider: string
shopId: string
skuCode: string
skuName: string
normalizedSkuName: string
normalizedSkuSeries: string
quantity: number
normalizedQuantity: number
canonicalProductId: number | null
revenueFen: number
basisAt: string
workOrderId: number | null
workProductRuleId: number | null
fulfillmentMode: FulfillmentMode
}
export type CostLine = {
costItemId: number
costItemCode: string
costItemName: string
costModel: CostModel
pricingMode: string
costVersionId: number
scopeType: string
amountFen: number
rateBps: number
quantity: number
unitAmountFen: number
totalAmountFen: number
}
export type CostExceptionDraft = {
reason: string
message: string
detail: Record<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:%')
)`
}
/**
* 成本模型是否适用于该履约方式。
*
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
* 对所有履约方式生效。
* 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口,
* 既有 import 保持不变:
* - cost-center-constants.ts 枚举、常量、SQL 片段
* - cost-center-types.ts 行结构与 DTO 类型
* - cost-center-mappers.ts 行映射与口径判断
* - cost-center-normalizers.ts 入参归一化与断言
*
* 新代码可以直接从对应模块导入;跨模块批量引用时才用本入口。
*/
export function costModelAppliesTo(costModel: string, fulfillmentMode: string) {
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
if (costModel === 'manual_actual') return fulfillmentMode === 'manual'
return false
}
/**
* 成本模型适用范围的 SQL 片段。
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
*/
export function costModelScopeSql(fulfillmentModeExpression: string): string {
return `(
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
OR (ci.cost_model = 'affiliate_purchase'
AND ${fulfillmentModeExpression} = 'affiliate_dash')
OR (ci.cost_model = 'work_order_reward'
AND ${fulfillmentModeExpression} = 'work_order')
OR (ci.cost_model = 'manual_actual'
AND ${fulfillmentModeExpression} = 'manual')
)`
}
/** 人工支出分类的默认推断:按成本模型归类。 */
export function inferExpenseCategory(costModel: string | undefined) {
if (costModel === 'affiliate_purchase') return 'goods_purchase'
if (costModel === 'manual_actual') return 'service_fee'
if (costModel === 'other') return 'freight'
if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee'
return 'other'
}
// ---------------------------------------------------------------------------
// 行映射
// ---------------------------------------------------------------------------
export function mapProduct(row: ProductRow) {
return {
id: Number(row.id),
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
category: row.category || '',
source: productSourceOf(row.canonical_sku),
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
subCategoryName: row.sub_category_name || '',
unitName: row.unit_name,
status: row.status,
attributes: parseJsonObject(row.attributes_json),
mappingCount: Number(row.mapping_count || 0),
costVersionCount: Number(row.cost_version_count || 0),
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapMapping(row: MappingRow) {
return {
id: Number(row.id),
provider: row.provider,
shopId: row.shop_id,
platformSkuCode: row.platform_sku_code,
platformSkuName: row.platform_sku_name,
platformSkuNameKey: row.platform_sku_name_key || '',
matchType: row.match_type || 'sku_code',
canonicalProductId: Number(row.canonical_product_id),
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
quantityMultiplier: Number(row.quantity_multiplier),
confidence: row.confidence,
source: row.source,
enabled: row.enabled !== false,
note: row.note || '',
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostItem(row: CostItemRow) {
return {
id: Number(row.id),
code: row.code,
name: row.name,
costModel: row.cost_model,
pricingMode: row.pricing_mode,
unitName: row.unit_name,
currency: row.currency,
status: row.status,
note: row.note || '',
versionCount: Number(row.version_count || 0),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostVersion(row: CostVersionRow) {
const revenueItemCount = Number(row.revenue_item_count || 0)
const revenueFen = Number(row.revenue_fen || 0)
const rewardAvgFen = Number(row.reward_avg_fen || 0)
const rewardFen = Number(row.reward_fen || 0)
const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
const amountKind =
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'rule' &&
ruleUnitPriceFen > 0 &&
ruleRewardAmountFen === 0
? 'unit_price'
: 'fixed'
return {
id: Number(row.id),
costItemId: Number(row.cost_item_id),
costItemCode: row.cost_item_code,
costItemName: row.cost_item_name,
costItemCostModel: row.cost_item_cost_model || 'other',
pricingMode: row.pricing_mode,
scopeType: row.scope_type,
provider: row.provider || '',
shopId: row.shop_id || '',
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
canonicalSku: row.canonical_sku || '',
canonicalName: row.canonical_name || '',
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
workProductRuleName: row.work_product_rule_name || '',
workProductRuleKey: row.work_product_rule_key || '',
ruleRewardAmountFen,
ruleUnitPriceFen,
revenueItemCount,
revenueFen,
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
rewardAvgFen,
rewardFen,
amountKind,
isSystemPlaceholder:
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'global' &&
Number(row.amount_fen) === 0,
amountFen: Number(row.amount_fen),
rateBps: Number(row.rate_bps),
effectiveFrom: row.effective_from,
effectiveTo: row.effective_to,
source: row.source,
status: row.status,
changeReason: row.change_reason || '',
createdByAdminUserId: row.created_by_admin_user_id
? Number(row.created_by_admin_user_id)
: null,
approvedByAdminUserId: row.approved_by_admin_user_id
? Number(row.approved_by_admin_user_id)
: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export function mapCostException(row: CostExceptionRow) {
return {
id: Number(row.id),
subjectType: row.subject_type,
subjectId: Number(row.subject_id),
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
fulfillmentMode: row.fulfillment_mode || '',
reason: row.reason,
status: row.status,
detail: parseJsonObject(row.detail_json),
createdAt: row.created_at,
resolvedAt: row.resolved_at,
resolvedByAdminUserId: row.resolved_by_admin_user_id
? Number(row.resolved_by_admin_user_id)
: null,
resolutionNote: row.resolution_note || '',
}
}
// ---------------------------------------------------------------------------
// 归一化
// ---------------------------------------------------------------------------
export function normalizePage(value: unknown) {
return Math.max(1, Math.trunc(Number(value) || 1))
}
export function normalizePageSize(value: unknown) {
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20)))
}
export function normalizeId(value: unknown, label: string) {
const id = Number(value)
if (!Number.isInteger(id) || id <= 0) {
throw createHttpError(`${label}无效`, {
statusCode: 400,
errorCode: 'cost_center_id_invalid',
})
}
return id
}
export function normalizeOptionalId(value: unknown) {
if (value === undefined || value === null || value === '') return null
const id = Number(value)
return Number.isInteger(id) && id > 0 ? id : null
}
export function normalizeCategoryFilter(value: unknown) {
if (value === undefined || value === null || value === '') return null
const id = Number(value)
if (!Number.isInteger(id) || (id <= 0 && id !== -1)) {
throw createHttpError('分类筛选无效', {
statusCode: 400,
errorCode: 'cost_center_category_invalid',
})
}
return id
}
export function normalizeRequiredText(value: unknown, label: string, maxLength: number) {
const normalized = text(value)
if (!normalized) {
throw createHttpError(`${label}不能为空`, {
statusCode: 400,
errorCode: 'cost_center_field_required',
})
}
if (normalized.length > maxLength) {
throw createHttpError(`${label}最多 ${maxLength} 个字符`, {
statusCode: 400,
errorCode: 'cost_center_field_too_long',
})
}
return normalized
}
export function normalizeOptionalText(value: unknown, maxLength: number) {
return text(value).slice(0, maxLength)
}
export function normalizePositiveInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(Number(value) || fallback)
return parsed > 0 ? parsed : fallback
}
export function normalizeNonNegativeInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(Number(value) || fallback)
return parsed >= 0 ? parsed : fallback
}
export function normalizeBoolean(value: unknown) {
return value === true || value === 'true' || value === 1 || value === '1'
}
export function normalizeStatus(
value: unknown,
allowed: Set<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
}
export * from './cost-center-constants.js'
export * from './cost-center-mappers.js'
export * from './cost-center-normalizers.js'
export * from './cost-center-types.js'
@@ -0,0 +1,197 @@
/**
* 成本中心共享层 · 行结构与 DTO 类型。
*/
import type { CostModel, FulfillmentMode } from './cost-center-constants.js'
// ---------------------------------------------------------------------------
// 行类型
// ---------------------------------------------------------------------------
export type ProductRow = {
[column: string]: unknown
id: number
canonical_sku: string
canonical_name: string
category: string
unit_name: string
status: string
root_category_id: number | null
root_category_name: string
sub_category_id: number | null
sub_category_name: string
attributes_json: unknown
created_at: string
updated_at: string
mapping_count?: number
cost_version_count?: number
affiliate_task_count?: number
affiliate_last_fulfilled_at?: string | null
}
export type ProductCategoryStatRow = {
category_id: number | null
name: string | null
parent_id: number | null
total: number
}
export type WorkCategoryRow = {
id: number
name: string
parent_id: number | null
}
export type ProductCategoryNode = {
categoryId: number
name: string
total: number
directTotal: number
children: Array<{
categoryId: number
name: string
total: number
}>
}
export type MappingRow = {
[column: string]: unknown
id: number
provider: string
shop_id: string
platform_sku_code: string
platform_sku_name: string
platform_sku_name_key: string
match_type: string
canonical_product_id: number
canonical_sku: string
canonical_name: string
quantity_multiplier: number
confidence: string
source: string
enabled: boolean
note: string
created_at: string
updated_at: string
}
export type CostItemRow = {
[column: string]: unknown
id: number
code: string
name: string
cost_model: string
pricing_mode: string
unit_name: string
currency: string
status: string
note: string
created_at: string
updated_at: string
version_count?: number
}
export type CostVersionRow = {
[column: string]: unknown
id: number
cost_item_id: number
cost_item_code: string
cost_item_name: string
cost_item_cost_model: string
pricing_mode: string
scope_type: string
provider: string
shop_id: string
canonical_product_id: number | null
canonical_sku: string | null
canonical_name: string | null
work_product_rule_id: number | null
work_product_rule_name: string | null
work_product_rule_key: string | null
rule_reward_amount: number | null
rule_unit_price_fen: number | null
revenue_item_count: number | null
revenue_fen: number | null
reward_avg_fen: number | null
reward_fen: number | null
amount_fen: number
rate_bps: number
effective_from: string
effective_to: string | null
source: string
status: string
change_reason: string
created_by_admin_user_id: number | null
approved_by_admin_user_id: number | null
created_at: string
updated_at: string
}
export type CostExceptionRow = {
[column: string]: unknown
id: number
subject_type: string
subject_id: number
order_item_id: number | null
work_order_id: number | null
fulfillment_mode: string | null
reason: string
status: string
detail_json: unknown
created_at: string
resolved_at: string | null
resolved_by_admin_user_id: number | null
resolution_note: string
}
export type OrderItemCostContext = {
orderItemId: number
orderId: number
provider: string
shopId: string
skuCode: string
skuName: string
normalizedSkuName: string
normalizedSkuSeries: string
quantity: number
normalizedQuantity: number
canonicalProductId: number | null
revenueFen: number
basisAt: string
workOrderId: number | null
workProductRuleId: number | null
fulfillmentMode: FulfillmentMode
}
export type CostLine = {
costItemId: number
costItemCode: string
costItemName: string
costModel: CostModel
pricingMode: string
costVersionId: number
scopeType: string
amountFen: number
rateBps: number
quantity: number
unitAmountFen: number
totalAmountFen: number
}
export type CostExceptionDraft = {
reason: string
message: string
detail: Record<string, unknown>
}
export type MissingCostItem = {
costItemId: number
costItemCode: string
costItemName: string
costModel: CostModel
}
export type CostResolution = {
lines: CostLine[]
missingCostItems: MissingCostItem[]
applicableCostItemCount: number
}
+17
View File
@@ -430,6 +430,23 @@ DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-2])_';
旧的逐列改造脚本 `scripts/rewrite-local-db-2026-10-23.sql` 已随迁移合并作废,移入 [archive/2026-10-23/](archive/2026-10-23/);只在追溯当时 `carrier_type → cost_model` / `fulfillment_mode` 的改造矩阵时查阅,**不要在新环境执行**。
### 8.1 收入口径回填(迁移 104 不做全表更新)
`order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `104` 里**只建列,不刷历史数据**:
375,447 行的全表 `UPDATE` 实测约 **34 秒**,超过默认语句超时(`DATABASE_STATEMENT_TIMEOUT_MS=15s`),
放进单事务迁移会拖住启动甚至直接失败。
历史行由脚本分批回填(幂等、可续跑):
```bash
npm run revenue:backfill -- --dry-run # 统计待回填量并抽样预览
npm run revenue:backfill # 默认每批 5000 行
npm run revenue:backfill -- --batch-size=2000 --max-batches=100
```
服务启动时会做一次带索引的计数:若仍有 `revenue_basis='unknown'` 的订单项,会打印告警并提示执行上面的命令。
**不执行回填的后果是销售事实收入偏低**(订单项收入按 0 计),不会报错,所以部署后必须确认该计数为 0。
`check-sql-guard.ts` 改为以 `main` 为基线校验迁移字节:**已发布的迁移不可修改**,未合并分支上的新迁移仍可重写;基线可用 `MIGRATION_BASELINE_REF` 覆盖,找不到基线时跳过该项检查。
---