Files
order_site/apps/backend/src/routes/admin/finance.ts
T
yml2213andClaude Opus 4.5 d07d88e25c feat(finance): 积分钱包操作 + 客服「确认履约」闭环
把「配置 → 待办 → 确认履约 → 扣额度 → 写成本快照」整条链路打通,
客服点一下就完成履约,不需要填复杂的东西。

积分钱包(P1.3):
- platform-credit-service.ts:充值 / 调整 / 消耗 / 流水查询。
- 三平台积分互不通用,每客服每平台一个账户;余额只能由流水产生,
  消耗不足直接拒绝(409),不允许负余额,与备用金口径一致。
- 换算率按平台:南航、乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元。
- 充值 / 调整只对 admin/operator/finance 开放;客服只能看自己的钱包。
- 备用金现金不在这里,走现有 fund_* 链路。

确认履约(P1.4):
- manual-fulfillment-service.ts:待办列表 + 确认履约。
- 待办口径:order_items.fulfillment_mode='manual' 且已配 fulfillment_config_id。
  人工单**不建 fulfillment_tasks**——那是自动发货通道的体系,人工单历来不走它
  (本地 102,955 个人工单的任务行数为 0)。
- 「已履约」判定:该订单项存在有效 manual_case 成本快照。
- 固定成本(消耗积分 > 0)→ 零输入;成本不固定(= 0)→ 只填实际成本金额。
- 确认时:积分平台扣积分钱包、备用金现金走 writeFundExpense,
  随后写 actual 成本快照(主体 = 积分流水 id 或备用金支出单 id)。
- 渠道与消耗积分永远提前配好,客服不选;重复履约直接拒绝。

前端:
- 新增「人工履约」页(客服菜单):待确认 / 已履约 / 全部,确认履约弹窗。
- 备用金页新增「平台积分」Tab(客服端与管理端各一份):
  余额卡片、积分流水、管理员充值与调整。

E2E 实测:配置 ROCK 100 积分 → 成本 ¥100.00(1 积分 = 1 元);充值 5000 积分
= ¥5000;确认履约后积分 5000 → 4900,成本快照 actual ¥100.00 写入;
重复履约被拒绝。测试数据已清理,登记表保留 357 条扫描结果供运营使用。

校验:后端 765 项 / 0 失败,前端 143 项全通过(含 tsc -b 与 build)。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:21:34 +08:00

208 lines
6.2 KiB
TypeScript

