fix(consignment): 修复内部匹配绑定与留痕并顺延迁移
This commit is contained in:
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
-- 135_consignment_matching.sql
|
-- 142_consignment_matching.sql
|
||||||
-- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。
|
-- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。
|
||||||
-- 全部语句幂等,可安全重跑;不改动历史迁移。
|
-- 全部语句幂等,可安全重跑;不改动历史迁移。
|
||||||
|
|
||||||
+1
-1
@@ -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
-1
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user