指派工单时自动核销电子凭证

This commit is contained in:
yml2213
2026-08-16 16:59:02 +08:00
parent e641064b63
commit 8705f6a6a1
5 changed files with 164 additions and 37 deletions
@@ -91,7 +91,10 @@ import {
getWorkerFinanceConfig, getWorkerFinanceConfig,
saveWorkerFinanceConfig, saveWorkerFinanceConfig,
} from './worker-finance-config-service.js' } from './worker-finance-config-service.js'
import { consumeIndustryVouchersBeforeWorkOrderPublish } from './publish-voucher-service.js' import {
consumeIndustryVouchersBeforeWorkOrderAssign,
consumeIndustryVouchersBeforeWorkOrderPublish,
} from './publish-voucher-service.js'
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeStatuses, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, resolveMatchingProductRule, resolveRequirementFields, resolveSkuNameQuantity, resolveWorkerPermissions } from './mappers.js' import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeStatuses, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, resolveMatchingProductRule, resolveRequirementFields, resolveSkuNameQuantity, resolveWorkerPermissions } from './mappers.js'
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js' import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js'
@@ -425,6 +428,26 @@ export async function assignAdminWorkOrderToWorker(
}) })
} }
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder)
if (!voucherConsume.ok) {
const failedAt = nowIso()
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
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 now = nowIso()
const { order: updated, failureReason } = await assignWorkOrderToWorker({ const { order: updated, failureReason } = await assignWorkOrderToWorker({
workOrderId: workOrder.id, workOrderId: workOrder.id,
@@ -435,30 +458,46 @@ export async function assignAdminWorkOrderToWorker(
now, now,
}) })
if (failureReason === 'work_order_not_assignable') { if (failureReason === 'work_order_not_assignable') {
throw createHttpError('订单状态已变化,无法指派', { throw createHttpError(
statusCode: 409, resolveAssignFailureMessage(voucherConsume.voucherCount, '订单状态已变化,无法指派'),
errorCode: 'work_order_not_assignable', {
}) statusCode: 409,
errorCode: 'work_order_not_assignable',
},
)
} }
if (failureReason === 'worker_active_order_limit') { if (failureReason === 'worker_active_order_limit') {
throw createHttpError('该打手已达到最大同时接单量', { throw createHttpError(
statusCode: 409, resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手已达到最大同时接单量'),
errorCode: 'worker_active_order_limit', {
}) statusCode: 409,
errorCode: 'worker_active_order_limit',
},
)
} }
if (failureReason === 'worker_deposit_insufficient') { if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError('该打手余额不足,无法冻结所需押金', { throw createHttpError(
statusCode: 409, resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手余额不足,无法冻结所需押金'),
errorCode: 'worker_deposit_insufficient', {
}) statusCode: 409,
errorCode: 'worker_deposit_insufficient',
},
)
} }
if (!updated) { if (!updated) {
throw createHttpError('指派失败,订单状态可能已变化', { throw createHttpError(
statusCode: 409, resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'),
errorCode: 'work_order_assign_conflict', {
}) statusCode: 409,
errorCode: 'work_order_assign_conflict',
},
)
} }
return { order: mapWorkOrderAdmin(updated) } return { order: mapWorkOrderAdmin(updated), voucherConsume }
}
function resolveAssignFailureMessage(voucherCount: number, fallback: string) {
return voucherCount > 0 ? `电子凭证已核销,但${fallback},请重试指派` : fallback
} }
function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null { function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null {
@@ -4,6 +4,7 @@ import test from 'node:test'
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
import { import {
consumeIndustryVouchersBeforeWorkOrderAssign,
consumeIndustryVouchersBeforeWorkOrderPublish, consumeIndustryVouchersBeforeWorkOrderPublish,
resolveWorkOrderVoucherTargets, resolveWorkOrderVoucherTargets,
} from './publish-voucher-service.js' } from './publish-voucher-service.js'
@@ -223,3 +224,67 @@ test('已核销券码按幂等成功处理', async () => {
assert.equal(result.consumedCount, 0) assert.equal(result.consumedCount, 0)
assert.equal(result.alreadyConsumedCount, 1) assert.equal(result.alreadyConsumedCount, 1)
}) })
test('指派前核销使用独立来源并同步关联任务状态', async () => {
const task = buildTask(100, 20)
const voucher = buildVoucher(1, 100)
const consumeSources: string[] = []
const attachSources: string[] = []
const result = await consumeIndustryVouchersBeforeWorkOrderAssign(buildWorkOrder(), {
listVouchersByOid: async () => [voucher],
listTasksByOrderId: async () => [task],
consumeVoucher: async (currentVoucher, options) => {
consumeSources.push(String(options.source || ''))
return {
ok: true,
voucher: { ...currentVoucher, status: 'CONSUMED' },
callbackSuccess: true,
}
},
attachVoucherToTask: async (currentTask, _currentVoucher, options) => {
attachSources.push(String(options.source || ''))
return currentTask
},
})
assert.equal(result.ok, true)
assert.deepEqual(consumeSources, ['work_order_assign'])
assert.deepEqual(attachSources, ['work_order_assign'])
})
test('指派前没有关联电子凭证时直接成功', async () => {
let consumeCalled = false
const result = await consumeIndustryVouchersBeforeWorkOrderAssign(buildWorkOrder(), {
listVouchersByOid: async () => [],
listTasksByOrderId: async () => [],
consumeVoucher: async (voucher) => {
consumeCalled = true
return { ok: true, voucher, callbackSuccess: true }
},
attachVoucherToTask: async (task) => task,
})
assert.equal(result.ok, true)
assert.equal(result.voucherCount, 0)
assert.equal(consumeCalled, false)
})
test('指派前电子凭证核销失败时返回失败结果', async () => {
const voucher = buildVoucher(1, null)
const result = await consumeIndustryVouchersBeforeWorkOrderAssign(buildWorkOrder(), {
listVouchersByOid: async () => [voucher],
listTasksByOrderId: async () => [],
consumeVoucher: async () => ({
ok: false,
voucher,
callbackSuccess: false,
errorMessage: '指派前核销失败',
}),
attachVoucherToTask: async (task) => task,
})
assert.equal(result.ok, false)
assert.equal(result.failedCount, 1)
assert.equal(result.errorMessage, '指派前核销失败')
})
@@ -15,14 +15,14 @@ export type WorkOrderVoucherConsumeResult = {
errorMessage: string errorMessage: string
} }
type PublishVoucherServiceDeps = { type WorkOrderVoucherServiceDeps = {
listVouchersByOid: typeof listKuaishouIndustryVouchersByOid listVouchersByOid: typeof listKuaishouIndustryVouchersByOid
listTasksByOrderId: typeof listTasksByOrderId listTasksByOrderId: typeof listTasksByOrderId
consumeVoucher: typeof consumeKuaishouIndustryVoucher consumeVoucher: typeof consumeKuaishouIndustryVoucher
attachVoucherToTask: typeof attachKuaishouIndustryVoucherToTask attachVoucherToTask: typeof attachKuaishouIndustryVoucherToTask
} }
const defaultDeps: PublishVoucherServiceDeps = { const defaultDeps: WorkOrderVoucherServiceDeps = {
listVouchersByOid: listKuaishouIndustryVouchersByOid, listVouchersByOid: listKuaishouIndustryVouchersByOid,
listTasksByOrderId, listTasksByOrderId,
consumeVoucher: consumeKuaishouIndustryVoucher, consumeVoucher: consumeKuaishouIndustryVoucher,
@@ -31,7 +31,22 @@ const defaultDeps: PublishVoucherServiceDeps = {
export async function consumeIndustryVouchersBeforeWorkOrderPublish( export async function consumeIndustryVouchersBeforeWorkOrderPublish(
workOrder: WorkOrderRow, workOrder: WorkOrderRow,
deps: PublishVoucherServiceDeps = defaultDeps, deps: WorkOrderVoucherServiceDeps = defaultDeps,
): Promise<WorkOrderVoucherConsumeResult> {
return consumeIndustryVouchersBeforeWorkOrderAction(workOrder, 'work_order_publish', deps)
}
export async function consumeIndustryVouchersBeforeWorkOrderAssign(
workOrder: WorkOrderRow,
deps: WorkOrderVoucherServiceDeps = defaultDeps,
): Promise<WorkOrderVoucherConsumeResult> {
return consumeIndustryVouchersBeforeWorkOrderAction(workOrder, 'work_order_assign', deps)
}
async function consumeIndustryVouchersBeforeWorkOrderAction(
workOrder: WorkOrderRow,
source: 'work_order_publish' | 'work_order_assign',
deps: WorkOrderVoucherServiceDeps,
): Promise<WorkOrderVoucherConsumeResult> { ): Promise<WorkOrderVoucherConsumeResult> {
const oid = String(workOrder.platform_order_id || '').trim() const oid = String(workOrder.platform_order_id || '').trim()
if (!oid) { if (!oid) {
@@ -53,7 +68,7 @@ export async function consumeIndustryVouchersBeforeWorkOrderPublish(
try { try {
consumeResult = await deps.consumeVoucher(target.voucher, { consumeResult = await deps.consumeVoucher(target.voucher, {
source: 'work_order_publish', source,
consumeTime: Date.now(), consumeTime: Date.now(),
...(target.task ? { task: target.task } : {}), ...(target.task ? { task: target.task } : {}),
}) })
@@ -82,7 +97,7 @@ export async function consumeIndustryVouchersBeforeWorkOrderPublish(
try { try {
if (target.task) { if (target.task) {
await deps.attachVoucherToTask(target.task, consumeResult.voucher, { await deps.attachVoucherToTask(target.task, consumeResult.voucher, {
source: 'work_order_publish', source,
now: new Date().toISOString(), now: new Date().toISOString(),
}) })
} }
@@ -275,7 +275,10 @@ export default function WorkOrdersPanel() {
assignAdminWorkOrderToWorker(assignOrder.workOrderId, { assignAdminWorkOrderToWorker(assignOrder.workOrderId, {
workerId: assignWorkerId, workerId: assignWorkerId,
}), }),
'已指派打手', (response) =>
response.data.voucherConsume.voucherCount > 0
? '电子凭证已自动核销,订单已指派给打手'
: '已指派打手',
) )
if (!succeeded) return if (!succeeded) return
setAssignOrder(null) setAssignOrder(null)
@@ -810,7 +813,7 @@ export default function WorkOrdersPanel() {
) : null} ) : null}
{['unassigned', 'open'].includes(row.status) ? ( {['unassigned', 'open'].includes(row.status) ? (
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}> <Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
</Button> </Button>
) : null} ) : null}
{row.status === 'in_progress' && row.worker ? ( {row.status === 'in_progress' && row.worker ? (
@@ -1567,14 +1570,14 @@ export default function WorkOrdersPanel() {
setAssignWorkerId(undefined) setAssignWorkerId(undefined)
}} }}
onOk={() => submitAssign()} onOk={() => submitAssign()}
okText="确认指派" okText="指派并核销"
okButtonProps={{ disabled: !assignWorkerId }} okButtonProps={{ disabled: !assignWorkerId }}
destroyOnHidden destroyOnHidden
width={520} width={520}
> >
<Space direction="vertical" size={12} className="full-width"> <Space direction="vertical" size={12} className="full-width">
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
</Typography.Paragraph> </Typography.Paragraph>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -12,6 +12,16 @@ import type {
WorkerUser, WorkerUser,
} from '@/types/worker-platform' } from '@/types/worker-platform'
type WorkOrderVoucherConsumeResult = {
ok: boolean
voucherCount: number
consumedCount: number
alreadyConsumedCount: number
failedCount: number
voucherCodes: string[]
errorMessage: string
}
export function fetchAdminWorkerPlatformSummary() { export function fetchAdminWorkerPlatformSummary() {
return apiGet<{ return apiGet<{
pendingWorkerCount: number pendingWorkerCount: number
@@ -137,7 +147,10 @@ export function assignAdminWorkOrderToWorker(
workOrderId: number, workOrderId: number,
payload: { workerId: number }, payload: { workerId: number },
) { ) {
return apiPost<{ order: WorkOrder }>( return apiPost<{
order: WorkOrder
voucherConsume: WorkOrderVoucherConsumeResult
}>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/assign`, `/api/v1/admin/worker-platform/orders/${workOrderId}/assign`,
payload, payload,
) )
@@ -249,15 +262,7 @@ export function submitAdminWorkOrderMaterial(
export function publishAdminWorkOrder(workOrderId: number) { export function publishAdminWorkOrder(workOrderId: number) {
return apiPost<{ return apiPost<{
order: WorkOrder order: WorkOrder
voucherConsume: { voucherConsume: WorkOrderVoucherConsumeResult
ok: boolean
voucherCount: number
consumedCount: number
alreadyConsumedCount: number
failedCount: number
voucherCodes: string[]
errorMessage: string
}
}>( }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`, `/api/v1/admin/worker-platform/orders/${workOrderId}/publish`,
) )