fix(fulfillment): 修复载体重试并完善核销任务列表
This commit is contained in:
@@ -492,10 +492,22 @@ export function buildTaskListFilters({
|
||||
roleId = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
currentOnly = false,
|
||||
}: TaskListQueryInput = {}): { whereClause: string; params: unknown[] } {
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (currentOnly) {
|
||||
filters.push(`ft.id = (
|
||||
SELECT latest.id
|
||||
FROM fulfillment_tasks latest
|
||||
WHERE latest.order_item_id = ft.order_item_id
|
||||
AND latest.unit_index = ft.unit_index
|
||||
ORDER BY latest.id DESC
|
||||
LIMIT 1
|
||||
)`)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
const trimmedKeyword = String(keyword).trim()
|
||||
const isOrderNoLookup = isPlatformOrderIdLookupKeyword(trimmedKeyword)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
retryPendingCarrierGroup,
|
||||
switchChannelForOrderItems,
|
||||
unlockChannel,
|
||||
unlockPendingChannelGroup,
|
||||
upsertChannelRule,
|
||||
} from '../../services/fulfillment/channel-assign-service.js'
|
||||
import {
|
||||
@@ -86,6 +87,9 @@ router.get(
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listVoucherConsumeJobs({
|
||||
keyword: String(req.query.keyword || ''),
|
||||
provider: String(req.query.provider || ''),
|
||||
channel: String(req.query.channel || ''),
|
||||
status: String(req.query.status || ''),
|
||||
page: Number(req.query.page),
|
||||
pageSize: Number(req.query.pageSize),
|
||||
@@ -200,6 +204,8 @@ router.post(
|
||||
(req) =>
|
||||
retryPendingCarrierGroup({
|
||||
internalSku: String(bodyOf(req).internalSku || ''),
|
||||
retryInactiveCarrier: bodyOf(req).retryInactiveCarrier === true,
|
||||
includeLocked: bodyOf(req).includeLocked === true,
|
||||
}),
|
||||
{
|
||||
successMessage: '履约载体重试完成',
|
||||
@@ -215,6 +221,31 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment/pending/unlock',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
(req) => {
|
||||
const body = bodyOf(req)
|
||||
return unlockPendingChannelGroup({
|
||||
groupKey: String(body.groupKey || ''),
|
||||
internalSku: String(body.internalSku || ''),
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '运营锁定已解除',
|
||||
errorMessage: '解除运营锁定失败',
|
||||
scope: '[admin/fulfillment/pending/unlock]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fulfillment.unlock_pending_group',
|
||||
targetType: 'pending_group',
|
||||
targetId: String(bodyOf(req).groupKey || bodyOf(req).internalSku || ''),
|
||||
data: data as Record<string, unknown>,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fulfillment/internal-sku-mappings',
|
||||
requireAdminPermission('finance.view'),
|
||||
|
||||
@@ -348,6 +348,7 @@ export async function getAdminTasks(
|
||||
roleId: String(query.roleId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
currentOnly: String(query.currentOnly ?? 'true') !== 'false',
|
||||
})
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
|
||||
@@ -54,7 +54,11 @@ async function hasAffiliateTask(orderItemId: number): Promise<boolean> {
|
||||
return result.rows[0]?.exists === true
|
||||
}
|
||||
|
||||
const BLOCKED_INACTIVE_CARRIER_STATUSES = new Set(['closed', 'cancelled', 'manual_review'])
|
||||
const BLOCKED_WORK_ORDER_STATUSES = new Set(['closed', 'cancelled', 'manual_review'])
|
||||
const HARD_BLOCKED_INACTIVE_REASONS = new Set([
|
||||
'carrier_task_user_refund',
|
||||
'carrier_task_cancelled',
|
||||
])
|
||||
|
||||
async function findBlockingInactiveCarrier(input: {
|
||||
orderItemId: number
|
||||
@@ -64,16 +68,16 @@ async function findBlockingInactiveCarrier(input: {
|
||||
|
||||
const result =
|
||||
input.channel === 'affiliate_dash'
|
||||
? await query<{ status: string }>(
|
||||
`SELECT task_status AS status
|
||||
? await query<{ status: string; result_code: string }>(
|
||||
`SELECT task_status AS status, result_code
|
||||
FROM fulfillment_tasks
|
||||
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[input.orderItemId],
|
||||
)
|
||||
: await query<{ status: string }>(
|
||||
`SELECT status
|
||||
: await query<{ status: string; result_code: string }>(
|
||||
`SELECT status, '' AS result_code
|
||||
FROM work_orders
|
||||
WHERE order_item_id = $1
|
||||
ORDER BY id DESC
|
||||
@@ -82,7 +86,14 @@ async function findBlockingInactiveCarrier(input: {
|
||||
)
|
||||
|
||||
const status = String(result.rows[0]?.status || '')
|
||||
if (!BLOCKED_INACTIVE_CARRIER_STATUSES.has(status)) return null
|
||||
const resultCode = String(result.rows[0]?.result_code || '')
|
||||
const shouldBlock =
|
||||
input.channel === 'affiliate_dash'
|
||||
? INACTIVE_CARRIER_TASK_STATUSES.includes(
|
||||
status as (typeof INACTIVE_CARRIER_TASK_STATUSES)[number],
|
||||
)
|
||||
: BLOCKED_WORK_ORDER_STATUSES.has(status)
|
||||
if (!shouldBlock) return null
|
||||
|
||||
if (status === 'manual_review') {
|
||||
return {
|
||||
@@ -91,6 +102,34 @@ async function findBlockingInactiveCarrier(input: {
|
||||
message: '已有履约载体进入人工复核,需先处理后再重试',
|
||||
}
|
||||
}
|
||||
if (status === 'retry_pending' || status === 'failed') {
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_retry_pending',
|
||||
message: '已有履约任务失败待重试,需由运营确认后重新创建',
|
||||
}
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_expired',
|
||||
message: '已有履约任务已过期,需由运营确认后重新创建',
|
||||
}
|
||||
}
|
||||
if (resultCode === 'USER_APPLY_REFUND') {
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_user_refund',
|
||||
message: '用户已申请退款,不能自动重新发货',
|
||||
}
|
||||
}
|
||||
if (resultCode === 'affiliate_dash_cancelled') {
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_cancelled',
|
||||
message: '上游履约订单已取消,不能自动重新发货',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_closed',
|
||||
@@ -101,6 +140,14 @@ async function findBlockingInactiveCarrier(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBlockInactiveCarrier(
|
||||
inactiveCarrier: { reason: string } | null,
|
||||
retryInactiveCarrier: boolean,
|
||||
): inactiveCarrier is { status: string; reason: string; message: string } {
|
||||
if (!inactiveCarrier) return false
|
||||
return !retryInactiveCarrier || HARD_BLOCKED_INACTIVE_REASONS.has(inactiveCarrier.reason)
|
||||
}
|
||||
|
||||
function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem {
|
||||
return {
|
||||
...item,
|
||||
@@ -127,15 +174,8 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
item: OrderItemRow
|
||||
channel: FulfillmentChannel
|
||||
retryInactiveCarrier?: boolean
|
||||
allowInactiveTaskRetry?: boolean
|
||||
}): Promise<EnsureCarrierResult> {
|
||||
const {
|
||||
order,
|
||||
item,
|
||||
channel,
|
||||
retryInactiveCarrier = false,
|
||||
allowInactiveTaskRetry = false,
|
||||
} = input
|
||||
const { order, item, channel, retryInactiveCarrier = false } = input
|
||||
const existingCarrier = findConflictingCarrier(
|
||||
await loadActiveCarrierEvidenceForItem(Number(item.id)),
|
||||
channel,
|
||||
@@ -194,7 +234,7 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
orderItemId: Number(item.id),
|
||||
channel,
|
||||
})
|
||||
if (inactiveCarrier && !retryInactiveCarrier) {
|
||||
if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) {
|
||||
return {
|
||||
ready: false,
|
||||
created: false,
|
||||
@@ -274,7 +314,7 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
orderItemId: Number(item.id),
|
||||
channel,
|
||||
})
|
||||
if (inactiveCarrier && !retryInactiveCarrier) {
|
||||
if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) {
|
||||
return {
|
||||
ready: false,
|
||||
created: false,
|
||||
@@ -326,7 +366,7 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
item_snapshot_json: snapshot,
|
||||
} as OrderItemRow,
|
||||
],
|
||||
{ allowInactiveTaskRetry: retryInactiveCarrier || allowInactiveTaskRetry },
|
||||
{ allowInactiveTaskRetry: retryInactiveCarrier },
|
||||
)
|
||||
}
|
||||
const ready = await hasAffiliateTask(Number(item.id))
|
||||
|
||||
@@ -76,7 +76,14 @@ async function loadCandidateItems(input: {
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})
|
||||
)`
|
||||
)
|
||||
AND COALESCE((
|
||||
SELECT ft.result_code
|
||||
FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
ORDER BY ft.id DESC
|
||||
LIMIT 1
|
||||
), '') NOT IN ('USER_APPLY_REFUND', 'affiliate_dash_cancelled')`
|
||||
: `oi.fulfillment_status = 'pending'
|
||||
${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}`
|
||||
|
||||
@@ -150,6 +157,38 @@ async function markPendingWithoutCarrier(input: {
|
||||
)
|
||||
}
|
||||
|
||||
async function markAssignedMissingCarrierPending(input: {
|
||||
itemId: number
|
||||
previousChannel: string
|
||||
internalSku: string
|
||||
attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }>
|
||||
}): Promise<void> {
|
||||
const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed'
|
||||
await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_channel = NULL,
|
||||
fulfillment_status = 'pending',
|
||||
fulfillment_source = 'auto_mapping',
|
||||
fulfillment_locked = FALSE,
|
||||
fulfillment_pending_reason = 'no_carrier',
|
||||
fulfillment_pending_detail = $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'assigned'
|
||||
`,
|
||||
[
|
||||
input.itemId,
|
||||
JSON.stringify({
|
||||
internalSku: input.internalSku,
|
||||
previousChannel: input.previousChannel,
|
||||
carrierIssue,
|
||||
carrierMessage: input.attempts[0]?.message || '',
|
||||
attempts: input.attempts,
|
||||
}),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async function selectSafeFallback(input: {
|
||||
orderItemId: number
|
||||
channel: FulfillmentChannel
|
||||
@@ -299,7 +338,6 @@ export async function reconcilePendingCarriers(
|
||||
item,
|
||||
channel: attempt.channel,
|
||||
retryInactiveCarrier: input.retryInactiveCarrier === true,
|
||||
allowInactiveTaskRetry: true,
|
||||
})
|
||||
if (!carrier.ready) {
|
||||
failures.push({
|
||||
@@ -361,6 +399,13 @@ export async function reconcilePendingCarriers(
|
||||
internalSku: candidate.internal_sku,
|
||||
attempts: failures,
|
||||
})
|
||||
} else {
|
||||
await markAssignedMissingCarrierPending({
|
||||
itemId: Number(item.id),
|
||||
previousChannel: assignedChannel,
|
||||
internalSku: candidate.internal_sku,
|
||||
attempts: failures,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +511,6 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
||||
item,
|
||||
channel: input.channel,
|
||||
retryInactiveCarrier: true,
|
||||
allowInactiveTaskRetry: true,
|
||||
})
|
||||
} catch (error) {
|
||||
carrier = {
|
||||
@@ -1216,9 +1215,16 @@ export type RetryPendingCarrierGroupResult = {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */
|
||||
/**
|
||||
* 运营确认后重试整组载体。
|
||||
*
|
||||
* 默认只处理未锁定且没有历史终态任务的订单;重开旧任务必须显式传入
|
||||
* retryInactiveCarrier。锁定项需要先解除锁定,避免一次操作误伤整组。
|
||||
*/
|
||||
export async function retryPendingCarrierGroup(input: {
|
||||
internalSku: string
|
||||
retryInactiveCarrier?: boolean
|
||||
includeLocked?: boolean
|
||||
}): Promise<RetryPendingCarrierGroupResult> {
|
||||
const internalSku = canonicalizeInternalSku(input.internalSku)
|
||||
if (!internalSku || !containsCjk(internalSku)) {
|
||||
@@ -1247,8 +1253,8 @@ export async function retryPendingCarrierGroup(input: {
|
||||
const carrier = await reconcilePendingCarriers({
|
||||
orderItemIds: batchIds,
|
||||
limit: batchIds.length,
|
||||
includeLocked: true,
|
||||
retryInactiveCarrier: true,
|
||||
includeLocked: input.includeLocked === true,
|
||||
retryInactiveCarrier: input.retryInactiveCarrier === true,
|
||||
})
|
||||
workOrdersCreated += carrier.workOrdersCreated
|
||||
affiliateTasksCreated += carrier.affiliateTasksCreated
|
||||
@@ -1258,7 +1264,7 @@ export async function retryPendingCarrierGroup(input: {
|
||||
batchSize: batchIds.length,
|
||||
maxBatches: 1,
|
||||
afterId: 0,
|
||||
includeLocked: true,
|
||||
includeLocked: input.includeLocked === true,
|
||||
})
|
||||
assignedItemIds.push(...applied.assignedItemIds)
|
||||
}
|
||||
@@ -1303,6 +1309,40 @@ export async function retryPendingCarrierGroup(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 解除一个待分类分组中的运营锁定,让自动路由或后续修复重新接管。 */
|
||||
export async function unlockPendingChannelGroup(input: {
|
||||
groupKey?: string
|
||||
internalSku?: string
|
||||
}): Promise<{ updated: number }> {
|
||||
const scope = resolvePendingGroupScopeInput(input)
|
||||
if (!scope) {
|
||||
throw createHttpError('缺少待分类队列分组标识', {
|
||||
statusCode: 400,
|
||||
errorCode: 'pending_group_key_required',
|
||||
})
|
||||
}
|
||||
|
||||
const params: unknown[] = []
|
||||
const where = ["oi.fulfillment_status = 'pending'", 'oi.fulfillment_locked = TRUE']
|
||||
if (scope.type === 'internal_sku') {
|
||||
params.push(scope.internalSku)
|
||||
where.push(`${INTERNAL_SKU_SQL} = $${params.length}`)
|
||||
} else {
|
||||
params.push(scope.provider, scope.shopId, scope.skuCode)
|
||||
where.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
|
||||
}
|
||||
const result = await query(
|
||||
`UPDATE order_items oi
|
||||
SET fulfillment_locked = FALSE,
|
||||
updated_at = NOW()
|
||||
FROM orders o
|
||||
WHERE o.id = oi.order_id
|
||||
AND ${where.join(' AND ')}`,
|
||||
params,
|
||||
)
|
||||
return { updated: result.rowCount || 0 }
|
||||
}
|
||||
|
||||
export type DeletePendingChannelGroupResult = {
|
||||
archivedItems: number
|
||||
preservedOrders: number
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const carrierCreation = readFileSync(
|
||||
new URL('./carrier-creation-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const carrierReconciliation = readFileSync(
|
||||
new URL('./carrier-reconciliation-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8')
|
||||
const taskRepo = readFileSync(new URL('../../repositories/task-repo.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('自动发货历史非活跃任务默认阻止后台自动重开', () => {
|
||||
assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes'))
|
||||
assert.ok(carrierCreation.includes("reason: 'carrier_task_retry_pending'"))
|
||||
assert.ok(carrierCreation.includes('HARD_BLOCKED_INACTIVE_REASONS'))
|
||||
assert.ok(carrierCreation.includes("'carrier_task_user_refund'"))
|
||||
assert.ok(!carrierReconciliation.includes('allowInactiveTaskRetry: true'))
|
||||
})
|
||||
|
||||
test('已分配但载体失效的订单会退回 pending/no_carrier', () => {
|
||||
assert.ok(carrierReconciliation.includes('markAssignedMissingCarrierPending'))
|
||||
assert.ok(carrierReconciliation.includes("fulfillment_status = 'pending'"))
|
||||
assert.ok(carrierReconciliation.includes("fulfillment_pending_reason = 'no_carrier'"))
|
||||
assert.ok(carrierReconciliation.includes("'USER_APPLY_REFUND', 'affiliate_dash_cancelled'"))
|
||||
})
|
||||
|
||||
test('整组补载体默认不包含锁定项,也不强制重开历史任务', () => {
|
||||
assert.ok(channelAssign.includes('retryInactiveCarrier?: boolean'))
|
||||
assert.ok(channelAssign.includes('includeLocked?: boolean'))
|
||||
assert.ok(channelAssign.includes('retryInactiveCarrier: input.retryInactiveCarrier === true'))
|
||||
assert.ok(channelAssign.includes('includeLocked: input.includeLocked === true'))
|
||||
assert.ok(channelAssign.includes('unlockPendingChannelGroup'))
|
||||
})
|
||||
|
||||
test('任务列表默认只展示每个订单项单元的最新尝试', () => {
|
||||
assert.ok(taskRepo.includes('currentOnly = false'))
|
||||
assert.ok(taskRepo.includes('SELECT latest.id'))
|
||||
assert.ok(taskRepo.includes('latest.unit_index = ft.unit_index'))
|
||||
})
|
||||
@@ -29,11 +29,13 @@ test(
|
||||
listPendingChannelGroups,
|
||||
retryPendingCarrierGroup,
|
||||
switchChannelForOrderItems,
|
||||
unlockPendingChannelGroup,
|
||||
upsertChannelRule,
|
||||
} = await import('./channel-assign-service.js')
|
||||
const { deleteManualSkuClassification, saveManualFulfillmentConfig } =
|
||||
await import('../cost-center/manual-fulfillment-config-service.js')
|
||||
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
||||
const { reconcilePendingCarriers } = await import('./carrier-reconciliation-service.js')
|
||||
const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js')
|
||||
const {
|
||||
deleteInternalSkuMapping,
|
||||
@@ -43,6 +45,8 @@ test(
|
||||
} = await import('./internal-sku-mapping-service.js')
|
||||
const { resolveOrderItemForFulfillment } = await import('./product-resolution-service.js')
|
||||
const { updateOrderItemSourceSnapshot } = await import('../../repositories/order-item-repo.js')
|
||||
const { listTasks } = await import('../../repositories/task-repo.js')
|
||||
const { listVoucherConsumeJobs } = await import('./voucher-consume-queue-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const shopId = `it-routing-${suffix}`
|
||||
const manualSku = `集成测试人工-${suffix}`
|
||||
@@ -56,6 +60,7 @@ test(
|
||||
const protectedRuleSku = `集成测试强制删除-${suffix}`
|
||||
const missingVoucherSku = `集成测试无商品卡券-${suffix}`
|
||||
const reactivatedVoucherSku = `集成测试归档卡券-${suffix}`
|
||||
const retryPendingAffiliateSku = `集成测试失败待重试-${suffix}`
|
||||
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
@@ -73,6 +78,7 @@ test(
|
||||
protectedRuleSku,
|
||||
missingVoucherSku,
|
||||
reactivatedVoucherSku,
|
||||
retryPendingAffiliateSku,
|
||||
]
|
||||
const createdVoucherProductIds: number[] = []
|
||||
let autoManualProductId: number | null = null
|
||||
@@ -383,6 +389,7 @@ test(
|
||||
)
|
||||
const retriedCarrier = await retryPendingCarrierGroup({
|
||||
internalSku: retryWorkOrderSku,
|
||||
retryInactiveCarrier: true,
|
||||
})
|
||||
assert.equal(retriedCarrier.scanned, 1)
|
||||
assert.equal(retriedCarrier.assigned, 1)
|
||||
@@ -645,6 +652,21 @@ test(
|
||||
retryTaskRows.rows.map((row) => row.task_status),
|
||||
['closed', 'paid'],
|
||||
)
|
||||
const currentTasksOnly = await listTasks({
|
||||
platformOrderId: retryTaskOrderRow.platform_order_id,
|
||||
currentOnly: true,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
assert.equal(currentTasksOnly.total, 1)
|
||||
assert.equal(currentTasksOnly.items[0]?.task_status, 'paid')
|
||||
const allTaskAttempts = await listTasks({
|
||||
platformOrderId: retryTaskOrderRow.platform_order_id,
|
||||
currentOnly: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
assert.equal(allTaskAttempts.total, 2)
|
||||
|
||||
assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false)
|
||||
const missingVoucherRules = await query<{ count: number }>(
|
||||
@@ -772,6 +794,188 @@ test(
|
||||
assert.equal(reactivated.rows[0]?.is_fallback, false)
|
||||
assert.equal(reactivated.rows[0]?.attributes_json.deletedAt, undefined)
|
||||
assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string')
|
||||
|
||||
const retryPendingItemId = await createPendingOrderItem(retryPendingAffiliateSku)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, config_json, source
|
||||
) VALUES (
|
||||
$1, 'affiliate_dash', 10, FALSE, TRUE,
|
||||
jsonb_build_object('affiliateSku', $1::text), 'integration_test'
|
||||
)`,
|
||||
[retryPendingAffiliateSku],
|
||||
)
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'assigned',
|
||||
fulfillment_channel = 'affiliate_dash',
|
||||
fulfillment_source = 'carrier_evidence',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO fulfillment_tasks (
|
||||
order_id, order_item_id, unit_index, task_no, provider, platform,
|
||||
shop_id, shop_name, platform_order_id, profile_id, executor_key,
|
||||
task_status, created_at, updated_at
|
||||
)
|
||||
SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform,
|
||||
o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash',
|
||||
'retry_pending', NOW(), NOW()
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN LATERAL (
|
||||
SELECT id FROM fulfillment_profiles
|
||||
WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash'
|
||||
ORDER BY id LIMIT 1
|
||||
) profile ON TRUE
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId, `IT-RETRY-PENDING-${suffix}`],
|
||||
)
|
||||
const blockedRetry = await reconcilePendingCarriers({
|
||||
orderItemIds: [retryPendingItemId],
|
||||
includeAssignedMissingCarrier: true,
|
||||
limit: 1,
|
||||
})
|
||||
assert.equal(blockedRetry.affiliateTasksCreated, 0)
|
||||
const retryPendingState = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string | null
|
||||
fulfillment_pending_reason: string | null
|
||||
fulfillment_pending_detail: Record<string, unknown>
|
||||
task_count: number
|
||||
}>(
|
||||
`SELECT
|
||||
oi.fulfillment_status,
|
||||
oi.fulfillment_channel,
|
||||
oi.fulfillment_pending_reason,
|
||||
oi.fulfillment_pending_detail,
|
||||
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
|
||||
FROM order_items oi
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.equal(retryPendingState.rows[0]?.fulfillment_status, 'pending')
|
||||
assert.equal(retryPendingState.rows[0]?.fulfillment_channel, null)
|
||||
assert.equal(retryPendingState.rows[0]?.fulfillment_pending_reason, 'no_carrier')
|
||||
assert.equal(
|
||||
retryPendingState.rows[0]?.fulfillment_pending_detail.carrierIssue,
|
||||
'carrier_task_retry_pending',
|
||||
)
|
||||
assert.equal(retryPendingState.rows[0]?.task_count, 1)
|
||||
await query(
|
||||
`UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
const unlockedPendingGroup = await unlockPendingChannelGroup({
|
||||
internalSku: retryPendingAffiliateSku,
|
||||
})
|
||||
assert.equal(unlockedPendingGroup.updated, 1)
|
||||
const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>(
|
||||
`SELECT fulfillment_locked FROM order_items WHERE id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false)
|
||||
|
||||
await query(
|
||||
`UPDATE fulfillment_tasks
|
||||
SET task_status = 'closed',
|
||||
result_code = 'USER_APPLY_REFUND',
|
||||
updated_at = NOW()
|
||||
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'pending',
|
||||
fulfillment_channel = NULL,
|
||||
fulfillment_source = 'auto_mapping',
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
const protectedRetry = await retryPendingCarrierGroup({
|
||||
internalSku: retryPendingAffiliateSku,
|
||||
retryInactiveCarrier: true,
|
||||
})
|
||||
assert.equal(protectedRetry.affiliateTasksCreated, 0)
|
||||
const protectedRetryState = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_pending_detail: Record<string, unknown>
|
||||
task_count: number
|
||||
}>(
|
||||
`SELECT
|
||||
oi.fulfillment_status,
|
||||
oi.fulfillment_pending_detail,
|
||||
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
|
||||
FROM order_items oi
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.equal(protectedRetryState.rows[0]?.fulfillment_status, 'pending')
|
||||
assert.equal(
|
||||
protectedRetryState.rows[0]?.fulfillment_pending_detail.carrierIssue,
|
||||
'carrier_task_user_refund',
|
||||
)
|
||||
assert.equal(protectedRetryState.rows[0]?.task_count, 1)
|
||||
|
||||
const voucherJobPrefix = `IT-VOUCHER-LIST-${suffix}`
|
||||
const voucherJobs = await query<{ id: number }>(
|
||||
`INSERT INTO voucher_consume_jobs (
|
||||
provider, channel, trigger_event, target_key, platform_order_id,
|
||||
due_at, status, attempt_count, max_attempts, updated_at
|
||||
) VALUES
|
||||
('kuaishou_industry', 'affiliate_dash', 'order_received', $1, $2, NOW(), 'succeeded', 1, 8, NOW()),
|
||||
('kuaishou_industry', 'manual', 'order_received', $3, $4, NOW(), 'failed', 2, 8, NOW()),
|
||||
('douyin_mashida', 'manual', 'order_received', $5, $6, NOW(), 'failed', 1, 8, NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
`${voucherJobPrefix}-KS-AFF`,
|
||||
`${voucherJobPrefix}-KS-AFF-ORDER`,
|
||||
`${voucherJobPrefix}-KS-MANUAL`,
|
||||
`${voucherJobPrefix}-KS-MANUAL-ORDER`,
|
||||
`${voucherJobPrefix}-DY-MANUAL`,
|
||||
`${voucherJobPrefix}-DY-MANUAL-ORDER`,
|
||||
],
|
||||
)
|
||||
const firstVoucherJobPage = await listVoucherConsumeJobs({
|
||||
keyword: voucherJobPrefix,
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
})
|
||||
assert.equal(firstVoucherJobPage.total, 3)
|
||||
assert.equal(firstVoucherJobPage.items.length, 2)
|
||||
assert.equal(firstVoucherJobPage.page, 1)
|
||||
assert.equal(firstVoucherJobPage.pageSize, 2)
|
||||
const secondVoucherJobPage = await listVoucherConsumeJobs({
|
||||
keyword: voucherJobPrefix,
|
||||
page: 2,
|
||||
pageSize: 2,
|
||||
})
|
||||
assert.equal(secondVoucherJobPage.total, 3)
|
||||
assert.equal(secondVoucherJobPage.items.length, 1)
|
||||
const filteredVoucherJobs = await listVoucherConsumeJobs({
|
||||
keyword: voucherJobPrefix,
|
||||
provider: 'kuaishou_industry',
|
||||
channel: 'manual',
|
||||
status: 'failed',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
assert.equal(filteredVoucherJobs.total, 1)
|
||||
assert.equal(filteredVoucherJobs.items[0]?.provider, 'kuaishou_industry')
|
||||
assert.equal(filteredVoucherJobs.items[0]?.channel, 'manual')
|
||||
assert.equal(filteredVoucherJobs.items[0]?.status, 'failed')
|
||||
const searchedVoucherJob = await listVoucherConsumeJobs({
|
||||
keyword: String(voucherJobs.rows[1]?.id || ''),
|
||||
status: 'failed',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
assert.ok(searchedVoucherJob.items.some((job) => job.id === voucherJobs.rows[1]?.id))
|
||||
} finally {
|
||||
if (orderItemIds.length > 0) {
|
||||
await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [
|
||||
@@ -795,6 +999,9 @@ test(
|
||||
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
|
||||
}
|
||||
await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`])
|
||||
await query(`DELETE FROM voucher_consume_jobs WHERE platform_order_id LIKE $1`, [
|
||||
`IT-VOUCHER-LIST-${suffix}%`,
|
||||
])
|
||||
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [
|
||||
internalSkus,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./voucher-consume-queue-service.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => {
|
||||
assert.ok(source.includes('keyword?: string'))
|
||||
assert.ok(source.includes('provider?: string'))
|
||||
assert.ok(source.includes('channel?: string'))
|
||||
assert.ok(source.includes('status?: string'))
|
||||
assert.ok(source.includes('provider = $'))
|
||||
assert.ok(source.includes('channel = $'))
|
||||
assert.ok(source.includes('status = $'))
|
||||
assert.ok(source.includes('platform_order_id ILIKE'))
|
||||
assert.ok(source.includes('target_key ILIKE'))
|
||||
})
|
||||
|
||||
test('核销任务列表使用服务端总数和分页参数', () => {
|
||||
assert.ok(source.includes('SELECT COUNT(*) AS total FROM voucher_consume_jobs'))
|
||||
assert.ok(source.includes('LIMIT $'))
|
||||
assert.ok(source.includes('OFFSET $'))
|
||||
assert.ok(source.includes('ORDER BY updated_at DESC, id DESC'))
|
||||
})
|
||||
@@ -22,6 +22,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus
|
||||
import { verifyCertificate } from '../redeem/certificate-service.js'
|
||||
import { logIntegration, logWarn } from '../../utils/logger.js'
|
||||
import {
|
||||
VOUCHER_CONSUME_CHANNELS,
|
||||
VOUCHER_CONSUME_PROVIDERS,
|
||||
getVoucherConsumePolicy,
|
||||
resolveVoucherConsumePolicy,
|
||||
type VoucherConsumeChannel,
|
||||
@@ -99,6 +101,13 @@ type VoucherOrderItemTargetRow = {
|
||||
|
||||
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
|
||||
const PROCESSING_LOCK_SECONDS = 120
|
||||
const VOUCHER_CONSUME_JOB_STATUSES = [
|
||||
'pending',
|
||||
'processing',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
] as const
|
||||
|
||||
export async function scheduleVoucherConsumeForTask(
|
||||
task: TaskRow,
|
||||
@@ -443,6 +452,9 @@ export async function runVoucherConsumeDispatchJob(
|
||||
|
||||
export async function listVoucherConsumeJobs(
|
||||
input: {
|
||||
keyword?: string
|
||||
provider?: string
|
||||
channel?: string
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -450,10 +462,43 @@ export async function listVoucherConsumeJobs(
|
||||
) {
|
||||
const page = clampInteger(input.page, 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const pageSize = clampInteger(input.pageSize, 20, 1, 100)
|
||||
const status = String(input.status || '').trim()
|
||||
const keyword = String(input.keyword || '')
|
||||
.trim()
|
||||
.slice(0, 100)
|
||||
const providerInput = String(input.provider || '').trim()
|
||||
const provider = (VOUCHER_CONSUME_PROVIDERS as readonly string[]).includes(providerInput)
|
||||
? providerInput
|
||||
: ''
|
||||
const channel = (VOUCHER_CONSUME_CHANNELS as readonly string[]).includes(
|
||||
String(input.channel || '').trim(),
|
||||
)
|
||||
? String(input.channel).trim()
|
||||
: ''
|
||||
const status = (VOUCHER_CONSUME_JOB_STATUSES as readonly string[]).includes(
|
||||
String(input.status || '').trim(),
|
||||
)
|
||||
? String(input.status).trim()
|
||||
: ''
|
||||
const params: unknown[] = []
|
||||
const where: string[] = []
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
where.push(
|
||||
`(CAST(id AS TEXT) ILIKE $${params.length}
|
||||
OR platform_order_id ILIKE $${params.length}
|
||||
OR target_key ILIKE $${params.length}
|
||||
OR CAST(order_id AS TEXT) ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
where.push(`provider = $${params.length}`)
|
||||
}
|
||||
if (channel) {
|
||||
params.push(channel)
|
||||
where.push(`channel = $${params.length}`)
|
||||
}
|
||||
if (status) {
|
||||
params.push(status)
|
||||
where.push(`status = $${params.length}`)
|
||||
|
||||
@@ -32,6 +32,7 @@ export type AdminTaskListQueryInput = {
|
||||
roleId?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
currentOnly?: boolean | string
|
||||
}
|
||||
|
||||
export type AdminEntityIdInput = number | string
|
||||
|
||||
@@ -62,6 +62,7 @@ export type TaskListQueryInput = {
|
||||
roleId?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
currentOnly?: boolean
|
||||
}
|
||||
|
||||
export type TaskRuntimeContextPatch = {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
retryPendingCarriers,
|
||||
retryPendingWorkOrderRouting,
|
||||
runFinancePipeline,
|
||||
unlockPendingChannelGroup,
|
||||
type FinancePipelineStatus,
|
||||
type InternalSkuMapping,
|
||||
type InternalSkuMappingMatchType,
|
||||
@@ -97,6 +99,24 @@ const ENABLED_FILTER_OPTIONS = [
|
||||
{ value: 'false', label: '停用' },
|
||||
]
|
||||
|
||||
const PENDING_CONFIG_REASONS = new Set([
|
||||
'manual_config_missing',
|
||||
'work_rule_missing',
|
||||
'affiliate_mapping_missing',
|
||||
'carrier_config_ambiguous',
|
||||
])
|
||||
|
||||
const PENDING_RETRYABLE_TERMINAL_REASONS = new Set([
|
||||
'carrier_task_closed',
|
||||
'carrier_task_retry_pending',
|
||||
'carrier_task_expired',
|
||||
])
|
||||
|
||||
const PENDING_NON_RETRYABLE_TERMINAL_REASONS = new Set([
|
||||
'carrier_task_user_refund',
|
||||
'carrier_task_cancelled',
|
||||
])
|
||||
|
||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||
|
||||
const INTERNAL_SKU_MAPPING_TYPES: Array<{
|
||||
@@ -131,6 +151,7 @@ export default function AdminFulfillmentChannelPage() {
|
||||
|
||||
function PendingPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
@@ -195,8 +216,11 @@ function PendingPanel() {
|
||||
})
|
||||
|
||||
const retryCarrierMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
retryPendingCarriers({ internalSku: row.internal_sku || '' }),
|
||||
mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) =>
|
||||
retryPendingCarriers({
|
||||
internalSku: input.row.internal_sku || '',
|
||||
retryInactiveCarrier: input.retryInactiveCarrier,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
const detail = [
|
||||
@@ -216,6 +240,19 @@ function PendingPanel() {
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'),
|
||||
})
|
||||
|
||||
const unlockMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
unlockPendingChannelGroup({
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '解除运营锁定失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
deletePendingChannelGroup({ groupKey: row.group_key }),
|
||||
@@ -326,6 +363,10 @@ function PendingPanel() {
|
||||
carrier_task_manual_review: '载体人工复核',
|
||||
carrier_not_attempted: '尚未尝试补载体',
|
||||
carrier_attempt_failed: '载体创建失败',
|
||||
carrier_task_retry_pending: '载体失败待确认重试',
|
||||
carrier_task_expired: '载体已过期',
|
||||
carrier_task_user_refund: '用户已申请退款',
|
||||
carrier_task_cancelled: '上游履约已取消',
|
||||
manual_config_missing: '缺人工配置',
|
||||
work_rule_missing: '缺接单模板',
|
||||
affiliate_mapping_missing: '缺自动发货映射',
|
||||
@@ -345,7 +386,9 @@ function PendingPanel() {
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{row.locked_item_count > 0 ? (
|
||||
<Typography.Text type="warning">运营锁定 {row.locked_item_count} 项</Typography.Text>
|
||||
<Typography.Text type="warning">
|
||||
已人工指定渠道,等待补配置 {row.locked_item_count} 项
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
@@ -367,71 +410,146 @@ function PendingPanel() {
|
||||
{
|
||||
title: '指定渠道',
|
||||
width: 440,
|
||||
render: (_: unknown, row: PendingChannelGroup) => (
|
||||
<Space size={4}>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder={
|
||||
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
|
||||
}
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
保存渠道
|
||||
</Button>
|
||||
{row.configured_channels?.length ? (
|
||||
<Tooltip title="重新确认渠道并补建工单或自动发货任务">
|
||||
render: (_: unknown, row: PendingChannelGroup) => {
|
||||
const subreasons = row.pending_subreasons || []
|
||||
const hasConfigIssue = subreasons.some((reason) => PENDING_CONFIG_REASONS.has(reason))
|
||||
const hasManualReview = subreasons.includes('carrier_task_manual_review')
|
||||
const hasRetryableTerminal = subreasons.some((reason) =>
|
||||
PENDING_RETRYABLE_TERMINAL_REASONS.has(reason),
|
||||
)
|
||||
const hasNonRetryableTerminal = subreasons.some((reason) =>
|
||||
PENDING_NON_RETRYABLE_TERMINAL_REASONS.has(reason),
|
||||
)
|
||||
const hasCarrierIssue = row.pending_reasons.includes('no_carrier')
|
||||
const hasUnlockedItems = row.locked_item_count < row.item_count
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder={
|
||||
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
|
||||
}
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
保存渠道
|
||||
</Button>
|
||||
{hasConfigIssue ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
|
||||
去补配置
|
||||
</Button>
|
||||
) : null}
|
||||
{hasManualReview ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/tasks')}>
|
||||
处理人工复核
|
||||
</Button>
|
||||
) : null}
|
||||
{hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
danger
|
||||
loading={retryCarrierMutation.isPending}
|
||||
onClick={() => retryCarrierMutation.mutate(row)}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '确认重开履约载体',
|
||||
content:
|
||||
'只会处理该 SKU 下未锁定、没有活跃载体的订单项。已有失败或关闭任务会保留为历史,并重新向上游创建一次履约尝试。',
|
||||
okText: '确认重试',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
retryCarrierMutation.mutateAsync({
|
||||
row,
|
||||
retryInactiveCarrier: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
补载体
|
||||
确认重试
|
||||
</Button>
|
||||
) : null}
|
||||
{!hasConfigIssue &&
|
||||
!hasManualReview &&
|
||||
!hasRetryableTerminal &&
|
||||
!hasNonRetryableTerminal &&
|
||||
hasCarrierIssue &&
|
||||
row.configured_channels?.length &&
|
||||
hasUnlockedItems ? (
|
||||
<Tooltip title="只补建当前无活跃载体的未锁定订单,不会重开失败或关闭的旧任务">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={retryCarrierMutation.isPending}
|
||||
onClick={() =>
|
||||
retryCarrierMutation.mutate({
|
||||
row,
|
||||
retryInactiveCarrier: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
补载体
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{row.locked_item_count > 0 ? (
|
||||
<Button
|
||||
size="small"
|
||||
loading={unlockMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '解除运营锁定',
|
||||
content: `将解除该分组中 ${row.locked_item_count} 个订单项的运营锁定。若渠道配置仍缺失,订单会继续留在待分类队列。`,
|
||||
okText: '解除锁定',
|
||||
cancelText: '取消',
|
||||
onOk: () => unlockMutation.mutateAsync(row),
|
||||
})
|
||||
}
|
||||
>
|
||||
解除锁定
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip title="从待办中忽略并归档,不删除原始订单">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '忽略并归档待分类记录',
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
|
||||
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认归档',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(row),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="从待办中忽略并归档,不删除原始订单">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '忽略并归档待分类记录',
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
|
||||
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认归档',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(row),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ type TaskFilterForm = {
|
||||
executorKey?: string
|
||||
status?: string
|
||||
dateRange?: unknown
|
||||
scope?: 'current' | 'all'
|
||||
}
|
||||
|
||||
type TaskListAction = 'retry' | 'manual_review' | 'close'
|
||||
@@ -150,15 +151,26 @@ export default function AdminTasksPage() {
|
||||
status: searchParams.get('status') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
dateTo: searchParams.get('dateTo') || '',
|
||||
scope: (searchParams.get('scope') === 'all' ? 'all' : 'current') as 'all' | 'current',
|
||||
}
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
() => ({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: filters.keyword,
|
||||
executorKey: filters.executorKey,
|
||||
status: filters.status,
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
currentOnly: filters.scope !== 'all',
|
||||
}),
|
||||
[
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.executorKey,
|
||||
filters.keyword,
|
||||
filters.scope,
|
||||
filters.status,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -175,12 +187,21 @@ export default function AdminTasksPage() {
|
||||
keyword: filters.keyword || undefined,
|
||||
executorKey: filters.executorKey || undefined,
|
||||
status: filters.status || undefined,
|
||||
scope: filters.scope,
|
||||
dateRange:
|
||||
filters.dateFrom && filters.dateTo
|
||||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||
: undefined,
|
||||
})
|
||||
}, [filters.dateFrom, filters.dateTo, filters.executorKey, filters.keyword, filters.status, form])
|
||||
}, [
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.executorKey,
|
||||
filters.keyword,
|
||||
filters.scope,
|
||||
filters.status,
|
||||
form,
|
||||
])
|
||||
|
||||
const columns: TableColumnsType<AdminTaskListItem> = [
|
||||
{
|
||||
@@ -507,6 +528,7 @@ export default function AdminTasksPage() {
|
||||
keyword: undefined,
|
||||
executorKey: undefined,
|
||||
status: undefined,
|
||||
scope: 'current',
|
||||
dateRange: undefined,
|
||||
})
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
@@ -548,6 +570,16 @@ export default function AdminTasksPage() {
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="scope">
|
||||
<Select
|
||||
placeholder="任务范围"
|
||||
options={[
|
||||
{ value: 'current', label: '当前任务' },
|
||||
{ value: 'all', label: '全部历史尝试' },
|
||||
]}
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange">
|
||||
<AdminRangePicker />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type VoucherConsumePolicy,
|
||||
type VoucherConsumeProvider,
|
||||
} from '@/services/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const PROVIDER_LABEL: Record<VoucherConsumeProvider, string> = {
|
||||
@@ -90,16 +92,63 @@ const JOB_STATUS: Record<string, { color: string; label: string }> = {
|
||||
cancelled: { color: 'default', label: '已取消' },
|
||||
}
|
||||
|
||||
const PROVIDER_FILTER_OPTIONS: Array<{ value: VoucherConsumeProvider; label: string }> = [
|
||||
{ value: 'kuaishou_industry', label: PROVIDER_LABEL.kuaishou_industry },
|
||||
{ value: 'douyin_mashida', label: PROVIDER_LABEL.douyin_mashida },
|
||||
]
|
||||
|
||||
const CHANNEL_FILTER_OPTIONS: Array<{ value: VoucherConsumeChannel; label: string }> = [
|
||||
{ value: 'affiliate_dash', label: CHANNEL_LABEL.affiliate_dash },
|
||||
{ value: 'work_order', label: CHANNEL_LABEL.work_order },
|
||||
{ value: 'manual', label: CHANNEL_LABEL.manual },
|
||||
{ value: 'third_party_voucher', label: CHANNEL_LABEL.third_party_voucher },
|
||||
{ value: 'on_account', label: CHANNEL_LABEL.on_account },
|
||||
]
|
||||
|
||||
const JOB_STATUS_FILTER_OPTIONS: Array<{
|
||||
value: VoucherConsumeJob['status']
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'pending', label: JOB_STATUS.pending!.label },
|
||||
{ value: 'processing', label: JOB_STATUS.processing!.label },
|
||||
{ value: 'succeeded', label: JOB_STATUS.succeeded!.label },
|
||||
{ value: 'failed', label: JOB_STATUS.failed!.label },
|
||||
{ value: 'cancelled', label: JOB_STATUS.cancelled!.label },
|
||||
]
|
||||
|
||||
export default function VoucherConsumePolicyPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<VoucherConsumePolicy | null>(null)
|
||||
const [keywordDraft, setKeywordDraft] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [providerFilter, setProviderFilter] = useState<VoucherConsumeProvider | ''>('')
|
||||
const [channelFilter, setChannelFilter] = useState<VoucherConsumeChannel | ''>('')
|
||||
const [statusFilter, setStatusFilter] = useState<VoucherConsumeJob['status'] | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const policyQuery = useQuery({
|
||||
queryKey: ['voucher-consume-policy'],
|
||||
queryFn: fetchVoucherConsumePolicy,
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: ['voucher-consume-jobs'],
|
||||
queryFn: () => fetchVoucherConsumeJobs({ page: 1, pageSize: 20 }),
|
||||
queryKey: [
|
||||
'voucher-consume-jobs',
|
||||
keyword,
|
||||
providerFilter,
|
||||
channelFilter,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
queryFn: () =>
|
||||
fetchVoucherConsumeJobs({
|
||||
keyword: keyword || undefined,
|
||||
provider: providerFilter || undefined,
|
||||
channel: channelFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
@@ -134,6 +183,16 @@ export default function VoucherConsumePolicyPanel() {
|
||||
})
|
||||
|
||||
const rows = useMemo(() => buildPolicyRows(draft), [draft])
|
||||
const hasJobFilters = Boolean(keyword || providerFilter || channelFilter || statusFilter)
|
||||
|
||||
function resetJobFilters() {
|
||||
setKeywordDraft('')
|
||||
setKeyword('')
|
||||
setProviderFilter('')
|
||||
setChannelFilter('')
|
||||
setStatusFilter('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function updateChannel(
|
||||
provider: VoucherConsumeProvider,
|
||||
@@ -348,25 +407,92 @@ export default function VoucherConsumePolicyPanel() {
|
||||
loading={policyQuery.isLoading}
|
||||
/>
|
||||
|
||||
<Space className="full-width" style={{ justifyContent: 'space-between' }}>
|
||||
<Space className="full-width" style={{ justifyContent: 'space-between' }} wrap>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
最近核销任务
|
||||
</Typography.Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="任务号 / 订单号 / 平台单号"
|
||||
value={keywordDraft}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setKeywordDraft(value)
|
||||
if (!value) {
|
||||
setKeyword('')
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
onSearch={(value) => {
|
||||
setKeyword(String(value || '').trim())
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部平台"
|
||||
value={providerFilter || undefined}
|
||||
options={PROVIDER_FILTER_OPTIONS}
|
||||
onChange={(value: VoucherConsumeProvider | undefined) => {
|
||||
setProviderFilter(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部渠道"
|
||||
value={channelFilter || undefined}
|
||||
options={CHANNEL_FILTER_OPTIONS}
|
||||
onChange={(value: VoucherConsumeChannel | undefined) => {
|
||||
setChannelFilter(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部状态"
|
||||
value={statusFilter || undefined}
|
||||
options={JOB_STATUS_FILTER_OPTIONS}
|
||||
onChange={(value: VoucherConsumeJob['status'] | undefined) => {
|
||||
setStatusFilter(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Button disabled={!hasJobFilters} onClick={resetJobFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })
|
||||
}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={jobColumns}
|
||||
dataSource={jobsQuery.data?.data.items || []}
|
||||
loading={jobsQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
loading={jobsQuery.isFetching}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: jobsQuery.data?.data.page || page,
|
||||
pageSize: jobsQuery.data?.data.pageSize || pageSize,
|
||||
total: jobsQuery.data?.data.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -222,7 +222,14 @@ export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) {
|
||||
}
|
||||
|
||||
export function fetchVoucherConsumeJobs(
|
||||
params: { status?: string; page?: number; pageSize?: number } = {},
|
||||
params: {
|
||||
keyword?: string
|
||||
provider?: VoucherConsumeProvider
|
||||
channel?: VoucherConsumeChannel
|
||||
status?: VoucherConsumeJob['status']
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<VoucherConsumeJobListResult>(
|
||||
'/api/v1/admin/fulfillment/voucher-consume-jobs',
|
||||
@@ -257,13 +264,21 @@ export function retryPendingWorkOrderRouting(
|
||||
)
|
||||
}
|
||||
|
||||
export function retryPendingCarriers(payload: { internalSku: string }) {
|
||||
export function retryPendingCarriers(payload: {
|
||||
internalSku: string
|
||||
retryInactiveCarrier?: boolean
|
||||
includeLocked?: boolean
|
||||
}) {
|
||||
return apiPost<PendingCarrierRetryResult>(
|
||||
'/api/v1/admin/fulfillment/pending/retry-carriers',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function unlockPendingChannelGroup(payload: { groupKey?: string; internalSku?: string }) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
|
||||
}
|
||||
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string }) {
|
||||
return apiPost<DeletePendingChannelGroupResult>(
|
||||
'/api/v1/admin/fulfillment/pending/delete',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const page = readFileSync(new URL('../src/pages/admin/AdminTasksPage.tsx', import.meta.url), 'utf8')
|
||||
|
||||
test('任务页默认仅展示当前任务,并可切换全部历史尝试', () => {
|
||||
assert.ok(page.includes("searchParams.get('scope') === 'all' ? 'all' : 'current'"))
|
||||
assert.ok(page.includes('currentOnly: filters.scope !=='))
|
||||
assert.ok(page.includes("label: '当前任务'"))
|
||||
assert.ok(page.includes("label: '全部历史尝试'"))
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const panel = readFileSync(
|
||||
new URL('../src/pages/admin/AdminFulfillmentChannelPage.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('待分类队列按问题类型拆分动作', () => {
|
||||
assert.ok(panel.includes('去补配置'))
|
||||
assert.ok(panel.includes('处理人工复核'))
|
||||
assert.ok(panel.includes('确认重试'))
|
||||
assert.ok(panel.includes('解除锁定'))
|
||||
assert.ok(panel.includes('等待补配置'))
|
||||
assert.ok(panel.includes('用户已申请退款'))
|
||||
assert.ok(panel.includes('上游履约已取消'))
|
||||
})
|
||||
|
||||
test('重开历史载体必须二次确认且不会处理锁定项', () => {
|
||||
assert.ok(panel.includes('确认重开履约载体'))
|
||||
assert.ok(panel.includes('retryInactiveCarrier: true'))
|
||||
assert.ok(panel.includes('不会重开失败或关闭的旧任务'))
|
||||
assert.ok(panel.includes('unlockPendingChannelGroup'))
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const panel = readFileSync(
|
||||
new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('最近核销任务提供关键词、平台、渠道和状态筛选', () => {
|
||||
assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号"'))
|
||||
assert.ok(panel.includes('placeholder="全部平台"'))
|
||||
assert.ok(panel.includes('placeholder="全部渠道"'))
|
||||
assert.ok(panel.includes('placeholder="全部状态"'))
|
||||
assert.ok(panel.includes('resetJobFilters'))
|
||||
})
|
||||
|
||||
test('最近核销任务使用服务端分页', () => {
|
||||
assert.ok(panel.includes('buildAdminTablePagination'))
|
||||
assert.ok(panel.includes('total: jobsQuery.data?.data.total'))
|
||||
assert.ok(panel.includes('setPageSize(nextPageSize)'))
|
||||
assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}'))
|
||||
})
|
||||
Reference in New Issue
Block a user