Files
order_site/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx
T
yml2213 62763ddf0c feat(finance): 支持代录指定客服账户,并上线影子总账/对账/关账/快手平台退款
- 人工确认履约:管理员/财务/运营可传 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。
2026-10-26 13:32:47 +08:00

322 lines
11 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 { 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>
)
}