fix(consignment): 寄卖改价上限按单价计算并补充单价/合计提示

This commit is contained in:
2026-11-08 12:12:01 +08:00
parent e03abc3a8a
commit 1773fe7837
6 changed files with 204 additions and 23 deletions
@@ -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<CandidateRow>(
`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<CandidateRow>(
`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<Fix[]> {
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()
}
@@ -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<string, unknown>
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 }
}
@@ -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'), '发布取价不得传寄卖数量')
})
@@ -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 }[] = []
@@ -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() {
<div className="worker-consignment-price-grid">
<Form.Item
label="寄售价格"
label="寄售单价(元/件)"
tooltip={
manualPriceAllowed
? `不能高于系统建议价;最低 ¥${priceMinYuan.toFixed(2)}。`
: '由后台寄卖配置自动带出,卖家不可修改;需要调整请联系客服。'
? `按每件计算,不能高于系统建议价;最低 ¥${priceMinYuan.toFixed(2)}。`
: '按每件计算,由后台寄卖配置自动带出,卖家不可修改;需要调整请联系客服。'
}
extra={
totalPriceYuan > 0
? `合计 ¥${totalPriceYuan.toFixed(2)}(${quantityForDeposit} 件)`
: undefined
}
className="worker-consignment-spec-card is-price"
>
@@ -929,7 +945,7 @@ export default function WorkerConsignmentPage() {
<Spin size="small" /> 计算建议价…
</span>
) : priceState.status === 'matched' ? (
<span className="is-amount">¥{(priceState.fen / 100).toFixed(2)}</span>
<span className="is-amount">¥{(priceState.fen / 100).toFixed(2)} / 件</span>
) : priceState.status === 'empty' ? (
<span className="is-empty">该规格暂无价格,请联系客服</span>
) : (
@@ -1663,7 +1679,7 @@ function ConsignmentRecordCard({
)}
<div className="worker-consignment-record-meta">
<span>
寄售价{' '}
单价{' '}
<strong>¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)}</strong>
</span>
{record.allocationId && Number(record.allocatedQuantity || 0) > 0 ? (
@@ -1679,6 +1695,18 @@ function ConsignmentRecordCard({
</strong>
</span>
) : null}
{Number(record.quantity || 1) > 1 ? (
<span>
合计{' '}
<strong>
¥
{(
((record.salePriceFen ?? record.askingPriceFen) * Number(record.quantity || 1)) /
100
).toFixed(2)}
</strong>
</span>
) : null}
<span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span>
@@ -1694,7 +1722,7 @@ function ConsignmentRecordCard({
setPriceOpen(true)
}}
>
修改售价
修改单价
</Button>
) : null}
</div>
@@ -1855,7 +1883,7 @@ function ConsignmentRecordCard({
{formatDateTime(record.createdAt)}
</Typography.Text>
<Modal
title="修改寄卖售价"
title="修改寄卖单价"
open={priceOpen}
onCancel={() => 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 ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
最高不能超过系统建议价 ¥{(record.priceCapFen / 100).toFixed(2)}
最高不能超过系统建议单价 ¥{(record.priceCapFen / 100).toFixed(2)}
</Typography.Text>
) : null}
{Number(record.quantity || 1) > 1 ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
数量 {record.quantity} 件,合计 ¥{(priceYuan * Number(record.quantity || 1)).toFixed(2)}
</Typography.Text>
) : null}
</Modal>
@@ -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('最高不能超过系统建议单价'),
'改价上限提示必须按单价口径,不能再说成整单建议价',
)
})