feat(fulfillment): 渠道联动与管道兜底

- 人工配置保存/删除同步渠道规则,人工确认写 channel/status
- 按 SKU 指定渠道默认同时落规则
- 管道补充:载体缺失、人工成本未记录、事实与总账按水位追赶
This commit is contained in:
yml2213
2026-10-31 19:52:22 +08:00
parent 4272515956
commit 0ce3c8fa97
6 changed files with 189 additions and 12 deletions
@@ -87,6 +87,7 @@ router.post(
channel,
adminUserId,
note,
createRule: body.createRule !== false,
})
}
@@ -18,6 +18,8 @@ export const COST_EXCEPTION_REASONS = [
'product_mapping_missing',
'cost_missing',
'manual_config_missing',
'manual_cost_missing',
'carrier_missing',
] as const
export type CostExceptionReason = (typeof COST_EXCEPTION_REASONS)[number]
@@ -127,7 +127,7 @@ export async function scanManualSkus(options: ScanOptions) {
oi.manual_sku_key AS sku_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_mode = 'manual'
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
),
@@ -240,7 +240,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
conditions.push(`EXISTS (
SELECT 1 FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_mode = 'manual'
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND o.provider = $${params.length}
AND oi.manual_sku_key = r.sku_key
)`)
@@ -378,6 +378,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
await syncRegistryConfigId(config.sku_key, config.id)
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
// 少了这一步,新配的 SKU 会一直停在待分类。
await syncManualChannelRule(config)
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
const today = formatShanghaiDate(new Date())
@@ -403,6 +406,7 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
[key],
)
await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key])
await syncRegistryConfigId(key, null)
return { skuKey: key, deleted: deleted.rowCount || 0 }
}
@@ -431,7 +435,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
JOIN orders o ON o.id = oi.order_id
JOIN manual_fulfillment_configs cfg
ON cfg.sku_key = oi.manual_sku_key
WHERE oi.fulfillment_mode = 'manual'
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND cfg.enabled = TRUE
AND ($1 = '' OR cfg.sku_key = $1)
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
@@ -699,3 +703,36 @@ function requiredRow<T>(row: T | undefined, message: string): T {
}
return row
}
/**
* 人工配置 → 渠道规则(内部 SKU 精确匹配)。
*
* 幂等:按唯一键 (provider, shop, match_key_type, match_key, channel) upsert。
*/
async function syncManualChannelRule(config: {
id: number
sku_key: string
enabled: boolean
note: string
}): Promise<void> {
const skuKey = String(config.sku_key || '').trim()
if (!skuKey) return
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())
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel)
DO UPDATE SET
enabled = EXCLUDED.enabled,
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)],
)
}
@@ -96,7 +96,11 @@ export async function listManualFulfillmentPending(
const keyword = String(input.keyword || '').trim()
const status = String(input.status || '').trim()
const conditions = ["oi.fulfillment_mode = 'manual'", 'cfg.id IS NOT NULL']
const conditions = [
"oi.fulfillment_status = 'assigned'",
"oi.fulfillment_channel = 'manual'",
'cfg.id IS NOT NULL',
]
const params: unknown[] = []
if (status === 'pending') {
@@ -219,7 +223,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS basis_at,
oi.canonical_product_id,
oi.fulfillment_mode,
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS fulfillment_mode,
oi.fulfillment_config_id AS config_id,
COALESCE(cfg.platform_key, '') AS platform_key,
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
@@ -450,6 +454,21 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
})
}
// 人工确认即渠道成立:写新三列,避免订单一直停在 pending。
await client.query(
`
UPDATE order_items
SET fulfillment_channel = 'manual',
fulfillment_status = 'assigned',
fulfillment_source = 'manual_confirm',
fulfillment_decided_at = $2,
updated_at = $2
WHERE id = $1
AND fulfillment_status <> 'archived'
`,
[orderItemId, now],
)
// 人工缺成本异常立即收口,不等下一轮定时任务。
await client.query(
`UPDATE cost_exceptions
@@ -461,6 +480,20 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
[orderItemId, now],
)
// v2 聚合异常:删除本条明细,没人再引用的异常头自动关闭。
await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId])
await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1
WHERE ce.status <> 'resolved'
AND NOT EXISTS (
SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id
)
`,
[now],
)
return {
orderItemId,
orderId: Number(row.order_id_resolved),
@@ -582,7 +615,7 @@ async function matchManualFulfillmentConfigWithClient(
)
)
WHERE oi.id = $1
AND oi.fulfillment_mode = 'manual'
AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NULL
ORDER BY
(cfg.canonical_product_id IS NOT NULL
@@ -820,7 +853,7 @@ export async function getManualFulfillmentCountsByOrderIds(
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
WHERE oi.order_id = ANY($1::bigint[])
AND oi.fulfillment_mode = 'manual'
AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
GROUP BY oi.order_id
`,
[ids],
@@ -371,7 +371,8 @@ export async function assignChannelBySku(input: {
channel: string
adminUserId: number | null
note?: string
}): Promise<{ updated: number }> {
createRule?: boolean
}): Promise<{ updated: number; ruleCreated: boolean }> {
if (!isFulfillmentChannel(input.channel)) {
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
}
@@ -395,7 +396,26 @@ export async function assignChannelBySku(input: {
`,
[input.provider, input.shopId, input.skuCode, input.channel, input.adminUserId],
)
return { updated: result.rowCount || 0 }
// 默认同时落一条渠道规则:否则同 SKU 的下一单还是会进待分类。
let ruleCreated = false
if (input.createRule !== false) {
const rule = await upsertChannelRule({
provider: input.provider,
shopId: input.shopId,
matchKeyType: 'sku_code',
matchKey: input.skuCode,
channel: input.channel,
priority: 10,
source: 'operator',
enabled: true,
note: `运营在待分类队列指定:${String(input.note || '').slice(0, 120)}`,
adminUserId: input.adminUserId,
})
ruleCreated = rule.id > 0
}
return { updated: result.rowCount || 0, ruleCreated }
}
export async function listChannelRules(
@@ -110,12 +110,74 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
})
}
// 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。
const pendingManualCost = await query<{ id: number; sku_key: string }>(
`
SELECT oi.id, oi.manual_sku_key AS sku_key
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND oi.fulfillment_config_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
)
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of pendingManualCost.rows) {
await upsertAggregatedException({
reason: 'manual_cost_missing',
scopeKey: `manual_cost_missing|-|-|${row.sku_key}|manual`,
orderItemId: row.id,
detail: { skuKey: row.sku_key },
})
}
// 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。
const missingCarrier = await query<{ id: number; channel: string; sku_code: string }>(
`
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code
FROM order_items oi
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
AND NOT EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher')
)
AND NOT EXISTS (
SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id
)
ORDER BY oi.id
LIMIT $1
`,
[limit],
)
for (const row of missingCarrier.rows) {
await upsertAggregatedException({
reason: 'carrier_missing',
scopeKey: `carrier_missing|${row.channel}|${row.sku_code}`,
orderItemId: row.id,
detail: { channel: row.channel, skuCode: row.sku_code },
})
}
await touchWatermark('carrier_dispatch')
return {
status: 'ok',
message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length}`,
message:
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
`缺履约载体 ${missingCarrier.rows.length}`,
attached,
missingManual: missingManual.rows.length,
missingCarrier: missingCarrier.rows.length,
}
}
@@ -153,7 +215,7 @@ export async function runSalesFactsJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const dateFrom = await resolveCatchUpStart('sales_facts', days, dateTo)
const result = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo })
await touchWatermark('sales_facts', { lastStatDate: result.dateTo })
@@ -168,7 +230,7 @@ export async function runLedgerPostJob(job: JsonObject = {}) {
const config = jobConfig(job)
const days = clampInt(config.days, 3, 1, 60)
const dateTo = formatShanghaiDate(new Date())
const dateFrom = shiftDate(dateTo, -(days - 1))
const dateFrom = await resolveCatchUpStart('ledger_post', days, dateTo)
const result = await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
await touchWatermark('ledger_post', { lastStatDate: dateTo })
@@ -215,3 +277,25 @@ export async function runSingleFinancePipeline(
throw error
}
}
/**
* 计算本轮起始日期:默认回看 N 天;若水位更早(例如停机数天),
* 从水位那天补起,最多补 90 天,避免一次扫描过长。
*/
async function resolveCatchUpStart(
pipeline: 'sales_facts' | 'ledger_post',
days: number,
dateTo: string,
): Promise<string> {
const windowStart = shiftDate(dateTo, -(days - 1))
const result = await query<{ last_stat_date: string | null }>(
`SELECT last_stat_date::text FROM finance_watermarks WHERE pipeline = $1`,
[pipeline],
)
const lastDate = result.rows[0]?.last_stat_date || ''
if (!lastDate) return windowStart
const earliest = shiftDate(dateTo, -89)
const candidate = lastDate < windowStart ? lastDate : windowStart
return candidate < earliest ? earliest : candidate
}