- 人工确认履约:管理员/财务/运营可传 fundAccountId / creditOwnerAdminUserId 指定客服备用金/积分账户;客服仍强制本人账户 - 新增迁移 112:finance_accounts / journal_entries / journal_lines / periods / reconciliations / reconciliation_items,种子 11 个科目 - 影子总账:销售事实按日生成收入/退款/成本凭证,支持手工凭证、试算平衡; 不接管钱包余额;总账同步接入定时任务与退款重算 - 期间关账/反关账:关账先补凭证并校验试算平衡,closed 期间禁止新增凭证 - 平台对账:JSON 账单导入,按 provider + platform_order_id 匹配订单/退款, 标记 matched/amount_mismatch/missing_order/missing_refund/duplicate - 平台退款:快手行业(91)同意退款 API 接入,成功后落 source=platform; 抖音/拼多多返回 501,保留手工退款 - 新增后台「财务 → 总账与对账」页面(总账/期间/对账三个 Tab) - 集成测试扩展到代录、总账平衡、关账阻断、手工凭证、对账匹配 验证:backend check 844(840 pass / 4 skip);frontend check 172 + build; 迁移 112 在 scratch 库全量执行并重跑幂等;本地生产副本已升级到 112, 销售事实同步 56 条凭证,借贷合计 1,885,351,864 = 1,885,351,864。
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
||
* 确认人工履约弹窗(订单列表 / 订单详情共用)。
|
||
*
|
||
* 口径(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 { 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 [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 fundAccountsQuery = useQuery({
|
||
queryKey: ['admin-manual-confirm-fund-accounts'],
|
||
queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }),
|
||
enabled: open && privileged && Boolean(selected?.isFundCash),
|
||
retry: false,
|
||
})
|
||
const creditOwnersQuery = useQuery({
|
||
queryKey: ['admin-manual-confirm-credit-owners'],
|
||
queryFn: () => fetchAdminPlatformCreditOwners(),
|
||
enabled: open && privileged && Boolean(selected) && !selected?.isFundCash,
|
||
retry: false,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
setSelectedId(initialOrderItemId || null)
|
||
form.resetFields()
|
||
}, [open, initialOrderItemId, form])
|
||
|
||
// 代录时默认选本人账户;客服没有代录权限,不展示选择。
|
||
useEffect(() => {
|
||
if (!open || !privileged) return
|
||
if (selected?.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
|
||
}
|
||
const owners = creditOwnersQuery.data?.data.items || []
|
||
const own = owners.find((item) => item.id === currentUserId)
|
||
setCreditOwnerAdminUserId(own?.id ?? null)
|
||
}, [
|
||
open,
|
||
privileged,
|
||
selected?.orderItemId,
|
||
selected?.isFundCash,
|
||
currentUserId,
|
||
fundAccountsQuery.data,
|
||
creditOwnersQuery.data,
|
||
])
|
||
|
||
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 || '',
|
||
...(privileged && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||
...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId
|
||
? { creditOwnerAdminUserId }
|
||
: {}),
|
||
}),
|
||
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 ||
|
||
(privileged &&
|
||
((selected.isFundCash && !fundAccountId) ||
|
||
(!selected.isFundCash && !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>
|
||
<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>
|
||
|
||
{privileged && selected ? (
|
||
selected.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>
|
||
) : (
|
||
<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="该商品成本不固定,请填写本次实际成本" />
|
||
) : (
|
||
<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>
|
||
)
|
||
}
|