fix(consignment): 修复内部匹配绑定与留痕并顺延迁移

This commit is contained in:
2026-11-06 11:21:50 +08:00
parent e30a5fa67e
commit 3f16b15c5c
15 changed files with 1473 additions and 128 deletions
+12 -4
View File
@@ -25,15 +25,23 @@ const MIGRATIONS_TABLE = 'schema_migrations'
const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/ const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/
const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i
/** /**
* 合并 main 后寄卖迁移从 129-131 顺延为 135-137(main 已占用 129-134)。 * 合并 main 后寄卖迁移编号顺延:
* 129-131 -> 135-137(main 占用 129-134)
* 135-137 -> 142-144(main 又占用 135-141)
* 已跑过旧编号的环境要清掉旧记录,让新编号按正常顺序重跑(迁移本身幂等)。 * 已跑过旧编号的环境要清掉旧记录,让新编号按正常顺序重跑(迁移本身幂等)。
*/ */
const RENAMED_MIGRATIONS: Array<{ legacyName: string; currentName: string }> = [ const RENAMED_MIGRATIONS: Array<{ legacyName: string; currentName: string }> = [
{ legacyName: '129_consignment_matching', currentName: '135_consignment_matching' }, { legacyName: '129_consignment_matching', currentName: '142_consignment_matching' },
{ legacyName: '130_consignment_dynamic_specs', currentName: '136_consignment_dynamic_specs' }, { legacyName: '135_consignment_matching', currentName: '142_consignment_matching' },
{ legacyName: '130_consignment_dynamic_specs', currentName: '143_consignment_dynamic_specs' },
{ legacyName: '136_consignment_dynamic_specs', currentName: '143_consignment_dynamic_specs' },
{ {
legacyName: '131_consignment_surnan_default_profile', legacyName: '131_consignment_surnan_default_profile',
currentName: '137_consignment_surnan_default_profile', currentName: '144_consignment_surnan_default_profile',
},
{
legacyName: '137_consignment_surnan_default_profile',
currentName: '144_consignment_surnan_default_profile',
}, },
] ]
@@ -1,4 +1,4 @@
-- 135_consignment_matching.sql -- 142_consignment_matching.sql
-- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。 -- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。
-- 全部语句幂等,可安全重跑;不改动历史迁移。 -- 全部语句幂等,可安全重跑;不改动历史迁移。
@@ -1,4 +1,4 @@
-- 136_consignment_dynamic_specs.sql -- 143_consignment_dynamic_specs.sql
-- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。 -- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。
-- 全部语句幂等,可安全重跑;不改动历史迁移。 -- 全部语句幂等,可安全重跑;不改动历史迁移。
@@ -1,4 +1,4 @@
-- 137_consignment_surnan_default_profile.sql -- 144_consignment_surnan_default_profile.sql
-- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。 -- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。
-- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。 -- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。
@@ -0,0 +1,8 @@
-- 145_consignment_inventory_multi_binding.sql
-- 拼单匹配允许同一张工单绑定多条寄卖库存;旧的“一单一件库存”唯一索引会挡住第二个份额。
-- 保留普通索引用于按工单反查库存;数据完整性由分配表和拼单份额表保证。
DROP INDEX IF EXISTS uniq_consignment_inventories_bound_work_order;
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_bound_work_order
ON consignment_inventories (bound_work_order_id)
WHERE bound_work_order_id IS NOT NULL;
@@ -25,6 +25,10 @@ export async function reserveConsignmentInventoryWithClient(
quantity?: number quantity?: number
unitPriceFen: number unitPriceFen: number
now: string now: string
/** 占用后用于卖家端关联工单的绑定信息(内部匹配/自动匹配都要写) */
boundOrderId?: number | null
boundPlatformOrderId?: string
shopName?: string
}, },
): Promise<ConsignmentAllocationRow | null> { ): Promise<ConsignmentAllocationRow | null> {
const quantity = Math.max(1, Math.round(Number(input.quantity || 1))) const quantity = Math.max(1, Math.round(Number(input.quantity || 1)))
@@ -40,6 +44,10 @@ export async function reserveConsignmentInventoryWithClient(
remaining_quantity = remaining_quantity - $1, remaining_quantity = remaining_quantity - $1,
status = CASE WHEN remaining_quantity - $1 <= 0 THEN 'consumed' ELSE status END, status = CASE WHEN remaining_quantity - $1 <= 0 THEN 'consumed' ELSE status END,
allocation_status = '已安排', allocation_status = '已安排',
bound_order_id = $4,
bound_work_order_id = $5,
bound_platform_order_id = $6,
shop_name = $7,
consumed_at = CASE WHEN remaining_quantity - $1 <= 0 THEN $2 ELSE consumed_at END, consumed_at = CASE WHEN remaining_quantity - $1 <= 0 THEN $2 ELSE consumed_at END,
updated_at = $2 updated_at = $2
WHERE id = $3 WHERE id = $3
@@ -48,7 +56,15 @@ export async function reserveConsignmentInventoryWithClient(
AND remaining_quantity >= $1 AND remaining_quantity >= $1
RETURNING id, remaining_quantity, status, allow_matching RETURNING id, remaining_quantity, status, allow_matching
`, `,
[quantity, input.now, input.inventoryId], [
quantity,
input.now,
input.inventoryId,
input.boundOrderId ?? null,
input.workOrderId,
String(input.boundPlatformOrderId || '').trim(),
String(input.shopName || '').trim(),
],
) )
if (!inventory.rows[0]) return null if (!inventory.rows[0]) return null
@@ -96,6 +112,29 @@ async function releaseAllocationRows(
`UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`, `UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`,
[now, row.id], [now, row.id],
) )
// 没有其它在途占用了才清空卖家端绑定,避免部分释放时把仍在履约的工单抹掉。
const active = await client.query<{ count: number }>(
`SELECT COUNT(*)::integer AS count
FROM consignment_inventory_allocations
WHERE inventory_id = $1 AND status IN ('reserved', 'consumed')`,
[row.inventory_id],
)
if (Number(active.rows[0]?.count || 0) === 0) {
await client.query(
`
UPDATE consignment_inventories
SET
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
allocation_status = CASE WHEN status = 'in_stock' THEN '待安排' ELSE allocation_status END,
updated_at = $1
WHERE id = $2
`,
[now, row.inventory_id],
)
}
} }
} }
@@ -4,8 +4,18 @@ import test from 'node:test'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),且计数查询带上同一个 join', async () => { /**
* 读取仓库源码并统一换行为 LF。
* 仓库在 Windows 上检出为 CRLF,若直接用 `\n}\n` 定位函数结尾会匹配失败,
* slice(start, -1) 会把整份文件都包进来,断言就失去意义(曾因此漏掉真实的 SELECT *)。
*/
async function readRepoSource(): Promise<string> {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
return source.replace(/\r\n/g, '\n')
}
test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),且计数查询带上同一个 join', async () => {
const source = await readRepoSource()
// 卖家端与客服端现在只把提报单号(SUB…)当主标识展示,搜不到它客服等于瞎了 // 卖家端与客服端现在只把提报单号(SUB…)当主标识展示,搜不到它客服等于瞎了
assert.ok(source.includes('OR cs.submission_no ILIKE $${values.length}')) assert.ok(source.includes('OR cs.submission_no ILIKE $${values.length}'))
// 条件里引用了 cs.submission_no,计数 SQL 就必须同样 join,否则 COUNT 直接 500 // 条件里引用了 cs.submission_no,计数 SQL 就必须同样 join,否则 COUNT 直接 500
@@ -22,7 +32,7 @@ test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),
}) })
test('寄卖仓库搜索必须覆盖供货打手姓名、打手电话与页面展示的卖家电话', async () => { test('寄卖仓库搜索必须覆盖供货打手姓名、打手电话与页面展示的卖家电话', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const inventoryListStart = source.indexOf('export async function listConsignmentInventories') const inventoryListStart = source.indexOf('export async function listConsignmentInventories')
const inventoryListBody = source.slice( const inventoryListBody = source.slice(
inventoryListStart, inventoryListStart,
@@ -55,7 +65,7 @@ test('寄卖仓库搜索必须覆盖供货打手姓名、打手电话与页面
}) })
test('寄卖仓库按通过审核入库时间筛选,结束日期必须包含当天全天', async () => { test('寄卖仓库按通过审核入库时间筛选,结束日期必须包含当天全天', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const inventoryListStart = source.indexOf('export async function listConsignmentInventories') const inventoryListStart = source.indexOf('export async function listConsignmentInventories')
const inventoryListBody = source.slice( const inventoryListBody = source.slice(
inventoryListStart, inventoryListStart,
@@ -77,7 +87,7 @@ test('寄卖仓库按通过审核入库时间筛选,结束日期必须包含
}) })
test('匹配候选要带出寄卖单号,并且 join 之后所有列名都必须加 ci. 前缀', async () => { test('匹配候选要带出寄卖单号,并且 join 之后所有列名都必须加 ci. 前缀', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const start = source.indexOf('export async function listActiveConsignmentInventoriesForMatching') const start = source.indexOf('export async function listActiveConsignmentInventoriesForMatching')
assert.ok(start > 0, '未找到匹配候选查询') assert.ok(start > 0, '未找到匹配候选查询')
const body = source.slice(start, source.indexOf('\n}\n', start)) const body = source.slice(start, source.indexOf('\n}\n', start))
@@ -87,16 +97,19 @@ test('匹配候选要带出寄卖单号,并且 join 之后所有列名都必
assert.ok(body.includes('LEFT JOIN consignment_submissions cs')) assert.ok(body.includes('LEFT JOIN consignment_submissions cs'))
// consignment_submissions 也有 status/game_name/category_id:不加前缀会 ambiguous column 直接 500 // consignment_submissions 也有 status/game_name/category_id:不加前缀会 ambiguous column 直接 500
assert.ok( assert.ok(
!/WHERE[^`]*[^.]\bstatus = /.test(body.replace(/ci\.status/g, 'X')), body.includes("ci.status = 'in_stock'"),
'存在未加前缀的 status', 'status 条件必须带 ci. 前缀,避免 join 后 ambiguous column',
)
assert.ok(
!/(?<!ci\.)\bstatus = 'in_stock'/.test(body),
'出现未加 ci. 前缀的 status 条件,join 后必然 ambiguous column',
) )
assert.ok(body.includes("ci.status = 'in_stock'"))
assert.ok(body.includes('ci.allow_matching = TRUE')) assert.ok(body.includes('ci.allow_matching = TRUE'))
assert.ok(!body.includes('SELECT *'), 'SELECT * 会把两表同名列搞乱,必须显式列出') assert.ok(!/SELECT\s+\*/.test(body), 'SELECT * 会把两表同名列搞乱,必须显式列出(允许 ci.*)')
}) })
test('占库写入的 allocation_status 默认值必须在 080 CHECK 枚举(待安排/已安排)内', async () => { test('占库写入的 allocation_status 默认值必须在 080 CHECK 枚举(待安排/已安排)内', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const start = source.indexOf('export async function claimConsignmentInventoryForWorkerWithClient') const start = source.indexOf('export async function claimConsignmentInventoryForWorkerWithClient')
assert.ok(start > 0, '未找到打手指派占库函数') assert.ok(start > 0, '未找到打手指派占库函数')
const body = source.slice(start, source.indexOf('\n}\n', start)) const body = source.slice(start, source.indexOf('\n}\n', start))
@@ -120,7 +133,7 @@ function readWorkerPageQuery(source: string) {
} }
test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/区服/钻级/角色/买家)', async () => { test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/区服/钻级/角色/买家)', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const body = readWorkerPageQuery(source) const body = readWorkerPageQuery(source)
for (const field of [ for (const field of [
@@ -140,13 +153,14 @@ test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/
} }
// 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错 // 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错
const orphanStart = body.indexOf('NULL AS submission_id') const orphanStart = body.indexOf('orphanKeywordFilter = `')
const orphanBody = body.slice(orphanStart, body.indexOf(')\n `', orphanStart)) const orphanEnd = body.indexOf('`', orphanStart + 'orphanKeywordFilter = `'.length)
const orphanBody = body.slice(orphanStart, orphanEnd)
assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.') assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.')
}) })
test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => { test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const body = readWorkerPageQuery(source) const body = readWorkerPageQuery(source)
assert.ok(body.includes('ORDER BY sort_at DESC'), '排序必须在 SQL 完成') assert.ok(body.includes('ORDER BY sort_at DESC'), '排序必须在 SQL 完成')
@@ -158,7 +172,7 @@ test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记
}) })
test('我的寄卖:SQL 里的分组口径与状态机的 group 映射保持一致', async () => { test('我的寄卖:SQL 里的分组口径与状态机的 group 映射保持一致', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const source = await readRepoSource()
const body = readWorkerPageQuery(source) const body = readWorkerPageQuery(source)
// 真源是 domain/consignment-item-stage.ts 的 STAGE_META.group: // 真源是 domain/consignment-item-stage.ts 的 STAGE_META.group:
@@ -66,8 +66,33 @@ function inventoryMatchesProfile(
if (!profile) return true if (!profile) return true
const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null
if (inventoryProfileId) return inventoryProfileId === profile.profileId if (inventoryProfileId) return inventoryProfileId === profile.profileId
// 存量库存没有 profile_id:按分类兜底(配置挂一级分类时同样命中)。 const inventoryCategoryId = Number(inventory.category_id || 0)
return Number(inventory.category_id || 0) === profile.categoryId if (inventoryCategoryId) return inventoryCategoryId === profile.categoryId
// 历史库存没有 profile_id/category_id(手工入库时代的数据):内置五字段配置可以
// 交给旧匹配器的品类门禁兜底,避免这些货在新匹配弹窗里彻底消失;
// 含自定义字段的配置无法判断归属,仍然要求显式分类。
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
}
function compareInventoryCandidates(
a: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
b: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
preferLowReward: boolean,
): number {
if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1
const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0
const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0
if (preferLowReward) {
const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory)
if (priceDiff !== 0) return priceDiff
if (listedA !== listedB) return listedA - listedB
} else {
// 关闭「优先低价」后按入库先后排队,价格只作为同时间的次序。
if (listedA !== listedB) return listedA - listedB
const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory)
if (priceDiff !== 0) return priceDiff
}
return Number(a.inventory.id) - Number(b.inventory.id)
} }
type InventoryMatchEvaluation = { type InventoryMatchEvaluation = {
@@ -82,6 +107,20 @@ type InventoryMatchEvaluation = {
details: ReturnType<typeof evaluateConsignmentSpecMatch>['details'] details: ReturnType<typeof evaluateConsignmentSpecMatch>['details']
} }
type ConsignmentMatchInventoryCandidate = {
inventory: ConsignmentInventoryRow
evaluation: InventoryMatchEvaluation
}
type ConsignmentMatchContext = {
matchType: 'auto' | 'manual'
actorName?: string
reason?: string
allowNonExact?: boolean
specConflicts?: string[]
partial?: boolean
}
function resolveInventorySpecs( function resolveInventorySpecs(
inventory: ConsignmentInventoryRow, inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null, profile: ConsignmentProfileSnapshot | null,
@@ -169,17 +208,6 @@ function evaluateInventoryMatch(
} }
} }
function inventoryMatchesWorkOrder(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null = null,
): boolean {
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
if (!inventory.allow_matching) return false
if (inventory.status !== 'in_stock') return false
return evaluateInventoryMatch(workOrder, inventory, profile).exact
}
async function resolveProfileForWorkOrder( async function resolveProfileForWorkOrder(
workOrder: WorkOrderRow, workOrder: WorkOrderRow,
): Promise<ConsignmentProfileSnapshot | null> { ): Promise<ConsignmentProfileSnapshot | null> {
@@ -293,30 +321,34 @@ function pickCandidateInventories(
workOrder: WorkOrderRow, workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null, profile: ConsignmentProfileSnapshot | null,
inventories: ConsignmentInventoryRow[], inventories: ConsignmentInventoryRow[],
): ConsignmentInventoryRow[] { options: { allowPartial?: boolean; preferLowReward?: boolean } = {},
): ConsignmentMatchInventoryCandidate[] {
const allowPartial = Boolean(options.allowPartial)
const preferLowReward = options.preferLowReward !== false
return inventories return inventories
.filter((inventory) => inventoryMatchesProfile(inventory, profile))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
}))
.filter( .filter(
(inventory) => ({ evaluation }) =>
inventoryMatchesProfile(inventory, profile) && evaluation.exact ||
inventoryMatchesWorkOrder(workOrder, inventory, profile), (allowPartial && evaluation.partial && evaluation.conflicts.length === 0),
) )
.sort((a, b) => { .sort((a, b) => compareInventoryCandidates(a, b, preferLowReward))
const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b)
if (priceDiff !== 0) return priceDiff
const listedA = Date.parse(String(a.listed_at || a.inbound_at || '')) || 0
const listedB = Date.parse(String(b.listed_at || b.inbound_at || '')) || 0
if (listedA !== listedB) return listedA - listedB
return Number(a.id) - Number(b.id)
})
} }
async function applyWholeOrderMatch( async function applyWholeOrderMatch(
input: MatchCandidate, input: MatchCandidate,
inventory: ConsignmentInventoryRow, candidate: ConsignmentMatchInventoryCandidate,
context: ConsignmentMatchContext = { matchType: 'auto' },
): Promise<boolean> { ): Promise<boolean> {
const { workOrder, profile } = input const { workOrder, profile } = input
const inventory = candidate.inventory
if (!workOrder.id || !inventory.worker_id) return false if (!workOrder.id || !inventory.worker_id) return false
const now = nowIso() const now = nowIso()
const manual = context.matchType === 'manual'
return withTransaction(async (client: PoolClient) => { return withTransaction(async (client: PoolClient) => {
const locked = await client.query<WorkOrderRow>( const locked = await client.query<WorkOrderRow>(
@@ -327,12 +359,18 @@ async function applyWholeOrderMatch(
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return false if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) return false
if (current.assigned_worker_id) return false if (current.assigned_worker_id) return false
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
const allocation = await reserveConsignmentInventoryWithClient(client, { const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id), inventoryId: Number(inventory.id),
workOrderId: Number(current.id), workOrderId: Number(current.id),
quantity: 1, quantity: 1,
unitPriceFen: inventorySalePriceFen(inventory), unitPriceFen: inventorySalePriceFen(inventory),
now, now,
boundOrderId: current.order_id ? Number(current.order_id) : null,
boundPlatformOrderId: current.platform_order_id || '',
shopName,
}) })
if (!allocation) return false if (!allocation) return false
@@ -342,7 +380,6 @@ async function applyWholeOrderMatch(
const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen) const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen)
const timeoutMinutes = config?.timeout.minutes ?? 30 const timeoutMinutes = config?.timeout.minutes ?? 30
const timeoutPolicy = config?.timeout.policy ?? 'reopen' const timeoutPolicy = config?.timeout.policy ?? 'reopen'
const material = parseMaterial(current.material_json)
const nextMaterial = { const nextMaterial = {
...material, ...material,
consignment: { consignment: {
@@ -364,7 +401,19 @@ async function applyWholeOrderMatch(
workerId: Number(inventory.worker_id), workerId: Number(inventory.worker_id),
workerName: inventory.worker_name || '', workerName: inventory.worker_name || '',
matchedAt: now, matchedAt: now,
matchType: 'auto', matchType: manual ? 'manual' : 'auto',
...(manual
? {
manualMatch: true,
fulfilledBy: context.actorName || '',
...(context.reason ? { matchNote: context.reason } : {}),
...(context.allowNonExact ? { allowNonExact: true } : {}),
...(context.specConflicts?.length
? { specConflicts: [...context.specConflicts] }
: {}),
}
: {}),
...(context.partial ? { partialMatch: true } : {}),
}, },
} }
@@ -402,9 +451,9 @@ async function applyWholeOrderMatch(
await createWorkOrderEventWithClient(client, { await createWorkOrderEventWithClient(client, {
workOrderId: Number(current.id), workOrderId: Number(current.id),
actorType: 'system', actorType: manual ? 'admin' : 'system',
actorId: 'consignment_match', actorId: manual ? context.actorName || 'admin' : 'consignment_match',
eventType: 'consignment_auto_matched', eventType: manual ? 'consignment_manual_matched' : 'consignment_auto_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS, toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({ payloadJson: JSON.stringify({
@@ -417,6 +466,16 @@ async function applyWholeOrderMatch(
serviceFeeFen, serviceFeeFen,
netRewardFen, netRewardFen,
allocationId: allocation.id, allocationId: allocation.id,
matchType: manual ? 'manual' : 'auto',
...(manual
? {
actorName: context.actorName || '',
reason: context.reason || '',
allowNonExact: Boolean(context.allowNonExact),
specConflicts: context.specConflicts || [],
}
: {}),
...(context.partial ? { partialMatch: true } : {}),
}), }),
now, now,
}) })
@@ -426,12 +485,14 @@ async function applyWholeOrderMatch(
async function applySharingMatch( async function applySharingMatch(
input: MatchCandidate, input: MatchCandidate,
inventories: ConsignmentInventoryRow[], candidates: ConsignmentMatchInventoryCandidate[],
context: ConsignmentMatchContext = { matchType: 'auto' },
): Promise<number> { ): Promise<number> {
const { workOrder, profile } = input const { workOrder, profile } = input
const now = nowIso() const now = nowIso()
const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
if (!workOrder.id || totalQuantity <= 1) return 0 if (!workOrder.id || totalQuantity <= 1) return 0
const manual = context.matchType === 'manual'
return withTransaction(async (client: PoolClient) => { return withTransaction(async (client: PoolClient) => {
const locked = await client.query<WorkOrderRow>( const locked = await client.query<WorkOrderRow>(
@@ -459,14 +520,15 @@ async function applySharingMatch(
const shareGrossFen = Math.max(0, Math.round(unitRewardFen)) const shareGrossFen = Math.max(0, Math.round(unitRewardFen))
const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0 const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0
const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen) const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen)
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
let createdCount = 0 let createdCount = 0
let firstInventoryId: number | null = null let firstInventoryId: number | null = null
for (const inventory of inventories) { for (const { inventory } of candidates) {
if (remaining <= 0) break if (remaining <= 0) break
if (!inventory.worker_id) continue if (!inventory.worker_id) continue
if (!inventoryMatchesProfile(inventory, profile)) continue
if (!inventoryMatchesWorkOrder(current, inventory, profile)) continue
const allocation = await reserveConsignmentInventoryWithClient(client, { const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id), inventoryId: Number(inventory.id),
@@ -474,6 +536,9 @@ async function applySharingMatch(
quantity: 1, quantity: 1,
unitPriceFen: inventorySalePriceFen(inventory), unitPriceFen: inventorySalePriceFen(inventory),
now, now,
boundOrderId: current.order_id ? Number(current.order_id) : null,
boundPlatformOrderId: current.platform_order_id || '',
shopName,
}) })
if (!allocation) continue if (!allocation) continue
@@ -515,7 +580,6 @@ async function applySharingMatch(
if (createdCount > 0) { if (createdCount > 0) {
active += createdCount active += createdCount
const material = parseMaterial(current.material_json)
const nextStatus = const nextStatus =
active >= totalQuantity active >= totalQuantity
? WORK_ORDER_STATUS.IN_PROGRESS ? WORK_ORDER_STATUS.IN_PROGRESS
@@ -542,7 +606,19 @@ async function applySharingMatch(
inventoryId: firstInventoryId, inventoryId: firstInventoryId,
profileId: profile?.profileId ?? null, profileId: profile?.profileId ?? null,
matchedAt: now, matchedAt: now,
matchType: 'auto_sharing', matchType: manual ? 'manual_sharing' : 'auto_sharing',
...(manual
? {
manualMatch: true,
fulfilledBy: context.actorName || '',
...(context.reason ? { matchNote: context.reason } : {}),
...(context.allowNonExact ? { allowNonExact: true } : {}),
...(context.specConflicts?.length
? { specConflicts: [...context.specConflicts] }
: {}),
}
: {}),
...(context.partial ? { partialMatch: true } : {}),
}, },
} }
: {}), : {}),
@@ -559,9 +635,9 @@ async function applySharingMatch(
) )
await createWorkOrderEventWithClient(client, { await createWorkOrderEventWithClient(client, {
workOrderId: Number(current.id), workOrderId: Number(current.id),
actorType: 'system', actorType: manual ? 'admin' : 'system',
actorId: 'consignment_match', actorId: manual ? context.actorName || 'admin' : 'consignment_match',
eventType: 'consignment_sharing_matched', eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
toStatus: nextStatus, toStatus: nextStatus,
payloadJson: JSON.stringify({ payloadJson: JSON.stringify({
@@ -570,6 +646,16 @@ async function applySharingMatch(
totalQuantity, totalQuantity,
shareRewardFen: shareNetFen, shareRewardFen: shareNetFen,
serviceFeeFenPerShare: shareFeeFen, serviceFeeFenPerShare: shareFeeFen,
matchType: manual ? 'manual_sharing' : 'auto_sharing',
...(manual
? {
actorName: context.actorName || '',
reason: context.reason || '',
allowNonExact: Boolean(context.allowNonExact),
specConflicts: context.specConflicts || [],
}
: {}),
...(context.partial ? { partialMatch: true } : {}),
}), }),
now, now,
}) })
@@ -598,17 +684,32 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) { if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) {
continue continue
} }
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( const inventories = await listActiveConsignmentInventoriesForMatching()
(inventory) => inventoryMatchesProfile(inventory, profile), const matching = profile.config.matching
) const candidates = pickCandidateInventories(workOrder, profile, inventories, {
const candidates = pickCandidateInventories(workOrder, profile, inventories) allowPartial: matching.exactOnly === false,
preferLowReward: matching.preferLowReward !== false,
})
if (candidates.length === 0) continue if (candidates.length === 0) continue
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
const created = await applySharingMatch({ workOrder, profile }, candidates) const context: ConsignmentMatchContext = {
matchType: 'auto',
partial: candidates.some(({ evaluation }) => !evaluation.exact),
specConflicts: candidates.flatMap(({ evaluation }) => [
...evaluation.conflicts,
...evaluation.unknowns,
]),
}
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
if (created > 0) matched += 1 if (created > 0) matched += 1
} else { } else {
const applied = await applyWholeOrderMatch({ workOrder, profile }, candidates[0]!) const chosen = candidates[0]!
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, {
matchType: 'auto',
partial: !chosen.evaluation.exact,
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
})
if (applied) matched += 1 if (applied) matched += 1
} }
} catch { } catch {
@@ -669,15 +770,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
inventory, inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile), evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
})) }))
.sort((a, b) => { .sort((a, b) =>
if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1 compareInventoryCandidates(a, b, profile?.config.matching.preferLowReward !== false),
const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory) )
if (priceDiff !== 0) return priceDiff
const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0
const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0
if (listedA !== listedB) return listedA - listedB
return Number(a.inventory.id) - Number(b.inventory.id)
})
const options = evaluated.map(({ inventory, evaluation }) => ({ const options = evaluated.map(({ inventory, evaluation }) => ({
inventoryId: Number(inventory.id), inventoryId: Number(inventory.id),
@@ -755,6 +850,10 @@ export async function matchConsignmentWorkOrderManually(input: {
const selected = input.inventoryIds const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id))) .map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
.filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory)) .filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
}))
if (selected.length === 0) { if (selected.length === 0) {
throw createHttpError('请选择可用的寄卖库存', { throw createHttpError('请选择可用的寄卖库存', {
@@ -763,12 +862,7 @@ export async function matchConsignmentWorkOrderManually(input: {
}) })
} }
const nonExact = selected const nonExact = selected.filter((item) => !item.evaluation.exact)
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
}))
.filter((item) => !item.evaluation.exact)
if (nonExact.length > 0) { if (nonExact.length > 0) {
if (!input.allowNonExact) { if (!input.allowNonExact) {
const details = nonExact const details = nonExact
@@ -790,6 +884,18 @@ export async function matchConsignmentWorkOrderManually(input: {
} }
} }
const context: ConsignmentMatchContext = {
matchType: 'manual',
actorName: String(input.actorName || '').trim() || 'admin',
reason: String(input.reason || '').trim(),
allowNonExact: Boolean(input.allowNonExact),
partial: nonExact.length > 0,
specConflicts: nonExact.flatMap(({ evaluation }) => [
...evaluation.conflicts,
...evaluation.unknowns,
]),
}
const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING } const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING }
if (!workOrder.sharing_enabled) { if (!workOrder.sharing_enabled) {
await query( await query(
@@ -804,11 +910,19 @@ export async function matchConsignmentWorkOrderManually(input: {
`UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`, `UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`,
[nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]], [nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]],
) )
const created = await applySharingMatch({ workOrder: appliedWorkOrder, profile }, selected) const created = await applySharingMatch(
{ workOrder: appliedWorkOrder, profile },
selected,
context,
)
return { matched: created > 0, matchedCount: created } return { matched: created > 0, matchedCount: created }
} }
const applied = await applyWholeOrderMatch({ workOrder: appliedWorkOrder, profile }, selected[0]!) const applied = await applyWholeOrderMatch(
{ workOrder: appliedWorkOrder, profile },
selected[0]!,
context,
)
return { matched: applied, matchedCount: applied ? 1 : 0 } return { matched: applied, matchedCount: applied ? 1 : 0 }
} }
@@ -87,9 +87,9 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售
assert.equal(suggestion?.dropHours, 24) assert.equal(suggestion?.dropHours, 24)
}) })
test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', async () => { test('迁移 142 必须包含寄卖配置、库存数量/售价与分配表', async () => {
const source = await readFile( const source = await readFile(
new URL('../../db/migrations/135_consignment_matching.sql', import.meta.url), new URL('../../db/migrations/142_consignment_matching.sql', import.meta.url),
'utf8', 'utf8',
) )
for (const fragment of [ for (const fragment of [
@@ -101,6 +101,6 @@ test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', as
'internal_match_deadline_at', 'internal_match_deadline_at',
'consignment_inventory_id', 'consignment_inventory_id',
]) { ]) {
assert.ok(source.includes(fragment), `迁移 135 缺少 ${fragment}`) assert.ok(source.includes(fragment), `迁移 142 缺少 ${fragment}`)
} }
}) })
@@ -34,7 +34,7 @@ function option(label: string, value: string) {
return { label, value, enabled: true } return { label, value, enabled: true }
} }
/** 苏尔南手枪种子配置的五个字段(与迁移 137 一致)。 */ /** 苏尔南手枪种子配置的五个字段(与迁移 144 一致)。 */
function surnanSpecs(): ConsignmentSpecField[] { function surnanSpecs(): ConsignmentSpecField[] {
return [ return [
{ {
@@ -423,9 +423,9 @@ test('动态规格:旧列仍由兼容 key 同步', () => {
}) })
}) })
test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async () => { test('迁移 143 必须包含 JSON、匹配 key、文本与改价上限', async () => {
const sql = await readFile( const sql = await readFile(
new URL('../../db/migrations/136_consignment_dynamic_specs.sql', import.meta.url), new URL('../../db/migrations/143_consignment_dynamic_specs.sql', import.meta.url),
'utf8', 'utf8',
) )
for (const fragment of [ for (const fragment of [
@@ -433,16 +433,15 @@ test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async
'spec_key', 'spec_key',
'spec_text', 'spec_text',
'price_cap_fen', 'price_cap_fen',
'idx_consignment_inventories_spec_key', 'CREATE INDEX IF NOT EXISTS idx_consignment_inventories_spec_key',
]) { ]) {
assert.ok(sql.includes(fragment), `迁移 136 缺少 ${fragment}`) assert.ok(sql.includes(fragment), `迁移 143 缺少 ${fragment}`)
} }
assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果')
}) })
test('迁移 137 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => { test('迁移 144 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
const sql = await readFile( const sql = await readFile(
new URL('../../db/migrations/137_consignment_surnan_default_profile.sql', import.meta.url), new URL('../../db/migrations/144_consignment_surnan_default_profile.sql', import.meta.url),
'utf8', 'utf8',
) )
for (const fragment of [ for (const fragment of [
@@ -452,6 +451,6 @@ test('迁移 137 必须为苏尔南手枪写入已发布的五个单选字段配
"'published'", "'published'",
'"key": "brillianceCount"', '"key": "brillianceCount"',
]) { ]) {
assert.ok(sql.includes(fragment), `迁移 137 缺少 ${fragment}`) assert.ok(sql.includes(fragment), `迁移 144 缺少 ${fragment}`)
} }
}) })
@@ -615,7 +615,10 @@ export function resolveWorkerConsignmentPrice(input: {
if (priceFen > 0) { if (priceFen > 0) {
if (submittedPriceFen > 0) { if (submittedPriceFen > 0) {
if (!input.pricing.allowWorkerPrice) { // 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
// 不能把按建议价直接发布也一起拦掉。
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
errors.push('当前寄卖配置不允许修改售价') errors.push('当前寄卖配置不允许修改售价')
} else if (submittedPriceFen > computed.capFen) { } else if (submittedPriceFen > computed.capFen) {
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`) errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
@@ -2,10 +2,75 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import test from 'node:test' import test from 'node:test'
import {
normalizeConsignmentSpecValues,
resolveWorkerConsignmentPrice,
type ConsignmentSpecField,
type ConsignmentPricingConfig,
} from './consignment-spec-service.js'
async function readServiceSource() { async function readServiceSource() {
return readFile(new URL('./consignment-worker-service.ts', import.meta.url), 'utf8') return readFile(new URL('./consignment-worker-service.ts', import.meta.url), 'utf8')
} }
function radioField(
key: string,
label: string,
options: { label: string; value: string }[],
): ConsignmentSpecField {
return {
key,
label,
type: 'radio',
required: true,
options: options.map((option) => ({ ...option, enabled: true })),
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
sortOrder: 10,
}
}
const PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
}
function baseSpecs(): ConsignmentSpecField[] {
return [
radioField('system', '操作系统', [
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
]),
radioField('serverZone', '游戏区服', [
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通(QQ/微信)', value: '互通' },
]),
radioField('series', '系列', [
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
]),
radioField(
'diamondLevel',
'钻石等级',
[1, 2, 3].map((n) => ({ label: `${n}钻`, value: String(n) })),
),
radioField(
'brillianceCount',
'璀璨数',
[0, 1, 2, 3].map((n) => ({ label: n === 0 ? '无' : `${n}璀`, value: String(n) })),
),
]
}
test('我的寄卖以提报单号为唯一锚点聚合,不再返回 submissions/inventories 两个数组', async () => { test('我的寄卖以提报单号为唯一锚点聚合,不再返回 submissions/inventories 两个数组', async () => {
const serviceSource = await readServiceSource() const serviceSource = await readServiceSource()
// 以前接口返回两个数组、前端分两个列表渲染 → 同一件货两行两个号 // 以前接口返回两个数组、前端分两个列表渲染 → 同一件货两行两个号
@@ -56,34 +121,86 @@ test('没有提报单的兜底行不给卖家操作能力,也不暴露内部
assert.ok(serviceSource.includes('canConfirmFriend: false, canSubmitEvidence: false')) assert.ok(serviceSource.includes('canConfirmFriend: false, canSubmitEvidence: false'))
}) })
test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', async () => { test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', () => {
const serviceSource = await readServiceSource() const invalidSystem = normalizeConsignmentSpecValues(baseSpecs(), {
specValues: {
system: '通用',
serverZone: '微信区',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
})
assert.ok( assert.ok(
serviceSource.includes("errorCode: 'consignment_system_invalid'"), invalidSystem.errors.some((error) => error.includes('操作系统')),
'系统取值不合法要有显式拒绝,防止旧页面缓存绕过前端选项', '系统取值不合法要有显式拒绝,防止旧页面缓存绕过前端选项',
) )
assert.ok(serviceSource.includes("errorCode: 'consignment_server_zone_invalid'")) const invalidZone = normalizeConsignmentSpecValues(baseSpecs(), {
// 校验必须是白名单式(只认 苹果/安卓 与 微信区/QQ区/互通),而不是只挡「通用」 specValues: {
assert.ok(serviceSource.includes("system !== '苹果' && system !== '安卓'")) system: '苹果',
serverZone: '国际服',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
})
assert.ok( assert.ok(
serviceSource.includes( invalidZone.errors.some((error) => error.includes('游戏区服')),
"serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通'", '区服必须拒绝白名单之外的值',
),
) )
const valid = normalizeConsignmentSpecValues(baseSpecs(), {
specValues: {
system: '苹果',
serverZone: '互通',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
})
assert.deepEqual(valid.errors, [])
}) })
test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀以此类推)', async () => { test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀以此类推)', () => {
const serviceSource = await readServiceSource() const result = normalizeConsignmentSpecValues(baseSpecs(), {
specValues: {
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '2',
},
})
assert.ok( assert.ok(
serviceSource.includes('brillianceCount > diamondLevel'), result.errors.some((error) => error.includes('璀璨数不能超过钻石等级')),
'服务端必须校验璀璨数不能超过钻石等级', '服务端必须校验璀璨数不能超过钻石等级',
) )
assert.ok(
serviceSource.includes("errorCode: 'brilliance_exceeds_diamond'"),
'超限必须返回专门的错误码',
)
}) })
test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', () => {
const suggested = resolveWorkerConsignmentPrice({
pricing: PRICING,
templatePriceFen: 10000,
submittedPriceFen: 0,
})
assert.equal(suggested.priceFen, 10000)
assert.equal(suggested.capFen, 10000)
const lowered = resolveWorkerConsignmentPrice({
pricing: PRICING,
templatePriceFen: 10000,
submittedPriceFen: 9000,
})
assert.equal(lowered.priceFen, 9000)
// 禁用改价时,前端回填的“系统建议价”本身必须允许发布。
const suggestedWhenLocked = resolveWorkerConsignmentPrice({
pricing: { ...PRICING, allowWorkerPrice: false },
templatePriceFen: 10000,
submittedPriceFen: 10000,
})
assert.deepEqual(suggestedWhenLocked.errors, [])
assert.equal(suggestedWhenLocked.priceFen, 10000)
})
test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => { test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', async () => {
const serviceSource = await readServiceSource() const serviceSource = await readServiceSource()
assert.ok( assert.ok(
@@ -95,15 +212,3 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截',
'未开通必须返回专门的 consignment_permission_required 错误码', '未开通必须返回专门的 consignment_permission_required 错误码',
) )
}) })
test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', async () => {
const serviceSource = await readServiceSource()
assert.ok(
serviceSource.includes('getWorkerConsignmentPriceSuggestion({'),
'发布时必须按同一套模板规则复算价格',
)
assert.ok(
serviceSource.includes('templatePriceFen > 0 ? templatePriceFen : submittedPriceFen'),
'模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额',
)
})
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => { test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => {
// Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行
const source = readFileSync( const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname, new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
'utf8', 'utf8',
) ).replace(/\r\n/g, '\n')
assert.ok( assert.ok(
source.includes("from '@/components/admin/AdminDatePicker'"), source.includes("from '@/components/admin/AdminDatePicker'"),
@@ -20,7 +20,7 @@ test('后台全量选项:系统含通用,区服含互通', () => {
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」') assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
}) })
test('苏尔南默认配置:取消代码内置字段,由迁移 137 提供五个单选字段', () => { test('苏尔南默认配置:取消代码内置字段,由迁移 144 提供五个单选字段', () => {
const utils = readFileSync( const utils = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname, new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
'utf8', 'utf8',