Files
order_site/apps/backend/src/services/cost-center/platform-credit-service.ts
T
yml2213andClaude Opus 4.5 0a743f00b1 feat(finance): 客服端「我的备用金」改为「我的钱包」,四平台并列
客服原来只能看到备用金现金,平台积分藏在 Tab 里且是空的。
现在四个钱包并列展示:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。

后端:
- 客服查询积分账户时自动补建三个空账户,保证四个钱包始终并列展示,
  不会出现「点了没反应」的空白卡片。

前端:
- 标题「我的备用金」→「我的钱包」,菜单同步改名。
- 顶部四张卡:三个积分平台各一张(显示积分余额 + 折合金额 + 换算率标签),
  备用金现金一张。余额为 0 时置灰。
- 四个钱包卡片与备用金账户解耦:没开通备用金账户的客服也能看到积分钱包,
  只是现金卡显示「尚未开通备用金账户」。
- Tab 调整为「积分流水 / 备用金流水 / 我的支出」,积分流水独立展示
  (平台、类型、变动、余额、折合金额、备注、时间)。
- 备用金账户详情卡(账户编号、责任主管、状态、登记支出、缴回备用金)
  仍然只在有账户时显示。

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

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

586 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
/**
* 客服平台积分钱包服务(南航 / 乐玩 / ROCK)。
*
* 口径(2026-10-24 确认):
* * 三平台积分**互不通用**,每客服每平台一个账户;
* * 换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元;
* 真源是 fulfillment_profiles.config_json.rechargeUnitPriceFen;
* * 余额只能由流水产生,不允许手工改;流水只追加,不修改不删除;
* * 消耗不足直接拒绝,不允许负余额(与备用金一致);
* * 备用金现金**不走这里**,它复用现有的 fund_accounts / fund_entries。
*/
export const PLATFORM_CREDIT_KEYS = ['nanhang', 'lewan', 'rock'] as const
export type PlatformCreditKey = (typeof PLATFORM_CREDIT_KEYS)[number]
const PLATFORM_LABELS: Record<PlatformCreditKey, string> = {
nanhang: '南航平台',
lewan: '乐玩后台',
rock: 'ROCK 后台',
}
type CreditAccountRow = {
id: number
platform_key: string
owner_admin_user_id: number
balance_quantity: string | number
unit_name: string
recharge_unit_price_fen: string | number
status: string
owner_name?: string | null
username?: string | null
created_at: string
updated_at: string
}
type CreditEntryRow = {
id: number
account_id: number
entry_type: string
quantity_delta: string | number
balance_after: string | number
amount_fen: string | number
platform_ref: string
related_task_id: number | null
note: string
created_at: string
platform_key?: string
owner_name?: string | null
}
/** 读取平台档案里配置的换算率(分/积分)。 */
async function loadUnitPrices(): Promise<Record<PlatformCreditKey, number>> {
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
)
const prices: Record<PlatformCreditKey, number> = { nanhang: 1, lewan: 1, rock: 100 }
for (const row of result.rows) {
const raw = Number(row.config_json?.rechargeUnitPriceFen)
const key = row.profile_key.replace(/^manual_/, '') as PlatformCreditKey
if (key in prices && Number.isInteger(raw) && raw > 0) {
prices[key] = raw
}
}
return prices
}
/** 客服只能看自己的积分钱包;管理员/财务可看全部。 */
function isPrivileged(session: AdminSession) {
return session.role === 'admin' || session.role === 'finance' || session.role === 'operator'
}
/**
* 列出积分钱包。客服返回自己的三个平台账户(没有就补建空账户)。
*/
export async function listPlatformCreditAccounts(session: AdminSession) {
const unitPrices = await loadUnitPrices()
const params: unknown[] = []
const clauses: string[] = []
if (!isPrivileged(session)) {
params.push(session.userId)
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
}
// 客服第一次进来时补建三个空账户,保证四个钱包始终并列展示。
if (!isPrivileged(session)) {
await ensurePlatformCreditAccounts(session.userId)
}
const where = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : ''
const result = await query<CreditAccountRow>(
`
SELECT
pca.id, pca.platform_key, pca.owner_admin_user_id, pca.balance_quantity,
pca.unit_name, pca.recharge_unit_price_fen, pca.status,
pca.created_at, pca.updated_at,
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name,
au.username
FROM platform_credit_accounts pca
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
WHERE 1 = 1 ${where}
ORDER BY pca.platform_key, pca.owner_admin_user_id
`,
params,
)
return {
items: result.rows.map((row) => mapAccount(row, unitPrices)),
platforms: PLATFORM_CREDIT_KEYS.map((key) => ({
key,
label: PLATFORM_LABELS[key],
creditUnitPriceFen: unitPrices[key],
})),
}
}
/** 给客服补建三个平台的空积分账户(没有才建)。 */
export async function ensurePlatformCreditAccounts(ownerAdminUserId: number): Promise<void> {
const unitPrices = await loadUnitPrices()
const now = nowIso()
for (const key of PLATFORM_CREDIT_KEYS) {
await query(
`
INSERT INTO platform_credit_accounts (
platform_key, owner_admin_user_id, balance_quantity, unit_name,
recharge_unit_price_fen, status, created_at, updated_at
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
`,
[key, ownerAdminUserId, unitPrices[key], now],
)
}
}
/** 某个客服在某个平台的积分余额(0 = 没账户)。 */
export async function getPlatformCreditBalance(
ownerAdminUserId: number,
platformKey: PlatformCreditKey,
) {
const result = await query<CreditAccountRow>(
`SELECT * FROM platform_credit_accounts
WHERE owner_admin_user_id = $1 AND platform_key = $2
LIMIT 1`,
[ownerAdminUserId, platformKey],
)
const row = result.rows[0]
if (!row) return null
const unitPrices = await loadUnitPrices()
return mapAccount(row, unitPrices)
}
/**
* 充值:公司往客服的平台积分账户加额度。
*
* 只有管理员/财务能充值;客服角色只能查看。
*/
export async function grantPlatformCredit(payload: JsonObject, session: AdminSession) {
if (!isPrivileged(session)) {
throw createHttpError('只有管理员或财务可以充值积分', {
statusCode: 403,
errorCode: 'platform_credit_grant_forbidden',
})
}
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
const ownerAdminUserId = normalizeRequiredId(
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
'请选择客服',
)
const quantity = normalizePositiveQuantity(payload.quantity)
const platformRef = optionalText(payload.platformRef ?? payload.platform_ref, 100)
const note = optionalText(payload.note, 300)
const unitPrices = await loadUnitPrices()
const unitPriceFen = unitPrices[platformKey]
const now = nowIso()
return withTransaction(async (client) => {
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
const account = await lockAccount(client, ownerAdminUserId, platformKey)
const nextBalance = Number(account.balance_quantity) + quantity
await client.query(
`UPDATE platform_credit_accounts
SET balance_quantity = $2, updated_at = $3
WHERE id = $1`,
[account.id, nextBalance, now],
)
const entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
platform_ref, note, created_at
) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7)
RETURNING *
`,
[account.id, quantity, nextBalance, quantity * unitPriceFen, platformRef, note, now],
)
return {
accountId: Number(account.id),
platformKey,
quantity,
balanceAfter: nextBalance,
amountFen: quantity * unitPriceFen,
entryId: Number(entryResult.rows[0]?.id || 0),
}
})
}
/** 人工调整余额(正负都允许,但不能把余额压成负数)。 */
export async function adjustPlatformCredit(payload: JsonObject, session: AdminSession) {
if (!isPrivileged(session)) {
throw createHttpError('只有管理员或财务可以调整积分', {
statusCode: 403,
errorCode: 'platform_credit_adjust_forbidden',
})
}
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
const ownerAdminUserId = normalizeRequiredId(
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
'请选择客服',
)
const quantity = normalizeNonZeroQuantity(payload.quantity)
const note = optionalText(payload.note, 300)
if (!note) {
throw createHttpError('调整积分必须填写原因', {
statusCode: 400,
errorCode: 'platform_credit_adjust_note_required',
})
}
const unitPrices = await loadUnitPrices()
const unitPriceFen = unitPrices[platformKey]
const now = nowIso()
return withTransaction(async (client) => {
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
const account = await lockAccount(client, ownerAdminUserId, platformKey)
const nextBalance = Number(account.balance_quantity) + quantity
if (nextBalance < 0) {
throw createHttpError('调整后积分余额不能为负', {
statusCode: 409,
errorCode: 'platform_credit_balance_negative',
})
}
await client.query(
`UPDATE platform_credit_accounts
SET balance_quantity = $2, updated_at = $3
WHERE id = $1`,
[account.id, nextBalance, now],
)
const entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
note, created_at
) VALUES ($1, 'adjust', $2, $3, $4, $5, $6)
RETURNING *
`,
[account.id, quantity, nextBalance, quantity * unitPriceFen, note, now],
)
return {
accountId: Number(account.id),
platformKey,
quantity,
balanceAfter: nextBalance,
amountFen: quantity * unitPriceFen,
entryId: Number(entryResult.rows[0]?.id || 0),
}
})
}
/**
* 消耗积分:客服履约时扣自己的某个平台钱包。
*
* 余额不足直接拒绝(409),不允许负余额。
*/
export async function consumePlatformCredit(input: {
ownerAdminUserId: number
platformKey: PlatformCreditKey
quantity: number
relatedTaskId?: number | null
platformRef?: string
note?: string
}): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> {
const unitPrices = await loadUnitPrices()
const unitPriceFen = unitPrices[input.platformKey]
const now = nowIso()
return withTransaction(async (client) => {
await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now)
const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey)
const balance = Number(account.balance_quantity)
if (balance < input.quantity) {
throw createHttpError(
`${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`,
{
statusCode: 409,
errorCode: 'platform_credit_balance_insufficient',
},
)
}
const nextBalance = balance - input.quantity
await client.query(
`UPDATE platform_credit_accounts
SET balance_quantity = $2, updated_at = $3
WHERE id = $1`,
[account.id, nextBalance, now],
)
const entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
platform_ref, related_task_id, note, created_at
) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`,
[
account.id,
-input.quantity,
nextBalance,
input.quantity * unitPriceFen,
input.platformRef || '',
input.relatedTaskId ?? null,
input.note || '',
now,
],
)
return {
entryId: Number(entryResult.rows[0]?.id || 0),
balanceAfter: nextBalance,
amountFen: input.quantity * unitPriceFen,
}
})
}
/** 积分流水列表。 */
export async function listPlatformCreditEntries(
input: Record<string, unknown>,
session: AdminSession,
) {
const unitPrices = await loadUnitPrices()
const page = normalizePositiveInteger(input.page, 1, '页码')
const pageSize = Math.min(
200,
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
)
const params: unknown[] = []
const clauses: string[] = []
if (!isPrivileged(session)) {
params.push(session.userId)
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
}
const platformKey = String(input.platformKey || '').trim()
if (platformKey) {
params.push(platformKey)
clauses.push(`pca.platform_key = $${params.length}`)
}
const entryType = String(input.entryType || '').trim()
if (entryType) {
params.push(entryType)
clauses.push(`pce.entry_type = $${params.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
params.push(`%${escapeLike(keyword)}%`)
const placeholder = `$${params.length}`
clauses.push(`(pce.note ILIKE ${placeholder} OR pce.platform_ref ILIKE ${placeholder})`)
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
const countResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM platform_credit_entries pce
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
${where}
`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<CreditEntryRow>(
`
SELECT
pce.*, pca.platform_key,
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name
FROM platform_credit_entries pce
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
${where}
ORDER BY pce.created_at DESC, pce.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
)
return {
items: result.rows.map((row) => mapEntry(row, unitPrices)),
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
}
}
/** 可选客服列表(充值/调整时选人)。 */
export async function listPlatformCreditOwners() {
const result = await query<{
id: number
username: string
display_name: string
}>(
`
SELECT
au.id, au.username, COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name
FROM admin_users au
WHERE au.status = 'active'
ORDER BY au.id
`,
)
return {
items: result.rows.map((row) => ({
id: Number(row.id),
username: row.username,
displayName: row.display_name,
})),
}
}
async function ensureAccountRow(
client: PoolClient,
ownerAdminUserId: number,
platformKey: PlatformCreditKey,
unitPriceFen: number,
now: string,
) {
await client.query(
`
INSERT INTO platform_credit_accounts (
platform_key, owner_admin_user_id, balance_quantity, unit_name,
recharge_unit_price_fen, status, created_at, updated_at
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
`,
[platformKey, ownerAdminUserId, unitPriceFen, now],
)
}
async function lockAccount(
client: PoolClient,
ownerAdminUserId: number,
platformKey: PlatformCreditKey,
) {
const result = await client.query<CreditAccountRow>(
`
SELECT * FROM platform_credit_accounts
WHERE owner_admin_user_id = $1 AND platform_key = $2
FOR UPDATE
`,
[ownerAdminUserId, platformKey],
)
const row = result.rows[0]
if (!row) {
throw createHttpError('积分账户不存在', {
statusCode: 404,
errorCode: 'platform_credit_account_not_found',
})
}
if (row.status !== 'active') {
throw createHttpError('积分账户已冻结或关闭', {
statusCode: 409,
errorCode: 'platform_credit_account_inactive',
})
}
return row
}
function mapAccount(row: CreditAccountRow, unitPrices: Record<PlatformCreditKey, number>) {
const platformKey = row.platform_key as PlatformCreditKey
const unitPriceFen = Number(row.recharge_unit_price_fen || unitPrices[platformKey] || 1)
return {
id: Number(row.id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
ownerAdminUserId: Number(row.owner_admin_user_id),
ownerName: row.owner_name || row.username || '',
balanceQuantity: Number(row.balance_quantity || 0),
balanceAmountFen: Number(row.balance_quantity || 0) * unitPriceFen,
unitName: row.unit_name || '积分',
creditUnitPriceFen: unitPriceFen,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function mapEntry(row: CreditEntryRow, unitPrices: Record<PlatformCreditKey, number>) {
const platformKey = (row.platform_key || '') as PlatformCreditKey
return {
id: Number(row.id),
accountId: Number(row.account_id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
ownerName: row.owner_name || '',
entryType: row.entry_type,
quantityDelta: Number(row.quantity_delta || 0),
balanceAfter: Number(row.balance_after || 0),
amountFen: Number(row.amount_fen || 0),
platformRef: row.platform_ref || '',
relatedTaskId: row.related_task_id ? Number(row.related_task_id) : null,
note: row.note || '',
createdAt: row.created_at,
}
}
function normalizePlatformKey(value: unknown): PlatformCreditKey {
const text = String(value || '').trim()
if (!PLATFORM_CREDIT_KEYS.includes(text as PlatformCreditKey)) {
throw createHttpError('积分平台无效', {
statusCode: 400,
errorCode: 'platform_credit_platform_invalid',
})
}
return text as PlatformCreditKey
}
function normalizeRequiredId(value: unknown, message: string) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'platform_credit_owner_invalid',
})
}
return parsed
}
function normalizePositiveQuantity(value: unknown) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw createHttpError('积分数必须是正整数', {
statusCode: 400,
errorCode: 'platform_credit_quantity_invalid',
})
}
return parsed
}
function normalizeNonZeroQuantity(value: unknown) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed === 0) {
throw createHttpError('调整积分数必须是非零整数', {
statusCode: 400,
errorCode: 'platform_credit_quantity_invalid',
})
}
return parsed
}
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
const parsed = Number(value)
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
if (!value) return fallback
throw createHttpError(`${label}必须是正整数`, {
statusCode: 400,
errorCode: 'platform_credit_pagination_invalid',
})
}
return parsed
}
function optionalText(value: unknown, maxLength: number) {
return String(value || '')
.trim()
.slice(0, maxLength)
}
function escapeLike(value: string) {
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
}