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:
co-authored by
Claude Opus 4.5
parent
4bcb4e6b57
commit
d07d88e25c
@@ -34,6 +34,7 @@ import dayjs from 'dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PlatformCreditPanel } from './PlatformCreditPanel'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
@@ -1073,6 +1074,11 @@ export default function FundManagementView({ role }: { role: ManagementRole }) {
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform-credits',
|
||||
label: '平台积分',
|
||||
children: <PlatformCreditPanel />,
|
||||
},
|
||||
{
|
||||
key: 'expenses',
|
||||
label: '支出明细',
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { PlatformCreditPanel } from './PlatformCreditPanel'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
@@ -409,6 +410,11 @@ export default function FundSupportView() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform-credits',
|
||||
label: '平台积分',
|
||||
children: <PlatformCreditPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 平台积分钱包面板:南航 / 乐玩 / ROCK 三平台的客服积分。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 三平台积分**互不通用**,每客服每平台一个钱包;
|
||||
* * 换算率按平台不同:南航、乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元;
|
||||
* * 余额只能由流水产生,不允许手工改;消耗不足直接拒绝。
|
||||
*
|
||||
* 客服看自己的积分余额与流水;管理员/财务可以充值、调整、看全部。
|
||||
*/
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
adjustAdminPlatformCredit,
|
||||
fetchAdminPlatformCreditAccounts,
|
||||
fetchAdminPlatformCreditEntries,
|
||||
grantAdminPlatformCredit,
|
||||
} from '@/services/admin'
|
||||
import type { PlatformCreditAccount, PlatformCreditEntry } from '@/types/admin'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
}
|
||||
|
||||
const ENTRY_TYPE_LABELS: Record<string, string> = {
|
||||
grant: '充值',
|
||||
consume: '消耗',
|
||||
adjust: '调整',
|
||||
return: '退回',
|
||||
}
|
||||
|
||||
const ENTRY_TYPE_COLORS: Record<string, string> = {
|
||||
grant: 'green',
|
||||
consume: 'red',
|
||||
adjust: 'gold',
|
||||
return: 'orange',
|
||||
}
|
||||
|
||||
type GrantFormValues = {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
platformRef?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
type AdjustFormValues = {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
note: string
|
||||
}
|
||||
|
||||
export function PlatformCreditPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [grantForm] = Form.useForm<GrantFormValues>()
|
||||
const [adjustForm] = Form.useForm<AdjustFormValues>()
|
||||
|
||||
const [grantOpen, setGrantOpen] = useState(false)
|
||||
const [adjustOpen, setAdjustOpen] = useState(false)
|
||||
const [platformFilter, setPlatformFilter] = useState('')
|
||||
|
||||
const canManage = hasAdminPermission('finance.config')
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ['admin-platform-credit-accounts'],
|
||||
queryFn: fetchAdminPlatformCreditAccounts,
|
||||
})
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ['admin-platform-credit-entries', platformFilter],
|
||||
queryFn: () =>
|
||||
fetchAdminPlatformCreditEntries({
|
||||
platformKey: platformFilter || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: (values: GrantFormValues) =>
|
||||
grantAdminPlatformCredit({
|
||||
ownerAdminUserId: values.ownerAdminUserId,
|
||||
platformKey: values.platformKey,
|
||||
quantity: values.quantity,
|
||||
platformRef: values.platformRef || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`已充值 ${result.quantity} 积分,折合 ¥${(result.amountFen / 100).toFixed(2)}`,
|
||||
)
|
||||
setGrantOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '充值失败')
|
||||
},
|
||||
})
|
||||
|
||||
const adjustMutation = useMutation({
|
||||
mutationFn: (values: AdjustFormValues) =>
|
||||
adjustAdminPlatformCredit({
|
||||
ownerAdminUserId: values.ownerAdminUserId,
|
||||
platformKey: values.platformKey,
|
||||
quantity: values.quantity,
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(`已调整,余额 ${result.balanceAfter} 积分`)
|
||||
setAdjustOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '调整失败')
|
||||
},
|
||||
})
|
||||
|
||||
const accounts = accountsQuery.data?.data.items || []
|
||||
const platforms = accountsQuery.data?.data.platforms || []
|
||||
const entries = entriesQuery.data?.data.items || []
|
||||
|
||||
const entryColumns: TableColumnsType<PlatformCreditEntry> = [
|
||||
{
|
||||
title: '客服',
|
||||
dataIndex: 'ownerName',
|
||||
width: 110,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platformKey',
|
||||
width: 100,
|
||||
render: (value: string, row) => (
|
||||
<Tag color={PLATFORM_TAG_COLORS[value] || 'default'}>{row.platformLabel}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entryType',
|
||||
width: 80,
|
||||
render: (value: string) => (
|
||||
<Tag color={ENTRY_TYPE_COLORS[value] || 'default'}>{ENTRY_TYPE_LABELS[value] || value}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'quantityDelta',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value: number) =>
|
||||
value > 0 ? (
|
||||
<Typography.Text style={{ color: '#389e0d' }}>+{value}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text style={{ color: '#cf1322' }}>{value}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balanceAfter',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="平台积分钱包">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="南航 / 乐玩 / ROCK 三平台积分互不通用,每平台一个钱包"
|
||||
description="换算率按平台:南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元。余额只能由流水产生,消耗不足会直接拒绝,不允许负余额。备用金现金不在这里,走「我的备用金」。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
{platforms.map((platform) => {
|
||||
const total = accounts
|
||||
.filter((item) => item.platformKey === platform.key)
|
||||
.reduce((sum, item) => sum + item.balanceQuantity, 0)
|
||||
return (
|
||||
<Col span={8} key={platform.key}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={`${platform.label}总积分`}
|
||||
value={total}
|
||||
suffix="积分"
|
||||
prefix={
|
||||
<Tag color={PLATFORM_TAG_COLORS[platform.key] || 'default'}>
|
||||
1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
grantForm.resetFields()
|
||||
setGrantOpen(true)
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
adjustForm.resetFields()
|
||||
setAdjustOpen(true)
|
||||
}}
|
||||
>
|
||||
调整余额
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select
|
||||
value={platformFilter}
|
||||
allowClear
|
||||
placeholder="全部平台"
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setPlatformFilter(value || '')}
|
||||
options={platforms.map((item) => ({ value: item.key, label: item.label }))}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void accountsQuery.refetch()
|
||||
void entriesQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={entryColumns}
|
||||
dataSource={entries}
|
||||
loading={entriesQuery.isLoading}
|
||||
scroll={{ x: 980 }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={grantOpen}
|
||||
title="充值积分"
|
||||
okText="确认充值"
|
||||
cancelText="取消"
|
||||
confirmLoading={grantMutation.isPending}
|
||||
onOk={() => {
|
||||
void grantForm.validateFields().then((values) => grantMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setGrantOpen(false)}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
<Form<GrantFormValues> form={grantForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="ownerAdminUserId"
|
||||
label="客服"
|
||||
rules={[{ required: true, message: '请选择客服' }]}
|
||||
extra="客服 ID(管理员用户 id)"
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="平台"
|
||||
rules={[{ required: true, message: '请选择平台' }]}
|
||||
>
|
||||
<Select
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="积分数"
|
||||
rules={[{ required: true, message: '请输入积分数' }]}
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="platformRef" label="平台流水号">
|
||||
<Input placeholder="平台侧充值流水号" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={adjustOpen}
|
||||
title="调整积分余额"
|
||||
okText="确认调整"
|
||||
cancelText="取消"
|
||||
confirmLoading={adjustMutation.isPending}
|
||||
onOk={() => {
|
||||
void adjustForm.validateFields().then((values) => adjustMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setAdjustOpen(false)}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
<Form<AdjustFormValues> form={adjustForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="ownerAdminUserId"
|
||||
label="客服"
|
||||
rules={[{ required: true, message: '请选择客服' }]}
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="平台"
|
||||
rules={[{ required: true, message: '请选择平台' }]}
|
||||
>
|
||||
<Select options={platforms.map((item) => ({ value: item.key, label: item.label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="调整积分数"
|
||||
rules={[{ required: true, message: '请输入调整值' }]}
|
||||
extra="正数增加,负数扣减;调整后余额不能为负。"
|
||||
>
|
||||
<InputNumber precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="调整原因"
|
||||
rules={[{ required: true, message: '请填写调整原因' }]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user