From 01c2517f29b76c54db4f03c3129a0e4a9e9c6de5 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 8 Nov 2026 12:52:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=E9=87=91=E6=94=AF=E6=8C=81=E6=8C=89=E8=A7=84=E6=A0=BC=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E4=BB=B7=E6=A0=BC=E4=B9=98=E5=AF=84=E5=94=AE=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consignment-profile-service.test.ts | 24 ++ .../consignment-profile-service.ts | 48 +++- .../consignment-worker-service.ts | 2 +- .../admin/panels/ConsignmentConfigPanel.tsx | 217 +++++++++++++++++- .../pages/worker/WorkerConsignmentPage.tsx | 28 ++- apps/frontend/src/types/consignment.ts | 7 +- .../frontend/src/utils/consignment-deposit.ts | 31 +++ .../tests/consignment-deposit.test.ts | 85 +++++++ .../tests/consignment-matching-config.test.ts | 11 + 9 files changed, 436 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/utils/consignment-deposit.ts create mode 100644 apps/frontend/tests/consignment-deposit.test.ts diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 524ed768..eb12721b 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -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 }, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 899a0132..9ac9d5d6 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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 } 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 { + const source = record(value) + const result: Record = {} + 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 = {}, +): 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)) } diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index ab6a28eb..9377a5ab 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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 ?? '', diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 6778c236..f1b11893 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -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 } 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([]) 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>({}) + const [depositBatchPriceYuan, setDepositBatchPriceYuan] = useState(null) const [form] = Form.useForm() 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 = { ...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: '按数量(规格)' }, ]} /> @@ -1627,7 +1710,51 @@ export default function ConsignmentConfigPanel() { /> ) : null} + {depositEnabled && depositMode === 'per_spec' ? ( + + + + + {depositSpecSelectedField + ? `${depositSpecSelectedField.label}:已配置 ${depositSpecPricedCount}/${depositSpecEnabledOptions.length} 个选项;保证金 = 选项价格 × 寄售数量` + : depositSpecFieldOptions.length === 0 + ? '该分类还没有下拉/单选规格字段,请先在「规格字段」里添加' + : '请选择规格字段并配置每个选项的每件保证金'} + + + + ) : null} + {depositEnabled && depositMode === 'per_spec' ? ( + <> + + + + ) : null}
@@ -1953,6 +2080,92 @@ export default function ConsignmentConfigPanel() { 暂无可用的模板建议,请稍后重试。 )} + + setDepositSpecOpen(false)} + destroyOnClose + > + +
+ 规格字段 +