fix(finance): 收口快手退款路由并修复销售事实变更后的总账同步

- 平台退款路由从通用订单路径改为
  POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record,
  服务改名 approveAndRecordKuaishouRefund,明确仅快手 91
- 总账同步改为未关账期间按日整体重建销售凭证,解决收入回填/退款重算后
  旧凭证金额不更新的问题;删除与重建放在同一事务,已关账期间保持不动
- 文档明确 revenue:backfill 是存量订单退款的前置条件:
  revenue_amount_fen=0 会导致退款登记按钮置灰
- 本地生产副本已执行 revenue:backfill:375,447 行全部回填,
  order 375483 可退金额恢复为 237.00 元

验证:backend check 844(840 pass / 4 skip);frontend check 172 + build;
集成测试通过(代录、总账平衡、关账阻断、对账)。
This commit is contained in:
yml2213
2026-10-26 13:48:53 +08:00
parent 861524a91a
commit 6b0078f680
7 changed files with 93 additions and 57 deletions
@@ -12,7 +12,8 @@ import {
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '../../services/admin/kuaishou-industry-admin-service.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
import { approveAndRecordKuaishouRefund } from '../../services/finance/order-refund-service.js'
import type {
AdminKuaishouIndustryRefundApproveRouteBody,
AdminKuaishouIndustryRefundDisagreeRouteBody,
@@ -27,6 +28,42 @@ import type { KuaishouIndustryVoucherAdminListQuery } from '../../repositories/k
const router = Router()
router.post(
'/kuaishou-industry/orders/:orderId/refunds/approve-and-record',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => {
const body = req.body as Record<string, unknown>
return approveAndRecordKuaishouRefund(
{
orderId: String((req.params as Record<string, string>).orderId || ''),
orderItemId: body?.orderItemId as string | undefined,
amountFen: body?.amountFen as string | undefined,
platformRefundId: String(body?.platformRefundId || ''),
reason: body?.reason as string | undefined,
note: body?.note as string | undefined,
refundedAt: body?.refundedAt as string | undefined,
sellerId: body?.sellerId as string | undefined,
refundHandingWay: body?.refundHandingWay,
desc: body?.desc as string | undefined,
},
req.adminSession || null,
)
},
{
successMessage: '快手退款已同意并登记',
errorMessage: '快手平台退款失败',
scope: '[admin/kuaishou-industry/orders/refunds/approve-and-record]',
audit: (req, data) => ({
action: 'kuaishou_order_refund_approved_and_recorded',
targetType: 'order',
targetId: String((req.params as Record<string, string>).orderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.get(
'/kuaishou-industry/vouchers',
createJsonHandler(
-37
View File
@@ -8,7 +8,6 @@ import {
} from '../../services/admin/write/order-actions.js'
import {
createManualRefund,
createPlatformRefund,
listOrderRefunds,
voidOrderRefund,
} from '../../services/finance/order-refund-service.js'
@@ -104,42 +103,6 @@ router.get(
),
)
router.post(
'/orders/:orderId/refunds/platform',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => {
const body = req.body as Record<string, unknown>
return createPlatformRefund(
{
orderId: String((req.params as AdminOrderRouteParams).orderId || ''),
orderItemId: body?.orderItemId as string | undefined,
amountFen: body?.amountFen as string | undefined,
platformRefundId: String(body?.platformRefundId || ''),
reason: body?.reason as string | undefined,
note: body?.note as string | undefined,
refundedAt: body?.refundedAt as string | undefined,
sellerId: body?.sellerId as string | undefined,
refundHandingWay: body?.refundHandingWay,
desc: body?.desc as string | undefined,
},
req.adminSession || null,
)
},
{
successMessage: '平台退款已提交并登记',
errorMessage: '平台退款失败',
scope: '[admin/orders/:orderId/refunds/platform]',
audit: (req, data) => ({
action: 'order_platform_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',
requireAdminPermission('finance.config'),
@@ -189,9 +189,22 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date
)
let created = 0
let replaced = 0
let skipped = 0
for (const row of rows.rows) {
const occurredAt = `${row.stat_date}T12:00:00+08:00`
const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7)
const period = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
// 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。
skipped += 1
continue
}
// 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建,
// 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。
const revenue = Number(row.gross_revenue_fen || 0)
const refund = Number(row.refund_amount_fen || 0)
const cost = Number(row.cost_amount_fen || 0)
@@ -235,20 +248,22 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date
],
})
}
for (const entry of entries) {
try {
const result = await postJournalEntry(entry)
if (result.created) created += 1
} catch (error) {
if (isPeriodClosedError(error)) {
skipped += 1
continue
}
throw error
const dayCreated = await withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`,
[row.stat_date],
)
replaced += deleted.rowCount || 0
let count = 0
for (const entry of entries) {
const result = await postJournalEntry(entry, client)
if (result.created) count += 1
}
}
return count
})
created += dayCreated
}
return { range: { fromDate: dateFrom, toDate: dateTo }, created, skipped }
return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped }
}
export async function listJournalEntries(input: Record<string, unknown> = {}) {
@@ -166,6 +166,14 @@ test('总账/对账/关账/平台退款能力已接入', async () => {
refund.includes('platform_refund_not_supported'),
'平台退款必须走平台 API 并落 source=platform',
)
const kuaishouRoute = await readFile(
new URL('../../routes/admin/kuaishou-industry.ts', import.meta.url),
'utf8',
)
assert.ok(
kuaishouRoute.includes('/kuaishou-industry/orders/:orderId/refunds/approve-and-record'),
'平台退款路由必须明确收口在快手行业前缀下',
)
assert.ok(
manual.includes('fundAccountId') && manual.includes('creditOwnerAdminUserId'),
'管理员/财务必须可以指定客服账户代录',
@@ -453,7 +453,7 @@ function formatFen(value: number): string {
* 当前只接入快手行业(91)的「同意退款」接口;抖音/拼多多暂无可用退款 API,
* 会返回 501,由后台手工登记兜底。
*/
export async function createPlatformRefund(
export async function approveAndRecordKuaishouRefund(
input: {
orderId: number | string
orderItemId?: number | string | null | undefined