fix(consignment): 寄卖改价上限按单价计算并补充单价/合计提示
This commit is contained in:
@@ -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('最高不能超过系统建议单价'),
|
||||
'改价上限提示必须按单价口径,不能再说成整单建议价',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user