feat: 支持第三方卡券收到订单后自动核销
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from '../cost-center/platform-credit-service.js'
|
||||
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
|
||||
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from '../fulfillment/voucher-consume-queue-service.js'
|
||||
|
||||
/**
|
||||
* 人工履约待办与「确认履约」。
|
||||
@@ -510,6 +511,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
}
|
||||
})
|
||||
|
||||
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
|
||||
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
|
||||
await refreshFactsForOrderItem(orderItemId)
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js'
|
||||
import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
/**
|
||||
* 自动发货失败 → 人工兜底。
|
||||
@@ -156,6 +157,7 @@ export async function runAffiliateFallback(
|
||||
for (const id of switchedIds) {
|
||||
await syncCostSnapshotsForItems([id]).catch(() => undefined)
|
||||
}
|
||||
await scheduleVoucherConsumeForOrderItems(switchedIds, 'order_received').catch(() => undefined)
|
||||
await refreshFactsForDates([...affectedDates]).catch(() => undefined)
|
||||
|
||||
return { scanned: candidates.rows.length, switched: switchedIds.length }
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
planInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
type CarrierCandidate = {
|
||||
id: number
|
||||
@@ -188,7 +189,13 @@ async function selectSafeFallback(input: {
|
||||
input.includeAssignedMissingCarrier,
|
||||
],
|
||||
)
|
||||
return (result.rowCount || 0) > 0
|
||||
const updated = (result.rowCount || 0) > 0
|
||||
if (updated) {
|
||||
await scheduleVoucherConsumeForOrderItems([input.orderItemId], 'order_received').catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -33,6 +34,7 @@ import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
|
||||
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
|
||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
/**
|
||||
* 管道 A:渠道判定。
|
||||
@@ -402,7 +404,7 @@ export async function assignPendingOrderItemChannels(
|
||||
}
|
||||
|
||||
if (assignments.length > 0) {
|
||||
const updated = await query(
|
||||
const updated = await query<{ id: number }>(
|
||||
`
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
@@ -418,6 +420,7 @@ export async function assignPendingOrderItemChannels(
|
||||
WHERE oi.id = input.id
|
||||
AND oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
RETURNING oi.id
|
||||
`,
|
||||
[
|
||||
assignments.map((row) => row.id),
|
||||
@@ -425,8 +428,20 @@ export async function assignPendingOrderItemChannels(
|
||||
assignments.map((row) => row.source),
|
||||
],
|
||||
)
|
||||
result.assigned += updated.rowCount || 0
|
||||
result.assignedItemIds = assignments.map((row) => row.id).slice(0, 2000)
|
||||
const assignedItemIds = updated.rows
|
||||
.map((row) => Number(row.id))
|
||||
.filter((id) => id > 0)
|
||||
.slice(0, 2000)
|
||||
result.assigned += assignedItemIds.length
|
||||
result.assignedItemIds = assignedItemIds
|
||||
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
|
||||
(error) => {
|
||||
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
|
||||
orderItemIds: assignedItemIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +581,12 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
||||
}
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 与旧 `channel` 的区别:
|
||||
* · 渠道只表达「谁来做」,不包含 pending/archived 这类状态;
|
||||
* · 「待分类」是状态,不是渠道;
|
||||
* · 「第三方卡券」是渠道,但没有履约载体,只做统计。
|
||||
* · 「第三方卡券」由外部卡券库履约,不建内部履约载体;平台电子凭证仍可配置核销。
|
||||
*/
|
||||
|
||||
export const FULFILLMENT_CHANNELS = [
|
||||
|
||||
@@ -25,6 +25,11 @@ test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒
|
||||
mode: 'after_task_created',
|
||||
delaySeconds: 10,
|
||||
})
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => {
|
||||
@@ -59,6 +64,31 @@ test('工单默认策略同时匹配发布和指派,且不匹配链接生成',
|
||||
)
|
||||
})
|
||||
|
||||
test('收到订单后事件可匹配四个渠道,第三方卡券默认延迟 5 秒', () => {
|
||||
const policy = createDefaultVoucherConsumePolicy()
|
||||
for (const channel of [
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'manual',
|
||||
'third_party_voucher',
|
||||
] as const) {
|
||||
const configured = structuredClone(policy)
|
||||
configured.platforms.kuaishou_industry.channels[channel] = {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: channel === 'third_party_voucher' ? 5 : 15,
|
||||
}
|
||||
const resolved = resolveVoucherConsumePolicy({
|
||||
provider: 'kuaishou_industry',
|
||||
channel,
|
||||
event: 'order_received',
|
||||
policy: configured,
|
||||
})
|
||||
assert.equal(resolved.enabled, true)
|
||||
assert.equal(resolved.delaySeconds, channel === 'third_party_voucher' ? 5 : 15)
|
||||
}
|
||||
})
|
||||
|
||||
test('策略归一化会丢弃非法模式并限制延迟范围', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
enabled: true,
|
||||
@@ -83,3 +113,53 @@ test('策略归一化会丢弃非法模式并限制延迟范围', () => {
|
||||
})
|
||||
assert.equal(policy.platforms.douyin_mashida.enabled, true)
|
||||
})
|
||||
|
||||
test('旧版第三方卡券停用配置自动升级为收到订单后 5 秒', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
third_party_voucher: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(policy.version, 2)
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('新版明确关闭第三方卡券时继续尊重用户配置', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
version: 2,
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
third_party_voucher: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export const VOUCHER_CONSUME_CHANNELS = [
|
||||
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
|
||||
|
||||
export const VOUCHER_CONSUME_EVENTS = [
|
||||
'order_received',
|
||||
'link_generated',
|
||||
'task_created',
|
||||
'work_order_published',
|
||||
@@ -23,6 +24,7 @@ export type VoucherConsumeEvent = (typeof VOUCHER_CONSUME_EVENTS)[number]
|
||||
|
||||
export const VOUCHER_CONSUME_MODES = [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_link_generated',
|
||||
'after_task_created',
|
||||
'after_work_order_published',
|
||||
@@ -43,12 +45,14 @@ export type VoucherPlatformConsumePolicy = {
|
||||
}
|
||||
|
||||
export type VoucherConsumePolicy = {
|
||||
version: number
|
||||
enabled: boolean
|
||||
platforms: Record<VoucherConsumeProvider, VoucherPlatformConsumePolicy>
|
||||
}
|
||||
|
||||
export function createDefaultVoucherConsumePolicy(): VoucherConsumePolicy {
|
||||
return {
|
||||
version: 2,
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: createDefaultPlatformPolicy(),
|
||||
@@ -77,17 +81,21 @@ export function normalizeVoucherConsumePolicy(rawValue: unknown): VoucherConsume
|
||||
const defaults = createDefaultVoucherConsumePolicy()
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawPlatforms = isPlainObject(source.platforms) ? source.platforms : {}
|
||||
const version = normalizeInteger(source.version, 1, 1, 2)
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
enabled: normalizeBoolean(source.enabled, defaults.enabled),
|
||||
platforms: {
|
||||
kuaishou_industry: normalizePlatformPolicy(
|
||||
rawPlatforms.kuaishou_industry,
|
||||
defaults.platforms.kuaishou_industry,
|
||||
version,
|
||||
),
|
||||
douyin_mashida: normalizePlatformPolicy(
|
||||
rawPlatforms.douyin_mashida,
|
||||
defaults.platforms.douyin_mashida,
|
||||
version,
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -138,9 +146,9 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
|
||||
delaySeconds: 10,
|
||||
},
|
||||
third_party_voucher: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -149,9 +157,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
|
||||
function normalizePlatformPolicy(
|
||||
rawValue: unknown,
|
||||
fallback: VoucherPlatformConsumePolicy,
|
||||
version: number,
|
||||
): VoucherPlatformConsumePolicy {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawChannels = isPlainObject(source.channels) ? source.channels : {}
|
||||
const legacyThirdPartyDisabled = isLegacyThirdPartyDisabled(rawValue, version)
|
||||
|
||||
return {
|
||||
enabled: normalizeBoolean(source.enabled, fallback.enabled),
|
||||
@@ -159,10 +169,11 @@ function normalizePlatformPolicy(
|
||||
affiliate_dash: normalizeChannelPolicy(
|
||||
rawChannels.affiliate_dash,
|
||||
fallback.channels.affiliate_dash,
|
||||
['manual', 'after_link_generated', 'after_task_created'],
|
||||
['manual', 'after_order_received', 'after_link_generated', 'after_task_created'],
|
||||
),
|
||||
work_order: normalizeChannelPolicy(rawChannels.work_order, fallback.channels.work_order, [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_task_created',
|
||||
'after_work_order_published',
|
||||
'after_work_order_assigned',
|
||||
@@ -170,17 +181,25 @@ function normalizePlatformPolicy(
|
||||
]),
|
||||
manual: normalizeChannelPolicy(rawChannels.manual, fallback.channels.manual, [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_task_created',
|
||||
]),
|
||||
third_party_voucher: normalizeChannelPolicy(
|
||||
rawChannels.third_party_voucher,
|
||||
legacyThirdPartyDisabled ? {} : rawChannels.third_party_voucher,
|
||||
fallback.channels.third_party_voucher,
|
||||
['manual'],
|
||||
['manual', 'after_order_received'],
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyThirdPartyDisabled(rawPlatform: unknown, version: number): boolean {
|
||||
if (version >= 2 || !isPlainObject(rawPlatform)) return false
|
||||
const rawChannels = isPlainObject(rawPlatform.channels) ? rawPlatform.channels : {}
|
||||
const raw = isPlainObject(rawChannels.third_party_voucher) ? rawChannels.third_party_voucher : {}
|
||||
return raw.enabled === false && String(raw.mode || '').trim() === 'manual'
|
||||
}
|
||||
|
||||
function normalizeChannelPolicy(
|
||||
rawValue: unknown,
|
||||
fallback: VoucherChannelConsumePolicy,
|
||||
@@ -199,6 +218,7 @@ function normalizeChannelPolicy(
|
||||
|
||||
function modeMatchesEvent(mode: VoucherConsumeMode, event: VoucherConsumeEvent): boolean {
|
||||
if (mode === 'manual') return false
|
||||
if (mode === 'after_order_received') return event === 'order_received'
|
||||
if (mode === 'after_link_generated') return event === 'link_generated'
|
||||
if (mode === 'after_task_created') return event === 'task_created'
|
||||
if (mode === 'after_work_order_published') return event === 'work_order_published'
|
||||
|
||||
@@ -88,6 +88,15 @@ type VoucherConsumeScheduleTarget = {
|
||||
voucherCount: number
|
||||
}
|
||||
|
||||
type VoucherOrderItemTargetRow = {
|
||||
id: number
|
||||
order_id: number
|
||||
provider: string
|
||||
platform: string
|
||||
platform_order_id: string
|
||||
fulfillment_channel: string
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
|
||||
const PROCESSING_LOCK_SECONDS = 120
|
||||
|
||||
@@ -152,6 +161,140 @@ export async function scheduleVoucherConsumeForWorkOrder(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule after an order item has a fulfillment channel.
|
||||
*
|
||||
* The platform voucher may not exist yet when this runs. The queued job resolves
|
||||
* the voucher again at execution time and retries while the incoming voucher is
|
||||
* still being created or synchronized.
|
||||
*/
|
||||
export async function scheduleVoucherConsumeForOrderItems(
|
||||
orderItemIds: number[],
|
||||
event: VoucherConsumeEvent,
|
||||
): 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,
|
||||
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 = 'assigned'
|
||||
`,
|
||||
[ids],
|
||||
)
|
||||
const policy = getVoucherConsumePolicy()
|
||||
const targets: Array<{
|
||||
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) {
|
||||
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
|
||||
const channel = normalizeVoucherChannel(row.fulfillment_channel)
|
||||
if (!provider || !channel) continue
|
||||
|
||||
const resolved = resolveVoucherConsumePolicy({
|
||||
provider,
|
||||
channel,
|
||||
event,
|
||||
policy,
|
||||
})
|
||||
if (!resolved.enabled) continue
|
||||
|
||||
targets.push({
|
||||
provider,
|
||||
channel,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
||||
}
|
||||
|
||||
const inserted = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO voucher_consume_jobs (
|
||||
provider,
|
||||
channel,
|
||||
trigger_event,
|
||||
target_key,
|
||||
platform_order_id,
|
||||
order_id,
|
||||
order_item_id,
|
||||
due_at,
|
||||
status,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
target.provider,
|
||||
target.channel,
|
||||
target.trigger_event,
|
||||
target.target_key,
|
||||
target.platform_order_id,
|
||||
target.order_id,
|
||||
target.order_item_id,
|
||||
target.due_at,
|
||||
'pending',
|
||||
0,
|
||||
8,
|
||||
NOW()
|
||||
FROM jsonb_to_recordset($1::jsonb) AS target(
|
||||
provider text,
|
||||
channel text,
|
||||
trigger_event text,
|
||||
target_key text,
|
||||
platform_order_id text,
|
||||
order_id bigint,
|
||||
order_item_id bigint,
|
||||
due_at timestamptz
|
||||
)
|
||||
ON CONFLICT (provider, target_key)
|
||||
WHERE status IN ('pending', 'processing', 'succeeded')
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[JSON.stringify(targets)],
|
||||
)
|
||||
|
||||
logIntegration('[voucher-consume/queue]', '订单渠道确定后批量安排核销', {
|
||||
event,
|
||||
targetCount: targets.length,
|
||||
queuedCount: inserted.rowCount || 0,
|
||||
jobIds: inserted.rows.map((row) => Number(row.id)),
|
||||
})
|
||||
|
||||
return {
|
||||
targetCount: targets.length,
|
||||
queuedCount: inserted.rowCount || 0,
|
||||
jobIds: inserted.rows.map((row) => Number(row.id)),
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleVoucherConsumeTarget(
|
||||
target: Omit<VoucherConsumeScheduleTarget, 'voucherCodes' | 'voucherCount'>,
|
||||
): Promise<VoucherConsumeScheduleResult> {
|
||||
@@ -688,11 +831,18 @@ async function failVoucherConsumeJob(job: VoucherConsumeJobRow, error: unknown,
|
||||
}
|
||||
|
||||
function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null {
|
||||
if (isDouyinMashidaProvider(order.provider)) {
|
||||
return resolveVoucherProviderFromFields(order.provider, order.platform)
|
||||
}
|
||||
|
||||
function resolveVoucherProviderFromFields(
|
||||
orderProvider: unknown,
|
||||
orderPlatform: unknown,
|
||||
): VoucherConsumeProvider | null {
|
||||
if (isDouyinMashidaProvider(orderProvider)) {
|
||||
return 'douyin_mashida'
|
||||
}
|
||||
const provider = String(order.provider || '').trim()
|
||||
const platform = String(order.platform || '').trim()
|
||||
const provider = String(orderProvider || '').trim()
|
||||
const platform = String(orderPlatform || '').trim()
|
||||
if (
|
||||
[OPEN_91_PROVIDER, 'open_91', '91'].includes(provider) ||
|
||||
[OPEN_91_PLATFORM, 'kuaishou'].includes(platform)
|
||||
@@ -702,6 +852,19 @@ function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
|
||||
const channel = String(value || '').trim()
|
||||
if (
|
||||
channel === 'affiliate_dash' ||
|
||||
channel === 'work_order' ||
|
||||
channel === 'manual' ||
|
||||
channel === 'third_party_voucher'
|
||||
) {
|
||||
return channel
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveTaskVoucherChannel(
|
||||
task: Pick<TaskRow, 'executor_key'>,
|
||||
orderItem: OrderItemRow | null,
|
||||
@@ -717,16 +880,7 @@ function resolveTaskVoucherChannel(
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
const channel = String(orderItem?.fulfillment_channel || '').trim()
|
||||
if (
|
||||
channel === 'affiliate_dash' ||
|
||||
channel === 'work_order' ||
|
||||
channel === 'manual' ||
|
||||
channel === 'third_party_voucher'
|
||||
) {
|
||||
return channel
|
||||
}
|
||||
return null
|
||||
return normalizeVoucherChannel(orderItem?.fulfillment_channel)
|
||||
}
|
||||
|
||||
function buildVoucherConsumeTargetKey(orderItemId: unknown, fallback: string): string {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 设计口径(2026-10-31 v2):
|
||||
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
|
||||
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
|
||||
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
|
||||
* · 第三方卡券由外部卡券库履约:不建内部任务、不发领取页,但支持平台券自动核销。
|
||||
*/
|
||||
import {
|
||||
DeleteOutlined,
|
||||
@@ -66,7 +66,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'work_order', label: '接单平台' },
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券(仅统计)' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
]
|
||||
|
||||
|
||||
@@ -46,11 +46,13 @@ const MODE_OPTIONS: Record<
|
||||
Array<{ value: VoucherConsumeMode; label: string }>
|
||||
> = {
|
||||
affiliate_dash: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'after_link_generated', label: '领取链接生成后' },
|
||||
{ value: 'after_task_created', label: '履约任务创建后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
work_order: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'after_work_order_published_or_assigned', label: '发布或指派后' },
|
||||
{ value: 'after_work_order_published', label: '仅发布后' },
|
||||
{ value: 'after_work_order_assigned', label: '仅指派后' },
|
||||
@@ -58,10 +60,14 @@ const MODE_OPTIONS: Record<
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
manual: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'after_task_created', label: '人工任务创建后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
third_party_voucher: [{ value: 'manual', label: '未接入自动核销' }],
|
||||
third_party_voucher: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
}
|
||||
|
||||
type PolicyRow = {
|
||||
@@ -186,7 +192,6 @@ export default function VoucherConsumePolicyPanel() {
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.policy.enabled}
|
||||
disabled={row.channel === 'third_party_voucher'}
|
||||
onChange={(enabled) => updateChannel(row.provider, row.channel, { enabled })}
|
||||
/>
|
||||
),
|
||||
@@ -198,7 +203,7 @@ export default function VoucherConsumePolicyPanel() {
|
||||
<Select
|
||||
value={row.policy.mode}
|
||||
options={MODE_OPTIONS[row.channel]}
|
||||
disabled={row.channel === 'third_party_voucher' || !row.policy.enabled}
|
||||
disabled={!row.policy.enabled}
|
||||
onChange={(mode) => updateChannel(row.provider, row.channel, { mode })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -213,7 +218,7 @@ export default function VoucherConsumePolicyPanel() {
|
||||
max={3600}
|
||||
precision={0}
|
||||
value={row.policy.delaySeconds}
|
||||
disabled={row.channel === 'third_party_voucher' || !row.policy.enabled}
|
||||
disabled={!row.policy.enabled}
|
||||
onChange={(value) =>
|
||||
updateChannel(row.provider, row.channel, {
|
||||
delaySeconds: Math.max(0, Number(value || 0)),
|
||||
|
||||
@@ -148,6 +148,7 @@ export type VoucherConsumeChannel =
|
||||
| 'third_party_voucher'
|
||||
export type VoucherConsumeMode =
|
||||
| 'manual'
|
||||
| 'after_order_received'
|
||||
| 'after_link_generated'
|
||||
| 'after_task_created'
|
||||
| 'after_work_order_published'
|
||||
@@ -161,6 +162,7 @@ export type VoucherChannelConsumePolicy = {
|
||||
}
|
||||
|
||||
export type VoucherConsumePolicy = {
|
||||
version: number
|
||||
enabled: boolean
|
||||
platforms: Record<
|
||||
VoucherConsumeProvider,
|
||||
|
||||
Reference in New Issue
Block a user