优化券码操作类型, 增加手动销毁

This commit is contained in:
yml2213
2026-07-12 18:46:30 +08:00
parent 1b8cdf16d3
commit 3d760f987c
16 changed files with 481 additions and 29 deletions
@@ -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, '')) = '';
@@ -66,6 +66,7 @@ async function insertKuaishouIndustryVoucher(
unit_index, unit_index,
seller_id, seller_id,
token, token,
eticket_type,
status, status,
valid_start_time, valid_start_time,
valid_end_time, valid_end_time,
@@ -94,17 +95,18 @@ async function insertKuaishouIndustryVoucher(
$9, $9,
$10, $10,
$11, $11,
$12::jsonb, $12,
$13, $13::jsonb,
$14, $14,
$15, $15,
$16, $16,
$17, $17,
$18::jsonb, $18,
$19, $19::jsonb,
$20::jsonb, $20,
$21, $21::jsonb,
$22 $22,
$23
) )
ON CONFLICT (oid, unit_index) DO UPDATE ON CONFLICT (oid, unit_index) DO UPDATE
SET SET
@@ -116,6 +118,10 @@ async function insertKuaishouIndustryVoucher(
WHEN EXCLUDED.token <> '' THEN EXCLUDED.token WHEN EXCLUDED.token <> '' THEN EXCLUDED.token
ELSE kuaishou_industry_vouchers.token ELSE kuaishou_industry_vouchers.token
END, 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), order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id),
task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id), task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id),
valid_start_time = EXCLUDED.valid_start_time, valid_start_time = EXCLUDED.valid_start_time,
@@ -157,6 +163,7 @@ async function insertKuaishouIndustryVoucher(
input.unitIndex, input.unitIndex,
input.sellerId || '', input.sellerId || '',
input.token || '', input.token || '',
input.eticketType || '',
input.status || 'UNUSED', input.status || 'UNUSED',
input.validStartTime || 0, input.validStartTime || 0,
input.validEndTime || 0, input.validEndTime || 0,
@@ -407,6 +414,10 @@ function normalizeVoucherPatchColumns(
columns.push({ column: 'seller_id', value: patch.sellerId || '' }) columns.push({ column: 'seller_id', value: patch.sellerId || '' })
} }
if (patch.eticketType !== undefined) {
columns.push({ column: 'eticket_type', value: patch.eticketType || '' })
}
if (patch.status !== undefined) { if (patch.status !== undefined) {
columns.push({ column: 'status', value: patch.status || 'UNUSED' }) columns.push({ column: 'status', value: patch.status || 'UNUSED' })
} }
@@ -3,6 +3,7 @@ import {
approveAdminKuaishouIndustryRefund, approveAdminKuaishouIndustryRefund,
checkAdminKuaishouIndustryVoucherAvailable, checkAdminKuaishouIndustryVoucherAvailable,
consumeAdminKuaishouIndustryVoucherByCode, consumeAdminKuaishouIndustryVoucherByCode,
destroyAdminKuaishouIndustryVoucherByCode,
disagreeAdminKuaishouIndustryRefund, disagreeAdminKuaishouIndustryRefund,
listAdminKuaishouIndustryRefunds, listAdminKuaishouIndustryRefunds,
listAdminKuaishouIndustryShops, listAdminKuaishouIndustryShops,
@@ -17,6 +18,7 @@ import type {
AdminKuaishouIndustryRefundListRouteBody, AdminKuaishouIndustryRefundListRouteBody,
AdminKuaishouIndustryVoucherCheckAvailableRouteBody, AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
AdminKuaishouIndustryVoucherConsumeRouteBody, AdminKuaishouIndustryVoucherConsumeRouteBody,
AdminKuaishouIndustryVoucherDestroyRouteBody,
AdminKuaishouIndustryVoucherResendRouteBody, AdminKuaishouIndustryVoucherResendRouteBody,
AdminKuaishouIndustryVoucherReverseRouteBody, AdminKuaishouIndustryVoucherReverseRouteBody,
} from '../../types/admin/route-inputs.js' } 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) { function buildKuaishouIndustryAudit(action: string, body: unknown, data: unknown) {
const payload = body && typeof body === 'object' ? body as Record<string, unknown> : {} const payload = body && typeof body === 'object' ? body as Record<string, unknown> : {}
const result = data && typeof data === 'object' ? data as Record<string, unknown> : {} const result = data && typeof data === 'object' ? data as Record<string, unknown> : {}
@@ -19,13 +19,18 @@ import {
type KuaishouIndustrySourceConfig, type KuaishouIndustrySourceConfig,
} from '../platforms/kuaishou-industry/source-config-service.js' } from '../platforms/kuaishou-industry/source-config-service.js'
import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou-industry/send-code-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 { import {
approveKuaishouIndustryRefund, approveKuaishouIndustryRefund,
disagreeKuaishouIndustryRefund, disagreeKuaishouIndustryRefund,
listKuaishouIndustryRefunds, listKuaishouIndustryRefunds,
} from '../platforms/kuaishou-industry/refund-service.js' } from '../platforms/kuaishou-industry/refund-service.js'
import { reverseKuaishouIndustryCallback } from '../platforms/kuaishou-industry/reverse-callback-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 { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.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) const payload = buildVoucherOpenApiPayload(input, voucher)
assertRequired(payload.sellerId, '卖家编号未填写') assertRequired(payload.sellerId, '卖家编号未填写')
assertRequired(payload.eticketType || payload.bizTypeCode, '电子凭证类型未填写') assertRequired(payload.eticketType || payload.bizTypeCode, '电子凭证类型未填写(请选择券码或手动填写类型)')
assertEtickets(payload.etickets, '电子凭证列表未填写') 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 = {}) { 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<typeof destroyKuaishouIndustryVoucher>[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( function buildVoucherOpenApiPayload(
input: JsonObject, input: JsonObject,
voucher: KuaishouIndustryVoucherRow | null, voucher: KuaishouIndustryVoucherRow | null,
): JsonObject { ): JsonObject {
const etickets = normalizeAdminEtickets(input.etickets) const etickets = normalizeAdminEtickets(input.etickets)
const voucherCode = String(voucher?.voucher_code || input.voucherCode || '').trim() const voucherCode = String(voucher?.voucher_code || input.voucherCode || '').trim()
const eticketType = resolveKuaishouIndustryEticketType(
voucher,
input.eticketType || input.bizTypeCode,
)
return { return {
...input, ...input,
@@ -202,6 +255,8 @@ function buildVoucherOpenApiPayload(
orderId: String(input.orderId || input.oid || voucher?.oid || '').trim(), orderId: String(input.orderId || input.oid || voucher?.oid || '').trim(),
token: String(input.token || voucher?.token || '').trim(), token: String(input.token || voucher?.token || '').trim(),
serialNum: String(input.serialNum || voucher?.consume_serial_num || '').trim(), serialNum: String(input.serialNum || voucher?.consume_serial_num || '').trim(),
eticketType,
...(eticketType ? { bizTypeCode: eticketType } : {}),
etickets: etickets:
etickets.length > 0 etickets.length > 0
? etickets ? etickets
@@ -414,6 +469,7 @@ function mapAdminKuaishouIndustryVoucher(
skuCode: String(listRow.sku_code || '').trim(), skuCode: String(listRow.sku_code || '').trim(),
skuName: String(listRow.sku_name || '').trim(), skuName: String(listRow.sku_name || '').trim(),
tokenMasked: maskSecret(voucher.token), tokenMasked: maskSecret(voucher.token),
eticketType: resolveKuaishouIndustryEticketType(voucher),
status: voucher.status, status: voucher.status,
validStartTime: Number(voucher.valid_start_time || 0) || 0, validStartTime: Number(voucher.valid_start_time || 0) || 0,
validEndTime: Number(voucher.valid_end_time || 0) || 0, validEndTime: Number(voucher.valid_end_time || 0) || 0,
@@ -18,6 +18,7 @@ import {
} from './response.js' } from './response.js'
import { destroyCallback } from './destroy-callback-service.js' import { destroyCallback } from './destroy-callback-service.js'
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js' import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
import { resolveKuaishouIndustryEticketType } from './voucher-service.js'
export async function handleDestroyCode(rawBody: JsonObject = {}) { export async function handleDestroyCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig() const config = getKuaishouIndustryConfig()
@@ -39,6 +40,9 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
const callbackSellerId = params.sellerId const callbackSellerId = params.sellerId
|| String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim() || String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim()
const callbackToken = params.token || resolveDestroyCallbackToken(targetVouchers) const callbackToken = params.token || resolveDestroyCallbackToken(targetVouchers)
const eticketType = resolveKuaishouIndustryEticketType(
targetVouchers[0] || null,
)
for (const voucher of targetVouchers) { for (const voucher of targetVouchers) {
const status = String(voucher.status || '').trim().toUpperCase() const status = String(voucher.status || '').trim().toUpperCase()
@@ -81,6 +85,7 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
})), })),
reason: params.reason, reason: params.reason,
token: callbackToken, token: callbackToken,
...(eticketType ? { eticketType } : {}),
}) })
return buildIndustrySuccessResponse({ oid: normalizedOid }) return buildIndustrySuccessResponse({ oid: normalizedOid })
@@ -28,6 +28,7 @@ import {
buildKuaishouIndustryEticketFromVoucher, buildKuaishouIndustryEticketFromVoucher,
isKuaishouIndustryVoucherSendCallbackSuccess, isKuaishouIndustryVoucherSendCallbackSuccess,
KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS, KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS,
resolveKuaishouIndustryEticketType,
resolveKuaishouIndustryVoucherValidity, resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js' } from './voucher-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js' import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
@@ -83,6 +84,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
unitIndex, unitIndex,
sellerId: params.sellerId, sellerId: params.sellerId,
token: params.token, token: params.token,
eticketType: params.eticketType,
orderId: order?.id || null, orderId: order?.id || null,
taskId: task?.id || null, taskId: task?.id || null,
status: 'UNUSED', status: 'UNUSED',
@@ -558,7 +560,7 @@ function resolveSendCodeCallbackParams(
sendType: String(body.sendType || 'VIRTUAL').trim() || 'VIRTUAL', sendType: String(body.sendType || 'VIRTUAL').trim() || 'VIRTUAL',
sellerId: String(body.sellerId || voucher.seller_id || '').trim(), sellerId: String(body.sellerId || voucher.seller_id || '').trim(),
token: String(body.token || voucher.token || '').trim(), token: String(body.token || voucher.token || '').trim(),
eticketType: String(body.eticketType || '').trim(), eticketType: resolveKuaishouIndustryEticketType(voucher, body.eticketType),
ext: String(body.ext || '').trim(), ext: String(body.ext || '').trim(),
} }
} }
@@ -6,6 +6,8 @@ import { buildKuaishouIndustryVoucherContext } from './voucher-binding-service.j
import { import {
buildKuaishouIndustryEticketFromVoucher, buildKuaishouIndustryEticketFromVoucher,
normalizeKuaishouIndustryConsumeType, normalizeKuaishouIndustryConsumeType,
normalizeKuaishouIndustryDestroyReason,
resolveKuaishouIndustryEticketType,
resolveKuaishouIndustryVoucherValidity, resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js' } 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') 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', () => { test('buildKuaishouIndustryVoucherContext maps consumed voucher state', () => {
const context = buildKuaishouIndustryVoucherContext( const context = buildKuaishouIndustryVoucherContext(
{ {
@@ -1,13 +1,17 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js' 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 { asJsonObject, type JsonObject } from '../../../types/json.js'
import { import {
findKuaishouIndustryVoucherByCode, findKuaishouIndustryVoucherByCode,
listKuaishouIndustryVouchersByTaskId, listKuaishouIndustryVouchersByTaskId,
updateKuaishouIndustryVoucherByCode, updateKuaishouIndustryVoucherByCode,
} from '../../../repositories/kuaishou-industry-voucher-repo.js' } from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { logWarn } from '../../../utils/logger.js' import { logWarn } from '../../../utils/logger.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import { buildIndustryEticketItem } from './response.js' import { buildIndustryEticketItem } from './response.js'
import { consumeCallback } from './consume-callback-service.js' import { consumeCallback } from './consume-callback-service.js'
import { destroyCallback } from './destroy-callback-service.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3 export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3
@@ -17,6 +21,12 @@ export const KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS = {
FAILED: 'failed', FAILED: 'failed',
} as const } as const
export const KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE = 'consume' 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( export function resolveKuaishouIndustryVoucherValidity(
input: { input: {
@@ -50,6 +60,33 @@ export function resolveKuaishouIndustryVoucherValidity(
} }
} }
/**
* 解析电子凭证类型。优先级:
* 显式入参 > voucher.eticket_type > raw_payload.body.eticketType > raw_payload.eticketType
*/
export function resolveKuaishouIndustryEticketType(
voucher?: Pick<KuaishouIndustryVoucherRow, 'eticket_type' | 'raw_payload_json'> | 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( export function buildKuaishouIndustryEticketFromVoucher(
voucher: KuaishouIndustryVoucherRow, voucher: KuaishouIndustryVoucherRow,
eticketType = '', eticketType = '',
@@ -61,7 +98,7 @@ export function buildKuaishouIndustryEticketFromVoucher(
num: 1, num: 1,
validStartTime: Number(voucher.valid_start_time || 0) || 0, validStartTime: Number(voucher.valid_start_time || 0) || 0,
validEndTime: Number(voucher.valid_end_time || 0) || 0, validEndTime: Number(voucher.valid_end_time || 0) || 0,
eticketType, eticketType: resolveKuaishouIndustryEticketType(voucher, eticketType),
consumeDetails: resolveVoucherConsumeDetails(voucher), 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<KuaishouIndustryVoucherRow[]> { function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[]> {
const taskId = Number(task?.id || 0) const taskId = Number(task?.id || 0)
if (taskId > 0) { if (taskId > 0) {
@@ -19,6 +19,7 @@ import type {
AdminKuaishouIndustrySourceConfigInput, AdminKuaishouIndustrySourceConfigInput,
AdminKuaishouIndustryVoucherCheckAvailableInput, AdminKuaishouIndustryVoucherCheckAvailableInput,
AdminKuaishouIndustryVoucherConsumeInput, AdminKuaishouIndustryVoucherConsumeInput,
AdminKuaishouIndustryVoucherDestroyInput,
AdminKuaishouIndustryVoucherResendInput, AdminKuaishouIndustryVoucherResendInput,
AdminKuaishouIndustryVoucherReverseInput, AdminKuaishouIndustryVoucherReverseInput,
AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesTestLoginInput,
@@ -44,6 +45,7 @@ export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody = AdminKuaishouI
export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput
export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput
export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput
export type AdminKuaishouIndustryVoucherDestroyRouteBody = AdminKuaishouIndustryVoucherDestroyInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput
export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput
@@ -327,3 +327,13 @@ export type AdminKuaishouIndustryVoucherResendInput = {
taskId?: number | string taskId?: number | string
voucherCode?: string voucherCode?: string
} }
export type AdminKuaishouIndustryVoucherDestroyInput = {
oid?: string
orderId?: string
taskId?: number | string
voucherCode?: string
token?: string
eticketType?: string
reason?: string
}
@@ -133,6 +133,7 @@ export type KuaishouIndustryVoucherUpsertInput = {
unitIndex: number unitIndex: number
sellerId?: string sellerId?: string
token?: string token?: string
eticketType?: string
orderId?: number | string | null orderId?: number | string | null
taskId?: number | string | null taskId?: number | string | null
status?: string status?: string
@@ -113,6 +113,7 @@ export type KuaishouIndustryVoucherRow = {
unit_index: number unit_index: number
seller_id: string seller_id: string
token: string token: string
eticket_type: string
status: string status: string
valid_start_time: number | string valid_start_time: number | string
valid_end_time: number | string valid_end_time: number | string
@@ -1,5 +1,6 @@
import { import {
CheckCircleOutlined, CheckCircleOutlined,
DeleteOutlined,
ReloadOutlined, ReloadOutlined,
RollbackOutlined, RollbackOutlined,
SearchOutlined, SearchOutlined,
@@ -33,6 +34,7 @@ import {
approveAdminKuaishouIndustryRefund, approveAdminKuaishouIndustryRefund,
checkAdminKuaishouIndustryVoucherAvailable, checkAdminKuaishouIndustryVoucherAvailable,
consumeAdminKuaishouIndustryVoucher, consumeAdminKuaishouIndustryVoucher,
destroyAdminKuaishouIndustryVoucher,
disagreeAdminKuaishouIndustryRefund, disagreeAdminKuaishouIndustryRefund,
fetchAdminKuaishouIndustryShops, fetchAdminKuaishouIndustryShops,
fetchAdminKuaishouIndustryVouchers, fetchAdminKuaishouIndustryVouchers,
@@ -64,6 +66,7 @@ type IndustryActionScope = 'vouchers' | 'refunds'
type IndustryActionKind = type IndustryActionKind =
| 'check' | 'check'
| 'consume' | 'consume'
| 'destroy'
| 'reverse' | 'reverse'
| 'resend' | 'resend'
| 'refund-list' | 'refund-list'
@@ -107,8 +110,6 @@ type RefundListState = {
negotiateStatus: string negotiateStatus: string
} }
const DEFAULT_ETICKET_TYPE = 'DINING_OPEN_TICKET'
export default function AdminKuaishouIndustryPage() { export default function AdminKuaishouIndustryPage() {
const isSupportOnly = getAdminRole() === 'support' const isSupportOnly = getAdminRole() === 'support'
const canManageRefund = hasAdminRole('operator') const canManageRefund = hasAdminRole('operator')
@@ -130,8 +131,9 @@ export default function AdminKuaishouIndustryPage() {
pageSize: 50, pageSize: 50,
}) })
const [toolForm, setToolForm] = useState<AdminKuaishouIndustryVoucherToolPayload>({ const [toolForm, setToolForm] = useState<AdminKuaishouIndustryVoucherToolPayload>({
eticketType: DEFAULT_ETICKET_TYPE, eticketType: '',
consumeType: 'consume', consumeType: 'consume',
reason: 'SYS_ADMIN_DESTROY',
}) })
const [refundDateRange, setRefundDateRange] = useState<DateRangeValue>(() => [ const [refundDateRange, setRefundDateRange] = useState<DateRangeValue>(() => [
dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day'),
@@ -255,13 +257,20 @@ export default function AdminKuaishouIndustryPage() {
}, },
{ {
title: '状态', title: '状态',
width: 118, width: 148,
render: (_, row) => ( render: (_, row) => (
<div className="cell-stack"> <div className="cell-stack">
<Tag color={getVoucherStatusColor(row.status)}>{getVoucherStatusLabel(row.status)}</Tag> <Tag color={getVoucherStatusColor(row.status)}>{getVoucherStatusLabel(row.status)}</Tag>
<Tag color={row.sendCallbackStatus === 'success' ? 'green' : 'orange'}> <Tag color={row.sendCallbackStatus === 'success' ? 'green' : 'orange'}>
{row.sendCallbackStatus || '-'} {row.sendCallbackStatus || '-'}
</Tag> </Tag>
{row.eticketType ? (
<Typography.Text type="secondary" ellipsis={{ tooltip: row.eticketType }} className="muted">
{row.eticketType}
</Typography.Text>
) : (
<span className="muted"></span>
)}
</div> </div>
), ),
}, },
@@ -425,7 +434,9 @@ export default function AdminKuaishouIndustryPage() {
orderId: row.oid, orderId: row.oid,
taskId: row.taskId || '', taskId: row.taskId || '',
voucherCode: row.voucherCode, voucherCode: row.voucherCode,
eticketType: row.eticketType || current.eticketType || '',
serialNum: row.consumeSerialNum || current.serialNum, serialNum: row.consumeSerialNum || current.serialNum,
reason: current.reason || 'SYS_ADMIN_DESTROY',
})) }))
showSuccess('已填入券码操作区') showSuccess('已填入券码操作区')
} }
@@ -440,7 +451,7 @@ export default function AdminKuaishouIndustryPage() {
await runOpenApiAction('reverse', '电子凭证冲正回调', 'reverse', () => await runOpenApiAction('reverse', '电子凭证冲正回调', 'reverse', () =>
reverseAdminKuaishouIndustryVoucher({ reverseAdminKuaishouIndustryVoucher({
...buildVoucherToolPayload(), ...buildVoucherToolPayload(),
reason: toolForm.reason || '后台手动冲正', reason: '后台手动冲正',
}), }),
) )
await loadVouchers() await loadVouchers()
@@ -484,7 +495,7 @@ export default function AdminKuaishouIndustryPage() {
orderId: row.oid, orderId: row.oid,
taskId: row.taskId || '', taskId: row.taskId || '',
voucherCode: row.voucherCode, voucherCode: row.voucherCode,
eticketType: DEFAULT_ETICKET_TYPE, eticketType: row.eticketType || undefined,
consumeType: 'consume', consumeType: 'consume',
serialNum: row.consumeSerialNum || undefined, serialNum: row.consumeSerialNum || undefined,
etickets: [{ id: row.voucherCode, code: row.voucherCode, num: 1 }], 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() { async function runRefundList() {
const [begin, end] = refundDateRange || [] const [begin, end] = refundDateRange || []
const sellerId = resolveRefundSellerId(refundListForm.sellerId) const sellerId = resolveRefundSellerId(refundListForm.sellerId)
@@ -888,7 +929,7 @@ export default function AdminKuaishouIndustryPage() {
onChange={(event) => updateToolForm({ voucherCode: event.target.value })} onChange={(event) => updateToolForm({ voucherCode: event.target.value })}
/> />
<Input <Input
placeholder="电子凭证类型" placeholder="电子凭证类型(选中券码自动填入)"
value={toolForm.eticketType} value={toolForm.eticketType}
onChange={(event) => updateToolForm({ eticketType: event.target.value })} onChange={(event) => updateToolForm({ eticketType: event.target.value })}
/> />
@@ -902,10 +943,18 @@ export default function AdminKuaishouIndustryPage() {
value={toolForm.consumeType} value={toolForm.consumeType}
onChange={(event) => updateToolForm({ consumeType: event.target.value })} onChange={(event) => updateToolForm({ consumeType: event.target.value })}
/> />
<Input <Select
placeholder="冲正原因" allowClear
value={toolForm.reason} placeholder="销毁/冲正原因"
onChange={(event) => updateToolForm({ reason: event.target.value })} value={toolForm.reason || undefined}
onChange={(value) => updateToolForm({ reason: value || '' })}
options={[
{ label: 'SYS_ADMIN_DESTROY(后台作废)', value: 'SYS_ADMIN_DESTROY' },
{ label: 'SUPPLY_DESTROY(供应商作废)', value: 'SUPPLY_DESTROY' },
{ label: 'USER_APPLY_REFUND(用户退款)', value: 'USER_APPLY_REFUND' },
{ label: 'ETICKET_EXPIRED(凭证过期)', value: 'ETICKET_EXPIRED' },
]}
style={{ minWidth: 220 }}
/> />
<Input <Input
placeholder="门店名称" placeholder="门店名称"
@@ -942,6 +991,23 @@ export default function AdminKuaishouIndustryPage() {
> >
</Button> </Button>
<Popconfirm
title="确认销毁该券码?"
description="将向快手发起销毁回调,并关闭关联任务。已销毁券会重试回调。"
okText="销毁"
cancelText="取消"
onConfirm={() => runDestroy()}
>
<Button
danger
type="primary"
icon={<DeleteOutlined />}
loading={actionLoading === 'destroy'}
disabled={!canManageRefund}
>
</Button>
</Popconfirm>
<Button <Button
icon={<SendOutlined />} icon={<SendOutlined />}
loading={actionLoading === 'resend'} loading={actionLoading === 'resend'}
@@ -1416,19 +1482,44 @@ function buildIndustryActionResultView(
} }
if (kind === 'check') { if (kind === 'check') {
const etickets = extractAvailableEtickets(responseData) const etickets = extractAvailableEtickets(response)
pushHighlight(highlights, '可核销券数', String(etickets.length || 0)) pushHighlight(highlights, '返回券数', String(etickets.length || 0))
const resolvedType = pickFirstString([
record.eticketType,
voucher?.eticketType,
asRecord(openApi.request)?.eticketType,
asRecord(asRecord(openApi.request)?.param)?.eticketType,
])
if (resolvedType) {
pushHighlight(highlights, '请求类型', resolvedType)
}
if (etickets[0]) { if (etickets[0]) {
pushHighlight( pushHighlight(
highlights, highlights,
'首张券码', '首张券码',
pickFirstString([etickets[0].code, etickets[0].id]) || '-', pickFirstString([etickets[0].code, etickets[0].id]) || '-',
) )
pushHighlight(highlights, '首张数量', String(etickets[0].num || 1)) pushHighlight(
highlights,
'展示状态',
pickFirstString([etickets[0].displayStatus, etickets[0].status]) || '-',
)
pushHighlight(
highlights,
'平台类型',
pickFirstString([etickets[0].eticketType]) || '-',
)
pushHighlight(highlights, '剩余数量', String(etickets[0].leftNum ?? etickets[0].num ?? 1))
} }
} }
if (kind === 'consume' || kind === 'reverse' || kind === 'resend' || voucher) { if (
kind === 'consume' ||
kind === 'destroy' ||
kind === 'reverse' ||
kind === 'resend' ||
voucher
) {
if (voucher) { if (voucher) {
pushHighlight(highlights, '券码', String(voucher.voucherCode || '-')) pushHighlight(highlights, '券码', String(voucher.voucherCode || '-'))
pushHighlight( pushHighlight(
@@ -1436,6 +1527,15 @@ function buildIndustryActionResultView(
'券状态', '券状态',
getVoucherStatusLabel(String(voucher.status || '')), getVoucherStatusLabel(String(voucher.status || '')),
) )
if (voucher.eticketType) {
pushHighlight(highlights, '凭证类型', String(voucher.eticketType))
}
if (kind === 'destroy' && record.reason) {
pushHighlight(highlights, '销毁原因', String(record.reason))
}
if (kind === 'destroy' && typeof record.alreadyDestroyed === 'boolean') {
pushHighlight(highlights, '已是销毁态', record.alreadyDestroyed ? '是(重试回调)' : '否')
}
if (voucher.consumeSerialNum) { if (voucher.consumeSerialNum) {
pushHighlight(highlights, '核销序列号', String(voucher.consumeSerialNum)) pushHighlight(highlights, '核销序列号', String(voucher.consumeSerialNum))
} }
@@ -1520,7 +1620,7 @@ function buildActionSummary(
if (kind === 'check') { if (kind === 'check') {
return success return success
? context.platformMessage || '平台返回券码有效,可继续核销。' ? context.platformMessage || '检查成功,请结合展示状态(如 frozen)判断是否可核销。'
: context.platformMessage || '检查失败,请核对卖家、券码与电子凭证类型。' : context.platformMessage || '检查失败,请核对卖家、券码与电子凭证类型。'
} }
@@ -1530,6 +1630,12 @@ function buildActionSummary(
: context.platformMessage || '核销失败。' : context.platformMessage || '核销失败。'
} }
if (kind === 'destroy') {
return success
? `销毁成功${context.voucherStatus ? `,券状态:${context.voucherStatus}` : ''}`
: context.platformMessage || '销毁失败。'
}
if (kind === 'reverse') { if (kind === 'reverse') {
return success return success
? `冲正成功${context.voucherStatus ? `,券状态已回写为 ${context.voucherStatus}` : ''}` ? `冲正成功${context.voucherStatus ? `,券状态已回写为 ${context.voucherStatus}` : ''}`
@@ -1575,7 +1681,7 @@ function resolveActionSuccess(
if (typeof record.success === 'boolean') { if (typeof record.success === 'boolean') {
return record.success return record.success
} }
if (kind === 'consume') { if (kind === 'consume' || kind === 'destroy') {
return Boolean(record.voucher) return Boolean(record.voucher)
} }
return openApi.success return openApi.success
@@ -6,6 +6,7 @@ import type {
AdminKuaishouIndustryRefundListPayload, AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryShopListResult, AdminKuaishouIndustryShopListResult,
AdminKuaishouIndustryVoucherConsumeResult, AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherDestroyResult,
AdminKuaishouIndustryVoucherListResult, AdminKuaishouIndustryVoucherListResult,
AdminKuaishouIndustryVoucherResendResult, AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryVoucherToolPayload, AdminKuaishouIndustryVoucherToolPayload,
@@ -84,3 +85,12 @@ export function resendAdminKuaishouIndustryVoucherCode(
payload, payload,
) )
} }
export function destroyAdminKuaishouIndustryVoucher(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
return apiPost<AdminKuaishouIndustryVoucherDestroyResult>(
'/api/v1/admin/kuaishou-industry/vouchers/destroy',
payload,
)
}
+1
View File
@@ -38,6 +38,7 @@ export type {
AdminKuaishouIndustryShopOption, AdminKuaishouIndustryShopOption,
AdminKuaishouIndustryShopListResult, AdminKuaishouIndustryShopListResult,
AdminKuaishouIndustryVoucherConsumeResult, AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherDestroyResult,
AdminKuaishouIndustryVoucherResendResult, AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryRefundListPayload, AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryRefundApprovePayload, AdminKuaishouIndustryRefundApprovePayload,
@@ -28,6 +28,8 @@ export interface AdminKuaishouIndustryVoucher {
/** 关联订单商品名称(列表 join) */ /** 关联订单商品名称(列表 join) */
skuName?: string skuName?: string
tokenMasked: string tokenMasked: string
/** 快手电子凭证类型,如 GAME_OPEN_TICKET_CONSUME */
eticketType: string
status: string status: string
validStartTime: number validStartTime: number
validEndTime: number validEndTime: number
@@ -72,6 +74,19 @@ export interface AdminKuaishouIndustryVoucherConsumeResult {
} }
} }
export interface AdminKuaishouIndustryVoucherDestroyResult {
success: boolean
alreadyDestroyed: boolean
reason: string
voucher: AdminKuaishouIndustryVoucher
task: null | {
taskId: number
taskNo: string
status: string
deliveryStatus: string
}
}
export interface AdminKuaishouIndustryVoucherResendResult { export interface AdminKuaishouIndustryVoucherResendResult {
success: boolean success: boolean
response: Record<string, unknown> | null response: Record<string, unknown> | null