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
@@ -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)],
)
}