feat(douyin-mashida): 新增抖音券核销后台界面(列表/核销/冲正/同步/对账/作废)
抖音订单并非纯自动发货:内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING 才走自动通道, 未命中显式回落人工,且核销依赖买家自己到 /redeem 输订单号。此前核销侧在后台完全 不可见:自动核销是 void .catch(warn) 只落日志,cancelVerify 出网方法已就绪但没有入口。 照快手电子凭证的骨架做一套抖音版(两张表两套状态机,不复用): - 券码状态、平台侧状态、同步错误全部可视化,支持只看同步失败 - 核销/冲正/重新同步/平台对账/重发券码/作废,每个写操作都带审计 - 券码列显示「核销实际会用的券码」(platform_cert_no 优先),平台改过名时额外标注 - 冲正先打平台 cancelVerify 再回退本地,避免本地与平台状态漂移
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
cancelAdminDouyinMashidaCertificateVerify,
|
||||
pickAdminPlatformCouponRow,
|
||||
resolvePlatformStatusLabel,
|
||||
syncAdminDouyinMashidaCertificate,
|
||||
verifyAdminDouyinMashidaCertificate,
|
||||
} from './douyin-mashida-certificate-service.js'
|
||||
|
||||
/**
|
||||
* 后台券操作的**守卫 + 对账口径**测试。
|
||||
*
|
||||
* 只覆盖不需要连库 / 连网关的分支:参数校验不全时会打到平台,对账选券错了会
|
||||
* 拿错券码核销(真实踩坑:`10017 核销码与订单不匹配`)。出网行为由联调脚本覆盖。
|
||||
*/
|
||||
test('手动核销:券码为空直接拒绝(不打到平台)', async () => {
|
||||
await assert.rejects(
|
||||
() => verifyAdminDouyinMashidaCertificate({}),
|
||||
(error: { errorCode?: string }) => {
|
||||
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('冲正:券码为空直接拒绝(不打到平台)', async () => {
|
||||
await assert.rejects(
|
||||
() => cancelAdminDouyinMashidaCertificateVerify({}),
|
||||
(error: { errorCode?: string }) => {
|
||||
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('重新同步:券码为空直接拒绝(不打到平台)', async () => {
|
||||
await assert.rejects(
|
||||
() => syncAdminDouyinMashidaCertificate({}),
|
||||
(error: { errorCode?: string }) => {
|
||||
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('对账选券:平台沿用我方券码时精确命中', () => {
|
||||
const picked = pickAdminPlatformCouponRow(
|
||||
[
|
||||
{ cert_no: 'DYCAAAA', status: 3, verify_num: 1 },
|
||||
{ cert_no: 'DYCDDDD', status: 2, verify_num: 0 },
|
||||
],
|
||||
'DYCAAAA',
|
||||
)
|
||||
|
||||
assert.equal(String(picked?.cert_no || ''), 'DYCAAAA')
|
||||
})
|
||||
|
||||
test('对账选券:平台改过券码时不会错拿别人的券去核销', () => {
|
||||
// 真实踩坑:syncV2 有 2/3 概率改写券码,此时必须挑「未使用」那张,
|
||||
// 而不是列表第一条(第一条可能是别的订单已核销的券)。
|
||||
const picked = pickAdminPlatformCouponRow(
|
||||
[
|
||||
{ cert_no: 'PLATFORM-USED', status: 3, verify_num: 2 },
|
||||
{ cert_no: 'PLATFORM-UNUSED', status: 2, verify_num: 0 },
|
||||
],
|
||||
'DYCAAAA',
|
||||
)
|
||||
|
||||
assert.equal(String(picked?.cert_no || ''), 'PLATFORM-UNUSED')
|
||||
})
|
||||
|
||||
test('对账选券:平台没返回任何券时返回 null(不瞎猜)', () => {
|
||||
assert.equal(pickAdminPlatformCouponRow([], 'DYCAAAA'), null)
|
||||
assert.equal(pickAdminPlatformCouponRow([null, 'bad'] as never, 'DYCAAAA'), null)
|
||||
})
|
||||
|
||||
test('平台状态文案:与 coupons.list 的 status 一一对应', () => {
|
||||
assert.equal(resolvePlatformStatusLabel(1), '未激活')
|
||||
assert.equal(resolvePlatformStatusLabel(2), '未使用')
|
||||
assert.equal(resolvePlatformStatusLabel(3), '已使用')
|
||||
assert.equal(resolvePlatformStatusLabel(4), '已废弃')
|
||||
assert.equal(resolvePlatformStatusLabel(5), '已过期')
|
||||
assert.equal(resolvePlatformStatusLabel(6), '已完成')
|
||||
// 尚未回查到平台状态时不能显示成「未知 0」之类的噪音
|
||||
assert.equal(resolvePlatformStatusLabel(Number.NaN), '')
|
||||
})
|
||||
@@ -0,0 +1,504 @@
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { findLatestOrderByPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
|
||||
import {
|
||||
findRedeemCertificateByCertNo,
|
||||
listRedeemCertificatesForAdmin,
|
||||
markRedeemCertificateInvalid,
|
||||
markRedeemCertificatePlatformCert,
|
||||
markRedeemCertificateSynced,
|
||||
markRedeemCertificateVerifyCancelled,
|
||||
resolveRedeemVerifyCertNo,
|
||||
type RedeemCertificateAdminListQuery,
|
||||
type RedeemCertificateAdminListRow,
|
||||
type RedeemCertificateRow,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import {
|
||||
fetchDouyinMashidaCoupons,
|
||||
fetchDouyinMashidaOrderDetail,
|
||||
syncDouyinMashidaCoupons,
|
||||
} from '../platforms/douyin-mashida/gateway-client.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PLATFORM,
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
} from '../platforms/douyin-mashida/order-mapper.js'
|
||||
import {
|
||||
assertDouyinMashidaConfig,
|
||||
type DouyinMashidaSourceConfig,
|
||||
} from '../platforms/douyin-mashida/source-config-service.js'
|
||||
import {
|
||||
cancelCertificateVerify,
|
||||
ensureCertificateForOrderItem,
|
||||
formatGrantTime,
|
||||
verifyCertificate,
|
||||
} from '../redeem/certificate-service.js'
|
||||
import type {
|
||||
AdminDouyinMashidaCertificateActionInput,
|
||||
AdminDouyinMashidaCertificateListInput,
|
||||
} from '../../types/admin/write-inputs.js'
|
||||
|
||||
/**
|
||||
* 后台「抖音券核销」:给运营一块独立于快手电子凭证的界面。
|
||||
*
|
||||
* 为什么单独一套:表 `redeem_certificates` 是我们**自发**的兑换券(DYC 券码,走抖音码速达
|
||||
* `syncV2` / `verifyV3` / `cancelVerify`),与快手行业电子凭证(`kuaishou_industry_vouchers`,
|
||||
* 走快手 OpenAPI)是两张表、两套状态机,混用会污染状态。
|
||||
*
|
||||
* 主要补两个此前完全看不见的运营场景:
|
||||
* - 皮肤单自动核销是 `void ... .catch(warn)`,失败只落日志,后台无感知;
|
||||
* - 误核销时 `coupons.cancelVerify` 出网方法早已就绪,但没有入口。
|
||||
*/
|
||||
|
||||
export async function listAdminDouyinMashidaCertificates(
|
||||
input: AdminDouyinMashidaCertificateListInput = {},
|
||||
) {
|
||||
const query: RedeemCertificateAdminListQuery = {
|
||||
certNo: String(input.certNo || '').trim(),
|
||||
orderId: String(input.orderId || input.platformOrderId || '').trim(),
|
||||
localOrderId: input.localOrderId,
|
||||
orderItemId: input.orderItemId,
|
||||
status: String(input.status || '').trim(),
|
||||
syncStatus: String(input.syncStatus || '').trim(),
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
}
|
||||
|
||||
const result = await listRedeemCertificatesForAdmin(query)
|
||||
|
||||
return {
|
||||
items: result.items.map(mapAdminCertificate),
|
||||
pagination: {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动核销(幂等):本地已核销直接返回;未同步成功的券不允许核销(平台侧没有这张券)。 */
|
||||
export async function verifyAdminDouyinMashidaCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const certificate = await resolveCertificate(input)
|
||||
const config = assertDouyinMashidaConfig()
|
||||
|
||||
const verified = await verifyCertificate({ certNo: certificate.cert_no, config })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(verified),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲正:先调平台 `coupons.cancelVerify`,成功后才把本地状态退回 `synced`。
|
||||
*
|
||||
* 顺序不能反:平台侧还是「已使用」时把本地改成可用,会造成「本地说没核销、平台说已核销」的状态漂移。
|
||||
*/
|
||||
export async function cancelAdminDouyinMashidaCertificateVerify(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const certificate = await resolveCertificate(input)
|
||||
const config = assertDouyinMashidaConfig()
|
||||
|
||||
if (certificate.status !== 'verified') {
|
||||
throw createHttpError('仅已核销的券码可以冲正', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_douyin_mashida_certificate_not_verified',
|
||||
context: { certNo: certificate.cert_no, status: certificate.status },
|
||||
})
|
||||
}
|
||||
|
||||
// 平台可能改过券码,冲正同样必须用平台侧券码
|
||||
await cancelCertificateVerify({
|
||||
certNo: resolveRedeemVerifyCertNo(certificate),
|
||||
batchNo: String(input.batchNo || '').trim(),
|
||||
config,
|
||||
})
|
||||
|
||||
const reverted = (await markRedeemCertificateVerifyCancelled(certificate.id)) || certificate
|
||||
|
||||
logIntegration('[admin/douyin-mashida]', '券码已冲正', {
|
||||
certNo: certificate.cert_no,
|
||||
orderId: certificate.order_id,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(reverted),
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新同步:把券码(+当前 cert_link)再推一次 `coupons.syncV2`,用于同步失败后止血。 */
|
||||
export async function syncAdminDouyinMashidaCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const certificate = await resolveCertificate(input)
|
||||
const config = assertDouyinMashidaConfig()
|
||||
|
||||
if (certificate.status === 'verified') {
|
||||
throw createHttpError('券码已核销,不能重复同步', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_douyin_mashida_certificate_verified',
|
||||
context: { certNo: certificate.cert_no },
|
||||
})
|
||||
}
|
||||
if (certificate.status === 'invalid') {
|
||||
throw createHttpError('券码已作废,不能重新同步', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_douyin_mashida_certificate_invalid',
|
||||
context: { certNo: certificate.cert_no },
|
||||
})
|
||||
}
|
||||
|
||||
const certLink = String(certificate.cert_link || config.certLink || '').trim()
|
||||
if (!certLink) {
|
||||
throw createHttpError('未配置兑换链接(certLink),无法同步券码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_douyin_mashida_cert_link_missing',
|
||||
})
|
||||
}
|
||||
|
||||
await syncDouyinMashidaCoupons({
|
||||
orderId: certificate.order_id,
|
||||
certs: [
|
||||
{
|
||||
certNo: certificate.cert_no,
|
||||
certLink,
|
||||
grantTime: formatGrantTime(certificate.grant_time || new Date().toISOString()),
|
||||
},
|
||||
],
|
||||
config,
|
||||
})
|
||||
|
||||
// syncV2 成功 ≠ 平台沿用我们的券码:回查平台侧真实券码再落地(与自动流程同一口径)
|
||||
const rows = await fetchDouyinMashidaCoupons({ orderId: certificate.order_id, config })
|
||||
const picked = pickAdminPlatformCouponRow(rows, certificate.cert_no)
|
||||
const refreshed = await refreshPlatformSnapshot(certificate, picked)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(refreshed || certificate),
|
||||
platform: picked ? mapPlatformCoupon(picked) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 对账:查平台侧 `coupons.list`,只读不改本地状态。 */
|
||||
export async function checkAdminDouyinMashidaCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const certificate = await resolveCertificate(input)
|
||||
const config = assertDouyinMashidaConfig()
|
||||
const rows = await fetchDouyinMashidaCoupons({
|
||||
orderId: certificate.order_id,
|
||||
certNo: certificate.cert_no,
|
||||
config,
|
||||
})
|
||||
const picked = pickAdminPlatformCouponRow(rows, certificate.cert_no)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(certificate),
|
||||
platform: picked ? mapPlatformCoupon(picked) : null,
|
||||
platformRowCount: rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 重发券码:按订单号再走一遍发券+同步(券已存在时幂等复用)。 */
|
||||
export async function resendAdminDouyinMashidaCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const config = assertDouyinMashidaConfig()
|
||||
const context = await resolveCertificateOrderContext(input)
|
||||
|
||||
const certificate = await ensureCertificateForOrderItem({
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
config,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(certificate),
|
||||
order: {
|
||||
id: context.order.id,
|
||||
platformOrderId: String(context.order.platform_order_id || '').trim(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动作废:把未核销的券打入 `invalid`(已核销的必须先冲正,避免资损)。 */
|
||||
export async function invalidateAdminDouyinMashidaCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const certificate = await resolveCertificate(input)
|
||||
|
||||
if (certificate.status === 'verified') {
|
||||
throw createHttpError('券码已核销,请先冲正再作废', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_douyin_mashida_certificate_verified',
|
||||
context: { certNo: certificate.cert_no },
|
||||
})
|
||||
}
|
||||
|
||||
const reason = String(input.reason || '').trim() || '后台手动作废'
|
||||
const updated = await markRedeemCertificateInvalid({ id: certificate.id, reason })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
certificate: mapAdminCertificate(updated || certificate),
|
||||
}
|
||||
}
|
||||
|
||||
/** 平台订单详情(只读快照,便于运营核对平台侧发生了什么)。 */
|
||||
export async function fetchAdminDouyinMashidaOrderDetail(
|
||||
input: AdminDouyinMashidaCertificateActionInput = {},
|
||||
) {
|
||||
const config = assertDouyinMashidaConfig()
|
||||
const shopOrderId = String(input.orderId || input.platformOrderId || '').trim()
|
||||
if (!shopOrderId) {
|
||||
throw createHttpError('缺少平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_douyin_mashida_order_id_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const detail = await fetchDouyinMashidaOrderDetail({ shopOrderId, config })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: shopOrderId,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台出参:后台是运营侧,券码不掩码(买家侧兑换页才掩码)。 */
|
||||
function mapAdminCertificate(row: RedeemCertificateRow | RedeemCertificateAdminListRow) {
|
||||
const listRow = row as RedeemCertificateAdminListRow
|
||||
const platformStatus = Number(row.platform_status ?? Number.NaN)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
certNo: row.cert_no,
|
||||
/** 核销实际会用的券码(平台侧优先) */
|
||||
verifyCertNo: resolveRedeemVerifyCertNo(row),
|
||||
platformCertNo: String(row.platform_cert_no || '').trim(),
|
||||
platformCertId: String(row.platform_cert_id || '').trim(),
|
||||
platformStatus: Number.isFinite(platformStatus) ? platformStatus : null,
|
||||
platformStatusLabel: resolvePlatformStatusLabel(platformStatus),
|
||||
platformSyncedAt: row.platform_synced_at,
|
||||
orderId: row.order_id,
|
||||
parentOrderId: row.parent_order_id,
|
||||
localOrderId: row.local_order_id,
|
||||
orderItemId: row.order_item_id,
|
||||
certLink: row.cert_link,
|
||||
status: row.status,
|
||||
syncStatus: row.sync_status,
|
||||
syncError: String(row.sync_error || '').trim(),
|
||||
grantTime: row.grant_time,
|
||||
syncedAt: row.synced_at,
|
||||
verifiedAt: row.verified_at,
|
||||
/** 仅列表 join 有值;单券详情接口为空串 */
|
||||
taskNo: String(listRow.task_no || '').trim(),
|
||||
skuCode: String(listRow.sku_code || '').trim(),
|
||||
skuName: String(listRow.sku_name || '').trim(),
|
||||
rawPayload: parseJsonObject(row.raw_payload_json),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapPlatformCoupon(row: Record<string, unknown>) {
|
||||
const status = Number(row.status ?? Number.NaN)
|
||||
|
||||
return {
|
||||
certNo: String(row.cert_no || '').trim(),
|
||||
certId: String(row.cert_id || '').trim(),
|
||||
status: Number.isFinite(status) ? status : null,
|
||||
statusLabel: resolvePlatformStatusLabel(status),
|
||||
verifyNum: Number(row.verify_num || 0) || 0,
|
||||
certLink: String(row.cert_link || '').trim(),
|
||||
verified: Number(row.status) === 3 || Number(row.verify_num || 0) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 平台侧券状态:1未激活/2未使用/3已使用/4已废弃/5已过期/6已完成。 */
|
||||
export function resolvePlatformStatusLabel(status: number): string {
|
||||
if (!Number.isFinite(status)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (Math.trunc(status)) {
|
||||
case 1:
|
||||
return '未激活'
|
||||
case 2:
|
||||
return '未使用'
|
||||
case 3:
|
||||
return '已使用'
|
||||
case 4:
|
||||
return '已废弃'
|
||||
case 5:
|
||||
return '已过期'
|
||||
case 6:
|
||||
return '已完成'
|
||||
default:
|
||||
return `未知(${Math.trunc(status)})`
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlatformSnapshot(
|
||||
certificate: RedeemCertificateRow,
|
||||
picked: Record<string, unknown> | null,
|
||||
): Promise<RedeemCertificateRow | null> {
|
||||
if (!picked) {
|
||||
return null
|
||||
}
|
||||
|
||||
const synced = await markRedeemCertificateSynced({
|
||||
id: certificate.id,
|
||||
certLink: String(certificate.cert_link || '').trim(),
|
||||
})
|
||||
|
||||
return (
|
||||
(await markRedeemCertificatePlatformCert({
|
||||
id: certificate.id,
|
||||
platformCertNo: String(picked.cert_no || '').trim(),
|
||||
platformCertId: String(picked.cert_id || '').trim(),
|
||||
platformStatus: Number.isFinite(Number(picked.status)) ? Number(picked.status) : null,
|
||||
})) || synced
|
||||
)
|
||||
}
|
||||
|
||||
/** 从 `coupons.list` 结果里挑出属于本次的那张券(与自动流程保持同一口径)。 */
|
||||
export function pickAdminPlatformCouponRow(
|
||||
rows: Array<Record<string, unknown>>,
|
||||
ourCertNo: string,
|
||||
): Record<string, unknown> | null {
|
||||
const usable = (Array.isArray(rows) ? rows : []).filter(
|
||||
(row) => Object.prototype.toString.call(row) === '[object Object]',
|
||||
)
|
||||
if (usable.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const exact = usable.find(
|
||||
(row) => String(row.cert_no || '').trim() === String(ourCertNo || '').trim(),
|
||||
)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
if (usable.length === 1) {
|
||||
return usable[0] || null
|
||||
}
|
||||
|
||||
return (
|
||||
usable.find((row) => Number(row.status) === 2) ||
|
||||
usable.find((row) => !(Number(row.status) === 3 || Number(row.verify_num || 0) > 0)) ||
|
||||
usable[0] ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveCertificate(
|
||||
input: AdminDouyinMashidaCertificateActionInput,
|
||||
): Promise<RedeemCertificateRow> {
|
||||
const certNo = String(input.certNo || '').trim()
|
||||
if (!certNo) {
|
||||
throw createHttpError('请填写券码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_douyin_mashida_cert_no_missing',
|
||||
})
|
||||
}
|
||||
|
||||
// 运营可能贴的是平台侧券码(平台会改写我们的券码),两边都要认
|
||||
const certificate =
|
||||
(await findRedeemCertificateByCertNo(certNo)) || (await findCertificateByPlatformCertNo(certNo))
|
||||
|
||||
if (!certificate) {
|
||||
throw createHttpError('券码不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_douyin_mashida_certificate_not_found',
|
||||
context: { certNo },
|
||||
})
|
||||
}
|
||||
|
||||
return certificate
|
||||
}
|
||||
|
||||
async function findCertificateByPlatformCertNo(
|
||||
certNo: string,
|
||||
): Promise<RedeemCertificateRow | null> {
|
||||
const rows = await listRedeemCertificatesForAdmin({ certNo, pageSize: 1 })
|
||||
|
||||
return (
|
||||
rows.items.find(
|
||||
(row) => String(row.platform_cert_no || '').trim() === String(certNo || '').trim(),
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/** 按订单号反查本地抖音订单 + 订单项(重发券码需要完整上下文)。 */
|
||||
async function resolveCertificateOrderContext(
|
||||
input: AdminDouyinMashidaCertificateActionInput,
|
||||
): Promise<{ order: OrderRow; orderItem: OrderItemRow }> {
|
||||
const orderId = String(input.orderId || input.platformOrderId || '').trim()
|
||||
const localOrderId = Number(input.localOrderId || 0)
|
||||
|
||||
const order =
|
||||
Number.isFinite(localOrderId) && localOrderId > 0
|
||||
? await getOrderById(Math.trunc(localOrderId))
|
||||
: orderId
|
||||
? await findLatestOrderByPlatformOrderId({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
platform: DOUYIN_MASHIDA_PLATFORM,
|
||||
platformOrderId: orderId,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('未找到对应的抖音订单,请填写平台订单号或本地订单 ID', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_douyin_mashida_order_not_found',
|
||||
context: { orderId, localOrderId },
|
||||
})
|
||||
}
|
||||
|
||||
const items = await listOrderItemsByOrderId(order.id)
|
||||
const requestedItemId = Number(input.orderItemId || 0)
|
||||
const orderItem =
|
||||
(Number.isFinite(requestedItemId) && requestedItemId > 0
|
||||
? items.find((item: OrderItemRow) => item.id === Math.trunc(requestedItemId))
|
||||
: items[0]) || null
|
||||
|
||||
if (!orderItem) {
|
||||
throw createHttpError('该订单没有订单项,无法发券', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_douyin_mashida_order_item_not_found',
|
||||
context: { orderId: order.id },
|
||||
})
|
||||
}
|
||||
|
||||
return { order, orderItem }
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): JsonObject {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as JsonObject)
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminDouyinMashidaCertificate = ReturnType<typeof mapAdminCertificate>
|
||||
export type AdminDouyinMashidaPlatformCoupon = ReturnType<typeof mapPlatformCoupon>
|
||||
Reference in New Issue
Block a user