feat(kuaishou): 增加历史电子券补发并核销
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
listAdminKuaishouIndustryRefunds,
|
||||
listAdminKuaishouIndustryShops,
|
||||
listAdminKuaishouIndustryVouchers,
|
||||
reissueAndConsumeAdminKuaishouIndustryVoucher,
|
||||
resendAdminKuaishouIndustryVoucherCode,
|
||||
reverseAdminKuaishouIndustryVoucher,
|
||||
} from '../../services/admin/kuaishou-industry-admin-service.js'
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
|
||||
AdminKuaishouIndustryVoucherConsumeRouteBody,
|
||||
AdminKuaishouIndustryVoucherDestroyRouteBody,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
|
||||
AdminKuaishouIndustryVoucherResendRouteBody,
|
||||
AdminKuaishouIndustryVoucherReverseRouteBody,
|
||||
} from '../../types/admin/route-inputs.js'
|
||||
@@ -204,6 +206,24 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-industry/vouchers/reissue-consume',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
reissueAndConsumeAdminKuaishouIndustryVoucher(
|
||||
req.body as AdminKuaishouIndustryVoucherReissueConsumeRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: '历史券补发并核销已执行',
|
||||
errorMessage: '历史券补发并核销失败',
|
||||
scope: '[admin/kuaishou-industry/vouchers/reissue-consume]',
|
||||
audit: (req, data) =>
|
||||
buildKuaishouIndustryAudit('kuaishou_industry_voucher_reissue_consume', req.body, data),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/kuaishou-industry/vouchers/destroy',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
@@ -238,6 +258,13 @@ function buildKuaishouIndustryAudit(action: string, body: unknown, data: unknown
|
||||
success: Boolean(result.success),
|
||||
httpStatus: Number(result.httpStatus || 0) || 0,
|
||||
error: String(result.error || ''),
|
||||
stage: String(result.stage || ''),
|
||||
reason: String(result.reason || ''),
|
||||
validMinutes: Number(result.validMinutes || 0) || 0,
|
||||
originalValidEndTime: Number(result.originalValidEndTime || 0) || 0,
|
||||
validEndTime: Number(result.validEndTime || 0) || 0,
|
||||
sendSuccess: Boolean(result.sendSuccess),
|
||||
consumeSuccess: Boolean(result.consumeSuccess),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou
|
||||
import {
|
||||
consumeKuaishouIndustryVoucher,
|
||||
destroyKuaishouIndustryVoucher,
|
||||
isKuaishouIndustryVoucherSendCallbackSuccess,
|
||||
resolveKuaishouIndustryEticketType,
|
||||
} from '../platforms/kuaishou-industry/voucher-service.js'
|
||||
import {
|
||||
@@ -39,6 +40,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus
|
||||
import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
|
||||
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
const historicalReissueInFlight = new Set<string>()
|
||||
|
||||
export async function listAdminKuaishouIndustryVouchers(
|
||||
input: KuaishouIndustryVoucherAdminListQuery = {},
|
||||
) {
|
||||
@@ -201,11 +204,204 @@ export async function resendAdminKuaishouIndustryVoucherCode(input: JsonObject =
|
||||
return {
|
||||
success: result.success,
|
||||
response: 'response' in result ? result.response || null : null,
|
||||
error: result.error || '',
|
||||
error:
|
||||
result.error ||
|
||||
resolveKuaishouOpenApiErrorMessage('response' in result ? result.response : null),
|
||||
voucher: result.voucher ? mapAdminKuaishouIndustryVoucher(result.voucher) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史发码失败券的受控补偿入口。
|
||||
*
|
||||
* 只允许未使用券进入;先把本地有效期临时改到未来,再复用原券码发码。
|
||||
* 发码成功后立即核销,避免为了补回调把历史券重新暴露给买家。
|
||||
*/
|
||||
export async function reissueAndConsumeAdminKuaishouIndustryVoucher(input: JsonObject = {}) {
|
||||
const voucher = await resolveRequiredVoucher(input)
|
||||
const voucherCode = String(voucher.voucher_code || '').trim()
|
||||
const accountStatus = String(voucher.status || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const nowMs = Date.now()
|
||||
const originalValidEndTime = Number(voucher.valid_end_time || 0) || 0
|
||||
|
||||
if (accountStatus !== 'UNUSED') {
|
||||
throw createHttpError('仅未使用电子凭证可以补发并核销', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_unused',
|
||||
})
|
||||
}
|
||||
|
||||
if (isKuaishouIndustryVoucherSendCallbackSuccess(voucher) && originalValidEndTime > nowMs) {
|
||||
throw createHttpError('该电子凭证已发码成功且仍在有效期,请直接执行手动核销', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_voucher_already_active',
|
||||
})
|
||||
}
|
||||
|
||||
if (historicalReissueInFlight.has(voucherCode)) {
|
||||
throw createHttpError('该电子凭证正在补发,请勿重复提交', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_in_progress',
|
||||
})
|
||||
}
|
||||
|
||||
const validity = resolveKuaishouIndustryHistoricalReissueWindow(input.validMinutes, nowMs)
|
||||
const reason = normalizeKuaishouIndustryHistoricalReissueReason(input.reason)
|
||||
const originalValidStartTime = Number(voucher.valid_start_time || 0) || 0
|
||||
const validStartTime =
|
||||
originalValidStartTime > 0 ? Math.min(originalValidStartTime, nowMs - 1_000) : nowMs - 1_000
|
||||
|
||||
historicalReissueInFlight.add(voucherCode)
|
||||
try {
|
||||
const prepared = await updateKuaishouIndustryVoucherByCode(voucherCode, {
|
||||
validStartTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
sendCallbackStatus: 'pending',
|
||||
sendCallbackLastError: '',
|
||||
sendCallbackResponseJson: {},
|
||||
sendCallbackSentAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
if (!prepared) {
|
||||
throw createHttpError('准备历史券补发时未找到电子凭证', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_voucher_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const sendResult = await resendAdminKuaishouIndustryVoucherCode({
|
||||
voucherCode,
|
||||
oid: voucher.oid,
|
||||
orderId: voucher.oid,
|
||||
})
|
||||
if (!sendResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
stage: 'send',
|
||||
reason,
|
||||
validMinutes: validity.validMinutes,
|
||||
originalValidEndTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
sendSuccess: false,
|
||||
consumeSuccess: false,
|
||||
response: sendResult.response,
|
||||
error: sendResult.error || '电子凭证发码回调失败',
|
||||
voucher: sendResult.voucher,
|
||||
task: null,
|
||||
}
|
||||
}
|
||||
|
||||
const sentVoucher = await findKuaishouIndustryVoucherByCode(voucherCode, voucher.oid)
|
||||
if (!sentVoucher || !isKuaishouIndustryVoucherSendCallbackSuccess(sentVoucher)) {
|
||||
return {
|
||||
success: false,
|
||||
stage: 'send',
|
||||
reason,
|
||||
validMinutes: validity.validMinutes,
|
||||
originalValidEndTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
sendSuccess: false,
|
||||
consumeSuccess: false,
|
||||
response: sendResult.response,
|
||||
error: '电子凭证发码回调返回成功,但本地状态未更新',
|
||||
voucher: sentVoucher ? mapAdminKuaishouIndustryVoucher(sentVoucher) : null,
|
||||
task: null,
|
||||
}
|
||||
}
|
||||
|
||||
const task = sentVoucher.task_id ? await getTaskById(sentVoucher.task_id) : null
|
||||
const consumeType = String(input.consumeType || '').trim()
|
||||
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
|
||||
source: 'admin_historical_reissue_consume',
|
||||
token: String(input.token || sentVoucher.token || '').trim(),
|
||||
consumeTime: Date.now(),
|
||||
...(consumeType ? { consumeType } : {}),
|
||||
...(input.serialNum ? { serialNum: String(input.serialNum).trim() } : {}),
|
||||
...(input.storeName ? { storeName: String(input.storeName).trim() } : {}),
|
||||
...(input.storeAddress ? { storeAddress: String(input.storeAddress).trim() } : {}),
|
||||
...(input.expressCode ? { expressCode: String(input.expressCode).trim() } : {}),
|
||||
...(input.expressNo ? { expressNo: String(input.expressNo).trim() } : {}),
|
||||
}
|
||||
if (task) {
|
||||
consumeInput.task = task
|
||||
}
|
||||
|
||||
const consumeResult = await consumeKuaishouIndustryVoucher(sentVoucher, consumeInput)
|
||||
if (!consumeResult.ok || !consumeResult.voucher) {
|
||||
return {
|
||||
success: false,
|
||||
stage: 'consume',
|
||||
reason,
|
||||
validMinutes: validity.validMinutes,
|
||||
originalValidEndTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
sendSuccess: true,
|
||||
consumeSuccess: false,
|
||||
response: sendResult.response,
|
||||
error: consumeResult.errorMessage || '电子凭证发码成功,但核销失败',
|
||||
voucher: consumeResult.voucher
|
||||
? mapAdminKuaishouIndustryVoucher(consumeResult.voucher)
|
||||
: mapAdminKuaishouIndustryVoucher(sentVoucher),
|
||||
task: task ? mapTaskReference(task) : null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stage: 'completed',
|
||||
reason,
|
||||
validMinutes: validity.validMinutes,
|
||||
originalValidEndTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
sendSuccess: true,
|
||||
consumeSuccess: true,
|
||||
response: sendResult.response,
|
||||
error: '',
|
||||
voucher: mapAdminKuaishouIndustryVoucher(consumeResult.voucher),
|
||||
task: task ? mapTaskReference(task) : null,
|
||||
}
|
||||
} finally {
|
||||
historicalReissueInFlight.delete(voucherCode)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveKuaishouIndustryHistoricalReissueWindow(
|
||||
rawMinutes: unknown,
|
||||
nowMs = Date.now(),
|
||||
) {
|
||||
const validMinutes = Number(rawMinutes)
|
||||
if (!Number.isInteger(validMinutes) || validMinutes < 1 || validMinutes > 60) {
|
||||
throw createHttpError('临时有效期必须是 1 到 60 分钟的整数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_valid_minutes_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
validMinutes,
|
||||
validEndTime: nowMs + validMinutes * 60_000,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeKuaishouIndustryHistoricalReissueReason(rawReason: unknown) {
|
||||
const reason = String(rawReason || '').trim()
|
||||
if (!reason) {
|
||||
throw createHttpError('补发原因不能为空', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_reason_required',
|
||||
})
|
||||
}
|
||||
if (reason.length > 200) {
|
||||
throw createHttpError('补发原因不能超过 200 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_kuaishou_industry_reissue_reason_too_long',
|
||||
})
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
export async function destroyAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) {
|
||||
const voucher = await resolveRequiredVoucher(input)
|
||||
const checkPayload = buildVoucherOpenApiPayload(input, voucher)
|
||||
@@ -485,6 +681,14 @@ function mapOpenApiResult(result: KuaishouIndustryOpenApiCallResult) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKuaishouOpenApiErrorMessage(response: unknown) {
|
||||
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
||||
return ''
|
||||
}
|
||||
const current = response as Record<string, unknown>
|
||||
return String(current.error_msg || current.sub_msg || current.message || current.msg || '').trim()
|
||||
}
|
||||
|
||||
function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null {
|
||||
if (!request) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
normalizeKuaishouIndustryHistoricalReissueReason,
|
||||
resolveKuaishouIndustryHistoricalReissueWindow,
|
||||
} from './kuaishou-industry-admin-service.js'
|
||||
|
||||
test('历史券补发窗口只允许 1 到 60 分钟', () => {
|
||||
const nowMs = 1_800_000_000_000
|
||||
|
||||
assert.deepEqual(resolveKuaishouIndustryHistoricalReissueWindow(10, nowMs), {
|
||||
validMinutes: 10,
|
||||
validEndTime: nowMs + 10 * 60_000,
|
||||
})
|
||||
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(0, nowMs), /1 到 60 分钟/)
|
||||
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow(61, nowMs), /1 到 60 分钟/)
|
||||
assert.throws(() => resolveKuaishouIndustryHistoricalReissueWindow('1.5', nowMs), /1 到 60 分钟/)
|
||||
})
|
||||
|
||||
test('历史券补发原因必填且限制长度', () => {
|
||||
assert.equal(
|
||||
normalizeKuaishouIndustryHistoricalReissueReason(' 历史发码失败补发 '),
|
||||
'历史发码失败补发',
|
||||
)
|
||||
assert.throws(() => normalizeKuaishouIndustryHistoricalReissueReason(' '), /不能为空/)
|
||||
assert.throws(
|
||||
() => normalizeKuaishouIndustryHistoricalReissueReason('x'.repeat(201)),
|
||||
/不能超过 200/,
|
||||
)
|
||||
})
|
||||
|
||||
test('受控补发必须先重置有效期,再发码,最后立即核销', async () => {
|
||||
const source = await readFile(
|
||||
new URL('./kuaishou-industry-admin-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const start = source.indexOf(
|
||||
'export async function reissueAndConsumeAdminKuaishouIndustryVoucher',
|
||||
)
|
||||
assert.ok(start > 0, '未找到历史券补发并核销服务')
|
||||
const body = source.slice(
|
||||
start,
|
||||
source.indexOf('\nexport function resolveKuaishouIndustryHistoricalReissueWindow', start),
|
||||
)
|
||||
|
||||
const prepareIndex = body.indexOf('updateKuaishouIndustryVoucherByCode')
|
||||
const sendIndex = body.indexOf('resendAdminKuaishouIndustryVoucherCode')
|
||||
const consumeIndex = body.indexOf('consumeKuaishouIndustryVoucher(sentVoucher')
|
||||
|
||||
assert.ok(prepareIndex > 0, '必须先更新本地临时有效期')
|
||||
assert.ok(sendIndex > prepareIndex, '必须先重置有效期再重发发码')
|
||||
assert.ok(consumeIndex > sendIndex, '必须等发码成功后再执行核销')
|
||||
assert.ok(body.includes("source: 'admin_historical_reissue_consume'"), '核销来源必须可审计')
|
||||
assert.ok(body.includes("sendCallbackStatus: 'pending'"), '重发前必须重置发码状态')
|
||||
assert.ok(body.includes("stage: 'send'"), '发码失败必须返回明确阶段')
|
||||
assert.ok(body.includes("stage: 'consume'"), '核销失败必须返回明确阶段')
|
||||
assert.ok(body.includes("stage: 'completed'"), '成功必须返回完成阶段')
|
||||
})
|
||||
|
||||
test('补发并核销路由只允许管理员和运营执行', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const routeIndex = source.indexOf("'/kuaishou-industry/vouchers/reissue-consume'")
|
||||
assert.ok(routeIndex > 0, '未注册历史券补发并核销路由')
|
||||
const routeBody = source.slice(routeIndex, source.indexOf('\nrouter.post(', routeIndex + 1))
|
||||
|
||||
assert.ok(routeBody.includes("requireAdminRoles(['admin', 'operator'])"), '必须限制管理员和运营')
|
||||
assert.ok(
|
||||
routeBody.includes("'kuaishou_industry_voucher_reissue_consume'"),
|
||||
'必须写入独立审计动作',
|
||||
)
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AdminKuaishouIndustryVoucherCheckAvailableInput,
|
||||
AdminKuaishouIndustryVoucherConsumeInput,
|
||||
AdminKuaishouIndustryVoucherDestroyInput,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeInput,
|
||||
AdminKuaishouIndustryVoucherResendInput,
|
||||
AdminKuaishouIndustryVoucherReverseInput,
|
||||
AdminCloudtentaclesTestLoginInput,
|
||||
@@ -49,6 +50,8 @@ export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody =
|
||||
export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput
|
||||
export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput
|
||||
export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput
|
||||
export type AdminKuaishouIndustryVoucherReissueConsumeRouteBody =
|
||||
AdminKuaishouIndustryVoucherReissueConsumeInput
|
||||
export type AdminKuaishouIndustryVoucherDestroyRouteBody = AdminKuaishouIndustryVoucherDestroyInput
|
||||
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
|
||||
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
|
||||
|
||||
@@ -354,6 +354,25 @@ export type AdminKuaishouIndustryVoucherResendInput = {
|
||||
voucherCode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史券补偿:临时重建有效期,重发原券码发码回调,成功后立即核销。
|
||||
* validMinutes 只允许较短窗口,避免发码成功但核销失败时长期暴露可用券。
|
||||
*/
|
||||
export type AdminKuaishouIndustryVoucherReissueConsumeInput = {
|
||||
oid?: string
|
||||
orderId?: string
|
||||
taskId?: number | string
|
||||
voucherCode?: string
|
||||
validMinutes?: number | string
|
||||
reason?: string
|
||||
consumeType?: string
|
||||
serialNum?: string
|
||||
storeName?: string
|
||||
storeAddress?: string
|
||||
expressCode?: string
|
||||
expressNo?: string
|
||||
}
|
||||
|
||||
export type AdminKuaishouIndustryVoucherDestroyInput = {
|
||||
oid?: string
|
||||
orderId?: string
|
||||
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
fetchAdminKuaishouIndustryShops,
|
||||
fetchAdminKuaishouIndustryVouchers,
|
||||
listAdminKuaishouIndustryRefunds,
|
||||
reissueAndConsumeAdminKuaishouIndustryVoucher,
|
||||
resendAdminKuaishouIndustryVoucherCode,
|
||||
reverseAdminKuaishouIndustryVoucher,
|
||||
} from '@/services/admin'
|
||||
@@ -69,6 +73,7 @@ type IndustryActionKind =
|
||||
| 'destroy'
|
||||
| 'reverse'
|
||||
| 'resend'
|
||||
| 'reissue-consume'
|
||||
| 'refund-list'
|
||||
| 'refund-approve'
|
||||
| 'refund-disagree'
|
||||
@@ -120,6 +125,11 @@ export default function AdminKuaishouIndustryPage() {
|
||||
const [actionLoading, setActionLoading] = useState('')
|
||||
const [consumingVoucherId, setConsumingVoucherId] = useState<number | null>(null)
|
||||
const [reversingVoucherId, setReversingVoucherId] = useState<number | null>(null)
|
||||
const [reissueConsumeOpen, setReissueConsumeOpen] = useState(false)
|
||||
const [reissueConsumeMinutes, setReissueConsumeMinutes] = useState<number>(10)
|
||||
const [reissueConsumeReason, setReissueConsumeReason] =
|
||||
useState('历史发码失败补偿,补发后立即核销')
|
||||
const [reissueConsumeConfirmed, setReissueConsumeConfirmed] = useState(false)
|
||||
const [vouchers, setVouchers] = useState<AdminKuaishouIndustryVoucher[]>([])
|
||||
const [selectedVoucher, setSelectedVoucher] = useState<AdminKuaishouIndustryVoucher | null>(null)
|
||||
const [voucherTotal, setVoucherTotal] = useState(0)
|
||||
@@ -646,6 +656,64 @@ export default function AdminKuaishouIndustryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReissueConsumeModal() {
|
||||
if (!selectedVoucher && !String(toolForm.voucherCode || '').trim()) {
|
||||
showError('请先选择或填写本地券码')
|
||||
return
|
||||
}
|
||||
if (selectedVoucher) {
|
||||
const sendSucceeded = selectedVoucher.sendCallbackStatus === 'success'
|
||||
const stillValid = Number(selectedVoucher.validEndTime || 0) > Date.now()
|
||||
if (selectedVoucher.status !== 'UNUSED') {
|
||||
showError('仅未使用电子凭证可以补发并核销')
|
||||
return
|
||||
}
|
||||
if (sendSucceeded && stillValid) {
|
||||
showError('该券已发码成功且仍在有效期,请直接执行手动核销')
|
||||
return
|
||||
}
|
||||
}
|
||||
setReissueConsumeConfirmed(false)
|
||||
setReissueConsumeOpen(true)
|
||||
}
|
||||
|
||||
async function runReissueConsume() {
|
||||
if (!reissueConsumeConfirmed) {
|
||||
showError('请先确认已核实订单仍需履约')
|
||||
return
|
||||
}
|
||||
|
||||
setActionLoading('reissue-consume')
|
||||
try {
|
||||
const response = await reissueAndConsumeAdminKuaishouIndustryVoucher({
|
||||
...buildVoucherToolPayload(),
|
||||
validMinutes: reissueConsumeMinutes,
|
||||
reason: reissueConsumeReason,
|
||||
})
|
||||
publishActionResult(
|
||||
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', response.data),
|
||||
)
|
||||
if (response.data.success) {
|
||||
showSuccess('原券码已同步到快手并完成核销')
|
||||
setReissueConsumeOpen(false)
|
||||
} else {
|
||||
showError(response.data.error || '历史券补发并核销失败')
|
||||
}
|
||||
await loadVouchers()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '历史券补发并核销失败'
|
||||
publishActionResult(
|
||||
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', {
|
||||
success: false,
|
||||
error: message,
|
||||
}),
|
||||
)
|
||||
showError(message)
|
||||
} finally {
|
||||
setActionLoading('')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDestroy() {
|
||||
setActionLoading('destroy')
|
||||
try {
|
||||
@@ -870,6 +938,98 @@ export default function AdminKuaishouIndustryPage() {
|
||||
onChange={(key) => setActiveTab(key as IndustryTab)}
|
||||
items={tabItems}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="历史券补发并核销"
|
||||
open={reissueConsumeOpen}
|
||||
okText="补发并核销"
|
||||
cancelText="取消"
|
||||
confirmLoading={actionLoading === 'reissue-consume'}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!reissueConsumeConfirmed ||
|
||||
!String(reissueConsumeReason || '').trim() ||
|
||||
reissueConsumeMinutes < 1 ||
|
||||
reissueConsumeMinutes > 60,
|
||||
}}
|
||||
onOk={runReissueConsume}
|
||||
onCancel={() => setReissueConsumeOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该操作会重发原券码并立即核销"
|
||||
description="仅用于已确认订单仍需履约、但历史发码失败或凭证已过期的补偿场景。操作会先临时延长本地券有效期,发码成功后立即核销。"
|
||||
/>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
bordered
|
||||
column={1}
|
||||
items={[
|
||||
{
|
||||
key: 'voucherCode',
|
||||
label: '本地券码',
|
||||
children:
|
||||
selectedVoucher?.voucherCode || String(toolForm.voucherCode || '').trim() || '-',
|
||||
},
|
||||
{
|
||||
key: 'oid',
|
||||
label: '平台订单',
|
||||
children:
|
||||
selectedVoucher?.oid ||
|
||||
String(toolForm.oid || toolForm.orderId || '').trim() ||
|
||||
'-',
|
||||
},
|
||||
{
|
||||
key: 'validEndTime',
|
||||
label: '原有效期至',
|
||||
children: selectedVoucher
|
||||
? formatAdminDateTime(
|
||||
new Date(Number(selectedVoucher.validEndTime || 0)).toISOString(),
|
||||
)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
key: 'sendError',
|
||||
label: '最近发码错误',
|
||||
children: selectedVoucher?.sendCallbackLastError || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<label className="kuaishou-industry-tool-field">
|
||||
<span>临时有效期(分钟)</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
precision={0}
|
||||
value={reissueConsumeMinutes}
|
||||
onChange={(value) => setReissueConsumeMinutes(Number(value || 10))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="kuaishou-industry-tool-field">
|
||||
<span>补发原因</span>
|
||||
<Input
|
||||
maxLength={200}
|
||||
showCount
|
||||
value={reissueConsumeReason}
|
||||
onChange={(event) => setReissueConsumeReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Checkbox
|
||||
checked={reissueConsumeConfirmed}
|
||||
onChange={(event) => setReissueConsumeConfirmed(event.target.checked)}
|
||||
>
|
||||
我已核实该订单仍需履约,并确认补发后立即核销
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1210,6 +1370,15 @@ export default function AdminKuaishouIndustryPage() {
|
||||
>
|
||||
手动核销
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={actionLoading === 'reissue-consume'}
|
||||
disabled={!hasVoucherTarget}
|
||||
onClick={openReissueConsumeModal}
|
||||
>
|
||||
历史补发并核销
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<RollbackOutlined />}
|
||||
@@ -1677,6 +1846,7 @@ function buildIndustryActionResultView(
|
||||
const responseData = asRecord(response?.data)
|
||||
const success = resolveActionSuccess(kind, record, openApi)
|
||||
const platformMessage = pickFirstString([
|
||||
...(kind === 'reissue-consume' ? [record.error] : []),
|
||||
response?.error_msg,
|
||||
response?.msg,
|
||||
response?.sub_msg,
|
||||
@@ -1788,6 +1958,24 @@ function buildIndustryActionResultView(
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'reissue-consume') {
|
||||
pushHighlight(highlights, '执行阶段', String(record.stage || '-'))
|
||||
if (record.validMinutes) {
|
||||
pushHighlight(highlights, '临时有效期', `${String(record.validMinutes)} 分钟`)
|
||||
}
|
||||
for (const [label, value] of [
|
||||
['发码成功', record.sendSuccess],
|
||||
['核销成功', record.consumeSuccess],
|
||||
] as const) {
|
||||
if (typeof value === 'boolean') {
|
||||
pushHighlight(highlights, label, value ? '是' : '否')
|
||||
}
|
||||
}
|
||||
if (record.reason) {
|
||||
pushHighlight(highlights, '补发原因', String(record.reason))
|
||||
}
|
||||
}
|
||||
|
||||
if (platformMessage) {
|
||||
pushHighlight(highlights, '平台消息', platformMessage)
|
||||
}
|
||||
@@ -1855,6 +2043,12 @@ function buildActionSummary(
|
||||
: context.platformMessage || '重发发码失败。'
|
||||
}
|
||||
|
||||
if (kind === 'reissue-consume') {
|
||||
return success
|
||||
? '原券码已同步到快手并完成核销。'
|
||||
: context.platformMessage || '历史券补发并核销失败。'
|
||||
}
|
||||
|
||||
if (kind === 'refund-list') {
|
||||
return success
|
||||
? `查询成功,共 ${context.refundCount ?? 0} 条售后单,已填充列表。`
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AdminKuaishouIndustryVoucherConsumeResult,
|
||||
AdminKuaishouIndustryVoucherDestroyResult,
|
||||
AdminKuaishouIndustryVoucherListResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeResult,
|
||||
AdminKuaishouIndustryVoucherResendResult,
|
||||
AdminKuaishouIndustryVoucherToolPayload,
|
||||
} from '@/types/admin'
|
||||
@@ -84,6 +86,15 @@ export function resendAdminKuaishouIndustryVoucherCode(
|
||||
)
|
||||
}
|
||||
|
||||
export function reissueAndConsumeAdminKuaishouIndustryVoucher(
|
||||
payload: AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
) {
|
||||
return apiPost<AdminKuaishouIndustryVoucherReissueConsumeResult>(
|
||||
'/api/v1/admin/kuaishou-industry/vouchers/reissue-consume',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function destroyAdminKuaishouIndustryVoucher(
|
||||
payload: AdminKuaishouIndustryVoucherToolPayload,
|
||||
) {
|
||||
|
||||
@@ -81,6 +81,8 @@ export type {
|
||||
AdminKuaishouIndustryShopListResult,
|
||||
AdminKuaishouIndustryVoucherConsumeResult,
|
||||
AdminKuaishouIndustryVoucherDestroyResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
AdminKuaishouIndustryVoucherResendResult,
|
||||
AdminKuaishouIndustryRefundListPayload,
|
||||
AdminKuaishouIndustryRefundApprovePayload,
|
||||
|
||||
@@ -94,6 +94,26 @@ export interface AdminKuaishouIndustryVoucherResendResult {
|
||||
voucher: AdminKuaishouIndustryVoucher | null
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryVoucherReissueConsumeResult {
|
||||
success: boolean
|
||||
stage: 'send' | 'consume' | 'completed' | string
|
||||
reason: string
|
||||
validMinutes: number
|
||||
originalValidEndTime: number
|
||||
validEndTime: number
|
||||
sendSuccess: boolean
|
||||
consumeSuccess: boolean
|
||||
response: Record<string, unknown> | null
|
||||
error: string
|
||||
voucher: AdminKuaishouIndustryVoucher | null
|
||||
task: null | {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
deliveryStatus: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryRefundListPayload {
|
||||
sellerId?: string
|
||||
beginTime?: number | string
|
||||
@@ -149,3 +169,8 @@ export interface AdminKuaishouIndustryVoucherToolPayload {
|
||||
expressNo?: string
|
||||
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryVoucherReissueConsumePayload extends AdminKuaishouIndustryVoucherToolPayload {
|
||||
validMinutes: number | string
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
test('快手电子凭证页提供受控的历史券补发并核销入口', () => {
|
||||
const page = readFileSync(
|
||||
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
const service = readFileSync(
|
||||
new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(page.includes('历史补发并核销'), '页面必须提供补发并核销按钮')
|
||||
assert.ok(page.includes('reissueAndConsumeAdminKuaishouIndustryVoucher'), '页面必须调用专用接口')
|
||||
assert.ok(page.includes('reissueConsumeOpen'), '操作必须通过确认弹窗执行')
|
||||
assert.ok(page.includes('reissueConsumeConfirmed'), '提交前必须显式勾选确认')
|
||||
assert.ok(page.includes('临时有效期(分钟)'), '必须允许设置短期临时有效期')
|
||||
assert.ok(
|
||||
page.includes("source: 'admin_historical_reissue_consume'") === false,
|
||||
'前端不能伪造核销来源',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
service.includes('/api/v1/admin/kuaishou-industry/vouchers/reissue-consume'),
|
||||
'必须调用后端补发并核销接口',
|
||||
)
|
||||
})
|
||||
|
||||
test('历史券补发并核销结果必须展示发码、核销两个阶段', () => {
|
||||
const page = readFileSync(
|
||||
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(page.includes("kind === 'reissue-consume'"), '结果视图必须识别补发并核销动作')
|
||||
assert.ok(
|
||||
page.includes("...(kind === 'reissue-consume' ? [record.error] : [])"),
|
||||
'核销阶段失败时必须优先展示消费错误,而不是发码成功的 success 响应',
|
||||
)
|
||||
assert.ok(page.includes("['发码成功', record.sendSuccess]"), '结果必须展示发码阶段状态')
|
||||
assert.ok(page.includes("['核销成功', record.consumeSuccess]"), '结果必须展示核销阶段状态')
|
||||
assert.ok(page.includes("pushHighlight(highlights, '补发原因'"), '结果必须展示补偿原因')
|
||||
})
|
||||
Reference in New Issue
Block a user