fix(consignment): 寄售价支持低于1元并按分类校验寄售数量上限

This commit is contained in:
2026-11-08 11:11:54 +08:00
parent 229eef31a8
commit ffe7c31a87
12 changed files with 281 additions and 33 deletions
@@ -19,9 +19,10 @@ const router = Router()
* GET /api/v1/worker/consignment/profiles
* 打手端寄卖页按已发布的分类配置动态生成 tab。
*/
router.get('/consignment/profiles', async (_req, res) => {
router.get('/consignment/profiles', async (req, res) => {
try {
const result = await listWorkerConsignmentProfiles()
const session = getRequiredWorkerSession(req)
const result = await listWorkerConsignmentProfiles(session.workerId)
res.json({ ok: true, data: { profiles: result } })
} catch (error) {
sendRouteError(res, error, '获取寄卖配置失败', '[worker/consignment/profiles]')
@@ -138,6 +138,23 @@ test('打手端寄卖分类:苏尔南手枪固定排第一', async () => {
)
})
test('寄售数量上限按分类统计,并在打手配置接口返回 activeQuantity', async () => {
const source = await readFile(
new URL('./consignment-profile-service.ts', import.meta.url),
'utf8',
)
const countStart = source.indexOf('export async function countWorkerActiveConsignmentQuantity')
const countEnd = source.indexOf('/** 一次取回打手各分类', countStart)
const countBody = source.slice(countStart, countEnd)
assert.ok(countBody.includes('profile_id = $2'), '数量统计必须按 profile_id 过滤')
assert.ok(countBody.includes("status = 'pending_review'"), '待审核提报也必须计入当前占用量')
assert.ok(
source.includes('activeQuantity: activeByProfile.get(Number(row.id)) || 0'),
'打手配置接口必须回传每个分类的 activeQuantity',
)
assert.ok(source.includes('GROUP BY profile_id'), '各分类数量必须一次分组取回')
})
test('合并迁移 147 的苏尔南种子:降价提示默认开启、24 小时、按比例 10%', async () => {
const sql = await readFile(
new URL('../../db/migrations/147_consignment_platform.sql', import.meta.url),
@@ -528,7 +528,7 @@ export async function archiveAdminConsignmentProfile(profileIdInput: number | st
}
/** 打手端:只返回已发布且分类启用的寄卖配置,按分类排序生成 tab。 */
export async function listWorkerConsignmentProfiles() {
export async function listWorkerConsignmentProfiles(workerId?: number) {
const result = await query<ConsignmentProfileRow>(
`
${PROFILE_SELECT}
@@ -537,6 +537,9 @@ export async function listWorkerConsignmentProfiles() {
ORDER BY (c.category_key = 'shouqiang') DESC, c.sort_order ASC, p.id ASC
`,
)
const activeByProfile = workerId
? await countWorkerActiveConsignmentQuantities(workerId)
: new Map<number, number>()
return result.rows.map((row) => {
const config = resolvePublishedProfileConfig(row)
return {
@@ -548,6 +551,7 @@ export async function listWorkerConsignmentProfiles() {
description: row.description || '',
tabTitle: row.tab_title || config.tabTitle || row.name,
tabDescription: config.tabDescription,
activeQuantity: activeByProfile.get(Number(row.id)) || 0,
config,
}
})
@@ -762,7 +766,12 @@ export async function getPublishedConsignmentProfileById(
}
/** 打手当前在售 + 待审核的寄卖数量(按件数,供单人寄售上限校验)。 */
export async function countWorkerActiveConsignmentQuantity(workerId: number): Promise<number> {
export async function countWorkerActiveConsignmentQuantity(
workerId: number,
profileId?: number | null,
): Promise<number> {
const scopedProfileId =
Number.isInteger(Number(profileId)) && Number(profileId) > 0 ? Number(profileId) : null
const result = await query<{ total: number | string }>(
`
SELECT
@@ -770,16 +779,43 @@ export async function countWorkerActiveConsignmentQuantity(workerId: number): Pr
SELECT SUM(GREATEST(remaining_quantity, 0))
FROM consignment_inventories
WHERE worker_id = $1 AND status = 'in_stock'
AND ($2::bigint IS NULL OR profile_id = $2)
), 0)
+ COALESCE((
SELECT SUM(GREATEST(quantity, 1))
FROM consignment_submissions
WHERE worker_id = $1 AND status = 'pending_review'
AND ($2::bigint IS NULL OR profile_id = $2)
), 0) AS total
`,
[workerId, scopedProfileId],
)
return Math.max(0, Math.round(Number(result.rows[0]?.total || 0)))
}
/** 一次取回打手各分类的在售+待审核件数,供打手端配置列表展示剩余额度。 */
async function countWorkerActiveConsignmentQuantities(
workerId: number,
): Promise<Map<number, number>> {
const result = await query<{ profile_id: number | string; total: number | string }>(
`
SELECT profile_id, SUM(quantity) AS total
FROM (
SELECT profile_id, GREATEST(remaining_quantity, 0) AS quantity
FROM consignment_inventories
WHERE worker_id = $1 AND status = 'in_stock' AND profile_id IS NOT NULL
UNION ALL
SELECT profile_id, GREATEST(quantity, 1) AS quantity
FROM consignment_submissions
WHERE worker_id = $1 AND status = 'pending_review' AND profile_id IS NOT NULL
) scoped
GROUP BY profile_id
`,
[workerId],
)
return Math.max(0, Math.round(Number(result.rows[0]?.total || 0)))
return new Map(
result.rows.map((row) => [Number(row.profile_id), Math.max(0, Number(row.total || 0))]),
)
}
export function buildDepositAmountFen(config: ConsignmentProfileConfig, quantity: number): number {
@@ -363,6 +363,32 @@ test('动态价格:打手改价只能低于系统建议价,且受最低价
assert.ok(belowMin.errors.some((error) => error.includes('不能少于')))
})
test('动态价格:低于1元的寄售价允许发布,只受配置最低价约束', () => {
// 模板价 1 元 × 7.3 折 = 73 分,系统建议价本身就可以低于 1 元。
const suggested = resolveWorkerConsignmentPrice({
pricing: buildPricing({ discount: 7.3 }),
templatePriceFen: 100,
submittedPriceFen: 0,
})
assert.deepEqual(suggested.errors, [])
assert.equal(suggested.priceFen, 73)
const submitted = resolveWorkerConsignmentPrice({
pricing: buildPricing(),
templatePriceFen: 100,
submittedPriceFen: 73,
})
assert.deepEqual(submitted.errors, [])
assert.equal(submitted.priceFen, 73)
const belowConfiguredMin = resolveWorkerConsignmentPrice({
pricing: buildPricing({ discount: 7.3, minPriceFen: 80 }),
templatePriceFen: 100,
submittedPriceFen: 73,
})
assert.ok(belowConfiguredMin.errors.some((error) => error.includes('不能少于')))
})
test('动态价格:没有模板价时一律阻止发布(不再允许手填)', () => {
for (const pricing of [buildPricing(), buildPricing({ missingPricePolicy: 'manual' })]) {
const blocked = resolveWorkerConsignmentPrice({
@@ -877,9 +877,7 @@ export function resolveWorkerConsignmentPrice(input: {
if (priceFen > 0 && priceFen < minPriceFen) {
errors.push(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`)
}
if (priceFen > 0) {
priceFen = Math.max(100, priceFen)
}
// 价格允许低于 1 元:下限只由配置的 minPriceFen 决定,0 元由发布侧拦截。
return {
priceFen,
capFen: computed.capFen,
@@ -218,3 +218,17 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截',
'未开通必须返回专门的 consignment_permission_required 错误码',
)
})
test('寄售价不再强制最低1元,数量上限按当前分类统计', async () => {
const [serviceSource, specSource] = await Promise.all([
readServiceSource(),
readFile(new URL('./consignment-spec-service.ts', import.meta.url), 'utf8'),
])
assert.ok(!serviceSource.includes('Math.max(100'), '改价不能把 0.73 元静默抬到 1 元')
assert.ok(!specSource.includes('priceFen = Math.max(100, priceFen)'), '发布价不能硬编码最低 1 元')
assert.ok(
serviceSource.includes('countWorkerActiveConsignmentQuantity(') &&
serviceSource.includes('profile.profileId'),
'数量上限校验必须按当前寄卖配置统计',
)
})
@@ -259,7 +259,13 @@ export async function updateWorkerConsignmentSalePrice(
errorCode: 'consignment_price_edit_forbidden',
})
}
const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0)))
const priceFen = Math.round(Number(priceFenInput || 0))
if (priceFen <= 0) {
throw createHttpError('寄售价格必须大于 0 元', {
statusCode: 400,
errorCode: 'consignment_price_invalid',
})
}
const capFen = Math.max(0, Math.round(Number(inventory.price_cap_fen || 0)))
if (capFen > 0 && priceFen > capFen) {
throw createHttpError(`寄售价格不能高于系统建议价 ¥${(capFen / 100).toFixed(2)}`, {
@@ -321,7 +327,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
payload.quantity,
)
if (profile?.config.inventoryLimit.enabled) {
const activeQuantity = await countWorkerActiveConsignmentQuantity(workerId)
const activeQuantity = await countWorkerActiveConsignmentQuantity(workerId, profile.profileId)
if (activeQuantity + quantity > profile.config.inventoryLimit.maxActiveQuantity) {
throw createHttpError(
`超出单人寄售数量上限(最多 ${profile.config.inventoryLimit.maxActiveQuantity} 件,当前在售/待审核 ${activeQuantity} 件)`,