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
@@ -55,6 +55,30 @@ test('保证金:固定金额或按数量计算', () => {
assert.equal(buildDepositAmountFen(perQuantity, 1), 500)
})
test('保证金:按数量(规格)取选项价格 × 寄售数量', () => {
const config = normalizeConsignmentProfileConfig({
deposit: {
enabled: true,
mode: 'per_spec',
specKey: 'diamondLevel',
specPrices: { '1': 100, '3': 300, '5': 500 },
},
})
assert.equal(config.deposit.mode, 'per_spec')
assert.equal(config.deposit.specKey, 'diamondLevel')
assert.deepEqual(config.deposit.specPrices, { '1': 100, '3': 300, '5': 500 })
assert.equal(buildDepositAmountFen(config, 5, { diamondLevel: '3' }), 1500)
// 没配价格的选项 / 字段缺失都按 0 计,不阻塞发布
assert.equal(buildDepositAmountFen(config, 2, { diamondLevel: '2' }), 0)
assert.equal(buildDepositAmountFen(config, 2, {}), 0)
const sanitized = normalizeConsignmentProfileConfig({
deposit: { mode: 'per_spec', specPrices: { '1': -5, '2': 'x', ' 3 ': 299.6 } },
})
assert.deepEqual(sanitized.deposit.specPrices, { '1': 0, '3': 300 })
})
test('服务费:按比例或固定金额,且不超过成交金额', () => {
const percent = normalizeConsignmentProfileConfig({
serviceFee: { enabled: true, mode: 'percent', percent: 10 },
@@ -25,9 +25,14 @@ export type {
export type ConsignmentDepositConfig = {
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>
}
export type ConsignmentServiceFeeConfig = {
@@ -156,6 +161,8 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
mode: 'fixed',
fixedAmountFen: 0,
unitAmountFen: 0,
specKey: '',
specPrices: {},
},
serviceFee: {
enabled: false,
@@ -233,6 +240,21 @@ function decimalOne(value: unknown, fallback: number, min: number, max: number):
return Math.min(max, Math.max(min, rounded))
}
/** 按数量(规格):选项值 → 每件保证金(分)。 */
function normalizeDepositSpecPrices(value: unknown): Record<string, number> {
const source = record(value)
const result: Record<string, number> = {}
for (const [rawKey, rawValue] of Object.entries(source)) {
const key = String(rawKey).trim().slice(0, 50)
if (!key) continue
const parsed = Number(rawValue)
if (!Number.isFinite(parsed)) continue
result[key] = Math.min(100_000_000, Math.max(0, Math.round(parsed)))
if (Object.keys(result).length >= 500) break
}
return result
}
export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig {
const outer = record(value)
// 兼容直接存 config 的场景,以及库存/提报里 { config: {...}, profileId... } 的快照包装。
@@ -258,7 +280,12 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
: matching.mode === 'manual'
? 'manual'
: 'off'
const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' : 'fixed'
const depositMode =
deposit.mode === 'per_quantity'
? 'per_quantity'
: deposit.mode === 'per_spec'
? 'per_spec'
: 'fixed'
const serviceFeeMode = serviceFee.mode === 'fixed' ? 'fixed' : 'percent'
const priceDropMode = priceDrop.mode === 'fixed' ? 'fixed' : 'percent'
const timeoutPolicy: ConsignmentTimeoutConfig['policy'] =
@@ -298,6 +325,8 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
mode: depositMode,
fixedAmountFen: integer(deposit.fixedAmountFen, 0, 0, 100_000_000),
unitAmountFen: integer(deposit.unitAmountFen, 0, 0, 100_000_000),
specKey: text(deposit.specKey, 50),
specPrices: normalizeDepositSpecPrices(deposit.specPrices),
},
serviceFee: {
enabled: bool(serviceFee.enabled, false),
@@ -826,10 +855,21 @@ async function countWorkerActiveConsignmentQuantities(
)
}
export function buildDepositAmountFen(config: ConsignmentProfileConfig, quantity: number): number {
export function buildDepositAmountFen(
config: ConsignmentProfileConfig,
quantity: number,
specValues: Record<string, unknown> = {},
): number {
if (!config.deposit.enabled) return 0
const count = Math.max(1, Math.round(Number(quantity || 1)))
if (config.deposit.mode === 'per_quantity') {
return Math.max(0, Math.round(config.deposit.unitAmountFen)) * Math.max(1, quantity)
return Math.max(0, Math.round(config.deposit.unitAmountFen)) * count
}
if (config.deposit.mode === 'per_spec') {
const selected = String(specValues[config.deposit.specKey] ?? '').trim()
const unitFen = Math.max(0, Math.round(Number(config.deposit.specPrices[selected] ?? 0)))
// 未配置价格的选项按 0 计(与后台配置口径一致,不阻塞发布)
return unitFen * count
}
return Math.max(0, Math.round(config.deposit.fixedAmountFen))
}
@@ -466,7 +466,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const priceCapFen = priceResolution.capFen
const depositAmountFen = profile?.config.deposit.enabled
? buildDepositAmountFen(profile.config, quantity)
? buildDepositAmountFen(profile.config, quantity, specValues)
: getConsignmentPublishDepositFen()
const sellerPhone = String(
intakeValues.sellerPhone ?? payload.sellerPhone ?? worker.phone ?? '',
@@ -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)))
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { resolveConsignmentDepositFen } from '../src/utils/consignment-deposit.ts'
const perSpecDeposit = {
enabled: true,
mode: 'per_spec' as const,
fixedAmountFen: 0,
unitAmountFen: 0,
specKey: 'diamondLevel',
specPrices: { '1': 100, '3': 300 },
}
test('按数量(规格):选项价格 × 寄售数量', () => {
assert.equal(
resolveConsignmentDepositFen({
deposit: perSpecDeposit,
quantity: 5,
specValues: { diamondLevel: '3' },
fallbackFen: 2000,
}),
1500,
)
})
test('按数量(规格):未配价格或字段缺失按 0 计,不阻塞发布', () => {
assert.equal(
resolveConsignmentDepositFen({
deposit: perSpecDeposit,
quantity: 2,
specValues: { diamondLevel: '2' },
fallbackFen: 2000,
}),
0,
)
assert.equal(
resolveConsignmentDepositFen({
deposit: perSpecDeposit,
quantity: 2,
specValues: {},
fallbackFen: 2000,
}),
0,
)
})
test('固定 / 按数量 / 关闭保证金的口径保持原样', () => {
assert.equal(
resolveConsignmentDepositFen({
deposit: { ...perSpecDeposit, enabled: false },
quantity: 3,
specValues: { diamondLevel: '3' },
fallbackFen: 2000,
}),
2000,
)
assert.equal(
resolveConsignmentDepositFen({
deposit: { ...perSpecDeposit, mode: 'fixed', fixedAmountFen: 500 },
quantity: 3,
fallbackFen: 2000,
}),
500,
)
assert.equal(
resolveConsignmentDepositFen({
deposit: { ...perSpecDeposit, mode: 'per_quantity', unitAmountFen: 200 },
quantity: 3,
fallbackFen: 2000,
}),
600,
)
})
test('打手端发布页:保证金按规格价格实时计算', () => {
const page = readFileSync(
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
'utf8',
)
assert.ok(page.includes('resolveConsignmentDepositFen('), '发布页押金必须走统一的规格保证金计算')
assert.ok(page.includes('deposit.specKey'), '发布页必须按所选规格字段实时计算押金')
})
@@ -124,3 +124,14 @@ test('寄卖配置:未售出时长支持一位小数', () => {
assert.ok(block.includes('precision={1}'), '未售出时长必须支持一位小数')
assert.ok(block.includes('min={0.1}'), '未售出时长下限应为 0.1 小时')
})
test('寄卖配置:保证金支持按数量(规格),弹窗内配置选项价格', () => {
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
assert.ok(
panel.includes("{ value: 'per_spec', label: '按数量(规格)' }"),
'保证金模式必须包含「按数量(规格)」',
)
assert.ok(panel.includes('title="按数量(规格)保证金"'), '必须提供规格价格配置弹窗')
assert.ok(panel.includes('specPricesYuan'), '选项价格必须写回配置')
assert.ok(panel.includes('填充全部选项'), '弹窗必须支持批量填充选项价格')
})