- 新增 internal_sku_mappings / internal_sku_channels,平台字段统一映射到内部中文 SKU - 下单入口按内部 SKU 渠道决策修正默认人工路由,自动发货补全 dash sku - 财务 channel_assign 读取统一渠道表;卡券/人工能力兜底,自动发货/接单要求真实载体 - carrier reconciliation 自动补自动发货任务和接单工单 - pending 落明确原因:无内部 SKU / 无渠道能力 / 无履约载体 - 合并、重判、旧 sku_code 规则清理、部署后 preflight 核对脚本 - 待分类队列按内部 SKU 聚合并展示原因
177 lines
5.4 KiB
TypeScript
177 lines
5.4 KiB
TypeScript
/**
|
||
* 断代后订单按新的商品渠道能力重判。
|
||
*
|
||
* 仅处理:assigned、未锁定、无成功履约证据、有商品能力的订单项。
|
||
* 默认 dry-run;--apply 才写库。
|
||
*/
|
||
import process from 'node:process'
|
||
|
||
import { closeDb, query, withTransaction } from '../src/db/client.js'
|
||
import { syncCostSnapshotsForItems } from '../src/services/cost-center/cost-snapshot-service.js'
|
||
import { refreshFactsForDates } from '../src/services/finance/refresh-facts.js'
|
||
|
||
const CHANNEL_COST_CODE: Record<string, string> = {
|
||
affiliate_dash: 'sys_affiliate_purchase',
|
||
third_party_voucher: 'sys_third_party_voucher_cost',
|
||
work_order: 'sys_work_order_reward',
|
||
}
|
||
|
||
type Candidate = {
|
||
id: number
|
||
current_channel: string
|
||
desired_channel: string
|
||
basis_date: string
|
||
}
|
||
|
||
async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||
const result = await query<Candidate>(
|
||
`
|
||
SELECT oi.id, oi.fulfillment_channel AS current_channel,
|
||
cap.channel AS desired_channel,
|
||
(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
|
||
JOIN LATERAL (
|
||
SELECT c.channel
|
||
FROM internal_sku_channels c
|
||
WHERE c.enabled
|
||
AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher')
|
||
AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name))
|
||
ORDER BY
|
||
c.priority ASC,
|
||
CASE c.channel
|
||
WHEN 'affiliate_dash' THEN 1
|
||
WHEN 'work_order' THEN 2
|
||
WHEN 'third_party_voucher' THEN 3
|
||
WHEN 'manual' THEN 4
|
||
ELSE 9
|
||
END,
|
||
c.channel ASC
|
||
LIMIT 1
|
||
) cap ON TRUE
|
||
WHERE oi.fulfillment_status = 'assigned'
|
||
AND oi.fulfillment_locked = FALSE
|
||
AND COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08'
|
||
AND oi.fulfillment_source NOT IN ('operator_assigned', 'manual_confirm')
|
||
AND (
|
||
cap.channel <> 'affiliate_dash'
|
||
OR EXISTS (
|
||
SELECT 1 FROM fulfillment_tasks ft
|
||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||
)
|
||
)
|
||
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 IN ('redeemed', 'paid')
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM work_orders wo
|
||
WHERE wo.order_item_id = oi.id AND wo.status = 'accepted'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM cost_snapshots cs
|
||
JOIN cost_items ci ON ci.id = cs.cost_item_id
|
||
WHERE cs.order_item_id = oi.id
|
||
AND ci.code = 'sys_manual_service'
|
||
AND cs.superseded_at IS NULL
|
||
AND cs.reversed_at IS NULL
|
||
)
|
||
ORDER BY oi.id
|
||
LIMIT $1
|
||
`,
|
||
[limit],
|
||
)
|
||
return result.rows
|
||
}
|
||
|
||
async function applyOne(row: Candidate): Promise<boolean> {
|
||
const previous = row.current_channel
|
||
if (!previous || previous === row.desired_channel) return false
|
||
|
||
const changed = await withTransaction(async (client) => {
|
||
const result = await client.query(
|
||
`
|
||
UPDATE order_items
|
||
SET original_channel = COALESCE(original_channel, $2),
|
||
fulfillment_channel = $3,
|
||
fulfillment_source = 'auto_rule',
|
||
channel_switch_reason = 'reclassify',
|
||
channel_switched_at = NOW(),
|
||
updated_at = NOW()
|
||
WHERE id = $1
|
||
AND fulfillment_status = 'assigned'
|
||
AND fulfillment_locked = FALSE
|
||
AND fulfillment_channel = $2
|
||
RETURNING id
|
||
`,
|
||
[row.id, previous, row.desired_channel],
|
||
)
|
||
if ((result.rowCount || 0) === 0) return false
|
||
|
||
const code = CHANNEL_COST_CODE[previous]
|
||
if (code) {
|
||
await client.query(
|
||
`
|
||
UPDATE cost_snapshots
|
||
SET reversed_at = NOW(), reversal_reason = 'reclassify'
|
||
WHERE order_item_id = $1
|
||
AND cost_item_id = (SELECT id FROM cost_items WHERE code = $2)
|
||
AND superseded_at IS NULL
|
||
AND reversed_at IS NULL
|
||
`,
|
||
[row.id, code],
|
||
)
|
||
}
|
||
return true
|
||
})
|
||
if (!changed) return false
|
||
|
||
await syncCostSnapshotsForItems([row.id]).catch(() => undefined)
|
||
return true
|
||
}
|
||
|
||
async function main() {
|
||
const apply = process.argv.includes('--apply')
|
||
const limit = 5000
|
||
const rows = await loadCandidates(limit)
|
||
const changes = rows.filter((row) => row.current_channel !== row.desired_channel)
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
mode: apply ? 'apply' : 'dry-run',
|
||
scanned: rows.length,
|
||
toChange: changes.length,
|
||
sample: changes.slice(0, 20).map((row) => ({
|
||
id: row.id,
|
||
from: row.current_channel,
|
||
to: row.desired_channel,
|
||
date: row.basis_date,
|
||
})),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
)
|
||
if (!apply || changes.length === 0) return
|
||
|
||
let switched = 0
|
||
const dates = new Set<string>()
|
||
for (const row of changes) {
|
||
if (await applyOne(row)) {
|
||
switched += 1
|
||
dates.add(row.basis_date)
|
||
}
|
||
}
|
||
await refreshFactsForDates([...dates])
|
||
console.log(JSON.stringify({ switched, dates: [...dates].sort() }, null, 2))
|
||
}
|
||
|
||
main()
|
||
.catch((error) => {
|
||
console.error(error)
|
||
process.exitCode = 1
|
||
})
|
||
.finally(() => closeDb())
|