diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 662bdefd..89f1eb9b 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -3,12 +3,14 @@ import type { Response } from 'express' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + type ConsignmentInventoryRow, getLatestMaterialEventAtByOrderIds, getPendingCancelRequestOrderIds, getPendingFeedbackOrderIds, getWorkerFinanceRequestById, getWorkerUserById, getWorkOrderById, + listConsignmentInventoriesByIds, listWorkerTimeoutEventsByOrderIds, listWorkerWorkOrderNotes, listWorkerWorkOrderViews, @@ -23,6 +25,7 @@ import { type RealtimeEventRow, } from '../../repositories/realtime-event-repo.js' import { getAdminDashboardSummary } from '../admin/admin-dashboard-service.js' +import { safeParseJson } from '../admin/admin-query-utils.js' import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { @@ -298,8 +301,27 @@ async function publishWorkOrderSnapshots(input: { return } - const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds([workOrderId]) - const shares = await listWorkOrderShares(workOrderId) + const material = safeParseJson(workOrder.material_json) + const consignmentMaterial = material.consignment + const consignmentInventoryId = + consignmentMaterial && + typeof consignmentMaterial === 'object' && + !Array.isArray(consignmentMaterial) + ? Math.max( + 0, + Math.floor(Number((consignmentMaterial as Record).inventoryId) || 0), + ) + : 0 + const [pendingUnfreezeByOrderId, shares, consignmentInventories] = await Promise.all([ + sumPendingUnfreezeByOrderIds([workOrderId]), + listWorkOrderShares(workOrderId), + consignmentInventoryId > 0 + ? listConsignmentInventoriesByIds([consignmentInventoryId]) + : Promise.resolve([] as ConsignmentInventoryRow[]), + ]) + // 寄售工单的快照也要带库存实时供货人:前端会整行替换, + // 若只给 material 里的旧快照,改名后的列表会被实时事件刷回旧名。 + const consignmentInventory = consignmentInventories[0] || null // 事件驱动缓存失效:这里已经拿到的工单与拼单快照恰好给出完整的受影响打手集合 // (当前指派 + 历史保留 + 全部拼单人 + 调用方显式指定的)。 @@ -317,7 +339,7 @@ async function publishWorkOrderSnapshots(input: { .reduce((sum, share) => sum + Number(share.quantity || 0), 0) const pendingSubmissionCount = shares.filter((share) => share.status === 'joined').length const adminSnapshot = { - ...mapWorkOrderAdmin(workOrder, shares), + ...mapWorkOrderAdmin(workOrder, shares, consignmentInventory), pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0, sharingProgress: { joinedQuantity, diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 30682fbc..20fee913 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -296,6 +296,12 @@ export function mapWorkOrderAdmin( const giftPhase = String(workOrder.gift_phase || '').trim() const resolvedGiftPhase = resolveWorkOrderGiftPhase(workOrder) const internalConsignment = hasInternalConsignmentFulfillment(workOrder) + const consignmentMaterial = safeParseJson( + safeParseJson(workOrder.material_json).consignment, + ) as JsonObject + const consignmentWorkerIdRaw = + consignmentInventory?.worker_id || consignmentMaterial.workerId || 0 + const consignmentWorkerId = Math.max(0, Math.floor(Number(consignmentWorkerIdRaw) || 0)) const hallAvailable = workOrder.status === WORK_ORDER_STATUS.OPEN && (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) @@ -474,6 +480,8 @@ export function mapWorkOrderAdmin( displayName: workOrder.worker_display_name || '', } : null, + /** 寄售供货打手:后台「接单人员」筛选也认这个 id(内部寄售直接履约没有 assigned_worker_id)。 */ + consignmentWorkerId: consignmentWorkerId > 0 ? consignmentWorkerId : null, ...(shares ? { sharingWorkers: mapWorkOrderSharingWorkers(shares) } : {}), publishedAt: workOrder.published_at, pinnedAt: workOrder.pinned_at, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 694dd614..77a1e70f 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -765,6 +765,8 @@ export type WorkOrder = { username: string displayName: string } + /** 寄售供货打手 id:后台「接单人员」筛选同时认这个 id。 */ + consignmentWorkerId?: number | null /** 拼单订单的当前参与打手,普通整单不返回该字段。 */ sharingWorkers?: Array<{ workerId: number diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 0751288d..16c60ef0 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -155,6 +155,18 @@ export function isExplicitOrderNoSearch(text: unknown): boolean { ) } +/** + * 快照里是否带寄售履约标记。 + * + * 寄售单的供货打手不在 worker / sharingWorkers 上,而在库存行上; + * 旧快照没有 consignmentWorkerId 时无法判断它是否命中「接单人员」筛选。 + */ +function hasConsignmentMaterial(snapshot: Record): boolean { + const material = isRecord(snapshot.material) ? snapshot.material : null + const consignment = material && isRecord(material.consignment) ? material.consignment : null + return Boolean(consignment && (consignment.inventoryId || consignment.inventoryNo)) +} + export function evaluateAdminWorkOrderRealtimeMatch( snapshot: unknown, filters: AdminWorkOrderListFilters, @@ -190,7 +202,16 @@ export function evaluateAdminWorkOrderRealtimeMatch( toNumber((worker as { workerId?: unknown })?.workerId), ) : [] - if (assignedWorkerId !== filters.workerId && !sharingWorkerIds.includes(filters.workerId)) { + // 后台「接单人员」筛选同时认寄售供货人;实时快照带 consignmentWorkerId。 + const consignmentWorkerId = toNumber(snapshot.consignmentWorkerId) + const matchesTargetWorker = + assignedWorkerId === filters.workerId || + sharingWorkerIds.includes(filters.workerId) || + consignmentWorkerId === filters.workerId + if (!matchesTargetWorker) { + // 寄售单的供货人只存在库存行上:旧快照 / 未加载库存时无法证伪, + // 直接删行会把「接单人员 + 内部寄售匹配」列表里的寄售单清空。 + if (consignmentWorkerId <= 0 && hasConsignmentMaterial(snapshot)) return 'unknown' return 'mismatch' } } diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index 65440072..a44083a9 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -332,11 +332,27 @@ test('打手 / 分类 / 工单号 / 创建日期筛选的证伪能力', () => { }), )! - // 指派给别的打手 → mismatch + // 指派给别的打手 → mismatch(consignmentWorkerId 明确指向别人,才能证伪) assert.equal( - evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ worker: { workerId: 8 } }), filters), + evaluateAdminWorkOrderRealtimeMatch( + buildSnapshot({ worker: { workerId: 8 }, consignmentWorkerId: 8 }), + filters, + ), 'mismatch', ) + // 寄售供货人是目标打手 → 命中(内部寄售直接履约没有 assigned_worker_id) + assert.equal( + evaluateAdminWorkOrderRealtimeMatch( + buildSnapshot({ consignmentWorkerId: 7, categoryId: 12 }), + filters, + ), + 'match', + ) + // 旧快照没有 consignmentWorkerId、但有寄售标记 → 无法证伪,只能原地更新,不能删行 + assert.equal( + evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ categoryId: 12 }), filters), + 'unknown', + ) // 拼单参与者是目标打手 → 命中(后端 workerId 筛选也认拼单参与者) assert.equal( evaluateAdminWorkOrderRealtimeMatch(