From ac2411ab6ed2c0927be42b0b087c153f5305d84a Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 8 Nov 2026 12:23:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E6=9C=AA=E5=94=AE?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E9=95=BF=E6=94=AF=E6=8C=81=E4=B8=80=E4=BD=8D?= =?UTF-8?q?=E5=B0=8F=E6=95=B0=EF=BC=8C=E6=94=B9=E4=BB=B7=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=8C=89=E5=BD=93=E5=89=8D=E6=A8=A1=E6=9D=BF=E4=BB=B7=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consignment-profile-service.test.ts | 37 +++++++++- .../consignment-profile-service.ts | 10 ++- .../consignment-worker-service.test.ts | 19 +++++ .../consignment-worker-service.ts | 71 ++++++++++++++++--- .../admin/panels/ConsignmentConfigPanel.tsx | 7 +- .../pages/worker/WorkerConsignmentPage.tsx | 9 +++ .../tests/consignment-matching-config.test.ts | 9 +++ .../tests/consignment-publish-limits.test.ts | 2 + 8 files changed, 151 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index c2ae2a69..524ed768 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -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), diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 76f978b2..899a0132 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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), }, diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index 83393c83..95814d99 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -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'), '实时价取不到时才允许回退快照') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 82836c77..ab6a28eb 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -101,6 +101,32 @@ function parseSpecValues(value: unknown): ConsignmentSpecValues { return result } +/** + * 打手端改价上限按**当前**模板价实时计算:运营改了模板价,已上架商品的上限要立刻跟着变。 + * + * 同一请求内按「分类 + 规格」缓存 Promise,避免同规格重复查库; + * 取不到实时价(分类下线 / 模板删除 / 查询异常)时返回 0,调用方回退发布时的快照。 + */ +function createLivePriceCapResolver(): ( + profileId: number, + specValues: ConsignmentSpecValues, +) => Promise { + const pendingByKey = new Map>() + 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 => item !== null) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 858fdc85..6778c236 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -1865,13 +1865,14 @@ export default function ConsignmentConfigPanel() { {priceDropEnabled ? ( <> diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 56c6d7a3..005d6488 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -1920,6 +1920,15 @@ function ConsignmentRecordCard({ {record.priceCapFen && record.priceCapFen > 0 ? ( 最高不能超过系统建议单价 ¥{(record.priceCapFen / 100).toFixed(2)} + (按当前模板价实时计算) + + ) : null} + {record.priceCapFen && + record.priceCapFen > 0 && + Number(record.salePriceFen ?? record.askingPriceFen) > record.priceCapFen ? ( + + 当前售价 ¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)} + 高于最新模板价,保存前请先下调到 ¥{(record.priceCapFen / 100).toFixed(2)} 以内 ) : null} {Number(record.quantity || 1) > 1 ? ( diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index 6df2ab52..26510e24 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -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('', start)) + assert.ok(block.includes('precision={1}'), '未售出时长必须支持一位小数') + assert.ok(block.includes('min={0.1}'), '未售出时长下限应为 0.1 小时') +}) diff --git a/apps/frontend/tests/consignment-publish-limits.test.ts b/apps/frontend/tests/consignment-publish-limits.test.ts index ca14d4db..fff92b95 100644 --- a/apps/frontend/tests/consignment-publish-limits.test.ts +++ b/apps/frontend/tests/consignment-publish-limits.test.ts @@ -115,4 +115,6 @@ test('打手端价格按单价展示并给出合计,避免把单价误读成 page.includes('最高不能超过系统建议单价'), '改价上限提示必须按单价口径,不能再说成整单建议价', ) + assert.ok(page.includes('按当前模板价实时计算'), '改价上限必须标明按当前模板价实时计算') + assert.ok(page.includes('高于最新模板价'), '当前售价高于模板价时必须提示先下调') })