feat: 完善履约路由、订单服务与自动核销

This commit is contained in:
yml2213
2026-11-05 14:59:58 +08:00
parent 517d763f17
commit 1df88cc639
54 changed files with 3166 additions and 318 deletions
@@ -152,6 +152,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
? JSON.stringify({
profileId: serviceProfile.profileId,
categoryId: serviceProfile.categoryId,
sourceCategoryId: serviceProfile.sourceCategoryId,
inherited: serviceProfile.inherited,
versionId: serviceProfile.versionId,
versionNo: serviceProfile.versionNo,
config: serviceProfile.config,
@@ -13,7 +13,10 @@ import {
publishWorkerWalletRealtimeChange,
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
import { consumeIndustryVouchersBeforeWorkOrderAssign } from './publish-voucher-service.js'
import {
scheduleVoucherConsumeForWorkOrder,
type VoucherConsumeScheduleResult,
} from '../fulfillment/voucher-consume-queue-service.js'
import {
mapWorkOrderAdmin,
normalizeOptionalId,
@@ -65,8 +68,7 @@ export async function assignAdminWorkOrderToWorker(
})
}
// 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前,
// 避免“凭证已核销但指派失败”的副作用。
// 已有拼单份额的订单不能再按整单指派。
const activeShares = (await listWorkOrderShares(workOrder.id)).filter(
(share) => share.status !== 'cancelled',
)
@@ -77,26 +79,6 @@ export async function assignAdminWorkOrderToWorker(
})
}
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder)
if (!voucherConsume.ok) {
const failedAt = nowIso()
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: actorName,
eventType: 'assign_voucher_consume_failed',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }),
now: failedAt,
})
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未指派', {
statusCode: 409,
errorCode: 'work_order_assign_voucher_consume_failed',
context: voucherConsume,
})
}
const now = nowIso()
const { order: updated, failureReason } = await assignWorkOrderToWorker({
workOrderId: workOrder.id,
@@ -108,38 +90,70 @@ export async function assignAdminWorkOrderToWorker(
actorName,
})
if (failureReason === 'work_order_not_assignable') {
throw createHttpError(
resolveAssignFailureMessage(voucherConsume.voucherCount, '订单状态已变化,无法指派'),
{ statusCode: 409, errorCode: 'work_order_not_assignable' },
)
throw createHttpError('订单状态已变化,无法指派', {
statusCode: 409,
errorCode: 'work_order_not_assignable',
})
}
if (failureReason === 'worker_active_order_limit') {
throw createHttpError(
resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手已达到最大同时接单量'),
{ statusCode: 409, errorCode: 'worker_active_order_limit' },
)
throw createHttpError('该打手已达到最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError(
resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手余额不足,无法冻结所需押金'),
{ statusCode: 409, errorCode: 'worker_deposit_insufficient' },
)
throw createHttpError('该打手余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
if (failureReason === 'work_order_has_shares') {
throw createHttpError(
resolveAssignFailureMessage(
voucherConsume.voucherCount,
'该订单已有拼单份额,不能按整单指派;请使用拼单管理',
),
{ statusCode: 409, errorCode: 'work_order_has_shares' },
)
throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', {
statusCode: 409,
errorCode: 'work_order_has_shares',
})
}
if (!updated) {
throw createHttpError(
resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'),
{ statusCode: 409, errorCode: 'work_order_assign_conflict' },
)
throw createHttpError('指派失败,订单状态可能已变化', {
statusCode: 409,
errorCode: 'work_order_assign_conflict',
})
}
let voucherConsume: VoucherConsumeScheduleResult
try {
voucherConsume = await scheduleVoucherConsumeForWorkOrder(updated, 'work_order_assigned')
} catch (error) {
voucherConsume = {
ok: false,
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
failedCount: 1,
voucherCodes: [],
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
queuedCount: 0,
jobIds: [],
dueAt: '',
status: 'failed',
}
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: actorName,
eventType:
voucherConsume.status === 'queued'
? 'assign_voucher_consume_scheduled'
: voucherConsume.status === 'failed'
? 'assign_voucher_consume_schedule_failed'
: 'assign_voucher_consume_schedule_skipped',
fromStatus: workOrder.status,
toStatus: updated.status,
payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }),
now,
}).catch(() => undefined)
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
@@ -149,10 +163,6 @@ export async function assignAdminWorkOrderToWorker(
return { order: mapWorkOrderAdmin(updated), voucherConsume }
}
function resolveAssignFailureMessage(voucherCount: number, fallback: string) {
return voucherCount > 0 ? `电子凭证已核销,但${fallback},请重试指派` : fallback
}
function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null {
if (timeoutMinutes <= 0) return null
return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
@@ -22,9 +22,9 @@ import { resolveAdminNotificationEntity } from '../admin/admin-notification-serv
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
type WorkOrderVoucherConsumeResult,
} from './publish-voucher-service.js'
scheduleVoucherConsumeForWorkOrder,
type VoucherConsumeScheduleResult,
} from '../fulfillment/voucher-consume-queue-service.js'
import {
mapWorkOrderAdmin,
mapWorkOrderShare,
@@ -347,7 +347,7 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string
export async function publishAdminWorkOrder(
workOrderId: number | string,
actorName = '',
options: { requireVoucherConsume?: boolean } = {},
_options: { requireVoucherConsume?: boolean } = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) {
@@ -364,39 +364,6 @@ export async function publishAdminWorkOrder(
}
const now = nowIso()
let voucherConsume: WorkOrderVoucherConsumeResult
try {
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
} catch (error) {
voucherConsume = {
ok: false,
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
failedCount: 1,
voucherCodes: [],
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
}
}
if (!voucherConsume.ok) {
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'system',
actorId: 'kuaishou_send_code',
eventType: 'publish_voucher_consume_failed',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify(voucherConsume),
now: nowIso(),
})
if (options.requireVoucherConsume === true) {
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
statusCode: 409,
errorCode: 'work_order_publish_voucher_consume_failed',
context: { voucherConsume },
})
}
}
const published = await publishUnassignedWorkOrder({
workOrderId: workOrder.id,
@@ -411,6 +378,39 @@ export async function publishAdminWorkOrder(
}
const currentOrder = await getRequiredWorkOrder(workOrder.id)
let voucherConsume: VoucherConsumeScheduleResult
try {
voucherConsume = await scheduleVoucherConsumeForWorkOrder(currentOrder, 'work_order_published')
} catch (error) {
voucherConsume = {
ok: false,
voucherCount: 0,
consumedCount: 0,
alreadyConsumedCount: 0,
failedCount: 1,
voucherCodes: [],
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
queuedCount: 0,
jobIds: [],
dueAt: '',
status: 'failed',
}
}
await createWorkOrderEvent({
workOrderId: currentOrder.id,
actorType: 'system',
actorId: 'voucher_consume_policy',
eventType:
voucherConsume.status === 'queued'
? 'publish_voucher_consume_scheduled'
: voucherConsume.status === 'failed'
? 'publish_voucher_consume_schedule_failed'
: 'publish_voucher_consume_schedule_skipped',
fromStatus: currentOrder.status,
toStatus: currentOrder.status,
payloadJson: JSON.stringify(voucherConsume),
now: nowIso(),
}).catch(() => undefined)
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
await enqueueCustomerOrderNotification({
orderId: currentOrder.order_id,
@@ -47,9 +47,9 @@ import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-serv
import { unassignAdminWorkOrder } from './admin-work-order-cancellation-service.js'
import { mapWorkOrderAdmin } from './mappers.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
type WorkOrderVoucherConsumeResult,
} from './publish-voucher-service.js'
scheduleVoucherConsumeForWorkOrder,
type VoucherConsumeScheduleResult,
} from '../fulfillment/voucher-consume-queue-service.js'
import {
type ConsignmentMatchOption,
type ConsignmentMatchResult,
@@ -383,7 +383,7 @@ export async function fulfillWorkOrderWithConsignment(input: {
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
voucherConsume: WorkOrderVoucherConsumeResult
voucherConsume: VoucherConsumeScheduleResult
}> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
@@ -576,13 +576,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
return { updatedOrder: wo, updatedInventory: inv }
})
// 顺带核销快手电子凭证。
// 核销要走快手外部接口、无法和上面的数据库事务合并回滚,因此这里只能做两件事:
// 1) 失败时不吞错:写事件留痕,并把 voucherConsume.ok=false 交给前端提示运营人工核销;
// 2) 绝不因为"凭证没核销"就谎报成功(此前前端无条件弹成功提示,属于假成功)。
let voucherConsume: WorkOrderVoucherConsumeResult
// 行内发货完成后安排延迟核销,外部平台调用不阻塞当前履约事务。
let voucherConsume: VoucherConsumeScheduleResult
try {
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder)
voucherConsume = await scheduleVoucherConsumeForWorkOrder(updatedOrder, 'work_order_assigned')
} catch (error) {
voucherConsume = {
ok: false,
@@ -591,7 +588,11 @@ export async function fulfillWorkOrderWithConsignment(input: {
alreadyConsumedCount: 0,
failedCount: 1,
voucherCodes: [],
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
queuedCount: 0,
jobIds: [],
dueAt: '',
status: 'failed',
}
}
if (!voucherConsume.ok) {
@@ -599,8 +600,13 @@ export async function fulfillWorkOrderWithConsignment(input: {
await createWorkOrderEvent({
workOrderId: Number(updatedOrder.id),
actorType: 'system',
actorId: 'kuaishou_send_code',
eventType: 'consignment_fulfill_voucher_consume_failed',
actorId: 'voucher_consume_policy',
eventType:
voucherConsume.status === 'queued'
? 'consignment_fulfill_voucher_consume_scheduled'
: voucherConsume.status === 'failed'
? 'consignment_fulfill_voucher_consume_schedule_failed'
: 'consignment_fulfill_voucher_consume_schedule_skipped',
fromStatus: String(updatedOrder.status),
toStatus: String(updatedOrder.status),
payloadJson: JSON.stringify(voucherConsume),
@@ -668,7 +674,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
voucherConsume: WorkOrderVoucherConsumeResult
voucherConsume: VoucherConsumeScheduleResult
}> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
@@ -5,7 +5,10 @@ import {
isOrderServiceMaterialRequired,
normalizeProfileConfig,
} from './order-service-profile-service.js'
import { backfillPendingOrderServiceSnapshotsForCategory } from '../../repositories/worker-platform/order-service-profile-repo.js'
import {
backfillPendingOrderServiceSnapshotsForCategory,
getPublishedOrderServiceProfileByCategoryWithInheritance,
} from '../../repositories/worker-platform/order-service-profile-repo.js'
function config(patch: Record<string, unknown> = {}) {
return normalizeProfileConfig({
@@ -181,4 +184,40 @@ test('发布配置:待提交且无有效拼单份额的工单可更新到最
assert.equal(count, 2)
assert.doesNotMatch(capturedSql, /service_profile_snapshot_json IS NULL/)
assert.match(capturedSql, /share\.status <> 'cancelled'/)
assert.match(capturedSql, /WITH affected_categories/)
assert.match(capturedSql, /child_profile\.status <> 'archived'/)
assert.match(capturedSql, /wo\.category_id IN/)
assert.match(capturedSql, /'sourceCategoryId', p\.category_id/)
assert.match(capturedSql, /'inherited', wo\.category_id <> p\.category_id/)
})
test('订单服务配置:二级分类优先,未发布时沿分类链继承父级', async () => {
let capturedSql = ''
const client = {
query: async (sql: string) => {
capturedSql = sql
return {
rows: [
{
profile_id: 9,
category_id: 3,
source_category_id: 3,
version_id: 19,
version_no: 4,
config_json: { fields: [] },
},
],
}
},
} as unknown as Parameters<typeof getPublishedOrderServiceProfileByCategoryWithInheritance>[1]
const resolved = await getPublishedOrderServiceProfileByCategoryWithInheritance(81, client)
assert.equal(resolved?.profile_id, 9)
assert.equal(resolved?.category_id, 3)
assert.equal(resolved?.source_category_id, 3)
assert.equal(resolved?.version_no, 4)
assert.match(capturedSql, /WITH RECURSIVE category_chain/)
assert.match(capturedSql, /ORDER BY chain\.depth ASC/)
assert.match(capturedSql, /p\.status <> 'archived'/)
})
@@ -10,7 +10,7 @@ import {
backfillPendingOrderServiceSnapshotsForCategory,
createOrderServiceProfileVersion,
getOrderServiceProfileVersion,
getPublishedOrderServiceProfileByCategory,
getPublishedOrderServiceProfileByCategoryWithInheritance,
listOrderServiceProfiles,
setOrderServiceProfileCurrentVersion,
upsertOrderServiceProfile,
@@ -83,6 +83,8 @@ export type OrderServiceProfileConfig = {
export type OrderServiceProfileSnapshot = {
profileId: number
categoryId: number
sourceCategoryId: number
inherited: boolean
versionId: number
versionNo: number
config: OrderServiceProfileConfig
@@ -307,11 +309,14 @@ export async function getPublishedOrderServiceProfileSnapshotByCategory(
categoryId: number | string,
client?: Pick<PoolClient, 'query'>,
): Promise<OrderServiceProfileSnapshot | null> {
const row = await getPublishedOrderServiceProfileByCategory(categoryId, client)
const row = await getPublishedOrderServiceProfileByCategoryWithInheritance(categoryId, client)
if (!row) return null
const targetCategoryId = Number(categoryId)
return {
profileId: Number(row.profile_id),
categoryId: Number(row.category_id),
categoryId: targetCategoryId,
sourceCategoryId: Number(row.source_category_id),
inherited: Number(row.source_category_id) !== targetCategoryId,
versionId: Number(row.version_id),
versionNo: Number(row.version_no),
config: normalizeProfileConfig(row.config_json),
@@ -329,6 +334,8 @@ export async function getOrderServiceProfileSnapshotByVersion(input: {
return {
profileId: Number(input.profileId),
categoryId: Number(input.categoryId),
sourceCategoryId: Number(input.categoryId),
inherited: false,
versionId: Number(version.id),
versionNo: Number(version.version_no),
config: normalizeProfileConfig(version.config_json),
@@ -560,6 +560,13 @@ async function resolveProfileConfig(
})
if (snapshotFromVersion) return snapshotFromVersion.config
}
if (order.category_id) {
const published = await getPublishedOrderServiceProfileSnapshotByCategory(
Number(order.category_id),
client,
)
if (published) return published.config
}
const legacy = safeParseJson(order.requirement_json)
if (Array.isArray(legacy.fields)) {
return {
@@ -8,7 +8,7 @@ import { query } from '../../db/client.js'
* - 订单不存在(91 未到/缺失):用快手数据兜底建单 + 建工单 + 凭证回填
*
* 幂等依据:orders 唯一键 provider+platform+shop_id+platform_order_id
* (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders.order_item_id 唯一索引、
* (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders 未取消工单对 order_item_id 的唯一索引、
* listWorkOrdersByPlatformOrderId 查重。
*
* 本服务只写 orders / order_items / work_orders,不触碰
@@ -125,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder(
sourceMetadata?: JsonObject
skipChannelAssign?: boolean
preferredWorkRuleKey?: string
retryCancelledWorkOrder?: boolean
} = {},
) {
const [rules, ruleMappings] = await Promise.all([
@@ -252,7 +253,10 @@ export async function syncWorkerOrdersForSourceOrder(
// 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。
const existingWorkOrder = await getWorkOrderByOrderItemId(item.id)
if (existingWorkOrder) {
if (
existingWorkOrder &&
!(options.retryCancelledWorkOrder === true && existingWorkOrder.status === 'cancelled')
) {
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
decisions.push(
buildWorkerConsumerDecision(item, {
@@ -425,6 +429,8 @@ export async function syncWorkerOrdersForSourceOrder(
? JSON.stringify({
profileId: serviceProfile.profileId,
categoryId: serviceProfile.categoryId,
sourceCategoryId: serviceProfile.sourceCategoryId,
inherited: serviceProfile.inherited,
versionId: serviceProfile.versionId,
versionNo: serviceProfile.versionNo,
config: serviceProfile.config,
@@ -2554,14 +2554,15 @@ test('履约与指派两条路径都要过服务端规格复检(不只依赖
assert.ok(src.includes('consignment_match_note_required'))
})
test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => {
test('内部履约要投递延迟核销任务并写事件留痕', async () => {
const readFile = await import('node:fs/promises')
const src = await readFile.readFile(
new URL('./consignment-fulfillment-service.ts', import.meta.url),
'utf8',
)
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
assert.ok(src.includes('consignment_fulfill_voucher_consume_scheduled'))
assert.ok(src.includes('scheduleVoucherConsumeForWorkOrder'))
assert.ok(src.includes('if (!voucherConsume.ok)'))
})