From e641064b636dce0756905660378d843352aeaded Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 16:43:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=B7=A5=E5=8D=95=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A0=B8=E9=94=80=E7=94=B5=E5=AD=90=E5=87=AD?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/worker-platform/admin-service.ts | 37 ++- .../publish-voucher-service.test.ts | 225 ++++++++++++++++++ .../publish-voucher-service.ts | 179 ++++++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 82 ++++++- .../src/services/admin/worker-platform.ts | 13 +- 5 files changed, 522 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/publish-voucher-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/publish-voucher-service.ts diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 30aaca94..8314e40a 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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) { diff --git a/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts new file mode 100644 index 00000000..6d9bbffa --- /dev/null +++ b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts @@ -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 { + 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) +}) diff --git a/apps/backend/src/services/worker-platform/publish-voucher-service.ts b/apps/backend/src/services/worker-platform/publish-voucher-service.ts new file mode 100644 index 00000000..b021a393 --- /dev/null +++ b/apps/backend/src/services/worker-platform/publish-voucher-service.ts @@ -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 { + 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> + + 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() + + 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() + 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() +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 3c24634d..712ccebd 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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 rewardAmount?: number @@ -347,13 +350,15 @@ export default function WorkOrdersPanel() { ]) } - async function runAction( - action: () => Promise, - successMessage: string, + async function runAction( + action: () => Promise, + 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: ( +
+ + 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。 + + { + skipFutureConfirm = event.target.checked + }} + > + 不再提示 + +
+ ), + 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)} > - 发布 + 发布并核销 ) : 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) diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 3f9488c3..f31db225 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -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`, ) }