统一内部 SKU 与渠道主/兜底解析,禁止并列主渠道。 运营指定渠道改为先创建真实载体,失败保持明确 pending。 历史缺载体、多载体冲突和自动发货兜底统一由管道修复。 迁移人工配置与旧 sku_code 规则,补充严格预检和集成测试。
1088 lines
35 KiB
TypeScript
1088 lines
35 KiB
TypeScript
import { query } from '../../db/client.js'
|
|
import { getOrderById } from '../../repositories/order-repo.js'
|
|
import type { OrderItemRow } from '../../types/repository/rows.js'
|
|
import { createHttpError } from '../../utils/http.js'
|
|
import { nowIso } from '../../utils/time.js'
|
|
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
|
|
import { readWatermarkItemId } from '../watermark-store.js'
|
|
import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js'
|
|
import { normalizeProductsForItems } from '../cost-center/product-normalize-service.js'
|
|
import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js'
|
|
import {
|
|
FULFILLMENT_CHANNELS,
|
|
MATCH_KEY_TYPES,
|
|
RULE_SOURCES,
|
|
isFulfillmentChannel,
|
|
isMatchKeyType,
|
|
isRuleSource,
|
|
type FulfillmentChannel,
|
|
type FulfillmentSource,
|
|
type MatchKeyType,
|
|
type RuleSource,
|
|
} from './channel-types.js'
|
|
import {
|
|
disableInternalSkuChannel,
|
|
planInternalSkuChannels,
|
|
setInternalSkuPrimaryChannel,
|
|
upsertInternalSkuChannel,
|
|
type InternalSkuChannelRow,
|
|
} from './internal-sku-channel-service.js'
|
|
import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
|
|
|
|
/**
|
|
* 管道 A:渠道判定。
|
|
*
|
|
* 只写 `fulfillment_channel / fulfillment_status / fulfillment_source /
|
|
* fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。
|
|
*
|
|
* 判定顺序:
|
|
* 1. 运营锁定 → 跳过
|
|
* 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突
|
|
* 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突
|
|
* 4. 旧 sku_channel_rules 仅作兼容回退,且自动发货/接单仍必须有真实载体
|
|
* 5. 都不满足 → 保留 pending 并写明确原因
|
|
*/
|
|
|
|
/** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */
|
|
const INTERNAL_SKU_SQL = `resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`
|
|
|
|
export type ChannelRule = {
|
|
id: number
|
|
provider: string
|
|
shop_id: string
|
|
match_key_type: MatchKeyType
|
|
match_key: string
|
|
channel: FulfillmentChannel
|
|
priority: number
|
|
enabled: boolean
|
|
}
|
|
|
|
export type ChannelCandidateItem = {
|
|
id: number
|
|
provider: string
|
|
shop_id: string
|
|
sku_code: string
|
|
sku_name: string
|
|
manual_sku_key: string | null
|
|
internal_sku: string
|
|
normalized_sku_name: string | null
|
|
has_work_order: boolean
|
|
has_affiliate_task: boolean
|
|
has_manual_task: boolean
|
|
channels: InternalSkuChannelRow[]
|
|
basis_date: string
|
|
}
|
|
|
|
export type ChannelDecision = {
|
|
channel: FulfillmentChannel | null
|
|
source: FulfillmentSource
|
|
conflict: boolean
|
|
conflictChannels: FulfillmentChannel[]
|
|
matchedRuleIds: number[]
|
|
pendingChannel: FulfillmentChannel | null
|
|
pendingReason: 'no_internal_sku' | 'no_channel_capability' | 'no_carrier' | ''
|
|
}
|
|
|
|
function normalizeKey(value: unknown): string {
|
|
return String(value ?? '').trim()
|
|
}
|
|
|
|
function matchesScope(rule: ChannelRule, item: ChannelCandidateItem): boolean {
|
|
if (rule.provider && rule.provider !== item.provider) return false
|
|
if (rule.shop_id && rule.shop_id !== item.shop_id) return false
|
|
return true
|
|
}
|
|
|
|
function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
|
|
if (keyType === 'sku_code') return normalizeKey(item.sku_code)
|
|
if (keyType === 'internal_sku') return normalizeKey(item.internal_sku || item.manual_sku_key)
|
|
return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase()
|
|
}
|
|
|
|
function resolvePendingReason(item: ChannelCandidateItem): {
|
|
reason: string
|
|
detail: Record<string, unknown>
|
|
} {
|
|
if (!String(item.internal_sku || '').trim()) {
|
|
return { reason: 'no_internal_sku', detail: { skuCode: item.sku_code } }
|
|
}
|
|
const plan = planInternalSkuChannels(item.channels || [])
|
|
if (plan.conflicts.length > 0) {
|
|
return {
|
|
reason: 'routing_conflict',
|
|
detail: { channels: plan.conflicts.map((channel) => channel.channel) },
|
|
}
|
|
}
|
|
if (!plan.primary) {
|
|
return {
|
|
reason: 'no_channel_capability',
|
|
detail: { internalSku: item.internal_sku },
|
|
}
|
|
}
|
|
return {
|
|
reason: 'no_carrier',
|
|
detail: {
|
|
internalSku: item.internal_sku,
|
|
channel: plan.primary.channel,
|
|
fallbacks: plan.fallbacks.map((channel) => channel.channel),
|
|
},
|
|
}
|
|
}
|
|
|
|
function emptyDecision(pendingReason: ChannelDecision['pendingReason'] = '') {
|
|
return {
|
|
channel: null,
|
|
source: '',
|
|
conflict: false,
|
|
conflictChannels: [],
|
|
matchedRuleIds: [],
|
|
pendingChannel: null,
|
|
pendingReason,
|
|
} satisfies ChannelDecision
|
|
}
|
|
|
|
/** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */
|
|
export function resolveChannelDecision(
|
|
item: ChannelCandidateItem,
|
|
rules: ChannelRule[],
|
|
): ChannelDecision {
|
|
const carrierChannels: FulfillmentChannel[] = [
|
|
...(item.has_work_order ? ['work_order' as const] : []),
|
|
...(item.has_affiliate_task ? ['affiliate_dash' as const] : []),
|
|
...(item.has_manual_task ? ['manual' as const] : []),
|
|
]
|
|
if (carrierChannels.length > 1) {
|
|
return {
|
|
channel: null,
|
|
source: '',
|
|
conflict: true,
|
|
conflictChannels: carrierChannels,
|
|
matchedRuleIds: [],
|
|
pendingChannel: carrierChannels[0] || null,
|
|
pendingReason: 'no_carrier',
|
|
}
|
|
}
|
|
|
|
const plan = planInternalSkuChannels(item.channels || [])
|
|
if (plan.conflicts.length > 0) {
|
|
return {
|
|
channel: null,
|
|
source: '',
|
|
conflict: true,
|
|
conflictChannels: plan.conflicts.map((channel) => channel.channel),
|
|
matchedRuleIds: [],
|
|
pendingChannel: null,
|
|
pendingReason: 'no_channel_capability',
|
|
}
|
|
}
|
|
|
|
// 真实载体是最终事实;配置只能决定准备创建哪个载体,不能覆盖已经存在的另一类载体。
|
|
const evidence = carrierChannels[0]
|
|
if (evidence) {
|
|
return {
|
|
channel: evidence,
|
|
source: 'carrier_evidence',
|
|
conflict: false,
|
|
conflictChannels: [],
|
|
matchedRuleIds: [],
|
|
pendingChannel: evidence,
|
|
pendingReason: '',
|
|
}
|
|
}
|
|
|
|
if (plan.primary) {
|
|
if (plan.primary.channel === 'manual' || plan.primary.channel === 'third_party_voucher') {
|
|
return {
|
|
channel: plan.primary.channel,
|
|
source: 'auto_mapping',
|
|
conflict: false,
|
|
conflictChannels: [],
|
|
matchedRuleIds: [],
|
|
pendingChannel: plan.primary.channel,
|
|
pendingReason: '',
|
|
}
|
|
}
|
|
return emptyDecision('no_carrier')
|
|
}
|
|
|
|
const hits = rules.filter(
|
|
(rule) =>
|
|
rule.enabled &&
|
|
matchesScope(rule, item) &&
|
|
itemKey(item, rule.match_key_type) === normalizeKey(rule.match_key),
|
|
)
|
|
|
|
if (hits.length > 0) {
|
|
const minPriority = Math.min(...hits.map((rule) => rule.priority))
|
|
const best = hits.filter((rule) => rule.priority === minPriority)
|
|
const channels = [...new Set(best.map((rule) => rule.channel))]
|
|
|
|
if (channels.length > 1) {
|
|
return {
|
|
channel: null,
|
|
source: '',
|
|
conflict: true,
|
|
conflictChannels: channels,
|
|
matchedRuleIds: best.map((rule) => rule.id),
|
|
pendingChannel: null,
|
|
pendingReason: 'no_channel_capability',
|
|
}
|
|
}
|
|
const channel = channels[0]!
|
|
if (channel === 'affiliate_dash' || channel === 'work_order') {
|
|
return emptyDecision('no_carrier')
|
|
}
|
|
return {
|
|
channel,
|
|
source: 'auto_rule',
|
|
conflict: false,
|
|
conflictChannels: [],
|
|
matchedRuleIds: best.map((rule) => rule.id),
|
|
pendingChannel: channel,
|
|
pendingReason: '',
|
|
}
|
|
}
|
|
return emptyDecision('no_channel_capability')
|
|
}
|
|
|
|
export type AssignChannelsResult = {
|
|
scanned: number
|
|
assigned: number
|
|
conflicted: number
|
|
stillPending: number
|
|
lastId: number
|
|
/** 下一轮起点;到达表尾时回绕到 0,保证漏判的订单还能被重新评估。 */
|
|
nextCursor: number
|
|
byChannel: Record<string, number>
|
|
/** 本轮被判定的订单所属业务日,供事实/总账按日回填。 */
|
|
affectedDates: string[]
|
|
/** 本轮新判定的订单项(供立即计算成本,最多 2000 条)。 */
|
|
assignedItemIds: number[]
|
|
/** 待分类原因计数。 */
|
|
pendingReasons: Record<string, number>
|
|
}
|
|
|
|
/** 批量判定:默认只扫 pending 且未锁定的订单项。 */
|
|
export async function assignPendingOrderItemChannels(
|
|
options: { batchSize?: number; maxBatches?: number; afterId?: number } = {},
|
|
): Promise<AssignChannelsResult> {
|
|
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
|
const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20)))
|
|
let cursor =
|
|
options.afterId != null
|
|
? Math.max(0, Math.trunc(options.afterId))
|
|
: await readWatermarkItemId('channel_assign')
|
|
let reachedEnd = false
|
|
|
|
const rules = await loadEnabledRules()
|
|
const result: AssignChannelsResult = {
|
|
scanned: 0,
|
|
assigned: 0,
|
|
conflicted: 0,
|
|
stillPending: 0,
|
|
lastId: cursor,
|
|
nextCursor: cursor,
|
|
byChannel: {},
|
|
affectedDates: [],
|
|
assignedItemIds: [],
|
|
pendingReasons: {},
|
|
}
|
|
const affectedDates = new Set<string>()
|
|
|
|
for (let batch = 0; batch < maxBatches; batch += 1) {
|
|
const rows = await query<ChannelCandidateItem>(
|
|
`
|
|
SELECT
|
|
oi.id,
|
|
o.provider,
|
|
o.shop_id,
|
|
oi.sku_code,
|
|
oi.sku_name,
|
|
oi.manual_sku_key,
|
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
|
oi.normalized_sku_name,
|
|
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
|
AS basis_date,
|
|
EXISTS (
|
|
SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id
|
|
) AS has_work_order,
|
|
EXISTS (
|
|
SELECT 1 FROM fulfillment_tasks ft
|
|
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
|
) AS has_affiliate_task,
|
|
EXISTS (
|
|
SELECT 1 FROM fulfillment_tasks ft
|
|
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
|
) AS has_manual_task
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
WHERE oi.fulfillment_status = 'pending'
|
|
AND oi.fulfillment_locked = FALSE
|
|
AND oi.id > $1
|
|
ORDER BY oi.id
|
|
LIMIT $2
|
|
`,
|
|
[cursor, batchSize],
|
|
)
|
|
|
|
if (rows.rows.length === 0) {
|
|
reachedEnd = true
|
|
break
|
|
}
|
|
if (rows.rows.length < batchSize) reachedEnd = true
|
|
result.scanned += rows.rows.length
|
|
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
|
result.lastId = cursor
|
|
|
|
const assignments: Array<{
|
|
id: number
|
|
channel: FulfillmentChannel
|
|
source: FulfillmentSource
|
|
}> = []
|
|
|
|
const channelsByInternalSku = await loadInternalSkuChannelsForKeys(
|
|
rows.rows.map((item) => item.internal_sku),
|
|
)
|
|
|
|
for (const row of rows.rows) {
|
|
const item: ChannelCandidateItem = {
|
|
...row,
|
|
channels: channelsByInternalSku.get(normalizeKey(row.internal_sku)) || [],
|
|
}
|
|
const decision = resolveChannelDecision(item, rules)
|
|
if (decision.channel) affectedDates.add(item.basis_date)
|
|
if (decision.conflict) {
|
|
result.conflicted += 1
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_pending_reason = 'routing_conflict',
|
|
fulfillment_pending_detail = $2::jsonb,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
|
[
|
|
item.id,
|
|
JSON.stringify({
|
|
channels: decision.conflictChannels,
|
|
ruleIds: decision.matchedRuleIds,
|
|
}),
|
|
],
|
|
)
|
|
await upsertAggregatedException({
|
|
reason: 'routing_conflict',
|
|
scopeKey: `routing_conflict|${item.provider}|${item.shop_id}|${item.sku_code}|-`,
|
|
orderItemId: item.id,
|
|
detail: { channels: decision.conflictChannels, ruleIds: decision.matchedRuleIds },
|
|
})
|
|
continue
|
|
}
|
|
if (!decision.channel) {
|
|
result.stillPending += 1
|
|
const pending = resolvePendingReason(item)
|
|
result.pendingReasons[pending.reason] = (result.pendingReasons[pending.reason] || 0) + 1
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_pending_reason = $2,
|
|
fulfillment_pending_detail = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
|
[item.id, pending.reason, JSON.stringify(pending.detail)],
|
|
)
|
|
continue
|
|
}
|
|
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
|
|
result.byChannel[decision.channel] = (result.byChannel[decision.channel] || 0) + 1
|
|
}
|
|
|
|
if (assignments.length > 0) {
|
|
const updated = await query(
|
|
`
|
|
UPDATE order_items oi
|
|
SET
|
|
fulfillment_channel = input.channel,
|
|
fulfillment_status = 'assigned',
|
|
fulfillment_source = input.source,
|
|
fulfillment_decided_at = NOW(),
|
|
fulfillment_pending_reason = NULL,
|
|
fulfillment_pending_detail = NULL,
|
|
updated_at = NOW()
|
|
FROM unnest($1::bigint[], $2::text[], $3::text[])
|
|
AS input(id, channel, source)
|
|
WHERE oi.id = input.id
|
|
AND oi.fulfillment_status = 'pending'
|
|
AND oi.fulfillment_locked = FALSE
|
|
`,
|
|
[
|
|
assignments.map((row) => row.id),
|
|
assignments.map((row) => row.channel),
|
|
assignments.map((row) => row.source),
|
|
],
|
|
)
|
|
result.assigned += updated.rowCount || 0
|
|
result.assignedItemIds = assignments.map((row) => row.id).slice(0, 2000)
|
|
}
|
|
}
|
|
|
|
result.nextCursor = reachedEnd ? 0 : cursor
|
|
result.affectedDates = [...affectedDates].sort()
|
|
return result
|
|
}
|
|
|
|
async function assignOrderItemsAfterCarrierReady(input: {
|
|
orderItemIds: number[]
|
|
channel: FulfillmentChannel
|
|
source: FulfillmentSource
|
|
lock: boolean
|
|
note?: string | undefined
|
|
}): Promise<{ updated: number; carrierFailed: number }> {
|
|
const ids = [
|
|
...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)),
|
|
]
|
|
if (ids.length === 0) return { updated: 0, carrierFailed: 0 }
|
|
|
|
const items = (
|
|
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
|
).rows
|
|
const orderIds = [...new Set(items.map((item) => Number(item.order_id)))]
|
|
const orders = new Map(
|
|
(await Promise.all(orderIds.map((orderId) => getOrderById(orderId))))
|
|
.filter((order): order is NonNullable<typeof order> => Boolean(order))
|
|
.map((order) => [Number(order.id), order]),
|
|
)
|
|
const assignedIds: number[] = []
|
|
let carrierFailed = 0
|
|
|
|
for (const item of items) {
|
|
if (item.fulfillment_status === 'archived') continue
|
|
const order = orders.get(Number(item.order_id))
|
|
if (!order) continue
|
|
|
|
if (
|
|
item.fulfillment_status === 'assigned' &&
|
|
item.fulfillment_channel &&
|
|
item.fulfillment_channel !== input.channel
|
|
) {
|
|
// 旧载体尚未撤销时禁止直接换渠道,否则会同时留下两个履约执行方。
|
|
carrierFailed += 1
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_reason = $2, updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status = 'assigned'`,
|
|
[item.id, '切换渠道前需先撤销现有履约载体'],
|
|
)
|
|
continue
|
|
}
|
|
|
|
let carrier
|
|
try {
|
|
carrier = await ensureCarrierForOrderItem({ order, item, channel: input.channel })
|
|
} catch (error) {
|
|
carrier = {
|
|
ready: false,
|
|
created: false,
|
|
reason: 'exception',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
}
|
|
}
|
|
|
|
if (!carrier.ready) {
|
|
carrierFailed += 1
|
|
if (item.fulfillment_status === 'assigned') {
|
|
// 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_channel = $2,
|
|
fulfillment_status = $3,
|
|
fulfillment_source = $4,
|
|
fulfillment_locked = $5,
|
|
fulfillment_reason = $6,
|
|
fulfillment_pending_reason = $7,
|
|
fulfillment_pending_detail = $8::jsonb,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
|
[
|
|
item.id,
|
|
item.fulfillment_channel,
|
|
item.fulfillment_status,
|
|
item.fulfillment_source,
|
|
item.fulfillment_locked,
|
|
item.fulfillment_reason,
|
|
item.fulfillment_pending_reason,
|
|
JSON.stringify(item.fulfillment_pending_detail || null),
|
|
],
|
|
)
|
|
} else {
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_channel = NULL,
|
|
fulfillment_status = 'pending',
|
|
fulfillment_source = '',
|
|
fulfillment_locked = TRUE,
|
|
fulfillment_pending_reason = 'no_carrier',
|
|
fulfillment_pending_detail = $2::jsonb,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
|
[
|
|
item.id,
|
|
JSON.stringify({
|
|
channel: input.channel,
|
|
reason: carrier.reason,
|
|
message: carrier.message,
|
|
}),
|
|
],
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
|
|
const updated = await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_channel = $2,
|
|
fulfillment_status = 'assigned',
|
|
fulfillment_source = $3,
|
|
fulfillment_locked = $4,
|
|
fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason),
|
|
fulfillment_pending_reason = NULL,
|
|
fulfillment_pending_detail = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
|
[item.id, input.channel, input.source, input.lock, String(input.note || '').slice(0, 200)],
|
|
)
|
|
if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id))
|
|
}
|
|
|
|
if (assignedIds.length > 0) {
|
|
await normalizeProductsForItems(assignedIds).catch(() => undefined)
|
|
const cost = await syncCostSnapshotsForItems(assignedIds).catch(() => null)
|
|
const dates = [
|
|
...new Set([...(await orderItemBasisDates(assignedIds)), ...(cost?.affectedDates || [])]),
|
|
]
|
|
await refreshFactsForDates(dates).catch(() => undefined)
|
|
}
|
|
const final = await query<{ count: number }>(
|
|
`WITH updated AS (
|
|
UPDATE order_items
|
|
SET fulfillment_source = $3,
|
|
fulfillment_locked = $4,
|
|
fulfillment_pending_reason = NULL,
|
|
fulfillment_pending_detail = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = ANY($1::bigint[])
|
|
AND fulfillment_status = 'assigned'
|
|
AND fulfillment_channel = $2
|
|
RETURNING id
|
|
)
|
|
SELECT count(*)::int AS count FROM updated`,
|
|
[ids, input.channel, input.source, input.lock],
|
|
)
|
|
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
|
|
}
|
|
|
|
/** 运营指定渠道:先保证真实载体,成功后才写入并锁定。 */
|
|
export async function assignChannelToOrderItems(input: {
|
|
orderItemIds: number[]
|
|
channel: string
|
|
adminUserId: number | null
|
|
note?: string
|
|
}): Promise<{ updated: number; carrierFailed: number }> {
|
|
if (!isFulfillmentChannel(input.channel)) {
|
|
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
|
|
}
|
|
const ids = [
|
|
...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)),
|
|
]
|
|
if (ids.length === 0) {
|
|
throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' })
|
|
}
|
|
|
|
const result = await assignOrderItemsAfterCarrierReady({
|
|
orderItemIds: ids,
|
|
channel: input.channel,
|
|
source: 'operator_assigned',
|
|
lock: true,
|
|
note: input.note,
|
|
})
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW()
|
|
WHERE id = ANY($1::bigint[])`,
|
|
[ids, input.adminUserId],
|
|
)
|
|
return result
|
|
}
|
|
|
|
/** 运营解锁:允许自动判定重新接管。 */
|
|
export async function unlockChannel(orderItemIds: number[]): Promise<{ updated: number }> {
|
|
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
|
if (ids.length === 0) return { updated: 0 }
|
|
const result = await query(
|
|
`UPDATE order_items SET fulfillment_locked = FALSE, updated_at = NOW() WHERE id = ANY($1::bigint[])`,
|
|
[ids],
|
|
)
|
|
return { updated: result.rowCount || 0 }
|
|
}
|
|
|
|
export type PendingGroupRow = {
|
|
provider: string
|
|
shop_id: string
|
|
internal_sku: string
|
|
sku_name: string
|
|
sku_codes: string[]
|
|
pending_reasons: string[]
|
|
item_count: number
|
|
revenue_fen: number
|
|
first_seen_at: string
|
|
last_seen_at: string
|
|
}
|
|
|
|
/** 待分类队列:按内部 SKU 聚合(同一中文 SKU 的不同平台码合并为一行)。 */
|
|
export async function listPendingChannelGroups(input: {
|
|
page?: number
|
|
pageSize?: number
|
|
keyword?: string
|
|
}): Promise<{ items: PendingGroupRow[]; total: number; page: number; pageSize: number }> {
|
|
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
|
|
const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30)))
|
|
const keyword = String(input.keyword || '').trim()
|
|
const params: unknown[] = []
|
|
const clauses = [`oi.fulfillment_status = 'pending'`]
|
|
if (keyword) {
|
|
params.push(`%${keyword}%`)
|
|
clauses.push(
|
|
`(oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`,
|
|
)
|
|
}
|
|
const where = clauses.join(' AND ')
|
|
|
|
const total = await query<{ count: number }>(
|
|
`
|
|
SELECT COUNT(*)::int AS count FROM (
|
|
SELECT 1
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
WHERE ${where}
|
|
GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL}
|
|
) grouped
|
|
`,
|
|
params,
|
|
)
|
|
|
|
params.push(pageSize, (page - 1) * pageSize)
|
|
const rows = await query<PendingGroupRow>(
|
|
`
|
|
SELECT
|
|
o.provider,
|
|
o.shop_id,
|
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
|
MIN(oi.sku_name) AS sku_name,
|
|
array_agg(DISTINCT oi.sku_code) AS sku_codes,
|
|
array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons,
|
|
COUNT(*)::int AS item_count,
|
|
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
|
MIN(oi.created_at) AS first_seen_at,
|
|
MAX(oi.created_at) AS last_seen_at
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
WHERE ${where}
|
|
GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL}
|
|
ORDER BY item_count DESC, internal_sku
|
|
LIMIT $${params.length - 1} OFFSET $${params.length}
|
|
`,
|
|
params,
|
|
)
|
|
|
|
return { items: rows.rows, total: total.rows[0]?.count || 0, page, pageSize }
|
|
}
|
|
|
|
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
|
|
export async function assignChannelByInternalSku(input: {
|
|
provider: string
|
|
shopId: string
|
|
internalSku: string
|
|
channel: string
|
|
adminUserId: number | null
|
|
note?: string
|
|
createRule?: boolean
|
|
}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> {
|
|
if (!isFulfillmentChannel(input.channel)) {
|
|
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
|
|
}
|
|
const internalSku = String(input.internalSku || '').trim()
|
|
if (!internalSku) {
|
|
throw createHttpError('内部 SKU 不能为空', {
|
|
statusCode: 400,
|
|
errorCode: 'internal_sku_required',
|
|
})
|
|
}
|
|
|
|
const idsResult = await query<{ id: number }>(
|
|
`
|
|
SELECT oi.id
|
|
FROM orders o
|
|
JOIN order_items oi ON oi.order_id = o.id
|
|
WHERE o.id = oi.order_id
|
|
AND o.provider = $1
|
|
AND o.shop_id = $2
|
|
AND ${INTERNAL_SKU_SQL} = $3
|
|
AND (
|
|
oi.fulfillment_status = 'pending'
|
|
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4)
|
|
)
|
|
`,
|
|
[input.provider, input.shopId, internalSku, input.channel],
|
|
)
|
|
|
|
const product = await query<{ id: number | null }>(
|
|
`SELECT MIN(id) AS id
|
|
FROM product_catalog
|
|
WHERE status = 'active' AND btrim(canonical_name) = $1`,
|
|
[internalSku],
|
|
)
|
|
await setInternalSkuPrimaryChannel({
|
|
internalSku,
|
|
channel: input.channel,
|
|
canonicalProductId: product.rows[0]?.id == null ? null : Number(product.rows[0].id),
|
|
source: 'operator',
|
|
configJson: { note: String(input.note || '').slice(0, 200) },
|
|
})
|
|
|
|
let ruleCreated = false
|
|
if (input.createRule !== false) {
|
|
const rule = await upsertChannelRule({
|
|
provider: input.provider,
|
|
shopId: input.shopId,
|
|
matchKeyType: 'internal_sku',
|
|
matchKey: internalSku,
|
|
channel: input.channel,
|
|
priority: 10,
|
|
source: 'operator',
|
|
enabled: true,
|
|
note: `运营在待分类队列指定:${String(input.note || '').slice(0, 120)}`,
|
|
adminUserId: input.adminUserId,
|
|
})
|
|
ruleCreated = rule.id > 0
|
|
}
|
|
|
|
const ids = idsResult.rows.map((row) => Number(row.id))
|
|
const applied = await assignOrderItemsAfterCarrierReady({
|
|
orderItemIds: ids,
|
|
channel: input.channel,
|
|
source: 'operator_assigned',
|
|
lock: true,
|
|
note: input.note,
|
|
})
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW()
|
|
WHERE id = ANY($1::bigint[])`,
|
|
[ids, input.adminUserId],
|
|
)
|
|
|
|
return { ...applied, ruleCreated }
|
|
}
|
|
|
|
/** 按 SKU 批量指定(待分类队列的主操作)。 */
|
|
export async function assignChannelBySku(input: {
|
|
provider: string
|
|
shopId: string
|
|
skuCode: string
|
|
channel: string
|
|
adminUserId: number | null
|
|
note?: string
|
|
createRule?: boolean
|
|
}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> {
|
|
if (!isFulfillmentChannel(input.channel)) {
|
|
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
|
|
}
|
|
const idsResult = await query<{ id: number; internal_sku: string }>(
|
|
`
|
|
SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku
|
|
FROM orders o
|
|
JOIN order_items oi ON oi.order_id = o.id
|
|
WHERE o.id = oi.order_id
|
|
AND o.provider = $1
|
|
AND o.shop_id = $2
|
|
AND oi.sku_code = $3
|
|
AND (
|
|
oi.fulfillment_status = 'pending'
|
|
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4)
|
|
)
|
|
`,
|
|
[input.provider, input.shopId, input.skuCode, input.channel],
|
|
)
|
|
|
|
const internalSkus = [
|
|
...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)),
|
|
]
|
|
if (internalSkus.length === 1) {
|
|
await setInternalSkuPrimaryChannel({
|
|
internalSku: internalSkus[0]!,
|
|
channel: input.channel,
|
|
source: 'operator',
|
|
configJson: { note: String(input.note || '').slice(0, 200) },
|
|
})
|
|
}
|
|
|
|
// 默认同时落一条渠道规则:否则同 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
|
|
}
|
|
|
|
const ids = idsResult.rows.map((row) => Number(row.id))
|
|
const applied = await assignOrderItemsAfterCarrierReady({
|
|
orderItemIds: ids,
|
|
channel: input.channel,
|
|
source: 'operator_assigned',
|
|
lock: true,
|
|
note: input.note,
|
|
})
|
|
await query(
|
|
`UPDATE order_items
|
|
SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW()
|
|
WHERE id = ANY($1::bigint[])`,
|
|
[ids, input.adminUserId],
|
|
)
|
|
return { ...applied, ruleCreated }
|
|
}
|
|
|
|
export async function listChannelRules(
|
|
input: {
|
|
channel?: string
|
|
enabled?: boolean
|
|
} = {},
|
|
): Promise<ChannelRule[]> {
|
|
const params: unknown[] = []
|
|
const clauses: string[] = []
|
|
if (input.channel) {
|
|
params.push(input.channel)
|
|
clauses.push(`channel = $${params.length}`)
|
|
}
|
|
if (typeof input.enabled === 'boolean') {
|
|
params.push(input.enabled)
|
|
clauses.push(`enabled = $${params.length}`)
|
|
}
|
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
|
const rows = await query<ChannelRule>(
|
|
`
|
|
SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled
|
|
FROM sku_channel_rules
|
|
${where}
|
|
ORDER BY priority, channel, match_key
|
|
`,
|
|
params,
|
|
)
|
|
return rows.rows
|
|
}
|
|
|
|
export async function upsertChannelRule(input: {
|
|
id?: number
|
|
provider?: string
|
|
shopId?: string
|
|
matchKeyType: string
|
|
matchKey: string
|
|
channel: string
|
|
priority?: number
|
|
source?: string
|
|
enabled?: boolean
|
|
note?: string
|
|
adminUserId: number | null
|
|
}): Promise<{ id: number }> {
|
|
if (!isMatchKeyType(input.matchKeyType)) {
|
|
throw createHttpError('匹配键类型无效', {
|
|
statusCode: 400,
|
|
errorCode: 'invalid_match_key_type',
|
|
})
|
|
}
|
|
if (!isFulfillmentChannel(input.channel)) {
|
|
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
|
|
}
|
|
const source: RuleSource = isRuleSource(input.source) ? input.source : 'operator'
|
|
const matchKey = String(input.matchKey || '').trim()
|
|
if (!matchKey) {
|
|
throw createHttpError('匹配键不能为空', { statusCode: 400, errorCode: 'match_key_required' })
|
|
}
|
|
|
|
const result = await query<{ id: number }>(
|
|
`
|
|
INSERT INTO sku_channel_rules (
|
|
provider, shop_id, match_key_type, match_key, channel, priority,
|
|
source, enabled, note, created_by_admin_user_id, created_at, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
|
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel)
|
|
DO UPDATE SET
|
|
priority = EXCLUDED.priority,
|
|
enabled = EXCLUDED.enabled,
|
|
note = EXCLUDED.note,
|
|
updated_at = NOW()
|
|
RETURNING id
|
|
`,
|
|
[
|
|
String(input.provider || '').trim(),
|
|
String(input.shopId || '').trim(),
|
|
input.matchKeyType,
|
|
matchKey,
|
|
input.channel,
|
|
Math.trunc(Number(input.priority) || 100),
|
|
source,
|
|
input.enabled !== false,
|
|
String(input.note || '').slice(0, 200),
|
|
input.adminUserId,
|
|
],
|
|
)
|
|
|
|
if (input.matchKeyType === 'internal_sku') {
|
|
await upsertInternalSkuChannel({
|
|
internalSku: matchKey,
|
|
channel: input.channel,
|
|
priority: Math.trunc(Number(input.priority) || 100),
|
|
isFallback: Math.trunc(Number(input.priority) || 100) >= 90,
|
|
enabled: input.enabled !== false,
|
|
source: source === 'operator' ? 'operator' : `rule:${source}`,
|
|
replacePriority: true,
|
|
})
|
|
}
|
|
|
|
return { id: result.rows[0]!.id }
|
|
}
|
|
|
|
export async function deleteChannelRule(ruleId: number): Promise<{ deleted: boolean }> {
|
|
const id = Math.trunc(Number(ruleId) || 0)
|
|
if (id <= 0) {
|
|
throw createHttpError('规则 id 无效', { statusCode: 400, errorCode: 'invalid_rule_id' })
|
|
}
|
|
const result = await query<{
|
|
id: number
|
|
match_key_type: string
|
|
match_key: string
|
|
channel: string
|
|
}>(
|
|
`DELETE FROM sku_channel_rules
|
|
WHERE id = $1
|
|
RETURNING id, match_key_type, match_key, channel`,
|
|
[id],
|
|
)
|
|
const deleted = result.rows[0]
|
|
if (deleted?.match_key_type === 'internal_sku' && isFulfillmentChannel(deleted.channel)) {
|
|
await disableInternalSkuChannel({
|
|
internalSku: deleted.match_key,
|
|
channel: deleted.channel,
|
|
})
|
|
}
|
|
return { deleted: Boolean(deleted) }
|
|
}
|
|
|
|
async function loadEnabledRules(): Promise<ChannelRule[]> {
|
|
const rows = await query<ChannelRule>(
|
|
`
|
|
SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled
|
|
FROM sku_channel_rules
|
|
WHERE enabled = TRUE
|
|
`,
|
|
)
|
|
return rows.rows
|
|
}
|
|
|
|
async function loadInternalSkuChannelsForKeys(
|
|
keys: string[],
|
|
): Promise<Map<string, InternalSkuChannelRow[]>> {
|
|
const normalized = [...new Set(keys.map(normalizeKey).filter(Boolean))]
|
|
const result = new Map<string, InternalSkuChannelRow[]>()
|
|
if (normalized.length === 0) return result
|
|
|
|
const rows = await query<InternalSkuChannelRow>(
|
|
`
|
|
SELECT internal_sku, channel, priority, is_fallback, enabled,
|
|
canonical_product_id, config_json, source
|
|
FROM internal_sku_channels
|
|
WHERE enabled = TRUE AND btrim(internal_sku) = ANY($1::text[])
|
|
ORDER BY internal_sku, priority, channel
|
|
`,
|
|
[normalized],
|
|
)
|
|
for (const row of rows.rows) {
|
|
const key = normalizeKey(row.internal_sku)
|
|
const list = result.get(key) || []
|
|
list.push(row)
|
|
result.set(key, list)
|
|
}
|
|
return result
|
|
}
|
|
|
|
export const CHANNEL_RULE_KEY_TYPES = MATCH_KEY_TYPES
|
|
export const CHANNEL_RULE_SOURCES = RULE_SOURCES
|
|
export const CHANNEL_VALUES = FULFILLMENT_CHANNELS
|
|
|
|
/** 为单个卡券 SKU 建规则(商品创建时调用,幂等)。 */
|
|
export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean> {
|
|
const key = String(skuCode || '').trim()
|
|
if (!key) return false
|
|
const result = await query<{ id: number }>(
|
|
`
|
|
INSERT INTO sku_channel_rules (
|
|
provider, shop_id, match_key_type, match_key, channel, priority,
|
|
source, enabled, note, created_at, updated_at
|
|
)
|
|
VALUES ('', '', 'sku_code', $1, 'third_party_voucher', 70, 'voucher', TRUE, '卡券商品自动生成', NOW(), NOW())
|
|
ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING
|
|
RETURNING id
|
|
`,
|
|
[key],
|
|
)
|
|
return (result.rowCount || 0) > 0
|
|
}
|
|
|
|
/**
|
|
* 为卡券商品(tpv: 前缀)批量补渠道规则。
|
|
*
|
|
* 卡券没有履约载体证据,只能靠规则判定;商品目录建好后一次补齐,
|
|
* 避免运营按 SKU 逐条点「指定渠道」。
|
|
*/
|
|
export async function ensureVoucherChannelRules(): Promise<{
|
|
created: number
|
|
productCount: number
|
|
}> {
|
|
const created = await query<{ id: number }>(
|
|
`
|
|
INSERT INTO sku_channel_rules (
|
|
provider, shop_id, match_key_type, match_key, channel, priority,
|
|
source, enabled, note, created_at, updated_at
|
|
)
|
|
SELECT
|
|
'', '', 'sku_code', substring(p.canonical_sku from 5), 'third_party_voucher', 70,
|
|
'voucher', TRUE, '卡券商品自动生成', NOW(), NOW()
|
|
FROM product_catalog p
|
|
WHERE p.status = 'active'
|
|
AND p.canonical_sku LIKE 'tpv:%'
|
|
AND btrim(substring(p.canonical_sku from 5)) <> ''
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM sku_channel_rules r
|
|
WHERE r.provider = ''
|
|
AND r.shop_id = ''
|
|
AND r.match_key_type = 'sku_code'
|
|
AND r.match_key = substring(p.canonical_sku from 5)
|
|
AND r.channel = 'third_party_voucher'
|
|
)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
`,
|
|
)
|
|
const total = await query<{ count: number }>(
|
|
`SELECT COUNT(*)::int AS count FROM product_catalog
|
|
WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'`,
|
|
)
|
|
return { created: created.rowCount || 0, productCount: Number(total.rows[0]?.count || 0) }
|
|
}
|