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}`
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
await resolveAdminCostException(item.id, { status: nextStatus })
|
||||
message.success('成本异常已处理')
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '处理成本异常失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
function openResolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
|
||||
setResolveNote('')
|
||||
setResolveTarget({ item, status: nextStatus })
|
||||
}
|
||||
|
||||
async function submitResolve() {
|
||||
if (!resolveTarget) return
|
||||
setResolving(true)
|
||||
try {
|
||||
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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` 为准。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user