feat(consignment): 保证金支持按规格选项价格乘寄售数量计算
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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('填充全部选项'), '弹窗必须支持批量填充选项价格')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user