Files
order_site/apps/backend/src/services/fulfillment/channel-assign-service.ts
T

2178 lines
72 KiB
TypeScript

import { query, withTransaction } from '../../db/client.js'
import { closeClaimTokensByTaskId } from '../../repositories/claim-token-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js'
import type { OrderItemRow } from '../../types/repository/rows.js'
import { createHttpError } from '../../utils/http.js'
import { logWarn } from '../../utils/logger.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,
INACTIVE_CARRIER_TASK_STATUSES,
RULE_SOURCES,
isFulfillmentChannel,
isRuleSource,
type FulfillmentChannel,
type FulfillmentSource,
type RuleSource,
} from './channel-types.js'
import {
deleteInternalSkuChannelById,
deleteInternalSkuChannelsByIds,
planInternalSkuChannels,
setInternalSkuPrimaryChannel,
upsertInternalSkuChannel,
type InternalSkuChannelRow,
} from './internal-sku-channel-service.js'
import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
import { TASK_STATUS } from '../../domain/task-status.js'
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
/**
* 管道 A:渠道判定。
*
* 只写 `fulfillment_channel / fulfillment_status / fulfillment_source /
* fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。
*
* 判定顺序:
* 1. 运营锁定 → 跳过
* 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突
* 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突
* 4. 没有统一配置或载体不满足 → 保留 pending 并写明确原因
*/
/** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */
const INTERNAL_SKU_SQL = `resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`
const ACTIVE_CARRIER_TASK_SQL = `task.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})`
export type ChannelRule = {
id: number
match_key_type: 'internal_sku'
match_key: string
channel: FulfillmentChannel
priority: number
enabled: boolean
is_fallback: boolean
source: string
config_json: Record<string, unknown>
note: string
}
export type ChannelRulePage = {
items: ChannelRule[]
total: number
page: number
pageSize: number
}
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 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 },
}
}
const primary = plan.primary
const primaryConfig = primary.config_json || {}
const carrierIssue =
primary.channel === 'manual' && !Number(primaryConfig.manualConfigId || 0)
? 'manual_config_missing'
: primary.channel === 'work_order' && !String(primaryConfig.workRuleKey || '').trim()
? 'work_rule_missing'
: primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim()
? 'affiliate_mapping_missing'
: 'carrier_not_attempted'
return {
reason: 'no_carrier',
detail: {
internalSku: item.internal_sku,
channel: primary.channel,
fallbacks: plan.fallbacks.map((channel) => channel.channel),
carrierIssue,
},
}
}
function emptyDecision(pendingReason: ChannelDecision['pendingReason'] = '') {
return {
channel: null,
source: '',
conflict: false,
conflictChannels: [],
matchedRuleIds: [],
pendingChannel: null,
pendingReason,
} satisfies ChannelDecision
}
/** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */
export function resolveChannelDecision(item: ChannelCandidateItem): 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') {
if (!Number(plan.primary.config_json.manualConfigId || 0)) {
return emptyDecision('no_carrier')
}
return {
channel: plan.primary.channel,
source: 'auto_mapping',
conflict: false,
conflictChannels: [],
matchedRuleIds: [],
pendingChannel: plan.primary.channel,
pendingReason: '',
}
}
if (plan.primary.channel === 'third_party_voucher' || plan.primary.channel === 'on_account') {
return {
channel: plan.primary.channel,
source: 'auto_mapping',
conflict: false,
conflictChannels: [],
matchedRuleIds: [],
pendingChannel: plan.primary.channel,
pendingReason: '',
}
}
return emptyDecision('no_carrier')
}
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
orderItemIds?: number[]
includeLocked?: boolean
} = {},
): 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 result: AssignChannelsResult = {
scanned: 0,
assigned: 0,
conflicted: 0,
stillPending: 0,
lastId: cursor,
nextCursor: cursor,
byChannel: {},
affectedDates: [],
assignedItemIds: [],
pendingReasons: {},
}
const affectedDates = new Set<string>()
const requestedIds = [
...new Set(
(options.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0),
),
]
const params: unknown[] = [cursor, batchSize]
let requestedFilter = ''
if (requestedIds.length > 0) {
params.push(requestedIds)
requestedFilter = `AND oi.id = ANY($${params.length}::bigint[])`
}
for (let batch = 0; batch < maxBatches; batch += 1) {
params[0] = cursor
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 AND wo.status <> 'cancelled'
) AS has_work_order,
EXISTS (
SELECT 1 FROM fulfillment_tasks task
WHERE task.order_item_id = oi.id AND task.executor_key = 'affiliate_dash'
AND ${ACTIVE_CARRIER_TASK_SQL}
) AS has_affiliate_task,
EXISTS (
SELECT 1 FROM fulfillment_tasks task
WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch'
AND ${ACTIVE_CARRIER_TASK_SQL}
) AS has_manual_task
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}
AND oi.id > $1
${requestedFilter}
ORDER BY oi.id
LIMIT $2
`,
params,
)
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(canonicalizeInternalSku(row.internal_sku)) || [],
}
const decision = resolveChannelDecision(item)
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.internal_sku}|-`,
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 = CASE
WHEN $2 = 'no_carrier'
AND COALESCE(fulfillment_pending_detail->>'carrierIssue', '')
NOT IN ('', 'carrier_not_attempted')
THEN fulfillment_pending_detail
ELSE $3::jsonb
END,
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<{ id: number }>(
`
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 OR $4::boolean)
RETURNING oi.id
`,
[
assignments.map((row) => row.id),
assignments.map((row) => row.channel),
assignments.map((row) => row.source),
options.includeLocked === true,
],
)
const assignedItemIds = updated.rows
.map((row) => Number(row.id))
.filter((id) => id > 0)
.slice(0, 2000)
result.assigned += assignedItemIds.length
result.assignedItemIds = assignedItemIds
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
(error) => {
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
orderItemIds: assignedItemIds,
error: error instanceof Error ? error.message : String(error),
})
},
)
}
}
result.nextCursor = reachedEnd ? 0 : cursor
result.affectedDates = [...affectedDates].sort()
return result
}
export async function assignOrderItemsAfterCarrierReady(input: {
orderItemIds: number[]
channel: FulfillmentChannel
source: FulfillmentSource
lock: boolean
note?: string | undefined
canonicalProductId?: number | null
}): Promise<{
updated: number
carrierFailed: number
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
}> {
const ids = [
...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)),
]
if (ids.length === 0) {
return { updated: 0, carrierFailed: 0, carrierFailures: [] }
}
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
const carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> = []
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,
retryInactiveCarrier: true,
allowInactiveTaskRetry: true,
})
} catch (error) {
carrier = {
ready: false,
created: false,
reason: 'exception',
message: error instanceof Error ? error.message : String(error),
}
}
if (!carrier.ready) {
carrierFailed += 1
carrierFailures.push({
orderItemId: Number(item.id),
reason: carrier.reason,
message: carrier.message,
})
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 = 'operator_assigned',
fulfillment_locked = TRUE,
fulfillment_pending_reason = 'no_carrier',
fulfillment_pending_detail = $2::jsonb,
canonical_product_id = COALESCE($3, canonical_product_id),
updated_at = NOW()
WHERE id = $1 AND fulfillment_status <> 'archived'`,
[
item.id,
JSON.stringify({
channel: input.channel,
reason: carrier.reason,
message: carrier.message,
carrierIssue: carrier.reason,
}),
input.canonicalProductId || null,
],
)
}
continue
}
const updated = await query(
`UPDATE order_items
SET fulfillment_channel = $2,
fulfillment_status = 'assigned',
fulfillment_source = $3,
fulfillment_locked = $4,
canonical_product_id = COALESCE($6, canonical_product_id),
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),
input.canonicalProductId || null,
],
)
if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id))
}
if (assignedIds.length > 0) {
await scheduleVoucherConsumeForOrderItems(assignedIds, 'order_received').catch((error) => {
logWarn('[channel-assign]', '指定渠道后安排自动核销失败', {
orderItemIds: assignedIds,
error: error instanceof Error ? error.message : String(error),
})
})
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],
)
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
return {
updated: Number(final.rows[0]?.count || assignedIds.length),
carrierFailed,
carrierFailures,
}
}
async function syncAssignedChannelSnapshots(
ids: number[],
channel: FulfillmentChannel,
source: FulfillmentSource,
) {
if (ids.length === 0) return
const configured = channel === 'affiliate_dash' || channel === 'manual'
const route =
channel === 'manual'
? {
selectedExecutorKey: 'manual_dispatch',
selectedRuleId: '',
reason: '运营指定人工客服',
matchedRule: null,
candidates: [],
skipped: [],
}
: channel === 'affiliate_dash'
? {
selectedExecutorKey: 'affiliate_dash',
selectedRuleId: '',
reason: '运营指定自动发货',
matchedRule: null,
candidates: [],
skipped: [],
}
: {
selectedExecutorKey: '',
selectedRuleId: '',
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: channel === 'on_account'
? '内部 SKU 渠道配置指定统一结算'
: '内部 SKU 渠道配置指定第三方卡券',
matchedRule: null,
candidates: [],
skipped: [],
}
const decision = {
internalSkuChannel: channel,
internalSkuChannelSource: source,
internalSkuChannelReason: '',
isConfigured: configured,
fulfillmentRoute: route,
...(channel !== 'affiliate_dash' ? { affiliateDash: null } : {}),
}
await query(
`UPDATE order_items
SET item_snapshot_json = (
COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb
) || jsonb_build_object(
'classification',
COALESCE(item_snapshot_json->'classification', '{}'::jsonb)
|| jsonb_build_object('channel', $3)
),
updated_at = NOW()
WHERE id = ANY($1::bigint[])
AND fulfillment_status = 'assigned'
AND fulfillment_channel = $3`,
[ids, JSON.stringify(decision), channel],
)
}
const WORK_ORDER_SWITCH_BLOCKED = new Set([
'open',
'in_progress',
'pending_acceptance',
'problem',
'accepted',
])
const TASK_SWITCH_BLOCKED = new Set<string>([
TASK_STATUS.REDEEMING,
TASK_STATUS.DISPATCHED_PENDING_RETURN,
TASK_STATUS.COMPLETED,
TASK_STATUS.REDEEMED,
])
/**
* 显式切换渠道:先取消尚未实际履约的旧载体,再创建新载体。
* 已经成功或进入实际交付阶段的订单不允许自动切换。
*/
export async function switchChannelForOrderItems(input: {
orderItemIds: number[]
channel: string
adminUserId: number | null
note?: string
}): Promise<{
switched: number
failed: number
errors: Array<{ orderItemId: number; reason: string }>
}> {
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 skuRows = await query<{ internal_sku: string }>(
`SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
const switchInternalSkus = skuRows.rows
.map((row) => canonicalizeInternalSku(row.internal_sku))
.filter(Boolean)
if (switchInternalSkus.length !== 1) {
throw createHttpError('切换渠道必须基于同一个中文内部 SKU', {
statusCode: 400,
errorCode:
switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus',
})
}
const targetInternalSku = switchInternalSkus[0]!
const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel)
if (!draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
})
}
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 readyIds: number[] = []
const errors: Array<{ orderItemId: number; reason: string }> = []
for (const item of items) {
const order = orders.get(Number(item.order_id))
if (!order) {
errors.push({ orderItemId: Number(item.id), reason: 'order_not_found' })
continue
}
if (item.fulfillment_status === 'archived') {
errors.push({ orderItemId: Number(item.id), reason: 'archived' })
continue
}
if (item.fulfillment_channel === input.channel) {
readyIds.push(Number(item.id))
continue
}
try {
const workOrders = await query<{ id: number; status: string }>(
`SELECT id, status FROM work_orders
WHERE order_item_id = $1 AND status <> 'cancelled'`,
[item.id],
)
const blockedWorkOrder = workOrders.rows.find((row) =>
WORK_ORDER_SWITCH_BLOCKED.has(String(row.status)),
)
if (blockedWorkOrder) {
throw createHttpError('旧接单工单已进入履约阶段,不能自动切换渠道', {
statusCode: 409,
errorCode: 'channel_switch_work_order_active',
})
}
const tasks = await query<{ id: number; task_status: string }>(
`SELECT id, task_status FROM fulfillment_tasks WHERE order_item_id = $1`,
[item.id],
)
const blockedTask = tasks.rows.find((row) => TASK_SWITCH_BLOCKED.has(String(row.task_status)))
if (blockedTask) {
throw createHttpError('旧履约任务已进入实际交付阶段,不能自动切换渠道', {
statusCode: 409,
errorCode: 'channel_switch_task_active',
})
}
const now = nowIso()
await withTransaction(async (client) => {
const locked = await client.query<OrderItemRow>(
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
[item.id],
)
if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') {
throw createHttpError('订单项状态已变化', {
statusCode: 409,
errorCode: 'channel_switch_order_item_changed',
})
}
for (const workOrder of workOrders.rows) {
await client.query(
`UPDATE work_orders
SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL,
deadline_at = NULL, published_at = NULL, updated_at = $2
WHERE id = $1 AND status <> 'cancelled'`,
[workOrder.id, now],
)
}
for (const task of tasks.rows) {
await client.query(
`UPDATE fulfillment_tasks
SET task_status = $2, delivery_status = 'closed', result_code = 'closed_by_channel_switch',
result_message = $3, user_action_status = 'closed', claim_expires_at = $4,
updated_at = $4
WHERE id = $1`,
[task.id, TASK_STATUS.CLOSED, '履约渠道已切换,原任务关闭', now],
)
}
await client.query(
`UPDATE cost_snapshots
SET superseded_at = $2
WHERE order_item_id = $1 AND superseded_at IS NULL AND reversed_at IS NULL`,
[item.id, now],
)
await client.query(
`UPDATE order_items
SET original_channel = COALESCE(original_channel, fulfillment_channel),
fulfillment_channel = NULL,
fulfillment_status = 'pending',
fulfillment_source = 'operator_assigned',
fulfillment_locked = FALSE,
fulfillment_reason = $2,
channel_switch_reason = 'operator',
channel_switched_at = $3,
canonical_product_id = NULL,
fulfillment_config_id = NULL,
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
updated_at = $3
WHERE id = $1`,
[item.id, String(input.note || '运营切换履约渠道').slice(0, 200), now],
)
})
for (const workOrder of workOrders.rows) {
await createWorkOrderEvent({
workOrderId: Number(workOrder.id),
actorType: 'admin',
actorId: String(input.adminUserId || ''),
eventType: 'cancelled_by_channel_switch',
fromStatus: workOrder.status,
toStatus: 'cancelled',
payloadJson: JSON.stringify({ channel: input.channel, note: input.note || '' }),
now,
})
}
for (const task of tasks.rows) {
await closeClaimTokensByTaskId(Number(task.id), now)
await createTaskEvent(
Number(task.id),
'task_closed_by_channel_switch',
{ channel: input.channel, note: input.note || '', previousStatus: task.task_status },
now,
)
}
readyIds.push(Number(item.id))
} catch (error) {
errors.push({
orderItemId: Number(item.id),
reason: error instanceof Error ? error.message : String(error),
})
}
}
const assigned = await assignOrderItemsAfterCarrierReady({
orderItemIds: readyIds,
channel: input.channel,
source: 'operator_assigned',
lock: true,
note: input.note,
canonicalProductId: draftProductId,
})
await query(
`UPDATE order_items
SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW()
WHERE id = ANY($1::bigint[])`,
[readyIds, input.adminUserId],
)
if (assigned.updated > 0) {
const carrier = await resolveInternalSkuCarrierConfig(targetInternalSku, input.channel)
await setInternalSkuPrimaryChannel({
internalSku: targetInternalSku,
channel: input.channel,
canonicalProductId: draftProductId,
configJson: carrier.configJson,
source: 'operator',
note: input.note,
adminUserId: input.adminUserId,
})
}
return {
switched: assigned.updated,
failed: errors.length + assigned.carrierFailed,
errors,
}
}
/** 运营指定渠道:先保证真实载体,成功后才写入并锁定。 */
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 rows = await query<{ internal_sku: string }>(
`SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
const internalSkus = [
...new Set(rows.rows.map((row) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean)),
]
if (internalSkus.length === 0) {
throw createHttpError('所选订单项没有中文内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
if (internalSkus.length > 1) {
throw createHttpError('所选订单项包含多个内部 SKU,请按内部 SKU 指定渠道', {
statusCode: 400,
errorCode: 'multiple_internal_skus',
})
}
const result = await assignChannelByInternalSku({
internalSku: internalSkus[0]!,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
})
return { updated: result.updated, carrierFailed: result.carrierFailed }
}
/** 运营解锁:允许自动判定重新接管。 */
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 = {
group_key: string
scope_type: 'internal_sku' | 'source_sku'
internal_sku: string | null
sku_name: string
providers: string[]
shop_ids: string[]
shops: Array<{ id: string; name: string }>
sku_codes: string[]
pending_reasons: string[]
pending_subreasons: string[]
configured_channels: string[]
locked_item_count: number
item_count: number
revenue_fen: number
first_seen_at: string
last_seen_at: string
}
type PendingShopNameRow = {
group_key: string
provider: string
shop_id: string
shop_name: string
usage_count: number
}
type PendingGroupDbRow = Omit<PendingGroupRow, 'shops'>
const PENDING_GROUP_KEY_SQL = `
CASE
WHEN COALESCE(BTRIM(${INTERNAL_SKU_SQL}), '') <> ''
THEN 'sku:' || encode(convert_to(${INTERNAL_SKU_SQL}, 'UTF8'), 'hex')
ELSE 'source:' || encode(
convert_to(
CONCAT_WS(CHR(31), o.provider, o.shop_id, oi.sku_code),
'UTF8'
),
'hex'
)
END
`
/** 待分类队列:按全局内部 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(
`(${INTERNAL_SKU_SQL} ILIKE $${params.length} OR 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 }>(
`
WITH pending_items AS (
SELECT
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
)
SELECT COUNT(DISTINCT group_key)::int AS count
FROM pending_items
`,
params,
)
params.push(pageSize, (page - 1) * pageSize)
const rows = await query<PendingGroupDbRow>(
`
WITH pending_items AS (
SELECT
oi.id,
oi.order_id,
oi.sku_code,
oi.sku_name,
oi.fulfillment_locked,
oi.fulfillment_pending_reason,
oi.fulfillment_pending_detail,
oi.revenue_amount_fen,
oi.created_at,
o.provider,
o.shop_id,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
)
SELECT
pi.group_key,
CASE WHEN pi.internal_sku <> '' THEN 'internal_sku' ELSE 'source_sku' END AS scope_type,
pi.internal_sku,
MIN(pi.sku_name) AS sku_name,
array_agg(DISTINCT pi.provider) AS providers,
array_agg(DISTINCT pi.shop_id) AS shop_ids,
array_agg(DISTINCT pi.sku_code) AS sku_codes,
array_remove(array_agg(DISTINCT pi.fulfillment_pending_reason), NULL) AS pending_reasons,
array_remove(
array_agg(
DISTINCT COALESCE(
pi.fulfillment_pending_detail->>'carrierIssue',
pi.fulfillment_pending_detail->>'reason'
)
),
NULL
) AS pending_subreasons,
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
COUNT(*) FILTER (WHERE pi.fulfillment_locked)::int AS locked_item_count,
COUNT(*)::int AS item_count,
COALESCE(SUM(pi.revenue_amount_fen), 0)::bigint AS revenue_fen,
MIN(pi.created_at) AS first_seen_at,
MAX(pi.created_at) AS last_seen_at
FROM pending_items pi
LEFT JOIN LATERAL (
SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku) =
pi.internal_sku
) cfg ON TRUE
GROUP BY pi.group_key, pi.internal_sku, cfg.channels
ORDER BY item_count DESC, internal_sku
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
const groupKeys = rows.rows.map((row) => row.group_key)
const shopsBySku = new Map<string, Array<{ id: string; name: string }>>()
if (groupKeys.length > 0) {
const shopRows = await query<PendingShopNameRow>(
`
WITH pending_items AS (
SELECT
oi.id,
o.provider,
o.shop_id,
o.shop_name,
oi.sku_code,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
)
SELECT
group_key,
provider,
shop_id,
COALESCE(NULLIF(BTRIM(shop_name), ''), shop_id) AS shop_name,
COUNT(*)::int AS usage_count
FROM pending_items
WHERE group_key = ANY($1::text[])
AND BTRIM(shop_id) <> ''
GROUP BY group_key, provider, shop_id, shop_name
ORDER BY
group_key,
provider,
shop_id,
CASE
WHEN BTRIM(shop_name) = '' OR BTRIM(shop_name) = shop_id THEN 3
WHEN BTRIM(shop_name) = '91卡券' OR BTRIM(shop_name) LIKE '快手店铺-%' THEN 2
WHEN BTRIM(shop_name) ~ '[一-龥]' THEN 0
ELSE 1
END,
usage_count DESC,
shop_name
`,
[groupKeys],
)
const seen = new Set<string>()
for (const row of shopRows.rows) {
const shopKey = `${row.group_key}\u0000${row.shop_id}`
if (seen.has(shopKey)) continue
seen.add(shopKey)
const shops = shopsBySku.get(row.group_key) || []
shops.push({ id: row.shop_id, name: row.shop_name || row.shop_id })
shopsBySku.set(row.group_key, shops)
}
}
return {
items: rows.rows.map((row) => ({
...row,
shops:
shopsBySku.get(row.group_key) ||
row.shop_ids.map((shopId) => ({ id: shopId, name: shopId })),
})),
total: total.rows[0]?.count || 0,
page,
pageSize,
}
}
export type RetryPendingCarrierGroupResult = {
scanned: number
workOrdersCreated: number
affiliateTasksCreated: number
assigned: number
failed: number
remaining: number
}
/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */
export async function retryPendingCarrierGroup(input: {
internalSku: string
}): Promise<RetryPendingCarrierGroupResult> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
const pending = await query<{ id: number }>(
`SELECT oi.id
FROM order_items oi
WHERE oi.fulfillment_status = 'pending'
AND ${INTERNAL_SKU_SQL} = $1
ORDER BY oi.id`,
[internalSku],
)
const ids = pending.rows.map((row) => Number(row.id))
let workOrdersCreated = 0
let affiliateTasksCreated = 0
let failed = 0
const assignedItemIds: number[] = []
for (let offset = 0; offset < ids.length; offset += 500) {
const batchIds = ids.slice(offset, offset + 500)
const carrier = await reconcilePendingCarriers({
orderItemIds: batchIds,
limit: batchIds.length,
includeLocked: true,
retryInactiveCarrier: true,
})
workOrdersCreated += carrier.workOrdersCreated
affiliateTasksCreated += carrier.affiliateTasksCreated
failed += carrier.failed
const applied = await assignPendingOrderItemChannels({
orderItemIds: batchIds,
batchSize: batchIds.length,
maxBatches: 1,
afterId: 0,
includeLocked: true,
})
assignedItemIds.push(...applied.assignedItemIds)
}
await query(
`UPDATE order_items oi
SET fulfillment_config_id = cfg.id,
canonical_product_id = COALESCE(oi.canonical_product_id, cfg.canonical_product_id),
updated_at = NOW()
FROM manual_fulfillment_configs cfg
WHERE oi.id = ANY($1::bigint[])
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
AND cfg.enabled = TRUE
AND canonicalize_internal_sku(cfg.sku_key) =
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`,
[ids],
)
if (assignedItemIds.length > 0) {
await normalizeProductsForItems(assignedItemIds).catch(() => undefined)
const cost = await syncCostSnapshotsForItems(assignedItemIds).catch(() => null)
const dates = [
...new Set([...(await orderItemBasisDates(assignedItemIds)), ...(cost?.affectedDates || [])]),
]
await refreshFactsForDates(dates).catch(() => undefined)
}
const final = await query<{ assigned: number; remaining: number }>(
`SELECT
count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned,
count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining
FROM order_items
WHERE id = ANY($1::bigint[])`,
[ids],
)
return {
scanned: ids.length,
workOrdersCreated,
affiliateTasksCreated,
assigned: Number(final.rows[0]?.assigned || 0),
failed,
remaining: Number(final.rows[0]?.remaining || 0),
}
}
export type DeletePendingChannelGroupResult = {
archivedItems: number
preservedOrders: number
deletedCostSnapshots: number
resolvedExceptions: number
refreshedDays: number
skippedClosedDays: number
}
type PendingGroupScope =
| { type: 'internal_sku'; internalSku: string }
| { type: 'source_sku'; provider: string; shopId: string; skuCode: string }
function resolvePendingGroupScopeInput(input: {
groupKey?: string
internalSku?: string
}): PendingGroupScope | null {
const groupKey = String(input.groupKey || '').trim()
if (groupKey) {
const match = /^(sku|source):([0-9a-f]+)$/i.exec(groupKey)
if (!match) return null
const scopeType = String(match[1] || '').toLowerCase()
const encoded = String(match[2] || '')
if (!encoded || encoded.length % 2 !== 0) return null
const decoded = Buffer.from(encoded, 'hex').toString('utf8')
if (scopeType === 'sku') {
const internalSku = canonicalizeInternalSku(decoded)
return internalSku ? { type: 'internal_sku', internalSku } : null
}
const parts = decoded.split(String.fromCharCode(31))
if (parts.length !== 3 || !parts[0]) return null
return {
type: 'source_sku',
provider: parts[0],
shopId: parts[1] || '',
skuCode: parts[2] || '',
}
}
const internalSku = canonicalizeInternalSku(input.internalSku)
return internalSku ? { type: 'internal_sku', internalSku } : null
}
/**
* 删除待分类 SKU:只归档订单项并清理其成本/异常,不删除原始订单。
* 已关账月份只跳过事实重算,不再阻止历史待办归档。
*/
export async function deletePendingChannelGroup(input: {
groupKey?: string
internalSku?: string
adminUserId: number | null
}): Promise<DeletePendingChannelGroupResult> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
throw createHttpError('缺少待分类队列分组标识', {
statusCode: 400,
errorCode: 'pending_group_key_required',
})
}
const result = await withTransaction(async (client) => {
const itemParams: unknown[] = []
const itemWhere = ["oi.fulfillment_status = 'pending'"]
if (scope.type === 'internal_sku') {
itemParams.push(scope.internalSku)
itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`)
} else {
itemParams.push(scope.provider, scope.shopId, scope.skuCode)
itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
}
const items = await client.query<{ id: number; order_id: number; basis_date: string }>(
`
SELECT
oi.id,
oi.order_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
WHERE ${itemWhere.join(' AND ')}
ORDER BY oi.id
FOR UPDATE OF oi
`,
itemParams,
)
const itemIds = items.rows.map((row) => Number(row.id))
if (itemIds.length === 0) {
return {
archivedItems: 0,
preservedOrders: 0,
deletedCostSnapshots: 0,
resolvedExceptions: 0,
dates: [] as string[],
}
}
const snapshots = await client.query(
`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`,
[itemIds],
)
const removedLinks = await client.query<{ exception_id: number }>(
`
DELETE FROM cost_exception_items
WHERE order_item_id = ANY($1::bigint[])
RETURNING exception_id
`,
[itemIds],
)
const exceptionIds = [
...new Set(removedLinks.rows.map((row) => Number(row.exception_id)).filter(Boolean)),
]
const resolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved',
resolved_at = NOW(),
item_count = 0,
last_seen_at = NOW()
WHERE (
ce.order_item_id = ANY($1::bigint[])
OR ce.id = ANY($2::bigint[])
)
AND ce.status <> 'resolved'
AND NOT EXISTS (
SELECT 1
FROM cost_exception_items i
WHERE i.exception_id = ce.id
)
`,
[itemIds, exceptionIds],
)
const archived = await client.query(
`
UPDATE order_items
SET fulfillment_channel = NULL,
fulfillment_status = 'archived',
fulfillment_source = 'cutover_archive',
fulfillment_locked = TRUE,
fulfillment_reason = '运营从待分类队列删除',
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
fulfillment_decided_by = $2,
fulfillment_decided_at = NOW(),
updated_at = NOW()
WHERE id = ANY($1::bigint[])
AND fulfillment_status = 'pending'
`,
[itemIds, input.adminUserId],
)
return {
archivedItems: archived.rowCount || 0,
preservedOrders: new Set(items.rows.map((row) => Number(row.order_id))).size,
deletedCostSnapshots: snapshots.rowCount || 0,
resolvedExceptions: resolved.rowCount || 0,
dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))],
}
})
const refreshed = await refreshFactsForDates(result.dates)
return {
archivedItems: result.archivedItems,
preservedOrders: result.preservedOrders,
deletedCostSnapshots: result.deletedCostSnapshots,
resolvedExceptions: result.resolvedExceptions,
refreshedDays: refreshed.days,
skippedClosedDays: refreshed.skippedClosed,
}
}
export async function assignPendingChannelGroup(input: {
groupKey?: string
internalSku?: string
mappedInternalSku?: string
channel: string
adminUserId: number | null
note?: string | undefined
}): Promise<{
updated: number
ruleCreated: boolean
carrierFailed: number
channelSaved: boolean
draftProductId: number | null
}> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
throw createHttpError('缺少待分类队列分组标识', {
statusCode: 400,
errorCode: 'pending_group_key_required',
})
}
if (scope.type !== 'internal_sku') {
const mappedInternalSku = canonicalizeInternalSku(input.mappedInternalSku)
if (!mappedInternalSku || !containsCjk(mappedInternalSku)) {
throw createHttpError('该来源 SKU 缺少内部 SKU,请先填写包含中文的内部 SKU', {
statusCode: 409,
errorCode: 'internal_sku_mapping_required',
})
}
const updated = await query(
`
UPDATE order_items oi
SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb)
|| jsonb_build_object('skuNick', $4::text),
updated_at = NOW()
FROM orders o
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'
`,
[scope.provider, scope.shopId, scope.skuCode, mappedInternalSku],
)
if ((updated.rowCount || 0) === 0) {
throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', {
statusCode: 409,
errorCode: 'pending_group_stale',
})
}
return assignChannelByInternalSku({
internalSku: mappedInternalSku,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
})
}
return assignChannelByInternalSku({
internalSku: scope.internalSku,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
})
}
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
export async function assignChannelByInternalSku(input: {
internalSku: string
channel: string
adminUserId: number | null
note?: string | undefined
createRule?: boolean | undefined
}): Promise<{
updated: number
ruleCreated: boolean
carrierFailed: number
channelSaved: boolean
draftProductId: number | null
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
}> {
if (!isFulfillmentChannel(input.channel)) {
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
}
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
if (input.createRule === false) {
throw createHttpError('内部 SKU 渠道配置必须持久化,不能只改当前订单', {
statusCode: 400,
errorCode: 'persistent_channel_rule_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 ${INTERNAL_SKU_SQL} = $1
AND (
oi.fulfillment_status = 'pending'
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $2)
)
`,
[internalSku, input.channel],
)
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel)
if (!draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
})
}
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
await setInternalSkuPrimaryChannel({
internalSku,
channel: input.channel,
canonicalProductId: draftProductId,
source: 'operator',
configJson: {
...carrier.configJson,
note: String(input.note || '').slice(0, 200),
},
note: input.note,
adminUserId: input.adminUserId,
})
const ruleCreated = true
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,
canonicalProductId: draftProductId,
})
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,
channelSaved: true,
draftProductId,
}
}
/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */
export async function assignChannelBySku(input: {
provider: string
shopId: string
skuCode: string
channel: string
adminUserId: number | null
note?: string | undefined
createRule?: boolean | undefined
}): 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) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean),
),
]
if (internalSkus.length === 0) {
return { updated: 0, ruleCreated: false, carrierFailed: 0 }
}
if (internalSkus.length > 1) {
throw createHttpError('同一平台 SKU 对应多个内部 SKU,请按内部 SKU 指定渠道', {
statusCode: 409,
errorCode: 'sku_code_ambiguous_internal_sku',
})
}
return assignChannelByInternalSku({
internalSku: internalSkus[0]!,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
createRule: input.createRule,
})
}
type ChannelRuleFilterInput = {
channel?: string
source?: string
keyword?: string
enabled?: boolean
}
function buildChannelRuleFilter(input: ChannelRuleFilterInput) {
const params: unknown[] = []
const clauses: string[] = []
if (input.channel) {
params.push(input.channel)
clauses.push(`channel = $${params.length}`)
}
const source = String(input.source || '').trim()
if (source === 'rule') {
clauses.push(`source LIKE 'rule:%'`)
} else if (source) {
params.push(source)
clauses.push(`source = $${params.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(match_key ILIKE $${params.length} OR config_json::text ILIKE $${params.length} OR COALESCE(note, '') ILIKE $${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 ')}` : ''
return { where, params }
}
const CHANNEL_RULE_FROM_SQL = `
FROM (
SELECT id,
'internal_sku'::text AS match_key_type,
canonicalize_internal_sku(internal_sku) AS match_key,
channel, priority, enabled, is_fallback, source, config_json, note
FROM internal_sku_channels
) internal_rules
`
function mapChannelRule(row: ChannelRule): ChannelRule {
return {
...row,
id: Number(row.id),
priority: Number(row.priority),
}
}
export async function listChannelRules(input: ChannelRuleFilterInput = {}): Promise<ChannelRule[]> {
const { where, params } = buildChannelRuleFilter(input)
const rows = await query<ChannelRule>(
`
SELECT id, match_key_type, match_key, channel, priority, enabled,
is_fallback, source, config_json, note
${CHANNEL_RULE_FROM_SQL}
${where}
ORDER BY priority, channel, match_key, id
`,
params,
)
return rows.rows.map(mapChannelRule)
}
export async function listChannelRulesPage(
input: ChannelRuleFilterInput & { page?: number; pageSize?: number } = {},
): Promise<ChannelRulePage> {
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20)))
const offset = (page - 1) * pageSize
const { where, params } = buildChannelRuleFilter(input)
const pageParams = [...params, pageSize, offset]
const [countResult, rows] = await Promise.all([
query<{ total: number }>(
`SELECT COUNT(*)::int AS total ${CHANNEL_RULE_FROM_SQL} ${where}`,
params,
),
query<ChannelRule>(
`
SELECT id, match_key_type, match_key, channel, priority, enabled,
is_fallback, source, config_json, note
${CHANNEL_RULE_FROM_SQL}
${where}
ORDER BY priority, channel, match_key, id
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}
`,
pageParams,
),
])
return {
items: rows.rows.map(mapChannelRule),
total: Number(countResult.rows[0]?.total || 0),
page,
pageSize,
}
}
async function ensureDraftProductForChannel(
internalSku: string,
channel: FulfillmentChannel,
): Promise<number | null> {
const key = canonicalizeInternalSku(internalSku)
if (!key) return null
const definition = {
affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' },
work_order: { prefix: 'tpl:', category: '接单', unit: '件' },
manual: { prefix: 'man:', category: '人工', unit: '件' },
on_account: { prefix: 'man:', category: '统一结算', unit: '件' },
third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' },
}[channel]
const canonicalSku = `${definition.prefix}${key}`
if (canonicalSku.length > 64) return null
const now = nowIso()
const result = await query<{ id: number; status: string }>(
`
INSERT INTO product_catalog (
canonical_sku,
canonical_name,
category,
unit_name,
status,
attributes_json,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, 'active',
$5::jsonb, $6, $6
)
ON CONFLICT (canonical_sku) DO UPDATE
SET status = 'active',
canonical_name = CASE
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
ELSE product_catalog.canonical_name
END,
attributes_json = (
product_catalog.attributes_json
- 'deletedAt'
- 'deletedBy'
- 'archivedAt'
) || jsonb_build_object('reactivatedAt', EXCLUDED.updated_at),
updated_at = EXCLUDED.updated_at
RETURNING id, status
`,
[
canonicalSku,
key.slice(0, 120),
definition.category,
definition.unit,
JSON.stringify({
source: channel,
auto_created: true,
draft: true,
pending_cost_config: true,
}),
now,
],
)
const product = result.rows[0]
const productId = Number(product?.id || 0)
if (!productId || product?.status !== 'active') return null
await query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source
) VALUES ($1, $2, 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId, channel],
)
return productId
}
async function resolveInternalSkuCarrierConfig(
internalSku: string,
channel: string,
): Promise<{ canonicalProductId: number | null; configJson: Record<string, unknown> }> {
const key = canonicalizeInternalSku(internalSku)
if (!key) return { canonicalProductId: null, configJson: {} }
if (channel === 'manual') {
const manual = await query<{ id: number; canonical_product_id: number | null }>(
`SELECT id, canonical_product_id
FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE
ORDER BY id`,
[key],
)
if (manual.rows.length > 1) {
throw createHttpError(`内部 SKU「${key}」存在多份人工履约配置`, {
statusCode: 409,
errorCode: 'carrier_config_ambiguous',
})
}
return {
canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null,
configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {},
}
}
const prefixes =
channel === 'affiliate_dash'
? ['aff:']
: channel === 'work_order'
? ['tpl:', 'wo:']
: channel === 'on_account'
? ['man:']
: ['tpv:']
const product = await query<{ id: number; canonical_sku: string }>(
`SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active'
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1
ORDER BY id`,
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
)
const row = product.rows[0]
if (!row) return { canonicalProductId: null, configJson: {} }
const distinctCarrierKeys = new Set(
product.rows.map((candidate) => {
const rawSku = String(candidate.canonical_sku)
const prefix = prefixes.find((item) => rawSku.startsWith(item)) || prefixes[0]!
return rawSku.slice(prefix.length)
}),
)
if (distinctCarrierKeys.size > 1) {
throw createHttpError(
`内部 SKU「${key}」在 ${channel} 渠道存在多个载体配置:${[...distinctCarrierKeys].join(' / ')}`,
{
statusCode: 409,
errorCode: 'carrier_config_ambiguous',
},
)
}
const rawSku = String(row.canonical_sku)
const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]!
const carrierKey = rawSku.slice(matchedPrefix.length)
return {
canonicalProductId: Number(row.id),
configJson:
channel === 'affiliate_dash'
? { affiliateSku: carrierKey }
: channel === 'work_order'
? { workRuleKey: carrierKey }
: channel === 'on_account'
? { settlementSku: carrierKey }
: { voucherSku: carrierKey },
}
}
export async function upsertChannelRule(input: {
id?: number
matchKeyType: string
matchKey: string
channel: string
priority?: number
source?: string
enabled?: boolean
note?: string
adminUserId: number | null
}): Promise<{ id: number }> {
if (input.matchKeyType !== 'internal_sku') {
throw createHttpError('渠道配置只接受内部 SKU;旧 sku_code 规则仅用于一次性迁移', {
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 = canonicalizeInternalSku(input.matchKey)
if (!matchKey || !containsCjk(matchKey)) {
throw createHttpError('匹配键必须是包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel)
if (!draftProductId) {
throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', {
statusCode: 409,
errorCode: 'channel_standard_product_required',
})
}
const priority =
input.channel === 'third_party_voucher' ? 10 : Math.trunc(Number(input.priority) || 100)
const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel)
const id = await upsertInternalSkuChannel({
internalSku: matchKey,
channel: input.channel,
priority,
isFallback: input.channel === 'third_party_voucher' ? false : priority >= 90,
enabled: input.enabled !== false,
source: source === 'operator' ? 'operator' : `rule:${source}`,
note: String(input.note || '').slice(0, 300),
adminUserId: input.adminUserId,
replacePriority: true,
canonicalProductId: draftProductId,
configJson: carrier.configJson,
})
return { 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' })
}
await assertRulesDeletable([id])
return { deleted: await deleteInternalSkuChannelById(id) }
}
export async function deleteChannelRules(idsInput: number[]): Promise<{
deletedCount: number
deletedIds: number[]
missingIds: number[]
}> {
if (!Array.isArray(idsInput) || idsInput.length === 0) {
throw createHttpError('请选择要删除的渠道规则', {
statusCode: 400,
errorCode: 'channel_rule_ids_required',
})
}
await assertRulesDeletable(idsInput)
const result = await deleteInternalSkuChannelsByIds(idsInput)
return { deletedCount: result.deletedIds.length, ...result }
}
/**
* 运营强制删除:跳过系统来源保护,只删除规则本身,不改历史订单渠道。
* 对应来源的同步任务后续仍可能重新生成规则。
*/
export async function forceDeleteChannelRules(idsInput: number[]): Promise<{
deletedCount: number
deletedIds: number[]
missingIds: number[]
}> {
if (!Array.isArray(idsInput) || idsInput.length === 0) {
throw createHttpError('请选择要强制删除的渠道规则', {
statusCode: 400,
errorCode: 'channel_rule_ids_required',
})
}
const result = await deleteInternalSkuChannelsByIds(idsInput)
return { deletedCount: result.deletedIds.length, ...result }
}
async function assertRulesDeletable(idsInput: number[]): Promise<void> {
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
if (ids.length === 0) return
const rows = await query<{ id: number; source: string; match_key: string; channel: string }>(
`SELECT id, source, canonicalize_internal_sku(internal_sku) AS match_key, channel
FROM internal_sku_channels WHERE id = ANY($1::bigint[])`,
[ids],
)
const protectedRow = rows.rows.find(
(row) => row.source !== 'operator' && !row.source.startsWith('rule:'),
)
if (protectedRow) {
throw createHttpError(
`「${protectedRow.match_key}」的 ${protectedRow.channel} 配置由 ${protectedRow.source} 生成,请到对应配置页停用,不能在渠道页直接删除`,
{ statusCode: 409, errorCode: 'system_channel_rule_protected' },
)
}
}
async function loadInternalSkuChannelsForKeys(
keys: string[],
): Promise<Map<string, InternalSkuChannelRow[]>> {
const normalized = [...new Set(keys.map(canonicalizeInternalSku).filter(Boolean))]
const result = new Map<string, InternalSkuChannelRow[]>()
if (normalized.length === 0) return result
const rows = await query<InternalSkuChannelRow>(
`
SELECT id, internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id
FROM internal_sku_channels
WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[])
ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id
`,
[normalized],
)
for (const row of rows.rows) {
const key = canonicalizeInternalSku(row.internal_sku)
const list = result.get(key) || []
list.push(row)
result.set(key, list)
}
return result
}
export const CHANNEL_RULE_KEY_TYPES = ['internal_sku'] as const
export const CHANNEL_RULE_SOURCES = RULE_SOURCES
export const CHANNEL_VALUES = FULFILLMENT_CHANNELS
/** 为单个卡券 SKU 建规则(商品创建时调用,幂等)。 */
export async function ensureVoucherRuleForSku(skuCode: string): Promise<boolean> {
const key = canonicalizeInternalSku(skuCode)
if (!key) return false
const product = await query<{ id: number }>(
`SELECT id FROM product_catalog
WHERE canonical_sku = $1 AND status = 'active'
LIMIT 1`,
[`tpv:${key}`],
)
const productId = Number(product.rows[0]?.id || 0)
if (!productId) return false
await upsertInternalSkuChannel({
internalSku: key,
channel: 'third_party_voucher',
priority: 10,
isFallback: false,
enabled: true,
canonicalProductId: productId,
configJson: { voucherSku: key },
source: 'voucher',
replacePriority: true,
})
return true
}
/**
* 为卡券商品(tpv: 前缀)批量补渠道规则。
*
* 卡券没有履约载体证据,只能靠规则判定;商品目录建好后一次补齐,
* 避免运营按 SKU 逐条点「指定渠道」。
*/
export async function ensureVoucherChannelRules(): Promise<{
created: number
productCount: number
}> {
await query(
`UPDATE internal_sku_channels channel
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
FROM product_catalog product
WHERE product.status = 'active'
AND product.canonical_sku LIKE 'tpv:%'
AND canonicalize_internal_sku(channel.internal_sku) =
canonicalize_internal_sku(substring(product.canonical_sku from 5))
AND channel.channel <> 'third_party_voucher'
AND channel.enabled = TRUE
AND channel.is_fallback = FALSE`,
)
const created = await query<{ id: number }>(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
)
SELECT
canonicalize_internal_sku(substring(p.canonical_sku from 5)),
'third_party_voucher', 10, FALSE, TRUE,
p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)),
'voucher', NOW(), NOW()
FROM product_catalog p
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'tpv:%'
AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> ''
ON CONFLICT (internal_sku, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
canonical_product_id = EXCLUDED.canonical_product_id,
config_json = internal_sku_channels.config_json || EXCLUDED.config_json,
source = EXCLUDED.source,
updated_at = NOW()
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) }
}