Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a595acfa9 | ||
|
|
e85bbee7e0 | ||
|
|
33d7c6597c |
@@ -0,0 +1,17 @@
|
|||||||
|
-- 146_voucher_consume_fallback_channel.sql
|
||||||
|
--
|
||||||
|
-- 未命中任何履约渠道时,允许按平台兜底策略安排电子凭证核销。
|
||||||
|
-- unmatched 只表示核销任务来源,不会写回 order_items.fulfillment_channel。
|
||||||
|
|
||||||
|
ALTER TABLE voucher_consume_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS voucher_consume_jobs_channel_check;
|
||||||
|
|
||||||
|
ALTER TABLE voucher_consume_jobs
|
||||||
|
ADD CONSTRAINT voucher_consume_jobs_channel_check
|
||||||
|
CHECK (channel IN (
|
||||||
|
'affiliate_dash', 'work_order', 'manual',
|
||||||
|
'third_party_voucher', 'on_account', 'unmatched'
|
||||||
|
));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN voucher_consume_jobs.channel IS
|
||||||
|
'触发核销时的履约渠道;unmatched 表示订单未命中任何履约渠道,由平台兜底策略触发';
|
||||||
@@ -1526,12 +1526,9 @@ export function buildWorkOrderWhere({
|
|||||||
if (acceptanceMode) {
|
if (acceptanceMode) {
|
||||||
const rawMode = String(acceptanceMode).trim()
|
const rawMode = String(acceptanceMode).trim()
|
||||||
if (rawMode === 'internal_consignment') {
|
if (rawMode === 'internal_consignment') {
|
||||||
filters.push(`(
|
// 「寄售来源」与是否已经匹配打手是两个正交条件。
|
||||||
wo.material_json->'consignment'->>'inventoryId' IS NOT NULL
|
// 这里不能内置“必须未指派”,否则再接 workerId 筛选会恒为空。
|
||||||
AND (wo.material_json->'consignment'->>'fulfillmentType' IS NULL OR wo.material_json->'consignment'->>'fulfillmentType' != 'worker_deposit')
|
filters.push(`wo.material_json->'consignment'->>'inventoryId' IS NOT NULL`)
|
||||||
AND (wo.material_json->'consignment'->>'workerId' IS NULL OR wo.material_json->'consignment'->>'workerId' = '0')
|
|
||||||
AND (wo.assigned_worker_id IS NULL OR wo.assigned_worker_id = 0)
|
|
||||||
)`)
|
|
||||||
} else if (rawMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT) {
|
} else if (rawMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT) {
|
||||||
filters.push(`(
|
filters.push(`(
|
||||||
wo.acceptance_mode = 'friend_gift'
|
wo.acceptance_mode = 'friend_gift'
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
|
|||||||
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
|
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
|
||||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||||
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
||||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
import {
|
||||||
|
scheduleVoucherConsumeFallbackForOrderItems,
|
||||||
|
scheduleVoucherConsumeForOrderItems,
|
||||||
|
} from './voucher-consume-queue-service.js'
|
||||||
import {
|
import {
|
||||||
assignFulfillmentChannelsWithExecutor,
|
assignFulfillmentChannelsWithExecutor,
|
||||||
attachFulfillmentConfigurations,
|
attachFulfillmentConfigurations,
|
||||||
@@ -359,6 +362,7 @@ export async function assignPendingOrderItemChannels(
|
|||||||
channel: FulfillmentChannel
|
channel: FulfillmentChannel
|
||||||
source: FulfillmentSource
|
source: FulfillmentSource
|
||||||
}> = []
|
}> = []
|
||||||
|
const unmatchedVoucherItemIds: number[] = []
|
||||||
|
|
||||||
const channelsByInternalSku = await loadInternalSkuChannelsForKeys(
|
const channelsByInternalSku = await loadInternalSkuChannelsForKeys(
|
||||||
rows.rows.map((item) => item.internal_sku),
|
rows.rows.map((item) => item.internal_sku),
|
||||||
@@ -410,6 +414,9 @@ export async function assignPendingOrderItemChannels(
|
|||||||
eventType: 'routing_pending',
|
eventType: 'routing_pending',
|
||||||
reason: pending.reason,
|
reason: pending.reason,
|
||||||
})
|
})
|
||||||
|
if (pending.reason === 'no_channel_capability') {
|
||||||
|
unmatchedVoucherItemIds.push(Number(item.id))
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
|
assignments.push({ id: item.id, channel: decision.channel, source: decision.source })
|
||||||
@@ -447,6 +454,18 @@ export async function assignPendingOrderItemChannels(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.nextCursor = reachedEnd ? 0 : cursor
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const migration = readFileSync(
|
|||||||
new URL('../../db/migrations/142_voucher_consume_job_codes.sql', import.meta.url),
|
new URL('../../db/migrations/142_voucher_consume_job_codes.sql', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const fallbackMigration = readFileSync(
|
||||||
|
new URL('../../db/migrations/146_voucher_consume_fallback_channel.sql', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const channelAssignSource = readFileSync(
|
||||||
|
new URL('./channel-assign-service.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => {
|
test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => {
|
||||||
assert.ok(source.includes('keyword?: string'))
|
assert.ok(source.includes('keyword?: string'))
|
||||||
@@ -39,3 +47,19 @@ test('核销任务保存并展示核销码,执行时刷新候选券码', () =>
|
|||||||
assert.ok(source.includes('updateVoucherConsumeJobCodes'))
|
assert.ok(source.includes('updateVoucherConsumeJobCodes'))
|
||||||
assert.ok(source.includes('voucher_codes = $1::text[]'))
|
assert.ok(source.includes('voucher_codes = $1::text[]'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('未命中履约渠道时按平台策略安排兜底核销,不修改订单渠道', () => {
|
||||||
|
assert.ok(source.includes('scheduleVoucherConsumeFallbackForOrderItems'))
|
||||||
|
assert.ok(source.includes("channel: 'unmatched'"))
|
||||||
|
assert.ok(source.includes("oi.fulfillment_pending_reason = 'no_channel_capability'"))
|
||||||
|
assert.ok(source.includes('未命中任何履约渠道时,按平台兜底策略安排核销'))
|
||||||
|
assert.ok(fallbackMigration.includes("'unmatched'"))
|
||||||
|
assert.ok(
|
||||||
|
channelAssignSource.includes('scheduleVoucherConsumeFallbackForOrderItems'),
|
||||||
|
'自动判渠未命中后必须进入兜底核销调度',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
channelAssignSource.includes("pending.reason === 'no_channel_capability'"),
|
||||||
|
'只允许未命中渠道进入兜底,不能覆盖无载体等其它问题',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒
|
|||||||
mode: 'after_order_received',
|
mode: 'after_order_received',
|
||||||
delaySeconds: 5,
|
delaySeconds: 5,
|
||||||
})
|
})
|
||||||
|
assert.deepEqual(policy.platforms.kuaishou_industry.channels.unmatched, {
|
||||||
|
enabled: false,
|
||||||
|
mode: 'after_order_received',
|
||||||
|
delaySeconds: 5,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => {
|
test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => {
|
||||||
@@ -163,3 +168,24 @@ test('新版明确关闭第三方卡券时继续尊重用户配置', () => {
|
|||||||
delaySeconds: 0,
|
delaySeconds: 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('旧配置自动补齐未命中渠道兜底,并支持手动开启', () => {
|
||||||
|
const legacy = normalizeVoucherConsumePolicy({ version: 2, enabled: true })
|
||||||
|
assert.equal(legacy.platforms.douyin_mashida.channels.unmatched.enabled, false)
|
||||||
|
|
||||||
|
const enabled = structuredClone(legacy)
|
||||||
|
enabled.platforms.douyin_mashida.channels.unmatched = {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'after_order_received',
|
||||||
|
delaySeconds: 12,
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveVoucherConsumePolicy({
|
||||||
|
provider: 'douyin_mashida',
|
||||||
|
channel: 'unmatched',
|
||||||
|
event: 'order_received',
|
||||||
|
policy: enabled,
|
||||||
|
}),
|
||||||
|
{ enabled: true, delaySeconds: 12, mode: 'after_order_received' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const VOUCHER_CONSUME_CHANNELS = [
|
|||||||
'manual',
|
'manual',
|
||||||
'third_party_voucher',
|
'third_party_voucher',
|
||||||
'on_account',
|
'on_account',
|
||||||
|
'unmatched',
|
||||||
] as const
|
] as const
|
||||||
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
|
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
|
||||||
|
|
||||||
@@ -156,6 +157,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
|
|||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
delaySeconds: 0,
|
delaySeconds: 0,
|
||||||
},
|
},
|
||||||
|
unmatched: {
|
||||||
|
enabled: false,
|
||||||
|
mode: 'after_order_received',
|
||||||
|
delaySeconds: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +205,10 @@ function normalizePlatformPolicy(
|
|||||||
'manual',
|
'manual',
|
||||||
'after_order_received',
|
'after_order_received',
|
||||||
]),
|
]),
|
||||||
|
unmatched: normalizeChannelPolicy(rawChannels.unmatched, fallback.channels.unmatched, [
|
||||||
|
'manual',
|
||||||
|
'after_order_received',
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ type VoucherOrderItemTargetRow = {
|
|||||||
platform: string
|
platform: string
|
||||||
platform_order_id: string
|
platform_order_id: string
|
||||||
fulfillment_channel: string
|
fulfillment_channel: string
|
||||||
|
fulfillment_status: string
|
||||||
|
fulfillment_pending_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoucherConsumeTargetInsert = {
|
||||||
|
provider: VoucherConsumeProvider
|
||||||
|
channel: VoucherConsumeChannel
|
||||||
|
trigger_event: VoucherConsumeEvent
|
||||||
|
target_key: string
|
||||||
|
platform_order_id: string
|
||||||
|
order_id: number
|
||||||
|
order_item_id: number
|
||||||
|
due_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
|
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
|
||||||
@@ -194,6 +207,8 @@ export async function scheduleVoucherConsumeForOrderItems(
|
|||||||
oi.id,
|
oi.id,
|
||||||
oi.order_id,
|
oi.order_id,
|
||||||
oi.fulfillment_channel,
|
oi.fulfillment_channel,
|
||||||
|
oi.fulfillment_status,
|
||||||
|
oi.fulfillment_pending_reason,
|
||||||
o.provider,
|
o.provider,
|
||||||
o.platform,
|
o.platform,
|
||||||
o.platform_order_id
|
o.platform_order_id
|
||||||
@@ -205,16 +220,7 @@ export async function scheduleVoucherConsumeForOrderItems(
|
|||||||
[ids],
|
[ids],
|
||||||
)
|
)
|
||||||
const policy = getVoucherConsumePolicy()
|
const policy = getVoucherConsumePolicy()
|
||||||
const targets: Array<{
|
const targets: VoucherConsumeTargetInsert[] = []
|
||||||
provider: VoucherConsumeProvider
|
|
||||||
channel: VoucherConsumeChannel
|
|
||||||
trigger_event: VoucherConsumeEvent
|
|
||||||
target_key: string
|
|
||||||
platform_order_id: string
|
|
||||||
order_id: number
|
|
||||||
order_item_id: number
|
|
||||||
due_at: string
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const row of rows.rows) {
|
for (const row of rows.rows) {
|
||||||
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
|
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
|
||||||
@@ -241,6 +247,76 @@ export async function scheduleVoucherConsumeForOrderItems(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return queueVoucherConsumeTargets(targets, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未命中任何履约渠道时,按平台兜底策略安排核销。
|
||||||
|
*
|
||||||
|
* 兜底只产生核销任务,不修改订单项渠道或履约状态,后续仍由问题队列处理履约。
|
||||||
|
*/
|
||||||
|
export async function scheduleVoucherConsumeFallbackForOrderItems(
|
||||||
|
orderItemIds: number[],
|
||||||
|
event: Extract<VoucherConsumeEvent, 'order_received'> = 'order_received',
|
||||||
|
): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> {
|
||||||
|
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<VoucherOrderItemTargetRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
oi.id,
|
||||||
|
oi.order_id,
|
||||||
|
oi.fulfillment_channel,
|
||||||
|
oi.fulfillment_status,
|
||||||
|
oi.fulfillment_pending_reason,
|
||||||
|
o.provider,
|
||||||
|
o.platform,
|
||||||
|
o.platform_order_id
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.id = oi.order_id
|
||||||
|
WHERE oi.id = ANY($1::bigint[])
|
||||||
|
AND oi.fulfillment_status = 'pending'
|
||||||
|
AND oi.fulfillment_pending_reason = 'no_channel_capability'
|
||||||
|
`,
|
||||||
|
[ids],
|
||||||
|
)
|
||||||
|
const policy = getVoucherConsumePolicy()
|
||||||
|
const targets: VoucherConsumeTargetInsert[] = []
|
||||||
|
|
||||||
|
for (const row of rows.rows) {
|
||||||
|
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
|
||||||
|
if (!provider) continue
|
||||||
|
|
||||||
|
const resolved = resolveVoucherConsumePolicy({
|
||||||
|
provider,
|
||||||
|
channel: 'unmatched',
|
||||||
|
event,
|
||||||
|
policy,
|
||||||
|
})
|
||||||
|
if (!resolved.enabled) continue
|
||||||
|
|
||||||
|
targets.push({
|
||||||
|
provider,
|
||||||
|
channel: 'unmatched',
|
||||||
|
trigger_event: event,
|
||||||
|
target_key: buildVoucherConsumeTargetKey(row.id, `order_item:${row.id}`),
|
||||||
|
platform_order_id: String(row.platform_order_id || '').trim(),
|
||||||
|
order_id: Number(row.order_id),
|
||||||
|
order_item_id: Number(row.id),
|
||||||
|
due_at: new Date(Date.now() + resolved.delaySeconds * 1000).toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueVoucherConsumeTargets(targets, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueVoucherConsumeTargets(
|
||||||
|
targets: VoucherConsumeTargetInsert[],
|
||||||
|
event: VoucherConsumeEvent,
|
||||||
|
): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> {
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
||||||
}
|
}
|
||||||
@@ -292,10 +368,11 @@ export async function scheduleVoucherConsumeForOrderItems(
|
|||||||
[JSON.stringify(targets)],
|
[JSON.stringify(targets)],
|
||||||
)
|
)
|
||||||
|
|
||||||
logIntegration('[voucher-consume/queue]', '订单渠道确定后批量安排核销', {
|
logIntegration('[voucher-consume/queue]', '安排电子凭证核销任务', {
|
||||||
event,
|
event,
|
||||||
targetCount: targets.length,
|
targetCount: targets.length,
|
||||||
queuedCount: inserted.rowCount || 0,
|
queuedCount: inserted.rowCount || 0,
|
||||||
|
channels: [...new Set(targets.map((target) => target.channel))],
|
||||||
jobIds: inserted.rows.map((row) => Number(row.id)),
|
jobIds: inserted.rows.map((row) => Number(row.id)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -943,7 +1020,8 @@ function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
|
|||||||
channel === 'work_order' ||
|
channel === 'work_order' ||
|
||||||
channel === 'manual' ||
|
channel === 'manual' ||
|
||||||
channel === 'third_party_voucher' ||
|
channel === 'third_party_voucher' ||
|
||||||
channel === 'on_account'
|
channel === 'on_account' ||
|
||||||
|
channel === 'unmatched'
|
||||||
) {
|
) {
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1970,15 +1970,22 @@ test('好友赠送待打手提交资料筛选排除待抢单订单', () => {
|
|||||||
assert.match(whereClause, /wo\.status IN \('in_progress', 'problem'\)/)
|
assert.match(whereClause, /wo\.status IN \('in_progress', 'problem'\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('工单筛选支持内部寄售匹配与打手好友赠送隔离,且赠送冷却中排除已验收已取消工单', () => {
|
test('工单筛选支持内部寄售来源叠加人员条件,并保持好友赠送与冷却筛选', () => {
|
||||||
const internalQuery = buildWorkOrderWhere({ acceptanceMode: 'internal_consignment' })
|
const internalQuery = buildWorkOrderWhere({ acceptanceMode: 'internal_consignment' })
|
||||||
assert.match(
|
assert.match(
|
||||||
internalQuery.whereClause,
|
internalQuery.whereClause,
|
||||||
/wo\.material_json->'consignment'->>'inventoryId' IS NOT NULL/,
|
/wo\.material_json->'consignment'->>'inventoryId' IS NOT NULL/,
|
||||||
)
|
)
|
||||||
|
assert.doesNotMatch(internalQuery.whereClause, /wo\.assigned_worker_id IS NULL/)
|
||||||
|
|
||||||
|
const workerScopedInternalQuery = buildWorkOrderWhere({
|
||||||
|
acceptanceMode: 'internal_consignment',
|
||||||
|
workerSharingId: 25,
|
||||||
|
})
|
||||||
|
assert.match(workerScopedInternalQuery.joins, /worker_visible\.work_order_id = wo\.id/)
|
||||||
assert.match(
|
assert.match(
|
||||||
internalQuery.whereClause,
|
workerScopedInternalQuery.whereClause,
|
||||||
/wo\.assigned_worker_id IS NULL OR wo\.assigned_worker_id = 0/,
|
/wo\.material_json->'consignment'->>'inventoryId' IS NOT NULL/,
|
||||||
)
|
)
|
||||||
|
|
||||||
const friendGiftQuery = buildWorkOrderWhere({ acceptanceMode: 'friend_gift' })
|
const friendGiftQuery = buildWorkOrderWhere({ acceptanceMode: 'friend_gift' })
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const CHANNEL_LABEL: Record<VoucherConsumeChannel, string> = {
|
|||||||
manual: '人工客服',
|
manual: '人工客服',
|
||||||
on_account: '统一结算',
|
on_account: '统一结算',
|
||||||
third_party_voucher: '第三方卡券',
|
third_party_voucher: '第三方卡券',
|
||||||
|
unmatched: '未命中渠道兜底',
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_OPTIONS: Record<
|
const MODE_OPTIONS: Record<
|
||||||
@@ -75,6 +76,10 @@ const MODE_OPTIONS: Record<
|
|||||||
{ value: 'after_order_received', label: '收到订单后' },
|
{ value: 'after_order_received', label: '收到订单后' },
|
||||||
{ value: 'manual', label: '人工核销' },
|
{ value: 'manual', label: '人工核销' },
|
||||||
],
|
],
|
||||||
|
unmatched: [
|
||||||
|
{ value: 'after_order_received', label: '未命中渠道后' },
|
||||||
|
{ value: 'manual', label: '人工核销' },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
type PolicyRow = {
|
type PolicyRow = {
|
||||||
@@ -103,6 +108,7 @@ const CHANNEL_FILTER_OPTIONS: Array<{ value: VoucherConsumeChannel; label: strin
|
|||||||
{ value: 'manual', label: CHANNEL_LABEL.manual },
|
{ value: 'manual', label: CHANNEL_LABEL.manual },
|
||||||
{ value: 'third_party_voucher', label: CHANNEL_LABEL.third_party_voucher },
|
{ value: 'third_party_voucher', label: CHANNEL_LABEL.third_party_voucher },
|
||||||
{ value: 'on_account', label: CHANNEL_LABEL.on_account },
|
{ value: 'on_account', label: CHANNEL_LABEL.on_account },
|
||||||
|
{ value: 'unmatched', label: CHANNEL_LABEL.unmatched },
|
||||||
]
|
]
|
||||||
|
|
||||||
const JOB_STATUS_FILTER_OPTIONS: Array<{
|
const JOB_STATUS_FILTER_OPTIONS: Array<{
|
||||||
|
|||||||
@@ -2385,10 +2385,12 @@ export default function WorkOrdersPanel() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<SearchOutlined />} onClick={searchOrders}>
|
<div className="worker-orders-filter-actions">
|
||||||
查询
|
<Button type="primary" icon={<SearchOutlined />} onClick={searchOrders}>
|
||||||
</Button>
|
查询
|
||||||
<Button onClick={clearOrderFilters}>清空筛选</Button>
|
</Button>
|
||||||
|
<Button onClick={clearOrderFilters}>清空筛选</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="worker-orders-mock">
|
<div className="worker-orders-mock">
|
||||||
<Typography.Text type="secondary">本地 Mock</Typography.Text>
|
<Typography.Text type="secondary">本地 Mock</Typography.Text>
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export type VoucherConsumeChannel =
|
|||||||
| 'manual'
|
| 'manual'
|
||||||
| 'on_account'
|
| 'on_account'
|
||||||
| 'third_party_voucher'
|
| 'third_party_voucher'
|
||||||
|
| 'unmatched'
|
||||||
export type VoucherConsumeMode =
|
export type VoucherConsumeMode =
|
||||||
| 'manual'
|
| 'manual'
|
||||||
| 'after_order_received'
|
| 'after_order_received'
|
||||||
|
|||||||
@@ -139,49 +139,55 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 单行筛选栏:搜索框、筛选条件、清空按钮同一行且不换行;空间不足时行内横向滚动 */
|
/* 筛选条件按内容分配宽度;空间不足时整组换行,避免单个下拉挤占整行。 */
|
||||||
.worker-orders-filter-bar {
|
.worker-orders-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-orders-search-input {
|
.worker-orders-search-input {
|
||||||
flex: 1.4 1 240px;
|
flex: 1 1 260px;
|
||||||
min-width: 180px;
|
min-width: 220px;
|
||||||
max-width: 300px;
|
max-width: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-orders-filter-select {
|
.worker-orders-filter-select {
|
||||||
flex: 1 1 110px;
|
flex: 0 1 165px;
|
||||||
min-width: 88px;
|
width: 165px;
|
||||||
|
min-width: 145px;
|
||||||
|
max-width: 185px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-orders-filter-select--wide {
|
.worker-orders-filter-select--wide {
|
||||||
flex: 1.6 1 140px;
|
flex: 0 1 220px;
|
||||||
min-width: 112px;
|
width: 220px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 状态:多选标签可截断,宽度让给时间区间 */
|
/* 状态是多选标签,保留独立宽度但不再随剩余空间无限拉伸。 */
|
||||||
.worker-orders-filter-select--compact {
|
.worker-orders-filter-select--compact {
|
||||||
flex: 0.9 1 96px;
|
flex: 0 1 170px;
|
||||||
min-width: 80px;
|
width: 170px;
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 时间区间:留足两个完整日期(YYYY-MM-DD)与「开始日期/结束日期」占位文案的宽度 */
|
/* 时间区间:留足两个完整日期(YYYY-MM-DD)与「开始日期/结束日期」占位文案的宽度 */
|
||||||
.worker-orders-date-range {
|
.worker-orders-date-range {
|
||||||
flex: 0 0 230px;
|
flex: 0 0 220px;
|
||||||
width: 230px;
|
width: 220px;
|
||||||
min-width: 230px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-orders-filter-bar > .ant-btn {
|
.worker-orders-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-orders-mock {
|
.worker-orders-mock {
|
||||||
|
|||||||
@@ -34,11 +34,10 @@ export type ConsignmentWorkerDisplayInfo = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。
|
* 尚未匹配打手的内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。
|
||||||
*
|
*
|
||||||
* 口径必须与后端「内部寄售匹配」筛选(work-order-query-repo 的 internal_consignment 分支)一致:
|
* 这是操作能力判断,不用于「内部寄售」来源筛选;来源筛选允许匹配前后,
|
||||||
* `material.consignment.inventoryId` 有值、`fulfillmentType ≠ worker_deposit`、
|
* 因此可以和接单人员条件叠加。
|
||||||
* `consignment.workerId` 缺失或为 0(后端按字符串 '0' 也视为无打手)、且工单未指派打手。
|
|
||||||
*/
|
*/
|
||||||
export function isInternalConsignmentOrder(row: { material?: unknown; worker?: unknown }): boolean {
|
export function isInternalConsignmentOrder(row: { material?: unknown; worker?: unknown }): boolean {
|
||||||
const consignment = asRecord(asRecord(row.material).consignment)
|
const consignment = asRecord(asRecord(row.material).consignment)
|
||||||
|
|||||||
@@ -21,3 +21,9 @@ test('最近核销任务使用服务端分页', () => {
|
|||||||
assert.ok(panel.includes('setPageSize(nextPageSize)'))
|
assert.ok(panel.includes('setPageSize(nextPageSize)'))
|
||||||
assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}'))
|
assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('自动核销策略提供未命中履约渠道的平台兜底配置', () => {
|
||||||
|
assert.ok(panel.includes("unmatched: '未命中渠道兜底'"))
|
||||||
|
assert.ok(panel.includes("label: '未命中渠道后'"))
|
||||||
|
assert.ok(panel.includes("value: 'unmatched'"))
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
test('接单工单筛选按内容分配宽度,店铺下拉不能吞掉查询区', async () => {
|
||||||
|
const [panel, css] = await Promise.all([
|
||||||
|
readFile(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url), 'utf8'),
|
||||||
|
readFile(new URL('../src/styles/worker.css', import.meta.url), 'utf8'),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.ok(panel.includes('className="worker-orders-filter-actions"'))
|
||||||
|
assert.ok(css.includes('flex-wrap: wrap'))
|
||||||
|
assert.ok(css.includes('.worker-orders-filter-select--wide'))
|
||||||
|
assert.ok(css.includes('width: 220px'))
|
||||||
|
assert.ok(css.includes('max-width: 240px'))
|
||||||
|
assert.ok(css.includes('.worker-orders-filter-actions'))
|
||||||
|
assert.ok(!css.includes('overflow-x: auto;\n scrollbar-width: thin;'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user