feat(consignment): 未售出时长支持一位小数,改价上限按当前模板价实时计算

This commit is contained in:
2026-11-08 12:23:33 +08:00
parent 1773fe7837
commit ac2411ab6e
8 changed files with 151 additions and 13 deletions
@@ -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('高于最新模板价'), '当前售价高于模板价时必须提示先下调')
})