Files
order_site/apps/backend/src/services/finance/order-refund-service.ts
T
yml2213 faf56e567e feat(finance): P2 手动退款登记与成本异常闭环
- 迁移 112:order_refunds(订单项级退款,整单拆多条共享 batch_no,source/external_ref 预留平台 API)+ cost_exceptions.resolution_note
- 新服务 order-refund-service:整单/指定商品退款、可退金额校验、撤销、全额退完同步 pay_status
- 销售事实改为按有效退款记录冲减;无记录时仍兼容 pay_status=refunded 的历史全额退款
- 订单详情新增退款登记与退款记录表,商品明细显示收入/已退/可退
- 全额退款自动关闭该订单项成本异常并写处理备注;成本异常处理改为带备注弹窗
- 财务事件 UNION 接入退款记录,文档同步 P2 状态与 API
2026-10-24 17:18:51 +08:00

437 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 订单退款(P2 手动先行):
* 1. 手动登记退款 → order_refunds(一条记录 = 一个订单项);
* 2. 整单退款按订单项拆成多条、共享 batch_no;
* 3. 全额退完后把订单 pay_status 置为 refunded,并关闭该订单项的成本异常;
* 4. 重算当天销售事实,净收入与毛利立即冲减。
*
* 后续接平台退款 API 时,只需写 source='api'/'platform' 的记录,其余逻辑复用。
*/
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 { rebuildSalesDailyFacts } from './sales-facts-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
}
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 order_items 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 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 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 order_items 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',
})
}
const batchNo = generateRefundNo(new Date(refundedAt))
const insertedIds: number[] = []
for (const [index, row] of rows.entries()) {
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, operator_admin_user_id,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, 'manual', 'confirmed', $8::timestamptz, $9,
$10::timestamptz, $10::timestamptz
)
RETURNING id
`,
[
`${batchNo}-${String(index + 1).padStart(2, '0')}`,
batchNo,
orderId,
row.itemId,
row.amountFen,
reason,
note,
refundedAt,
session?.userId || null,
now,
],
)
insertedIds.push(Number(inserted.rows[0]?.id || 0))
}
// 订单项都退完 → 订单级 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 order_items 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 basisDate = toChinaDate(written.basisAt)
if (basisDate) {
await rebuildSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
}
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
basis_at: string
}>(
`
SELECT r.id, r.order_id, r.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',
})
}
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],
)
return { orderId: Number(row.order_id), basisAt: row.basis_at }
})
const rebuiltDate = toChinaDate(context.basisAt)
if (rebuiltDate) {
await rebuildSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: 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,
}
}
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()
}
function toChinaDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
function formatFen(value: number): string {
return `¥${(Number(value || 0) / 100).toFixed(2)}`
}