From 1773fe78372f368a48282b5ae45d6ce76204c5d2 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 8 Nov 2026 12:12:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E5=AF=84=E5=8D=96=E6=94=B9?= =?UTF-8?q?=E4=BB=B7=E4=B8=8A=E9=99=90=E6=8C=89=E5=8D=95=E4=BB=B7=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=B9=B6=E8=A1=A5=E5=85=85=E5=8D=95=E4=BB=B7/?= =?UTF-8?q?=E5=90=88=E8=AE=A1=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/fix-consignment-price-cap.ts | 123 ++++++++++++++++++ .../consignment-template-spec-service.ts | 13 +- .../consignment-worker-service.test.ts | 20 +++ .../consignment-worker-service.ts | 6 +- .../pages/worker/WorkerConsignmentPage.tsx | 51 ++++++-- .../tests/consignment-publish-limits.test.ts | 14 ++ 6 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 apps/backend/scripts/fix-consignment-price-cap.ts diff --git a/apps/backend/scripts/fix-consignment-price-cap.ts b/apps/backend/scripts/fix-consignment-price-cap.ts new file mode 100644 index 00000000..d298ad51 --- /dev/null +++ b/apps/backend/scripts/fix-consignment-price-cap.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +/** + * 修复寄卖「改价上限」被寄卖数量放大的历史数据。 + * + * 背景:模板驱动规格曾是 unit 计价模板「模板单价 × 寄卖数量」写进 price_cap_fen, + * 但售价/库存/匹配引擎都是单价口径,导致改价弹窗提示的上限是单价的 N 倍。 + * 取价逻辑已改为按单价返回;本脚本把历史脏数据修正为单价。 + * + * 只处理命中 bug 特征的行:price_cap_fen === 单价 × quantity 且 quantity > 1。 + * 默认 dry-run(只打印),加 --apply 才写库。 + * + * 用法(apps/backend 目录下): + * npx tsx scripts/fix-consignment-price-cap.ts + * npx tsx scripts/fix-consignment-price-cap.ts --apply + */ + +import process from 'node:process' + +import { closeDb, query } from '../src/db/client.js' +import { parseConsignmentSpecValues } from '../src/services/worker-platform/consignment-spec-service.js' +import { getWorkerConsignmentPriceSuggestion } from '../src/services/worker-platform/consignment-worker-service.js' + +type CandidateRow = { + id: number + profile_id: number + quantity: number + price_fen: number + price_cap_fen: number + spec_values_json: unknown +} + +type Fix = { + table: 'submissions' | 'inventories' + id: number + quantity: number + beforeFen: number + afterFen: number +} + +const apply = process.argv.includes('--apply') + +async function loadCandidates() { + const submissions = await query( + `SELECT id, profile_id, quantity, asking_price_fen AS price_fen, price_cap_fen, spec_values_json + FROM consignment_submissions + WHERE profile_id IS NOT NULL AND quantity > 1 AND price_cap_fen > 0 + AND status IN ('pending_review', 'approved') + ORDER BY id`, + ) + const inventories = await query( + `SELECT id, profile_id, quantity, sale_price_fen AS price_fen, price_cap_fen, spec_values_json + FROM consignment_inventories + WHERE profile_id IS NOT NULL AND quantity > 1 AND price_cap_fen > 0 + ORDER BY id`, + ) + return { submissions: submissions.rows, inventories: inventories.rows } +} + +async function resolveFixes(table: Fix['table'], rows: CandidateRow[]): Promise { + const fixes: Fix[] = [] + for (const row of rows) { + const suggestion = await getWorkerConsignmentPriceSuggestion({ + profileId: Number(row.profile_id), + values: parseConsignmentSpecValues(row.spec_values_json), + }) + const unitCapFen = Math.max(0, Math.round(Number(suggestion.capFen || 0))) + // pg 的 BIGINT 默认返回字符串,这里统一转数字再比较 + const quantity = Math.max(1, Math.round(Number(row.quantity || 1))) + const beforeFen = Math.max(0, Math.round(Number(row.price_cap_fen || 0))) + const priceFen = Math.max(0, Math.round(Number(row.price_fen || 0))) + // bug 特征:上限 = 单价 × 寄卖数量。用「售价」和「当前建议价」两种口径各判一次, + // 避免运营改过折扣后当前建议价与历史值不一致导致漏修。 + const inflatedByPrice = priceFen > 0 && beforeFen === Math.round(priceFen * quantity) + const inflatedBySuggestion = unitCapFen > 0 && beforeFen === Math.round(unitCapFen * quantity) + if (!inflatedByPrice && !inflatedBySuggestion) continue + const afterFen = Math.max(0, Math.round(beforeFen / quantity)) + if (afterFen <= 0 || afterFen === beforeFen) continue + fixes.push({ + table, + id: Number(row.id), + quantity, + beforeFen, + afterFen, + }) + } + return fixes +} + +const yuan = (fen: number) => `¥${(fen / 100).toFixed(2)}` + +try { + const { submissions, inventories } = await loadCandidates() + const fixes = [ + ...(await resolveFixes('submissions', submissions)), + ...(await resolveFixes('inventories', inventories)), + ] + + console.log(`扫描:提报 ${submissions.length} 条 / 库存 ${inventories.length} 条(数量 > 1)`) + if (fixes.length === 0) { + console.log('没有命中需要修复的数据。') + } else { + console.log('需要修复:') + for (const fix of fixes) { + console.log( + ` [${fix.table}] id=${fix.id} 数量=${fix.quantity} 上限 ${yuan(fix.beforeFen)} → ${yuan(fix.afterFen)}`, + ) + } + } + + if (!apply) { + console.log('\n当前为预览模式。确认后追加 `--apply` 执行写库。') + } else if (fixes.length > 0) { + for (const fix of fixes) { + const table = + fix.table === 'submissions' ? 'consignment_submissions' : 'consignment_inventories' + await query(`UPDATE ${table} SET price_cap_fen = $1 WHERE id = $2`, [fix.afterFen, fix.id]) + } + console.log(`\n已修正 ${fixes.length} 条记录。`) + } +} finally { + await closeDb() +} diff --git a/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts index d9622a35..c5c51e46 100644 --- a/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts @@ -11,7 +11,8 @@ * 「商品名下拉」(每个选项绑定一条模板的 ruleKey)。 * * 取价(resolveTemplateSpecPriceFen):按选项的 ruleKey 实时回查启用的模板规则, - * fixed 模式取 reward_amount;unit 模式取 unit_price_fen × 数量。 + * fixed 模式取 reward_amount;unit 模式取 unit_price_fen × 模板拼单份数(sharingTotalQuantity)。 + * 返回的是**每个寄卖单位**的价格:卖家寄卖数量只决定库存件数,不再参与取价/改价上限。 * 模板停用/删除 → 返回 0 → 发布侧按 missingPricePolicy=block 拦截,提示运营补模板价。 * 取价永远只看本分类(+子树)的模板,绝不跨分类借用(根治跨分类串价)。 */ @@ -394,10 +395,8 @@ export function templateRulePriceFen(rule: ConsignmentTemplateRuleRow): number { export async function resolveTemplateSpecPriceFen(input: { specs: ConsignmentSpecField[] values: Record - quantity?: number }): Promise<{ priceFen: number; matchedRuleKeys: string[]; missingKeys: string[] }> { const specs = parseTemplateSpecs(input.specs) - const quantity = Math.max(1, Math.round(Number(input.quantity || 1))) const matchedRuleKeys: string[] = [] const missingKeys: string[] = [] @@ -424,12 +423,8 @@ export async function resolveTemplateSpecPriceFen(input: { // 同一组合的多个字段都指向同一条模板;取第一条命中的模板价 const firstKey = matchedRuleKeys[0]! const rule = await getEnabledWorkProductRuleByKey(firstKey) - const basePriceFen = rule ? templateRulePriceFen(rule) : 0 - // unit 计价的模板(如 X 许愿币按件价):寄卖数量是份数,按「模板 × 数量」放大 - const priceFen = - rule && rule.pricingMode === 'unit' && rule.unitPriceFen > 0 - ? Math.max(0, Math.round(rule.unitPriceFen * quantity)) - : basePriceFen + // 模板价就是每个寄卖单位的单价;卖家寄卖数量只决定库存件数,不能放大改价上限 + const priceFen = rule ? templateRulePriceFen(rule) : 0 return { priceFen, matchedRuleKeys, missingKeys } } diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index 6cf77d86..83393c83 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -232,3 +232,23 @@ test('寄售价不再强制最低1元,数量上限按当前分类统计', asyn '数量上限校验必须按当前寄卖配置统计', ) }) + +test('模板建议价与改价上限按单价计算,不随寄卖数量放大', async () => { + const [serviceSource, templateSource] = await Promise.all([ + readServiceSource(), + readFile(new URL('./consignment-template-spec-service.ts', import.meta.url), 'utf8'), + ]) + // unit 计价模板:模板价 = 单价 × 拼单份数(templateRulePriceFen 内已算完); + // 卖家寄卖数量只决定库存件数,取价时不能再乘一次 + assert.ok( + templateSource.includes('const priceFen = rule ? templateRulePriceFen(rule) : 0'), + '取价必须直接使用模板单价', + ) + assert.ok(!templateSource.includes('quantity?: number'), '取价函数不再接收寄卖数量') + const publishBlock = serviceSource.slice( + serviceSource.indexOf('const priceSuggestion = await getWorkerConsignmentPriceSuggestion'), + serviceSource.indexOf('const priceResolution = resolveWorkerConsignmentPrice'), + ) + assert.ok(publishBlock.length > 0, '必须存在发布取价调用') + assert.ok(!publishBlock.includes('quantity'), '发布取价不得传寄卖数量') +}) 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 0584f57a..82836c77 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -407,12 +407,11 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb const sellerNote = String(intakeValues.sellerNote ?? payload.sellerNote ?? '').trim() // 寄售价:按配置算出系统建议价(模板折扣价 / 模板价固定下调),打手只能往下调。 - // 没有模板价时一律阻止发布。模板驱动规格按数量放大单价(unit 计价模板)。 + // 没有模板价时一律阻止发布。建议价与改价上限都是**单价**,不随寄卖数量放大。 const priceSuggestion = await getWorkerConsignmentPriceSuggestion({ profileId: profile?.profileId, specs, values: specValues, - quantity, }) const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING const submittedPriceFen = Math.max( @@ -1072,8 +1071,6 @@ export async function getWorkerConsignmentPriceSuggestion(payload: { /** 保留旧参数,价格只依赖系统/系列/钻级/璀璨 */ specs?: ConsignmentSpecField[] values?: ConsignmentSpecValues - /** 模板驱动规格:unit 计价模板按数量放大单价 */ - quantity?: unknown /** 旧调用兼容:平铺参数 */ system?: unknown series?: unknown @@ -1114,7 +1111,6 @@ export async function getWorkerConsignmentPriceSuggestion(payload: { const resolution = await resolveTemplateSpecPriceFen({ specs, values, - quantity: Number(payload.quantity || 1), }) const templatePriceFen = Math.max(0, Math.round(Number(resolution.priceFen || 0))) const matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] = [] diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 4dcc148a..56c6d7a3 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -279,6 +279,17 @@ export default function WorkerConsignmentPage() { ) const publishDepositYuan = publishDepositFen / 100 const manualPriceAllowed = selectedProfile?.config.pricing.allowWorkerPrice !== false + const watchedAskingPrice = Form.useWatch('askingPrice', form) + // 寄售价格是「单价」:数量只决定库存件数,合计单独展示,避免把单价误读成整单总价 + const unitPriceForTotalYuan = manualPriceAllowed + ? Number(watchedAskingPrice ?? 0) + : priceState.status === 'matched' + ? priceState.fen / 100 + : 0 + const totalPriceYuan = + Number.isFinite(unitPriceForTotalYuan) && unitPriceForTotalYuan > 0 + ? unitPriceForTotalYuan * quantityForDeposit + : 0 const availableAmount = Number(worker?.wallet?.availableAmount || 0) const canPublish = availableAmount >= publishDepositFen const canConsign = canWorkerPublishConsignment({ @@ -899,11 +910,16 @@ export default function WorkerConsignmentPage() {
0 + ? `合计 ¥${totalPriceYuan.toFixed(2)}(${quantityForDeposit} 件)` + : undefined } className="worker-consignment-spec-card is-price" > @@ -929,7 +945,7 @@ export default function WorkerConsignmentPage() { 计算建议价… ) : priceState.status === 'matched' ? ( - ¥{(priceState.fen / 100).toFixed(2)} + ¥{(priceState.fen / 100).toFixed(2)} / 件 ) : priceState.status === 'empty' ? ( 该规格暂无价格,请联系客服 ) : ( @@ -1663,7 +1679,7 @@ function ConsignmentRecordCard({ )}
- 寄售价{' '} + 单价{' '} ¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)} {record.allocationId && Number(record.allocatedQuantity || 0) > 0 ? ( @@ -1679,6 +1695,18 @@ function ConsignmentRecordCard({ ) : null} + {Number(record.quantity || 1) > 1 ? ( + + 合计{' '} + + ¥ + {( + ((record.salePriceFen ?? record.askingPriceFen) * Number(record.quantity || 1)) / + 100 + ).toFixed(2)} + + + ) : null} 押金 ¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)} @@ -1694,7 +1722,7 @@ function ConsignmentRecordCard({ setPriceOpen(true) }} > - 修改售价 + 修改单价 ) : null}
@@ -1855,7 +1883,7 @@ function ConsignmentRecordCard({ {formatDateTime(record.createdAt)} setPriceOpen(false)} confirmLoading={savingPrice} @@ -1870,7 +1898,7 @@ function ConsignmentRecordCard({ setSavingPrice(true) try { await updateWorkerConsignmentPrice(record.submissionId, nextPriceFen) - message.success('寄卖售价已更新') + message.success('寄卖单价已更新') setPriceOpen(false) await queryClient.invalidateQueries({ queryKey: ['worker-my-consignments'] }) } catch (error) { @@ -1891,7 +1919,12 @@ function ConsignmentRecordCard({ /> {record.priceCapFen && record.priceCapFen > 0 ? ( - 最高不能超过系统建议价 ¥{(record.priceCapFen / 100).toFixed(2)} + 最高不能超过系统建议单价 ¥{(record.priceCapFen / 100).toFixed(2)} + + ) : null} + {Number(record.quantity || 1) > 1 ? ( + + 数量 {record.quantity} 件,合计 ¥{(priceYuan * Number(record.quantity || 1)).toFixed(2)} ) : null} diff --git a/apps/frontend/tests/consignment-publish-limits.test.ts b/apps/frontend/tests/consignment-publish-limits.test.ts index 02404ad5..ca14d4db 100644 --- a/apps/frontend/tests/consignment-publish-limits.test.ts +++ b/apps/frontend/tests/consignment-publish-limits.test.ts @@ -102,3 +102,17 @@ test('打手端发布页:发布成功、切回发布 tab、手动刷新都会 '手动刷新必须同步刷新剩余寄卖额度', ) }) + +test('打手端价格按单价展示并给出合计,避免把单价误读成整单总价', () => { + const page = readFileSync( + fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)), + 'utf8', + ) + assert.ok(page.includes('label="寄售单价(元/件)"'), '发布页价格必须标明是单价') + assert.ok(page.includes('合计 ¥${totalPriceYuan.toFixed(2)}'), '发布页必须展示单价 × 数量的合计') + assert.ok(page.includes('title="修改寄卖单价"'), '改价弹窗标题必须标明是单价') + assert.ok( + page.includes('最高不能超过系统建议单价'), + '改价上限提示必须按单价口径,不能再说成整单建议价', + ) +})