feat: 支持第三方卡券收到订单后自动核销

This commit is contained in:
yml2213
2026-11-05 15:19:39 +08:00
parent 1df88cc639
commit 92ceb9697b
11 changed files with 323 additions and 30 deletions
@@ -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,