feat(cost-center): 人工 SKU 配置自动同步商品与订单

This commit is contained in:
yml2213
2026-11-03 15:37:14 +08:00
parent eeb9fada63
commit efe706e601
7 changed files with 189 additions and 26 deletions
@@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
/**
@@ -361,6 +362,49 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
[skuKey],
)
const existingId = Number(existing.rows[0]?.id || 0)
let resolvedProductId = canonicalProductId
// 人工配置本身就是该 SKU 的标准商品声明。没有显式关联时自动创建/复用
// man:<内部 SKU>,避免运营再去「标准商品」手工建一次。
if (!resolvedProductId) {
const registry = await client.query<{ display_name: string }>(
`SELECT display_name FROM manual_sku_registry
WHERE canonicalize_internal_sku(sku_key) = $1
LIMIT 1`,
[skuKey],
)
const canonicalSku = `man:${skuKey}`
if (canonicalSku.length > 64) {
throw createHttpError('内部 SKU 过长,无法生成标准商品编码', {
statusCode: 400,
errorCode: 'manual_product_sku_too_long',
})
}
const canonicalName =
String(registry.rows[0]?.display_name || skuKey)
.trim()
.slice(0, 120) || skuKey.slice(0, 120)
const product = await client.query<{ id: number }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, '人工', '件', 'active',
'{"source":"manual_config","auto_created":true}'::jsonb, $3, $3
)
ON CONFLICT (canonical_sku) DO UPDATE SET
status = 'active',
canonical_name = CASE
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
ELSE product_catalog.canonical_name
END,
updated_at = EXCLUDED.updated_at
RETURNING id`,
[canonicalSku, canonicalName, now],
)
resolvedProductId = Number(product.rows[0]?.id || 0) || null
}
const result = existingId
? await client.query(
`UPDATE manual_fulfillment_configs
@@ -372,7 +416,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
[
existingId,
skuKey,
canonicalProductId,
resolvedProductId,
platformKey,
creditQuantity,
costMode,
@@ -387,7 +431,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
cost_mode, enabled, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
RETURNING *`,
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
[skuKey, resolvedProductId, platformKey, creditQuantity, costMode, enabled, note, now],
)
return requiredRow(result.rows[0], '保存人工履约配置失败')
})
@@ -396,16 +440,53 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
// 少了这一步,新配的 SKU 会一直停在待分类。
await syncManualChannelRule(config)
// 配置补全后,失败的 pending 会因 locked=true 无法被定时任务重试,这里主动解锁并重试。
const pendingAssigned = await retryPendingManualItems(config.sku_key)
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
const today = formatShanghaiDate(new Date())
await rematchManualFulfillments({
const rematched = await rematchManualFulfillments({
skuKey: config.sku_key,
onlyUnmatched: true,
dateFrom: shiftDate(today, -29),
dateTo: today,
})
return mapManualConfig(config, unitPrices)
return {
...mapManualConfig(config, unitPrices),
syncSummary: {
canonicalProductId: config.canonical_product_id ? Number(config.canonical_product_id) : null,
pendingAssigned,
rematchedItems: rematched.updatedCount,
},
}
}
async function retryPendingManualItems(skuKey: string): Promise<number> {
const key = canonicalizeInternalSku(skuKey)
if (!key) return 0
const candidates = await query<{ id: number }>(
`SELECT oi.id
FROM order_items oi
WHERE oi.fulfillment_status = 'pending'
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
ORDER BY oi.id
LIMIT 500`,
[key],
)
const ids = candidates.rows.map((row) => Number(row.id))
if (ids.length === 0) return 0
await query(
`UPDATE order_items
SET fulfillment_locked = FALSE, updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`,
[ids],
)
const result = await reconcilePendingCarriers({
orderItemIds: ids,
limit: ids.length,
allowFallback: true,
})
return result.fallbacksSelected
}
/** 删除一份人工履约配置(SKU 回到未配置状态)。 */
@@ -28,6 +28,8 @@ test(
switchChannelForOrderItems,
upsertChannelRule,
} = await import('./channel-assign-service.js')
const { saveManualFulfillmentConfig } =
await import('../cost-center/manual-fulfillment-config-service.js')
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
const {
deleteInternalSkuMapping,
@@ -44,6 +46,7 @@ test(
const voucherSku = `集成测试卡券-${suffix}`
const triggerSku = `集成测试规范-${suffix}`
const fallbackSku = `集成测试兜底-${suffix}`
const autoManualSku = `集成测试自动人工-${suffix}`
const orderIds: number[] = []
const orderItemIds: number[] = []
const crossShopSku = `集成测试跨店-${suffix}`
@@ -54,8 +57,10 @@ test(
crossShopSku,
triggerSku,
fallbackSku,
autoManualSku,
]
let generatedVoucherProductId: number | null = null
let autoManualProductId: number | null = null
let fallbackProfileId: number | null = null
async function createPendingOrderItem(
@@ -248,6 +253,39 @@ test(
assert.equal(pendingItem.rows[0]?.fulfillment_channel, null)
assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier')
const autoManualItemId = await createPendingOrderItem(autoManualSku)
const missingManual = await assignChannelByInternalSku({
internalSku: autoManualSku,
channel: 'manual',
adminUserId: null,
note: '集成测试缺人工配置',
})
assert.equal(missingManual.updated, 0)
assert.equal(missingManual.carrierFailed, 1)
const savedManual = await saveManualFulfillmentConfig({
skuKey: autoManualSku,
platformKey: 'fund_cash',
creditQuantity: 69,
note: '集成测试一次配置',
})
autoManualProductId = Number(savedManual.canonicalProductId || 0) || null
assert.ok(autoManualProductId)
assert.ok(savedManual.syncSummary.pendingAssigned >= 1)
const autoManualItem = await query<{
fulfillment_status: string
fulfillment_channel: string
fulfillment_config_id: number | null
canonical_product_id: number | null
}>(
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id
FROM order_items WHERE id = $1`,
[autoManualItemId],
)
assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned')
assert.equal(autoManualItem.rows[0]?.fulfillment_channel, 'manual')
assert.ok(Number(autoManualItem.rows[0]?.fulfillment_config_id) > 0)
assert.equal(Number(autoManualItem.rows[0]?.canonical_product_id), autoManualProductId)
const crossShopA = `it-routing-a-${suffix}`
const crossShopB = `it-routing-b-${suffix}`
await createPendingOrderItem(crossShopSku, { shopId: crossShopA })
@@ -460,6 +498,15 @@ test(
`tpv:${manualSku}`,
])
}
if (autoManualProductId) {
await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [
autoManualProductId,
])
await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [
autoManualProductId,
])
await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId])
}
if (fallbackProfileId) {
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId])
}