diff --git a/apps/backend/src/db/migrations/112_order_refunds.sql b/apps/backend/src/db/migrations/112_order_refunds.sql new file mode 100644 index 00000000..23c1ac9f --- /dev/null +++ b/apps/backend/src/db/migrations/112_order_refunds.sql @@ -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 ''; diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index d8f99433..d52a4767 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -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)?.orderItemId as string | undefined, + amountFen: (req.body as Record)?.amountFen as string | undefined, + reason: (req.body as Record)?.reason as string | undefined, + note: (req.body as Record)?.note as string | undefined, + refundedAt: (req.body as Record)?.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) : {}, + }), + }, + ), +) + +router.post( + '/orders/:orderId/refunds/:refundId/void', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => + voidOrderRefund( + String((req.params as Record).refundId || ''), + { reason: String((req.body as Record)?.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).refundId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + export default router diff --git a/apps/backend/src/services/admin/admin-finance-service.ts b/apps/backend/src/services/admin/admin-finance-service.ts index 9e48fd86..d873bc52 100644 --- a/apps/backend/src/services/admin/admin-finance-service.ts +++ b/apps/backend/src/services/admin/admin-finance-service.ts @@ -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 diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 65d17525..e46d4171 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -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: { diff --git a/apps/backend/src/services/cost-center/cost-center-shared.ts b/apps/backend/src/services/cost-center/cost-center-shared.ts index f8efd588..50a5d0bc 100644 --- a/apps/backend/src/services/cost-center/cost-center-shared.ts +++ b/apps/backend/src/services/cost-center/cost-center-shared.ts @@ -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 || '', } } diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts index 18166eff..38b10636 100644 --- a/apps/backend/src/services/cost-center/cost-definition-service.ts +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -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( ` 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], '成本异常不存在')) } } diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts new file mode 100644 index 00000000..d34507eb --- /dev/null +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -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 { + const id = normalizeId(orderId, '订单 ID') + const result = await query( + ` + 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)}` +} diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts index de768635..15d9ec51 100644 --- a/apps/backend/src/services/finance/sales-facts-service.ts +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -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 diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 927b78c4..770e458e 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -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 item_snapshot_json?: string | Record fulfillment_mode?: string | null diff --git a/apps/backend/src/utils/random.ts b/apps/backend/src/utils/random.ts index e277f545..53bbca76 100644 --- a/apps/backend/src/utils/random.ts +++ b/apps/backend/src/utils/random.ts @@ -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}` +} diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index 65aae79a..c1ef8848 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -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(null) + const [refundModalOpen, setRefundModalOpen] = useState(false) + const [refundScope, setRefundScope] = useState<'order' | 'item'>('order') + const [refundItemId, setRefundItemId] = useState(null) + const [refundAmountYuan, setRefundAmountYuan] = useState(null) + const [refundReason, setRefundReason] = useState('买家退款') + const [refundNote, setRefundNote] = useState('') + const [refundedAt, setRefundedAt] = useState(dayjs()) + const [refunding, setRefunding] = useState(false) + const [voidTarget, setVoidTarget] = useState(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 (
@@ -206,6 +304,16 @@ export default function AdminOrderDetailPage() { title="基础信息" extra={ + {hasAdminPermission('finance.config') ? ( + + ) : null} @@ -351,6 +459,18 @@ export default function AdminOrderDetailPage() { ), }, { title: '数量', dataIndex: 'quantity', width: 90 }, + { + title: '收入 / 已退', + width: 190, + render: (_, item) => ( +
+ {formatMoney(item.revenueFen)} + + 已退 {formatMoney(item.refundedFen)} · 可退 {formatMoney(item.refundableFen)} + +
+ ), + }, { title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 }, { title: '成本', @@ -365,6 +485,99 @@ export default function AdminOrderDetailPage() { /> + {detail.refunds.length > 0 ? ( + + 退款记录 + + {formatMoney( + detail.refunds + .filter((item) => item.status === 'confirmed') + .reduce((sum, item) => sum + item.amountFen, 0), + )} + +
+ } + > + + rowKey="id" + pagination={false} + dataSource={detail.refunds} + scroll={{ x: 980 }} + columns={[ + { + title: '退款单号', + dataIndex: 'refundNo', + width: 190, + render: (value: string, row) => ( +
+ {value} + {row.batchNo} +
+ ), + }, + { + 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) => ( +
+ {value || '-'} + {row.note ? {row.note} : null} +
+ ), + }, + { + 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' ? ( + 已确认 + ) : ( + 已撤销{row.voidReason ? `:${row.voidReason}` : ''} + ), + }, + { + title: '操作', + width: 90, + render: (_, row) => + hasAdminPermission('finance.config') && row.status === 'confirmed' ? ( + + ) : null, + }, + ]} + /> + + ) : null} + rowKey="taskId" @@ -421,6 +634,123 @@ export default function AdminOrderDetailPage() { onClose={closeManualCost} /> + setRefundModalOpen(false)} + onOk={() => void submitRefund()} + confirmLoading={refunding} + okText="确认退款" + cancelText="取消" + width={560} + > + + sum + item.refundableFen, 0), + )}`} + description="整单退款会按每个商品剩余可退金额拆成多条记录;登记后当天销售事实立即重算。" + /> + { + 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) + } + }} + > + 整单退款 + 指定商品 + + {refundScope === 'item' ? ( + <> + setRefundReason(event.target.value)} + placeholder="退款原因,如:买家退款 / 售后补偿 / 发错货" + /> + { + if (value) setRefundedAt(value) + }} + style={{ width: '100%' }} + /> + setRefundNote(event.target.value)} + placeholder="备注(可选,如平台退款单号、沟通记录)" + /> + + + + { + setVoidTarget(null) + setVoidReason('') + }} + onOk={() => void submitVoidRefund()} + confirmLoading={voiding} + okText="确认撤销" + okButtonProps={{ danger: true }} + cancelText="取消" + width={520} + > + + + setVoidReason(event.target.value)} + placeholder="撤销原因(可选)" + /> + + + (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 = [ @@ -131,6 +157,11 @@ export function CostExceptionPanel() { {String(row.detail?.message || '')} + {row.status !== 'open' && row.resolutionNote ? ( + + 处理备注:{row.resolutionNote} + + ) : null} ), }, @@ -183,10 +214,10 @@ export function CostExceptionPanel() { {canConfig && row.status === 'open' ? ( <> - - @@ -308,6 +339,39 @@ export function CostExceptionPanel() { })} /> + setResolveTarget(null)} + onOk={() => void submitResolve()} + confirmLoading={resolving} + okText="确认" + cancelText="取消" + width={520} + > + + + setResolveNote(event.target.value)} + placeholder="处理备注(可选):怎么处理的、为什么忽略" + /> + + 备注会随异常一起留档;退款导致的异常由系统自动写入备注。 + + + + ) { 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, + ) +} diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 0c66ec90..d2e39b06 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -138,6 +138,7 @@ export interface CostException { createdAt: string resolvedAt: string | null resolvedByAdminUserId: number | null + resolutionNote: string } export interface CostCenterOptions { diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 926bcc79..03191a33 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -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 { diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 62577f80..07340411 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -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 }> + 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 +} diff --git a/docs/财务与履约口径总纲-2026-10-23.md b/docs/财务与履约口径总纲-2026-10-23.md index c3ff6b6a..e2afe582 100644 --- a/docs/财务与履约口径总纲-2026-10-23.md +++ b/docs/财务与履约口径总纲-2026-10-23.md @@ -152,12 +152,12 @@ | P0 | 统一履约事实(`fulfillment_mode`)+ 成本模型正交 | **已完成(本地,未部署生产)** | | P0.5 | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」(下线登记/审批/报销) | **已完成(本地,未部署生产)** | | P1 | 人工商品归一(`man:`)+ 上线后人工成本全覆盖 | **已完成(本地,未部署生产)** | -| P2 | 退款冲减与异常闭环 | 未实施 | +| P2 | 退款冲减与异常闭环 | **已完成(手动登记先行,平台退款 API 待接入)** | | P3 | 影子总账与对账 | 未实施 | P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履约模型](成本中心与履约模型.md);接口契约见 [成本中心API契约](成本中心API契约.md)。 -> 下一步只做 P2,不要同时改总账。 +> 下一步只做 P3,不要同时改总账与平台对接。 --- @@ -178,11 +178,12 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履 | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `108_drop_manual_expense_records`;下线登记单与审批报销 | | 人工商品归一 `man:` | **已实施(本地库已归一)** | `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` 为准。 --- diff --git a/docs/财务系统集成方案.md b/docs/财务系统集成方案.md index 2bc41fd2..43acb539 100644 --- a/docs/财务系统集成方案.md +++ b/docs/财务系统集成方案.md @@ -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`: