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
This commit is contained in:
yml2213
2026-10-24 17:18:51 +08:00
parent a78cc1f984
commit faf56e567e
18 changed files with 1127 additions and 48 deletions
@@ -32,6 +32,23 @@ const FINANCE_EVENTS_CTE = `
UNION ALL
SELECT
'order_refund'::text,
r.id::bigint,
r.refunded_at,
r.amount_fen,
'订单退款'::text,
o.platform_order_id::text,
COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text,
r.created_at,
NULL::timestamptz
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE r.status = 'confirmed'
UNION ALL
-- 历史/平台标记的全额退款:没有退款明细时按订单全额冲减(兼容旧数据)。
SELECT
'order_refund'::text,
o.id::bigint,
@@ -45,6 +62,10 @@ const FINANCE_EVENTS_CTE = `
FROM orders o
WHERE o.pay_status = 'refunded'
AND o.total_amount > 0
AND NOT EXISTS (
SELECT 1 FROM order_refunds r
WHERE r.order_id = o.id AND r.status = 'confirmed'
)
UNION ALL
@@ -6,6 +6,7 @@ import {
listOrderItemsByOrderIds,
} from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { listOrderRefunds } from '../finance/order-refund-service.js'
import {
getTaskById,
listTasks,
@@ -181,10 +182,11 @@ export async function getAdminOrderDetail(
})
}
const [items, tasks, workOrders] = await Promise.all([
const [items, tasks, workOrders, refunds] = await Promise.all([
listOrderItemsByOrderId(order.id),
listTasksByOrderId(order.id),
listWorkOrdersByOrderId(order.id),
listOrderRefunds(order.id),
])
const kuaishouIndustryVouchers = await listKuaishouIndustryVouchersByOid(order.platform_order_id)
const itemSummary = summarizeOrderItems(items, order, kuaishouIndustryVouchers)
@@ -236,21 +238,31 @@ export async function getAdminOrderDetail(
workOrderCount: workOrders.length,
canClaimH5: fulfillment.canClaimH5,
},
items: items.map((item) => ({
orderItemId: Number(item.id),
skuCode: item.sku_code,
skuName: item.sku_name,
itemTitle: resolveOrderItemTitle(item, order, kuaishouIndustryVouchers),
skuNick: resolveOrderItemSkuNick(item, order, kuaishouIndustryVouchers),
matchSku: resolveOrderItemMatchSku(item, order, kuaishouIndustryVouchers),
quantity: item.quantity,
fulfillmentMode: normalizeOrderItemFulfillmentMode(item.fulfillment_mode),
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
spec:
item.spec_json && typeof item.spec_json === 'object'
? item.spec_json
: JSON.parse(String(item.spec_json || '{}')),
})),
items: items.map((item) => {
const revenueFen = Math.max(0, Number(item.revenue_amount_fen || 0))
const refundedFen = refunds
.filter((refund) => refund.status === 'confirmed' && refund.orderItemId === Number(item.id))
.reduce((sum, refund) => sum + refund.amountFen, 0)
return {
orderItemId: Number(item.id),
skuCode: item.sku_code,
skuName: item.sku_name,
itemTitle: resolveOrderItemTitle(item, order, kuaishouIndustryVouchers),
skuNick: resolveOrderItemSkuNick(item, order, kuaishouIndustryVouchers),
matchSku: resolveOrderItemMatchSku(item, order, kuaishouIndustryVouchers),
quantity: item.quantity,
revenueFen,
refundedFen,
refundableFen: Math.max(0, revenueFen - refundedFen),
fulfillmentMode: normalizeOrderItemFulfillmentMode(item.fulfillment_mode),
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
spec:
item.spec_json && typeof item.spec_json === 'object'
? item.spec_json
: JSON.parse(String(item.spec_json || '{}')),
}
}),
refunds,
tasks: tasks.map((task) => mapAdminTaskSummary(task)),
kuaishouIndustryVouchers: kuaishouIndustryVouchers.map(mapAdminKuaishouIndustryVoucher),
operations: {
@@ -273,6 +273,7 @@ export type CostExceptionRow = {
created_at: string
resolved_at: string | null
resolved_by_admin_user_id: number | null
resolution_note: string
}
export type OrderItemCostContext = {
@@ -557,6 +558,7 @@ export function mapCostException(row: CostExceptionRow) {
resolvedByAdminUserId: row.resolved_by_admin_user_id
? Number(row.resolved_by_admin_user_id)
: null,
resolutionNote: row.resolution_note || '',
}
}
@@ -724,14 +724,18 @@ export async function resolveAdminCostException(
})
}
const now = nowIso()
const note = text(payload.note ?? payload.resolutionNote ?? payload.resolution_note).slice(0, 300)
const result = await query<CostExceptionRow>(
`
UPDATE cost_exceptions
SET status = $1, resolved_at = $2, resolved_by_admin_user_id = $3
SET status = $1,
resolved_at = $2,
resolved_by_admin_user_id = $3,
resolution_note = $5
WHERE id = $4
RETURNING *
`,
[status, now, session.userId, id],
[status, now, session.userId, id, note],
)
return { exception: mapCostException(requiredRow(result.rows[0], '成本异常不存在')) }
}
@@ -0,0 +1,436 @@
/**
* 订单退款(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)}`
}
@@ -68,7 +68,13 @@ export async function rebuildSalesDailyFacts(
oi.order_id,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
CASE
WHEN o.pay_status = 'refunded' THEN COALESCE(oi.revenue_amount_fen, 0)::bigint
WHEN COALESCE(refunds.refund_count, 0) > 0
THEN LEAST(
COALESCE(refunds.refund_fen, 0),
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)
)::bigint
WHEN o.pay_status = 'refunded'
THEN GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint
ELSE 0::bigint
END AS refund_fen,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
@@ -86,6 +92,14 @@ export async function rebuildSalesDailyFacts(
WHERE cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
) cost ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(r.amount_fen)::bigint AS refund_fen,
COUNT(*)::int AS refund_count
FROM order_refunds r
WHERE r.order_item_id = oi.id
AND r.status = 'confirmed'
) refunds ON TRUE
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date