Files
order_site/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx
T

387 lines
14 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.
/**
* 确认人工履约弹窗(订单列表 / 订单详情共用)。
*
* 口径(2026-10-24):
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
* * 固定成本 → 直接确认;
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
* * 确认后扣对应平台积分/备用金并写成本快照。
*
* 原「人工履约待办」页面已下线,入口收敛到订单界面。
*/
import { CheckOutlined } from '@ant-design/icons'
import {
Alert,
App,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import {
confirmAdminManualFulfillment,
fetchAdminFundAccounts,
fetchAdminManualFulfillmentPending,
fetchAdminPlatformCreditOwners,
} from '@/services/admin'
import type { ManualConfiguredChannel, ManualFulfillmentPendingItem } from '@/types/admin'
import { getAdminRole, getAdminUserId } from '@/utils/admin-auth'
type ConfirmFormValues = {
costYuan?: number
payeeName?: string
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
}
export type ManualFulfillmentConfirmModalProps = {
open: boolean
orderId: number | null
initialOrderItemId?: number | null
onClose: () => void
onConfirmed?: () => void
}
export function ManualFulfillmentConfirmModal({
open,
orderId,
initialOrderItemId = null,
onClose,
onConfirmed,
}: ManualFulfillmentConfirmModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfirmFormValues>()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [selectedPlatformKey, setSelectedPlatformKey] = useState<string | null>(null)
const [fundAccountId, setFundAccountId] = useState<number | null>(null)
const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState<number | null>(null)
const role = getAdminRole()
const privileged = role !== 'support'
const currentUserId = getAdminUserId()
const pendingQuery = useQuery({
queryKey: ['admin-manual-fulfillment-pending', 'order', orderId],
queryFn: () =>
fetchAdminManualFulfillmentPending({
orderId: orderId || undefined,
status: 'pending',
page: 1,
pageSize: 20,
}),
enabled: open && Boolean(orderId),
})
const items = pendingQuery.data?.data.items || []
const selected =
items.find((item) => item.orderItemId === selectedId) ||
items.find((item) => item.orderItemId === initialOrderItemId) ||
items[0] ||
null
const configuredChannels = selected?.channels || []
const selectedChannel: ManualConfiguredChannel | null =
configuredChannels.find((channel) => channel.platformKey === selectedPlatformKey) ||
configuredChannels[0] ||
null
const fundAccountsQuery = useQuery({
queryKey: ['admin-manual-confirm-fund-accounts'],
queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }),
enabled: open && privileged && selectedChannel?.isFundCash === true,
retry: false,
})
const creditOwnersQuery = useQuery({
queryKey: ['admin-manual-confirm-credit-owners'],
queryFn: () => fetchAdminPlatformCreditOwners(),
enabled:
open &&
privileged &&
Boolean(selectedChannel) &&
selectedChannel?.isFundCash !== true &&
selectedChannel?.isOnAccount !== true,
retry: false,
})
useEffect(() => {
if (!open) return
setSelectedId(initialOrderItemId || null)
setSelectedPlatformKey(null)
form.resetFields()
}, [open, initialOrderItemId, form])
useEffect(() => {
if (!open || !selected) return
setSelectedPlatformKey((current) => {
if (current && selected.channels.some((channel) => channel.platformKey === current)) {
return current
}
return selected.channels[0]?.platformKey || selected.platformKey || null
})
}, [open, selected?.orderItemId, selected?.channels, selected?.platformKey])
// 代录时默认选本人账户;客服没有代录权限,不展示选择。
useEffect(() => {
if (!open || !privileged) return
if (selectedChannel?.isFundCash) {
const accounts = (fundAccountsQuery.data?.data.items || []).filter(
(item) => item.accountType === 'agent' && item.status === 'active',
)
const own = accounts.find((item) => item.ownerAdminUserId === currentUserId)
setFundAccountId(own?.id ?? null)
return
}
if (selectedChannel?.isOnAccount) {
setCreditOwnerAdminUserId(null)
setFundAccountId(null)
return
}
const owners = creditOwnersQuery.data?.data.items || []
const own = owners.find((item) => item.id === currentUserId)
setCreditOwnerAdminUserId(own?.id ?? null)
}, [
open,
privileged,
selected?.orderItemId,
selectedChannel?.isFundCash,
selectedChannel?.isOnAccount,
currentUserId,
fundAccountsQuery.data,
creditOwnersQuery.data,
])
const confirmMutation = useMutation({
mutationFn: (values: ConfirmFormValues) =>
confirmAdminManualFulfillment({
orderItemId: selected?.orderItemId || 0,
platformKey: selectedChannel?.platformKey || selected?.platformKey || '',
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
payeeName: values.payeeName || '',
note: values.note || '',
...(privileged && selectedChannel?.isFundCash && fundAccountId ? { fundAccountId } : {}),
...(privileged &&
selectedChannel &&
!selectedChannel.isFundCash &&
!selectedChannel.isOnAccount &&
creditOwnerAdminUserId
? { creditOwnerAdminUserId }
: {}),
}),
onSuccess: (response) => {
const result = response.data
message.success(
result.onAccountBillId
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
onConfirmed?.()
onClose()
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '确认履约失败')
},
})
const showCostInput = selectedChannel?.costFen === null
const costRequired = showCostInput && selectedChannel?.isOnAccount !== true
return (
<Modal
open={open}
title="确认人工履约"
okText="确认履约"
okButtonProps={{
icon: <CheckOutlined />,
disabled:
!selected ||
!selectedChannel ||
(privileged &&
((selectedChannel.isFundCash && !fundAccountId) ||
(!selectedChannel.isFundCash &&
!selectedChannel.isOnAccount &&
!creditOwnerAdminUserId))),
}}
cancelText="取消"
confirmLoading={confirmMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => confirmMutation.mutate(values))
}}
onCancel={onClose}
destroyOnHidden
width={560}
>
{pendingQuery.isLoading ? (
<Typography.Text type="secondary">正在读取待履约商品…</Typography.Text>
) : items.length === 0 ? (
<Alert
type="warning"
showIcon
message="该订单没有待确认的人工履约项"
description="可能已履约,或该商品还没有在「商品与成本 → 人工 SKU 配置」里配置履约渠道。"
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{items.length > 1 ? (
<Select
value={selected?.orderItemId}
onChange={(value) => setSelectedId(value)}
options={items.map((item) => ({
value: item.orderItemId,
label: `${item.skuName || item.skuCode} · ${item.internalSku || item.skuCode}`,
}))}
style={{ width: '100%' }}
/>
) : null}
<div>
<Typography.Text type="secondary">商品:</Typography.Text>
<Typography.Text strong>
{selected?.skuName || selected?.skuCode || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
<Typography.Text copyable={{ text: selected?.internalSku || '' }}>
{selected?.internalSku || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">本次实际履约渠道:</Typography.Text>
{selected && configuredChannels.length > 1 ? (
<Select
value={selectedChannel?.platformKey}
onChange={(value) => {
setSelectedPlatformKey(value)
form.setFieldValue('costYuan', undefined)
}}
options={configuredChannels.map((channel) => ({
value: channel.platformKey,
label: channel.platformLabel,
}))}
style={{ width: '100%', marginTop: 6 }}
/>
) : (
<Tag color={PLATFORM_TAG_COLORS[selectedChannel?.platformKey || ''] || 'default'}>
{selectedChannel?.platformLabel || '-'}
</Tag>
)}
{selectedChannel && !selectedChannel.isFundCash && !selectedChannel.isOnAccount ? (
<Typography.Text>消耗 {selectedChannel.creditQuantity} 积分</Typography.Text>
) : null}
{selectedChannel?.isOnAccount ? (
<Typography.Text>只生成虚拟账单,不扣积分或备用金</Typography.Text>
) : null}
</div>
<div>
<Typography.Text type="secondary">订单金额:</Typography.Text>
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
</div>
{privileged && selected && selectedChannel ? (
selectedChannel.isFundCash ? (
<div>
<Typography.Text type="secondary">代录到客服备用金账户:</Typography.Text>
<Select
showSearch
optionFilterProp="label"
value={fundAccountId ?? undefined}
placeholder="请选择客服备用金账户"
style={{ width: '100%', marginTop: 6 }}
loading={fundAccountsQuery.isLoading}
onChange={(value) => setFundAccountId(value)}
options={(fundAccountsQuery.data?.data.items || [])
.filter((item) => item.accountType === 'agent' && item.status === 'active')
.map((item) => ({
value: item.id,
label: `${item.ownerName || item.accountNo} · 余额 ¥${(item.balanceFen / 100).toFixed(2)}`,
}))}
/>
</div>
) : selectedChannel.isOnAccount ? null : (
<div>
<Typography.Text type="secondary">代录到客服积分账户:</Typography.Text>
<Select
showSearch
optionFilterProp="label"
value={creditOwnerAdminUserId ?? undefined}
placeholder="请选择客服账号"
style={{ width: '100%', marginTop: 6 }}
loading={creditOwnersQuery.isLoading}
onChange={(value) => setCreditOwnerAdminUserId(value)}
options={(creditOwnersQuery.data?.data.items || []).map((item) => ({
value: item.id,
label: item.displayName || item.username,
}))}
/>
</div>
)
) : null}
{showCostInput ? (
<Alert
type="warning"
showIcon
message={
selectedChannel?.isOnAccount
? '统一结算金额可以先留空,后续统一对账时补录'
: '该商品成本不固定,请填写本次实际成本'
}
/>
) : (
<Statistic
title="本次成本"
value={(selectedChannel?.costFen || 0) / 100}
precision={2}
prefix="¥"
/>
)}
<Form<ConfirmFormValues> form={form} layout="vertical">
{showCostInput ? (
<Form.Item
name="costYuan"
label={selectedChannel?.isOnAccount ? '预计结算金额(元)' : '实际成本(元)'}
rules={costRequired ? [{ required: true, message: '请填写实际成本' }] : []}
>
<InputNumber
min={selectedChannel?.isOnAccount ? 0 : 0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
) : null}
<Form.Item name="payeeName" label="收款方 / 处理人">
<Input placeholder="例如:客服小王、上游商家" maxLength={120} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Space>
)}
</Modal>
)
}