Files
order_site/apps/backend/scripts/sync-affiliate-dash-prices.ts
T
yml2213 63ae32d18c 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)。
2026-10-23 16:06:25 +08:00

253 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 按上游自动发货商品价格,批量生成「自动发货采购成本」成本版本。
*
* 用法:
* npx tsx scripts/sync-affiliate-dash-prices.ts [价格 JSON 路径] [--cost-item-id=5] [--effective-from=YYYY-MM-DD] [--dry-run]
*
* 规则:
* - 标准商品编码固定为 `aff:<sku>`,载体 affiliate_dash;
* - 成本版本范围 = 标准商品,金额 = 上游 cost_amount(分);
* - 同一商品已有相同金额的生效版本时跳过;指定 --effective-from 时,
* 若现版本生效时间更晚,则把首次基线版本的生效时间回填到该日期;
* - 金额变化时先停用旧版本,再插入新版本(历史快照不变)。
*/
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
type PriceRow = {
sku: string
name: string
category: string
costAmountFen: number
}
type CostItemRow = {
id: number
code: string
name: string
pricing_mode: string
}
function parseArgs(argv: string[]) {
let filePath = ''
let costItemId: number | null = null
let effectiveFrom = ''
let dryRun = false
for (const arg of argv) {
if (arg === '--dry-run') {
dryRun = true
continue
}
if (arg.startsWith('--cost-item-id=')) {
const parsed = Number(arg.slice('--cost-item-id='.length))
if (Number.isFinite(parsed) && parsed > 0) costItemId = parsed
continue
}
if (arg.startsWith('--effective-from=')) {
effectiveFrom = arg.slice('--effective-from='.length).trim()
continue
}
if (!arg.startsWith('--') && !filePath) filePath = arg
}
return {
filePath: path.resolve(filePath || 'scripts/data/affiliate-dash-prices.json'),
costItemId,
effectiveFrom,
dryRun,
}
}
async function resolveCostItem(costItemId: number | null): Promise<CostItemRow> {
if (costItemId) {
const result = await query<CostItemRow>('SELECT * FROM cost_items WHERE id = $1', [costItemId])
const row = result.rows[0]
if (!row) throw new Error(`成本项不存在: ${costItemId}`)
if (row.pricing_mode !== 'per_item') {
throw new Error(`成本项 ${row.code} 的计价方式不是按件,不能导入按件采购价`)
}
return row
}
const existing = await query<CostItemRow>(
`SELECT * FROM cost_items
WHERE cost_model = 'affiliate_purchase' AND status = 'active'
ORDER BY id LIMIT 1`,
)
if (existing.rows[0]) return existing.rows[0]
const now = new Date().toISOString()
const inserted = await query<CostItemRow>(
`INSERT INTO cost_items (
code, name, cost_model, pricing_mode, unit_name, currency, status, note, created_at, updated_at
) VALUES ('auto_purchase', '自动发货采购成本', 'affiliate_purchase', 'per_item', '件', 'CNY', 'active', '上游自动发货价格同步', $1, $1)
ON CONFLICT (code) DO NOTHING
RETURNING *`,
[now],
)
if (inserted.rows[0]) return inserted.rows[0]
const fallback = await query<CostItemRow>(
"SELECT * FROM cost_items WHERE code = 'auto_purchase' LIMIT 1",
)
if (!fallback.rows[0]) throw new Error('无法创建自动发货采购成本项')
return fallback.rows[0]
}
async function main() {
const { filePath, costItemId, effectiveFrom, dryRun } = parseArgs(process.argv.slice(2))
const effectiveFromIso = normalizeEffectiveFrom(effectiveFrom)
const rows = JSON.parse(fs.readFileSync(filePath, 'utf8')) as PriceRow[]
const costItem = await resolveCostItem(costItemId)
console.log(
`[affiliate-price] 成本项 #${costItem.id} ${costItem.name}(${costItem.code});价格条目 ${rows.length} 条` +
`${effectiveFromIso ? `;生效时间 ${effectiveFromIso}` : ''}${dryRun ? '(dry-run)' : ''}`,
)
let createdProductCount = 0
let updatedProductCount = 0
let createdVersionCount = 0
let retiredVersionCount = 0
let backdatedVersionCount = 0
let skippedCount = 0
for (const row of rows) {
const now = new Date().toISOString()
const canonicalSku = `aff:${row.sku}`
const unitName = row.name.includes('个') ? '个' : '件'
if (dryRun) {
const existingProduct = await query<{ id: number }>(
'SELECT id FROM product_catalog WHERE canonical_sku = $1',
[canonicalSku],
)
const existingId = existingProduct.rows[0]?.id
if (!existingId) {
createdProductCount += 1
createdVersionCount += 1
continue
}
updatedProductCount += 1
const current = await query<{ amount_fen: number }>(
`SELECT amount_fen FROM cost_versions
WHERE cost_item_id = $1 AND scope_type = 'product' AND canonical_product_id = $2 AND status = 'active'
ORDER BY effective_from DESC, id DESC LIMIT 1`,
[costItem.id, existingId],
)
if (current.rows[0] && Number(current.rows[0].amount_fen) === row.costAmountFen) {
skippedCount += 1
} else {
createdVersionCount += 1
}
continue
}
const productResult = await query<{ id: number; inserted: boolean }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6)
ON CONFLICT (canonical_sku) DO UPDATE
SET canonical_name = EXCLUDED.canonical_name,
category = EXCLUDED.category,
unit_name = EXCLUDED.unit_name,
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
updated_at = $6
RETURNING id, (xmax = 0) AS inserted`,
[
canonicalSku,
row.name,
row.category,
unitName,
JSON.stringify({
source: 'affiliate_dash_price',
skuCode: row.sku,
priceFen: row.costAmountFen,
}),
now,
],
)
const product = productResult.rows[0]
if (!product) throw new Error(`商品写入失败: ${canonicalSku}`)
if (product.inserted) createdProductCount += 1
else updatedProductCount += 1
const currentResult = await query<{ id: number; amount_fen: number; effective_from: string }>(
`SELECT id, amount_fen, effective_from FROM cost_versions
WHERE cost_item_id = $1 AND scope_type = 'product' AND canonical_product_id = $2 AND status = 'active'
ORDER BY effective_from DESC, id DESC LIMIT 1`,
[costItem.id, product.id],
)
const current = currentResult.rows[0]
if (current && Number(current.amount_fen) === row.costAmountFen) {
if (
effectiveFromIso &&
new Date(current.effective_from).getTime() > new Date(effectiveFromIso).getTime()
) {
await query(
`UPDATE cost_versions
SET effective_from = $1, change_reason = '上游基线价格回填', updated_at = $2
WHERE id = $3`,
[effectiveFromIso, now, current.id],
)
backdatedVersionCount += 1
continue
}
skippedCount += 1
continue
}
if (current) {
const retired = await query(
`UPDATE cost_versions
SET status = 'retired', change_reason = '上游价格更新', updated_at = $1
WHERE cost_item_id = $2 AND canonical_product_id = $3 AND status = 'active'`,
[now, costItem.id, product.id],
)
retiredVersionCount += retired.rowCount || 0
}
await query(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id, work_product_rule_id,
amount_fen, rate_bps, effective_from, effective_to, source, status, change_reason,
created_by_admin_user_id, approved_by_admin_user_id, created_at, updated_at
) VALUES ($1, 'product', '', '', $2, NULL, $3, 0, $4, NULL, 'upstream_sync', 'active',
'上游自动发货价格同步', NULL, NULL, $5, $5)`,
[costItem.id, product.id, row.costAmountFen, effectiveFromIso || now, now],
)
createdVersionCount += 1
}
console.log(
`[affiliate-price] 完成:新增商品 ${createdProductCount}、更新商品 ${updatedProductCount}、` +
`新增版本 ${createdVersionCount}、停用旧版本 ${retiredVersionCount}、` +
`回填生效时间 ${backdatedVersionCount}、金额未变跳过 ${skippedCount}`,
)
}
function normalizeEffectiveFrom(value: string) {
if (!value) return ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('--effective-from 必须是 YYYY-MM-DD')
}
const parsed = new Date(`${value}T00:00:00+08:00`)
if (!Number.isFinite(parsed.getTime())) {
throw new Error('--effective-from 日期无效')
}
return parsed.toISOString()
}
main()
.then(async () => {
await closeDb()
})
.catch(async (error) => {
console.error(error)
await closeDb()
process.exitCode = 1
})