feat(consignment): 未售出时长支持一位小数,改价上限按当前模板价实时计算
This commit is contained in:
@@ -38,7 +38,7 @@ test('寄卖配置:非法枚举与越界数字回落到安全值', () => {
|
||||
assert.equal(config.matching.mode, 'off')
|
||||
assert.equal(config.timeout.policy, 'reopen')
|
||||
assert.equal(config.timeout.minutes, 0)
|
||||
assert.equal(config.priceDrop.hours, 1)
|
||||
assert.equal(config.priceDrop.hours, 0.1)
|
||||
assert.equal(config.priceDrop.value, 0)
|
||||
})
|
||||
|
||||
@@ -90,6 +90,41 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售
|
||||
assert.equal(suggestion?.dropHours, 24)
|
||||
})
|
||||
|
||||
test('降价提示:未售出时长支持一位小数,按小数小时判断', () => {
|
||||
const config = normalizeConsignmentProfileConfig({
|
||||
priceDrop: { enabled: true, hours: 1.5, mode: 'percent', value: 10 },
|
||||
})
|
||||
assert.equal(config.priceDrop.hours, 1.5)
|
||||
|
||||
// 1.56 保留一位小数 → 1.6;0.05 收敛到下限 0.1
|
||||
assert.equal(
|
||||
normalizeConsignmentProfileConfig({ priceDrop: { hours: 1.56 } }).priceDrop.hours,
|
||||
1.6,
|
||||
)
|
||||
assert.equal(
|
||||
normalizeConsignmentProfileConfig({ priceDrop: { hours: 0.05 } }).priceDrop.hours,
|
||||
0.1,
|
||||
)
|
||||
|
||||
const listedAt = '2026-01-01T00:00:00.000Z'
|
||||
assert.equal(
|
||||
resolvePriceDropSuggestion(config, {
|
||||
listedAt,
|
||||
salePriceFen: 10_000,
|
||||
now: '2026-01-01T01:00:00.000Z',
|
||||
}),
|
||||
null,
|
||||
'1 小时未到 1.5 小时,不提示',
|
||||
)
|
||||
const suggestion = resolvePriceDropSuggestion(config, {
|
||||
listedAt,
|
||||
salePriceFen: 10_000,
|
||||
now: '2026-01-01T01:30:00.000Z',
|
||||
})
|
||||
assert.equal(suggestion?.dropHours, 1.5)
|
||||
assert.equal(suggestion?.suggestedPriceFen, 9_000)
|
||||
})
|
||||
|
||||
test('合并迁移 147 必须包含寄卖配置、库存数量/售价与分配表', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../db/migrations/147_consignment_platform.sql', import.meta.url),
|
||||
|
||||
@@ -225,6 +225,14 @@ function integer(value: unknown, fallback: number, min: number, max: number): nu
|
||||
return Math.min(max, Math.max(min, Math.round(parsed)))
|
||||
}
|
||||
|
||||
/** 保留一位小数的数值:未售出时长这类允许小数的配置用。 */
|
||||
function decimalOne(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
const rounded = Math.round(parsed * 10) / 10
|
||||
return Math.min(max, Math.max(min, rounded))
|
||||
}
|
||||
|
||||
export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig {
|
||||
const outer = record(value)
|
||||
// 兼容直接存 config 的场景,以及库存/提报里 { config: {...}, profileId... } 的快照包装。
|
||||
@@ -315,7 +323,7 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
||||
},
|
||||
priceDrop: {
|
||||
enabled: bool(priceDrop.enabled, true),
|
||||
hours: integer(priceDrop.hours, 24, 1, 24 * 365),
|
||||
hours: decimalOne(priceDrop.hours, 24, 0.1, 24 * 365),
|
||||
mode: priceDropMode,
|
||||
value: integer(priceDrop.value, 10, 0, 100_000_000),
|
||||
},
|
||||
|
||||
@@ -252,3 +252,22 @@ test('模板建议价与改价上限按单价计算,不随寄卖数量放大',
|
||||
assert.ok(publishBlock.length > 0, '必须存在发布取价调用')
|
||||
assert.ok(!publishBlock.includes('quantity'), '发布取价不得传寄卖数量')
|
||||
})
|
||||
|
||||
test('打手端改价上限按当前模板价实时计算,不依赖发布时的快照', async () => {
|
||||
const serviceSource = await readServiceSource()
|
||||
assert.ok(
|
||||
serviceSource.includes('createLivePriceCapResolver('),
|
||||
'我的寄卖列表必须实时解析改价上限',
|
||||
)
|
||||
assert.ok(serviceSource.includes('livePriceCapByRow'), '列表必须使用实时上限而不是快照')
|
||||
const updateBlock = serviceSource.slice(
|
||||
serviceSource.indexOf('export async function updateWorkerConsignmentSalePrice'),
|
||||
serviceSource.indexOf('const updated = await updateConsignmentInventory'),
|
||||
)
|
||||
assert.ok(updateBlock.length > 0, '必须存在改价处理函数')
|
||||
assert.ok(
|
||||
updateBlock.includes('getWorkerConsignmentPriceSuggestion('),
|
||||
'改价校验必须实时取当前模板价',
|
||||
)
|
||||
assert.ok(updateBlock.includes('liveCapFen'), '实时价取不到时才允许回退快照')
|
||||
})
|
||||
|
||||
@@ -101,6 +101,32 @@ function parseSpecValues(value: unknown): ConsignmentSpecValues {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 打手端改价上限按**当前**模板价实时计算:运营改了模板价,已上架商品的上限要立刻跟着变。
|
||||
*
|
||||
* 同一请求内按「分类 + 规格」缓存 Promise,避免同规格重复查库;
|
||||
* 取不到实时价(分类下线 / 模板删除 / 查询异常)时返回 0,调用方回退发布时的快照。
|
||||
*/
|
||||
function createLivePriceCapResolver(): (
|
||||
profileId: number,
|
||||
specValues: ConsignmentSpecValues,
|
||||
) => Promise<number> {
|
||||
const pendingByKey = new Map<string, Promise<number>>()
|
||||
return (profileId, specValues) => {
|
||||
const id = Number(profileId || 0)
|
||||
if (!id) return Promise.resolve(0)
|
||||
const key = `${id}:${JSON.stringify(specValues)}`
|
||||
let pending = pendingByKey.get(key)
|
||||
if (!pending) {
|
||||
pending = getWorkerConsignmentPriceSuggestion({ profileId: id, values: specValues })
|
||||
.then((suggestion) => Math.max(0, Math.round(Number(suggestion.capFen || 0))))
|
||||
.catch(() => 0)
|
||||
pendingByKey.set(key, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
function extractConsignmentGiftEvidenceFromAcceptance(value: unknown) {
|
||||
const acceptance = toRecord(value)
|
||||
const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : []
|
||||
@@ -266,7 +292,14 @@ export async function updateWorkerConsignmentSalePrice(
|
||||
errorCode: 'consignment_price_invalid',
|
||||
})
|
||||
}
|
||||
const capFen = Math.max(0, Math.round(Number(inventory.price_cap_fen || 0)))
|
||||
// 上限按当前模板价实时算:模板调价后立刻生效;实时取不到价(分类下线/模板删除)再回退快照
|
||||
const liveSuggestion = await getWorkerConsignmentPriceSuggestion({
|
||||
profileId: Number(inventory.profile_id || 0),
|
||||
values: parseSpecValues(inventory.spec_values_json),
|
||||
})
|
||||
const liveCapFen = Math.max(0, Math.round(Number(liveSuggestion.capFen || 0)))
|
||||
const capFen =
|
||||
liveCapFen > 0 ? liveCapFen : Math.max(0, Math.round(Number(inventory.price_cap_fen || 0)))
|
||||
if (capFen > 0 && priceFen > capFen) {
|
||||
throw createHttpError(`寄售价格不能高于系统建议价 ¥${(capFen / 100).toFixed(2)}`, {
|
||||
statusCode: 409,
|
||||
@@ -616,6 +649,25 @@ export async function listWorkerConsignments(
|
||||
const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub]))
|
||||
const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv]))
|
||||
|
||||
// 改价上限跟着当前模板价走:模板调价后,列表里的上限要立刻反映,不能再用发布时快照
|
||||
const resolveLivePriceCap = createLivePriceCapResolver()
|
||||
const livePriceCapByRow = new Map<(typeof page.rows)[number], number>()
|
||||
await Promise.all(
|
||||
page.rows.map(async (row) => {
|
||||
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
|
||||
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
|
||||
const storedCapFen = Math.max(
|
||||
0,
|
||||
Math.round(Number(inv?.price_cap_fen ?? sub?.price_cap_fen ?? 0)),
|
||||
)
|
||||
const liveCapFen = await resolveLivePriceCap(
|
||||
Number(inv?.profile_id ?? sub?.profile_id ?? 0),
|
||||
parseSpecValues(inv?.spec_values_json ?? sub?.spec_values_json),
|
||||
)
|
||||
livePriceCapByRow.set(row, liveCapFen > 0 ? liveCapFen : storedCapFen)
|
||||
}),
|
||||
)
|
||||
|
||||
const boundWorkOrderIds = [
|
||||
...inventories.map((inv) => Number(inv.bound_work_order_id || 0)),
|
||||
...page.rows.map((row) => Number(row.workOrderId || 0)),
|
||||
@@ -663,6 +715,7 @@ export async function listWorkerConsignments(
|
||||
const buildSubmissionItem = (
|
||||
sub: (typeof submissions)[number],
|
||||
inv: (typeof inventories)[number] | undefined,
|
||||
livePriceCapFen: number,
|
||||
) => {
|
||||
const boundWo = inv?.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
@@ -696,7 +749,7 @@ export async function listWorkerConsignments(
|
||||
salePriceFen: Number(
|
||||
inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_fen ?? 0,
|
||||
),
|
||||
priceCapFen: Number(inv?.price_cap_fen ?? sub.price_cap_fen ?? 0),
|
||||
priceCapFen: livePriceCapFen,
|
||||
quantity: Math.max(1, Number(sub.quantity || inv?.quantity || 1)),
|
||||
remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0,
|
||||
listedAt: inv?.listed_at || null,
|
||||
@@ -734,6 +787,7 @@ export async function listWorkerConsignments(
|
||||
inv: (typeof inventories)[number] | undefined,
|
||||
wo: WorkOrderRow,
|
||||
share: WorkOrderShareRow | undefined,
|
||||
livePriceCapFen: number,
|
||||
) => {
|
||||
const submissionStatus = String(sub?.status || 'approved')
|
||||
// 这一行代表“被本单占用的那部分”,即使库存还有剩余,也必须按已出库推导阶段。
|
||||
@@ -786,7 +840,7 @@ export async function listWorkerConsignments(
|
||||
salePriceFen: Number(
|
||||
inv?.sale_price_fen ?? sub?.requested_sale_price_fen ?? sub?.asking_price_fen ?? 0,
|
||||
),
|
||||
priceCapFen: Number(inv?.price_cap_fen ?? sub?.price_cap_fen ?? 0),
|
||||
priceCapFen: livePriceCapFen,
|
||||
quantity: Math.max(1, Number(inv?.quantity ?? sub?.quantity ?? 1)),
|
||||
allocatedQuantity: row.rowQuantity,
|
||||
remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0,
|
||||
@@ -815,7 +869,7 @@ export async function listWorkerConsignments(
|
||||
}
|
||||
}
|
||||
|
||||
const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => {
|
||||
const buildOrphanInventoryItem = (inv: (typeof inventories)[number], livePriceCapFen: number) => {
|
||||
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
|
||||
const boundWo = inv.bound_work_order_id
|
||||
? workOrderById.get(Number(inv.bound_work_order_id))
|
||||
@@ -846,7 +900,7 @@ export async function listWorkerConsignments(
|
||||
specText: inv.spec_text || '',
|
||||
askingPriceFen: inv.cost_price_fen,
|
||||
salePriceFen: Number(inv.sale_price_fen || inv.cost_price_fen || 0),
|
||||
priceCapFen: Number(inv.price_cap_fen || 0),
|
||||
priceCapFen: livePriceCapFen,
|
||||
quantity: Math.max(1, Number(inv.quantity || 1)),
|
||||
remainingQuantity: Math.max(0, Number(inv.remaining_quantity ?? 0)),
|
||||
listedAt: inv.listed_at || inv.inbound_at || null,
|
||||
@@ -879,15 +933,16 @@ export async function listWorkerConsignments(
|
||||
.map((row) => {
|
||||
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
|
||||
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
|
||||
const livePriceCapFen = livePriceCapByRow.get(row) ?? 0
|
||||
if (row.allocationId && inv) {
|
||||
const wo = workOrderById.get(Number(row.workOrderId || 0))
|
||||
if (wo) {
|
||||
const share = row.shareId ? shareById.get(Number(row.shareId)) : undefined
|
||||
return buildAllocationItem(row, sub, inv, wo, share)
|
||||
return buildAllocationItem(row, sub, inv, wo, share, livePriceCapFen)
|
||||
}
|
||||
}
|
||||
if (sub) return buildSubmissionItem(sub, inv)
|
||||
return inv ? buildOrphanInventoryItem(inv) : null
|
||||
if (sub) return buildSubmissionItem(sub, inv, livePriceCapFen)
|
||||
return inv ? buildOrphanInventoryItem(inv, livePriceCapFen) : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
|
||||
|
||||
@@ -1865,13 +1865,14 @@ export default function ConsignmentConfigPanel() {
|
||||
{priceDropEnabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="未售出时长(小时)"
|
||||
label="未售出时长(小时,可填一位小数)"
|
||||
name={['priceDrop', 'hours']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
min={0.1}
|
||||
step={0.5}
|
||||
precision={1}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
|
||||
@@ -1920,6 +1920,15 @@ function ConsignmentRecordCard({
|
||||
{record.priceCapFen && record.priceCapFen > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
|
||||
最高不能超过系统建议单价 ¥{(record.priceCapFen / 100).toFixed(2)}
|
||||
(按当前模板价实时计算)
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{record.priceCapFen &&
|
||||
record.priceCapFen > 0 &&
|
||||
Number(record.salePriceFen ?? record.askingPriceFen) > record.priceCapFen ? (
|
||||
<Typography.Text type="warning" style={{ display: 'block', marginTop: 4 }}>
|
||||
当前售价 ¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)}
|
||||
高于最新模板价,保存前请先下调到 ¥{(record.priceCapFen / 100).toFixed(2)} 以内
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{Number(record.quantity || 1) > 1 ? (
|
||||
|
||||
@@ -115,3 +115,12 @@ test('打手端:动态规格 + 系统建议价 + 改价不得高于建议价',
|
||||
assert.ok(page.includes('specValues'), '发布必须提交动态规格值')
|
||||
assert.ok(page.includes('config.pricing.allowWorkerPrice'), '改价能力必须按配置')
|
||||
})
|
||||
|
||||
test('寄卖配置:未售出时长支持一位小数', () => {
|
||||
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
|
||||
const start = panel.indexOf("name={['priceDrop', 'hours']}")
|
||||
assert.ok(start > 0, '必须存在未售出时长配置')
|
||||
const block = panel.slice(start, panel.indexOf('</Form.Item>', start))
|
||||
assert.ok(block.includes('precision={1}'), '未售出时长必须支持一位小数')
|
||||
assert.ok(block.includes('min={0.1}'), '未售出时长下限应为 0.1 小时')
|
||||
})
|
||||
|
||||
@@ -115,4 +115,6 @@ test('打手端价格按单价展示并给出合计,避免把单价误读成
|
||||
page.includes('最高不能超过系统建议单价'),
|
||||
'改价上限提示必须按单价口径,不能再说成整单建议价',
|
||||
)
|
||||
assert.ok(page.includes('按当前模板价实时计算'), '改价上限必须标明按当前模板价实时计算')
|
||||
assert.ok(page.includes('高于最新模板价'), '当前售价高于模板价时必须提示先下调')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user