/** * 订单退款(P2 手动先行): * 1. 手动登记退款 → order_refunds(一条记录 = 一个订单项); * 2. 整单退款按订单项拆成多条、共享 batch_no; * 3. 全额退完后把订单 pay_status 置为 refunded,并关闭该订单项的成本异常; * 4. 重算当天销售事实,净收入与毛利立即冲减。 * * 后续接平台退款 API 时,只需写 source='api'/'platform' 的记录,其余逻辑复用。 */ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' import type { AdminSession } from '../admin/admin-auth-service.js' import { createHttpError } from '../../utils/http.js' import { generateRefundNo } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js' import { syncChannelCostSnapshots } from '../cost-center/cost-snapshot-service.js' import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js' import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js' export type OrderRefundRow = { id: number refund_no: string batch_no: string order_id: number order_item_id: number amount_fen: number reason: string note: string source: string status: string refunded_at: string operator_admin_user_id: number | null operator_name?: string | null voided_at: string | null void_reason: string created_at: string sku_name?: string | null } export type OrderRefund = { id: number refundNo: string batchNo: string orderId: number orderItemId: number skuName: string amountFen: number reason: string note: string source: string status: string refundedAt: string operatorAdminUserId: number | null operatorName: string voidedAt: string | null voidReason: string createdAt: string } export type CreateManualRefundInput = { orderId: number | string orderItemId?: number | string | null | undefined amountFen?: number | string | null | undefined reason?: string | undefined note?: string | undefined refundedAt?: string | undefined /** 默认 manual;平台接口退款写 platform,平台回调写 api。 */ source?: 'manual' | 'api' | 'platform' | 'legacy' /** 平台退款单号/外部流水号。 */ externalRef?: string } export async function listOrderRefunds(orderId: number | string): Promise { const id = normalizeId(orderId, '订单 ID') const result = await query( ` SELECT r.*, oi.sku_name, COALESCE(NULLIF(u.display_name, ''), u.username, '') AS operator_name FROM order_refunds r LEFT JOIN v_order_item_current oi ON oi.id = r.order_item_id LEFT JOIN admin_users u ON u.id = r.operator_admin_user_id WHERE r.order_id = $1 ORDER BY r.refunded_at DESC, r.id DESC `, [id], ) return result.rows.map(mapOrderRefund) } export async function createManualRefund( input: CreateManualRefundInput, session: AdminSession | null, ): Promise<{ batchNo: string refunds: OrderRefund[] fullRefunded: boolean closedExceptionCount: number }> { const orderId = normalizeId(input.orderId, '订单 ID') const requestedItemId = input.orderItemId ? normalizeId(input.orderItemId, '订单项 ID') : null const amountFen = normalizeAmountFen(input.amountFen) if (requestedItemId && amountFen <= 0) { throw createHttpError('退款金额必须大于 0', { statusCode: 400, errorCode: 'order_refund_amount_invalid', }) } if (!requestedItemId && amountFen > 0) { throw createHttpError('整单退款不需要填写金额,系统按订单项剩余可退金额拆分', { statusCode: 400, errorCode: 'order_refund_amount_not_allowed', }) } const reason = normalizeText(input.reason, 200) || '手动退款' const note = normalizeText(input.note, 500) const refundedAt = normalizeTimestamp(input.refundedAt) const source = normalizeRefundSource(input.source) const externalRef = normalizeText(input.externalRef, 120) const now = nowIso() const written = await withTransaction(async (client) => { const orderResult = await client.query<{ id: number pay_status: string basis_at: string }>( ` SELECT id, pay_status, COALESCE(paid_at, created_at) AS basis_at FROM orders WHERE id = $1 FOR UPDATE `, [orderId], ) const order = orderResult.rows[0] if (!order) { throw createHttpError('订单不存在', { statusCode: 404, errorCode: 'order_refund_order_not_found', }) } const activeItems = await client.query<{ total: number }>( `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [orderId], ) if (Number(activeItems.rows[0]?.total || 0) > 0) { // 只有断代后订单才影响财务;历史归档订单退款不校验期间。 await assertBasisPeriodOpen(client, order.basis_at, '退款') await assertRefundMonthOpen(client, '退款', toChinaDate(refundedAt || now)) } const itemsResult = await client.query<{ id: number sku_name: string revenue_fen: number refunded_fen: number }>( ` SELECT oi.id, COALESCE(oi.sku_name, '') AS sku_name, GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen, COALESCE(( SELECT SUM(r.amount_fen) FROM order_refunds r WHERE r.order_item_id = oi.id AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen FROM v_order_item_current oi WHERE oi.order_id = $1 ORDER BY oi.id `, [orderId], ) const items = itemsResult.rows if (items.length === 0) { throw createHttpError('订单没有商品明细,无法退款', { statusCode: 409, errorCode: 'order_refund_items_missing', }) } const targets = requestedItemId ? items.filter((item) => Number(item.id) === requestedItemId) : items if (requestedItemId && targets.length === 0) { throw createHttpError('订单项不属于该订单', { statusCode: 400, errorCode: 'order_refund_item_mismatch', }) } const rows: Array<{ itemId: number; amountFen: number }> = [] for (const item of targets) { const remaining = Math.max(0, Number(item.revenue_fen) - Number(item.refunded_fen)) if (requestedItemId) { if (amountFen > remaining) { throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remaining)})`, { statusCode: 409, errorCode: 'order_refund_amount_exceeds', }) } rows.push({ itemId: Number(item.id), amountFen }) } else if (remaining > 0) { rows.push({ itemId: Number(item.id), amountFen: remaining }) } } if (rows.length === 0) { throw createHttpError('该订单没有可退金额(可能已全部退款)', { statusCode: 409, errorCode: 'order_refund_nothing_left', }) } if (externalRef) { const duplicate = await client.query<{ id: number }>( `SELECT id FROM order_refunds WHERE source = $1 AND external_ref = $2 LIMIT 1`, [source, externalRef], ) if (duplicate.rows[0]) { throw createHttpError('该平台退款单号已登记过,不能重复退款', { statusCode: 409, errorCode: 'order_refund_external_ref_exists', }) } } const batchNo = generateRefundNo(new Date(refundedAt)) const insertedIds: number[] = [] for (const [index, row] of rows.entries()) { try { const inserted = await client.query<{ id: number }>( ` INSERT INTO order_refunds ( refund_no, batch_no, order_id, order_item_id, amount_fen, reason, note, source, status, refunded_at, external_ref, operator_admin_user_id, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, 'confirmed', $9::timestamptz, $10, $11, $12::timestamptz, $12::timestamptz ) RETURNING id `, [ `${batchNo}-${String(index + 1).padStart(2, '0')}`, batchNo, orderId, row.itemId, row.amountFen, reason, note, source, refundedAt, externalRef, session?.userId || null, now, ], ) insertedIds.push(Number(inserted.rows[0]?.id || 0)) } catch (error) { if ((error as { code?: string }).code === '23505') { throw createHttpError('该平台退款单号已登记过,不能重复退款', { statusCode: 409, errorCode: 'order_refund_external_ref_exists', }) } throw error } } // 订单项都退完 → 订单级 pay_status 同步为 refunded(与平台全额退款口径一致)。 const totalResult = await client.query<{ revenue_fen: number; refunded_fen: number }>( ` SELECT COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen, COALESCE(( SELECT SUM(r.amount_fen) FROM order_refunds r JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen FROM v_order_item_current oi WHERE oi.order_id = $1 `, [orderId], ) const totalRevenue = Number(totalResult.rows[0]?.revenue_fen || 0) const totalRefunded = Number(totalResult.rows[0]?.refunded_fen || 0) const fullRefunded = totalRevenue > 0 && totalRefunded >= totalRevenue if (fullRefunded && order.pay_status !== 'refunded') { await client.query( `UPDATE orders SET pay_status = 'refunded', updated_at = $1::timestamptz WHERE id = $2`, [now, orderId], ) } // 已完成全额退款的订单项,成本异常直接闭环(退款单不再需要成本)。 const fullyRefundedItems = items .filter((item) => { const refunded = rows .filter((row) => row.itemId === Number(item.id)) .reduce((sum, row) => sum + row.amountFen, 0) return Number(item.refunded_fen) + refunded >= Number(item.revenue_fen) }) .map((item) => Number(item.id)) let closedExceptionCount = 0 if (fullyRefundedItems.length > 0) { const closed = await client.query( ` UPDATE cost_exceptions SET status = 'ignored', resolved_at = $1::timestamptz, resolved_by_admin_user_id = $2, resolution_note = $3 WHERE status = 'open' AND order_item_id = ANY($4::bigint[]) `, [now, session?.userId || null, '订单已退款,成本异常自动关闭', fullyRefundedItems], ) closedExceptionCount = closed.rowCount || 0 } return { batchNo, insertedIds, fullRefunded, closedExceptionCount, basisAt: order.basis_at, } }) // 比例类费用按净收入计:退款后重算相关订单项的费用快照,再回填事实。 const refundedItems = await query<{ order_item_id: number }>( 'SELECT DISTINCT order_item_id FROM order_refunds WHERE id = ANY($1::bigint[])', [written.insertedIds], ) try { for (const row of refundedItems.rows) { await syncChannelCostSnapshots({ afterId: Math.max(0, Number(row.order_item_id) - 1), batchSize: 1, maxBatches: 1, }) } } catch { // 快照重算失败不影响退款本身,成本快照管道会兜底。 } await refreshActiveRefundFacts( refundedItems.rows.map((row) => Number(row.order_item_id)), toChinaDate(written.basisAt), ) const refunds = await listOrderRefunds(orderId) return { batchNo: written.batchNo, refunds: refunds.filter((item) => written.insertedIds.includes(item.id)), fullRefunded: written.fullRefunded, closedExceptionCount: written.closedExceptionCount, } } export async function voidOrderRefund( refundId: number | string, payload: { reason?: string }, session: AdminSession | null, ): Promise<{ refund: OrderRefund; rebuiltDate: string }> { const id = normalizeId(refundId, '退款记录 ID') const reason = normalizeText(payload.reason, 200) || '撤销退款' const now = nowIso() const context = await withTransaction(async (client) => { const result = await client.query<{ id: number order_id: number status: string pay_status: string basis_at: string }>( ` SELECT r.id, r.order_id, r.status, o.pay_status, COALESCE(o.paid_at, o.created_at) AS basis_at FROM order_refunds r JOIN orders o ON o.id = r.order_id WHERE r.id = $1 FOR UPDATE `, [id], ) const row = result.rows[0] if (!row) { throw createHttpError('退款记录不存在', { statusCode: 404, errorCode: 'order_refund_not_found', }) } if (row.status !== 'confirmed') { throw createHttpError('该退款记录已撤销', { statusCode: 409, errorCode: 'order_refund_already_voided', }) } const activeItems = await client.query<{ total: number }>( `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [row.order_id], ) if (Number(activeItems.rows[0]?.total || 0) > 0) { await assertBasisPeriodOpen(client, row.basis_at, '撤销退款') await assertRefundMonthOpen(client, '撤销退款') } await client.query( ` UPDATE order_refunds SET status = 'voided', voided_at = $1::timestamptz, void_reason = $2, operator_admin_user_id = COALESCE($3, operator_admin_user_id), updated_at = $1::timestamptz WHERE id = $4 `, [now, reason, session?.userId || null, id], ) // 撤销后按“剩余 confirmed 退款”重算订单支付状态: // 全额退款撤销必须把订单从 refunded 恢复为 paid,否则销售事实仍会按全额退款冲减。 const totals = await client.query<{ revenue_fen: number; refunded_fen: number }>( ` SELECT COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen, COALESCE(( SELECT SUM(r.amount_fen) FROM order_refunds r JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen FROM v_order_item_current oi WHERE oi.order_id = $1 `, [row.order_id], ) const totalRevenue = Number(totals.rows[0]?.revenue_fen || 0) const totalRefunded = Number(totals.rows[0]?.refunded_fen || 0) let nextPayStatus = row.pay_status if (totalRevenue > 0) { nextPayStatus = totalRefunded >= totalRevenue ? 'refunded' : 'paid' } await client.query( `UPDATE orders SET pay_status = $1, updated_at = $2::timestamptz WHERE id = $3`, [nextPayStatus, now, row.order_id], ) // 退款时自动关闭的成本异常,撤销后重新打开,避免成本缺口被永久忽略。 await client.query( ` UPDATE cost_exceptions SET status = 'open', resolved_at = NULL, resolved_by_admin_user_id = NULL, resolution_note = '' WHERE status = 'ignored' AND resolution_note = '订单已退款,成本异常自动关闭' AND order_item_id IN (SELECT id FROM order_items WHERE order_id = $1) `, [row.order_id], ) return { orderId: Number(row.order_id), basisAt: row.basis_at } }) const rebuiltDate = toChinaDate(context.basisAt) const voidedItem = await query<{ order_item_id: number }>( 'SELECT order_item_id FROM order_refunds WHERE id = $1', [id], ) await refreshActiveRefundFacts( voidedItem.rows.map((row) => Number(row.order_item_id)), rebuiltDate, ) const refunds = await listOrderRefunds(context.orderId) const refund = refunds.find((item) => item.id === id) if (!refund) { throw createHttpError('退款记录读取失败', { statusCode: 500, errorCode: 'order_refund_reload_failed', }) } return { refund, rebuiltDate } } export function mapOrderRefund(row: OrderRefundRow): OrderRefund { return { id: Number(row.id), refundNo: row.refund_no, batchNo: row.batch_no || '', orderId: Number(row.order_id), orderItemId: Number(row.order_item_id), skuName: row.sku_name || '', amountFen: Number(row.amount_fen), reason: row.reason || '', note: row.note || '', source: row.source || 'manual', status: row.status, refundedAt: row.refunded_at, operatorAdminUserId: row.operator_admin_user_id ? Number(row.operator_admin_user_id) : null, operatorName: row.operator_name || '', voidedAt: row.voided_at, voidReason: row.void_reason || '', createdAt: row.created_at, } } async function assertRefundMonthOpen( client: Pick, action: string, date?: string, ) { const day = date || CHINA_DATE_FORMATTER.format(new Date()) const periodKey = day.slice(0, 7) const period = await client.query<{ status: string }>( `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, [periodKey], ) if (period.rows[0]?.status === 'closed') { throw createHttpError(`退款所属期间 ${periodKey} 已关账,请先反关账后再${action}`, { statusCode: 409, errorCode: 'order_refund_period_closed', }) } } async function assertBasisPeriodOpen( client: Pick, basisAt: string, action: string, ) { const basisDate = toChinaDate(basisAt) if (!basisDate) return const periodKey = basisDate.slice(0, 7) const period = await client.query<{ status: string }>( `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, [periodKey], ) if (period.rows[0]?.status === 'closed') { throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再${action}`, { statusCode: 409, errorCode: 'order_refund_period_closed', }) } } function normalizeId(value: unknown, label: string): number { const id = Number(value || 0) if (!Number.isFinite(id) || id <= 0) { throw createHttpError(`${label}无效`, { statusCode: 400, errorCode: 'invalid_id' }) } return Math.trunc(id) } function normalizeAmountFen(value: unknown): number { const amount = Number(value || 0) if (!Number.isFinite(amount) || amount < 0) return 0 return Math.round(amount) } function normalizeText(value: unknown, maxLength: number): string { return String(value ?? '') .trim() .slice(0, maxLength) } function normalizeTimestamp(value: unknown): string { const text = String(value || '').trim() if (!text) return nowIso() const date = new Date(text) if (Number.isNaN(date.getTime())) return nowIso() return date.toISOString() } /** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */ const CHINA_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }) function toChinaDate(value: string): string { const date = new Date(value) if (Number.isNaN(date.getTime())) return '' return CHINA_DATE_FORMATTER.format(date) } /** * 退款后回填事实与总账:只处理断代后的订单项。 * 历史归档订单的退款不进入新财务口径(业务侧自行处理)。 */ async function refreshActiveRefundFacts(itemIds: number[], basisDate: string): Promise { const ids = [...new Set(itemIds.filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0 || !basisDate) return const active = await query<{ total: number }>( `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE id = ANY($1::bigint[]) AND fulfillment_status <> 'archived'`, [ids], ) if (Number(active.rows[0]?.total || 0) === 0) return await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate }) await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate }) } function formatFen(value: number): string { return `¥${(Number(value || 0) / 100).toFixed(2)}` } /** * 平台退款:先调用平台退款接口,成功后再落本地退款记录(source=platform)。 * * 当前只接入快手行业(91)的「同意退款」接口;抖音/拼多多暂无可用退款 API, * 会返回 501,由后台手工登记兜底。 */ export async function approveAndRecordKuaishouRefund( input: { orderId: number | string orderItemId?: number | string | null | undefined amountFen?: number | string | null | undefined platformRefundId?: string | undefined reason?: string | undefined note?: string | undefined refundedAt?: string | undefined sellerId?: string | undefined refundHandingWay?: unknown status?: unknown negotiateStatus?: unknown desc?: string | undefined }, session: AdminSession | null, ) { const orderId = normalizeId(input.orderId, '订单 ID') const platformRefundId = normalizeText(input.platformRefundId, 120) if (!platformRefundId) { throw createHttpError('缺少平台退款单号', { statusCode: 400, errorCode: 'platform_refund_id_required', }) } // 平台退款必须在调用平台前完成本地参数校验:平台一旦同意,本地再失败会造成账实不一致。 const orderItemId = Number(input.orderItemId) if (!Number.isInteger(orderItemId) || orderItemId <= 0) { throw createHttpError('平台退款必须指定订单项', { statusCode: 400, errorCode: 'platform_refund_order_item_required', }) } const order = await query<{ provider: string; platform_order_id: string }>( `SELECT provider, platform_order_id FROM orders WHERE id = $1 LIMIT 1`, [orderId], ) const row = order.rows[0] if (!row) { throw createHttpError('订单不存在', { statusCode: 404, errorCode: 'platform_refund_order_not_found', }) } if (!['91kaquan', 'kuaishou-91'].includes(String(row.provider || ''))) { throw createHttpError('该平台暂未接入退款 API,请使用手工退款登记', { statusCode: 501, errorCode: 'platform_refund_not_supported', }) } const amountFen = normalizeAmountFen(input.amountFen) if (amountFen <= 0) { throw createHttpError('平台退款金额必须大于 0', { statusCode: 400, errorCode: 'platform_refund_amount_invalid', }) } const existingItem = await query<{ id: number; fulfillment_status: string }>( `SELECT id, fulfillment_status FROM v_order_item_current WHERE id = $1 AND order_id = $2 LIMIT 1`, [orderItemId, orderId], ) if (!existingItem.rows[0]) { throw createHttpError('订单项不存在或不属于该订单', { statusCode: 404, errorCode: 'platform_refund_order_item_not_found', }) } // 已关账期间不能直接改原期间事实;必须先反关账,否则平台退款后本地无法正确冲减。 const basis = await query<{ basis_at: string }>( `SELECT COALESCE(paid_at, created_at) AS basis_at FROM orders WHERE id = $1 LIMIT 1`, [orderId], ) const platformItemArchived = existingItem.rows[0]?.fulfillment_status === 'archived' if (!platformItemArchived && basis.rows[0]?.basis_at) { const basisDate = toChinaDate(String(basis.rows[0].basis_at)) const periodKey = basisDate.slice(0, 7) const period = await query<{ status: string }>( `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, [periodKey], ) if (period.rows[0]?.status === 'closed') { throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再退款`, { statusCode: 409, errorCode: 'order_refund_period_closed', }) } } // 退款当天所属期间也必须开着,否则退款冲减会静默丢失(历史归档订单除外)。 const refundPeriodKey = CHINA_DATE_FORMATTER.format(new Date()).slice(0, 7) const refundPeriod = platformItemArchived ? { rows: [] as Array<{ status: string }> } : await query<{ status: string }>( `SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`, [refundPeriodKey], ) if (refundPeriod.rows[0]?.status === 'closed') { throw createHttpError(`退款所属期间 ${refundPeriodKey} 已关账,请先反关账后再退款`, { statusCode: 409, errorCode: 'order_refund_period_closed', }) } // 已经登记过的平台退款必须幂等返回,不能因为剩余可退为 0 被误判为超额。 const recorded = await findRecordedPlatformRefund(orderId, platformRefundId) if (recorded) { return { approval: { idempotent: true, platformRefundId }, batchNo: recorded.batchNo, refunds: [recorded], fullRefunded: false, closedExceptionCount: 0, } } // 先做可退金额校验,再落退款意图;平台调用之后不再做“才发现金额超了”的校验。 const remainingFen = await getRefundableFen(orderId, orderItemId) if (amountFen > remainingFen) { throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remainingFen)})`, { statusCode: 409, errorCode: 'order_refund_amount_exceeds', }) } const intent = await beginPlatformRefundIntent({ provider: '91kaquan', externalRef: platformRefundId, orderId, orderItemId, amountFen, actorUserId: session?.userId || null, }) const needsPlatformCall = intent.status === 'pending' || (intent.status === 'failed' && !String(intent.error_message || '').startsWith('record_failed:')) let approval: unknown = { idempotent: true, platformRefundId } if (needsPlatformCall) { try { approval = await approveKuaishouIndustryRefund({ refundId: platformRefundId, refundAmount: amountFen, desc: input.desc || input.note || '', refundHandingWay: input.refundHandingWay, status: input.status, negotiateStatus: input.negotiateStatus, ...(input.sellerId ? { sellerId: input.sellerId } : {}), }) await updatePlatformRefundIntent(intent.id, 'platform_approved') } catch (error) { const message = error instanceof Error ? error.message : String(error) await updatePlatformRefundIntent(intent.id, 'failed', `platform_call_failed:${message}`) throw error } } try { const refund = await createManualRefund( { orderId, orderItemId, amountFen, reason: input.reason || '平台退款', note: input.note, refundedAt: input.refundedAt, source: 'platform', externalRef: platformRefundId, }, session, ) await updatePlatformRefundIntent(intent.id, 'recorded') return { approval, ...refund } } catch (error) { const message = error instanceof Error ? error.message : String(error) await updatePlatformRefundIntent(intent.id, 'failed', `record_failed:${message}`) throw createHttpError( `平台已同意退款,但本地记账失败:${message}。已保留退款意图,可用同一 platformRefundId 重试`, { statusCode: 502, errorCode: 'platform_refund_record_failed', }, ) } } type PlatformRefundIntentRow = { id: number status: string error_message: string } async function beginPlatformRefundIntent(input: { provider: string externalRef: string orderId: number orderItemId: number amountFen: number actorUserId: number | null }): Promise { return withTransaction(async (client) => { const existing = await client.query( `SELECT id, status, error_message FROM finance_refund_intents WHERE provider = $1 AND external_ref = $2 FOR UPDATE`, [input.provider, input.externalRef], ) if (existing.rows[0]) return existing.rows[0] try { const inserted = await client.query( ` INSERT INTO finance_refund_intents ( provider, external_ref, order_id, order_item_id, amount_fen, status, error_message, created_by_admin_user_id, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, 'pending', '', $6, $7, $7) RETURNING id, status, error_message `, [ input.provider, input.externalRef, input.orderId, input.orderItemId, input.amountFen, input.actorUserId, nowIso(), ], ) return inserted.rows[0] as PlatformRefundIntentRow } catch (error) { // 并发请求同时插入时,唯一键冲突后读取已存在的意图,由调用方按状态决定是否重试。 if ((error as { code?: string }).code === '23505') { const again = await client.query( `SELECT id, status, error_message FROM finance_refund_intents WHERE provider = $1 AND external_ref = $2 LIMIT 1`, [input.provider, input.externalRef], ) return again.rows[0] as PlatformRefundIntentRow } throw error } }) } async function updatePlatformRefundIntent( id: number, status: 'platform_approved' | 'recorded' | 'failed', errorMessage = '', ) { await query( `UPDATE finance_refund_intents SET status = $2, error_message = $3, updated_at = $4 WHERE id = $1`, [id, status, errorMessage.slice(0, 1000), nowIso()], ) } async function getRefundableFen(orderId: number, orderItemId: number): Promise { const result = await query<{ remaining_fen: string }>( ` SELECT (GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0) - COALESCE(( SELECT SUM(r.amount_fen) FROM order_refunds r WHERE r.order_item_id = oi.id AND r.status = 'confirmed' ), 0))::bigint AS remaining_fen FROM v_order_item_current oi WHERE oi.id = $1 AND oi.order_id = $2 LIMIT 1 `, [orderItemId, orderId], ) if (!result.rows[0]) { throw createHttpError('订单项不存在或不属于该订单', { statusCode: 404, errorCode: 'platform_refund_order_item_not_found', }) } return Number(result.rows[0].remaining_fen || 0) } async function findRecordedPlatformRefund(orderId: number, externalRef: string) { const result = await query<{ id: number }>( `SELECT id FROM order_refunds WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 AND status = 'confirmed' LIMIT 1`, [orderId, externalRef], ) if (!result.rows[0]) return null const refunds = await listOrderRefunds(orderId) return refunds.find((item) => item.id === Number(result.rows[0]?.id)) || null } function normalizeRefundSource(value: unknown): 'manual' | 'api' | 'platform' | 'legacy' { const source = String(value || 'manual').trim() if (source === 'manual' || source === 'api' || source === 'platform' || source === 'legacy') { return source } throw createHttpError('退款来源无效', { statusCode: 400, errorCode: 'order_refund_source_invalid', }) }