发布工单时自动核销电子凭证
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user