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:
@@ -0,0 +1,36 @@
|
||||
-- P2 退款冲减(手动录入先行,后续接平台退款 API)。
|
||||
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_refunds (
|
||||
id bigserial PRIMARY KEY,
|
||||
refund_no text NOT NULL UNIQUE,
|
||||
batch_no text NOT NULL DEFAULT '',
|
||||
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
|
||||
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
|
||||
reason text NOT NULL DEFAULT '',
|
||||
note text NOT NULL DEFAULT '',
|
||||
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
|
||||
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
|
||||
refunded_at timestamp with time zone NOT NULL,
|
||||
external_ref text NOT NULL DEFAULT '',
|
||||
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
voided_at timestamp with time zone,
|
||||
void_reason text NOT NULL DEFAULT '',
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
|
||||
ON order_refunds (order_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
|
||||
ON order_refunds (order_item_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
|
||||
ON order_refunds (refunded_at DESC);
|
||||
|
||||
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
|
||||
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
|
||||
|
||||
-- 异常闭环备注:手动处理或退款自动关闭时记录原因。
|
||||
ALTER TABLE cost_exceptions
|
||||
ADD COLUMN IF NOT EXISTS resolution_note text NOT NULL DEFAULT '';
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
updateAdminOrderClaimStatus,
|
||||
type UpdateOrderClaimStatusPayload,
|
||||
} from '../../services/admin/write/order-actions.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import {
|
||||
createManualRefund,
|
||||
listOrderRefunds,
|
||||
voidOrderRefund,
|
||||
} from '../../services/finance/order-refund-service.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
|
||||
import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/admin/route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -85,4 +90,71 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/orders/:orderId/refunds',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(
|
||||
(req) => listOrderRefunds(String((req.params as AdminOrderRouteParams).orderId || '')),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取退款记录失败',
|
||||
scope: '[admin/orders/:orderId/refunds]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/refunds',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
createManualRefund(
|
||||
{
|
||||
orderId: String((req.params as AdminOrderRouteParams).orderId || ''),
|
||||
orderItemId: (req.body as Record<string, unknown>)?.orderItemId as string | undefined,
|
||||
amountFen: (req.body as Record<string, unknown>)?.amountFen as string | undefined,
|
||||
reason: (req.body as Record<string, unknown>)?.reason as string | undefined,
|
||||
note: (req.body as Record<string, unknown>)?.note as string | undefined,
|
||||
refundedAt: (req.body as Record<string, unknown>)?.refundedAt as string | undefined,
|
||||
},
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '退款已登记',
|
||||
errorMessage: '登记退款失败',
|
||||
scope: '[admin/orders/:orderId/refunds]',
|
||||
audit: (req, data) => ({
|
||||
action: 'order_refund_created',
|
||||
targetType: 'order',
|
||||
targetId: String((req.params as AdminOrderRouteParams).orderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/refunds/:refundId/void',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
voidOrderRefund(
|
||||
String((req.params as Record<string, string>).refundId || ''),
|
||||
{ reason: String((req.body as Record<string, unknown>)?.reason || '') },
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '退款已撤销',
|
||||
errorMessage: '撤销退款失败',
|
||||
scope: '[admin/orders/:orderId/refunds/:refundId/void]',
|
||||
audit: (req, data) => ({
|
||||
action: 'order_refund_voided',
|
||||
targetType: 'order_refund',
|
||||
targetId: String((req.params as Record<string, string>).refundId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,8 @@ export type OrderItemRow = {
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
quantity: number
|
||||
revenue_amount_fen?: number | string | null
|
||||
canonical_product_id?: number | string | null
|
||||
spec_json: string | Record<string, unknown>
|
||||
item_snapshot_json?: string | Record<string, unknown>
|
||||
fulfillment_mode?: string | null
|
||||
|
||||
@@ -69,3 +69,17 @@ export function generateConsignmentSubmissionNo(date: Date = new Date()): string
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
return `SUB${yy}${mm}${dd}${hh}${min}${rand}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成退款单号,形如 `RF26102415301234`。
|
||||
* 整单退款按订单项拆成多条记录,共享同一个 batch_no(取首条单号)。
|
||||
*/
|
||||
export function generateRefundNo(date: Date = new Date()): string {
|
||||
const yy = String(date.getFullYear()).slice(-2)
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
return `RF${yy}${mm}${dd}${hh}${min}${rand}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user