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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -323,7 +323,7 @@ export type CostModel =
|
||||
| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 |
|
||||
| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 |
|
||||
| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 |
|
||||
| POST | `/api/v1/admin/orders/:orderId/refunds/platform` | finance.config | 平台退款(当前仅快手 91) |
|
||||
| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) |
|
||||
|
||||
确认履约接口新增代录字段:
|
||||
|
||||
|
||||
@@ -145,16 +145,23 @@ GROUP BY status;
|
||||
|
||||
`manual_cost_missing` 只在「首次配置之后」生成;确认履约写入快照后自动置为 `resolved`。
|
||||
|
||||
### 5. 历史回填(可选,不阻塞上线)
|
||||
### 5. 历史回填(退款功能上线前必须跑 `revenue:backfill`)
|
||||
|
||||
本次上线不要求补历史人工成本;如需历史商品归一/成本快照/销售事实用于报表,另跑:
|
||||
**存量订单要能退款,必须先回填订单项收入。** 迁移 `104` 只加列不回填;`revenue_basis='unknown'` 的订单项 `revenue_amount_fen=0`,订单详情「退款登记」会因可退金额为 0 而置灰,手工退款也会失败。
|
||||
|
||||
```bash
|
||||
npm run revenue:backfill -- --dry-run # 应为 unknown=0
|
||||
npm run fulfillment:backfill # 只填 NULL
|
||||
npm run revenue:backfill -- --dry-run # 待回填量,应为 0
|
||||
npm run revenue:backfill # 分批回填;中断可重跑
|
||||
npm run fulfillment:backfill # 只填 NULL(可选,报表用)
|
||||
npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM order_items WHERE revenue_basis = 'unknown'; -- 退款上线前应为 0
|
||||
```
|
||||
|
||||
长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。
|
||||
注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。
|
||||
|
||||
@@ -212,6 +219,10 @@ SELECT reason, status, COUNT(*) FROM cost_exceptions
|
||||
WHERE created_at >= :launch_at
|
||||
GROUP BY 1,2 ORDER BY 3 DESC;
|
||||
|
||||
-- 7.1 存量订单收入回填(退款功能上线前应为 0)
|
||||
SELECT COUNT(*) AS unknown_revenue_items
|
||||
FROM order_items WHERE revenue_basis = 'unknown';
|
||||
|
||||
-- 8. 总账借贷平衡(应为差额 0)
|
||||
SELECT
|
||||
COALESCE(SUM(debit_fen), 0) AS debit_fen,
|
||||
@@ -240,6 +251,7 @@ GROUP BY 1;
|
||||
| 确认时报「积分不足」 | 未充值/充值到别的客服 | 积分钱包 → 给本人账户发放 |
|
||||
| 确认成功但列表仍有「待确认」 | 前端缓存 | 刷新;后端以快照为准 |
|
||||
| `manual_cost_missing` 不减少 | 未确认履约,或确认失败回滚 | 看订单详情确认;异常只能由快照自动收口 |
|
||||
| 订单详情「退款登记」置灰 | 订单项 `revenue_amount_fen=0`,通常是存量订单未跑收入回填 | 执行 `npm run revenue:backfill`;刷新后「可退」应大于 0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -250,6 +262,7 @@ GROUP BY 1;
|
||||
### 4.1 影子总账
|
||||
|
||||
- 销售事实按日同步:收入(借 1101 / 贷 4001)、退款(借 4101 / 贷 1101)、成本(借 5001 / 贷 2001);
|
||||
- **未关账期间按日整体重建**:退款/收入回填导致销售事实变化时,旧日凭证会被替换;已关账期间保持不动;
|
||||
- 财务可手工录入调整凭证(借贷科目 + 金额 + 摘要),必须借贷平衡;
|
||||
- 试算平衡按日期区间展示;总账不接管 `worker_wallets` / `fund_accounts` 余额;
|
||||
- 定时任务 `cost-finance-sync` 每 5 分钟同步最近窗口;也可手动「同步销售事实到总账」。
|
||||
@@ -277,7 +290,7 @@ GROUP BY 1;
|
||||
|
||||
### 4.4 平台退款 API
|
||||
|
||||
- 快手行业(91):`POST /api/v1/admin/orders/:orderId/refunds/platform`,传 `platformRefundId`、`orderItemId`、`amountFen`;
|
||||
- 快手行业(91):`POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record`,传 `platformRefundId`、`orderItemId`、`amountFen`;
|
||||
先调用快手「同意退款」,成功后再落 `order_refunds.source='platform'` 并冲减销售事实;
|
||||
- 抖音/拼多多:暂无退款 API,返回 501,仍用「手工退款登记」;
|
||||
- 平台退款必须传订单项;参数校验在调用平台之前完成,避免平台已同意、本地记账失败。
|
||||
|
||||
Reference in New Issue
Block a user