feat(fulfillment): 渠道联动与管道兜底
- 人工配置保存/删除同步渠道规则,人工确认写 channel/status - 按 SKU 指定渠道默认同时落规则 - 管道补充:载体缺失、人工成本未记录、事实与总账按水位追赶
This commit is contained in:
@@ -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)],
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user