feat(consignment): 保证金支持按规格选项价格乘寄售数量计算

This commit is contained in:
2026-11-08 12:52:24 +08:00
parent ac2411ab6e
commit 01c2517f29
9 changed files with 436 additions and 17 deletions
@@ -79,9 +79,11 @@ type ConfigFormValues = {
nameTemplate: string
deposit: {
enabled: boolean
mode: 'fixed' | 'per_quantity'
mode: 'fixed' | 'per_quantity' | 'per_spec'
fixedAmountYuan: number
unitAmountYuan: number
specKey: string
specPricesYuan: Record<string, number>
}
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
@@ -188,6 +190,10 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
mode: config.deposit.mode,
fixedAmountYuan: yuan(config.deposit.fixedAmountFen),
unitAmountYuan: yuan(config.deposit.unitAmountFen),
specKey: config.deposit.specKey || '',
specPricesYuan: Object.fromEntries(
Object.entries(config.deposit.specPrices || {}).map(([key, value]) => [key, yuan(value)]),
),
},
serviceFee: {
enabled: config.serviceFee.enabled,
@@ -234,7 +240,14 @@ function emptyFormValues(): ConfigFormValues {
missingPricePolicy: 'block',
},
nameTemplate: '',
deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 },
deposit: {
enabled: false,
mode: 'fixed',
fixedAmountYuan: 0,
unitAmountYuan: 0,
specKey: '',
specPricesYuan: {},
},
serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 },
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
@@ -292,6 +305,19 @@ function buildPayload(
mode: values.deposit.enabled ? values.deposit.mode : 'fixed',
fixedAmountFen: values.deposit.enabled ? fen(values.deposit.fixedAmountYuan) : 0,
unitAmountFen: values.deposit.enabled ? fen(values.deposit.unitAmountYuan) : 0,
specKey:
values.deposit.enabled && values.deposit.mode === 'per_spec'
? values.deposit.specKey || ''
: '',
specPrices:
values.deposit.enabled && values.deposit.mode === 'per_spec'
? Object.fromEntries(
Object.entries(values.deposit.specPricesYuan || {}).map(([key, value]) => [
key,
fen(value),
]),
)
: {},
},
serviceFee: {
enabled: values.serviceFee.enabled,
@@ -564,6 +590,11 @@ export default function ConsignmentConfigPanel() {
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual')
const [proposalOpen, setProposalOpen] = useState(false)
// 按数量(规格)保证金:弹窗里选字段 + 配每个选项的每件保证金
const [depositSpecOpen, setDepositSpecOpen] = useState(false)
const [depositSpecDraftKey, setDepositSpecDraftKey] = useState('')
const [depositSpecDraftPrices, setDepositSpecDraftPrices] = useState<Record<string, number>>({})
const [depositBatchPriceYuan, setDepositBatchPriceYuan] = useState<number | null>(null)
const [form] = Form.useForm<ConfigFormValues>()
const profilesQuery = useQuery({
@@ -901,9 +932,60 @@ export default function ConsignmentConfigPanel() {
setIntakeDrafts((current) => [...current, newIntakeField(current)])
}
function openDepositSpecModal() {
const currentKey = String(form.getFieldValue(['deposit', 'specKey']) || '')
const fallbackKey = depositSpecFieldOptions[0]?.value || ''
const nextKey = depositSpecFieldOptions.some((option) => option.value === currentKey)
? currentKey
: fallbackKey
const prices = (form.getFieldValue(['deposit', 'specPricesYuan']) || {}) as Record<
string,
number
>
setDepositSpecDraftKey(nextKey)
setDepositSpecDraftPrices({ ...prices })
setDepositBatchPriceYuan(null)
setDepositSpecOpen(true)
}
function applyDepositBatchPrice() {
if (!depositSpecDraftField) return
const value = Math.max(0, Number(depositBatchPriceYuan || 0))
const next: Record<string, number> = { ...depositSpecDraftPrices }
for (const option of depositSpecDraftField.options) {
if (option.enabled === false) continue
next[option.value] = value
}
setDepositSpecDraftPrices(next)
}
function saveDepositSpecModal() {
if (!depositSpecDraftKey) {
message.warning('请先选择规格字段')
return
}
form.setFieldValue(['deposit', 'specKey'], depositSpecDraftKey)
form.setFieldValue(['deposit', 'specPricesYuan'], { ...depositSpecDraftPrices })
setDepositSpecOpen(false)
}
const priceType = Form.useWatch(['pricing', 'priceType'], form)
const depositEnabled = Form.useWatch(['deposit', 'enabled'], form)
const depositMode = Form.useWatch(['deposit', 'mode'], form)
const depositSpecKeyValue = Form.useWatch(['deposit', 'specKey'], form)
const depositSpecPricesValue = Form.useWatch(['deposit', 'specPricesYuan'], form)
const depositSpecFieldOptions = specDrafts
.filter(
(field) => (field.type === 'select' || field.type === 'radio') && field.options.length > 0,
)
.map((field) => ({ value: field.key, label: `${field.label}(${field.key})` }))
const depositSpecSelectedField = specDrafts.find((field) => field.key === depositSpecKeyValue)
const depositSpecEnabledOptions =
depositSpecSelectedField?.options.filter((option) => option.enabled !== false) || []
const depositSpecPricedCount = depositSpecEnabledOptions.filter(
(option) => Number(depositSpecPricesValue?.[option.value] || 0) > 0,
).length
const depositSpecDraftField = specDrafts.find((field) => field.key === depositSpecDraftKey)
const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form)
const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form)
const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form)
@@ -1595,6 +1677,7 @@ export default function ConsignmentConfigPanel() {
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'per_quantity', label: '按数量' },
{ value: 'per_spec', label: '按数量(规格)' },
]}
/>
</Form.Item>
@@ -1627,7 +1710,51 @@ export default function ConsignmentConfigPanel() {
/>
</Form.Item>
) : null}
{depositEnabled && depositMode === 'per_spec' ? (
<Form.Item label="规格保证金" style={{ marginBottom: 0 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Button
onClick={openDepositSpecModal}
disabled={depositSpecFieldOptions.length === 0}
>
{depositSpecKeyValue ? '重新配置规格价格' : '配置规格价格'}
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
{depositSpecSelectedField
? `${depositSpecSelectedField.label}:已配置 ${depositSpecPricedCount}/${depositSpecEnabledOptions.length} 个选项;保证金 = 选项价格 × 寄售数量`
: depositSpecFieldOptions.length === 0
? '该分类还没有下拉/单选规格字段,请先在「规格字段」里添加'
: '请选择规格字段并配置每个选项的每件保证金'}
</Text>
</Space>
</Form.Item>
) : null}
</div>
{depositEnabled && depositMode === 'per_spec' ? (
<>
<Form.Item
name={['deposit', 'specKey']}
hidden
rules={[{ required: true, message: '请先配置规格保证金字段' }]}
>
<Input />
</Form.Item>
<Form.Item
name={['deposit', 'specPricesYuan']}
hidden
getValueProps={(value) => ({ value: JSON.stringify(value || {}) })}
normalize={(value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
}}
>
<Input />
</Form.Item>
</>
) : null}
</section>
<section className="order-service-section">
@@ -1953,6 +2080,92 @@ export default function ConsignmentConfigPanel() {
<Text type="secondary">暂无可用的模板建议,请稍后重试。</Text>
)}
</Modal>
<Modal
title="按数量(规格)保证金"
open={depositSpecOpen}
width={560}
okText="保存"
cancelText="取消"
onOk={saveDepositSpecModal}
onCancel={() => setDepositSpecOpen(false)}
destroyOnClose
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Text type="secondary">规格字段</Text>
<Select
style={{ width: '100%', marginTop: 6 }}
value={depositSpecDraftKey || undefined}
placeholder={
depositSpecFieldOptions.length > 0
? '请选择规格字段'
: '该分类还没有下拉/单选规格字段'
}
options={depositSpecFieldOptions}
onChange={(value) => {
setDepositSpecDraftKey(value)
setDepositSpecDraftPrices({})
setDepositBatchPriceYuan(null)
}}
/>
</div>
{depositSpecDraftField ? (
<>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ flex: 1 }}
min={0}
precision={2}
addonBefore="¥"
placeholder="批量填充每件保证金"
value={depositBatchPriceYuan}
onChange={(value) => setDepositBatchPriceYuan(Number(value ?? 0))}
/>
<Button onClick={applyDepositBatchPrice}>填充全部选项</Button>
</Space.Compact>
<div
style={{
display: 'grid',
gap: 8,
maxHeight: 360,
overflowY: 'auto',
paddingRight: 4,
}}
>
{depositSpecDraftField.options
.filter((option) => option.enabled !== false)
.map((option) => (
<div
key={option.value}
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
>
<Text style={{ flex: 1 }}>{option.label}</Text>
<InputNumber
min={0}
precision={2}
addonBefore="¥"
style={{ width: 180 }}
value={depositSpecDraftPrices[option.value] ?? 0}
onChange={(value) =>
setDepositSpecDraftPrices((current) => ({
...current,
[option.value]: Number(value ?? 0),
}))
}
/>
</div>
))}
</div>
<Text type="secondary">
保证金 = 所选选项的每件保证金 × 寄售数量;未配置价格的选项按 0 计。
</Text>
</>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择规格字段" />
)}
</Space>
</Modal>
</>
) : (
<Empty description="请选择分类" />
@@ -64,6 +64,7 @@ import type { UploadedFile } from '@/types/worker-platform'
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { resolveConsignmentDepositFen } from '@/utils/consignment-deposit'
import { resolveConsignmentPageTitle } from '@/utils/consignment-page-title'
import {
resolveConsignmentPriceMinYuan,
@@ -268,15 +269,24 @@ export default function WorkerConsignmentPage() {
}
const watchedQuantity = Form.useWatch('quantity', form)
const quantityForDeposit = quantityLocked ? 1 : Math.max(1, Number(watchedQuantity || 1))
const publishDepositFen = selectedProfile?.config.deposit.enabled
? selectedProfile.config.deposit.mode === 'per_quantity'
? Math.max(0, selectedProfile.config.deposit.unitAmountFen) * quantityForDeposit
: Math.max(0, selectedProfile.config.deposit.fixedAmountFen)
: Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
2000,
)
// 按数量(规格):监听所选规格字段,选项变化时押金实时跟着变
const depositSpecKey = selectedProfile?.config.deposit.specKey || ''
const watchedDepositSpecValue = Form.useWatch(
depositSpecKey || '__consignment_deposit_spec_placeholder__',
form,
)
const publishDepositFen = resolveConsignmentDepositFen({
deposit: selectedProfile?.config.deposit,
quantity: quantityForDeposit,
specValues: depositSpecKey
? { [depositSpecKey]: (watchedDepositSpecValue as string | number | undefined) ?? '' }
: undefined,
fallbackFen: Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
2000,
),
})
const publishDepositYuan = publishDepositFen / 100
const manualPriceAllowed = selectedProfile?.config.pricing.allowWorkerPrice !== false
const watchedAskingPrice = Form.useWatch('askingPrice', form)
+6 -1
View File
@@ -406,9 +406,14 @@ export type ConsignmentProfileConfig = {
}
deposit: {
enabled: boolean
mode: 'fixed' | 'per_quantity'
/** fixed = 固定金额;per_quantity = 每件 × 寄售数量;per_spec = 规格选项价格 × 寄售数量 */
mode: 'fixed' | 'per_quantity' | 'per_spec'
fixedAmountFen: number
unitAmountFen: number
/** per_spec:取哪个规格字段的选项价格 */
specKey?: string
/** per_spec:选项值 -> 每件保证金(分) */
specPrices?: Record<string, number>
}
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
@@ -0,0 +1,31 @@
import type { ConsignmentProfileConfig, ConsignmentSpecValues } from '../types/consignment'
/**
* 发布寄卖时的履约保证金(分)。
*
* - fixed:固定金额
* - per_quantity:每件金额 × 寄售数量
* - per_spec:所选规格选项的每件保证金 × 寄售数量(选项没配价格按 0 计)
* - 保证金开关关闭时退回平台统一押金
*/
export function resolveConsignmentDepositFen(input: {
deposit: ConsignmentProfileConfig['deposit'] | null | undefined
quantity: number
specValues?: ConsignmentSpecValues | null
fallbackFen: number
}): number {
const deposit = input.deposit
if (!deposit?.enabled) return Math.max(0, Math.round(Number(input.fallbackFen || 0)))
const quantity = Math.max(1, Math.round(Number(input.quantity || 1)))
if (deposit.mode === 'per_quantity') {
return Math.max(0, Math.round(Number(deposit.unitAmountFen || 0))) * quantity
}
if (deposit.mode === 'per_spec') {
const specKey = String(deposit.specKey || '').trim()
const selected = specKey ? String(input.specValues?.[specKey] ?? '').trim() : ''
const unitFen = Math.max(0, Math.round(Number(deposit.specPrices?.[selected] ?? 0)))
return unitFen * quantity
}
return Math.max(0, Math.round(Number(deposit.fixedAmountFen || 0)))
}