feat(fulfillment): 增加统一结算与多渠道人工履约
This commit is contained in:
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user