后端: - 删除 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)。
253 lines
8.7 KiB
TypeScript
253 lines
8.7 KiB
TypeScript
/**
|
||
* 按上游自动发货商品价格,批量生成「自动发货采购成本」成本版本。
|
||
*
|
||
* 用法:
|
||
* 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
|
||
})
|