2443 lines
80 KiB
TypeScript
2443 lines
80 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 {
|
||
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
|
||
}
|