940 lines
31 KiB
TypeScript
940 lines
31 KiB
TypeScript
/**
|
||
* 订单退款(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<OrderRefund[]> {
|
||
const id = normalizeId(orderId, '订单 ID')
|
||
const result = await query<OrderRefundRow>(
|
||
`
|
||
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<PoolClient, 'query'>,
|
||
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<PoolClient, 'query'>,
|
||
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<void> {
|
||
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<PlatformRefundIntentRow> {
|
||
return withTransaction(async (client) => {
|
||
const existing = await client.query<PlatformRefundIntentRow>(
|
||
`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<PlatformRefundIntentRow>(
|
||
`
|
||
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<PlatformRefundIntentRow>(
|
||
`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<number> {
|
||
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',
|
||
})
|
||
}
|