From 3f16b15c5c1791ca8a6609964a4a4818abed3dce Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 6 Nov 2026 11:21:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E4=BF=AE=E5=A4=8D=E5=86=85?= =?UTF-8?q?=E9=83=A8=E5=8C=B9=E9=85=8D=E7=BB=91=E5=AE=9A=E4=B8=8E=E7=95=99?= =?UTF-8?q?=E7=97=95=E5=B9=B6=E9=A1=BA=E5=BB=B6=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/db/migrate.ts | 16 +- ...ching.sql => 142_consignment_matching.sql} | 2 +- ....sql => 143_consignment_dynamic_specs.sql} | 2 +- ...44_consignment_surnan_default_profile.sql} | 2 +- ...45_consignment_inventory_multi_binding.sql | 8 + .../consignment-allocation-repo.ts | 41 +- .../worker-platform/consignment-repo.test.ts | 42 +- .../consignment-match-engine-service.ts | 240 +++- .../consignment-matching.integration.test.ts | 1054 +++++++++++++++++ .../consignment-profile-service.test.ts | 6 +- .../consignment-spec-service.test.ts | 17 +- .../consignment-spec-service.ts | 5 +- .../consignment-worker-service.test.ts | 161 ++- .../consignment-inventory-date-filter.test.ts | 3 +- .../tests/consignment-spec-options.test.ts | 2 +- 15 files changed, 1473 insertions(+), 128 deletions(-) rename apps/backend/src/db/migrations/{135_consignment_matching.sql => 142_consignment_matching.sql} (99%) rename apps/backend/src/db/migrations/{136_consignment_dynamic_specs.sql => 143_consignment_dynamic_specs.sql} (99%) rename apps/backend/src/db/migrations/{137_consignment_surnan_default_profile.sql => 144_consignment_surnan_default_profile.sql} (99%) create mode 100644 apps/backend/src/db/migrations/145_consignment_inventory_multi_binding.sql create mode 100644 apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 8e58c2e8..118ef5be 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -25,15 +25,23 @@ const MIGRATIONS_TABLE = 'schema_migrations' const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/ 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 }> = [ - { legacyName: '129_consignment_matching', currentName: '135_consignment_matching' }, - { legacyName: '130_consignment_dynamic_specs', currentName: '136_consignment_dynamic_specs' }, + { legacyName: '129_consignment_matching', currentName: '142_consignment_matching' }, + { 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', - currentName: '137_consignment_surnan_default_profile', + currentName: '144_consignment_surnan_default_profile', + }, + { + legacyName: '137_consignment_surnan_default_profile', + currentName: '144_consignment_surnan_default_profile', }, ] diff --git a/apps/backend/src/db/migrations/135_consignment_matching.sql b/apps/backend/src/db/migrations/142_consignment_matching.sql similarity index 99% rename from apps/backend/src/db/migrations/135_consignment_matching.sql rename to apps/backend/src/db/migrations/142_consignment_matching.sql index 570cddd0..380c36e4 100644 --- a/apps/backend/src/db/migrations/135_consignment_matching.sql +++ b/apps/backend/src/db/migrations/142_consignment_matching.sql @@ -1,4 +1,4 @@ --- 135_consignment_matching.sql +-- 142_consignment_matching.sql -- 寄卖配置化 + 库存数量/售价 + 内部匹配/自动匹配 + 拼单库存分配。 -- 全部语句幂等,可安全重跑;不改动历史迁移。 diff --git a/apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql b/apps/backend/src/db/migrations/143_consignment_dynamic_specs.sql similarity index 99% rename from apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql rename to apps/backend/src/db/migrations/143_consignment_dynamic_specs.sql index 74eae118..f904329d 100644 --- a/apps/backend/src/db/migrations/136_consignment_dynamic_specs.sql +++ b/apps/backend/src/db/migrations/143_consignment_dynamic_specs.sql @@ -1,4 +1,4 @@ --- 136_consignment_dynamic_specs.sql +-- 143_consignment_dynamic_specs.sql -- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。 -- 全部语句幂等,可安全重跑;不改动历史迁移。 diff --git a/apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql b/apps/backend/src/db/migrations/144_consignment_surnan_default_profile.sql similarity index 99% rename from apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql rename to apps/backend/src/db/migrations/144_consignment_surnan_default_profile.sql index 43364fdd..0dcf9173 100644 --- a/apps/backend/src/db/migrations/137_consignment_surnan_default_profile.sql +++ b/apps/backend/src/db/migrations/144_consignment_surnan_default_profile.sql @@ -1,4 +1,4 @@ --- 137_consignment_surnan_default_profile.sql +-- 144_consignment_surnan_default_profile.sql -- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。 -- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。 diff --git a/apps/backend/src/db/migrations/145_consignment_inventory_multi_binding.sql b/apps/backend/src/db/migrations/145_consignment_inventory_multi_binding.sql new file mode 100644 index 00000000..7b287a59 --- /dev/null +++ b/apps/backend/src/db/migrations/145_consignment_inventory_multi_binding.sql @@ -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; diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts index dcf4c151..9d4aaa5a 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -25,6 +25,10 @@ export async function reserveConsignmentInventoryWithClient( quantity?: number unitPriceFen: number now: string + /** 占用后用于卖家端关联工单的绑定信息(内部匹配/自动匹配都要写) */ + boundOrderId?: number | null + boundPlatformOrderId?: string + shopName?: string }, ): Promise { const quantity = Math.max(1, Math.round(Number(input.quantity || 1))) @@ -40,6 +44,10 @@ export async function reserveConsignmentInventoryWithClient( remaining_quantity = remaining_quantity - $1, status = CASE WHEN remaining_quantity - $1 <= 0 THEN 'consumed' ELSE status END, 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, updated_at = $2 WHERE id = $3 @@ -48,7 +56,15 @@ export async function reserveConsignmentInventoryWithClient( AND remaining_quantity >= $1 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 @@ -96,6 +112,29 @@ async function releaseAllocationRows( `UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`, [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], + ) + } } } diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts index 51bb80f5..487794fe 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.test.ts @@ -4,8 +4,18 @@ import test from 'node:test' 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 { 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…)当主标识展示,搜不到它客服等于瞎了 assert.ok(source.includes('OR cs.submission_no ILIKE $${values.length}')) // 条件里引用了 cs.submission_no,计数 SQL 就必须同样 join,否则 COUNT 直接 500 @@ -22,7 +32,7 @@ test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…), }) 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 inventoryListBody = source.slice( inventoryListStart, @@ -55,7 +65,7 @@ test('寄卖仓库搜索必须覆盖供货打手姓名、打手电话与页面 }) 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 inventoryListBody = source.slice( inventoryListStart, @@ -77,7 +87,7 @@ test('寄卖仓库按通过审核入库时间筛选,结束日期必须包含 }) 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') assert.ok(start > 0, '未找到匹配候选查询') 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')) // consignment_submissions 也有 status/game_name/category_id:不加前缀会 ambiguous column 直接 500 assert.ok( - !/WHERE[^`]*[^.]\bstatus = /.test(body.replace(/ci\.status/g, 'X')), - '存在未加前缀的 status', + body.includes("ci.status = 'in_stock'"), + 'status 条件必须带 ci. 前缀,避免 join 后 ambiguous column', + ) + assert.ok( + !/(? { - 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') assert.ok(start > 0, '未找到打手指派占库函数') const body = source.slice(start, source.indexOf('\n}\n', start)) @@ -120,7 +133,7 @@ function readWorkerPageQuery(source: string) { } test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/区服/钻级/角色/买家)', async () => { - const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + const source = await readRepoSource() const body = readWorkerPageQuery(source) for (const field of [ @@ -140,13 +153,14 @@ test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/ } // 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错 - const orphanStart = body.indexOf('NULL AS submission_id') - const orphanBody = body.slice(orphanStart, body.indexOf(')\n `', orphanStart)) + const orphanStart = body.indexOf('orphanKeywordFilter = `') + const orphanEnd = body.indexOf('`', orphanStart + 'orphanKeywordFilter = `'.length) + const orphanBody = body.slice(orphanStart, orphanEnd) assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.') }) test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => { - const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + const source = await readRepoSource() const body = readWorkerPageQuery(source) assert.ok(body.includes('ORDER BY sort_at DESC'), '排序必须在 SQL 完成') @@ -158,7 +172,7 @@ test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记 }) 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) // 真源是 domain/consignment-item-stage.ts 的 STAGE_META.group: diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 79f6e5c1..9034eb12 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -66,8 +66,33 @@ function inventoryMatchesProfile( if (!profile) return true const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null if (inventoryProfileId) return inventoryProfileId === profile.profileId - // 存量库存没有 profile_id:按分类兜底(配置挂一级分类时同样命中)。 - return Number(inventory.category_id || 0) === profile.categoryId + const inventoryCategoryId = Number(inventory.category_id || 0) + 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 = { @@ -82,6 +107,20 @@ type InventoryMatchEvaluation = { details: ReturnType['details'] } +type ConsignmentMatchInventoryCandidate = { + inventory: ConsignmentInventoryRow + evaluation: InventoryMatchEvaluation +} + +type ConsignmentMatchContext = { + matchType: 'auto' | 'manual' + actorName?: string + reason?: string + allowNonExact?: boolean + specConflicts?: string[] + partial?: boolean +} + function resolveInventorySpecs( inventory: ConsignmentInventoryRow, 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( workOrder: WorkOrderRow, ): Promise { @@ -293,30 +321,34 @@ function pickCandidateInventories( workOrder: WorkOrderRow, profile: ConsignmentProfileSnapshot | null, inventories: ConsignmentInventoryRow[], -): ConsignmentInventoryRow[] { + options: { allowPartial?: boolean; preferLowReward?: boolean } = {}, +): ConsignmentMatchInventoryCandidate[] { + const allowPartial = Boolean(options.allowPartial) + const preferLowReward = options.preferLowReward !== false return inventories + .filter((inventory) => inventoryMatchesProfile(inventory, profile)) + .map((inventory) => ({ + inventory, + evaluation: evaluateInventoryMatch(workOrder, inventory, profile), + })) .filter( - (inventory) => - inventoryMatchesProfile(inventory, profile) && - inventoryMatchesWorkOrder(workOrder, inventory, profile), + ({ evaluation }) => + evaluation.exact || + (allowPartial && evaluation.partial && evaluation.conflicts.length === 0), ) - .sort((a, b) => { - 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) - }) + .sort((a, b) => compareInventoryCandidates(a, b, preferLowReward)) } async function applyWholeOrderMatch( input: MatchCandidate, - inventory: ConsignmentInventoryRow, + candidate: ConsignmentMatchInventoryCandidate, + context: ConsignmentMatchContext = { matchType: 'auto' }, ): Promise { const { workOrder, profile } = input + const inventory = candidate.inventory if (!workOrder.id || !inventory.worker_id) return false const now = nowIso() + const manual = context.matchType === 'manual' return withTransaction(async (client: PoolClient) => { const locked = await client.query( @@ -327,12 +359,18 @@ async function applyWholeOrderMatch( if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) 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, { inventoryId: Number(inventory.id), workOrderId: Number(current.id), quantity: 1, unitPriceFen: inventorySalePriceFen(inventory), now, + boundOrderId: current.order_id ? Number(current.order_id) : null, + boundPlatformOrderId: current.platform_order_id || '', + shopName, }) if (!allocation) return false @@ -342,7 +380,6 @@ async function applyWholeOrderMatch( const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen) const timeoutMinutes = config?.timeout.minutes ?? 30 const timeoutPolicy = config?.timeout.policy ?? 'reopen' - const material = parseMaterial(current.material_json) const nextMaterial = { ...material, consignment: { @@ -364,7 +401,19 @@ async function applyWholeOrderMatch( workerId: Number(inventory.worker_id), workerName: inventory.worker_name || '', 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, { workOrderId: Number(current.id), - actorType: 'system', - actorId: 'consignment_match', - eventType: 'consignment_auto_matched', + actorType: manual ? 'admin' : 'system', + actorId: manual ? context.actorName || 'admin' : 'consignment_match', + eventType: manual ? 'consignment_manual_matched' : 'consignment_auto_matched', fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, toStatus: WORK_ORDER_STATUS.IN_PROGRESS, payloadJson: JSON.stringify({ @@ -417,6 +466,16 @@ async function applyWholeOrderMatch( serviceFeeFen, netRewardFen, 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, }) @@ -426,12 +485,14 @@ async function applyWholeOrderMatch( async function applySharingMatch( input: MatchCandidate, - inventories: ConsignmentInventoryRow[], + candidates: ConsignmentMatchInventoryCandidate[], + context: ConsignmentMatchContext = { matchType: 'auto' }, ): Promise { const { workOrder, profile } = input const now = nowIso() const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) if (!workOrder.id || totalQuantity <= 1) return 0 + const manual = context.matchType === 'manual' return withTransaction(async (client: PoolClient) => { const locked = await client.query( @@ -459,14 +520,15 @@ async function applySharingMatch( const shareGrossFen = Math.max(0, Math.round(unitRewardFen)) const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0 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 firstInventoryId: number | null = null - for (const inventory of inventories) { + for (const { inventory } of candidates) { if (remaining <= 0) break if (!inventory.worker_id) continue - if (!inventoryMatchesProfile(inventory, profile)) continue - if (!inventoryMatchesWorkOrder(current, inventory, profile)) continue const allocation = await reserveConsignmentInventoryWithClient(client, { inventoryId: Number(inventory.id), @@ -474,6 +536,9 @@ async function applySharingMatch( quantity: 1, unitPriceFen: inventorySalePriceFen(inventory), now, + boundOrderId: current.order_id ? Number(current.order_id) : null, + boundPlatformOrderId: current.platform_order_id || '', + shopName, }) if (!allocation) continue @@ -515,7 +580,6 @@ async function applySharingMatch( if (createdCount > 0) { active += createdCount - const material = parseMaterial(current.material_json) const nextStatus = active >= totalQuantity ? WORK_ORDER_STATUS.IN_PROGRESS @@ -542,7 +606,19 @@ async function applySharingMatch( inventoryId: firstInventoryId, profileId: profile?.profileId ?? null, 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, { workOrderId: Number(current.id), - actorType: 'system', - actorId: 'consignment_match', - eventType: 'consignment_sharing_matched', + actorType: manual ? 'admin' : 'system', + actorId: manual ? context.actorName || 'admin' : 'consignment_match', + eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched', fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING, toStatus: nextStatus, payloadJson: JSON.stringify({ @@ -570,6 +646,16 @@ async function applySharingMatch( totalQuantity, shareRewardFen: shareNetFen, 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, }) @@ -598,17 +684,32 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) { continue } - const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( - (inventory) => inventoryMatchesProfile(inventory, profile), - ) - const candidates = pickCandidateInventories(workOrder, profile, inventories) + const inventories = await listActiveConsignmentInventoriesForMatching() + const matching = profile.config.matching + const candidates = pickCandidateInventories(workOrder, profile, inventories, { + allowPartial: matching.exactOnly === false, + preferLowReward: matching.preferLowReward !== false, + }) if (candidates.length === 0) continue 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 } 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 } } catch { @@ -669,15 +770,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu inventory, evaluation: evaluateInventoryMatch(workOrder, inventory, profile), })) - .sort((a, b) => { - if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1 - 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) - }) + .sort((a, b) => + compareInventoryCandidates(a, b, profile?.config.matching.preferLowReward !== false), + ) const options = evaluated.map(({ inventory, evaluation }) => ({ inventoryId: Number(inventory.id), @@ -755,6 +850,10 @@ export async function matchConsignmentWorkOrderManually(input: { const selected = input.inventoryIds .map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id))) .filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory)) + .map((inventory) => ({ + inventory, + evaluation: evaluateInventoryMatch(workOrder, inventory, profile), + })) if (selected.length === 0) { throw createHttpError('请选择可用的寄卖库存', { @@ -763,12 +862,7 @@ export async function matchConsignmentWorkOrderManually(input: { }) } - const nonExact = selected - .map((inventory) => ({ - inventory, - evaluation: evaluateInventoryMatch(workOrder, inventory, profile), - })) - .filter((item) => !item.evaluation.exact) + const nonExact = selected.filter((item) => !item.evaluation.exact) if (nonExact.length > 0) { if (!input.allowNonExact) { 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 } if (!workOrder.sharing_enabled) { 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[])`, [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 } } - 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 } } diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts new file mode 100644 index 00000000..539022bc --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -0,0 +1,1054 @@ +/** + * 寄卖配置 + 提报审核 + 内部匹配全链路集成测试。 + * + * 只在隔离库运行: + * CONSIGNMENT_MATCHING_TEST_DATABASE_URL=postgres://.../order_site_ctest \ + * DATABASE_URL=postgres://.../order_site_ctest \ + * node --import tsx --test src/services/worker-platform/consignment-matching.integration.test.ts + * + * 未设置 CONSIGNMENT_MATCHING_TEST_DATABASE_URL 时整个文件跳过,普通单测不依赖数据库。 + */ +import test from 'node:test' + +import { closeDb, query } from '../../db/client.js' +import { + approveAdminConsignmentSubmission, + rejectAdminConsignmentSubmission, +} from './consignment-admin-service.js' +import { + autoMatchConsignmentWorkOrders, + expireConsignmentInternalMatching, + listConsignmentMatchCandidatesForWorkOrder, + matchConsignmentWorkOrderManually, + releaseConsignmentForWorkOrder, +} from './consignment-match-engine-service.js' +import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js' +import { + computeConsignmentPrice, + normalizeConsignmentSpecValues, + resolveWorkerConsignmentPrice, +} from './consignment-spec-service.js' +import { + getWorkerConsignmentPriceSuggestion, + listWorkerConsignments, + publishWorkerConsignment, + updateWorkerConsignmentSalePrice, +} from './consignment-worker-service.js' + +const TEST_DATABASE_URL = String(process.env.CONSIGNMENT_MATCHING_TEST_DATABASE_URL || '').trim() + +type CheckResult = { name: string; ok: boolean; detail: string } +const results: CheckResult[] = [] + +async function check(name: string, fn: () => Promise | void) { + try { + await fn() + results.push({ name, ok: true, detail: '' }) + } catch (error) { + results.push({ + name, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }) + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function errorCode(error: unknown): string { + return String((error as { errorCode?: string })?.errorCode || '') +} + +async function expectError(fn: () => Promise, code: string) { + try { + await fn() + } catch (error) { + assert(errorCode(error) === code, `期望错误码 ${code},实际 ${errorCode(error) || error}`) + return + } + throw new Error(`期望抛出 ${code},实际成功`) +} + +const suffix = `${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}` + +const pricing = { + priceType: 'template_discount' as const, + discount: 10, + fixedPriceFen: 0, + allowWorkerPrice: true, + minPriceFen: 1000, + missingPricePolicy: 'manual' as const, +} + +const specs = [ + { + key: 'system', + label: '操作系统', + type: 'radio', + required: true, + options: [ + { label: '苹果', value: '苹果', enabled: true }, + { label: '安卓', value: '安卓', enabled: true }, + ], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: true, + sortOrder: 10, + }, + { + key: 'serverZone', + label: '游戏区服', + type: 'radio', + required: true, + options: [ + { label: '微信区', value: '微信区', enabled: true }, + { label: 'QQ区', value: 'QQ区', enabled: true }, + { label: '互通(QQ/微信)', value: '互通', enabled: true }, + ], + placeholder: '', + helpText: '', + matchMode: 'wildcard', + wildcardValues: ['互通', '通用', '全区', '不限'], + showInItemName: false, + sortOrder: 20, + }, + { + key: 'series', + label: '系列', + type: 'radio', + required: true, + options: [ + { label: '暗潮', value: '暗潮', enabled: true }, + { label: '怒嚎', value: '怒嚎', enabled: true }, + { label: '血宴', value: '血宴', enabled: true }, + ], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: true, + sortOrder: 30, + }, + { + key: 'diamondLevel', + label: '钻石等级', + type: 'radio', + required: true, + options: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ + label: `${n}钻`, + value: String(n), + enabled: true, + })), + placeholder: '', + helpText: '', + matchMode: 'minimum', + wildcardValues: [], + showInItemName: true, + sortOrder: 40, + }, + { + key: 'brillianceCount', + label: '璀璨数', + type: 'radio', + required: true, + options: Array.from({ length: 8 }, (_, n) => ({ + label: n === 0 ? '无' : `${n}璀`, + value: String(n), + enabled: true, + })), + placeholder: '', + helpText: '', + matchMode: 'minimum', + wildcardValues: [], + showInItemName: true, + sortOrder: 50, + }, +] as const + +const profileConfig = { + version: 1, + tabTitle: '测试苏尔南寄卖', + tabDescription: '', + specs, + nameTemplate: '{system}{series}{diamondLevel}钻{brillianceCount}璀璨 苏尔南', + pricing, + fields: { + system: true, + serverZone: true, + series: true, + diamondLevel: true, + brillianceCount: true, + quantity: true, + }, + deposit: { enabled: false, mode: 'fixed', fixedAmountFen: 0, unitAmountFen: 0 }, + serviceFee: { enabled: true, mode: 'percent', percent: 5, amountFen: 0 }, + inventoryLimit: { enabled: false, maxActiveQuantity: 0 }, + matching: { + mode: 'auto', + autoMatchEnabled: true, + exactOnly: true, + preferLowReward: true, + }, + internalMatch: { delayMinutes: 30, fallbackToHall: true }, + timeout: { minutes: 60, policy: 'reopen' }, + priceDrop: { enabled: true, hours: 72, mode: 'percent', value: 10 }, +} + +async function main() { + // ---------- 造数 ---------- + const categoryRes = await query<{ id: number }>( + `INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) + VALUES ($1, $2, 100, 'active', NOW(), NOW()) RETURNING id`, + [`ctest_${suffix}`, `测试苏尔南-${suffix}`], + ) + const categoryId = Number(categoryRes.rows[0]!.id) + + const adminRes = await query<{ id: number }>( + `INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at) + VALUES ($1, 'x', 'admin', 'active', NOW(), NOW()) RETURNING id`, + [`ctest_admin_${suffix}`], + ) + const adminId = Number(adminRes.rows[0]!.id) + + const contactRes = await query<{ id: number }>( + `INSERT INTO consignment_contact_supporters (name, note) VALUES ($1, 'ctest') RETURNING id`, + [`测试客服-${suffix}`], + ) + const contactId = Number(contactRes.rows[0]!.id) + + const profileRes = await query<{ id: number }>( + `INSERT INTO consignment_profiles + (category_id, name, title, description, tab_title, config_json, status, + created_by_admin_user_id, created_at, updated_at) + VALUES ($1, $2, $2, '', $3, $4::jsonb, 'draft', $5, NOW(), NOW()) RETURNING id`, + [categoryId, `测试苏尔南寄卖-${suffix}`, '测试寄卖', JSON.stringify(profileConfig), adminId], + ) + const profileId = Number(profileRes.rows[0]!.id) + const versionRes = await query<{ id: number }>( + `INSERT INTO consignment_profile_versions (profile_id, version_no, config_json, published_at, created_at) + VALUES ($1, 1, $2::jsonb, NOW(), NOW()) RETURNING id`, + [profileId, JSON.stringify(profileConfig)], + ) + const versionId = Number(versionRes.rows[0]!.id) + await query( + `UPDATE consignment_profiles SET current_version_id = $1, status = 'published', updated_at = NOW() WHERE id = $2`, + [versionId, profileId], + ) + + async function createWorker(label: string) { + const res = await query<{ id: number }>( + `INSERT INTO worker_users + (username, password_hash, display_name, phone, status, invite_code, can_consign, + created_at, updated_at) + VALUES ($1, 'x', $2, $3, 'active', $4, TRUE, NOW(), NOW()) RETURNING id`, + [ + `ctest_${label}_${suffix}`, + `测试卖家${label}-${suffix}`, + `ctest_${label}_${suffix}`, + `CT${label}${suffix}`.slice(0, 32), + ], + ) + const id = Number(res.rows[0]!.id) + await query( + `INSERT INTO worker_wallets (worker_id, available_amount, created_at, updated_at) + VALUES ($1, 100000, NOW(), NOW())`, + [id], + ) + return id + } + + const sellerId = await createWorker('a') + const seller2Id = await createWorker('b') + + const poorRes = await query<{ id: number }>( + `INSERT INTO worker_users + (username, password_hash, display_name, phone, status, invite_code, can_consign, created_at, updated_at) + VALUES ($1, 'x', $2, $3, 'active', $4, TRUE, NOW(), NOW()) RETURNING id`, + [ + `ctest_poor_${suffix}`, + `测试穷卖家-${suffix}`, + `ctest_poor_${suffix}`, + `CTP${suffix}`.slice(0, 32), + ], + ) + const poorWorkerId = Number(poorRes.rows[0]!.id) + + await query( + `INSERT INTO work_product_rules + (rule_key, product_name, category_id, enabled, reward_amount, created_at, updated_at) + VALUES ($1, $2, $3, TRUE, 10000, NOW(), NOW())`, + [`ctest_rule_${suffix}`, '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南', categoryId], + ) + + async function createWorkOrder(input: { + no: string + productName?: string + status?: string + fields?: Record + sharing?: { total: number; unitReward: number } + internalDeadline?: string | null + }) { + const material = { + collect: { + fields: input.fields || { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + }, + } + const res = await query>( + `INSERT INTO work_orders + (work_order_no, product_name, category_id, status, reward_amount, material_json, + sharing_enabled, sharing_total_quantity, sharing_unit_reward, + internal_match_deadline_at, hall_visible_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $10, NOW(), NOW()) + RETURNING *`, + [ + input.no, + input.productName || '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南', + categoryId, + input.status || 'unassigned', + 10000, + JSON.stringify(material), + Boolean(input.sharing), + input.sharing?.total || 1, + input.sharing?.unitReward || 0, + input.internalDeadline || null, + ], + ) + return res.rows[0]! + } + + async function createInventory(input: { + no: string + workerId: number + itemName?: string + system?: string + zone?: string + series?: string + diamond?: number + brilliance?: number + costFen?: number + saleFen?: number + capFen?: number + quantity?: number + sourceSubmissionId?: number | null + profileId?: number | null + }) { + const res = await query>( + `INSERT INTO consignment_inventories + (inventory_no, category_id, item_name, system, server_zone, series, diamond_level, + brilliance_count, cost_price_fen, sale_price_fen, price_cap_fen, quantity, + remaining_quantity, allow_matching, status, allocation_status, source_type, + source_submission_id, worker_id, worker_name, profile_id, profile_version_id, + profile_snapshot_json, spec_values_json, spec_key, spec_text, listed_at, inbound_at, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, TRUE, 'in_stock', + '待安排', 'worker_portal', $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, + $20, $21, NOW(), NOW(), NOW(), NOW()) + RETURNING *`, + [ + input.no, + categoryId, + input.itemName || '测试苏尔南手枪', + input.system || '苹果', + input.zone || '微信区', + input.series || '暗潮', + input.diamond || 1, + input.brilliance || 1, + input.costFen ?? 7000, + input.saleFen ?? 9000, + input.capFen ?? 9000, + input.quantity || 1, + input.sourceSubmissionId ?? null, + input.workerId, + `测试卖家${input.workerId}`, + input.profileId === null ? null : (input.profileId ?? profileId), + input.profileId === null ? null : versionId, + input.profileId === null ? null : JSON.stringify(profileConfig), + JSON.stringify({ + system: input.system || '苹果', + serverZone: input.zone || '微信区', + series: input.series || '暗潮', + diamondLevel: input.diamond || 1, + brillianceCount: input.brilliance || 1, + }), + `system=${input.system || '苹果'}|series=${input.series || '暗潮'}`, + `${input.system || '苹果'} ${input.series || '暗潮'} ${input.diamond || 1}钻`, + ], + ) + return res.rows[0]! + } + + console.log(`\n========== 寄卖全链路探针 ==========`) + console.log(`profile=${profileId} category=${categoryId} seller=${sellerId}`) + + // ---------- 1. 纯函数价格 ---------- + await check('价格:模板价 100 元 10 折 = 10000 分,改价上限同为 10000', () => { + const result = computeConsignmentPrice({ pricing, templatePriceFen: 10000 }) + assert(result.priceFen === 10000, `实际 ${result.priceFen}`) + assert(result.capFen === 10000, `上限实际 ${result.capFen}`) + assert(result.source === 'template', `source=${result.source}`) + }) + + await check('价格:打手提交 9000 允许,提交 11000 拒绝', () => { + const ok = resolveWorkerConsignmentPrice({ + pricing, + templatePriceFen: 10000, + submittedPriceFen: 9000, + }) + assert(ok.errors.length === 0 && ok.priceFen === 9000, `低价结果 ${JSON.stringify(ok)}`) + const bad = resolveWorkerConsignmentPrice({ + pricing, + templatePriceFen: 10000, + submittedPriceFen: 11000, + }) + assert(bad.errors.length > 0, '高于建议价必须报错') + }) + + await check('价格:固定价 12000 高于模板价 10000 必须拒绝', () => { + const result = resolveWorkerConsignmentPrice({ + pricing: { ...pricing, priceType: 'fixed', fixedPriceFen: 12000 }, + templatePriceFen: 10000, + }) + assert(result.errors.length > 0, '固定价高于模板价必须报错') + }) + + await check('价格:allowWorkerPrice=false 时按系统建议价发布不应被拦截', () => { + const result = resolveWorkerConsignmentPrice({ + pricing: { ...pricing, allowWorkerPrice: false }, + templatePriceFen: 10000, + submittedPriceFen: 10000, + }) + assert(result.errors.length === 0, `errors=${result.errors.join(';')}`) + assert(result.priceFen === 10000, `price=${result.priceFen}`) + }) + + await check('规格:必填缺失、非法选项、璀璨>钻石 均被拒绝', () => { + const missing = normalizeConsignmentSpecValues(specs as never, { + specValues: { system: '苹果' }, + }) + assert(missing.errors.length > 0, '必填缺失必须报错') + const invalid = normalizeConsignmentSpecValues(specs as never, { + specValues: { + system: '通用', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + }) + assert(invalid.errors.length > 0, '非法系统必须报错') + const exceed = normalizeConsignmentSpecValues(specs as never, { + specValues: { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '2', + }, + }) + assert(exceed.errors.length > 0, '璀璨必须是 <= 钻石') + }) + + await check('建议价:苹果+暗潮+1钻+1璀 命中模板 10000 分', async () => { + const suggestion = await getWorkerConsignmentPriceSuggestion({ + profileId, + values: { system: '苹果', series: '暗潮', diamondLevel: 1, brillianceCount: 1 }, + }) + assert(suggestion.templatePriceFen === 10000, `模板价 ${suggestion.templatePriceFen}`) + assert(suggestion.suggestedPriceFen === 10000, `建议价 ${suggestion.suggestedPriceFen}`) + assert(suggestion.source === 'template', `source=${suggestion.source}`) + }) + + // ---------- 2. 发布 / 审核 / 驳回 ---------- + let submissionId = 0 + let inventoryId = 0 + await check('发布:押金 2000 冻结,提报单 pending_review,规格与改价上限落库', async () => { + const result = await publishWorkerConsignment(sellerId, { + profileId, + quantity: 1, + specValues: { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + sellerContact: `wx_${suffix}`, + characterName: '测试角色', + characterId: '123456', + screenshotsJson: ['https://example.com/screenshot.png'], + askingPriceFen: 9000, + contactSupporterId: contactId, + sellerNote: 'ctest publish', + }) + submissionId = Number(result.id) + assert(result.status === 'pending_review', `status=${result.status}`) + assert(Number(result.depositAmountFen) === 2000, `押金 ${result.depositAmountFen}`) + assert(Number(result.askingPriceFen) === 9000, `价格 ${result.askingPriceFen}`) + assert(Number(result.priceCapFen) === 10000, `上限 ${result.priceCapFen}`) + const detail = await query>( + 'SELECT * FROM consignment_submissions WHERE id = $1', + [submissionId], + ) + const row = detail.rows[0]! + assert(row.spec_key !== null && row.spec_key !== '', 'spec_key 必须落库') + assert(row.spec_values_json.system === '苹果', 'spec_values_json 必须落库') + const wallet = await query>( + 'SELECT available_amount, frozen_deposit_amount FROM worker_wallets WHERE worker_id = $1', + [sellerId], + ) + assert( + Number(wallet.rows[0]!.available_amount) === 98000, + `余额 ${wallet.rows[0]!.available_amount}`, + ) + assert(Number(wallet.rows[0]!.frozen_deposit_amount) === 2000, '冻结额必须 2000') + }) + + await check('审核入库:库存 in_stock / 待安排 / 成本 7000 / 售价 9000 / 可匹配', async () => { + const result = await approveAdminConsignmentSubmission( + submissionId, + { costPriceFen: 7000, buyerName: 'ctest' }, + 'ctest-admin', + ) + inventoryId = Number(result.inventory.id) + const inv = result.inventory + assert(inv.status === 'in_stock', `status=${inv.status}`) + assert(inv.allocationStatus === '待安排', `allocation=${inv.allocationStatus}`) + assert(Number(inv.costPriceFen) === 7000, `cost=${inv.costPriceFen}`) + assert(Number(inv.salePriceFen) === 9000, `sale=${inv.salePriceFen}`) + assert(inv.allowMatching === true, 'allowMatching 必须 true') + assert(result.submission.status === 'approved', '提报单必须 approved') + }) + + await check('改价:高于改价上限拒绝,低价可改', async () => { + await expectError( + () => updateWorkerConsignmentSalePrice(sellerId, submissionId, 11000), + 'consignment_price_above_cap', + ) + const updated = await updateWorkerConsignmentSalePrice(sellerId, submissionId, 5000) + assert(updated.salePriceFen === 5000, `改价 ${updated.salePriceFen}`) + await updateWorkerConsignmentSalePrice(sellerId, submissionId, 9000) + }) + + await check('驳回:提报单 rejected 且押金退回', async () => { + const published = await publishWorkerConsignment(sellerId, { + profileId, + quantity: 1, + specValues: { + system: '安卓', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + sellerContact: `wx_${suffix}`, + characterName: '测试角色2', + characterId: '654321', + screenshotsJson: ['https://example.com/screenshot2.png'], + askingPriceFen: 8000, + contactSupporterId: contactId, + }) + await rejectAdminConsignmentSubmission( + Number(published.id), + { reviewNote: 'ctest reject' }, + 'ctest-admin', + ) + const row = await query<{ status: string }>( + 'SELECT status FROM consignment_submissions WHERE id = $1', + [Number(published.id)], + ) + assert(row.rows[0]!.status === 'rejected', `status=${row.rows[0]!.status}`) + const wallet = await query<{ available_amount: number; frozen_deposit_amount: number }>( + 'SELECT available_amount, frozen_deposit_amount FROM worker_wallets WHERE worker_id = $1', + [sellerId], + ) + assert(Number(wallet.rows[0]!.frozen_deposit_amount) === 2000, '只应保留第一单的 2000 冻结') + assert( + Number(wallet.rows[0]!.available_amount) === 98000, + `余额 ${wallet.rows[0]!.available_amount}`, + ) + }) + + await check('余额不足:发布必须返回 consignment_deposit_insufficient', async () => { + await expectError( + () => + publishWorkerConsignment(poorWorkerId, { + profileId, + quantity: 1, + specValues: { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + sellerContact: `wx_${suffix}`, + characterName: '穷角色', + characterId: '999999', + screenshotsJson: ['https://example.com/poor.png'], + askingPriceFen: 9000, + contactSupporterId: contactId, + }), + 'consignment_deposit_insufficient', + ) + }) + + // ---------- 3. 匹配候选 ---------- + const invA = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [inventoryId], + ) + const invARow = invA.rows[0]! + + const conflictInv = await createInventory({ + no: `CT-INV-CONFLICT-${suffix}`, + workerId: seller2Id, + series: '怒嚎', + saleFen: 5000, + capFen: 5000, + }) + const partialInv = await createInventory({ + no: `CT-INV-PARTIAL-${suffix}`, + workerId: seller2Id, + diamond: 3, + brilliance: 3, + saleFen: 4000, + capFen: 4000, + }) + + const exactWo = await createWorkOrder({ no: `CT-WO-EXACT-${suffix}` }) + await check('匹配候选:精确库存 vs 冲突库存 vs 高配库存都能列出且分类正确', async () => { + const result = await listConsignmentMatchCandidatesForWorkOrder(Number(exactWo.id)) + const optionA = result.options.find((item) => item.inventoryId === Number(invARow.id)) + const optionConflict = result.options.find( + (item) => item.inventoryId === Number(conflictInv.id), + ) + const optionPartial = result.options.find((item) => item.inventoryId === Number(partialInv.id)) + assert(optionA && optionA.matchType === 'exact', `精确项 ${JSON.stringify(optionA?.matchType)}`) + assert( + optionConflict && optionConflict.matchType === 'conflict', + `冲突项 ${JSON.stringify(optionConflict?.matchType)}`, + ) + assert( + optionPartial && optionPartial.matchType === 'partial', + `高配项 ${JSON.stringify(optionPartial?.matchType)}`, + ) + }) + + // ---------- 4. 手动精确匹配 ---------- + await check( + '手动精确匹配:工单 in_progress、库存 consumed、分配 reserved、报酬扣 5% 服务费', + async () => { + const result = await matchConsignmentWorkOrderManually({ + workOrderId: Number(exactWo.id), + inventoryIds: [Number(invARow.id)], + actorName: 'ctest-admin', + }) + assert(result.matched && result.matchedCount === 1, JSON.stringify(result)) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(exactWo.id), + ]) + const row = wo.rows[0]! + assert(row.status === 'in_progress', `status=${row.status}`) + assert(Number(row.assigned_worker_id) === sellerId, `assigned=${row.assigned_worker_id}`) + assert(Number(row.reward_amount) === 8550, `reward=${row.reward_amount}`) + assert( + Number(row.material_json.consignment.inventoryId) === Number(invARow.id), + 'material 未写库存', + ) + const inv = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [Number(invARow.id)], + ) + assert(inv.rows[0]!.status === 'consumed', `库存 status=${inv.rows[0]!.status}`) + assert(Number(inv.rows[0]!.remaining_quantity) === 0, '剩余数量必须 0') + const allocation = await query>( + 'SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1', + [Number(exactWo.id)], + ) + assert(allocation.rows.length === 1, `分配行 ${allocation.rows.length}`) + assert(allocation.rows[0]!.status === 'reserved', `分配 status=${allocation.rows[0]!.status}`) + assert(Number(allocation.rows[0]!.unit_price_fen) === 9000, '分配单价必须 9000') + }, + ) + + await check('匹配后卖家「我的寄卖」能看到买家与工单', async () => { + const page = await listWorkerConsignments(sellerId) + const item = page.items.find((entry) => Number(entry.submissionId) === submissionId) + assert(item, '卖家列表找不到这笔寄卖') + assert(item.workOrderId, `workOrderId=${item.workOrderId}`) + assert(item.buyerInfo, '缺少买家资料') + assert( + item.stage === 'fulfilling' || item.stage === 'pending_acceptance', + `stage=${item.stage}`, + ) + }) + + await check('匹配后库存 bound_work_order_id 必须指向工单', async () => { + const inv = await query>( + 'SELECT bound_work_order_id FROM consignment_inventories WHERE id = $1', + [Number(invARow.id)], + ) + assert( + Number(inv.rows[0]!.bound_work_order_id) === Number(exactWo.id), + `bound_work_order_id=${inv.rows[0]!.bound_work_order_id}`, + ) + }) + + await check('手动匹配 material 必须标记 matchType=manual 与操作人', async () => { + const wo = await query>( + 'SELECT material_json FROM work_orders WHERE id = $1', + [Number(exactWo.id)], + ) + const consignment = wo.rows[0]!.material_json.consignment + assert(consignment.matchType === 'manual', `matchType=${consignment.matchType}`) + assert( + String(consignment.fulfilledBy || '').includes('ctest'), + `fulfilledBy=${consignment.fulfilledBy}`, + ) + }) + + await check('手动匹配留痕:事件/material 记录操作人与非精确原因', async () => { + const events = await query>( + 'SELECT * FROM work_order_events WHERE work_order_id = $1 ORDER BY id DESC', + [Number(exactWo.id)], + ) + const manualEvent = events.rows.find( + (event) => + String(event.event_type).includes('manual') || String(event.actor_id).includes('ctest'), + ) + assert(manualEvent, `没有 manual 事件:${events.rows.map((e) => e.event_type).join(',')}`) + const wo = await query>( + 'SELECT material_json FROM work_orders WHERE id = $1', + [Number(exactWo.id)], + ) + assert( + wo.rows[0]!.material_json.consignment.manualMatch === true, + 'material 未标记 manualMatch', + ) + assert( + String(wo.rows[0]!.material_json.consignment.fulfilledBy || '').includes('ctest'), + 'material 未记录操作人', + ) + }) + + await check('释放:库存回到 in_stock、分配 released、剩余数量回补', async () => { + const released = await releaseConsignmentForWorkOrder(Number(exactWo.id)) + assert(released === 1, `released=${released}`) + const inv = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [Number(invARow.id)], + ) + assert(inv.rows[0]!.status === 'in_stock', `status=${inv.rows[0]!.status}`) + assert( + Number(inv.rows[0]!.remaining_quantity) === 1, + `remaining=${inv.rows[0]!.remaining_quantity}`, + ) + const allocation = await query>( + 'SELECT status FROM consignment_inventory_allocations WHERE work_order_id = $1', + [Number(exactWo.id)], + ) + assert(allocation.rows[0]!.status === 'released', `allocation=${allocation.rows[0]!.status}`) + }) + await query('UPDATE consignment_inventories SET allow_matching = FALSE WHERE id = $1', [ + Number(invARow.id), + ]) + + // ---------- 5. 非精确匹配拦截 ---------- + const mismatchWo = await createWorkOrder({ no: `CT-WO-MISMATCH-${suffix}` }) + await check('非精确匹配:不勾选确认直接拒绝', async () => { + await expectError( + () => + matchConsignmentWorkOrderManually({ + workOrderId: Number(mismatchWo.id), + inventoryIds: [Number(conflictInv.id)], + }), + 'consignment_spec_mismatch', + ) + }) + await check('非精确匹配:勾选确认但没填原因拒绝', async () => { + await expectError( + () => + matchConsignmentWorkOrderManually({ + workOrderId: Number(mismatchWo.id), + inventoryIds: [Number(conflictInv.id)], + allowNonExact: true, + }), + 'consignment_match_reason_required', + ) + }) + await check('非精确强制匹配:填原因后成功', async () => { + const result = await matchConsignmentWorkOrderManually({ + workOrderId: Number(mismatchWo.id), + inventoryIds: [Number(conflictInv.id)], + allowNonExact: true, + reason: 'ctest 强制匹配:客户接受怒嚎', + actorName: 'ctest-admin', + }) + assert(result.matched, JSON.stringify(result)) + }) + await check('非精确强制匹配留痕:原因与操作人必须可追溯', async () => { + const events = await query>( + 'SELECT * FROM work_order_events WHERE work_order_id = $1 ORDER BY id DESC', + [Number(mismatchWo.id)], + ) + const matchedEvent = events.rows.find((event) => + String(event.event_type).includes('consignment'), + ) + assert(matchedEvent, '没有寄卖匹配事件') + assert( + JSON.stringify(matchedEvent.payload_json).includes('客户接受怒嚎'), + `事件 payload 没有原因:${JSON.stringify(matchedEvent.payload_json)}`, + ) + const wo = await query>( + 'SELECT material_json FROM work_orders WHERE id = $1', + [Number(mismatchWo.id)], + ) + assert( + JSON.stringify(wo.rows[0]!.material_json).includes('客户接受怒嚎'), + 'material 没有记录匹配原因', + ) + }) + + // ---------- 6. 自动匹配 ---------- + const autoInv = await createInventory({ + no: `CT-INV-AUTO-${suffix}`, + workerId: sellerId, + saleFen: 8500, + capFen: 8500, + }) + const autoWo = await createWorkOrder({ + no: `CT-WO-AUTO-${suffix}`, + status: 'internal_matching', + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + await check('自动匹配:internal_matching 工单命中精确库存', async () => { + const result = await autoMatchConsignmentWorkOrders({ limit: 50 }) + assert(result.scanned >= 1, `scanned=${result.scanned}`) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(autoWo.id), + ]) + assert(wo.rows[0]!.status === 'in_progress', `status=${wo.rows[0]!.status}`) + const inv = await query>( + 'SELECT * FROM consignment_inventories WHERE id = $1', + [Number(autoInv.id)], + ) + assert(inv.rows[0]!.status === 'consumed', `inventory=${inv.rows[0]!.status}`) + assert( + Number(wo.rows[0]!.material_json.consignment.inventoryId) === Number(autoInv.id), + 'material 未写自动匹配库存', + ) + }) + + // ---------- 7. 拼单匹配 ---------- + const shareInv1 = await createInventory({ + no: `CT-INV-SHARE1-${suffix}`, + workerId: sellerId, + saleFen: 6000, + capFen: 6000, + }) + const shareInv2 = await createInventory({ + no: `CT-INV-SHARE2-${suffix}`, + workerId: seller2Id, + saleFen: 6500, + capFen: 6500, + }) + const shareWo = await createWorkOrder({ + no: `CT-WO-SHARE-${suffix}`, + status: 'internal_matching', + sharing: { total: 2, unitReward: 7000 }, + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + await check('拼单匹配:两条库存生成两个份额并绑定分配', async () => { + const result = await matchConsignmentWorkOrderManually({ + workOrderId: Number(shareWo.id), + inventoryIds: [Number(shareInv1.id), Number(shareInv2.id)], + actorName: 'ctest-admin', + }) + assert(result.matched && result.matchedCount === 2, JSON.stringify(result)) + const shares = await query>( + 'SELECT * FROM work_order_shares WHERE work_order_id = $1 ORDER BY id', + [Number(shareWo.id)], + ) + assert(shares.rows.length === 2, `shares=${shares.rows.length}`) + assert( + shares.rows.every((share) => Number(share.consignment_inventory_id) > 0), + '份额缺少寄卖库存', + ) + const allocations = await query>( + 'SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1', + [Number(shareWo.id)], + ) + assert(allocations.rows.length === 2, `allocations=${allocations.rows.length}`) + assert( + allocations.rows.every((allocation) => Number(allocation.work_order_share_id) > 0), + '分配没有绑定份额', + ) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(shareWo.id), + ]) + assert(wo.rows[0]!.status === 'in_progress', `sharing wo status=${wo.rows[0]!.status}`) + }) + + await check('拼单匹配后每条库存的 bound_work_order_id 必须指向工单', async () => { + const invs = await query>( + 'SELECT id, bound_work_order_id FROM consignment_inventories WHERE id = ANY($1::bigint[])', + [[Number(shareInv1.id), Number(shareInv2.id)]], + ) + assert(invs.rows.length === 2, `库存行 ${invs.rows.length}`) + for (const inv of invs.rows) { + assert( + Number(inv.bound_work_order_id) === Number(shareWo.id), + `库存 ${inv.id} bound=${inv.bound_work_order_id}`, + ) + } + }) + + await check('拼单匹配后各卖家「我的寄卖」能定位到工单', async () => { + const page1 = await listWorkerConsignments(sellerId) + const item1 = page1.items.find((entry) => Number(entry.inventoryId) === Number(shareInv1.id)) + assert(item1, '卖家1 看不到拼单库存') + assert(item1.workOrderId, `卖家1 workOrderId=${item1.workOrderId}`) + const page2 = await listWorkerConsignments(seller2Id) + const item2 = page2.items.find((entry) => Number(entry.inventoryId) === Number(shareInv2.id)) + assert(item2, '卖家2 看不到拼单库存') + assert(item2.workOrderId, `卖家2 workOrderId=${item2.workOrderId}`) + }) + + // ---------- 8. 超时回大厅 ---------- + const expireWo = await createWorkOrder({ + no: `CT-WO-EXPIRE-${suffix}`, + status: 'internal_matching', + internalDeadline: new Date(Date.now() - 60 * 1000).toISOString(), + }) + await check('内部匹配超时:工单回到 open 并写超时事件', async () => { + const result = await expireConsignmentInternalMatching({ + now: new Date(Date.now() + 60 * 1000).toISOString(), + }) + assert(result.expired >= 1, `expired=${result.expired}`) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(expireWo.id), + ]) + assert(wo.rows[0]!.status === 'open', `status=${wo.rows[0]!.status}`) + assert(wo.rows[0]!.internal_match_deadline_at === null, 'deadline 必须清空') + assert(wo.rows[0]!.hall_visible_at === null, 'hall_visible_at 必须清空') + const event = await query>( + `SELECT * FROM work_order_events WHERE work_order_id = $1 AND event_type = 'consignment_internal_matching_expired'`, + [Number(expireWo.id)], + ) + assert(event.rows.length === 1, '缺少超时事件') + }) + + // ---------- 9. 死配置探测 ---------- + await query( + `UPDATE consignment_profiles SET config_json = jsonb_set(config_json, '{matching,exactOnly}', 'false'::jsonb) WHERE id = $1`, + [profileId], + ) + await query( + `UPDATE consignment_profiles SET config_json = jsonb_set(config_json, '{matching,preferLowReward}', 'false'::jsonb) WHERE id = $1`, + [profileId], + ) + await check('配置开关 exactOnly=false 应允许自动匹配非精确库存', async () => { + const profile = await query>( + 'SELECT config_json FROM consignment_profiles WHERE id = $1', + [profileId], + ) + assert(profile.rows[0]!.config_json.matching.exactOnly === false, 'exactOnly 未更新') + // 此时唯一在库可匹配的只剩 3钻3璀(高配)库存,1钻1璀 订单只能靠它。 + const partialOnlyWo = await createWorkOrder({ + no: `CT-WO-PARTIAL-AUTO-${suffix}`, + status: 'internal_matching', + internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }) + const result = await autoMatchConsignmentWorkOrders({ limit: 50 }) + assert(result.matched >= 1, `exactOnly=false 仍没有自动匹配,matched=${result.matched}`) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(partialOnlyWo.id), + ]) + assert(wo.rows[0]!.status === 'in_progress', `status=${wo.rows[0]!.status}`) + }) + + // ---------- 10. 对照:legacy /fulfill 内部现货链路 ---------- + await check('对照:legacy /fulfill 正确绑定工单并进入好友赠送阶段', async () => { + const legacyInv = await createInventory({ + no: `CT-INV-LEGACY-${suffix}`, + workerId: sellerId, + saleFen: 8800, + capFen: 8800, + }) + const legacyWo = await createWorkOrder({ no: `CT-WO-LEGACY-${suffix}` }) + await fulfillWorkOrderWithConsignment({ + workOrderId: Number(legacyWo.id), + inventoryId: Number(legacyInv.id), + actorName: 'ctest-admin', + }) + const inv = await query>( + 'SELECT bound_work_order_id FROM consignment_inventories WHERE id = $1', + [Number(legacyInv.id)], + ) + assert( + Number(inv.rows[0]!.bound_work_order_id) === Number(legacyWo.id), + `bound=${inv.rows[0]!.bound_work_order_id}`, + ) + const wo = await query>('SELECT * FROM work_orders WHERE id = $1', [ + Number(legacyWo.id), + ]) + assert(wo.rows[0]!.status === 'in_progress', `status=${wo.rows[0]!.status}`) + assert(wo.rows[0]!.acceptance_mode === 'friend_gift', `mode=${wo.rows[0]!.acceptance_mode}`) + assert(wo.rows[0]!.gift_phase === 'material_required', `gift=${wo.rows[0]!.gift_phase}`) + assert(wo.rows[0]!.assigned_worker_id === null, '内部现货不应有接单打手') + assert( + wo.rows[0]!.material_json.consignment.fulfillmentType === 'internal_stock', + 'material 未标记 internal_stock', + ) + const page = await listWorkerConsignments(sellerId) + const item = page.items.find((entry) => Number(entry.inventoryId) === Number(legacyInv.id)) + assert(item, '卖家看不到 legacy 履约库存') + assert(Number(item.workOrderId) === Number(legacyWo.id), `workOrderId=${item.workOrderId}`) + }) + + // ---------- 输出 ---------- + console.log('\n========== 结果 ==========') + for (const result of results) { + console.log( + `${result.ok ? 'PASS' : 'FAIL'} ${result.name}${result.detail ? `\n ${result.detail}` : ''}`, + ) + } + const failed = results.filter((result) => !result.ok).length + console.log(`\n合计 ${results.length} 项,通过 ${results.length - failed},失败 ${failed}`) +} + +test( + '寄卖匹配全链路:配置→发布→入库→精确/非精确/拼单/自动/超时/释放', + { + skip: TEST_DATABASE_URL ? false : '未设置 CONSIGNMENT_MATCHING_TEST_DATABASE_URL', + }, + async () => { + assert( + String(process.env.DATABASE_URL || '').trim() === TEST_DATABASE_URL, + 'DATABASE_URL 必须与 CONSIGNMENT_MATCHING_TEST_DATABASE_URL 一致,避免误跑开发库', + ) + try { + await main() + } finally { + await closeDb().catch(() => undefined) + } + const failed = results.filter((result) => !result.ok) + if (failed.length > 0) { + throw new Error( + `寄卖匹配集成测试失败:\n${failed + .map((result) => `${result.name}: ${result.detail}`) + .join('\n')}`, + ) + } + }, +) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 81eabed6..d87ee100 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -87,9 +87,9 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售 assert.equal(suggestion?.dropHours, 24) }) -test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', async () => { +test('迁移 142 必须包含寄卖配置、库存数量/售价与分配表', async () => { 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', ) for (const fragment of [ @@ -101,6 +101,6 @@ test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', as 'internal_match_deadline_at', 'consignment_inventory_id', ]) { - assert.ok(source.includes(fragment), `迁移 135 缺少 ${fragment}`) + assert.ok(source.includes(fragment), `迁移 142 缺少 ${fragment}`) } }) diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts index 8e6c011b..334cc78a 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -34,7 +34,7 @@ function option(label: string, value: string) { return { label, value, enabled: true } } -/** 苏尔南手枪种子配置的五个字段(与迁移 137 一致)。 */ +/** 苏尔南手枪种子配置的五个字段(与迁移 144 一致)。 */ function surnanSpecs(): ConsignmentSpecField[] { return [ { @@ -423,9 +423,9 @@ test('动态规格:旧列仍由兼容 key 同步', () => { }) }) -test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async () => { +test('迁移 143 必须包含 JSON、匹配 key、文本与改价上限', async () => { 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', ) for (const fragment of [ @@ -433,16 +433,15 @@ test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async 'spec_key', 'spec_text', '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( - 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', ) for (const fragment of [ @@ -452,6 +451,6 @@ test('迁移 137 必须为苏尔南手枪写入已发布的五个单选字段配 "'published'", '"key": "brillianceCount"', ]) { - assert.ok(sql.includes(fragment), `迁移 137 缺少 ${fragment}`) + assert.ok(sql.includes(fragment), `迁移 144 缺少 ${fragment}`) } }) diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index c50c987d..580a1524 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -615,7 +615,10 @@ export function resolveWorkerConsignmentPrice(input: { if (priceFen > 0) { if (submittedPriceFen > 0) { - if (!input.pricing.allowWorkerPrice) { + // 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交, + // 不能把按建议价直接发布也一起拦掉。 + const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen + if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) { errors.push('当前寄卖配置不允许修改售价') } else if (submittedPriceFen > computed.capFen) { errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index d8e1afb7..608e2e51 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -2,10 +2,75 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' +import { + normalizeConsignmentSpecValues, + resolveWorkerConsignmentPrice, + type ConsignmentSpecField, + type ConsignmentPricingConfig, +} from './consignment-spec-service.js' + async function readServiceSource() { 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 () => { const serviceSource = await readServiceSource() // 以前接口返回两个数组、前端分两个列表渲染 → 同一件货两行两个号 @@ -56,34 +121,86 @@ test('没有提报单的兜底行不给卖家操作能力,也不暴露内部 assert.ok(serviceSource.includes('canConfirmFriend: false, canSubmitEvidence: false')) }) -test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', async () => { - const serviceSource = await readServiceSource() +test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', () => { + const invalidSystem = normalizeConsignmentSpecValues(baseSpecs(), { + specValues: { + system: '通用', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + }) assert.ok( - serviceSource.includes("errorCode: 'consignment_system_invalid'"), + invalidSystem.errors.some((error) => error.includes('操作系统')), '系统取值不合法要有显式拒绝,防止旧页面缓存绕过前端选项', ) - assert.ok(serviceSource.includes("errorCode: 'consignment_server_zone_invalid'")) - // 校验必须是白名单式(只认 苹果/安卓 与 微信区/QQ区/互通),而不是只挡「通用」 - assert.ok(serviceSource.includes("system !== '苹果' && system !== '安卓'")) + const invalidZone = normalizeConsignmentSpecValues(baseSpecs(), { + specValues: { + system: '苹果', + serverZone: '国际服', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + }) assert.ok( - serviceSource.includes( - "serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通'", - ), + invalidZone.errors.some((error) => error.includes('游戏区服')), + '区服必须拒绝白名单之外的值', ) + const valid = normalizeConsignmentSpecValues(baseSpecs(), { + specValues: { + system: '苹果', + serverZone: '互通', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '1', + }, + }) + assert.deepEqual(valid.errors, []) }) -test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀以此类推)', async () => { - const serviceSource = await readServiceSource() +test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀以此类推)', () => { + const result = normalizeConsignmentSpecValues(baseSpecs(), { + specValues: { + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: '1', + brillianceCount: '2', + }, + }) 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 () => { const serviceSource = await readServiceSource() assert.ok( @@ -95,15 +212,3 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截', '未开通必须返回专门的 consignment_permission_required 错误码', ) }) - -test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', async () => { - const serviceSource = await readServiceSource() - assert.ok( - serviceSource.includes('getWorkerConsignmentPriceSuggestion({'), - '发布时必须按同一套模板规则复算价格', - ) - assert.ok( - serviceSource.includes('templatePriceFen > 0 ? templatePriceFen : submittedPriceFen'), - '模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额', - ) -}) diff --git a/apps/frontend/tests/consignment-inventory-date-filter.test.ts b/apps/frontend/tests/consignment-inventory-date-filter.test.ts index 07a7b627..cda79fde 100644 --- a/apps/frontend/tests/consignment-inventory-date-filter.test.ts +++ b/apps/frontend/tests/consignment-inventory-date-filter.test.ts @@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs' import test from 'node:test' test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => { + // Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行 const source = readFileSync( new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname, 'utf8', - ) + ).replace(/\r\n/g, '\n') assert.ok( source.includes("from '@/components/admin/AdminDatePicker'"), diff --git a/apps/frontend/tests/consignment-spec-options.test.ts b/apps/frontend/tests/consignment-spec-options.test.ts index ec51e315..bc4e9514 100644 --- a/apps/frontend/tests/consignment-spec-options.test.ts +++ b/apps/frontend/tests/consignment-spec-options.test.ts @@ -20,7 +20,7 @@ test('后台全量选项:系统含通用,区服含互通', () => { assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」') }) -test('苏尔南默认配置:取消代码内置字段,由迁移 137 提供五个单选字段', () => { +test('苏尔南默认配置:取消代码内置字段,由迁移 144 提供五个单选字段', () => { const utils = readFileSync( new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname, 'utf8',