diff --git a/apps/backend/src/db/migrations/003_kuaishou_industry_voucher_eticket_type.sql b/apps/backend/src/db/migrations/003_kuaishou_industry_voucher_eticket_type.sql new file mode 100644 index 00000000..1397632c --- /dev/null +++ b/apps/backend/src/db/migrations/003_kuaishou_industry_voucher_eticket_type.sql @@ -0,0 +1,17 @@ +-- 003_kuaishou_industry_voucher_eticket_type.sql +-- 电子凭证类型落库,避免后台默认 DINING_OPEN_TICKET 覆盖真实类目类型。 + +ALTER TABLE kuaishou_industry_vouchers + ADD COLUMN IF NOT EXISTS eticket_type TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN kuaishou_industry_vouchers.eticket_type IS + '快手电子凭证类型,发码请求 eticketType(如 GAME_OPEN_TICKET_CONSUME)'; + +-- 从历史 raw_payload 回填 +UPDATE kuaishou_industry_vouchers +SET eticket_type = COALESCE( + NULLIF(BTRIM(raw_payload_json #>> '{body,eticketType}'), ''), + NULLIF(BTRIM(raw_payload_json ->> 'eticketType'), ''), + eticket_type +) +WHERE BTRIM(COALESCE(eticket_type, '')) = ''; diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index d0fcfc99..a8b8b837 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -66,6 +66,7 @@ async function insertKuaishouIndustryVoucher( unit_index, seller_id, token, + eticket_type, status, valid_start_time, valid_end_time, @@ -94,17 +95,18 @@ async function insertKuaishouIndustryVoucher( $9, $10, $11, - $12::jsonb, - $13, + $12, + $13::jsonb, $14, $15, $16, $17, - $18::jsonb, - $19, - $20::jsonb, - $21, - $22 + $18, + $19::jsonb, + $20, + $21::jsonb, + $22, + $23 ) ON CONFLICT (oid, unit_index) DO UPDATE SET @@ -116,6 +118,10 @@ async function insertKuaishouIndustryVoucher( WHEN EXCLUDED.token <> '' THEN EXCLUDED.token ELSE kuaishou_industry_vouchers.token END, + eticket_type = CASE + WHEN EXCLUDED.eticket_type <> '' THEN EXCLUDED.eticket_type + ELSE kuaishou_industry_vouchers.eticket_type + END, order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id), task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id), valid_start_time = EXCLUDED.valid_start_time, @@ -157,6 +163,7 @@ async function insertKuaishouIndustryVoucher( input.unitIndex, input.sellerId || '', input.token || '', + input.eticketType || '', input.status || 'UNUSED', input.validStartTime || 0, input.validEndTime || 0, @@ -407,6 +414,10 @@ function normalizeVoucherPatchColumns( columns.push({ column: 'seller_id', value: patch.sellerId || '' }) } + if (patch.eticketType !== undefined) { + columns.push({ column: 'eticket_type', value: patch.eticketType || '' }) + } + if (patch.status !== undefined) { columns.push({ column: 'status', value: patch.status || 'UNUSED' }) } diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index c9bd2b2d..383402bb 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -3,6 +3,7 @@ import { approveAdminKuaishouIndustryRefund, checkAdminKuaishouIndustryVoucherAvailable, consumeAdminKuaishouIndustryVoucherByCode, + destroyAdminKuaishouIndustryVoucherByCode, disagreeAdminKuaishouIndustryRefund, listAdminKuaishouIndustryRefunds, listAdminKuaishouIndustryShops, @@ -17,6 +18,7 @@ import type { AdminKuaishouIndustryRefundListRouteBody, AdminKuaishouIndustryVoucherCheckAvailableRouteBody, AdminKuaishouIndustryVoucherConsumeRouteBody, + AdminKuaishouIndustryVoucherDestroyRouteBody, AdminKuaishouIndustryVoucherResendRouteBody, AdminKuaishouIndustryVoucherReverseRouteBody, } from '../../types/admin/route-inputs.js' @@ -156,6 +158,23 @@ router.post( ), ) +router.post( + '/kuaishou-industry/vouchers/destroy', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + destroyAdminKuaishouIndustryVoucherByCode( + req.body as AdminKuaishouIndustryVoucherDestroyRouteBody, + ), + { + successMessage: '电子凭证已手动销毁', + errorMessage: '手动销毁电子凭证失败', + scope: '[admin/kuaishou-industry/vouchers/destroy]', + audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_voucher_destroy', req.body, data), + }, + ), +) + function buildKuaishouIndustryAudit(action: string, body: unknown, data: unknown) { const payload = body && typeof body === 'object' ? body as Record : {} const result = data && typeof data === 'object' ? data as Record : {} diff --git a/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts index 0cfbd5fb..d24a096e 100644 --- a/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts +++ b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts @@ -19,13 +19,18 @@ import { type KuaishouIndustrySourceConfig, } from '../platforms/kuaishou-industry/source-config-service.js' import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou-industry/send-code-service.js' -import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js' +import { + consumeKuaishouIndustryVoucher, + destroyKuaishouIndustryVoucher, + resolveKuaishouIndustryEticketType, +} from '../platforms/kuaishou-industry/voucher-service.js' import { approveKuaishouIndustryRefund, disagreeKuaishouIndustryRefund, listKuaishouIndustryRefunds, } from '../platforms/kuaishou-industry/refund-service.js' import { reverseKuaishouIndustryCallback } from '../platforms/kuaishou-industry/reverse-callback-service.js' +import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js' import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js' @@ -103,10 +108,15 @@ export async function checkAdminKuaishouIndustryVoucherAvailable(input: JsonObje const payload = buildVoucherOpenApiPayload(input, voucher) assertRequired(payload.sellerId, '卖家编号未填写') - assertRequired(payload.eticketType || payload.bizTypeCode, '电子凭证类型未填写') + assertRequired(payload.eticketType || payload.bizTypeCode, '电子凭证类型未填写(请选择券码或手动填写类型)') assertEtickets(payload.etickets, '电子凭证列表未填写') - return mapOpenApiResult(await checkKuaishouIndustryEticketAvailable(payload)) + const result = mapOpenApiResult(await checkKuaishouIndustryEticketAvailable(payload)) + return { + ...result, + eticketType: String(payload.eticketType || payload.bizTypeCode || ''), + voucher: voucher ? mapAdminKuaishouIndustryVoucher(voucher) : null, + } } export async function reverseAdminKuaishouIndustryVoucher(input: JsonObject = {}) { @@ -188,12 +198,55 @@ export async function resendAdminKuaishouIndustryVoucherCode(input: JsonObject = } } +export async function destroyAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) { + const voucher = await resolveRequiredVoucher(input) + const task = voucher.task_id ? await getTaskById(voucher.task_id) : null + const destroyInput: Parameters[1] = { + source: 'admin_tool_manual_destroy', + reason: String(input.reason || '').trim(), + token: String(input.token || voucher.token || '').trim(), + eticketType: resolveKuaishouIndustryEticketType(voucher, input.eticketType), + } + if (task) { + destroyInput.task = task + } + + const result = await destroyKuaishouIndustryVoucher(voucher, destroyInput) + + if (!result.ok || !result.voucher) { + throw createHttpError(result.errorMessage || '电子凭证销毁失败', { + statusCode: 409, + errorCode: 'admin_kuaishou_industry_destroy_failed', + }) + } + + const nextTask = result.task || task + if (nextTask && result.voucher && !result.alreadyDestroyed) { + await attachKuaishouIndustryVoucherToTask(nextTask, result.voucher, { + source: 'admin_tool_manual_destroy', + now: new Date().toISOString(), + }) + } + + return { + success: true, + alreadyDestroyed: Boolean(result.alreadyDestroyed), + reason: result.reason || '', + voucher: mapAdminKuaishouIndustryVoucher(result.voucher), + task: nextTask ? mapTaskReference(nextTask) : null, + } +} + function buildVoucherOpenApiPayload( input: JsonObject, voucher: KuaishouIndustryVoucherRow | null, ): JsonObject { const etickets = normalizeAdminEtickets(input.etickets) const voucherCode = String(voucher?.voucher_code || input.voucherCode || '').trim() + const eticketType = resolveKuaishouIndustryEticketType( + voucher, + input.eticketType || input.bizTypeCode, + ) return { ...input, @@ -202,6 +255,8 @@ function buildVoucherOpenApiPayload( orderId: String(input.orderId || input.oid || voucher?.oid || '').trim(), token: String(input.token || voucher?.token || '').trim(), serialNum: String(input.serialNum || voucher?.consume_serial_num || '').trim(), + eticketType, + ...(eticketType ? { bizTypeCode: eticketType } : {}), etickets: etickets.length > 0 ? etickets @@ -414,6 +469,7 @@ function mapAdminKuaishouIndustryVoucher( skuCode: String(listRow.sku_code || '').trim(), skuName: String(listRow.sku_name || '').trim(), tokenMasked: maskSecret(voucher.token), + eticketType: resolveKuaishouIndustryEticketType(voucher), status: voucher.status, validStartTime: Number(voucher.valid_start_time || 0) || 0, validEndTime: Number(voucher.valid_end_time || 0) || 0, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts index 20ea7937..78d7507c 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts @@ -18,6 +18,7 @@ import { } from './response.js' import { destroyCallback } from './destroy-callback-service.js' import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js' +import { resolveKuaishouIndustryEticketType } from './voucher-service.js' export async function handleDestroyCode(rawBody: JsonObject = {}) { const config = getKuaishouIndustryConfig() @@ -39,6 +40,9 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) { const callbackSellerId = params.sellerId || String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim() const callbackToken = params.token || resolveDestroyCallbackToken(targetVouchers) + const eticketType = resolveKuaishouIndustryEticketType( + targetVouchers[0] || null, + ) for (const voucher of targetVouchers) { const status = String(voucher.status || '').trim().toUpperCase() @@ -81,6 +85,7 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) { })), reason: params.reason, token: callbackToken, + ...(eticketType ? { eticketType } : {}), }) return buildIndustrySuccessResponse({ oid: normalizedOid }) diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts index f2292c6a..ce7f7c06 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -28,6 +28,7 @@ import { buildKuaishouIndustryEticketFromVoucher, isKuaishouIndustryVoucherSendCallbackSuccess, KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS, + resolveKuaishouIndustryEticketType, resolveKuaishouIndustryVoucherValidity, } from './voucher-service.js' import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js' @@ -83,6 +84,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) { unitIndex, sellerId: params.sellerId, token: params.token, + eticketType: params.eticketType, orderId: order?.id || null, taskId: task?.id || null, status: 'UNUSED', @@ -558,7 +560,7 @@ function resolveSendCodeCallbackParams( sendType: String(body.sendType || 'VIRTUAL').trim() || 'VIRTUAL', sellerId: String(body.sellerId || voucher.seller_id || '').trim(), token: String(body.token || voucher.token || '').trim(), - eticketType: String(body.eticketType || '').trim(), + eticketType: resolveKuaishouIndustryEticketType(voucher, body.eticketType), ext: String(body.ext || '').trim(), } } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts index 47e5f56d..0b4407cb 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts @@ -6,6 +6,8 @@ import { buildKuaishouIndustryVoucherContext } from './voucher-binding-service.j import { buildKuaishouIndustryEticketFromVoucher, normalizeKuaishouIndustryConsumeType, + normalizeKuaishouIndustryDestroyReason, + resolveKuaishouIndustryEticketType, resolveKuaishouIndustryVoucherValidity, } from './voucher-service.js' @@ -82,6 +84,41 @@ test('buildKuaishouIndustryEticketFromVoucher mirrors voucher code into id and c assert.equal(eticket.eticketType, 'GAME_OPEN_TICKET_CONSUME') }) +test('resolveKuaishouIndustryEticketType prefers explicit then column then raw payload', () => { + assert.equal( + resolveKuaishouIndustryEticketType( + { + eticket_type: 'GAME_OPEN_TICKET_CONSUME', + raw_payload_json: { body: { eticketType: 'DINING_OPEN_TICKET' } }, + } as any, + 'EXPLICIT_TYPE', + ), + 'EXPLICIT_TYPE', + ) + + assert.equal( + resolveKuaishouIndustryEticketType({ + eticket_type: 'GAME_OPEN_TICKET_CONSUME', + raw_payload_json: { body: { eticketType: 'DINING_OPEN_TICKET' } }, + } as any), + 'GAME_OPEN_TICKET_CONSUME', + ) + + assert.equal( + resolveKuaishouIndustryEticketType({ + eticket_type: '', + raw_payload_json: { body: { eticketType: 'GAME_OPEN_TICKET_CONSUME' } }, + } as any), + 'GAME_OPEN_TICKET_CONSUME', + ) +}) + +test('normalizeKuaishouIndustryDestroyReason defaults to SYS_ADMIN_DESTROY', () => { + assert.equal(normalizeKuaishouIndustryDestroyReason(''), 'SYS_ADMIN_DESTROY') + assert.equal(normalizeKuaishouIndustryDestroyReason('supply_destroy'), 'SUPPLY_DESTROY') + assert.equal(normalizeKuaishouIndustryDestroyReason('unknown'), 'SYS_ADMIN_DESTROY') +}) + test('buildKuaishouIndustryVoucherContext maps consumed voucher state', () => { const context = buildKuaishouIndustryVoucherContext( { diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts index 56aecc77..b3113f6b 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts @@ -1,13 +1,17 @@ import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { getTaskById, updateTask } from '../../../repositories/task-repo.js' import { asJsonObject, type JsonObject } from '../../../types/json.js' import { findKuaishouIndustryVoucherByCode, listKuaishouIndustryVouchersByTaskId, updateKuaishouIndustryVoucherByCode, } from '../../../repositories/kuaishou-industry-voucher-repo.js' +import { TASK_STATUS } from '../../../domain/task-status.js' import { logWarn } from '../../../utils/logger.js' +import { normalizeTimestampIso } from '../../../utils/time.js' import { buildIndustryEticketItem } from './response.js' import { consumeCallback } from './consume-callback-service.js' +import { destroyCallback } from './destroy-callback-service.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js' export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3 @@ -17,6 +21,12 @@ export const KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS = { FAILED: 'failed', } as const export const KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE = 'consume' +export const KUAISHOU_INDUSTRY_DESTROY_REASONS = { + ETICKET_EXPIRED: 'ETICKET_EXPIRED', + USER_APPLY_REFUND: 'USER_APPLY_REFUND', + SUPPLY_DESTROY: 'SUPPLY_DESTROY', + SYS_ADMIN_DESTROY: 'SYS_ADMIN_DESTROY', +} as const export function resolveKuaishouIndustryVoucherValidity( input: { @@ -50,6 +60,33 @@ export function resolveKuaishouIndustryVoucherValidity( } } +/** + * 解析电子凭证类型。优先级: + * 显式入参 > voucher.eticket_type > raw_payload.body.eticketType > raw_payload.eticketType + */ +export function resolveKuaishouIndustryEticketType( + voucher?: Pick | null, + explicit?: unknown, +): string { + const fromInput = String(explicit || '').trim() + if (fromInput) { + return fromInput + } + + if (!voucher) { + return '' + } + + const fromColumn = String(voucher.eticket_type || '').trim() + if (fromColumn) { + return fromColumn + } + + const rawPayload = parseJsonObject(voucher.raw_payload_json) + const body = parseJsonObject(rawPayload.body) + return String(body.eticketType || rawPayload.eticketType || '').trim() +} + export function buildKuaishouIndustryEticketFromVoucher( voucher: KuaishouIndustryVoucherRow, eticketType = '', @@ -61,7 +98,7 @@ export function buildKuaishouIndustryEticketFromVoucher( num: 1, validStartTime: Number(voucher.valid_start_time || 0) || 0, validEndTime: Number(voucher.valid_end_time || 0) || 0, - eticketType, + eticketType: resolveKuaishouIndustryEticketType(voucher, eticketType), consumeDetails: resolveVoucherConsumeDetails(voucher), }) } @@ -274,6 +311,128 @@ export async function consumeKuaishouIndustryVoucher( } } +/** + * 主动销毁电子凭证(管理端/运维补救)。 + * 先调快手销毁回调,成功后再落本地 DESTROYED 并关闭关联任务。 + * 已 DESTROYED 时仅重试回调(幂等)。 + */ +export async function destroyKuaishouIndustryVoucher( + voucher: KuaishouIndustryVoucherRow, + input: { + source?: string + reason?: string + token?: string + eticketType?: string + task?: TaskRow | null + skipCallback?: boolean + goodsValue?: number + } = {}, +) { + const currentStatus = normalizeVoucherStatus(voucher.status) + if (currentStatus === 'CONSUMED') { + return { + ok: false, + voucher, + callbackSuccess: false, + errorMessage: '电子凭证已核销,不能销毁;如需作废请先冲正', + } + } + + const reason = normalizeKuaishouIndustryDestroyReason(input.reason) + const now = normalizeTimestampIso(new Date().toISOString()) + const token = String(input.token || voucher.token || '').trim() + const eticketType = resolveKuaishouIndustryEticketType(voucher, input.eticketType) + const alreadyDestroyed = currentStatus === 'DESTROYED' + + const callbackResult = input.skipCallback + ? { success: true as const } + : await destroyCallback({ + oid: voucher.oid, + sellerId: String(voucher.seller_id || '').trim(), + etickets: [{ + id: voucher.voucher_code, + code: voucher.voucher_code, + num: 1, + ...(input.goodsValue != null ? { goodsValue: input.goodsValue } : {}), + }], + reason, + token, + ...(eticketType ? { eticketType } : {}), + }) + + if (!callbackResult.success) { + return { + ok: false, + voucher, + callbackSuccess: false, + errorMessage: callbackResult.error || '电子凭证销毁回调失败', + } + } + + let updated = voucher + if (!alreadyDestroyed) { + const next = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, { + status: 'DESTROYED', + destroyedAt: now, + updatedAt: now, + ...(eticketType && !String(voucher.eticket_type || '').trim() + ? { eticketType } + : {}), + }) + updated = next || voucher + } + + let task = + input.task || + (voucher.task_id ? await getTaskById(voucher.task_id).catch(() => null) : null) + + if (task && !alreadyDestroyed) { + task = await updateTask(task.id, { + task_status: TASK_STATUS.CLOSED, + delivery_status: 'cancelled', + result_code: reason, + result_message: `电子凭证销毁: ${reason}`, + updated_at: now, + }) || task + + await createTaskEvent( + task.id, + 'kuaishou_industry_voucher_destroyed', + { + oid: voucher.oid, + voucherCode: voucher.voucher_code, + reason, + source: input.source || 'admin_tool_manual_destroy', + eticketType, + }, + now, + ) + } + + return { + ok: true, + voucher: updated, + callbackSuccess: true, + alreadyDestroyed, + reason, + task: task || null, + } +} + +export function normalizeKuaishouIndustryDestroyReason(value: unknown): string { + const normalized = String(value || '').trim().toUpperCase() + if ( + normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.ETICKET_EXPIRED || + normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.USER_APPLY_REFUND || + normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.SUPPLY_DESTROY || + normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.SYS_ADMIN_DESTROY + ) { + return normalized + } + + return KUAISHOU_INDUSTRY_DESTROY_REASONS.SYS_ADMIN_DESTROY +} + function resolveTaskVouchers(task: TaskRow): Promise { const taskId = Number(task?.id || 0) if (taskId > 0) { diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index 4d05b860..19f7cb5c 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -19,6 +19,7 @@ import type { AdminKuaishouIndustrySourceConfigInput, AdminKuaishouIndustryVoucherCheckAvailableInput, AdminKuaishouIndustryVoucherConsumeInput, + AdminKuaishouIndustryVoucherDestroyInput, AdminKuaishouIndustryVoucherResendInput, AdminKuaishouIndustryVoucherReverseInput, AdminCloudtentaclesTestLoginInput, @@ -44,6 +45,7 @@ export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody = AdminKuaishouI export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput +export type AdminKuaishouIndustryVoucherDestroyRouteBody = AdminKuaishouIndustryVoucherDestroyInput export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index 3881ca88..9a789f9a 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -327,3 +327,13 @@ export type AdminKuaishouIndustryVoucherResendInput = { taskId?: number | string voucherCode?: string } + +export type AdminKuaishouIndustryVoucherDestroyInput = { + oid?: string + orderId?: string + taskId?: number | string + voucherCode?: string + token?: string + eticketType?: string + reason?: string +} diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 1ba2c5f4..389a0800 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -133,6 +133,7 @@ export type KuaishouIndustryVoucherUpsertInput = { unitIndex: number sellerId?: string token?: string + eticketType?: string orderId?: number | string | null taskId?: number | string | null status?: string diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 782d0a7a..45921655 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -113,6 +113,7 @@ export type KuaishouIndustryVoucherRow = { unit_index: number seller_id: string token: string + eticket_type: string status: string valid_start_time: number | string valid_end_time: number | string diff --git a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx index fc148f31..2e145c0c 100644 --- a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx +++ b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx @@ -1,5 +1,6 @@ import { CheckCircleOutlined, + DeleteOutlined, ReloadOutlined, RollbackOutlined, SearchOutlined, @@ -33,6 +34,7 @@ import { approveAdminKuaishouIndustryRefund, checkAdminKuaishouIndustryVoucherAvailable, consumeAdminKuaishouIndustryVoucher, + destroyAdminKuaishouIndustryVoucher, disagreeAdminKuaishouIndustryRefund, fetchAdminKuaishouIndustryShops, fetchAdminKuaishouIndustryVouchers, @@ -64,6 +66,7 @@ type IndustryActionScope = 'vouchers' | 'refunds' type IndustryActionKind = | 'check' | 'consume' + | 'destroy' | 'reverse' | 'resend' | 'refund-list' @@ -107,8 +110,6 @@ type RefundListState = { negotiateStatus: string } -const DEFAULT_ETICKET_TYPE = 'DINING_OPEN_TICKET' - export default function AdminKuaishouIndustryPage() { const isSupportOnly = getAdminRole() === 'support' const canManageRefund = hasAdminRole('operator') @@ -130,8 +131,9 @@ export default function AdminKuaishouIndustryPage() { pageSize: 50, }) const [toolForm, setToolForm] = useState({ - eticketType: DEFAULT_ETICKET_TYPE, + eticketType: '', consumeType: 'consume', + reason: 'SYS_ADMIN_DESTROY', }) const [refundDateRange, setRefundDateRange] = useState(() => [ dayjs().subtract(1, 'day'), @@ -255,13 +257,20 @@ export default function AdminKuaishouIndustryPage() { }, { title: '状态', - width: 118, + width: 148, render: (_, row) => (
{getVoucherStatusLabel(row.status)} 发码:{row.sendCallbackStatus || '-'} + {row.eticketType ? ( + + {row.eticketType} + + ) : ( + 类型未知 + )}
), }, @@ -425,7 +434,9 @@ export default function AdminKuaishouIndustryPage() { orderId: row.oid, taskId: row.taskId || '', voucherCode: row.voucherCode, + eticketType: row.eticketType || current.eticketType || '', serialNum: row.consumeSerialNum || current.serialNum, + reason: current.reason || 'SYS_ADMIN_DESTROY', })) showSuccess('已填入券码操作区') } @@ -440,7 +451,7 @@ export default function AdminKuaishouIndustryPage() { await runOpenApiAction('reverse', '电子凭证冲正回调', 'reverse', () => reverseAdminKuaishouIndustryVoucher({ ...buildVoucherToolPayload(), - reason: toolForm.reason || '后台手动冲正', + reason: '后台手动冲正', }), ) await loadVouchers() @@ -484,7 +495,7 @@ export default function AdminKuaishouIndustryPage() { orderId: row.oid, taskId: row.taskId || '', voucherCode: row.voucherCode, - eticketType: DEFAULT_ETICKET_TYPE, + eticketType: row.eticketType || undefined, consumeType: 'consume', serialNum: row.consumeSerialNum || undefined, etickets: [{ id: row.voucherCode, code: row.voucherCode, num: 1 }], @@ -540,6 +551,36 @@ export default function AdminKuaishouIndustryPage() { } } + async function runDestroy() { + setActionLoading('destroy') + try { + const response = await destroyAdminKuaishouIndustryVoucher({ + ...buildVoucherToolPayload(), + reason: toolForm.reason || 'SYS_ADMIN_DESTROY', + }) + publishActionResult( + buildIndustryActionResultView('destroy', '手动销毁', response.data), + ) + showSuccess( + response.data.alreadyDestroyed + ? `券码已是销毁状态,销毁回调已重试(${response.data.reason || 'SYS_ADMIN_DESTROY'})` + : `券码已销毁(${response.data.reason || 'SYS_ADMIN_DESTROY'})`, + ) + await loadVouchers() + } catch (error) { + const message = error instanceof Error ? error.message : '手动销毁失败' + publishActionResult( + buildIndustryActionResultView('destroy', '手动销毁', { + success: false, + error: message, + }), + ) + showError(message) + } finally { + setActionLoading('') + } + } + async function runRefundList() { const [begin, end] = refundDateRange || [] const sellerId = resolveRefundSellerId(refundListForm.sellerId) @@ -888,7 +929,7 @@ export default function AdminKuaishouIndustryPage() { onChange={(event) => updateToolForm({ voucherCode: event.target.value })} /> updateToolForm({ eticketType: event.target.value })} /> @@ -902,10 +943,18 @@ export default function AdminKuaishouIndustryPage() { value={toolForm.consumeType} onChange={(event) => updateToolForm({ consumeType: event.target.value })} /> - updateToolForm({ reason: event.target.value })} + 冲正 + runDestroy()} + > + +