feat(fulfillment): 增加统一结算与多渠道人工履约
This commit is contained in:
@@ -32,7 +32,7 @@ import {
|
||||
fetchAdminManualFulfillmentPending,
|
||||
fetchAdminPlatformCreditOwners,
|
||||
} from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import type { ManualConfiguredChannel, ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import { getAdminRole, getAdminUserId } from '@/utils/admin-auth'
|
||||
|
||||
type ConfirmFormValues = {
|
||||
@@ -67,6 +67,7 @@ export function ManualFulfillmentConfirmModal({
|
||||
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()
|
||||
@@ -91,30 +92,51 @@ export function ManualFulfillmentConfirmModal({
|
||||
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 && Boolean(selected?.isFundCash),
|
||||
enabled: open && privileged && selectedChannel?.isFundCash === true,
|
||||
retry: false,
|
||||
})
|
||||
const creditOwnersQuery = useQuery({
|
||||
queryKey: ['admin-manual-confirm-credit-owners'],
|
||||
queryFn: () => fetchAdminPlatformCreditOwners(),
|
||||
enabled: open && privileged && Boolean(selected) && !selected?.isFundCash,
|
||||
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 (selected?.isFundCash) {
|
||||
if (selectedChannel?.isFundCash) {
|
||||
const accounts = (fundAccountsQuery.data?.data.items || []).filter(
|
||||
(item) => item.accountType === 'agent' && item.status === 'active',
|
||||
)
|
||||
@@ -122,6 +144,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
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)
|
||||
@@ -129,7 +156,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
open,
|
||||
privileged,
|
||||
selected?.orderItemId,
|
||||
selected?.isFundCash,
|
||||
selectedChannel?.isFundCash,
|
||||
selectedChannel?.isOnAccount,
|
||||
currentUserId,
|
||||
fundAccountsQuery.data,
|
||||
creditOwnersQuery.data,
|
||||
@@ -139,18 +167,25 @@ export function ManualFulfillmentConfirmModal({
|
||||
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 && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||||
...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId
|
||||
...(privileged && selectedChannel?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||||
...(privileged &&
|
||||
selectedChannel &&
|
||||
!selectedChannel.isFundCash &&
|
||||
!selectedChannel.isOnAccount &&
|
||||
creditOwnerAdminUserId
|
||||
? { creditOwnerAdminUserId }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
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'] })
|
||||
@@ -167,7 +202,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
},
|
||||
})
|
||||
|
||||
const showCostInput = selected?.costFen === null
|
||||
const showCostInput = selectedChannel?.costFen === null
|
||||
const costRequired = showCostInput && selectedChannel?.isOnAccount !== true
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -178,9 +214,12 @@ export function ManualFulfillmentConfirmModal({
|
||||
icon: <CheckOutlined />,
|
||||
disabled:
|
||||
!selected ||
|
||||
!selectedChannel ||
|
||||
(privileged &&
|
||||
((selected.isFundCash && !fundAccountId) ||
|
||||
(!selected.isFundCash && !creditOwnerAdminUserId))),
|
||||
((selectedChannel.isFundCash && !fundAccountId) ||
|
||||
(!selectedChannel.isFundCash &&
|
||||
!selectedChannel.isOnAccount &&
|
||||
!creditOwnerAdminUserId))),
|
||||
}}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmMutation.isPending}
|
||||
@@ -227,12 +266,30 @@ export function ManualFulfillmentConfirmModal({
|
||||
</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>
|
||||
<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>
|
||||
@@ -240,8 +297,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{privileged && selected ? (
|
||||
selected.isFundCash ? (
|
||||
{privileged && selected && selectedChannel ? (
|
||||
selectedChannel.isFundCash ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服备用金账户:</Typography.Text>
|
||||
<Select
|
||||
@@ -260,7 +317,7 @@ export function ManualFulfillmentConfirmModal({
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : selectedChannel.isOnAccount ? null : (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服积分账户:</Typography.Text>
|
||||
<Select
|
||||
@@ -281,11 +338,19 @@ export function ManualFulfillmentConfirmModal({
|
||||
) : null}
|
||||
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
selectedChannel?.isOnAccount
|
||||
? '统一结算金额可以先留空,后续统一对账时补录'
|
||||
: '该商品成本不固定,请填写本次实际成本'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Statistic
|
||||
title="本次成本"
|
||||
value={(selected?.costFen || 0) / 100}
|
||||
value={(selectedChannel?.costFen || 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
@@ -295,11 +360,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
{showCostInput ? (
|
||||
<Form.Item
|
||||
name="costYuan"
|
||||
label="实际成本(元)"
|
||||
rules={[{ required: true, message: '请填写实际成本' }]}
|
||||
label={selectedChannel?.isOnAccount ? '预计结算金额(元)' : '实际成本(元)'}
|
||||
rules={costRequired ? [{ required: true, message: '请填写实际成本' }] : []}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
min={selectedChannel?.isOnAccount ? 0 : 0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
|
||||
@@ -68,6 +68,7 @@ const CHANNEL_OPTIONS = [
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
]
|
||||
|
||||
const CHANNEL_COLOR: Record<string, string> = {
|
||||
@@ -75,6 +76,7 @@ const CHANNEL_COLOR: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'purple',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
const CHANNEL_SOURCE_LABEL: Record<string, string> = {
|
||||
@@ -133,6 +135,7 @@ function PendingPanel() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
const [internalSkuDrafts, setInternalSkuDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword, page, pageSize],
|
||||
@@ -142,16 +145,26 @@ function PendingPanel() {
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
assignChannelByInternalSku({
|
||||
internalSku: row.internal_sku,
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
mappedInternalSku: row.internal_sku
|
||||
? ''
|
||||
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const productMessage = result.data.draftProductId ? '和标准商品' : ''
|
||||
if (result.data.carrierFailed > 0) {
|
||||
const firstFailure = result.data.carrierFailures?.[0]
|
||||
message.warning(
|
||||
`已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`,
|
||||
`渠道${productMessage}已保存,${result.data.carrierFailed} 个订单项仍待补配置${
|
||||
firstFailure?.message ? `:${firstFailure.message}` : ''
|
||||
}`,
|
||||
)
|
||||
} else {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
message.success(
|
||||
`已指定 ${result.data.updated} 个订单项${productMessage ? `并创建${productMessage.slice(1)}` : ''}`,
|
||||
)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
@@ -183,7 +196,7 @@ function PendingPanel() {
|
||||
|
||||
const retryCarrierMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
retryPendingCarriers({ internalSku: row.internal_sku }),
|
||||
retryPendingCarriers({ internalSku: row.internal_sku || '' }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
const detail = [
|
||||
@@ -205,11 +218,11 @@ function PendingPanel() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
deletePendingChannelGroup({ internalSku: row.internal_sku }),
|
||||
deletePendingChannelGroup({ groupKey: row.group_key }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
message.success(
|
||||
`已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
`已忽略归档 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] })
|
||||
@@ -261,9 +274,23 @@ function PendingPanel() {
|
||||
title: '内部SKU',
|
||||
dataIndex: 'internal_sku',
|
||||
width: 220,
|
||||
render: (value: string, row: PendingChannelGroup) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text delete={!value}>{value || '未识别内部 SKU'}</Typography.Text>
|
||||
render: (value: string | null, row: PendingChannelGroup) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
{value ? (
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="填写内部 SKU"
|
||||
value={internalSkuDrafts[rowKey(row)] || ''}
|
||||
onChange={(event) =>
|
||||
setInternalSkuDrafts((current) => ({
|
||||
...current,
|
||||
[rowKey(row)]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{row.configured_channels?.length ? (
|
||||
<Space size={2} wrap>
|
||||
{row.configured_channels.map((channel) => (
|
||||
@@ -345,7 +372,9 @@ function PendingPanel() {
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择渠道"
|
||||
placeholder={
|
||||
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
|
||||
}
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
@@ -353,11 +382,14 @@ function PendingPanel() {
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!selections[rowKey(row)]}
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
指定
|
||||
保存渠道
|
||||
</Button>
|
||||
{row.configured_channels?.length ? (
|
||||
<Tooltip title="重新确认渠道并补建工单或自动发货任务">
|
||||
@@ -371,26 +403,26 @@ function PendingPanel() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="删除待分类记录,不删除原始订单">
|
||||
<Tooltip title="从待办中忽略并归档,不删除原始订单">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '删除待分类记录',
|
||||
title: '忽略并归档待分类记录',
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的成本快照、
|
||||
分类异常,重算对应销售事实与总账。
|
||||
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
|
||||
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
原始订单和订单商品不会被删除;已关账期间禁止删除。
|
||||
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
okText: '确认归档',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(row),
|
||||
@@ -441,7 +473,7 @@ function PendingPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
|
||||
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
@@ -1196,7 +1228,7 @@ function PipelinesPanel() {
|
||||
}
|
||||
|
||||
function rowKey(row: PendingChannelGroup) {
|
||||
return row.internal_sku
|
||||
return row.group_key
|
||||
}
|
||||
|
||||
function formatCarrierParams(row: ChannelRule) {
|
||||
|
||||
@@ -40,6 +40,7 @@ const FULFILLMENT_MODE_OPTIONS = [
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
{ value: 'unclassified', label: '未分类' },
|
||||
{ value: 'archived', label: '历史归档' },
|
||||
]
|
||||
@@ -49,6 +50,7 @@ const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ScanOutlined,
|
||||
SyncOutlined,
|
||||
@@ -50,14 +52,21 @@ import {
|
||||
saveAdminManualConfig,
|
||||
scanAdminManualSkus,
|
||||
} from '@/services/admin'
|
||||
import type { ManualSkuItem } from '@/types/admin'
|
||||
import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import {
|
||||
formatCreditQuantity,
|
||||
formatCreditUnitPriceLabel,
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '@/utils/credit-unit-price'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
type ConfigFormValues = {
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
}>
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -66,13 +75,21 @@ const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
fund_cash: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
/** 按换算率把「消耗单位」翻译成人话。 */
|
||||
function unitLabel(platformKey: string, creditUnitPriceFen: number) {
|
||||
if (platformKey === 'fund_cash') return '元'
|
||||
if (creditUnitPriceFen >= 100) return '积分(1积分=1元)'
|
||||
return '积分(1积分=1分)'
|
||||
function isCashChannel(platformKey: string) {
|
||||
return platformKey === 'fund_cash' || platformKey === 'on_account'
|
||||
}
|
||||
|
||||
function channelQuantityText(platformKey: string, creditQuantity: number) {
|
||||
const quantity = formatCreditQuantity(creditQuantity)
|
||||
return isCashChannel(platformKey) ? `¥${quantity}` : `${quantity} 积分`
|
||||
}
|
||||
|
||||
function platformOptionLabel(platformKey: string, label: string, creditUnitPriceFen: number) {
|
||||
if (isCashChannel(platformKey)) return `${label} · 按元填写`
|
||||
return `${label} · ${formatCreditUnitPriceLabel(creditUnitPriceFen)}`
|
||||
}
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
@@ -139,8 +156,10 @@ export function ManualSkuConfigPanel() {
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
platformKey: values.platformKey,
|
||||
creditQuantity: values.creditQuantity,
|
||||
channels: values.channels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
})),
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
@@ -191,9 +210,21 @@ export function ManualSkuConfigPanel() {
|
||||
|
||||
function openConfig(item: ManualSkuItem) {
|
||||
setEditing(item)
|
||||
const configuredChannels = item.config?.channels?.length
|
||||
? item.config.channels
|
||||
: item.config
|
||||
? [
|
||||
{
|
||||
platformKey: item.config.platformKey,
|
||||
creditQuantity: item.config.creditQuantity,
|
||||
},
|
||||
]
|
||||
: [{ platformKey: '', creditQuantity: 0 }]
|
||||
form.setFieldsValue({
|
||||
platformKey: item.config?.platformKey || undefined,
|
||||
creditQuantity: item.config?.creditQuantity ?? undefined,
|
||||
channels: configuredChannels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
})),
|
||||
note: item.config?.note || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
@@ -229,20 +260,37 @@ export function ManualSkuConfigPanel() {
|
||||
title: '履约渠道',
|
||||
key: 'platform',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
render: (_, row) => {
|
||||
if (!row.config) {
|
||||
return <Typography.Text type="secondary">未配置</Typography.Text>
|
||||
}
|
||||
const channels = row.config.channels?.length
|
||||
? row.config.channels
|
||||
: [
|
||||
{
|
||||
platformKey: row.config.platformKey,
|
||||
platformLabel: row.config.platformLabel,
|
||||
creditQuantity: row.config.creditQuantity,
|
||||
creditUnitPriceFen: row.config.creditUnitPriceFen,
|
||||
costFen: row.config.costFen,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<Space size={4} direction="vertical">
|
||||
<Tag color={PLATFORM_TAG_COLORS[row.config.platformKey] || 'default'}>
|
||||
{row.config.platformLabel || row.config.platformKey}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.config.creditQuantity}{' '}
|
||||
{unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)}
|
||||
</Typography.Text>
|
||||
{channels.map((channel) => (
|
||||
<div key={`${row.id}-${channel.platformKey}`}>
|
||||
<Tag color={PLATFORM_TAG_COLORS[channel.platformKey] || 'default'}>
|
||||
{channel.platformLabel}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{channelQuantityText(channel.platformKey, channel.creditQuantity)}
|
||||
{' · '}¥{((channel.costFen || 0) / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未配置</Typography.Text>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
@@ -251,7 +299,9 @@ export function ManualSkuConfigPanel() {
|
||||
align: 'right',
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
row.config.costMode === 'fixed' ? (
|
||||
(row.config.channels?.length || 0) > 1 ? (
|
||||
<Tag color="blue">按实际渠道</Tag>
|
||||
) : row.config.costMode === 'fixed' ? (
|
||||
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
|
||||
) : (
|
||||
<Tag color="orange">不固定·客服填</Tag>
|
||||
@@ -286,11 +336,10 @@ export function ManualSkuConfigPanel() {
|
||||
},
|
||||
]
|
||||
|
||||
const selectedPlatform = form.getFieldValue('platformKey')
|
||||
const selectedCredit = form.getFieldValue('creditQuantity')
|
||||
const previewUnitPrice = platformMap.get(selectedPlatform)?.creditUnitPriceFen ?? 0
|
||||
const previewCostFen =
|
||||
selectedPlatform && Number(selectedCredit) > 0 ? Number(selectedCredit) * previewUnitPrice : 0
|
||||
function previewChannelCost(platformKey: string, creditQuantity: number) {
|
||||
const unitPrice = platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
return Number(creditQuantity) > 0 ? Math.round(Number(creditQuantity) * unitPrice) : 0
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
@@ -426,38 +475,138 @@ export function ManualSkuConfigPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
<Form.List
|
||||
name="channels"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, channels) => {
|
||||
if (!Array.isArray(channels) || channels.length === 0) {
|
||||
throw new Error('请至少配置一个履约渠道')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`,
|
||||
}))}
|
||||
onChange={() => form.validateFields(['creditQuantity'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="creditQuantity"
|
||||
label="每单消耗积分"
|
||||
rules={[{ required: true, message: '请输入消耗积分' }]}
|
||||
extra="填 0 表示成本不固定,客服确认履约时手填实际成本。"
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="例如 10" />
|
||||
</Form.Item>
|
||||
<Form.Item label="折算成本">
|
||||
<Typography.Text strong>
|
||||
{previewCostFen > 0
|
||||
? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)`
|
||||
: '成本不固定,由客服手填'}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key} className="manual-channel-config-item">
|
||||
<div className="manual-channel-config-head">
|
||||
<Typography.Text strong>履约渠道 {index + 1}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
title="移除此渠道"
|
||||
aria-label={`移除履约渠道 ${index + 1}`}
|
||||
icon={<MinusCircleOutlined />}
|
||||
disabled={fields.length <= 1}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[field.name, 'platformKey']}
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择履约渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: platformOptionLabel(item.key, item.label, item.creditUnitPriceFen),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const platformKey = String(
|
||||
form.getFieldValue(['channels', field.name, 'platformKey']) || '',
|
||||
)
|
||||
const creditQuantity = Number(
|
||||
form.getFieldValue(['channels', field.name, 'creditQuantity']) || 0,
|
||||
)
|
||||
const cashChannel = isCashChannel(platformKey)
|
||||
const unitPriceFen =
|
||||
platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
const costFen = previewChannelCost(platformKey, creditQuantity)
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name={[field.name, 'creditQuantity']}
|
||||
label={
|
||||
!platformKey
|
||||
? '消耗 / 金额'
|
||||
: cashChannel
|
||||
? '每单金额'
|
||||
: '每单消耗积分'
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入消耗或金额' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const quantity = Number(value)
|
||||
if (!Number.isFinite(quantity) || quantity < 0) {
|
||||
throw new Error('请输入大于等于 0 的数字')
|
||||
}
|
||||
if (!cashChannel && !Number.isInteger(quantity)) {
|
||||
throw new Error('积分必须是整数')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
platformKey
|
||||
? cashChannel
|
||||
? '单位:元,最多两位小数'
|
||||
: `换算率:${formatCreditUnitPriceLabel(unitPriceFen)}`
|
||||
: '积分填整数,现金和统一结算填金额'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={cashChannel ? 2 : 0}
|
||||
step={cashChannel ? 0.01 : 1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={cashChannel ? '例如 12.50' : '例如 10'}
|
||||
addonAfter={
|
||||
platformKey ? (cashChannel ? '元' : '积分') : undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="折算成本" style={{ marginBottom: 0 }}>
|
||||
<div className="manual-channel-cost-preview">
|
||||
<Typography.Text strong={costFen > 0}>
|
||||
{costFen > 0
|
||||
? `¥${(costFen / 100).toFixed(2)}`
|
||||
: '成本不固定,由客服确认时填写'}
|
||||
</Typography.Text>
|
||||
{platformKey && !cashChannel ? (
|
||||
<Typography.Text type="secondary">
|
||||
1积分={formatCreditUnitPriceYuan(unitPriceFen)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
|
||||
添加履约渠道
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
|
||||
@@ -130,6 +130,7 @@ export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
@@ -139,6 +140,7 @@ export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'cyan',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
archived: 'default',
|
||||
unclassified: 'gold',
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
@@ -387,7 +388,7 @@ export default function FundSupportView() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="我的钱包"
|
||||
description="四个平台钱包:南航 / 乐玩 / ROCK 积分与备用金现金。余额由流水自动计算,不能手工修改。"
|
||||
description="五种履约方式:南航 / 乐玩 / ROCK 积分、备用金现金、统一结算。积分余额与现金余额由流水自动计算,不能手工修改。"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -418,51 +419,61 @@ export default function FundSupportView() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<div className="support-wallet-grid">
|
||||
{myCreditWallets.map((wallet) => (
|
||||
<Col xs={24} sm={12} lg={6} key={wallet.platform.key}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
1积分=
|
||||
{wallet.platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Card key={wallet.platform.key}>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
{formatCreditUnitPriceLabel(wallet.platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Card>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>统一结算</span>
|
||||
<Tag color="gold">后对账</Tag>
|
||||
</Space>
|
||||
}
|
||||
value="按单生成"
|
||||
valueStyle={{ color: '#8c6d1f', fontSize: 22 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生成虚拟账单,不扣积分或备用金
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { PlatformCreditAccount, PlatformCreditEntry, PlatformCreditOwner }
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
@@ -342,7 +343,7 @@ export function PlatformCreditPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="南航 / 乐玩 / ROCK 三平台积分互不通用,每平台一个钱包"
|
||||
description="换算率按平台:南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
description="换算率按平台展示为人民币金额;南航、乐玩默认 1 积分 = 0.01 元,ROCK 默认 1 积分 = 1.00 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
@@ -359,7 +360,7 @@ export function PlatformCreditPanel() {
|
||||
suffix="积分"
|
||||
prefix={
|
||||
<Tag color={PLATFORM_TAG_COLORS[platform.key] || 'default'}>
|
||||
1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
{formatCreditUnitPriceLabel(platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
@@ -459,7 +460,7 @@ export function PlatformCreditPanel() {
|
||||
<Select
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`,
|
||||
label: `${item.label} · ${formatCreditUnitPriceLabel(item.creditUnitPriceFen)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -38,6 +38,7 @@ const CHANNEL_LABEL: Record<VoucherConsumeChannel, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
work_order: '接单工单',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
third_party_voucher: '第三方卡券',
|
||||
}
|
||||
|
||||
@@ -68,6 +69,10 @@ const MODE_OPTIONS: Record<
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
on_account: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
}
|
||||
|
||||
type PolicyRow = {
|
||||
|
||||
@@ -378,8 +378,11 @@ export function scanAdminManualSkus(payload: { fromDate: string; toDate: string
|
||||
|
||||
export function saveAdminManualConfig(payload: {
|
||||
skuKey: string
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
enabled?: boolean
|
||||
}>
|
||||
canonicalProductId?: number | null
|
||||
enabled?: boolean
|
||||
note?: string
|
||||
@@ -493,6 +496,7 @@ export function fetchAdminManualFulfillmentPending(
|
||||
|
||||
export function confirmAdminManualFulfillment(payload: {
|
||||
orderItemId: number
|
||||
platformKey: string
|
||||
costFen?: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
|
||||
@@ -2,7 +2,9 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/** 待分类队列(按内部 SKU 聚合)。 */
|
||||
export type PendingChannelGroup = {
|
||||
internal_sku: string
|
||||
group_key: string
|
||||
scope_type: 'internal_sku' | 'source_sku'
|
||||
internal_sku: string | null
|
||||
sku_name: string
|
||||
providers: string[]
|
||||
shop_ids: string[]
|
||||
@@ -30,7 +32,7 @@ export type ChannelRule = {
|
||||
id: number
|
||||
match_key_type: 'internal_sku'
|
||||
match_key: string
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' | 'on_account'
|
||||
priority: number
|
||||
enabled: boolean
|
||||
is_fallback: boolean
|
||||
@@ -145,6 +147,7 @@ export type VoucherConsumeChannel =
|
||||
| 'affiliate_dash'
|
||||
| 'work_order'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'third_party_voucher'
|
||||
export type VoucherConsumeMode =
|
||||
| 'manual'
|
||||
@@ -261,7 +264,7 @@ export function retryPendingCarriers(payload: { internalSku: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePendingChannelGroup(payload: { internalSku: string }) {
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string }) {
|
||||
return apiPost<DeletePendingChannelGroupResult>(
|
||||
'/api/v1/admin/fulfillment/pending/delete',
|
||||
payload,
|
||||
@@ -269,14 +272,20 @@ export function deletePendingChannelGroup(payload: { internalSku: string }) {
|
||||
}
|
||||
|
||||
export function assignChannelByInternalSku(payload: {
|
||||
groupKey?: string
|
||||
internalSku: string
|
||||
mappedInternalSku?: string
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>(
|
||||
'/api/v1/admin/fulfillment/assign',
|
||||
payload,
|
||||
)
|
||||
return apiPost<{
|
||||
updated: number
|
||||
carrierFailed: number
|
||||
ruleCreated: boolean
|
||||
channelSaved: boolean
|
||||
draftProductId: number | null
|
||||
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
|
||||
}>('/api/v1/admin/fulfillment/assign', payload)
|
||||
}
|
||||
|
||||
export function assignChannelToItems(payload: {
|
||||
|
||||
@@ -15,6 +15,48 @@
|
||||
linear-gradient(315deg, rgba(31, 41, 55, 0.045), transparent 38%), #f5f7fb;
|
||||
}
|
||||
|
||||
.manual-channel-config-item {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid #e6eaf0;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.manual-channel-config-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.manual-channel-cost-preview {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid #e6eaf0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.manual-channel-cost-preview .ant-typography {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-wallet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.support-wallet-grid > .ant-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: min(940px, 100%);
|
||||
min-height: 560px;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。
|
||||
*/
|
||||
export type FulfillmentChannel = 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
export type FulfillmentChannel =
|
||||
| 'work_order'
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
|
||||
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
|
||||
export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''
|
||||
@@ -371,10 +376,9 @@ export interface CostExceptionSummary {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约渠道:客服人工有四个钱包。
|
||||
* 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。
|
||||
* 人工履约渠道:南航/乐玩/ROCK 积分、备用金现金、统一结算。
|
||||
*/
|
||||
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash'
|
||||
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash' | 'on_account'
|
||||
|
||||
/** 成本模式:fixed=按积分固定;variable=成本不固定,客服确认履约时手填。 */
|
||||
export type ManualCostMode = 'fixed' | 'variable'
|
||||
@@ -424,6 +428,19 @@ export interface ManualFulfillmentConfig {
|
||||
pendingAssigned: number
|
||||
rematchedItems: number
|
||||
}
|
||||
channels: ManualConfiguredChannel[]
|
||||
}
|
||||
|
||||
export interface ManualConfiguredChannel {
|
||||
platformKey: ManualPlatformKey
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
costMode: ManualCostMode
|
||||
costFen: number | null
|
||||
creditUnitPriceFen: number
|
||||
isFundCash: boolean
|
||||
isOnAccount: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ManualSkuListResult {
|
||||
@@ -525,9 +542,10 @@ export interface ManualFulfillmentPendingItem {
|
||||
revenueFen: number
|
||||
paidAt: string
|
||||
configId: number
|
||||
platformKey: PlatformCreditKey | 'fund_cash'
|
||||
platformKey: PlatformCreditKey | 'fund_cash' | 'on_account'
|
||||
platformLabel: string
|
||||
isFundCash: boolean
|
||||
isOnAccount: boolean
|
||||
creditQuantity: number
|
||||
creditUnitPriceFen: number
|
||||
costMode: ManualCostMode
|
||||
@@ -535,6 +553,7 @@ export interface ManualFulfillmentPendingItem {
|
||||
costFen: number | null
|
||||
fulfilled: boolean
|
||||
fulfilledAt: string | null
|
||||
channels: ManualConfiguredChannel[]
|
||||
}
|
||||
|
||||
export interface ManualFulfillmentPendingListResult {
|
||||
@@ -553,4 +572,5 @@ export interface ManualFulfillmentConfirmResult {
|
||||
fundExpenseId: number | null
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
onAccountBillId: number | null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode =
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ export function resolveOrderFulfillmentMode(row: {
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
| 'mixed'
|
||||
@@ -240,6 +241,8 @@ export function resolveOrderFulfillmentMode(row: {
|
||||
return { label: '🗄 历史归档', color: 'default' }
|
||||
case 'manual':
|
||||
return { label: '🙋 人工履约', color: 'orange' }
|
||||
case 'on_account':
|
||||
return { label: '统一结算', color: 'gold' }
|
||||
case 'mixed':
|
||||
return { label: '🔀 混合履约', color: 'geekblue' }
|
||||
default:
|
||||
|
||||
@@ -34,6 +34,7 @@ export const adminOrderFulfillmentModeOptions = [
|
||||
{ label: '接单平台', value: 'work_order' },
|
||||
{ label: '第三方卡券', value: 'third_party_voucher' },
|
||||
{ label: '人工履约', value: 'manual' },
|
||||
{ label: '统一结算', value: 'on_account' },
|
||||
{ label: '混合履约', value: 'mixed' },
|
||||
{ label: '待判定', value: 'pending' },
|
||||
{ label: '历史归档', value: 'archived' },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 分 → 元,用于统一展示积分换算率。 */
|
||||
export function formatCreditUnitPriceYuan(value: number | string | null | undefined) {
|
||||
const fen = Number(value)
|
||||
return `${(Number.isFinite(fen) ? fen / 100 : 0).toFixed(2)}元`
|
||||
}
|
||||
|
||||
export function formatCreditUnitPriceLabel(value: number | string | null | undefined) {
|
||||
return `1积分=${formatCreditUnitPriceYuan(value)}`
|
||||
}
|
||||
|
||||
/** 配置金额最多 2 位小数,去掉无意义的尾随 0。 */
|
||||
export function formatCreditQuantity(value: number | string | null | undefined) {
|
||||
const quantity = Number(value)
|
||||
if (!Number.isFinite(quantity)) return '0'
|
||||
return String(Number(quantity.toFixed(2)))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatCreditQuantity,
|
||||
formatCreditUnitPriceLabel,
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '../src/utils/credit-unit-price.ts'
|
||||
|
||||
test('积分换算率统一按元展示,不再使用“分”', () => {
|
||||
assert.equal(formatCreditUnitPriceYuan(1), '0.01元')
|
||||
assert.equal(formatCreditUnitPriceYuan(100), '1.00元')
|
||||
assert.equal(formatCreditUnitPriceLabel(125), '1积分=1.25元')
|
||||
})
|
||||
|
||||
test('人工履约配置量支持小数并去掉无意义尾零', () => {
|
||||
assert.equal(formatCreditQuantity(1.5), '1.5')
|
||||
assert.equal(formatCreditQuantity('2.2500'), '2.25')
|
||||
assert.equal(formatCreditQuantity(0), '0')
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
test('客服我的钱包展示统一结算虚拟账单入口', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('统一结算'), '钱包页必须展示统一结算卡片')
|
||||
assert.ok(source.includes('生成虚拟账单,不扣积分或备用金'))
|
||||
assert.ok(source.includes('support-wallet-grid'))
|
||||
})
|
||||
Reference in New Issue
Block a user