发布工单时自动核销电子凭证

This commit is contained in:
yml2213
2026-08-16 16:43:32 +08:00
parent bc2909d6f2
commit e641064b63
5 changed files with 522 additions and 14 deletions
@@ -91,6 +91,7 @@ import {
getWorkerFinanceConfig,
saveWorkerFinanceConfig,
} from './worker-finance-config-service.js'
import { 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 { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js'
@@ -1039,12 +1040,45 @@ export async function publishAdminWorkOrder(workOrderId: number | string) {
errorCode: 'work_order_publish_reward_invalid',
})
}
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
if (!voucherConsume.ok) {
const failedAt = nowIso()
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'publish_voucher_consume_failed',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify(voucherConsume),
now: failedAt,
})
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
statusCode: 409,
errorCode: 'work_order_publish_voucher_consume_failed',
context: voucherConsume,
})
}
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.OPEN,
published_at: now,
updated_at: now,
})
if (!updated) {
throw createHttpError(
voucherConsume.voucherCount > 0
? '电子凭证已核销,但订单发布失败,请重试发布'
: '订单发布失败,请重试',
{
statusCode: 409,
errorCode: 'work_order_publish_update_failed',
context: voucherConsume,
},
)
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
@@ -1052,9 +1086,10 @@ export async function publishAdminWorkOrder(workOrderId: number | string) {
eventType: 'published',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.OPEN,
payloadJson: JSON.stringify({ voucherConsume }),
now,
})
return { order: mapWorkOrderAdmin(updated || workOrder) }
return { order: mapWorkOrderAdmin(updated), voucherConsume }
}
export async function unpublishAdminWorkOrder(workOrderId: number | string) {
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
resolveWorkOrderVoucherTargets,
} from './publish-voucher-service.js'
function buildWorkOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
return {
id: 1,
work_order_no: 'WO001',
order_id: 10,
order_item_id: 20,
task_id: null,
platform_order_id: 'OID001',
product_name: '测试商品',
category_id: null,
status: 'unassigned',
reward_amount: 100,
required_deposit_amount: 0,
deposit_threshold_amount: 20000,
assigned_worker_id: null,
last_assigned_worker_id: null,
material_json: '{}',
requirement_json: '{}',
acceptance_json: '{}',
draft_acceptance_json: '{}',
problem_note: '',
sharing_enabled: false,
sharing_total_quantity: 1,
sharing_unit_reward: 0,
published_at: null,
pinned_at: null,
assigned_at: null,
submitted_at: null,
accepted_at: null,
deadline_at: null,
timeout_minutes: 0,
timeout_policy: 'reopen',
created_at: '2026-08-16T00:00:00.000Z',
updated_at: '2026-08-16T00:00:00.000Z',
...overrides,
}
}
function buildTask(id: number, orderItemId: number): TaskRow {
return {
id,
order_id: 10,
order_item_id: orderItemId,
unit_index: 1,
platform_order_id: 'OID001',
profile_id: 1,
task_no: `DT${id}`,
executor_key: 'manual_dispatch',
task_status: 'pending',
delivery_status: 'pending',
result_code: '',
result_message: '',
automation_mode: 'manual',
requires_claim: false,
user_action_status: 'not_required',
attempt_count: 0,
runtime_session_id: '',
login_type: '',
nickname: '',
role_name: '',
role_id: '',
area: '',
partition_name: '',
claim_token: '',
primary_claim_token: '',
primary_claim_token_id: null,
primary_claim_token_status: '',
artifacts_json: '{}',
context_json: '{}',
screenshot_path: '',
last_error: '',
retry_count: 0,
created_at: '2026-08-16T00:00:00.000Z',
updated_at: '2026-08-16T00:00:00.000Z',
claimed_at: null,
role_confirmed_at: null,
redeemed_at: null,
}
}
function buildVoucher(
id: number,
taskId: number | null,
status = 'UNUSED',
): KuaishouIndustryVoucherRow {
return {
id,
voucher_code: `KSV${id}`,
oid: 'OID001',
order_id: 10,
task_id: taskId,
unit_index: id,
seller_id: 'seller-1',
token: 'token',
eticket_type: 'GAME_OPEN_TICKET_CONSUME',
status,
valid_start_time: 0,
valid_end_time: 0,
consume_serial_num: '',
consume_details_json: [],
consumed_at: status === 'CONSUMED' ? '2026-08-16T00:00:00.000Z' : null,
destroyed_at: null,
send_callback_status: 'success',
send_callback_attempt_count: 1,
send_callback_last_error: '',
send_callback_response_json: {},
send_callback_sent_at: '2026-08-16T00:00:00.000Z',
raw_payload_json: {},
created_at: '2026-08-16T00:00:00.000Z',
updated_at: '2026-08-16T00:00:00.000Z',
}
}
test('发布核销优先选择当前订单商品关联任务的券码', () => {
const tasks = [buildTask(100, 20), buildTask(200, 30)]
const vouchers = [buildVoucher(1, 100), buildVoucher(2, 200)]
const targets = resolveWorkOrderVoucherTargets(buildWorkOrder(), tasks, vouchers)
assert.deepEqual(
targets.map((target) => target.voucher.voucher_code),
['KSV1'],
)
assert.equal(targets[0]?.task?.id, 100)
})
test('历史券码未绑定任务时回退核销同一平台订单的券码', () => {
const targets = resolveWorkOrderVoucherTargets(
buildWorkOrder(),
[buildTask(100, 20)],
[buildVoucher(1, null)],
)
assert.deepEqual(
targets.map((target) => target.voucher.voucher_code),
['KSV1'],
)
assert.equal(targets[0]?.task, null)
})
test('不会核销已明确绑定到其他商品任务的券码', () => {
const targets = resolveWorkOrderVoucherTargets(
buildWorkOrder(),
[buildTask(100, 20), buildTask(200, 30)],
[buildVoucher(2, 200)],
)
assert.deepEqual(targets, [])
})
test('核销失败时阻止发布并返回失败信息', async () => {
const voucher = buildVoucher(1, null)
const result = await consumeIndustryVouchersBeforeWorkOrderPublish(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, '快手核销回调失败')
})
test('发布前核销券码并同步关联任务状态', async () => {
const task = buildTask(100, 20)
const voucher = buildVoucher(1, 100)
const consumedVoucher = {
...voucher,
status: 'CONSUMED',
consumed_at: '2026-08-16T01:00:00.000Z',
}
const consumedCodes: string[] = []
const attachedTaskIds: number[] = []
const result = await consumeIndustryVouchersBeforeWorkOrderPublish(buildWorkOrder(), {
listVouchersByOid: async () => [voucher],
listTasksByOrderId: async () => [task],
consumeVoucher: async (currentVoucher) => {
consumedCodes.push(currentVoucher.voucher_code)
return { ok: true, voucher: consumedVoucher, callbackSuccess: true }
},
attachVoucherToTask: async (currentTask) => {
attachedTaskIds.push(currentTask.id)
return currentTask
},
})
assert.equal(result.ok, true)
assert.equal(result.voucherCount, 1)
assert.equal(result.consumedCount, 1)
assert.equal(result.alreadyConsumedCount, 0)
assert.deepEqual(consumedCodes, ['KSV1'])
assert.deepEqual(attachedTaskIds, [100])
})
test('已核销券码按幂等成功处理', async () => {
const voucher = buildVoucher(1, null, 'CONSUMED')
const result = await consumeIndustryVouchersBeforeWorkOrderPublish(buildWorkOrder(), {
listVouchersByOid: async () => [voucher],
listTasksByOrderId: async () => [],
consumeVoucher: async () => ({ ok: true, voucher, callbackSuccess: true }),
attachVoucherToTask: async (task) => task,
})
assert.equal(result.ok, true)
assert.equal(result.voucherCount, 1)
assert.equal(result.consumedCount, 0)
assert.equal(result.alreadyConsumedCount, 1)
})
@@ -0,0 +1,179 @@
import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js'
export type WorkOrderVoucherConsumeResult = {
ok: boolean
voucherCount: number
consumedCount: number
alreadyConsumedCount: number
failedCount: number
voucherCodes: string[]
errorMessage: string
}
type PublishVoucherServiceDeps = {
listVouchersByOid: typeof listKuaishouIndustryVouchersByOid
listTasksByOrderId: typeof listTasksByOrderId
consumeVoucher: typeof consumeKuaishouIndustryVoucher
attachVoucherToTask: typeof attachKuaishouIndustryVoucherToTask
}
const defaultDeps: PublishVoucherServiceDeps = {
listVouchersByOid: listKuaishouIndustryVouchersByOid,
listTasksByOrderId,
consumeVoucher: consumeKuaishouIndustryVoucher,
attachVoucherToTask: attachKuaishouIndustryVoucherToTask,
}
export async function consumeIndustryVouchersBeforeWorkOrderPublish(
workOrder: WorkOrderRow,
deps: PublishVoucherServiceDeps = defaultDeps,
): Promise<WorkOrderVoucherConsumeResult> {
const oid = String(workOrder.platform_order_id || '').trim()
if (!oid) {
return buildConsumeResult([])
}
const [vouchers, tasks] = await Promise.all([
deps.listVouchersByOid(oid),
workOrder.order_id ? deps.listTasksByOrderId(workOrder.order_id) : Promise.resolve([]),
])
const targets = resolveWorkOrderVoucherTargets(workOrder, tasks, vouchers)
let consumedCount = 0
let alreadyConsumedCount = 0
for (const target of targets) {
const voucherCode = String(target.voucher.voucher_code || '').trim()
const alreadyConsumed = normalizeVoucherStatus(target.voucher.status) === 'CONSUMED'
let consumeResult: Awaited<ReturnType<typeof consumeKuaishouIndustryVoucher>>
try {
consumeResult = await deps.consumeVoucher(target.voucher, {
source: 'work_order_publish',
consumeTime: Date.now(),
...(target.task ? { task: target.task } : {}),
})
} catch (error) {
return buildConsumeResult(targets, {
consumedCount,
alreadyConsumedCount,
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
})
}
if (!consumeResult.ok || !consumeResult.voucher) {
return buildConsumeResult(targets, {
consumedCount,
alreadyConsumedCount,
errorMessage: consumeResult.errorMessage || `电子凭证 ${voucherCode} 核销失败`,
})
}
if (alreadyConsumed) {
alreadyConsumedCount += 1
} else {
consumedCount += 1
}
try {
if (target.task) {
await deps.attachVoucherToTask(target.task, consumeResult.voucher, {
source: 'work_order_publish',
now: new Date().toISOString(),
})
}
} catch (error) {
return buildConsumeResult(targets, {
consumedCount,
alreadyConsumedCount,
errorMessage: `电子凭证 ${voucherCode} 已核销,但任务状态同步失败:${
error instanceof Error ? error.message : '未知错误'
}`,
})
}
}
return buildConsumeResult(targets, {
consumedCount,
alreadyConsumedCount,
})
}
export function resolveWorkOrderVoucherTargets(
workOrder: WorkOrderRow,
tasks: TaskRow[],
vouchers: KuaishouIndustryVoucherRow[],
) {
const taskById = new Map(tasks.map((task) => [Number(task.id), task]))
const linkedTaskIds = new Set<number>()
if (workOrder.task_id) {
linkedTaskIds.add(Number(workOrder.task_id))
}
if (workOrder.order_item_id) {
for (const task of tasks) {
if (Number(task.order_item_id) === Number(workOrder.order_item_id)) {
linkedTaskIds.add(Number(task.id))
}
}
}
const linkedVouchers = vouchers.filter(
(voucher) => voucher.task_id && linkedTaskIds.has(Number(voucher.task_id)),
)
// 历史数据可能尚未完成任务绑定;此时回退到同一平台订单,避免仍需人工核销。
const hasTaskBoundVoucher = vouchers.some((voucher) => Number(voucher.task_id || 0) > 0)
const selectedVouchers =
linkedVouchers.length > 0
? linkedVouchers
: linkedTaskIds.size > 0 && hasTaskBoundVoucher
? []
: vouchers
return deduplicateVouchers(selectedVouchers).map((voucher) => ({
voucher,
task: voucher.task_id ? taskById.get(Number(voucher.task_id)) || null : null,
}))
}
function buildConsumeResult(
targets: Array<{ voucher: KuaishouIndustryVoucherRow }>,
input: {
consumedCount?: number
alreadyConsumedCount?: number
errorMessage?: string
} = {},
): WorkOrderVoucherConsumeResult {
const errorMessage = String(input.errorMessage || '').trim()
const processedCount = Number(input.consumedCount || 0) + Number(input.alreadyConsumedCount || 0)
return {
ok: !errorMessage,
voucherCount: targets.length,
consumedCount: Number(input.consumedCount || 0),
alreadyConsumedCount: Number(input.alreadyConsumedCount || 0),
failedCount: errorMessage ? Math.max(1, targets.length - processedCount) : 0,
voucherCodes: targets.map((target) => String(target.voucher.voucher_code || '').trim()),
errorMessage,
}
}
function deduplicateVouchers(vouchers: KuaishouIndustryVoucherRow[]) {
const seen = new Set<string>()
return vouchers.filter((voucher) => {
const key = String(voucher.voucher_code || voucher.id || '').trim()
if (!key || seen.has(key)) return false
seen.add(key)
return true
})
}
function normalizeVoucherStatus(value: unknown) {
return String(value || '')
.trim()
.toUpperCase()
}
@@ -19,6 +19,7 @@ import {
Alert,
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
@@ -128,6 +129,8 @@ const FILTERABLE_WORK_ORDER_STATUSES = [
'cancelled',
] as const
const SKIP_PUBLISH_CONFIRM_KEY = 'admin-work-order-skip-publish-confirm'
type MaterialFormValues = {
fields?: Record<string, string>
rewardAmount?: number
@@ -347,13 +350,15 @@ export default function WorkOrdersPanel() {
])
}
async function runAction(
action: () => Promise<unknown>,
successMessage: string,
async function runAction<T>(
action: () => Promise<T>,
successMessage: string | ((result: T) => string),
) {
try {
await action()
message.success(successMessage)
const result = await action()
message.success(
typeof successMessage === 'function' ? successMessage(result) : successMessage,
)
await refreshAll()
return true
} catch (error) {
@@ -362,6 +367,48 @@ export default function WorkOrdersPanel() {
}
}
function publishWorkOrder(row: WorkOrder) {
return runAction(
() => publishAdminWorkOrder(row.workOrderId),
(response) =>
response.data.voucherConsume.voucherCount > 0
? '电子凭证已自动核销,订单已发布到大厅'
: '订单已发布到大厅',
)
}
function confirmPublishWorkOrder(row: WorkOrder) {
if (shouldSkipPublishConfirm()) {
void publishWorkOrder(row)
return
}
let skipFutureConfirm = false
modal.confirm({
title: '确认发布并核销该订单?',
content: (
<div>
<Typography.Paragraph>
使
</Typography.Paragraph>
<Checkbox
onChange={(event) => {
skipFutureConfirm = event.target.checked
}}
>
</Checkbox>
</div>
),
okText: '发布并核销',
cancelText: '取消',
onOk: () => {
if (skipFutureConfirm) setSkipPublishConfirm()
return publishWorkOrder(row)
},
})
}
function confirmBatchAcceptance() {
const workOrderIds = selectedAcceptanceOrderIds
if (workOrderIds.length === 0) {
@@ -792,14 +839,9 @@ export default function WorkOrdersPanel() {
? '接单金额为 0,不能发布到大厅'
: undefined
}
onClick={() =>
runAction(
() => publishAdminWorkOrder(row.workOrderId),
'已发布到大厅',
)
}
onClick={() => confirmPublishWorkOrder(row)}
>
</Button>
) : null}
{row.status === 'open' ? (
@@ -1687,6 +1729,22 @@ export default function WorkOrdersPanel() {
)
}
function shouldSkipPublishConfirm() {
try {
return localStorage.getItem(SKIP_PUBLISH_CONFIRM_KEY) === '1'
} catch {
return false
}
}
function setSkipPublishConfirm() {
try {
localStorage.setItem(SKIP_PUBLISH_CONFIRM_KEY, '1')
} catch {
// 浏览器禁用本地存储时继续发布,但不记住本次选择。
}
}
function formatUserPaymentAmount(order: WorkOrder): string {
const paymentFen = getUserPaymentFen(order)
return paymentFen === null ? '-' : formatMoney(paymentFen)
@@ -247,7 +247,18 @@ export function submitAdminWorkOrderMaterial(
}
export function publishAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(
return apiPost<{
order: WorkOrder
voucherConsume: {
ok: boolean
voucherCount: number
consumedCount: number
alreadyConsumedCount: number
failedCount: number
voucherCodes: string[]
errorMessage: string
}
}>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`,
)
}