fix(finance): 收敛内部 SKU 履约路由与载体状态

统一内部 SKU 与渠道主/兜底解析,禁止并列主渠道。

运营指定渠道改为先创建真实载体,失败保持明确 pending。

历史缺载体、多载体冲突和自动发货兜底统一由管道修复。

迁移人工配置与旧 sku_code 规则,补充严格预检和集成测试。
This commit is contained in:
yml2213
2026-11-02 15:02:49 +08:00
parent 5fe33b0b46
commit 2f87445ed9
33 changed files with 2199 additions and 446 deletions
@@ -4,6 +4,7 @@ import type { JsonObject } from '../../types/json.js'
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'
/**
* 人工履约 SKU 配置服务。
@@ -125,7 +126,7 @@ export async function scanManualSkus(options: ScanOptions) {
oi.sku_name,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
oi.manual_sku_key AS sku_key
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
@@ -243,7 +244,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND o.provider = $${params.length}
AND oi.manual_sku_key = r.sku_key
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = r.sku_key
)`)
}
@@ -408,6 +409,15 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
[key],
)
await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key])
await upsertInternalSkuChannel({
internalSku: key,
channel: 'manual',
priority: 90,
isFallback: true,
enabled: false,
source: 'manual_config',
configJson: { manualConfigId: null },
})
await syncRegistryConfigId(key, null)
return { skuKey: key, deleted: deleted.rowCount || 0 }
}
@@ -435,7 +445,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = oi.manual_sku_key
ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND cfg.enabled = TRUE
AND ($1 = '' OR cfg.sku_key = $1)
@@ -719,27 +729,57 @@ function requiredRow<T>(row: T | undefined, message: string): T {
async function syncManualChannelRule(config: {
id: number
sku_key: string
canonical_product_id: number | null
enabled: boolean
note: string
}): Promise<void> {
const skuKey = String(config.sku_key || '').trim()
if (!skuKey) return
const otherChannel = await query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1 FROM internal_sku_channels
WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual'
) AS exists
`,
[skuKey],
)
const hasOtherChannel = otherChannel.rows[0]?.exists === true
await upsertInternalSkuChannel({
internalSku: skuKey,
channel: 'manual',
priority: hasOtherChannel ? 90 : 10,
isFallback: hasOtherChannel,
enabled: config.enabled !== false,
canonicalProductId: config.canonical_product_id,
source: 'manual_config',
configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) },
replacePriority: true,
})
await query(
`
INSERT INTO sku_channel_rules (
provider, shop_id, match_key_type, match_key, channel, priority,
source, source_ref_id, enabled, note, created_at, updated_at
)
VALUES ('', '', 'internal_sku', $1, 'manual', 70, 'manual', $2, $3, $4, NOW(), NOW())
VALUES ('', '', 'internal_sku', $1, 'manual', $5, 'manual', $2, $3, $4, NOW(), NOW())
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel)
DO UPDATE SET
enabled = EXCLUDED.enabled,
priority = EXCLUDED.priority,
source = 'manual',
source_ref_id = EXCLUDED.source_ref_id,
note = EXCLUDED.note,
updated_at = NOW()
`,
[skuKey, config.id, config.enabled !== false, String(config.note || '').slice(0, 200)],
[
skuKey,
config.id,
config.enabled !== false,
String(config.note || '').slice(0, 200),
hasOtherChannel ? 90 : 10,
],
)
}
@@ -215,21 +215,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
[internalSku],
)
const skuCode = String(item.sku_code || '').trim()
if (skuCode) {
await query(
`
INSERT INTO sku_channel_rules (
provider, shop_id, match_key_type, match_key, channel, priority,
source, enabled, note, created_at, updated_at
)
VALUES ($1, $2, 'sku_code', $3, 'third_party_voucher', 10, 'voucher', TRUE,
'卡券订单自动建商品后生成', NOW(), NOW())
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
`,
[item.provider, item.shop_id, skuCode],
)
}
return productId
}