feat(douyin-mashida): 新增抖音券核销后台界面(列表/核销/冲正/同步/对账/作废)
抖音订单并非纯自动发货:内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING 才走自动通道, 未命中显式回落人工,且核销依赖买家自己到 /redeem 输订单号。此前核销侧在后台完全 不可见:自动核销是 void .catch(warn) 只落日志,cancelVerify 出网方法已就绪但没有入口。 照快手电子凭证的骨架做一套抖音版(两张表两套状态机,不复用): - 券码状态、平台侧状态、同步错误全部可视化,支持只看同步失败 - 核销/冲正/重新同步/平台对账/重发券码/作废,每个写操作都带审计 - 券码列显示「核销实际会用的券码」(platform_cert_no 优先),平台改过名时额外标注 - 冲正先打平台 cancelVerify 再回退本地,避免本地与平台状态漂移
This commit is contained in:
@@ -61,6 +61,137 @@ export function generateRedeemCertificateCode(
|
||||
return `${REDEEM_CERT_CODE_PREFIX}${encodeCrockfordBase32(randomBytes(CERT_CODE_RANDOM_BYTES))}`
|
||||
}
|
||||
|
||||
export type RedeemCertificateAdminListQuery = {
|
||||
/** 券码(我方 cert_no 或平台侧 platform_cert_no,精确匹配,大小写不敏感) */
|
||||
certNo?: string | undefined
|
||||
/** 平台订单号(子/父订单号任一匹配) */
|
||||
orderId?: string | undefined
|
||||
/** 本地订单 ID */
|
||||
localOrderId?: number | string | undefined
|
||||
/** 本地订单项 ID */
|
||||
orderItemId?: number | string | undefined
|
||||
/** 券状态:issued / synced / verified / invalid */
|
||||
status?: string | undefined
|
||||
/** 同步状态:pending / success / failed */
|
||||
syncStatus?: string | undefined
|
||||
page?: number | string | undefined
|
||||
pageSize?: number | string | undefined
|
||||
}
|
||||
|
||||
/** 后台列表行:券 + 关联的任务号 / 商品编码,方便运营认货。 */
|
||||
export type RedeemCertificateAdminListRow = RedeemCertificateRow & {
|
||||
task_no: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
}
|
||||
|
||||
export async function listRedeemCertificatesForAdmin(
|
||||
input: RedeemCertificateAdminListQuery = {},
|
||||
): Promise<{
|
||||
items: RedeemCertificateAdminListRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}> {
|
||||
const page = normalizePositiveLimit(input.page, 1)
|
||||
const pageSize = Math.min(normalizePositiveLimit(input.pageSize, 20), 100)
|
||||
const where: string[] = ["c.provider = 'douyin_mashida'"]
|
||||
const params: unknown[] = []
|
||||
|
||||
const certNo = String(input.certNo || '').trim()
|
||||
if (certNo) {
|
||||
params.push(Array.from(new Set([certNo, certNo.toUpperCase()])))
|
||||
where.push(
|
||||
`(c.cert_no = ANY($${params.length}::text[]) OR c.platform_cert_no = ANY($${params.length}::text[]))`,
|
||||
)
|
||||
}
|
||||
|
||||
const orderId = String(input.orderId || '').trim()
|
||||
if (orderId) {
|
||||
params.push(orderId)
|
||||
where.push(`(c.order_id = $${params.length} OR c.parent_order_id = $${params.length})`)
|
||||
}
|
||||
|
||||
const localOrderId = Number(input.localOrderId || 0)
|
||||
if (Number.isFinite(localOrderId) && localOrderId > 0) {
|
||||
params.push(Math.trunc(localOrderId))
|
||||
where.push(`c.local_order_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const orderItemId = Number(input.orderItemId || 0)
|
||||
if (Number.isFinite(orderItemId) && orderItemId > 0) {
|
||||
params.push(Math.trunc(orderItemId))
|
||||
where.push(`c.order_item_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const status = String(input.status || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (status) {
|
||||
params.push(status)
|
||||
where.push(`c.status = $${params.length}`)
|
||||
}
|
||||
|
||||
const syncStatus = String(input.syncStatus || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (syncStatus) {
|
||||
params.push(syncStatus)
|
||||
where.push(`c.sync_status = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`
|
||||
const totalResult = await query<{ total: string | number }>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM redeem_certificates c
|
||||
${whereSql}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const listResult = await query<RedeemCertificateAdminListRow>(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
COALESCE(ft.task_no, '') AS task_no,
|
||||
COALESCE(oi_task.sku_code, oi_order.sku_code, '') AS sku_code,
|
||||
COALESCE(oi_task.sku_name, oi_order.sku_name, '') AS sku_name
|
||||
FROM redeem_certificates c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ft.task_no, ft.order_item_id
|
||||
FROM fulfillment_tasks ft
|
||||
WHERE c.order_item_id IS NOT NULL
|
||||
AND ft.order_item_id = c.order_item_id
|
||||
ORDER BY ft.id ASC
|
||||
LIMIT 1
|
||||
) ft ON true
|
||||
LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT oi.sku_code, oi.sku_name
|
||||
FROM order_items oi
|
||||
WHERE c.local_order_id IS NOT NULL
|
||||
AND oi.order_id = c.local_order_id
|
||||
ORDER BY oi.id ASC
|
||||
LIMIT 1
|
||||
) oi_order ON true
|
||||
${whereSql}
|
||||
ORDER BY c.updated_at DESC, c.id DESC
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: listResult.rows,
|
||||
total: Number(totalResult.rows[0]?.total || 0) || 0,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
export async function findRedeemCertificateByOrder(input: {
|
||||
provider: string
|
||||
orderId: string
|
||||
@@ -262,6 +393,51 @@ export async function invalidateRedeemCertificatesByLocalOrder(input: {
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台冲正(`coupons.cancelVerify` 成功后)把本地状态退回「已同步未核销」。
|
||||
*
|
||||
* 只回退 `verified` 的券:issued/synced 本来就没核销,invalid 是退款作废的终止态,
|
||||
* 冲正动不了平台侧的作废状态,改本地反而会造成「本地可用、平台不可用」的状态漂移。
|
||||
*/
|
||||
export async function markRedeemCertificateVerifyCancelled(
|
||||
id: number | string,
|
||||
): Promise<RedeemCertificateRow | null> {
|
||||
const result = await query<RedeemCertificateRow>(
|
||||
`
|
||||
UPDATE redeem_certificates
|
||||
SET status = 'synced', verified_at = NULL, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'verified'
|
||||
RETURNING *
|
||||
`,
|
||||
[Number(id)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/** 后台作废:把未核销的券打入 `invalid`(已核销的留给售后/冲正处理)。 */
|
||||
export async function markRedeemCertificateInvalid(input: {
|
||||
id: number | string
|
||||
reason: string
|
||||
}): Promise<RedeemCertificateRow | null> {
|
||||
const result = await query<RedeemCertificateRow>(
|
||||
`
|
||||
UPDATE redeem_certificates
|
||||
SET status = 'invalid',
|
||||
sync_error = CASE WHEN sync_error = '' THEN $2 ELSE sync_error END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND status <> 'verified' AND status <> 'invalid'
|
||||
RETURNING *
|
||||
`,
|
||||
[Number(input.id), String(input.reason || '').slice(0, 500)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
function normalizePositiveLimit(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback
|
||||
}
|
||||
|
||||
function encodeCrockfordBase32(bytes: Uint8Array): string {
|
||||
let bits = 0
|
||||
let value = 0
|
||||
|
||||
Reference in New Issue
Block a user