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>
This commit is contained in:
yml2213
2026-10-24 13:21:34 +08:00
co-authored by Claude Opus 4.5
parent 4bcb4e6b57
commit d07d88e25c
12 changed files with 2221 additions and 0 deletions
@@ -31,6 +31,13 @@ import {
updateAdminChannelMapping,
updateAdminProduct,
} from '../../services/cost-center/product-catalog-service.js'
import {
adjustPlatformCredit,
grantPlatformCredit,
listPlatformCreditAccounts,
listPlatformCreditEntries,
listPlatformCreditOwners,
} from '../../services/cost-center/platform-credit-service.js'
import {
deleteManualFulfillmentConfig,
getManualFulfillmentConfig,
@@ -745,4 +752,95 @@ router.post(
}),
)
// ---------------------------------------------------------------------------
// 客服平台积分钱包(南航 / 乐玩 / ROCK)
// ---------------------------------------------------------------------------
router.get(
'/cost-center/platform-credits/accounts',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listPlatformCreditAccounts(sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取积分钱包失败',
scope: '[admin/cost-center/platform-credits/accounts]',
}),
)
router.get(
'/cost-center/platform-credits/entries',
requireAdminPermission('finance.view'),
createJsonHandler(
(req) => listPlatformCreditEntries(req.query as Record<string, unknown>, sessionOf(req)),
{
successMessage: 'ok',
errorMessage: '读取积分流水失败',
scope: '[admin/cost-center/platform-credits/entries]',
},
),
)
router.get(
'/cost-center/platform-credits/owners',
requireAdminPermission('finance.config'),
createJsonHandler(() => listPlatformCreditOwners(), {
successMessage: 'ok',
errorMessage: '读取客服列表失败',
scope: '[admin/cost-center/platform-credits/owners]',
}),
)
router.post(
'/cost-center/platform-credits/grant',
requireAdminPermission('finance.config'),
createJsonHandler((req) => grantPlatformCredit(bodyOf(req), sessionOf(req)), {
successMessage: '积分已充值',
errorMessage: '积分充值失败',
scope: '[admin/cost-center/platform-credits/grant]',
audit: (req, data) => {
const result = data as {
platformKey?: string
quantity?: number
amountFen?: number
}
return {
action: 'platform_credit_granted',
targetType: 'platform_credit_account',
targetId: String(req.body?.ownerAdminUserId || ''),
data: {
platformKey: result.platformKey || '',
quantity: result.quantity || 0,
amountFen: result.amountFen || 0,
},
}
},
}),
)
router.post(
'/cost-center/platform-credits/adjust',
requireAdminPermission('finance.config'),
createJsonHandler((req) => adjustPlatformCredit(bodyOf(req), sessionOf(req)), {
successMessage: '积分已调整',
errorMessage: '积分调整失败',
scope: '[admin/cost-center/platform-credits/adjust]',
audit: (req, data) => {
const result = data as {
platformKey?: string
quantity?: number
amountFen?: number
}
return {
action: 'platform_credit_adjusted',
targetType: 'platform_credit_account',
targetId: String(req.body?.ownerAdminUserId || ''),
data: {
platformKey: result.platformKey || '',
quantity: result.quantity || 0,
amountFen: result.amountFen || 0,
},
}
},
}),
)
export default router
+51
View File
@@ -15,6 +15,10 @@ 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,
@@ -153,4 +157,51 @@ router.post(
}),
)
// ---------------------------------------------------------------------------
// 人工履约待办与确认履约
// ---------------------------------------------------------------------------
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