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
@@ -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 '';
+73 -1
View File
@@ -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,7 +238,12 @@ export async function getAdminOrderDetail(
workOrderCount: workOrders.length,
canClaimH5: fulfillment.canClaimH5,
},
items: items.map((item) => ({
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,
@@ -244,13 +251,18 @@ export async function getAdminOrderDetail(
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
+14
View File
@@ -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}`
}
@@ -4,6 +4,7 @@ import {
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
RollbackOutlined,
StopOutlined,
} from '@ant-design/icons'
import {
@@ -11,9 +12,14 @@ import {
App,
Button,
Card,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Skeleton,
Space,
Tag,
@@ -22,19 +28,29 @@ import {
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Dayjs } from 'dayjs'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import dayjs from '@/lib/dayjs'
import JsonPreview from '@/components/admin/JsonPreview'
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
import {
createAdminOrderRefund,
fetchAdminOrderDetail,
updateAdminOrderClaimStatus,
voidAdminOrderRefund,
} from '@/services/admin'
import type { AdminOrderDetail, AdminOrderRefund, AdminTaskListItem } from '@/types/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display'
import { CostPreviewModal } from './cost-center/CostPreviewModal'
import { formatMoney } from './panels/shared'
export default function AdminOrderDetailPage() {
const navigate = useNavigate()
@@ -45,6 +61,17 @@ export default function AdminOrderDetailPage() {
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [manualCostOpen, setManualCostOpen] = useState(false)
const [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
const [refundModalOpen, setRefundModalOpen] = useState(false)
const [refundScope, setRefundScope] = useState<'order' | 'item'>('order')
const [refundItemId, setRefundItemId] = useState<number | null>(null)
const [refundAmountYuan, setRefundAmountYuan] = useState<number | null>(null)
const [refundReason, setRefundReason] = useState('买家退款')
const [refundNote, setRefundNote] = useState('')
const [refundedAt, setRefundedAt] = useState<Dayjs>(dayjs())
const [refunding, setRefunding] = useState(false)
const [voidTarget, setVoidTarget] = useState<AdminOrderRefund | null>(null)
const [voidReason, setVoidReason] = useState('')
const [voiding, setVoiding] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
@@ -112,6 +139,77 @@ export default function AdminOrderDetailPage() {
setStatusModalVisible(true)
}
function openRefundModal(itemId?: number) {
const items = detail?.items || []
const target = itemId ? items.find((item) => item.orderItemId === itemId) : null
const fallback = items.find((item) => item.refundableFen > 0) || null
const focus = target || fallback
setRefundScope(target ? 'item' : 'order')
setRefundItemId(focus?.orderItemId ?? null)
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
setRefundReason('买家退款')
setRefundNote('')
setRefundedAt(dayjs())
setRefundModalOpen(true)
}
async function submitRefund() {
if (!detail) return
if (refundScope === 'item' && !refundItemId) {
message.warning('请选择要退款的商品')
return
}
const amountFen = Math.round(Number(refundAmountYuan || 0) * 100)
if (refundScope === 'item' && amountFen <= 0) {
message.warning('请输入退款金额')
return
}
setRefunding(true)
try {
const response = await createAdminOrderRefund(detail.order.orderId, {
orderItemId: refundScope === 'item' ? refundItemId || undefined : undefined,
amountFen: refundScope === 'item' ? amountFen : undefined,
reason: refundReason.trim() || '手动退款',
note: refundNote.trim(),
refundedAt: refundedAt.format('YYYY-MM-DD HH:mm:ss'),
})
const result = response.data
message.success(
`已登记 ${result.refunds.length} 条退款${result.fullRefunded ? '(订单已全额退款)' : ''}${
result.closedExceptionCount > 0
? `,自动关闭 ${result.closedExceptionCount} 条成本异常`
: ''
}`,
)
setRefundModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '登记退款失败')
} finally {
setRefunding(false)
}
}
async function submitVoidRefund() {
if (!detail || !voidTarget) return
setVoiding(true)
try {
await voidAdminOrderRefund(detail.order.orderId, voidTarget.id, {
reason: voidReason.trim() || '撤销退款',
})
message.success('退款已撤销')
setVoidTarget(null)
setVoidReason('')
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '撤销退款失败')
} finally {
setVoiding(false)
}
}
if (query.isLoading) {
return (
<section className="page-stack">
@@ -206,6 +304,16 @@ export default function AdminOrderDetailPage() {
title="基础信息"
extra={
<Space>
{hasAdminPermission('finance.config') ? (
<Button
danger
icon={<RollbackOutlined />}
disabled={!detail.items.some((item) => item.refundableFen > 0)}
onClick={() => openRefundModal()}
>
退款登记
</Button>
) : null}
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
人工录单
</Button>
@@ -351,6 +459,18 @@ export default function AdminOrderDetailPage() {
),
},
{ title: '数量', dataIndex: 'quantity', width: 90 },
{
title: '收入 / 已退',
width: 190,
render: (_, item) => (
<div className="cell-stack" style={{ gap: 4 }}>
<span>{formatMoney(item.revenueFen)}</span>
<span className="muted">
已退 {formatMoney(item.refundedFen)} · 可退 {formatMoney(item.refundableFen)}
</span>
</div>
),
},
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
{
title: '成本',
@@ -365,6 +485,99 @@ export default function AdminOrderDetailPage() {
/>
</Card>
{detail.refunds.length > 0 ? (
<Card
title={
<Space size={6}>
<Typography.Text strong>退款记录</Typography.Text>
<Tag color="red">
{formatMoney(
detail.refunds
.filter((item) => item.status === 'confirmed')
.reduce((sum, item) => sum + item.amountFen, 0),
)}
</Tag>
</Space>
}
>
<PercentTable<AdminOrderRefund>
rowKey="id"
pagination={false}
dataSource={detail.refunds}
scroll={{ x: 980 }}
columns={[
{
title: '退款单号',
dataIndex: 'refundNo',
width: 190,
render: (value: string, row) => (
<div className="cell-stack" style={{ gap: 2 }}>
<span>{value}</span>
<span className="muted">{row.batchNo}</span>
</div>
),
},
{
title: '商品',
dataIndex: 'skuName',
width: 240,
render: (value: string) => value || '-',
},
{
title: '金额',
dataIndex: 'amountFen',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '原因',
dataIndex: 'reason',
width: 220,
render: (value: string, row) => (
<div className="cell-stack" style={{ gap: 2 }}>
<span>{value || '-'}</span>
{row.note ? <span className="muted">{row.note}</span> : null}
</div>
),
},
{
title: '退款时间',
dataIndex: 'refundedAt',
width: 180,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作人',
dataIndex: 'operatorName',
width: 120,
render: (value: string) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (value: string, row) =>
value === 'confirmed' ? (
<Tag color="green">已确认</Tag>
) : (
<Tag color="default">已撤销{row.voidReason ? `:${row.voidReason}` : ''}</Tag>
),
},
{
title: '操作',
width: 90,
render: (_, row) =>
hasAdminPermission('finance.config') && row.status === 'confirmed' ? (
<Button size="small" danger onClick={() => setVoidTarget(row)}>
撤销
</Button>
) : null,
},
]}
/>
</Card>
) : null}
<Card title="任务">
<PercentTable<AdminTaskListItem>
rowKey="taskId"
@@ -421,6 +634,123 @@ export default function AdminOrderDetailPage() {
onClose={closeManualCost}
/>
<Modal
title="退款登记"
open={refundModalOpen}
onCancel={() => setRefundModalOpen(false)}
onOk={() => void submitRefund()}
confirmLoading={refunding}
okText="确认退款"
cancelText="取消"
width={560}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={`当前可退合计 ${formatMoney(
detail.items.reduce((sum, item) => sum + item.refundableFen, 0),
)}`}
description="整单退款会按每个商品剩余可退金额拆成多条记录;登记后当天销售事实立即重算。"
/>
<Radio.Group
value={refundScope}
onChange={(event) => {
const next = event.target.value as 'order' | 'item'
setRefundScope(next)
if (next === 'item') {
const focus = detail.items.find((item) => item.refundableFen > 0) || null
setRefundItemId(focus?.orderItemId ?? null)
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
}
}}
>
<Radio.Button value="order">整单退款</Radio.Button>
<Radio.Button value="item">指定商品</Radio.Button>
</Radio.Group>
{refundScope === 'item' ? (
<>
<Select
value={refundItemId ?? undefined}
onChange={(value) => {
setRefundItemId(value)
const item = detail.items.find((row) => row.orderItemId === value)
setRefundAmountYuan(item ? item.refundableFen / 100 : 0)
}}
options={detail.items
.filter((item) => item.refundableFen > 0)
.map((item) => ({
value: item.orderItemId,
label: `${item.itemTitle || item.skuName} · 可退 ${formatMoney(item.refundableFen)}`,
}))}
style={{ width: '100%' }}
/>
<InputNumber
addonBefore="¥"
min={0.01}
precision={2}
value={refundAmountYuan}
onChange={(value) => setRefundAmountYuan(value ?? 0)}
style={{ width: '100%' }}
/>
</>
) : (
<Typography.Text type="secondary">
将按 {detail.items.filter((item) => item.refundableFen > 0).length}{' '}
个有可退金额的商品逐条登记。
</Typography.Text>
)}
<Input
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
placeholder="退款原因,如:买家退款 / 售后补偿 / 发错货"
/>
<DatePicker
showTime
value={refundedAt}
onChange={(value) => {
if (value) setRefundedAt(value)
}}
style={{ width: '100%' }}
/>
<Input.TextArea
rows={2}
value={refundNote}
onChange={(event) => setRefundNote(event.target.value)}
placeholder="备注(可选,如平台退款单号、沟通记录)"
/>
</Space>
</Modal>
<Modal
title="撤销退款"
open={voidTarget !== null}
onCancel={() => {
setVoidTarget(null)
setVoidReason('')
}}
onOk={() => void submitVoidRefund()}
confirmLoading={voiding}
okText="确认撤销"
okButtonProps={{ danger: true }}
cancelText="取消"
width={520}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={`撤销 ${voidTarget?.refundNo || ''}(${formatMoney(voidTarget?.amountFen || 0)})`}
description="撤销后销售事实会按剩余有效退款重算;已置为「已退款」的订单支付状态不会自动回退。"
/>
<Input
value={voidReason}
onChange={(event) => setVoidReason(event.target.value)}
placeholder="撤销原因(可选)"
/>
</Space>
</Modal>
<CostPreviewModal
open={costPreviewItemId !== null}
orderItemId={costPreviewItemId}
@@ -5,7 +5,21 @@
* 页面先按原因聚合,默认只看近 24 小时,历史存量需要显式切换时间范围。
* 每行可直接打开「成本解析」定位是哪个 SKU 没归一、哪条成本没命中。
*/
import { App, Button, Card, Col, Row, Select, Space, Statistic, Tag, Typography } from 'antd'
import {
Alert,
App,
Button,
Card,
Col,
Input,
Modal,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -50,7 +64,7 @@ const RANGE_OPTIONS = [
]
export function CostExceptionPanel() {
const { message, modal } = App.useApp()
const { message } = App.useApp()
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
const [status, setStatus] = useState('open')
@@ -59,6 +73,12 @@ export function CostExceptionPanel() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [previewItemId, setPreviewItemId] = useState<number | null>(null)
const [resolveTarget, setResolveTarget] = useState<{
item: CostException
status: 'resolved' | 'ignored'
} | null>(null)
const [resolveNote, setResolveNote] = useState('')
const [resolving, setResolving] = useState(false)
// 时间下界在取数时计算,避免渲染期调用 Date.now(React 纯度规则)。
const summaryQuery = useQuery({
@@ -102,22 +122,28 @@ export function CostExceptionPanel() {
const exceptions = exceptionsQuery.data?.data.items || []
const total = exceptionsQuery.data?.data.pagination.total || 0
function resolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
modal.confirm({
title: nextStatus === 'resolved' ? '标记异常已处理' : '忽略异常',
content: `确认处理「${REASON_LABELS[item.reason] || item.reason}」?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
function openResolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
setResolveNote('')
setResolveTarget({ item, status: nextStatus })
}
async function submitResolve() {
if (!resolveTarget) return
setResolving(true)
try {
await resolveAdminCostException(item.id, { status: nextStatus })
await resolveAdminCostException(resolveTarget.item.id, {
status: resolveTarget.status,
note: resolveNote.trim(),
})
message.success('成本异常已处理')
setResolveTarget(null)
setResolveNote('')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理成本异常失败')
} finally {
setResolving(false)
}
},
})
}
const columns: TableColumnsType<CostException> = [
@@ -131,6 +157,11 @@ export function CostExceptionPanel() {
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{String(row.detail?.message || '')}
</Typography.Text>
{row.status !== 'open' && row.resolutionNote ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
处理备注:{row.resolutionNote}
</Typography.Text>
) : null}
</Space>
),
},
@@ -183,10 +214,10 @@ export function CostExceptionPanel() {
</Button>
{canConfig && row.status === 'open' ? (
<>
<Button size="small" type="primary" onClick={() => resolve(row, 'resolved')}>
<Button size="small" type="primary" onClick={() => openResolve(row, 'resolved')}>
已处理
</Button>
<Button size="small" onClick={() => resolve(row, 'ignored')}>
<Button size="small" onClick={() => openResolve(row, 'ignored')}>
忽略
</Button>
</>
@@ -308,6 +339,39 @@ export function CostExceptionPanel() {
})}
/>
<Modal
title={resolveTarget?.status === 'ignored' ? '忽略成本异常' : '标记异常已处理'}
open={resolveTarget !== null}
onCancel={() => setResolveTarget(null)}
onOk={() => void submitResolve()}
confirmLoading={resolving}
okText="确认"
cancelText="取消"
width={520}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={
resolveTarget
? REASON_LABELS[resolveTarget.item.reason] || resolveTarget.item.reason
: ''
}
description={String(resolveTarget?.item.detail?.message || '')}
/>
<Input.TextArea
rows={3}
value={resolveNote}
onChange={(event) => setResolveNote(event.target.value)}
placeholder="处理备注(可选):怎么处理的、为什么忽略"
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
备注会随异常一起留档;退款导致的异常由系统自动写入备注。
</Typography.Text>
</Space>
</Modal>
<CostPreviewModal
open={previewItemId !== null}
orderItemId={previewItemId}
+35 -1
View File
@@ -1,5 +1,10 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
import type {
AdminOrderDetail,
AdminOrderListItem,
AdminOrderRefund,
AdminPagination,
} from '@/types/admin'
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>(
@@ -24,3 +29,32 @@ export function updateAdminOrderClaimStatus(
claimStatusUpdatedAt: string
}>(`/api/v1/admin/orders/${orderId}/claim-status`, payload)
}
export function createAdminOrderRefund(
orderId: number | string,
payload: {
orderItemId?: number
amountFen?: number
reason?: string
note?: string
refundedAt?: string
},
) {
return apiPost<{
batchNo: string
refunds: AdminOrderRefund[]
fullRefunded: boolean
closedExceptionCount: number
}>(`/api/v1/admin/orders/${orderId}/refunds`, payload)
}
export function voidAdminOrderRefund(
orderId: number | string,
refundId: number,
payload: { reason?: string },
) {
return apiPost<{ refund: AdminOrderRefund; rebuiltDate: string }>(
`/api/v1/admin/orders/${orderId}/refunds/${refundId}/void`,
payload,
)
}
@@ -138,6 +138,7 @@ export interface CostException {
createdAt: string
resolvedAt: string | null
resolvedByAdminUserId: number | null
resolutionNote: string
}
export interface CostCenterOptions {
+6 -1
View File
@@ -58,7 +58,12 @@ export type {
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
// Orders types
export type { AdminOrderFulfillmentProgress, AdminOrderListItem, AdminOrderDetail } from './orders'
export type {
AdminOrderFulfillmentProgress,
AdminOrderListItem,
AdminOrderDetail,
AdminOrderRefund,
} from './orders'
// Tasks types
export type {
+27
View File
@@ -112,9 +112,36 @@ export interface AdminOrderDetail {
skuNick?: string
matchSku?: string
quantity: number
/** 订单项收入(分) */
revenueFen: number
/** 已确认退款(分) */
refundedFen: number
/** 还可退金额(分) */
refundableFen: number
fulfillmentMode: AdminOrderItemFulfillmentMode
deliveryMode: string
spec: Record<string, unknown>
}>
refunds: AdminOrderRefund[]
tasks: AdminTaskListItem[]
}
export interface AdminOrderRefund {
id: number
refundNo: string
batchNo: string
orderId: number
orderItemId: number
skuName: string
amountFen: number
reason: string
note: string
source: string
status: 'confirmed' | 'voided'
refundedAt: string
operatorAdminUserId: number | null
operatorName: string
voidedAt: string | null
voidReason: string
createdAt: string
}
@@ -152,12 +152,12 @@
| P0 | 统一履约事实(`fulfillment_mode`)+ 成本模型正交 | **已完成(本地,未部署生产)** |
| P0.5 | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」(下线登记/审批/报销) | **已完成(本地,未部署生产)** |
| P1 | 人工商品归一(`man:<skuId>`)+ 上线后人工成本全覆盖 | **已完成(本地,未部署生产)** |
| P2 | 退款冲减与异常闭环 | 未实施 |
| P2 | 退款冲减与异常闭环 | **已完成(手动登记先行,平台退款 API 待接入)** |
| P3 | 影子总账与对账 | 未实施 |
P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履约模型](成本中心与履约模型.md);接口契约见 [成本中心API契约](成本中心API契约.md)。
> 下一步只做 P2,不要同时改总账。
> 下一步只做 P3,不要同时改总账与平台对接。
---
@@ -178,11 +178,12 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
| 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `108_drop_manual_expense_records`;下线登记单与审批报销 |
| 人工商品归一 `man:<skuId>` | **已实施(本地库已归一)** | `manual-product-normalization-service` + `POST /cost-center/manual-products/normalize`;1,116 个 `man:` 商品、91,606 个人工订单项归一,人工未归一 = 0 |
| 人工商品归一后的销售事实 | **已实施** | 94 天分 30 天窗口重算:26,507 行;人工 ¥1,219.20 万收入全部按 `man:` 商品归集 |
| 退款冲减、异常闭环 | **未实施** | §六 P2 |
| 退款冲减、异常闭环 | **已实施(手动登记先行)** | 迁移 `112_order_refunds`;订单页「退款登记」(整单/指定商品、可撤销);销售事实按有效退款冲减;全额退完后自动关闭该订单项成本异常并写 `resolution_note` |
| 平台退款 API 接入 | **未实施** | 预留 `order_refunds.source='api'/'platform'` 与 `external_ref` |
| 影子总账、对账、关账 | **未实施** | §六 P3 |
> 历史平台仍有 53,901 个订单项(¥389.12 万)未归一,不属于 P1 范围;人工历史成本按既定口径不补录,因此人工毛利当前是“收入已归商品、成本仅新单有”的状态。
> 分支事实:以上实现已快进合并到 `dev`;迁移 `103–111` **未推送远端、未部署生产**。生产口径仍以 `main` 为准。
> 分支事实:以上实现已快进合并到 `dev`;迁移 `103–112` **未推送远端、未部署生产**。生产口径仍以 `main` 为准。
---
+4
View File
@@ -399,9 +399,13 @@ POST /api/v1/admin/finance/sales-facts/rebuild
POST /api/v1/admin/cost-center/snapshots/sync
GET /api/v1/admin/finance/manual-order-cost-items
POST /api/v1/admin/finance/manual-order-costs
GET /api/v1/admin/orders/:orderId/refunds
POST /api/v1/admin/orders/:orderId/refunds
POST /api/v1/admin/orders/:orderId/refunds/:refundId/void
```
> 2026-10-23 下线了 `/finance/manual-expenses*` 全套接口(登记/审批/报销),改为上表的人工录单接口。
> 2026-10-24 上线手动退款登记(`order_refunds`):整单退款按订单项拆条、共享 `batch_no`,销售事实按有效退款冲减;后续平台退款 API 只需写 `source='api'/'platform'` + `external_ref`,其余聚合与冲减逻辑复用。
总账层建议新增前缀 `/api/v1/admin/finance`: