163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
import { query, withTransaction } from '../../db/client.js'
|
|
import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js'
|
|
import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
|
|
|
/**
|
|
* 自动发货失败 → 人工兜底。
|
|
*
|
|
* 触发:该订单项最新一条 affiliate_dash 任务处于失败状态,且没有任何成功任务。
|
|
* 切换后:原渠道成本(sys_affiliate_purchase)冲销,人工成本等确认时再记;
|
|
* 自动任务本身不改状态,只记录订单项的切换原因,避免重复触发。
|
|
*/
|
|
export const AFFILIATE_FAILURE_STATUSES = [
|
|
'failed',
|
|
'closed',
|
|
'expired',
|
|
'manual_review',
|
|
'retry_pending',
|
|
'cancelled',
|
|
] as const
|
|
|
|
const AFFILIATE_SUCCESS_STATUSES = ['redeemed', 'paid'] as const
|
|
|
|
export async function runAffiliateFallback(
|
|
options: { limit?: number } = {},
|
|
): Promise<{ scanned: number; switched: number }> {
|
|
const limit = Math.min(1000, Math.max(1, Math.trunc(options.limit || 200)))
|
|
const candidates = await query<{
|
|
id: number
|
|
order_item_id: number
|
|
provider: string
|
|
shop_id: string
|
|
internal_sku: string
|
|
sku_name: string
|
|
config_id: number | null
|
|
config_product_id: number | null
|
|
basis_date: string
|
|
}>(
|
|
`
|
|
SELECT
|
|
oi.id,
|
|
oi.id AS order_item_id,
|
|
o.provider,
|
|
o.shop_id,
|
|
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
|
|
oi.sku_name,
|
|
cfg.id AS config_id,
|
|
cfg.canonical_product_id AS config_product_id,
|
|
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
LEFT JOIN manual_fulfillment_configs cfg
|
|
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
|
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
|
)
|
|
AND cfg.enabled
|
|
WHERE oi.fulfillment_status = 'assigned'
|
|
AND oi.fulfillment_channel = 'affiliate_dash'
|
|
AND oi.fulfillment_locked = FALSE
|
|
AND cfg.id IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM internal_sku_channels channel
|
|
WHERE channel.enabled = TRUE
|
|
AND channel.channel = 'manual'
|
|
AND channel.is_fallback = TRUE
|
|
AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku(
|
|
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
|
)
|
|
)
|
|
AND (
|
|
SELECT task.task_status
|
|
FROM fulfillment_tasks task
|
|
WHERE task.order_item_id = oi.id AND task.executor_key = 'affiliate_dash'
|
|
ORDER BY task.unit_index DESC, task.updated_at DESC, task.id DESC
|
|
LIMIT 1
|
|
) = ANY($1::text[])
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM fulfillment_tasks ft
|
|
WHERE ft.order_item_id = oi.id
|
|
AND ft.executor_key = 'affiliate_dash'
|
|
AND ft.task_status = ANY($2::text[])
|
|
)
|
|
ORDER BY oi.id
|
|
LIMIT $3
|
|
`,
|
|
[AFFILIATE_FAILURE_STATUSES, AFFILIATE_SUCCESS_STATUSES, limit],
|
|
)
|
|
|
|
const switchedIds: number[] = []
|
|
const affectedDates = new Set<string>()
|
|
|
|
for (const row of candidates.rows) {
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE order_items
|
|
SET
|
|
original_channel = COALESCE(original_channel, 'affiliate_dash'),
|
|
fulfillment_channel = 'manual',
|
|
fulfillment_status = 'assigned',
|
|
fulfillment_source = 'affiliate_fallback',
|
|
channel_switch_reason = 'affiliate_failed',
|
|
channel_switched_at = NOW(),
|
|
fulfillment_config_id = COALESCE(fulfillment_config_id, $2),
|
|
canonical_product_id = COALESCE($3, canonical_product_id),
|
|
fulfillment_pending_reason = NULL,
|
|
fulfillment_pending_detail = NULL,
|
|
fulfillment_decided_at = NOW(),
|
|
item_snapshot_json = COALESCE(item_snapshot_json, '{}'::jsonb) || jsonb_build_object(
|
|
'internalSkuChannel', 'manual',
|
|
'internalSkuChannelSource', 'affiliate_fallback',
|
|
'internalSkuChannelReason', '自动发货失败,转人工兜底',
|
|
'isConfigured', TRUE,
|
|
'affiliateDash', NULL,
|
|
'fulfillmentRoute', jsonb_build_object(
|
|
'selectedExecutorKey', 'manual_dispatch',
|
|
'selectedRuleId', '',
|
|
'reason', '自动发货失败,转人工兜底',
|
|
'matchedRule', NULL,
|
|
'candidates', '[]'::jsonb,
|
|
'skipped', '[]'::jsonb
|
|
),
|
|
'classification', jsonb_build_object(
|
|
'channel', 'manual',
|
|
'canonicalSku', resolve_internal_sku(manual_sku_key, sku_name, sku_code)
|
|
)
|
|
),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND fulfillment_status = 'assigned'
|
|
AND fulfillment_channel = 'affiliate_dash'
|
|
AND fulfillment_locked = FALSE
|
|
RETURNING id
|
|
`,
|
|
[row.id, row.config_id, row.config_product_id],
|
|
)
|
|
if ((result.rowCount || 0) === 0) return false
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE cost_snapshots
|
|
SET reversed_at = NOW(), reversal_reason = 'affiliate_failed'
|
|
WHERE order_item_id = $1
|
|
AND cost_item_id = (SELECT id FROM cost_items WHERE code = 'sys_affiliate_purchase')
|
|
AND superseded_at IS NULL AND reversed_at IS NULL
|
|
AND reversed_at IS NULL
|
|
`,
|
|
[row.id],
|
|
)
|
|
return true
|
|
})
|
|
if (!updated) continue
|
|
switchedIds.push(Number(row.id))
|
|
affectedDates.add(row.basis_date)
|
|
}
|
|
|
|
for (const id of switchedIds) {
|
|
await syncCostSnapshotsForItems([id]).catch(() => undefined)
|
|
}
|
|
await refreshFactsForDates([...affectedDates]).catch(() => undefined)
|
|
|
|
return { scanned: candidates.rows.length, switched: switchedIds.length }
|
|
}
|