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

2443 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
scheduleVoucherConsumeFallbackForOrderItems,
scheduleVoucherConsumeForOrderItems,
} from './voucher-consume-queue-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurations,
transitionFulfillmentState,
transitionFulfillmentStateBatch,
transitionFulfillmentStateBatchWithExecutor,
transitionFulfillmentStateWithExecutor,
} from './fulfillment-state-service.js'
/**
* 管道 A:渠道判定。
*
* 只写 `fulfillments.channel / status / source / decided_at`,
* 绝不写商品、成本、事实,也绝不改冻结的订单项旧列。
*
* 判定顺序:
* 1. 运营锁定 → 跳过
* 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突
* 3. sku_channel_routes:一个主渠道 + 显式兜底;多主渠道 → 冲突
* 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
cutoffCreatedAt?: string
} = {},
): 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[])`
}
const cutoffCreatedAt = String(options.cutoffCreatedAt || '').trim()
let cutoffFilter = ''
if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) {
params.push(cutoffCreatedAt)
cutoffFilter = `AND o.created_at >= $${params.length}::timestamptz`
}
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 v_order_item_current 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}
${cutoffFilter}
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 unmatchedVoucherItemIds: number[] = []
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 transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'pending' },
patch: {
pendingReason: 'routing_conflict',
pendingDetail: {
channels: decision.conflictChannels,
ruleIds: decision.matchedRuleIds,
},
},
eventType: 'routing_conflict_detected',
reason: 'routing_conflict',
})
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 transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'pending' },
patch: {
pendingReason: pending.reason,
...(pending.reason === 'no_carrier'
? { pendingDetailUnlessCarrierIssue: pending.detail }
: { pendingDetail: pending.detail }),
},
eventType: 'routing_pending',
reason: pending.reason,
})
if (pending.reason === 'no_channel_capability') {
unmatchedVoucherItemIds.push(Number(item.id))
}
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 withTransaction((client) =>
assignFulfillmentChannelsWithExecutor(client, {
assignments: assignments.map((row) => ({
orderItemId: row.id,
channel: row.channel,
source: row.source,
})),
includeLocked: options.includeLocked === true,
eventType: 'routing_channel_assigned',
reason: 'automatic_routing',
}),
)
const assignedItemIds = updated
.map((row) => Number(row.id))
.filter((id) => id > 0)
.slice(0, 2000)
result.assigned += assignedItemIds.length
result.assignedItemIds = [...new Set([...result.assignedItemIds, ...assignedItemIds])].slice(
0,
2000,
)
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
(error) => {
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
orderItemIds: assignedItemIds,
error: error instanceof Error ? error.message : String(error),
})
},
)
}
if (unmatchedVoucherItemIds.length > 0) {
await scheduleVoucherConsumeFallbackForOrderItems(
unmatchedVoucherItemIds,
'order_received',
).catch((error) => {
logWarn('[channel-assign]', '未命中渠道后安排兜底核销失败', {
orderItemIds: unmatchedVoucherItemIds,
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 v_order_item_current 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 transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'assigned' },
patch: { reason: '切换渠道前需先撤销现有履约载体' },
eventType: 'channel_switch_blocked_by_existing_carrier',
reason: String(item.fulfillment_channel || ''),
})
continue
}
let carrier
try {
carrier = await ensureCarrierForOrderItem({
order,
item,
channel: input.channel,
retryInactiveCarrier: 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 transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'assigned' },
patch: {
channel: item.fulfillment_channel as FulfillmentChannel,
status: 'assigned',
source: item.fulfillment_source || '',
locked: Boolean(item.fulfillment_locked),
reason: item.fulfillment_reason || null,
pendingReason: item.fulfillment_pending_reason || null,
pendingDetail:
item.fulfillment_pending_detail && typeof item.fulfillment_pending_detail === 'object'
? (item.fulfillment_pending_detail as Record<string, unknown>)
: null,
},
eventType: 'channel_switch_failed_existing_state_kept',
reason: carrier.reason,
})
} else {
await transitionFulfillmentState({
orderItemId: Number(item.id),
expected: { status: 'pending' },
patch: {
channel: null,
status: 'pending',
source: 'operator_assigned',
locked: true,
pendingReason: 'no_carrier',
pendingDetail: {
channel: input.channel,
reason: carrier.reason,
message: carrier.message,
carrierIssue: carrier.reason,
},
...(input.canonicalProductId
? { canonicalProductIdIfNull: Number(input.canonicalProductId) }
: {}),
},
eventType: 'operator_channel_assign_missing_carrier',
actor: { type: 'operator' },
reason: carrier.reason,
})
}
continue
}
const updated = await transitionFulfillmentState({
orderItemId: Number(item.id),
patch: {
channel: input.channel,
status: 'assigned',
source: input.source,
locked: input.lock,
...(input.canonicalProductId
? { canonicalProductIdIfNull: Number(input.canonicalProductId) }
: {}),
...(String(input.note || '').trim() ? { reason: String(input.note).slice(0, 200) } : {}),
pendingReason: null,
pendingDetail: null,
},
eventType: 'operator_carrier_ready',
actor: { type: 'operator' },
reason: input.note || '',
})
if (updated) 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 finalized = await transitionFulfillmentStateBatch({
orderItemIds: ids,
expected: { status: 'assigned', channel: input.channel },
patch: {
source: input.source,
locked: input.lock,
pendingReason: null,
pendingDetail: null,
},
eventType: 'assigned_channel_snapshot_updated',
actor: { type: 'operator' },
reason: input.note || '',
})
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
return {
updated: finalized.length || 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 IN (
SELECT id
FROM v_order_item_current
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 v_order_item_current 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 v_order_item_current 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 status AS fulfillment_status
FROM fulfillments
WHERE order_item_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 transitionFulfillmentStateWithExecutor(client, {
orderItemId: Number(item.id),
expected: { status: item.fulfillment_status as 'pending' | 'assigned' | 'archived' },
patch: {
...(isFulfillmentChannel(item.fulfillment_channel)
? { originalChannelIfNull: item.fulfillment_channel }
: {}),
channel: null,
status: 'pending',
source: 'operator_assigned',
locked: false,
reason: String(input.note || '运营切换履约渠道').slice(0, 200),
switchReason: 'operator',
switchedAt: now,
canonicalProductId: null,
configId: null,
pendingReason: null,
pendingDetail: null,
},
eventType: 'operator_channel_switched',
actor: { type: 'operator', id: input.adminUserId },
reason: input.note || '',
})
})
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 transitionFulfillmentStateBatch({
orderItemIds: readyIds,
patch: {
decidedBy: input.adminUserId,
decidedAt: nowIso(),
},
eventType: 'operator_channel_decision_recorded',
actor: { type: 'operator', id: input.adminUserId },
reason: input.note || '',
})
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 v_order_item_current 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 updated = await transitionFulfillmentStateBatch({
orderItemIds: ids,
expected: { locked: true },
patch: { locked: false },
eventType: 'fulfillment_unlocked',
actor: { type: 'operator' },
})
return { updated: updated.length }
}
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
issue_breakdown: Array<{
issue_code: string
issue_category: string
item_count: number
locked_item_count: number
retryable: boolean
auto_retryable: boolean
owner_role: string
}>
retryable_item_count: number
retryable_unlocked_item_count: number
auto_retryable_item_count: number
auto_retryable_unlocked_item_count: number
config_issue_item_count: number
manual_review_item_count: number
after_sales_item_count: number
system_issue_item_count: number
oldest_age_minutes: number
}
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
`
const PENDING_STATE_GROUP_KEY_SQL = `
CASE
WHEN COALESCE(BTRIM(v.internal_sku), '') <> ''
THEN 'sku:' || encode(convert_to(v.internal_sku, 'UTF8'), 'hex')
ELSE 'source:' || encode(
convert_to(
CONCAT_WS(CHR(31), v.provider, v.shop_id, v.sku_code),
'UTF8'
),
'hex'
)
END
`
const RETRYABLE_CARRIER_ISSUES = [
'carrier_closed',
'carrier_retry_pending',
'carrier_expired',
'carrier_missing',
'state_inconsistent',
] as const
const AUTO_RETRYABLE_CARRIER_ISSUES = ['carrier_missing', 'state_inconsistent'] as const
const PENDING_ISSUE_CATEGORIES = [
'classification',
'configuration',
'carrier',
'manual_review',
'after_sales',
'system',
] as const
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
export async function listPendingChannelGroups(input: {
page?: number
pageSize?: number
keyword?: string
issueCategory?: 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 issueCategoryInput = String(input.issueCategory || '').trim()
const issueCategory = (PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategoryInput)
? issueCategoryInput
: ''
const params: unknown[] = []
const clauses = [`v.fulfillment_status = 'pending'`]
if (issueCategory) {
params.push(issueCategory)
clauses.push(`v.issue_category = $${params.length}`)
}
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(v.internal_sku ILIKE $${params.length}
OR v.sku_code ILIKE $${params.length}
OR v.sku_name ILIKE $${params.length})`,
)
}
const where = clauses.join(' AND ')
const total = await query<{ count: number }>(
`
WITH pending_items AS (
SELECT
v.internal_sku,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
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
v.*,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
WHERE ${where}
),
grouped AS (
SELECT
group_key,
internal_sku,
MIN(sku_name) AS sku_name,
array_agg(DISTINCT provider) AS providers,
array_agg(DISTINCT shop_id) AS shop_ids,
array_agg(DISTINCT sku_code) AS sku_codes,
array_remove(
array_agg(
DISTINCT CASE
WHEN issue_code = 'missing_internal_sku' THEN 'no_internal_sku'
WHEN issue_code = 'missing_channel' THEN 'no_channel_capability'
WHEN issue_code = '' THEN NULL
ELSE 'no_carrier'
END
),
NULL
) AS pending_reasons,
array_remove(array_agg(DISTINCT issue_code), '') AS pending_subreasons,
COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count,
COUNT(*)::int AS item_count,
COALESCE(SUM(revenue_amount_fen), 0)::bigint AS revenue_fen,
MIN(created_at) AS first_seen_at,
MAX(created_at) AS last_seen_at,
COALESCE(MAX(age_minutes), 0)::bigint AS oldest_age_minutes,
COUNT(*) FILTER (WHERE retryable)::int AS retryable_item_count,
COUNT(*) FILTER (WHERE retryable AND NOT fulfillment_locked)::int
AS retryable_unlocked_item_count,
COUNT(*) FILTER (WHERE auto_retryable)::int AS auto_retryable_item_count,
COUNT(*) FILTER (WHERE auto_retryable AND NOT fulfillment_locked)::int
AS auto_retryable_unlocked_item_count,
COUNT(*) FILTER (WHERE issue_category = 'configuration')::int
AS config_issue_item_count,
COUNT(*) FILTER (WHERE issue_category = 'manual_review')::int
AS manual_review_item_count,
COUNT(*) FILTER (WHERE issue_category = 'after_sales')::int
AS after_sales_item_count,
COUNT(*) FILTER (WHERE issue_category = 'system')::int
AS system_issue_item_count
FROM pending_items
GROUP BY group_key, internal_sku
),
issue_counts AS (
SELECT
group_key,
issue_code,
issue_category,
bool_or(retryable) AS retryable,
bool_or(auto_retryable) AS auto_retryable,
MIN(owner_role) AS owner_role,
COUNT(*)::int AS item_count,
COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count
FROM pending_items
GROUP BY group_key, issue_code, issue_category
),
issue_summary AS (
SELECT
group_key,
jsonb_agg(
jsonb_build_object(
'issue_code', issue_code,
'issue_category', issue_category,
'item_count', item_count,
'locked_item_count', locked_item_count,
'retryable', retryable,
'auto_retryable', auto_retryable,
'owner_role', owner_role
)
ORDER BY item_count DESC, issue_code
) AS issue_breakdown
FROM issue_counts
GROUP BY group_key
)
SELECT
grouped.group_key,
CASE
WHEN grouped.internal_sku <> '' THEN 'internal_sku'
ELSE 'source_sku'
END AS scope_type,
grouped.internal_sku,
grouped.sku_name,
grouped.providers,
grouped.shop_ids,
grouped.sku_codes,
grouped.pending_reasons,
grouped.pending_subreasons,
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
grouped.locked_item_count,
grouped.item_count,
grouped.revenue_fen,
grouped.first_seen_at,
grouped.last_seen_at,
grouped.oldest_age_minutes,
grouped.retryable_item_count,
grouped.retryable_unlocked_item_count,
grouped.auto_retryable_item_count,
grouped.auto_retryable_unlocked_item_count,
grouped.config_issue_item_count,
grouped.manual_review_item_count,
grouped.after_sales_item_count,
grouped.system_issue_item_count,
COALESCE(issue_summary.issue_breakdown, '[]'::jsonb) AS issue_breakdown
FROM grouped
LEFT JOIN issue_summary ON issue_summary.group_key = grouped.group_key
LEFT JOIN LATERAL (
SELECT array_agg(DISTINCT route.channel ORDER BY route.channel) AS channels
FROM sku_channel_routes route
JOIN internal_skus sku ON sku.id = route.internal_sku_id
WHERE route.enabled = TRUE
AND sku.internal_sku_key = grouped.internal_sku
) cfg ON TRUE
ORDER BY grouped.item_count DESC, grouped.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
v.provider,
v.shop_id,
v.shop_name,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
WHERE v.fulfillment_status = 'pending'
AND ($2::text = '' OR v.issue_category = $2)
)
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, issueCategory],
)
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
}
/**
* 运营确认后重试整组载体。
*
* 默认只处理未锁定且没有历史终态任务的订单;重开旧任务必须显式传入
* retryInactiveCarrier。锁定项需要先解除锁定,避免一次操作误伤整组。
*/
export async function retryPendingCarrierGroup(input: {
internalSku: string
retryInactiveCarrier?: boolean
includeLocked?: boolean
issueCodes?: string[]
}): Promise<RetryPendingCarrierGroupResult> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
statusCode: 400,
errorCode: 'internal_sku_required',
})
}
const requestedIssueCodes = [
...new Set(
(input.issueCodes || [])
.map((value) => String(value || '').trim())
.filter((value): value is (typeof RETRYABLE_CARRIER_ISSUES)[number] =>
(RETRYABLE_CARRIER_ISSUES as readonly string[]).includes(value),
),
),
]
if ((input.issueCodes || []).length > 0 && requestedIssueCodes.length === 0) {
throw createHttpError('所选问题不允许重试,请刷新后重试', {
statusCode: 409,
errorCode: 'fulfillment_issue_not_retryable',
})
}
const issueCodes =
requestedIssueCodes.length > 0
? requestedIssueCodes
: input.retryInactiveCarrier === true
? [...RETRYABLE_CARRIER_ISSUES]
: [...AUTO_RETRYABLE_CARRIER_ISSUES]
const pending = await query<{ id: number }>(
`SELECT oi.order_item_id AS id
FROM v_fulfillment_item_state oi
WHERE oi.fulfillment_status = 'pending'
AND oi.internal_sku = $1
AND oi.issue_code = ANY($2::text[])
AND ($3::boolean OR NOT oi.fulfillment_locked)
ORDER BY oi.order_item_id`,
[internalSku, issueCodes, input.includeLocked === true],
)
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: input.includeLocked === true,
retryInactiveCarrier: input.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: input.includeLocked === true,
})
assignedItemIds.push(...applied.assignedItemIds)
}
const manualConfigs = await query<{
order_item_id: number
config_id: number
canonical_product_id: number | null
}>(
`SELECT DISTINCT ON (oi.id)
oi.id AS order_item_id,
cfg.id AS config_id,
cfg.canonical_product_id
FROM v_order_item_current oi
JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) =
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
AND cfg.enabled = TRUE
WHERE oi.id = ANY($1::bigint[])
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
ORDER BY oi.id, cfg.id`,
[ids],
)
if (manualConfigs.rows.length > 0) {
await attachFulfillmentConfigurations({
mappings: manualConfigs.rows.map((row) => ({
orderItemId: Number(row.order_item_id),
configId: Number(row.config_id),
...(row.canonical_product_id
? { canonicalProductId: Number(row.canonical_product_id) }
: {}),
})),
expected: { status: 'assigned', channel: 'manual' },
onlyWhenCanonicalProductNull: true,
})
}
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 v_order_item_current
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 async function unlockPendingChannelGroup(input: {
groupKey?: string
internalSku?: string
issueCategory?: string
}): Promise<{ updated: number }> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
throw createHttpError('缺少待分类队列分组标识', {
statusCode: 400,
errorCode: 'pending_group_key_required',
})
}
const params: unknown[] = []
const where = ["state.fulfillment_status = 'pending'", 'state.fulfillment_locked = TRUE']
if (scope.type === 'internal_sku') {
params.push(scope.internalSku)
where.push(`state.internal_sku = $${params.length}`)
} else {
params.push(scope.provider, scope.shopId, scope.skuCode)
where.push(`state.provider = $1 AND state.shop_id = $2 AND state.sku_code = $3`)
}
const issueCategory = String(input.issueCategory || '').trim()
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
params.push(issueCategory)
where.push(`state.issue_category = $${params.length}`)
}
const matched = await query<{ order_item_id: number }>(
`SELECT state.order_item_id
FROM v_fulfillment_item_state state
WHERE ${where.join(' AND ')}
ORDER BY state.order_item_id`,
params,
)
const updated = await transitionFulfillmentStateBatch({
orderItemIds: matched.rows.map((row) => Number(row.order_item_id)),
expected: { status: 'pending', locked: true },
patch: { locked: false },
eventType: 'pending_group_unlocked',
actor: { type: 'operator' },
})
return { updated: updated.length }
}
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
issueCategory?: 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 = ["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 issueCategory = String(input.issueCategory || '').trim()
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
itemParams.push(issueCategory)
itemWhere.push(`EXISTS (
SELECT 1
FROM v_fulfillment_item_state state
WHERE state.order_item_id = oi.id
AND state.issue_category = $${itemParams.length}
)`)
}
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 fulfillments fulfillment ON fulfillment.order_item_id = oi.id
JOIN orders o ON o.id = oi.order_id
WHERE ${itemWhere.join(' AND ')}
ORDER BY oi.id
FOR UPDATE OF fulfillment
`,
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 transitionFulfillmentStateBatchWithExecutor(client, {
orderItemIds: itemIds,
expected: { status: 'pending' },
patch: {
channel: null,
status: 'archived',
source: 'cutover_archive',
locked: true,
reason: '运营从待分类队列删除',
pendingReason: null,
pendingDetail: null,
decidedBy: input.adminUserId,
decidedAt: nowIso(),
},
eventType: 'pending_group_archived',
actor: { type: 'operator', id: input.adminUserId },
})
return {
archivedItems: archived.length,
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
issueCategory?: 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 updateParams: unknown[] = [scope.provider, scope.shopId, scope.skuCode, mappedInternalSku]
const issueCategory = String(input.issueCategory || '').trim()
let issueCategoryFilter = ''
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
updateParams.push(issueCategory)
issueCategoryFilter = `AND EXISTS (
SELECT 1
FROM v_fulfillment_item_state state
WHERE state.order_item_id = oi.id
AND state.issue_category = $${updateParams.length}
)`
}
const updated = await query<{ id: number }>(
`
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 EXISTS (
SELECT 1
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = oi.id
AND fulfillment.status = 'pending'
)
${issueCategoryFilter}
RETURNING oi.id
`,
updateParams,
)
if ((updated.rowCount || 0) === 0) {
throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', {
statusCode: 409,
errorCode: 'pending_group_stale',
})
}
return assignChannelByInternalSku({
internalSku: mappedInternalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
orderItemIds: updated.rows.map((row) => Number(row.id)),
adminUserId: input.adminUserId,
note: input.note,
})
}
return assignChannelByInternalSku({
internalSku: scope.internalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
adminUserId: input.adminUserId,
note: input.note,
})
}
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
export async function assignChannelByInternalSku(input: {
internalSku: string
channel: string
issueCategory?: string
orderItemIds?: number[]
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 issueCategory = String(input.issueCategory || '').trim()
const requestedOrderItemIds = [
...new Set(
(input.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0),
),
]
const idParams = [...requestedOrderItemIds]
const idsResult = await query<{ id: number }>(
`
SELECT state.order_item_id AS id
FROM v_fulfillment_item_state state
WHERE state.internal_sku = $1
AND (
state.fulfillment_status = 'pending'
OR (
state.fulfillment_status = 'assigned'
AND state.stored_channel = $2
)
)
AND ($3::text = '' OR state.issue_category = $3)
AND (
$4::boolean = FALSE
OR state.order_item_id = ANY($5::bigint[])
)
`,
[internalSku, input.channel, issueCategory, requestedOrderItemIds.length > 0, idParams],
)
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 transitionFulfillmentStateBatch({
orderItemIds: ids,
patch: {
decidedBy: input.adminUserId,
decidedAt: nowIso(),
},
eventType: 'operator_channel_decision_recorded',
actor: { type: 'operator', id: input.adminUserId },
reason: input.note || '',
})
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 v_order_item_current 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 route.id,
'internal_sku'::text AS match_key_type,
sku.internal_sku_key AS match_key,
route.channel,
route.priority,
route.enabled,
route.route_role = 'fallback' AS is_fallback,
route.source,
route.carrier_config_json AS config_json,
route.note
FROM sku_channel_routes route
JOIN internal_skus sku ON sku.id = route.internal_sku_id
) 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
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 route.id,
route.source,
sku.internal_sku_key AS match_key,
route.channel
FROM sku_channel_routes route
JOIN internal_skus sku ON sku.id = route.internal_sku_id
WHERE route.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 route.id,
sku.internal_sku_key AS internal_sku,
route.channel,
route.priority,
route.route_role = 'fallback' AS is_fallback,
route.enabled,
route.canonical_product_id,
route.carrier_config_json AS config_json,
route.source,
route.note,
route.created_by_admin_user_id
FROM sku_channel_routes route
JOIN internal_skus sku ON sku.id = route.internal_sku_id
WHERE route.enabled = TRUE AND sku.internal_sku_key = ANY($1::text[])
ORDER BY sku.internal_sku_key, route.priority, route.channel, route.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
}