feat(fulfillment): 渠道联动与管道兜底
- 人工配置保存/删除同步渠道规则,人工确认写 channel/status - 按 SKU 指定渠道默认同时落规则 - 管道补充:载体缺失、人工成本未记录、事实与总账按水位追赶
This commit is contained in:
@@ -87,6 +87,7 @@ router.post(
|
|||||||
channel,
|
channel,
|
||||||
adminUserId,
|
adminUserId,
|
||||||
note,
|
note,
|
||||||
|
createRule: body.createRule !== false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const COST_EXCEPTION_REASONS = [
|
|||||||
'product_mapping_missing',
|
'product_mapping_missing',
|
||||||
'cost_missing',
|
'cost_missing',
|
||||||
'manual_config_missing',
|
'manual_config_missing',
|
||||||
|
'manual_cost_missing',
|
||||||
|
'carrier_missing',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type CostExceptionReason = (typeof COST_EXCEPTION_REASONS)[number]
|
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
|
oi.manual_sku_key AS sku_key
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
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
|
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||||
BETWEEN $1::date AND $2::date
|
BETWEEN $1::date AND $2::date
|
||||||
),
|
),
|
||||||
@@ -240,7 +240,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
|||||||
conditions.push(`EXISTS (
|
conditions.push(`EXISTS (
|
||||||
SELECT 1 FROM order_items oi
|
SELECT 1 FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
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 o.provider = $${params.length}
|
||||||
AND oi.manual_sku_key = r.sku_key
|
AND oi.manual_sku_key = r.sku_key
|
||||||
)`)
|
)`)
|
||||||
@@ -378,6 +378,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
|||||||
|
|
||||||
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
|
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
|
||||||
await syncRegistryConfigId(config.sku_key, config.id)
|
await syncRegistryConfigId(config.sku_key, config.id)
|
||||||
|
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
|
||||||
|
// 少了这一步,新配的 SKU 会一直停在待分类。
|
||||||
|
await syncManualChannelRule(config)
|
||||||
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
|
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
|
||||||
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
|
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
|
||||||
const today = formatShanghaiDate(new Date())
|
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`,
|
`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`,
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
|
await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key])
|
||||||
await syncRegistryConfigId(key, null)
|
await syncRegistryConfigId(key, null)
|
||||||
return { skuKey: key, deleted: deleted.rowCount || 0 }
|
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 orders o ON o.id = oi.order_id
|
||||||
JOIN manual_fulfillment_configs cfg
|
JOIN manual_fulfillment_configs cfg
|
||||||
ON cfg.sku_key = oi.manual_sku_key
|
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 cfg.enabled = TRUE
|
||||||
AND ($1 = '' OR cfg.sku_key = $1)
|
AND ($1 = '' OR cfg.sku_key = $1)
|
||||||
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
|
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
|
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 keyword = String(input.keyword || '').trim()
|
||||||
const status = String(input.status || '').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[] = []
|
const params: unknown[] = []
|
||||||
|
|
||||||
if (status === 'pending') {
|
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(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||||
oi.canonical_product_id,
|
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,
|
oi.fulfillment_config_id AS config_id,
|
||||||
COALESCE(cfg.platform_key, '') AS platform_key,
|
COALESCE(cfg.platform_key, '') AS platform_key,
|
||||||
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
|
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(
|
await client.query(
|
||||||
`UPDATE cost_exceptions
|
`UPDATE cost_exceptions
|
||||||
@@ -461,6 +480,20 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
|||||||
[orderItemId, now],
|
[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 {
|
return {
|
||||||
orderItemId,
|
orderItemId,
|
||||||
orderId: Number(row.order_id_resolved),
|
orderId: Number(row.order_id_resolved),
|
||||||
@@ -582,7 +615,7 @@ async function matchManualFulfillmentConfigWithClient(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
WHERE oi.id = $1
|
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
|
AND oi.fulfillment_config_id IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
(cfg.canonical_product_id IS NOT NULL
|
(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 manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||||
WHERE oi.order_id = ANY($1::bigint[])
|
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
|
GROUP BY oi.order_id
|
||||||
`,
|
`,
|
||||||
[ids],
|
[ids],
|
||||||
|
|||||||
@@ -371,7 +371,8 @@ export async function assignChannelBySku(input: {
|
|||||||
channel: string
|
channel: string
|
||||||
adminUserId: number | null
|
adminUserId: number | null
|
||||||
note?: string
|
note?: string
|
||||||
}): Promise<{ updated: number }> {
|
createRule?: boolean
|
||||||
|
}): Promise<{ updated: number; ruleCreated: boolean }> {
|
||||||
if (!isFulfillmentChannel(input.channel)) {
|
if (!isFulfillmentChannel(input.channel)) {
|
||||||
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_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],
|
[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(
|
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')
|
await touchWatermark('carrier_dispatch')
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length}`,
|
message:
|
||||||
|
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
|
||||||
|
`缺履约载体 ${missingCarrier.rows.length}`,
|
||||||
attached,
|
attached,
|
||||||
missingManual: missingManual.rows.length,
|
missingManual: missingManual.rows.length,
|
||||||
|
missingCarrier: missingCarrier.rows.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +215,7 @@ export async function runSalesFactsJob(job: JsonObject = {}) {
|
|||||||
const config = jobConfig(job)
|
const config = jobConfig(job)
|
||||||
const days = clampInt(config.days, 3, 1, 60)
|
const days = clampInt(config.days, 3, 1, 60)
|
||||||
const dateTo = formatShanghaiDate(new Date())
|
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 })
|
const result = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo })
|
||||||
|
|
||||||
await touchWatermark('sales_facts', { lastStatDate: result.dateTo })
|
await touchWatermark('sales_facts', { lastStatDate: result.dateTo })
|
||||||
@@ -168,7 +230,7 @@ export async function runLedgerPostJob(job: JsonObject = {}) {
|
|||||||
const config = jobConfig(job)
|
const config = jobConfig(job)
|
||||||
const days = clampInt(config.days, 3, 1, 60)
|
const days = clampInt(config.days, 3, 1, 60)
|
||||||
const dateTo = formatShanghaiDate(new Date())
|
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 })
|
const result = await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
|
||||||
|
|
||||||
await touchWatermark('ledger_post', { lastStatDate: dateTo })
|
await touchWatermark('ledger_post', { lastStatDate: dateTo })
|
||||||
@@ -215,3 +277,25 @@ export async function runSingleFinancePipeline(
|
|||||||
throw error
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user