From 40ee36d72a99141e1fcb15e7074192e9d2047c95 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 12:17:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E4=BF=AE=E5=A4=8D=E5=AF=84?= =?UTF-8?q?=E5=8D=96=E5=8F=96=E6=B6=88=E8=A7=A3=E7=BB=91=E3=80=81SUB=20?= =?UTF-8?q?=E5=8D=95=E5=8F=B7=E5=86=99=E7=A9=BA=E3=80=81=E6=8B=BC=E5=8D=95?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E4=B8=8E=E5=90=8E=E5=8F=B0=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=AE=9E=E6=97=B6=E4=B9=B1=E8=B7=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 问题单处置「取消」后打手信息不消失:resolveProblemWorkOrder 的取消分支从未解绑寄卖现货,现货永久停在 consumed 占库不还,工单 material 上的寄售标记残留又让列表继续显示「供货打手」。取消时补上解绑;调用必须排在状态 UPDATE 之前——那条 UPDATE 会清空 friend_added_at,清完再解绑会把已确认加好友的货误判成可回池(二次出货); 2. 解绑函数在没有现货可解绑时直接 return,跳过了摘标记,于是「库存行早已不存在」的老帖永远摘不掉寄售标记(列表一直显示供货打手)。现在无论是否解绑到现货都摘标记,痕迹保留在 material.consignmentHistory 的 detachReason 里; 3. 内部现货履约写入的 sourceSubmissionNo 恒为空:取的是占库 `UPDATE ... RETURNING *` 的行,只带回本表列,没有 join 出来的 submission_no,导致工单列表把所有寄卖单都显示成「老单无提报单号」,SUB 单号这个唯一锚点丢失。改取带 join 的预读库存(内部现货资料、指派工单资料、指派事件留痕三处)。存量老单按业务口径不回填; 4. 后台接单工单列表实时乱跳:SSE 快照补丁按数组下标读 queryKey,面板 key 加了字段后下标全错(page 被读成 targetWorkOrderId → 任何页都被当成第 1 页),且完全没判「内部寄售匹配」等筛选,于是任意一条不在当前页的老单只要发生变更就被插到列表顶部。改为结构化 queryKey + 三态判定(match 才允许插顶、mismatch 才允许移除、其余只原地更新),插入还要求 page===1 且工单 id 大于当前首行;状态比对改用 rawStatus(快照的 status 是运营态,拼单母单会被显示成 in_progress),分类筛选因快照不带 parentCategoryId 归入不可证伪; 5. 新增「寄卖只做整单」硬门禁:已有人参与的拼单工单不参与现货匹配与库存提醒,履约入口一律返回 work_order_sharing_not_consignable,前端「手动匹配寄卖」入口按同一口径收敛;开了拼单但无人参与的整单不受影响,避免过度拦截。 验证:后端 520 项单测(517 pass / 3 skip)、前端 78 项全绿;并对真实开发库跑通问题单取消三类场景(老帖无库存行 / 回库 / 已加好友作废)、退回打手与退回大厅的反向用例(不得解绑)、履约写入真实 SUB 单号、拼单门禁四入口 + 未过度拦截反向用例。 --- .../worker-platform/consignment-repo.ts | 21 +- .../worker-platform/work-order-issue-repo.ts | 14 + .../consignment-fulfillment-service.ts | 42 ++- .../worker-platform-service.test.ts | 133 ++++++++ apps/frontend/src/layouts/AdminLayout.tsx | 92 +++--- .../pages/admin/panels/WorkOrdersPanel.tsx | 55 ++-- .../src/utils/admin-realtime-work-orders.ts | 231 ++++++++++++++ .../frontend/src/utils/consignment-display.ts | 33 ++ .../src/utils/consignment-manual-match.ts | 17 ++ .../tests/admin-realtime-work-orders.test.ts | 289 ++++++++++++++++++ .../tests/consignment-manual-match.test.ts | 37 +++ 11 files changed, 869 insertions(+), 95 deletions(-) create mode 100644 apps/frontend/src/utils/admin-realtime-work-orders.ts create mode 100644 apps/frontend/tests/admin-realtime-work-orders.test.ts diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 60d18591..073b0bc7 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -824,16 +824,23 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient( [input.now, workOrderId, input.reason || ''], ) const released = res.rows[0] || null - if (!released) return null - // 现货已解绑回库,工单上的寄售标记必须同步摘掉:否则工单列表会继续显示 - // 「寄售履约: 某现货」,撤销按钮也会再次出现(点下去必然 409)。 + // 工单上的寄售标记必须同步摘掉:否则工单列表会继续显示「供货打手 / 内部现货」, + // 撤销按钮也会再次出现(点下去必然 409)。 + // 关键:**没有解绑到现货时也要摘**。released 为 null 有两类真实场景: + // 1) 老帖工单的 material.consignment 指向的库存行早已不存在或已被人工回库; + // 2) 现货此前已被其它路径处理过(重复处置 / 补偿链路)。 + // 这类单子若直接 return,标记会永久留在工单上——"问题单取消后打手列仍显示供货打手" + // 就是这么来的。此时按工单整体摘(不做 inventoryId 过滤):已经不存在被本工单占用的 + // 现货,也就不存在误伤新绑定的可能。 await detachConsignmentMaterialFromWorkOrderWithClient(client, { workOrderId, - inventoryId: released.id, - reason: archiveOnly - ? `已确认加好友,现货作废下架: ${input.reason || ''}` - : `工单解绑回库: ${input.reason || ''}`, + inventoryId: released ? released.id : null, + reason: released + ? archiveOnly + ? `已确认加好友,现货作废下架: ${input.reason || ''}` + : `工单解绑回库: ${input.reason || ''}` + : `无在库现货可解绑,仅摘除寄售标记: ${input.reason || ''}`, now: input.now, }) return released diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 396488df..3c776e0e 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -143,6 +143,20 @@ export async function resolveProblemWorkOrder(input: { input.action === 'reopen' || (input.action === 'return_to_worker' && targetStatus === 'in_progress') + // 取消处置 = 工单终止:被本工单占用的寄卖现货必须解绑(回库;已确认加好友则作废下架), + // 并同步摘掉 material 上的寄售标记。否则会出现两个后果: + // 1) 列表「打手」列继续显示「供货打手 / 内部现货」,看着像打手还挂在这张已取消的单上; + // 2) 现货永久停在 consumed 占库不还,既不参与匹配也没有押金释放入口。 + // ⚠️ 必须放在下面的 UPDATE 之前:那条 UPDATE 在取消时会把 friend_added_at 清空, + // 而解绑是否需要改成「作废」正是看这个字段——清空后再解绑会把已加好友的货退回在库池, + // 等于二次出货。 + if (targetStatus === 'cancelled') { + await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, { + now: input.now, + reason: `问题单处置取消(${effectiveAction})`, + }) + } + await client.query( ` UPDATE work_orders diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index e9ac02a2..a8b6e796 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -170,6 +170,18 @@ export type ConsignmentMatchOptionView = Omit } +/** + * 工单是否已有打手参与的拼单(`active_share_quantity` 由 WORK_ORDER_SELECT 计算下发)。 + * + * 寄卖现货只做**整单**履约:一旦让拼单工单走寄卖履约,之后若运营走「取消拼单」, + * 那条链路只处理份额押金、不解绑寄卖现货(`work-order-settlement-repo` 的取消分支), + * 现货会永久停在 consumed 占库不还、工单上还挂着寄售标记。 + * 与其在拼单侧补一段基本跑不到的解绑逻辑,不如把组合直接挡在入口——业务上寄卖本来就是整单。 + */ +function hasActiveWorkOrderShares(workOrder: { active_share_quantity?: unknown }): boolean { + return Number(workOrder.active_share_quantity || 0) > 0 +} + /** * 订单侧「手动指定现货」:列出该订单的**全部**在库现货(含不符合规格的)。 * @@ -186,6 +198,11 @@ export async function listWorkOrderInventoryMatchOptions(workOrderId: number | s if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } + // 寄卖只做整单:已经开拼单且有打手参与的工单不参与现货匹配, + // 否则会给出"可选现货"的错觉,客服点下去要么 409、要么把拼单工序和寄卖搅在一起。 + if (hasActiveWorkOrderShares(workOrder)) { + return { hasMatch: false, exactCount: 0, partialCount: 0, options: [] } + } const fields = resolveCollectSubmittedFieldValues(workOrder) const inventories = await listActiveConsignmentInventoriesForMatching() const ranking = { exact: 0, partial: 1, conflict: 2 } @@ -261,7 +278,9 @@ export async function listInventoryMatchableOrders( }) const ranking = { exact: 0, partial: 1, conflict: 2 } + // 拼单工单不进候选:寄卖只做整单(同 hasActiveWorkOrderShares 的口径)。 const orders = items + .filter((order) => !hasActiveWorkOrderShares(order)) .map((order) => { const fields = resolveCollectSubmittedFieldValues(order) const orderSpecs = extractPeacekeeperSpecs(order.product_name, { @@ -312,6 +331,10 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } + // 同上:拼单工单不做寄卖现货匹配提醒。 + if (hasActiveWorkOrderShares(workOrder)) { + return { hasMatch: false, exactCount: 0, partialCount: 0, matches: [] } + } const fields = resolveCollectSubmittedFieldValues(workOrder) @@ -376,6 +399,14 @@ export async function fulfillWorkOrderWithConsignment(input: { }) } + // 寄卖只做整单:已有打手参与的拼单工单不接受寄卖现货履约(入口硬拦,不靠前端隐藏按钮)。 + if (hasActiveWorkOrderShares(workOrder)) { + throw createHttpError('该工单已开启拼单且有打手参与,寄卖现货只能履约整单订单', { + statusCode: 409, + errorCode: 'work_order_sharing_not_consignable', + }) + } + const inventory = await getConsignmentInventoryById(input.inventoryId) if (!inventory) { throw createHttpError('寄卖物品不存在', { @@ -479,7 +510,11 @@ export async function fulfillWorkOrderWithConsignment(input: { screenshots: safeParseJson(inv.screenshots_json || inv.submission_screenshots_json || []), costPriceFen: inv.cost_price_fen, sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null, - sourceSubmissionNo: inv.source_submission_no || '', + // ⚠️ sourceSubmissionNo 必须取「带 join 预读」的 inventory:上面的条件 UPDATE 是 + // `RETURNING *`,只带回 consignment_inventories 自己的列,没有 join 出来的 + // submission_no。取 inv.source_submission_no 会恒为 undefined → 工单列表永远显示 + // 「老单无提报单号」,把 SUB 单号这个唯一锚点丢掉。 + sourceSubmissionNo: inventory.source_submission_no || '', fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK, fulfilledAt: now, fulfilledBy: input.actorName, @@ -722,7 +757,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: { sourceSubmissionId: claimed.source_submission_id ? Number(claimed.source_submission_id) : null, - sourceSubmissionNo: claimed.source_submission_no || '', + sourceSubmissionNo: inventory.source_submission_no || '', fulfillmentType: 'worker_deposit', assignedAt: now, assignedBy: input.actorName, @@ -767,7 +802,8 @@ export async function assignWorkOrderWithConsignmentWorker(input: { sourceSubmissionId: claimed.source_submission_id ? Number(claimed.source_submission_id) : null, - sourceSubmissionNo: claimed.source_submission_no || '', + // 同上:claimed 没有 join 列,事件留痕也要用预读的 inventory 才拿得到 SUB 单号 + sourceSubmissionNo: inventory.source_submission_no || '', }), now, }) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 13630d50..f528fe15 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -210,6 +210,65 @@ test('已确认加好友后解绑:现货作废下架,绝不回池被下一 assert.equal(released?.status, 'archived') }) +test('没解绑到现货时也必须摘掉工单上的寄售标记(老帖 / 现货已被处理过的单子)', async () => { + const statements: { sql: string; values: unknown[] }[] = [] + const client = { + query: async (sql: string, values?: unknown[]) => { + statements.push({ sql, values: values || [] }) + if (sql.includes('SELECT acceptance_mode')) { + return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] } + } + // 库存行没有被本工单占用:解绑 0 行(老帖指向的库存行已不存在,或已被人工回库) + if (sql.includes('UPDATE consignment_inventories')) return { rows: [] } + return { rows: [] } + }, + } as unknown as PoolClient + + const released = await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100200, { + now: '2026-09-15T00:00:00.000Z', + reason: '问题单处置取消(cancel_release)', + }) + + assert.equal(released, null, '没有占到现货时解绑结果为空') + + const detach = statements.find((item) => item.sql.includes("material_json - 'consignment'")) + assert.ok( + detach, + '没有解绑到现货也必须摘掉寄售标记,否则问题单取消后「打手」列会一直显示供货打手', + ) + // 传 null 代表不做 inventoryId 过滤、按工单整体摘:此时已不存在被本工单占用的现货 + assert.equal(detach!.values[3], null, '无现货可解绑时应按工单整体摘标记') +}) + +test('问题单处置取消:先解绑寄卖现货,再改工单状态', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const start = src.indexOf('export async function resolveProblemWorkOrder(') + assert.ok(start > 0, '未找到问题单处置函数') + const body = src.slice(start, src.indexOf('\n}\n', start)) + + const releaseIndex = body.indexOf( + 'releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId', + ) + assert.ok(releaseIndex > 0, '取消问题单必须解绑被本工单占用的寄卖现货,否则现货永久占库不还') + assert.ok( + body.includes("if (targetStatus === 'cancelled')"), + '只对「取消」处置解绑:退回打手重做 / 退回大厅时货还在履约,不能解绑', + ) + + // 顺序很重要:那条 UPDATE 会在取消时把 friend_added_at 清空,而"是否改判作废"正是看这个字段。 + // 清空之后再解绑,会把已加好友的现货退回在库池 → 二次出货。 + const updateIndex = body.indexOf('UPDATE work_orders') + assert.ok(updateIndex > 0, '未找到工单状态更新语句') + assert.ok( + releaseIndex < updateIndex, + '解绑必须排在工单状态更新之前,否则已确认加好友的单子会被误判成可回池', + ) +}) + test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => { const statements: string[] = [] const client = { @@ -2072,6 +2131,80 @@ test('打手查询条件支持提现黑名单筛选', () => { assert.equal(allWhere.params.length, 0) }) +test('寄卖只做整单:拼单工单不得进入寄卖现货履约链路', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('./consignment-fulfillment-service.ts', import.meta.url), + 'utf8', + ) + + // 口径来源:WORK_ORDER_SELECT 计算出来的 active_share_quantity(已有打手参与的份额) + assert.ok( + src.includes('function hasActiveWorkOrderShares('), + '缺少统一的拼单判定口径,散落的 active_share_quantity 判断容易漏改', + ) + assert.ok( + src.includes("errorCode: 'work_order_sharing_not_consignable'"), + '履约入口必须硬拦拼单工单(不能只靠前端隐藏按钮)', + ) + + // 订单侧「手动指定现货」候选:拼单工单直接返回空 + const optionsBody = src.slice( + src.indexOf('export async function listWorkOrderInventoryMatchOptions'), + src.indexOf('export async function listInventoryMatchableOrders'), + ) + assert.ok( + optionsBody.includes('hasActiveWorkOrderShares(workOrder)'), + '订单侧候选现货必须对拼单工单返回空', + ) + + // 发布时的内部库存提醒 + const matchesBody = src.slice( + src.indexOf('export async function checkWorkOrderInventoryMatches'), + src.indexOf('export async function fulfillWorkOrderWithConsignment'), + ) + assert.ok( + matchesBody.includes('hasActiveWorkOrderShares(workOrder)'), + '发布时的内部库存提醒也必须跳过拼单工单', + ) + + // 现货侧「可匹配订单」候选:过滤掉拼单工单 + const ordersBody = src.slice(src.indexOf('export async function listInventoryMatchableOrders')) + assert.ok( + ordersBody.includes('.filter((order) => !hasActiveWorkOrderShares(order))'), + '现货侧「可匹配订单」候选必须过滤掉拼单工单', + ) +}) + +test('寄卖履约必须写入真实 SUB 单号:只能取带 join 的预读库存', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('./consignment-fulfillment-service.ts', import.meta.url), + 'utf8', + ) + + // 内部现货履行的占库是 `UPDATE consignment_inventories ... RETURNING *`, + // 只带回本表列,submission_no 来自与 consignment_submissions 的 join。 + // 取 RETURNING 行上的 source_submission_no 会恒为 undefined → 工单列表永远显示 + // 「老单无提报单号」,SUB 单号这个唯一锚点就丢了(生产上 4 张单全中招)。 + assert.ok( + !src.includes('sourceSubmissionNo: inv.source_submission_no'), + '内部现货路径不能取 RETURNING 行上的 submission_no', + ) + assert.ok( + !src.includes('sourceSubmissionNo: claimed.source_submission_no'), + '指派路径不能取 RETURNING 行上的 submission_no', + ) + + const usingJoinedInventory = + src.split("sourceSubmissionNo: inventory.source_submission_no || ''").length - 1 + assert.equal( + usingJoinedInventory, + 3, + '内部现货资料、指派工单资料、指派事件留痕三处都必须用带 join 的预读库存', + ) +}) + test('指派"部分成功"窗口:失败补偿必须撤干净(取消指派 + 还库存 + 摘寄售标记)', async () => { const readFile = await import('node:fs/promises') const src = await readFile.readFile( diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index d202203f..fdf48a4e 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -52,6 +52,12 @@ import { setAdminPermissions, } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' +import { + evaluateAdminWorkOrderRealtimeMatch, + parseAdminWorkOrderListFilters, + patchAdminWorkOrderListItems, + shouldPrependAdminWorkOrder, +} from '@/utils/admin-realtime-work-orders' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import dayjs from '@/lib/dayjs' @@ -436,40 +442,43 @@ function patchAdminWorkOrderCaches( queryClient: ReturnType, event: RealtimeEvent, ) { - const item = event.data as { - workOrderId?: unknown - status?: unknown - productName?: unknown - platformOrderId?: unknown - worker?: { workerId?: unknown } | null - } + const item = event.data for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-worker-platform-orders'], })) { - const statuses = String(queryKey[1] || '') - .split(',') - .filter(Boolean) - const keyword = String(queryKey[2] || '') - const workerId = Number(queryKey[3] || 0) - const workerLevelIds = String(queryKey[4] || '') - .split(',') - .map((value) => Number(value)) - .filter(Boolean) - const targetWorkOrderId = Number(queryKey[5] || 0) - const page = Number(queryKey[6] || 1) - // 等级筛选的匹配逻辑只在服务端(接单打手或拼单参与者等级),快照不含等级, - // 无法在前端复现:已有行视为匹配(拉取时已通过服务端过滤),但不再插入新行。 - const levelFilterActive = workerLevelIds.length > 0 - const matches = - (!statuses.length || statuses.includes(String(item.status || ''))) && - matchesWorkOrderKeyword(item, keyword) && - (!workerId || workerId === Number(item.worker?.workerId || 0)) && - (!targetWorkOrderId || targetWorkOrderId === Number(item.workOrderId || 0)) - const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !levelFilterActive - const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert' + const filters = parseAdminWorkOrderListFilters(queryKey) + if (!filters) { + // 读不懂这个 key(旧结构 / 别的调用方):不猜、不插、不移除,交给服务端下一次拉取。 + continue + } + + // 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。 + const verdict = + event.operation === 'remove' ? 'mismatch' : evaluateAdminWorkOrderRealtimeMatch(item, filters) + if (verdict === 'unknown') { + queryClient.setQueryData( + queryKey, + patchAdminWorkOrderListItems(current, item, { remove: false, insert: false }), + ) + continue + } + + const items = + isRecord(current) && isRecord(current.data) && Array.isArray(current.data.items) + ? (current.data.items as unknown[]) + : [] + const insert = + verdict === 'match' && + event.operation === 'upsert' && + filters.page === 1 && + shouldPrependAdminWorkOrder(items, item) + queryClient.setQueryData( queryKey, - patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert), + patchAdminWorkOrderListItems(current, item, { + remove: verdict === 'mismatch', + insert, + }), ) } } @@ -597,20 +606,6 @@ function patchAdminOrderListEnvelope( } } -function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) { - if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { - return current - } - const item = event.data - const id = Number((item as { workOrderId?: unknown }).workOrderId || event.entityId) - const items = current.data.items as unknown[] - const nextItems = - event.operation === 'remove' - ? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id) - : upsertById(items, item, 'workOrderId', insert) - return { ...current, data: { ...current.data, items: nextItems } } -} - function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current @@ -682,17 +677,6 @@ function matchesAdminOrderDateRange( return true } -function matchesWorkOrderKeyword( - item: { productName?: unknown; platformOrderId?: unknown }, - keyword: string, -) { - const normalizedKeyword = keyword.trim().toLowerCase() - if (!normalizedKeyword) return true - return [item.productName, item.platformOrderId] - .map((value) => String(value || '').toLowerCase()) - .some((value) => value.includes(normalizedKeyword)) -} - function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 188c3727..0e791c64 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -83,8 +83,12 @@ import { import { notifyVoucherConsume } from '@/utils/voucher-consume' import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment' import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' -import { isManuallyMatchableOrderStatus } from '@/utils/consignment-manual-match' -import { resolveConsignmentScreenshotUrls } from '@/utils/consignment-display' +import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match' +import { + isConsignmentOrder, + isInternalConsignmentOrder, + resolveConsignmentScreenshotUrls, +} from '@/utils/consignment-display' import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal' import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' @@ -172,22 +176,6 @@ type CancelSharingFormValues = { reason?: string } -function isInternalConsignmentOrder(row: WorkOrder) { - const consignment = asRecord((row.material as Record | undefined)?.consignment) - if (consignment.fulfillmentType === 'worker_deposit' || consignment.workerId) return false - return Boolean(consignment.inventoryId) && !row.worker -} - -function isConsignmentOrder(row: WorkOrder) { - const consignment = asRecord((row.material as Record | undefined)?.consignment) - return Boolean( - consignment.inventoryId || - consignment.inventoryNo || - consignment.fulfillmentType || - row.gift?.boosterMaterial?.fields?.inventoryNo, - ) -} - /** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null { const consignment = asRecord((row.material as Record | undefined)?.consignment) @@ -392,20 +380,25 @@ export default function WorkOrdersPanel() { }, [searchParams, setSearchParams]) const ordersQuery = useQuery({ + // ⚠️ 结构化 key:实时快照补丁(layouts/AdminLayout.tsx)要按名字读筛选条件。 + // 以前这里是位置数组,AdminLayout 按下标去读,下标一改就全错(page 被读成 targetWorkOrderId, + // 导致任何页都被当成第 1 页,历史单被插到列表顶部"乱跳")。改字段时两边一起改。 queryKey: [ 'admin-worker-platform-orders', - statuses.join(','), - keyword, - workerId, - categoryId, - workerLevelIds.join(','), - targetWorkOrderId, - acceptanceMode || '', - giftPhase || '', - page, - pageSize, - createdRange ? createdRange[0] : '', - createdRange ? createdRange[1] : '', + { + statuses: statuses.join(','), + keyword, + workerId, + categoryId, + workerLevelIds: workerLevelIds.join(','), + targetWorkOrderId, + acceptanceMode: acceptanceMode || '', + giftPhase: giftPhase || '', + page, + pageSize, + createdFrom: createdRange ? createdRange[0] : '', + createdTo: createdRange ? createdRange[1] : '', + }, ], queryFn: () => fetchAdminWorkOrders({ @@ -1664,7 +1657,7 @@ export default function WorkOrdersPanel() { 手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口, 订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。 */} - {canFulfillConsignment && isManuallyMatchableOrderStatus(row.status) ? ( + {canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? (