feat(admin): 收口人工履约入口并修复履约筛选
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 确认人工履约弹窗(订单列表 / 订单详情共用)。
|
||||
*
|
||||
* 口径(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, fetchAdminManualFulfillmentPending } from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
|
||||
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 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
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(initialOrderItemId || null)
|
||||
form.resetFields()
|
||||
}, [open, initialOrderItemId, form])
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (values: ConfirmFormValues) =>
|
||||
confirmAdminManualFulfillment({
|
||||
orderItemId: selected?.orderItemId || 0,
|
||||
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`履约已确认,扣 ${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 = selected?.costFen === null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="确认人工履约"
|
||||
okText="确认履约"
|
||||
okButtonProps={{ icon: <CheckOutlined />, disabled: !selected }}
|
||||
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>
|
||||
<Tag color={PLATFORM_TAG_COLORS[selected?.platformKey || ''] || 'default'}>
|
||||
{selected?.platformLabel || '-'}
|
||||
</Tag>
|
||||
{selected && !selected.isFundCash ? (
|
||||
<Typography.Text>消耗 {selected.creditQuantity} 积分</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">订单金额:</Typography.Text>
|
||||
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
) : (
|
||||
<Statistic
|
||||
title="本次成本"
|
||||
value={(selected?.costFen || 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form<ConfirmFormValues> form={form} layout="vertical">
|
||||
{showCostInput ? (
|
||||
<Form.Item
|
||||
name="costYuan"
|
||||
label="实际成本(元)"
|
||||
rules={[{ required: true, message: '请填写实际成本' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={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>
|
||||
)
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
export type ManualOrderCostItem = {
|
||||
orderItemId: number
|
||||
matchSku?: string
|
||||
skuName?: string
|
||||
skuCode?: string
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
type ManualOrderCostModalProps = {
|
||||
open: boolean
|
||||
orderId: number
|
||||
orderNo: string
|
||||
items: ManualOrderCostItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ManualOrderCostFormValues = {
|
||||
orderItemId: number
|
||||
fundAccountId: number
|
||||
amountYuan: number
|
||||
expenseCategory: string
|
||||
payeeName?: string
|
||||
paymentMethod?: string
|
||||
evidence?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'service_fee', label: '人工服务费' },
|
||||
{ value: 'goods_purchase', label: '商品采购' },
|
||||
{ value: 'compensation', label: '赔付 / 补偿' },
|
||||
{ value: 'refund_advance', label: '垫付退款' },
|
||||
{ value: 'freight', label: '运费' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'bank', label: '银行卡' },
|
||||
{ value: 'cash', label: '现金' },
|
||||
{ value: 'platform_balance', label: '平台余额' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
function itemLabel(item: ManualOrderCostItem) {
|
||||
// 只展示平台判断用的真实 SKU,引流长标题不进录单选项。
|
||||
const sku = item.matchSku || item.skuName || item.skuCode || `订单项 #${item.orderItemId}`
|
||||
return item.quantity && item.quantity > 1 ? `${sku} ×${item.quantity}` : sku
|
||||
}
|
||||
|
||||
export default function ManualOrderCostModal({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
items,
|
||||
onClose,
|
||||
}: ManualOrderCostModalProps) {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-fund-accounts', 'agent'],
|
||||
queryFn: () =>
|
||||
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
|
||||
enabled: open,
|
||||
})
|
||||
const fundAccounts = fundAccountsQuery.data?.data.items || []
|
||||
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
|
||||
const isSupport = getAdminRole() === 'support'
|
||||
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||||
fundAccountId: soleFundAccountId,
|
||||
amountYuan: undefined,
|
||||
expenseCategory: 'service_fee',
|
||||
paymentMethod: 'other',
|
||||
})
|
||||
}, [form, items, open, soleFundAccountId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ManualOrderCostFormValues) =>
|
||||
createAdminManualOrderCost({
|
||||
orderId,
|
||||
orderItemId: values.orderItemId,
|
||||
fundAccountId: values.fundAccountId,
|
||||
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
|
||||
expenseCategory: values.expenseCategory,
|
||||
payeeName: values.payeeName || '内部人工',
|
||||
paymentMethod: values.paymentMethod || 'other',
|
||||
note: values.note || '',
|
||||
evidenceJson: values.evidence
|
||||
? { reference: values.evidence }
|
||||
: { note: values.note || '订单页人工录单' },
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
if (result.warning) {
|
||||
message.warning(result.warning)
|
||||
} else {
|
||||
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '录入人工成本失败')
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields()
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="人工录单 / 录入订单成本"
|
||||
okText="确认录入并计入成本"
|
||||
cancelText="取消"
|
||||
confirmLoading={mutation.isPending}
|
||||
onOk={() => void submit()}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
<Form<ManualOrderCostFormValues>
|
||||
form={form as FormInstance<ManualOrderCostFormValues>}
|
||||
layout="vertical"
|
||||
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="每一单单独录实际金额,不按小时、不按单量套模板"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。"
|
||||
/>
|
||||
<Form.Item label="订单号">
|
||||
<Input value={orderNo} readOnly />
|
||||
</Form.Item>
|
||||
{isSupport ? (
|
||||
<>
|
||||
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
ownFundAccount
|
||||
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ¥${(
|
||||
ownFundAccount.balanceFen / 100
|
||||
).toFixed(2)}`
|
||||
: '当前账号还没有备用金账户'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="fundAccountId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="fundAccountId"
|
||||
label="备用金账户"
|
||||
rules={[{ required: true, message: '请选择备用金账户' }]}
|
||||
extra="人工支出会直接从该客服的备用金余额扣减"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={fundAccountsQuery.isLoading}
|
||||
placeholder="请选择这笔支出从哪个客服备用金账户出"
|
||||
options={fundAccounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: `${account.ownerName} · ${account.accountNo} · 余额 ¥${(
|
||||
account.balanceFen / 100
|
||||
).toFixed(2)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="没有可用的客服备用金账户"
|
||||
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
|
||||
/>
|
||||
) : null}
|
||||
{items.length === 1 ? (
|
||||
<>
|
||||
<Form.Item label="计入订单项">
|
||||
<Input value={itemLabel(items[0])} readOnly />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderItemId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="orderItemId"
|
||||
label="计入订单项"
|
||||
rules={[{ required: true, message: '请选择订单项' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="订单有多个商品,请选择计入哪一项"
|
||||
options={items.map((item) => ({
|
||||
value: item.orderItemId,
|
||||
label: itemLabel(item),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="人工成本金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
prefix="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expenseCategory"
|
||||
label="费用类型"
|
||||
rules={[{ required: true, message: '请选择费用类型' }]}
|
||||
>
|
||||
<Select options={CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="处理人 / 收款方">
|
||||
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentMethod" label="付款方式">
|
||||
<Select options={PAYMENT_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
|
||||
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="这一单实际做了什么、为什么是这个金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user