diff --git a/apps/backend/scripts/data/affiliate-dash-prices.json b/apps/backend/scripts/data/affiliate-dash-prices.json new file mode 100644 index 00000000..eb73b10b --- /dev/null +++ b/apps/backend/scripts/data/affiliate-dash-prices.json @@ -0,0 +1,40 @@ +[ + { "sku": "rock_blue_sportswear", "name": "蓝色运动服上衣", "category": "服饰", "costAmountFen": 2500 }, + { "sku": "rock_black_tshirt", "name": "黑色ROCK长袖T恤", "category": "服饰", "costAmountFen": 2500 }, + { "sku": "suit_jester_face", "name": "套装-花面小丑", "category": "套装", "costAmountFen": 20000 }, + { "sku": "suit_dune_song", "name": "套装-沙丘之歌", "category": "套装", "costAmountFen": 10200 }, + { "sku": "pack_dragon_palace", "name": "龙宫之主大礼包", "category": "礼包", "costAmountFen": 80000 }, + { "sku": "lucky_coin_x90", "name": "幸运币90个", "category": "道具", "costAmountFen": 9000 }, + { "sku": "lucky_coin_x30", "name": "幸运币30个", "category": "道具", "costAmountFen": 3000 }, + { "sku": "lucky_coin_x2", "name": "幸运币2个", "category": "道具", "costAmountFen": 200 }, + { "sku": "honor_medal_x90", "name": "荣耀勋章90个", "category": "道具", "costAmountFen": 9000 }, + { "sku": "honor_medal_x30", "name": "荣耀勋章30个", "category": "道具", "costAmountFen": 3000 }, + { "sku": "honor_medal_x2", "name": "荣耀勋章2个", "category": "道具", "costAmountFen": 200 }, + { "sku": "suit_cloud_bear", "name": "套装-绵云熊熊", "category": "套装", "costAmountFen": 13480 }, + { "sku": "pack_star_roam_weapon", "name": "星际漫游枪械礼包", "category": "礼包", "costAmountFen": 51600 }, + { "sku": "pack_star_roam_outfit", "name": "星际漫游服装礼包", "category": "礼包", "costAmountFen": 83600 }, + { "sku": "suit_sand_dancer", "name": "套装-狂沙舞者", "category": "套装", "costAmountFen": 8400 }, + { "sku": "pack_lava_ranger", "name": "熔岩游骑兵礼包", "category": "礼包", "costAmountFen": 9600 }, + { "sku": "suit_panda_tuan", "name": "套装-胖达团团", "category": "套装", "costAmountFen": 23400 }, + { "sku": "suit_panda_round", "name": "套装-胖达圆圆", "category": "套装", "costAmountFen": 23400 }, + { "sku": "helmet_panda_treasure", "name": "国宝胖达头盔", "category": "头盔", "costAmountFen": 23400 }, + { "sku": "suit_western_mystery", "name": "套装-西部谜踪", "category": "套装", "costAmountFen": 11500 }, + { "sku": "helmet_hamster_gray", "name": "仓鼠灰灰头盔", "category": "头盔", "costAmountFen": 16800 }, + { "sku": "bag_hamster_gray", "name": "仓鼠灰灰背包", "category": "背包", "costAmountFen": 16800 }, + { "sku": "helmet_pink_sheep", "name": "糯粉咩咩头盔", "category": "头盔", "costAmountFen": 16800 }, + { "sku": "bag_pink_sheep", "name": "糯粉咩咩背包", "category": "背包", "costAmountFen": 16800 }, + { "sku": "suit_cute_bear", "name": "套装-萌熊伴侣", "category": "套装", "costAmountFen": 16800 }, + { "sku": "suit_hamster_gray", "name": "套装-仓鼠灰灰", "category": "套装", "costAmountFen": 12800 }, + { "sku": "frag_pink_sheep", "name": "破片手榴弹-糯粉咩咩", "category": "投掷物", "costAmountFen": 4880 }, + { "sku": "smoke_pink_sheep", "name": "烟雾弹-糯粉咩咩", "category": "投掷物", "costAmountFen": 4880 }, + { "sku": "pack_western_cowboy", "name": "西部牛仔大礼包", "category": "礼包", "costAmountFen": 32000 }, + { "sku": "suit_romantic_destiny", "name": "套装-浪漫天命", "category": "套装", "costAmountFen": 3600 }, + { "sku": "suit_first_peach", "name": "套装-恋恋初桃", "category": "套装", "costAmountFen": 9880 }, + { "sku": "suit_pink_sheep", "name": "套装-糯粉咩咩", "category": "套装", "costAmountFen": 18800 }, + { "sku": "suit_dual_fluffy", "name": "套装-双彩绵绵", "category": "套装", "costAmountFen": 11240 }, + { "sku": "bag_cute_bear", "name": "萌熊伴侣背包", "category": "背包", "costAmountFen": 28800 }, + { "sku": "m416_hamster_gray", "name": "M416-仓鼠灰灰", "category": "枪械", "costAmountFen": 16800 }, + { "sku": "top_black_elite_trainer", "name": "黑色高级特训官上衣", "category": "上衣", "costAmountFen": 3600 }, + { "sku": "suit_shadow_gothic", "name": "套装-暗影哥特", "category": "套装", "costAmountFen": 8880 }, + { "sku": "suit_alan_walker", "name": "套装-Alan Walker", "category": "套装", "costAmountFen": 3600 } +] diff --git a/apps/backend/scripts/sync-affiliate-dash-prices.ts b/apps/backend/scripts/sync-affiliate-dash-prices.ts new file mode 100644 index 00000000..d87c708b --- /dev/null +++ b/apps/backend/scripts/sync-affiliate-dash-prices.ts @@ -0,0 +1,217 @@ +/** + * 按上游自动发货商品价格,批量生成「自动发货采购成本」成本版本。 + * + * 用法: + * npx tsx scripts/sync-affiliate-dash-prices.ts [价格 JSON 路径] [--cost-item-id=5] [--dry-run] + * + * 规则: + * - 标准商品编码固定为 `aff:`,载体 affiliate_dash; + * - 成本版本范围 = 标准商品,金额 = 上游 cost_amount(分); + * - 同一商品已有相同金额的生效版本时跳过; + * - 金额变化时先停用旧版本,再插入新版本(历史快照不变)。 + */ +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 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('--') && !filePath) filePath = arg + } + return { + filePath: path.resolve(filePath || 'scripts/data/affiliate-dash-prices.json'), + costItemId, + dryRun, + } +} + +async function resolveCostItem(costItemId: number | null): Promise { + if (costItemId) { + const result = await query('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( + `SELECT * FROM cost_items + WHERE carrier_type = 'affiliate_dash' AND cost_type = 'auto_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( + `INSERT INTO cost_items ( + code, name, cost_type, carrier_type, pricing_mode, unit_name, currency, status, note, created_at, updated_at + ) VALUES ('auto_purchase', '自动发货采购成本', 'auto_purchase', 'affiliate_dash', '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( + "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, dryRun } = parseArgs(process.argv.slice(2)) + 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} 条${dryRun ? '(dry-run)' : ''}`, + ) + + let createdProductCount = 0 + let updatedProductCount = 0 + let createdVersionCount = 0 + let retiredVersionCount = 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, carrier_type, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'affiliate_dash', '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, + carrier_type = 'affiliate_dash', + 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 }>( + `SELECT id, 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, product.id], + ) + const current = currentResult.rows[0] + if (current && Number(current.amount_fen) === row.costAmountFen) { + 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, $4, $4)`, + [costItem.id, product.id, row.costAmountFen, now], + ) + createdVersionCount += 1 + } + + console.log( + `[affiliate-price] 完成:新增商品 ${createdProductCount}、更新商品 ${updatedProductCount}、` + + `新增版本 ${createdVersionCount}、停用旧版本 ${retiredVersionCount}、金额未变跳过 ${skippedCount}`, + ) +} + +main() + .then(async () => { + await closeDb() + }) + .catch(async (error) => { + console.error(error) + await closeDb() + process.exitCode = 1 + }) diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts index 89de5912..010256e4 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -25,6 +25,17 @@ const COST_TYPES = new Set([ 'shipping_other', 'other', ]) +/** 成本项自动编码的基础名:按成本类型生成,重复时追加 _2、_3。 */ +const COST_ITEM_CODE_BASE: Record = { + auto_purchase: 'auto_purchase', + work_order_reward: 'work_order_reward', + manual_service: 'manual_service', + platform_fee: 'platform_fee', + payment_fee: 'payment_fee', + after_sales_loss: 'after_sales_loss', + shipping_other: 'shipping_other', + other: 'other_cost', +} const CARRIER_TYPES = new Set(['affiliate_dash', 'work_order', 'manual', 'shared']) const PRICING_MODES = new Set([ 'per_item', @@ -1498,7 +1509,7 @@ export async function syncAdminAffiliateDashProducts() { SET carrier_type = 'affiliate_dash', canonical_name = EXCLUDED.canonical_name, unit_name = EXCLUDED.unit_name, - attributes_json = EXCLUDED.attributes_json, + attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = $1 RETURNING (xmax = 0) AS inserted `, @@ -2161,11 +2172,12 @@ export async function listAdminCostItems(payload: JsonObject = {}) { } export async function createAdminCostItem(payload: JsonObject, _session: AdminSession) { - const code = normalizeRequiredText(payload.code, '成本项编码', 64) const name = normalizeRequiredText(payload.name, '成本项名称', 120) const costType = normalizeStatus(payload.costType, COST_TYPES, null, '成本类型无效') const carrierType = normalizeStatus(payload.carrierType, CARRIER_TYPES, null, '成本载体无效') const pricingMode = normalizeStatus(payload.pricingMode, PRICING_MODES, null, '计价方式无效') + // 编码由系统生成(按成本类型 + 冲突序号);显式传入时仍优先使用,兼容脚本导入。 + const code = normalizeOptionalText(payload.code, 64) || (await generateCostItemCode(costType)) const unitName = normalizeOptionalText(payload.unitName, 16) || '件' const currency = normalizeOptionalText(payload.currency, 8) || 'CNY' const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '成本项状态无效') @@ -2193,6 +2205,22 @@ export async function createAdminCostItem(payload: JsonObject, _session: AdminSe return { costItem: mapCostItem(inserted) } } +async function generateCostItemCode(costType: string) { + const base = COST_ITEM_CODE_BASE[costType] || 'cost_item' + let candidate = base + let index = 2 + for (let attempt = 0; attempt < 100; attempt += 1) { + const existing = await query<{ id: number }>( + 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', + [candidate], + ) + if (!existing.rows[0]) return candidate + candidate = `${base}_${index}` + index += 1 + } + return `${base}_${Date.now()}` +} + export async function updateAdminCostItem(costItemId: number | string, payload: JsonObject) { const id = normalizeId(costItemId, '成本项 ID') const current = await getCostItemRow(id) diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index dd598656..214c32b8 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -110,7 +110,6 @@ type MappingFormValues = { } type CostItemFormValues = { - code: string name: string costType: CostItem['costType'] carrierType: CostItem['carrierType'] @@ -208,7 +207,6 @@ function tabKeysForCarrier(carrier: CarrierScope) { type CostItemPreset = { key: string label: string - code: string name: string costType: CostItem['costType'] carrierType: CostCarrierType @@ -221,7 +219,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'auto_purchase', label: '自动发货采购成本', - code: 'auto_purchase', name: '自动发货采购成本', costType: 'auto_purchase', carrierType: 'affiliate_dash', @@ -231,7 +228,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'work_order_reward', label: '接单平台打手报酬', - code: 'work_order_reward', name: '接单平台打手报酬', costType: 'work_order_reward', carrierType: 'work_order', @@ -241,7 +237,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'manual_service', label: '人工客服成本', - code: 'manual_service', name: '人工客服成本', costType: 'manual_service', carrierType: 'manual', @@ -251,7 +246,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'platform_fee', label: '平台服务费', - code: 'platform_fee', name: '平台服务费', costType: 'platform_fee', carrierType: 'shared', @@ -261,7 +255,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'payment_fee', label: '支付通道费', - code: 'payment_fee', name: '支付通道费', costType: 'payment_fee', carrierType: 'shared', @@ -271,7 +264,6 @@ const COST_ITEM_PRESETS: CostItemPreset[] = [ { key: 'after_sales_loss', label: '售后损失', - code: 'after_sales_loss', name: '售后损失', costType: 'after_sales_loss', carrierType: 'shared', @@ -781,7 +773,6 @@ export default function AdminCostCenterPage() { costItemForm.resetFields() if (preset) { costItemForm.setFieldsValue({ - code: preset.code, name: preset.name, costType: preset.costType, carrierType: preset.carrierType, @@ -815,7 +806,6 @@ export default function AdminCostCenterPage() { function openEditCostItem(item: CostItem) { setEditingCostItem(item) costItemForm.setFieldsValue({ - code: item.code, name: item.name, costType: item.costType, carrierType: item.carrierType, @@ -841,8 +831,8 @@ export default function AdminCostCenterPage() { }) message.success('成本项目已更新') } else { - await createAdminCostItem(values) - message.success('成本项目已创建') + const created = await createAdminCostItem(values) + message.success(`成本项目已创建(编码 ${created.data.costItem.code})`) } setCostItemModalOpen(false) refreshAll() @@ -2596,14 +2586,12 @@ export default function AdminCostCenterPage() { destroyOnClose >
- - - + diff --git a/docs/成本中心与三块履约模型.md b/docs/成本中心与三块履约模型.md index b66a2bd7..27229a30 100644 --- a/docs/成本中心与三块履约模型.md +++ b/docs/成本中心与三块履约模型.md @@ -139,6 +139,8 @@ product_catalog `carrier_type`(迁移 `107_product_catalog_carrier.sql`)是后台「商品与成本中心」的分区维度:自动发货 / 接单平台 / 人工 / 共用。回填口径(2026-10-22 修正):只把**真正走 affiliate-dash 执行器**的商品算作 `affiliate_dash`——以 `fulfillment_tasks.executor_key='affiliate_dash'` 的履约证据为准,按 `context_json.skuCode` 建标准商品(`aff:`,生产快照 41,676 条任务 / 28 个 SKU);接单模板初始化出来的 `tpl:*` 商品全部是 `work_order`(662 个)。**渠道映射只代表平台 SKU 能归一到标准商品,不代表自动发货**。人工/共用由后台按需手工调整;新增自动发货 SKU 可在页面点「同步自动发货商品」补建。 +上游自动发货商品价格用 `apps/backend/scripts/sync-affiliate-dash-prices.ts` 批量导入(价格清单见 `apps/backend/scripts/data/affiliate-dash-prices.json`):按 `aff:` 补建/更新商品,并给指定成本项生成 `scope=product` 的按件版本;金额取上游 `cost_amount`(分),价格变化时先停用旧版本再插入新版本,历史快照不变。示例:`npx tsx scripts/sync-affiliate-dash-prices.ts --cost-item-id=5`。 + ### 3.2 渠道 SKU 映射 ```text