import type { Request } from 'express'
import { Router } from 'express'
import {
getAdminFinanceSummary,
listAdminFinanceTransactions,
} from '../../services/admin/admin-finance-service.js'
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
import {
getSalesProfitOverview,
listSalesDailyFacts,
rebuildSalesDailyFacts,
} from '../../services/finance/sales-facts-service.js'
import {
createManualOrderCost,
listManualOrderCostItems,
} from '../../services/finance/manual-order-cost-service.js'
import {
confirmManualFulfillment,
listManualFulfillmentPending,
} from '../../services/finance/manual-fulfillment-service.js'
import type { JsonObject } from '../../types/json.js'
import {
createJsonHandler,
requireAdminPermission,
requireAdminRoles,
requireAnyAdminPermission,
} from './session.js'
const router = Router()
function sessionOf(req: Request): AdminSession {
return req.adminSession as AdminSession
}
function idOf(req: Request, key: string) {
return Number(req.params[key])
}
function bodyOf(req: Request): JsonObject {
return req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {}
}
router.get(
'/finance/summary',
requireAdminRoles(['admin', 'operator', 'finance']),
createJsonHandler((req) => getAdminFinanceSummary(req.query), {
successMessage: 'ok',
errorMessage: '读取财务汇总失败',
scope: '[admin/finance/summary]',
}),
)
router.get(
'/finance/transactions',
requireAdminRoles(['admin', 'operator', 'finance']),
createJsonHandler((req) => listAdminFinanceTransactions(req.query), {
successMessage: 'ok',
errorMessage: '读取财务明细失败',
scope: '[admin/finance/transactions]',
}),
)
router.get(
'/finance/sales-profit/overview',
requireAdminRoles(['admin', 'operator', 'finance']),
requireAdminPermission('finance.view'),
createJsonHandler((req) => getSalesProfitOverview(req.query as Record<string, unknown>), {
successMessage: 'ok',
errorMessage: '读取销售毛利失败',
scope: '[admin/finance/sales-profit/overview]',
}),
)
router.get(
'/finance/sales-facts',
requireAdminRoles(['admin', 'operator', 'finance']),
requireAdminPermission('finance.view'),
createJsonHandler((req) => listSalesDailyFacts(req.query as Record<string, unknown>), {
successMessage: 'ok',
errorMessage: '读取销售事实失败',
scope: '[admin/finance/sales-facts]',
}),
)
router.post(
'/finance/sales-facts/rebuild',
requireAdminRoles(['admin', 'operator', 'finance']),
requireAdminPermission('finance.config'),
createJsonHandler(
(req) =>
rebuildSalesDailyFacts(
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {},
),
{
successMessage: '销售事实已重算',
errorMessage: '重算销售事实失败',
scope: '[admin/finance/sales-facts/rebuild]',
audit: (req, data) => {
const result = data as {
range?: { fromDate?: string; toDate?: string }
insertedCount?: number
deletedCount?: number
}
return {
action: 'sales_daily_facts_rebuilt',
targetType: 'sales_daily_facts',
targetId: '',
data: {
dateFrom: result.range?.fromDate || req.body?.dateFrom || '',
dateTo: result.range?.toDate || req.body?.dateTo || '',
insertedCount: result.insertedCount || 0,
deletedCount: result.deletedCount || 0,
},
}
},
},
),
)
router.get(
'/finance/manual-order-cost-items',
requireAnyAdminPermission(['finance.view', 'fund.view']),
createJsonHandler(() => listManualOrderCostItems(), {
successMessage: 'ok',
errorMessage: '读取人工录单成本项失败',
scope: '[admin/finance/manual-order-cost-items]',
}),
)
router.post(
'/finance/manual-order-costs',
requireAnyAdminPermission(['fund.expense', 'finance.config']),
createJsonHandler((req) => createManualOrderCost(bodyOf(req), sessionOf(req)), {
successMessage: '订单人工成本已录入',
errorMessage: '录入订单人工成本失败',
scope: '[admin/finance/manual-order-costs]',
audit: (req, data) => {
const result = data as {
expenseNo?: string
amountFen?: number
orderId?: number
costSnapshotId?: number | null
}
return {
action: 'manual_order_cost_created',
targetType: 'fund_expense',
targetId: result.expenseNo || '',
data: {
expenseNo: result.expenseNo || '',
amountFen: result.amountFen || 0,
orderId: result.orderId || req.body?.orderId || '',
costSnapshotId: result.costSnapshotId || null,
},
}
},
}),
)
// ---------------------------------------------------------------------------
// 人工履约待办与确认履约
// ---------------------------------------------------------------------------
router.get(
'/finance/manual-fulfillment/pending',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(
(req) => listManualFulfillmentPending(req.query as Record<string, unknown>, sessionOf(req)),
{
successMessage: 'ok',
errorMessage: '读取人工履约待办失败',
scope: '[admin/finance/manual-fulfillment/pending]',
},
),
)
router.post(
'/finance/manual-fulfillment/confirm',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => confirmManualFulfillment(bodyOf(req), sessionOf(req)), {
successMessage: '履约已确认',
errorMessage: '确认履约失败',
scope: '[admin/finance/manual-fulfillment/confirm]',
audit: (req, data) => {
const result = data as {
orderItemId?: number
costSnapshotId?: number
platformKey?: string
creditQuantity?: number
costFen?: number
}
return {
action: 'manual_fulfillment_confirmed',
targetType: 'order_item',
targetId: String(result.orderItemId || ''),
data: {
costSnapshotId: result.costSnapshotId || null,
platformKey: result.platformKey || '',
creditQuantity: result.creditQuantity || 0,
costFen: result.costFen || 0,
},
}
},
}),
)
export default router