feat(finance): 统一内部 SKU 驱动的履约与财务分流
- 新增 internal_sku_mappings / internal_sku_channels,平台字段统一映射到内部中文 SKU - 下单入口按内部 SKU 渠道决策修正默认人工路由,自动发货补全 dash sku - 财务 channel_assign 读取统一渠道表;卡券/人工能力兜底,自动发货/接单要求真实载体 - carrier reconciliation 自动补自动发货任务和接单工单 - pending 落明确原因:无内部 SKU / 无渠道能力 / 无履约载体 - 合并、重判、旧 sku_code 规则清理、部署后 preflight 核对脚本 - 待分类队列按内部 SKU 聚合并展示原因
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 把旧的 sku_code 渠道规则转换为 internal_sku 全局规则,并停用旧规则。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts
|
||||
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
type LegacyRule = {
|
||||
id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
match_key: string
|
||||
channel: string
|
||||
priority: number
|
||||
source: string
|
||||
}
|
||||
|
||||
async function resolveInternalSku(rule: LegacyRule): Promise<string> {
|
||||
if (rule.channel === 'affiliate_dash') {
|
||||
const product = await query<{ canonical_name: string }>(
|
||||
`SELECT canonical_name FROM product_catalog
|
||||
WHERE canonical_sku = $1 AND status = 'active' AND btrim(canonical_name) <> ''
|
||||
LIMIT 1`,
|
||||
[`aff:${rule.match_key}`],
|
||||
)
|
||||
if (product.rows[0]?.canonical_name) return String(product.rows[0].canonical_name).trim()
|
||||
}
|
||||
|
||||
const params: unknown[] = [rule.match_key]
|
||||
let scope = ''
|
||||
if (rule.provider) {
|
||||
params.push(rule.provider)
|
||||
scope += ` AND o.provider = $${params.length}`
|
||||
}
|
||||
if (rule.shop_id) {
|
||||
params.push(rule.shop_id)
|
||||
scope += ` AND o.shop_id = $${params.length}`
|
||||
}
|
||||
const items = await query<{ internal_sku: string }>(
|
||||
`
|
||||
SELECT DISTINCT btrim(oi.manual_sku_key) AS internal_sku
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.sku_code = $1
|
||||
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]'
|
||||
${scope}
|
||||
LIMIT 2
|
||||
`,
|
||||
params,
|
||||
)
|
||||
const values = items.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)
|
||||
return values.length === 1 ? values[0]! : ''
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
const rules = await query<LegacyRule>(
|
||||
`
|
||||
SELECT id, provider, shop_id, match_key, channel, priority, source
|
||||
FROM sku_channel_rules
|
||||
WHERE match_key_type = 'sku_code' AND enabled = TRUE
|
||||
ORDER BY id
|
||||
`,
|
||||
)
|
||||
|
||||
const converted: Array<{ id: number; matchKey: string; internalSku: string; channel: string }> =
|
||||
[]
|
||||
const unresolved: LegacyRule[] = []
|
||||
for (const rule of rules.rows) {
|
||||
const internalSku = await resolveInternalSku(rule)
|
||||
if (!internalSku) {
|
||||
unresolved.push(rule)
|
||||
continue
|
||||
}
|
||||
converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel })
|
||||
if (!apply) continue
|
||||
|
||||
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 ('', '', 'internal_sku', $1, $2, $3, $4, TRUE,
|
||||
'由旧 sku_code 规则迁移', NOW(), NOW())
|
||||
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
||||
`,
|
||||
[internalSku, rule.channel, rule.priority, rule.source || 'operator'],
|
||||
)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, source
|
||||
) VALUES ($1, $2, $3, FALSE, TRUE, 'legacy-rule')
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
enabled = TRUE,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[internalSku, rule.channel, rule.priority],
|
||||
)
|
||||
await query(
|
||||
`UPDATE sku_channel_rules
|
||||
SET enabled = FALSE,
|
||||
note = CONCAT(COALESCE(note, ''), ' [已迁移 internal_sku:', $2::text, ']'),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[rule.id, internalSku],
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
apply,
|
||||
legacyRules: rules.rows.length,
|
||||
convertible: converted.length,
|
||||
converted,
|
||||
unresolved,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -147,6 +147,22 @@ async function mergeGroup(
|
||||
[group.keeper_id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
|
||||
)
|
||||
VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, $2, 'merged'),
|
||||
($1, 'manual', 20, TRUE, TRUE, $2, 'merged')
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback OR EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[group.canonical_name, group.keeper_id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO sku_channel_rules (
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 履约/财务统一分流部署后核对(只读)。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/preflight-fulfillment-routing.ts
|
||||
* npx tsx scripts/preflight-fulfillment-routing.ts --strict
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
type CountRow = { label: string; total: number }
|
||||
|
||||
async function main() {
|
||||
const strict = process.argv.includes('--strict')
|
||||
const checks: Record<string, unknown> = {}
|
||||
|
||||
checks.migrations = (
|
||||
await query<{ name: string }>(`SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5`)
|
||||
).rows.map((row) => row.name)
|
||||
|
||||
checks.pendingReasons = (
|
||||
await query<CountRow>(
|
||||
`SELECT COALESCE(fulfillment_pending_reason, '(null)') AS label, count(*)::int AS total
|
||||
FROM order_items WHERE fulfillment_status = 'pending'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.renameCardChannels = (
|
||||
await query<CountRow>(
|
||||
`SELECT COALESCE(fulfillment_channel, '(pending)') AS label, count(*)::int AS total
|
||||
FROM order_items
|
||||
WHERE sku_name = '11月改名卡'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.rules = (
|
||||
await query<CountRow>(
|
||||
`SELECT match_key_type || '/' || channel || '/' ||
|
||||
CASE WHEN enabled THEN 'on' ELSE 'off' END AS label, count(*)::int AS total
|
||||
FROM sku_channel_rules GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.internalChannels = (
|
||||
await query<CountRow>(
|
||||
`SELECT channel AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels WHERE enabled GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.pendingWithoutChannel = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND btrim(COALESCE(oi.manual_sku_key, '')) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM internal_sku_channels c
|
||||
WHERE c.enabled AND btrim(c.internal_sku) = btrim(oi.manual_sku_key)
|
||||
)`,
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.channelCarrier = (
|
||||
await query<{
|
||||
channel: string
|
||||
total: number
|
||||
work_orders: number
|
||||
affiliate_tasks: number
|
||||
manual_tasks: number
|
||||
}>(
|
||||
`SELECT COALESCE(fulfillment_channel, '(pending)') AS channel,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
const failed: string[] = []
|
||||
const rename = checks.renameCardChannels as CountRow[]
|
||||
if (rename.some((row) => row.label === 'pending')) failed.push('11月改名卡仍有 pending')
|
||||
if ((checks.pendingWithoutChannel as CountRow).total > 0)
|
||||
failed.push('存在有内部 SKU 但无渠道能力的 pending')
|
||||
|
||||
console.log(JSON.stringify({ strict, failed, ...checks }, null, 2))
|
||||
if (strict && failed.length > 0) process.exitCode = 2
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -33,11 +33,20 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM product_channel_capabilities c
|
||||
WHERE c.product_id = oi.canonical_product_id
|
||||
AND c.enabled
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled
|
||||
AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher')
|
||||
ORDER BY c.priority ASC
|
||||
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
WHEN 'third_party_voucher' THEN 3
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- 118_internal_sku_channels.sql
|
||||
--
|
||||
-- 内部中文 SKU 的渠道决策单一真源。
|
||||
-- 从商品渠道能力 + 现有 internal_sku 全局规则回填;后续 intake/finance 都读这张表。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internal_sku_channels (
|
||||
internal_sku TEXT NOT NULL,
|
||||
channel TEXT NOT NULL CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual')),
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
|
||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT 'backfill',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (internal_sku, channel)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_priority
|
||||
ON internal_sku_channels (internal_sku, priority, channel)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
COMMENT ON TABLE internal_sku_channels IS
|
||||
'内部中文 SKU 渠道能力:priority 越小越优先,is_fallback 表示兜底渠道';
|
||||
|
||||
WITH capability_source AS (
|
||||
SELECT
|
||||
btrim(p.canonical_name) AS internal_sku,
|
||||
c.channel,
|
||||
MIN(c.priority) AS priority,
|
||||
BOOL_AND(c.is_fallback) AS is_fallback,
|
||||
MIN(p.id) AS canonical_product_id
|
||||
FROM product_catalog p
|
||||
JOIN product_channel_capabilities c ON c.product_id = p.id AND c.enabled = TRUE
|
||||
WHERE p.status = 'active' AND btrim(p.canonical_name) <> ''
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, canonical_product_id, source
|
||||
)
|
||||
SELECT internal_sku, channel, priority, is_fallback, canonical_product_id, 'capability'
|
||||
FROM capability_source
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(internal_sku_channels.canonical_product_id, EXCLUDED.canonical_product_id),
|
||||
updated_at = NOW();
|
||||
|
||||
WITH rule_source AS (
|
||||
SELECT btrim(match_key) AS internal_sku, channel, MIN(priority) AS priority
|
||||
FROM sku_channel_rules
|
||||
WHERE match_key_type = 'internal_sku'
|
||||
AND provider = '' AND shop_id = ''
|
||||
AND enabled = TRUE
|
||||
AND btrim(match_key) <> ''
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, canonical_product_id, source
|
||||
)
|
||||
SELECT internal_sku, channel, priority, FALSE, NULL, 'rule'
|
||||
FROM rule_source
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = FALSE,
|
||||
updated_at = NOW();
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 119_fulfillment_pending_reason.sql
|
||||
--
|
||||
-- 待分类不再是一个模糊状态:落明确原因,供队列/审计区分。
|
||||
-- no_internal_sku 没有内部中文 SKU
|
||||
-- no_channel_capability 有内部 SKU,但没有渠道能力/规则
|
||||
-- no_carrier 有渠道能力,但缺任务/工单载体
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_pending_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fulfillment_pending_detail JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_pending_reason
|
||||
ON order_items (fulfillment_pending_reason)
|
||||
WHERE fulfillment_status = 'pending';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||
|
||||
/**
|
||||
* 管道 B:商品归一。
|
||||
@@ -191,6 +192,15 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
|
||||
[productId],
|
||||
)
|
||||
|
||||
await upsertInternalSkuChannel({
|
||||
internalSku,
|
||||
channel: 'third_party_voucher',
|
||||
priority: 10,
|
||||
isFallback: false,
|
||||
canonicalProductId: productId,
|
||||
source: 'auto',
|
||||
}).catch(() => undefined)
|
||||
|
||||
// 同一内部中文 SKU 跨平台码统一分流到卡券渠道。
|
||||
await query(
|
||||
`
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js'
|
||||
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
|
||||
type PendingCarrierItem = {
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
channel: 'affiliate_dash' | 'work_order'
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为“内部 SKU 有渠道能力、但还没有载体”的 pending 单补载体。
|
||||
*
|
||||
* - 接单平台:走现有工单匹配器,能匹配模板就建工单;
|
||||
* - 自动发货:补 fulfillmentRoute/affiliateDash 后建任务;
|
||||
* - 补不出来的继续保留 no_carrier,等运营补模板/映射。
|
||||
*
|
||||
* 财务渠道判定不在本服务内做;调用方在补完载体后再跑一次 channel_assign。
|
||||
*/
|
||||
export async function reconcilePendingCarriers(input: { limit?: number } = {}): Promise<{
|
||||
scanned: number
|
||||
workOrdersCreated: number
|
||||
affiliateTasksCreated: number
|
||||
remaining: number
|
||||
}> {
|
||||
const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200)))
|
||||
const rows = await query<PendingCarrierItem>(
|
||||
`
|
||||
SELECT oi.id, oi.order_id, oi.sku_code, oi.sku_name, oi.quantity,
|
||||
oi.item_snapshot_json, oi.spec_json,
|
||||
cap.channel
|
||||
FROM order_items oi
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND c.channel IN ('affiliate_dash', 'work_order')
|
||||
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
ELSE 9
|
||||
END,
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
)
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
)
|
||||
|
||||
let workOrdersCreated = 0
|
||||
let affiliateTasksCreated = 0
|
||||
const byOrder = new Map<number, PendingCarrierItem[]>()
|
||||
for (const row of rows.rows) {
|
||||
const list = byOrder.get(Number(row.order_id)) || []
|
||||
list.push(row)
|
||||
byOrder.set(Number(row.order_id), list)
|
||||
}
|
||||
|
||||
for (const [orderId, items] of byOrder) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (!order) continue
|
||||
const itemRows = await query<{
|
||||
id: number
|
||||
order_id: number
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
item_snapshot_json: unknown
|
||||
spec_json: unknown
|
||||
}>(
|
||||
`SELECT id, order_id, sku_code, sku_name, quantity, item_snapshot_json, spec_json
|
||||
FROM order_items WHERE order_id = $1`,
|
||||
[orderId],
|
||||
)
|
||||
const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item]))
|
||||
|
||||
for (const target of items) {
|
||||
const item = itemById.get(Number(target.id))
|
||||
if (!item) continue
|
||||
|
||||
try {
|
||||
if (target.channel === 'work_order') {
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, [item as never], {
|
||||
source: 'carrier_reconcile',
|
||||
autoOnly: true,
|
||||
})
|
||||
workOrdersCreated += result.created.length
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
...item,
|
||||
itemId: String(item.id),
|
||||
externalItemId: String(item.id),
|
||||
externalSkuCode: item.sku_code,
|
||||
externalSkuName: item.sku_name,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: parseObject(item.spec_json),
|
||||
snapshot: parseObject(item.item_snapshot_json),
|
||||
isConfigured: false,
|
||||
} as unknown as ResolvedFulfillmentItem
|
||||
const enriched = await applyIntakeChannelDecision(resolved, {
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
})
|
||||
const snapshot = parseObject(enriched.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
if (route.selectedExecutorKey !== 'affiliate_dash') continue
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET sku_code = $2,
|
||||
spec_json = $3,
|
||||
item_snapshot_json = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
item.id,
|
||||
String(enriched.skuCode || item.sku_code),
|
||||
JSON.stringify(enriched.spec || {}),
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
)
|
||||
const tasks = await syncDeliveryTasksForOrder(order, [
|
||||
{
|
||||
...item,
|
||||
sku_code: String(enriched.skuCode || item.sku_code),
|
||||
spec_json: enriched.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as never,
|
||||
])
|
||||
affiliateTasksCreated += tasks.length
|
||||
} catch (error) {
|
||||
logWarn('[carrier-reconciliation]', '补履约载体失败', {
|
||||
orderItemId: target.id,
|
||||
channel: target.channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remainingResult = await query<{ count: number }>(
|
||||
`
|
||||
SELECT count(*)::int AS count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_pending_reason = 'no_carrier'
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
scanned: rows.rows.length,
|
||||
workOrdersCreated,
|
||||
affiliateTasksCreated,
|
||||
remaining: Number(remainingResult.rows[0]?.count || 0),
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@ function buildItem(patch: Partial<ChannelCandidateItem> = {}): ChannelCandidateI
|
||||
sku_code: 'suit_xxx',
|
||||
sku_name: '套装-测试',
|
||||
manual_sku_key: 'suit_xxx',
|
||||
internal_sku: '套装-测试',
|
||||
normalized_sku_name: '套装-测试',
|
||||
has_work_order: false,
|
||||
has_affiliate_task: false,
|
||||
has_manual_task: false,
|
||||
capability_channel: null,
|
||||
basis_date: '2026-10-31',
|
||||
...patch,
|
||||
}
|
||||
@@ -98,7 +101,9 @@ test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => {
|
||||
channel: 'manual',
|
||||
})
|
||||
assert.equal(
|
||||
resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001' }), [byInternal]).channel,
|
||||
resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001', internal_sku: 'nick-001' }), [
|
||||
byInternal,
|
||||
]).channel,
|
||||
'manual',
|
||||
)
|
||||
|
||||
@@ -117,3 +122,28 @@ test('渠道判定:停用的规则不参与', () => {
|
||||
const decision = resolveChannelDecision(buildItem(), [buildRule({ enabled: false })])
|
||||
assert.equal(decision.channel, null)
|
||||
})
|
||||
|
||||
test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => {
|
||||
const voucher = resolveChannelDecision(
|
||||
buildItem({ capability_channel: 'third_party_voucher' }),
|
||||
[],
|
||||
)
|
||||
assert.equal(voucher.channel, 'third_party_voucher')
|
||||
assert.equal(voucher.source, 'auto_mapping')
|
||||
|
||||
const manual = resolveChannelDecision(buildItem({ capability_channel: 'manual' }), [])
|
||||
assert.equal(manual.channel, 'manual')
|
||||
assert.equal(manual.source, 'auto_mapping')
|
||||
|
||||
const affiliate = resolveChannelDecision(buildItem({ capability_channel: 'affiliate_dash' }), [])
|
||||
assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径')
|
||||
|
||||
const workOrder = resolveChannelDecision(buildItem({ capability_channel: 'work_order' }), [])
|
||||
assert.equal(workOrder.channel, null, '接单能力没有工单时不能只改财务口径')
|
||||
})
|
||||
|
||||
test('渠道判定:人工任务作为人工载体证据', () => {
|
||||
const decision = resolveChannelDecision(buildItem({ has_manual_task: true }), [])
|
||||
assert.equal(decision.channel, 'manual')
|
||||
assert.equal(decision.source, 'carrier_evidence')
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ export type ChannelCandidateItem = {
|
||||
normalized_sku_name: string | null
|
||||
has_work_order: boolean
|
||||
has_affiliate_task: boolean
|
||||
has_manual_task: boolean
|
||||
capability_channel: FulfillmentChannel | null
|
||||
basis_date: string
|
||||
}
|
||||
@@ -91,6 +92,25 @@ function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
|
||||
return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase()
|
||||
}
|
||||
|
||||
function resolvePendingReason(item: ChannelCandidateItem): {
|
||||
reason: string
|
||||
detail: Record<string, unknown>
|
||||
} {
|
||||
if (!String(item.internal_sku || '').trim()) {
|
||||
return { reason: 'no_internal_sku', detail: { skuCode: item.sku_code } }
|
||||
}
|
||||
if (!item.capability_channel) {
|
||||
return {
|
||||
reason: 'no_channel_capability',
|
||||
detail: { internalSku: item.internal_sku },
|
||||
}
|
||||
}
|
||||
return {
|
||||
reason: 'no_carrier',
|
||||
detail: { internalSku: item.internal_sku, channel: item.capability_channel },
|
||||
}
|
||||
}
|
||||
|
||||
/** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */
|
||||
export function resolveChannelDecision(
|
||||
item: ChannelCandidateItem,
|
||||
@@ -144,6 +164,15 @@ export function resolveChannelDecision(
|
||||
matchedRuleIds: [],
|
||||
}
|
||||
}
|
||||
if (item.has_manual_task) {
|
||||
return {
|
||||
channel: 'manual',
|
||||
source: 'carrier_evidence',
|
||||
conflict: false,
|
||||
conflictChannels: [],
|
||||
matchedRuleIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 卡券/人工没有必须创建的履约任务,可直接用商品渠道能力兜底;
|
||||
// 自动发货/接单平台仍要求真实载体,避免只改口径不派单。
|
||||
@@ -173,6 +202,8 @@ export type AssignChannelsResult = {
|
||||
affectedDates: string[]
|
||||
/** 本轮新判定的订单项(供立即计算成本,最多 2000 条)。 */
|
||||
assignedItemIds: number[]
|
||||
/** 待分类原因计数。 */
|
||||
pendingReasons: Record<string, number>
|
||||
}
|
||||
|
||||
/** 批量判定:默认只扫 pending 且未锁定的订单项。 */
|
||||
@@ -198,6 +229,7 @@ export async function assignPendingOrderItemChannels(
|
||||
byChannel: {},
|
||||
affectedDates: [],
|
||||
assignedItemIds: [],
|
||||
pendingReasons: {},
|
||||
}
|
||||
const affectedDates = new Set<string>()
|
||||
|
||||
@@ -222,17 +254,20 @@ export async function assignPendingOrderItemChannels(
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
) AS has_affiliate_task,
|
||||
EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
) AS has_manual_task,
|
||||
cap.channel AS capability_channel
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM product_catalog p
|
||||
JOIN product_channel_capabilities c
|
||||
ON c.product_id = p.id AND c.enabled = TRUE
|
||||
WHERE p.status = 'active'
|
||||
AND btrim(p.canonical_name) = ${INTERNAL_SKU_SQL}
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled = TRUE
|
||||
AND btrim(c.internal_sku) = ${INTERNAL_SKU_SQL}
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
@@ -240,7 +275,7 @@ export async function assignPendingOrderItemChannels(
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
c.priority ASC
|
||||
c.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
@@ -282,6 +317,16 @@ export async function assignPendingOrderItemChannels(
|
||||
}
|
||||
if (!decision.channel) {
|
||||
result.stillPending += 1
|
||||
const pending = resolvePendingReason(item)
|
||||
result.pendingReasons[pending.reason] = (result.pendingReasons[pending.reason] || 0) + 1
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_reason = $2,
|
||||
fulfillment_pending_detail = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
||||
[item.id, pending.reason, JSON.stringify(pending.detail)],
|
||||
)
|
||||
continue
|
||||
}
|
||||
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
|
||||
@@ -297,6 +342,8 @@ export async function assignPendingOrderItemChannels(
|
||||
fulfillment_status = 'assigned',
|
||||
fulfillment_source = input.source,
|
||||
fulfillment_decided_at = NOW(),
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
FROM unnest($1::bigint[], $2::text[], $3::text[])
|
||||
AS input(id, channel, source)
|
||||
@@ -385,6 +432,7 @@ export type PendingGroupRow = {
|
||||
internal_sku: string
|
||||
sku_name: string
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
@@ -432,6 +480,7 @@ export async function listPendingChannelGroups(input: {
|
||||
${INTERNAL_SKU_SQL} AS internal_sku,
|
||||
MIN(oi.sku_name) AS sku_name,
|
||||
array_agg(DISTINCT oi.sku_code) AS sku_codes,
|
||||
array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons,
|
||||
COUNT(*)::int AS item_count,
|
||||
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
||||
MIN(oi.created_at) AS first_seen_at,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
listInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||
import { matchAffiliateDashSkuByInternalSku } from './product-resolution-service.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
|
||||
type IntakeChannelDecisionInput = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function resolveInternalSku(item: ResolvedFulfillmentItem): string {
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const spec = parseObject(item.spec)
|
||||
return String(
|
||||
snapshot.internalSku || spec.skuNick || spec.outSkuSn || item.skuCode || item.skuName || '',
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单入口读取内部 SKU 渠道决策。
|
||||
*
|
||||
* 目前只补“自动发货”载体:若内部 SKU 能力是 affiliate_dash 且能解析出 dash sku,
|
||||
* 直接写 fulfillmentRoute + affiliateDash,让 delivery-task-service 建任务。
|
||||
* 接单平台仍由 work_order 匹配器创建;卡券/人工无任务,由财务侧兜底。
|
||||
*/
|
||||
export async function applyIntakeChannelDecision(
|
||||
item: ResolvedFulfillmentItem,
|
||||
_input: IntakeChannelDecisionInput,
|
||||
): Promise<ResolvedFulfillmentItem> {
|
||||
const internalSku = resolveInternalSku(item)
|
||||
if (!internalSku) return item
|
||||
|
||||
let channels: InternalSkuChannelRow[] = []
|
||||
try {
|
||||
channels = await listInternalSkuChannels(internalSku)
|
||||
} catch (error) {
|
||||
// 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。
|
||||
logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', {
|
||||
internalSku,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return item
|
||||
}
|
||||
if (channels.length === 0) return item
|
||||
|
||||
const snapshot = parseObject(item.snapshot)
|
||||
const route = parseObject(snapshot.fulfillmentRoute)
|
||||
const selected = String(route.selectedExecutorKey || '').trim()
|
||||
// 路由配置未命中时会默认落 manual_dispatch;这不是运营显式规则,不能覆盖内部 SKU 能力。
|
||||
const isDefaultManualFallback = selected === 'manual_dispatch' && !route.matchedRule
|
||||
const primary = channels[0]!
|
||||
const nextSnapshot: Record<string, unknown> = {
|
||||
...snapshot,
|
||||
internalSku: String(snapshot.internalSku || internalSku),
|
||||
internalSkuChannel: primary.channel,
|
||||
internalSkuChannelSource: primary.source,
|
||||
}
|
||||
|
||||
const canOverrideDefaultManual = !selected || isDefaultManualFallback
|
||||
const existingAffiliateSku = String(parseObject(snapshot.affiliateDash).sku || '').trim()
|
||||
|
||||
if (primary.channel === 'affiliate_dash' || selected === 'affiliate_dash') {
|
||||
const dashSku = existingAffiliateSku || matchAffiliateDashSkuByInternalSku(internalSku)
|
||||
if (dashSku) {
|
||||
if (canOverrideDefaultManual || selected === 'affiliate_dash') {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: 'affiliate_dash',
|
||||
selectedRuleId: String(route.selectedRuleId || ''),
|
||||
reason: canOverrideDefaultManual
|
||||
? '内部 SKU 渠道能力指定自动发货'
|
||||
: String(route.reason || '自动发货路由'),
|
||||
matchedRule: route.matchedRule ?? null,
|
||||
candidates: Array.isArray(route.candidates) ? route.candidates : [],
|
||||
skipped: Array.isArray(route.skipped) ? route.skipped : [],
|
||||
}
|
||||
nextSnapshot.affiliateDash = {
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
productNo: internalSku,
|
||||
sku: dashSku,
|
||||
skuName: internalSku,
|
||||
}
|
||||
// 商品归一按 aff:<dashSku> 找商品;内部中文 SKU 固定落 spec.skuNick。
|
||||
return {
|
||||
...item,
|
||||
skuCode: dashSku,
|
||||
spec: {
|
||||
...(item.spec && typeof item.spec === 'object' ? item.spec : {}),
|
||||
skuNick: internalSku,
|
||||
},
|
||||
isConfigured: true,
|
||||
snapshot: nextSnapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
nextSnapshot.internalSkuChannelReason = 'missing_affiliate_dash_mapping'
|
||||
return { ...item, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
// 内部能力是接单/卡券,但路由只是默认人工兜底:清掉人工任务,交给工单或财务能力兜底。
|
||||
if (
|
||||
(primary.channel === 'work_order' || primary.channel === 'third_party_voucher') &&
|
||||
isDefaultManualFallback
|
||||
) {
|
||||
nextSnapshot.fulfillmentRoute = {
|
||||
...route,
|
||||
selectedExecutorKey: '',
|
||||
reason: `内部 SKU 渠道能力为 ${primary.channel},不使用默认人工兜底`,
|
||||
matchedRule: null,
|
||||
}
|
||||
return { ...item, isConfigured: false, snapshot: nextSnapshot }
|
||||
}
|
||||
|
||||
return { ...item, snapshot: nextSnapshot }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import type { FulfillmentChannel } from './channel-types.js'
|
||||
|
||||
export type InternalSkuChannelRow = {
|
||||
internal_sku: string
|
||||
channel: FulfillmentChannel
|
||||
priority: number
|
||||
is_fallback: boolean
|
||||
enabled: boolean
|
||||
canonical_product_id: number | null
|
||||
source: string
|
||||
}
|
||||
|
||||
export async function listInternalSkuChannels(
|
||||
internalSku: string,
|
||||
): Promise<InternalSkuChannelRow[]> {
|
||||
const key = String(internalSku || '').trim()
|
||||
if (!key) return []
|
||||
const result = await query<InternalSkuChannelRow>(
|
||||
`
|
||||
SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
|
||||
FROM internal_sku_channels
|
||||
WHERE btrim(internal_sku) = $1 AND enabled = TRUE
|
||||
ORDER BY
|
||||
priority ASC,
|
||||
CASE channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
WHEN 'third_party_voucher' THEN 3
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
channel ASC
|
||||
`,
|
||||
[key],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/** 财务侧可直接兜底的渠道:卡券/人工不要求新建履约任务。 */
|
||||
export async function resolveInternalSkuFallbackChannel(
|
||||
internalSku: string,
|
||||
): Promise<FulfillmentChannel | null> {
|
||||
const channels = await listInternalSkuChannels(internalSku)
|
||||
const safe = channels.find(
|
||||
(row) => row.channel === 'third_party_voucher' || row.channel === 'manual',
|
||||
)
|
||||
return safe?.channel || null
|
||||
}
|
||||
|
||||
export async function upsertInternalSkuChannel(input: {
|
||||
internalSku: string
|
||||
channel: FulfillmentChannel
|
||||
priority?: number
|
||||
isFallback?: boolean
|
||||
canonicalProductId?: number | null
|
||||
source?: string
|
||||
}): Promise<void> {
|
||||
const internalSku = String(input.internalSku || '').trim()
|
||||
if (!internalSku) return
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, source, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
enabled = TRUE,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[
|
||||
internalSku,
|
||||
input.channel,
|
||||
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
|
||||
input.isFallback === true,
|
||||
input.canonicalProductId ?? null,
|
||||
String(input.source || 'auto').trim() || 'auto',
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -377,6 +377,15 @@ function resolveDouyinMashidaItemCandidate({
|
||||
* 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`),
|
||||
* 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。
|
||||
*/
|
||||
export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) return ''
|
||||
return matchAffiliateDashSku(
|
||||
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
|
||||
raw,
|
||||
)
|
||||
}
|
||||
|
||||
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
|
||||
import { applyIntakeChannelDecision } from '../fulfillment/intake-channel-decision-service.js'
|
||||
import {
|
||||
resolveOrderFulfillmentReadiness,
|
||||
syncOrderFulfillmentAttachments,
|
||||
@@ -113,14 +114,19 @@ export async function upsertOrderFromSource(
|
||||
})
|
||||
|
||||
const resolvedItems = await Promise.all(
|
||||
event.items.map((item) =>
|
||||
resolveOrderItemForFulfillment({
|
||||
event.items.map(async (item) => {
|
||||
const resolved = await resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopId: event.shopId,
|
||||
item,
|
||||
}),
|
||||
),
|
||||
})
|
||||
return applyIntakeChannelDecision(resolved, {
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
shopId: event.shopId,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const resolvedFulfillmentItems = resolvedItems as FulfillmentOrderItem[]
|
||||
const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
|
||||
import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js'
|
||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||
import {
|
||||
resolveExceptionsForItemsByReason,
|
||||
resolveOrphanCostExceptions,
|
||||
@@ -84,6 +85,10 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
|
||||
export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const limit = clampInt(config.limit, 200, 1, 2000)
|
||||
const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) })
|
||||
if (reconciled.affiliateTasksCreated > 0) {
|
||||
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }).catch(() => undefined)
|
||||
}
|
||||
const cursor =
|
||||
config.afterId != null
|
||||
? Math.max(0, Math.trunc(Number(config.afterId)))
|
||||
|
||||
@@ -126,6 +126,19 @@ function PendingPanel() {
|
||||
render: (value: string[] | undefined) => (value?.length ? value.join(' / ') : '-'),
|
||||
},
|
||||
{ title: '商品名', dataIndex: 'sku_name', ellipsis: true },
|
||||
{
|
||||
title: '待分类原因',
|
||||
dataIndex: 'pending_reasons',
|
||||
width: 160,
|
||||
render: (value: string[] | undefined) => {
|
||||
const labels: Record<string, string> = {
|
||||
no_internal_sku: '无内部SKU',
|
||||
no_channel_capability: '无渠道能力',
|
||||
no_carrier: '无履约载体',
|
||||
}
|
||||
return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'
|
||||
},
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'item_count', width: 90 },
|
||||
{
|
||||
title: '收入',
|
||||
|
||||
@@ -7,6 +7,7 @@ export type PendingChannelGroup = {
|
||||
internal_sku: string
|
||||
sku_name: string
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 生产部署核对清单(履约与财务分流统一)
|
||||
|
||||
日期:2026-11-02
|
||||
适用:生产当前在迁移 115,本次新增 116–119 + backend/frontend。
|
||||
|
||||
## 1. 部署前
|
||||
|
||||
1. 备份生产数据库(pg_dump,报告文件大小和 sha256)。
|
||||
2. 记录当前:
|
||||
- `schema_migrations` 最大版本;
|
||||
- pending 数量及原因;
|
||||
- `order_items` 渠道分布;
|
||||
- `sku_channel_rules` 匹配键分布。
|
||||
3. 确认没有正在跑的财务管道(或暂停定时任务)。
|
||||
4. 本地/预发已通过:
|
||||
- 后端测试、前端测试、build、lint、typecheck、SQL guard;
|
||||
- 三平台 E2E(自动发货 / 卡券 / 接单 / 抖音自动发货);
|
||||
- 成本、销售事实金额正确。
|
||||
|
||||
## 2. 迁移顺序
|
||||
|
||||
```text
|
||||
116_voucher_internal_sku_backfill
|
||||
117_internal_sku_mappings
|
||||
118_internal_sku_channels
|
||||
119_fulfillment_pending_reason
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- 116:给已有 `tpv:` 商品补 capability 和 internal_sku 规则;
|
||||
- 118:按商品能力/规则回填 `internal_sku_channels`;
|
||||
- SQL 已做聚合,避免同名商品同渠道 `ON CONFLICT` 二次更新报错;
|
||||
- 119:只加列和索引,风险低。
|
||||
|
||||
## 3. 部署顺序
|
||||
|
||||
1. 执行迁移 116–119;
|
||||
2. 部署 backend;
|
||||
3. 部署 frontend;
|
||||
4. 启动/恢复定时任务。
|
||||
|
||||
## 4. 部署后核对
|
||||
|
||||
### 待分类
|
||||
|
||||
```sql
|
||||
SELECT fulfillment_pending_reason, count(*)
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'pending'
|
||||
GROUP BY 1;
|
||||
```
|
||||
|
||||
预期只剩:
|
||||
|
||||
- `no_internal_sku`
|
||||
- `no_channel_capability`
|
||||
- `no_carrier`
|
||||
|
||||
### 11月改名卡
|
||||
|
||||
```sql
|
||||
SELECT oi.fulfillment_channel, count(*)
|
||||
FROM order_items oi
|
||||
WHERE oi.sku_name = '11月改名卡'
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1;
|
||||
```
|
||||
|
||||
预期:`third_party_voucher`,不再 manual / pending。
|
||||
|
||||
### 财务与载体一致
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
oi.fulfillment_channel,
|
||||
count(*) FILTER (WHERE EXISTS (
|
||||
SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id
|
||||
)) AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
)) AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
)) AS manual_tasks
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1;
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
```sql
|
||||
SELECT match_key_type, channel, enabled, count(*)
|
||||
FROM sku_channel_rules
|
||||
GROUP BY 1,2,3
|
||||
ORDER BY 1,2,3;
|
||||
```
|
||||
|
||||
旧 `sku_code` 规则应只剩无法定位内部 SKU 的少量记录。
|
||||
|
||||
### 内部 SKU 渠道表
|
||||
|
||||
```sql
|
||||
SELECT channel, count(*)
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled
|
||||
GROUP BY 1;
|
||||
```
|
||||
|
||||
## 5. 数据清理(确认后执行)
|
||||
|
||||
```bash
|
||||
# 先 dry-run
|
||||
npx tsx scripts/merge-duplicate-products.ts
|
||||
npx tsx scripts/cleanup-legacy-sku-code-rules.ts
|
||||
npx tsx scripts/reclassify-post-cutover.ts
|
||||
|
||||
# 确认后 apply
|
||||
npx tsx scripts/merge-duplicate-products.ts --apply
|
||||
npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
|
||||
npx tsx scripts/reclassify-post-cutover.ts --apply
|
||||
```
|
||||
|
||||
顺序:
|
||||
|
||||
1. 合并重复商品;
|
||||
2. 清理旧 sku_code 规则;
|
||||
3. 重判断代后订单;
|
||||
4. 跑一次完整管道;
|
||||
5. 核对成本/销售事实。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
- 代码:回退到部署前镜像/commit;
|
||||
- 数据库:
|
||||
- 116–119 均为新增表/列/数据,不删旧表,可保留;
|
||||
- 若必须回滚,恢复到部署前备份;
|
||||
- 迁移 118/119 不改旧字段语义,回滚代码后旧逻辑仍可运行。
|
||||
Reference in New Issue
Block a user