feat(finance): P2 手动退款登记与成本异常闭环

- 迁移 112:order_refunds(订单项级退款,整单拆多条共享 batch_no,source/external_ref 预留平台 API)+ cost_exceptions.resolution_note
- 新服务 order-refund-service:整单/指定商品退款、可退金额校验、撤销、全额退完同步 pay_status
- 销售事实改为按有效退款记录冲减;无记录时仍兼容 pay_status=refunded 的历史全额退款
- 订单详情新增退款登记与退款记录表,商品明细显示收入/已退/可退
- 全额退款自动关闭该订单项成本异常并写处理备注;成本异常处理改为带备注弹窗
- 财务事件 UNION 接入退款记录,文档同步 P2 状态与 API
This commit is contained in:
yml2213
2026-10-24 17:18:51 +08:00
parent a78cc1f984
commit faf56e567e
18 changed files with 1127 additions and 48 deletions
@@ -0,0 +1,36 @@
-- P2 退款冲减(手动录入先行,后续接平台退款 API)。
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
CREATE TABLE IF NOT EXISTS order_refunds (
id bigserial PRIMARY KEY,
refund_no text NOT NULL UNIQUE,
batch_no text NOT NULL DEFAULT '',
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
reason text NOT NULL DEFAULT '',
note text NOT NULL DEFAULT '',
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
refunded_at timestamp with time zone NOT NULL,
external_ref text NOT NULL DEFAULT '',
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
voided_at timestamp with time zone,
void_reason text NOT NULL DEFAULT '',
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
ON order_refunds (order_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
ON order_refunds (order_item_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
ON order_refunds (refunded_at DESC);
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
-- 异常闭环备注:手动处理或退款自动关闭时记录原因。
ALTER TABLE cost_exceptions
ADD COLUMN IF NOT EXISTS resolution_note text NOT NULL DEFAULT '';
+73 -1
View File
@@ -6,7 +6,12 @@ import {
updateAdminOrderClaimStatus,
type UpdateOrderClaimStatusPayload,
} from '../../services/admin/write/order-actions.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
import {
createManualRefund,
listOrderRefunds,
voidOrderRefund,
} from '../../services/finance/order-refund-service.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/admin/route-inputs.js'
const router = Router()
@@ -85,4 +90,71 @@ router.post(
),
)
router.get(
'/orders/:orderId/refunds',
requireAdminPermission('finance.view'),
createJsonHandler(
(req) => listOrderRefunds(String((req.params as AdminOrderRouteParams).orderId || '')),
{
successMessage: 'ok',
errorMessage: '读取退款记录失败',
scope: '[admin/orders/:orderId/refunds]',
},
),
)
router.post(
'/orders/:orderId/refunds',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) =>
createManualRefund(
{
orderId: String((req.params as AdminOrderRouteParams).orderId || ''),
orderItemId: (req.body as Record<string, unknown>)?.orderItemId as string | undefined,
amountFen: (req.body as Record<string, unknown>)?.amountFen as string | undefined,
reason: (req.body as Record<string, unknown>)?.reason as string | undefined,
note: (req.body as Record<string, unknown>)?.note as string | undefined,
refundedAt: (req.body as Record<string, unknown>)?.refundedAt as string | undefined,
},
req.adminSession || null,
),
{
successMessage: '退款已登记',
errorMessage: '登记退款失败',
scope: '[admin/orders/:orderId/refunds]',
audit: (req, data) => ({
action: 'order_refund_created',
targetType: 'order',
targetId: String((req.params as AdminOrderRouteParams).orderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/orders/:orderId/refunds/:refundId/void',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) =>
voidOrderRefund(
String((req.params as Record<string, string>).refundId || ''),
{ reason: String((req.body as Record<string, unknown>)?.reason || '') },
req.adminSession || null,
),
{
successMessage: '退款已撤销',
errorMessage: '撤销退款失败',
scope: '[admin/orders/:orderId/refunds/:refundId/void]',
audit: (req, data) => ({
action: 'order_refund_voided',
targetType: 'order_refund',
targetId: String((req.params as Record<string, string>).refundId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
export default router
@@ -32,6 +32,23 @@ const FINANCE_EVENTS_CTE = `
UNION ALL
SELECT
'order_refund'::text,
r.id::bigint,
r.refunded_at,
r.amount_fen,
'订单退款'::text,
o.platform_order_id::text,
COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text,
r.created_at,
NULL::timestamptz
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE r.status = 'confirmed'
UNION ALL
-- 历史/平台标记的全额退款:没有退款明细时按订单全额冲减(兼容旧数据)。
SELECT
'order_refund'::text,
o.id::bigint,
@@ -45,6 +62,10 @@ const FINANCE_EVENTS_CTE = `
FROM orders o
WHERE o.pay_status = 'refunded'
AND o.total_amount > 0
AND NOT EXISTS (
SELECT 1 FROM order_refunds r
WHERE r.order_id = o.id AND r.status = 'confirmed'
)
UNION ALL
@@ -6,6 +6,7 @@ import {
listOrderItemsByOrderIds,
} from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { listOrderRefunds } from '../finance/order-refund-service.js'
import {
getTaskById,
listTasks,
@@ -181,10 +182,11 @@ export async function getAdminOrderDetail(
})
}
const [items, tasks, workOrders] = await Promise.all([
const [items, tasks, workOrders, refunds] = await Promise.all([
listOrderItemsByOrderId(order.id),
listTasksByOrderId(order.id),
listWorkOrdersByOrderId(order.id),
listOrderRefunds(order.id),
])
const kuaishouIndustryVouchers = await listKuaishouIndustryVouchersByOid(order.platform_order_id)
const itemSummary = summarizeOrderItems(items, order, kuaishouIndustryVouchers)
@@ -236,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
+14
View File
@@ -69,3 +69,17 @@ export function generateConsignmentSubmissionNo(date: Date = new Date()): string
const rand = Math.floor(1000 + Math.random() * 9000)
return `SUB${yy}${mm}${dd}${hh}${min}${rand}`
}
/**
* 生成退款单号,形如 `RF26102415301234`。
* 整单退款按订单项拆成多条记录,共享同一个 batch_no(取首条单号)。
*/
export function generateRefundNo(date: Date = new Date()): string {
const yy = String(date.getFullYear()).slice(-2)
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const rand = Math.floor(1000 + Math.random() * 9000)
return `RF${yy}${mm}${dd}${hh}${min}${rand}`
}