diff --git a/apps/backend/src/db/migrations/093_clear_internal_consignment_last_worker.sql b/apps/backend/src/db/migrations/093_clear_internal_consignment_last_worker.sql new file mode 100644 index 00000000..4472acf6 --- /dev/null +++ b/apps/backend/src/db/migrations/093_clear_internal_consignment_last_worker.sql @@ -0,0 +1,12 @@ +-- 内部现货履约没有接单打手。退回大厅后留下的 last_assigned_worker_id +-- 会在状态离开 open 后,把买家资料重新暴露给上一手打手。 +-- 判定与 hasInternalConsignmentFulfillment 一致:无当前指派、挂着寄卖库存、不是押金履约、没有供货打手。 +UPDATE work_orders +SET + last_assigned_worker_id = NULL, + updated_at = NOW() +WHERE assigned_worker_id IS NULL + AND last_assigned_worker_id IS NOT NULL + AND COALESCE(material_json #>> '{consignment,inventoryId}', '') <> '' + AND COALESCE(material_json #>> '{consignment,fulfillmentType}', '') <> 'worker_deposit' + AND COALESCE(NULLIF(material_json #>> '{consignment,workerId}', ''), '0') = '0'; diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index 487a6926..1a9ffa70 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -173,6 +173,22 @@ test('普通关键词仍然走 trgm 表达式模糊搜索', () => { assert.deepEqual(query.params, ['%指挥官密钥%']) }) +test('内部现货不再因上一手 last_assigned 进入打手可见集', async () => { + const query = buildWorkOrderWhere({ workerSharingId: 183, statuses: ['in_progress'] }) + assert.match(query.joins, /historical\.last_assigned_worker_id = \$1/) + assert.match(query.joins, /consignment,fulfillmentType/) + assert.match(query.joins, /worker_deposit/) + assert.match(query.joins, /historical\.assigned_worker_id IS NULL/) + + const { readFile } = await import('node:fs/promises') + const src = await readFile( + new URL('./worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + const calls = src.match(/retainedWorkerOrderPredicate\('/g) || [] + assert.equal(calls.length, 2, '列表和概览必须共用同一条历史可见条件') +}) + test('「仅问题单」检索走短路分支,从 pending 表反查而非枚举可见集', () => { const query = buildWorkOrderWhere({ workerSharingId: 7, statuses: ['problem'] }) diff --git a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts index 27993257..009d4c27 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts @@ -96,6 +96,25 @@ export function hasInternalConsignmentFulfillment(workOrder: { return true } +/** + * 打手是否还能把这张工单当成自己的单。 + * + * 当前指派优先。退回大厅后留下的 last_assigned_worker_id 只用于回看自己做过的单; + * 一旦改走内部现货,这张单没有接单打手,上一手不能再看到买家资料。 + */ +export function workerOwnsWorkOrder( + workOrder: { + assigned_worker_id?: number | null + last_assigned_worker_id?: number | null + material_json?: unknown + }, + workerId: number, +): boolean { + if (Number(workOrder.assigned_worker_id || 0) === workerId) return true + if (Number(workOrder.last_assigned_worker_id || 0) !== workerId) return false + return !hasInternalConsignmentFulfillment(workOrder) +} + /** * 内部寄售现货履约落地:占库成功,但**不**算已加好友。 * @@ -150,6 +169,9 @@ export async function parkWorkOrderWaitingFriendAddWithClient( friend_added_at = NULL, gift_available_at = NULL, deadline_at = NULL, + -- 内部现货没有接单打手。退回大厅留下的上一手必须摘掉, + -- 否则状态一离开 open,他的「我的订单」会重新带出买家资料。 + last_assigned_worker_id = NULL, accepted_at = CASE WHEN $4 THEN NULL ELSE accepted_at END, material_json = COALESCE($5::jsonb, material_json), reward_amount = COALESCE($7, reward_amount), diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index af2a4a4b..6e746495 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -513,6 +513,34 @@ export async function getWorkOrderStatistics(input: ListInput = {}): Promise 'open' + OR EXISTS ( + SELECT 1 + FROM work_order_events timeout_event + WHERE timeout_event.work_order_id = ${alias}.id + AND timeout_event.event_type = 'timeout_reopen' + ) + ) + AND NOT ( + ${alias}.assigned_worker_id IS NULL + AND COALESCE(${alias}.material_json #>> '{consignment,inventoryId}', '') <> '' + AND COALESCE(${alias}.material_json #>> '{consignment,fulfillmentType}', '') <> 'worker_deposit' + AND COALESCE(NULLIF(${alias}.material_json #>> '{consignment,workerId}', ''), '0') = '0' + ) + ` +} + /** 汇总打手本人各订单状态的可结算金额,拼单按个人份额计算。 */ export async function getWorkerOrderOverview(workerId: number | string): Promise< Array<{ @@ -533,16 +561,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise UNION SELECT wo.id AS work_order_id FROM work_orders wo - WHERE wo.last_assigned_worker_id = $1 - AND ( - wo.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = wo.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) + WHERE ${retainedWorkerOrderPredicate('wo', '$1')} UNION SELECT wos.work_order_id FROM work_order_shares wos @@ -909,16 +928,7 @@ export function buildWorkOrderWhere({ UNION SELECT historical.id AS work_order_id FROM work_orders historical - WHERE historical.last_assigned_worker_id = ${workerSharePlaceholder} - AND ( - historical.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = historical.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) + WHERE ${retainedWorkerOrderPredicate('historical', workerSharePlaceholder)} UNION SELECT share_source.work_order_id FROM work_order_shares share_source diff --git a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts index f1686f67..34a71d8e 100644 --- a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts @@ -2,6 +2,7 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { workerOwnsWorkOrder } from './work-order-gift-repo.js' import { getWorkOrderByIdWithClient } from './work-order-repo.js' import type { WorkerOrderFeedbackRow, WorkOrderRow, WorkOrderShareRow } from './types.js' @@ -79,10 +80,9 @@ export async function createWorkerOrderFeedback(input: { [input.workOrderId, input.workerId], ) const share = shareResult.rows[0] || null - const isOrderOwner = - Number(order.assigned_worker_id || 0) === input.workerId || - Number(order.last_assigned_worker_id || 0) === input.workerId - if (!share && !isOrderOwner) return { feedback: null, failureReason: 'owner_required' } + if (!share && !workerOwnsWorkOrder(order, input.workerId)) { + return { feedback: null, failureReason: 'owner_required' } + } const result = await client.query<{ id: number }>( `INSERT INTO worker_order_feedbacks ( 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 e379dfc8..4e3fdf12 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -599,7 +599,13 @@ export async function fulfillWorkOrderWithConsignment(input: { } } - publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(updatedOrder.id), + hallChanged: true, + // 履约会清掉 last_assigned_worker_id,广播时工单上已经没有上一手, + // 要显式带上他,否则「我的订单」缓存最长 30 秒还显示这张单。 + workerIds: [Number(workOrder.last_assigned_worker_id || 0)].filter((id) => id > 0), + }) return { order: mapWorkOrderAdmin(updatedOrder), diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 68e57b4a..46508228 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -16,6 +16,7 @@ import { listWorkOrderEventsByOrderId, listWorkOrders, markWorkerWorkOrderViewed, + workerOwnsWorkOrder, upsertWorkerWorkOrderNote, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' @@ -181,10 +182,7 @@ export async function getWorkerMyOrderEvents(workOrderId: number | string, sessi const workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - const isOrderOwner = - Number(workOrder.assigned_worker_id || 0) === workerId || - Number(workOrder.last_assigned_worker_id || 0) === workerId - if (!sharingShare && !isOrderOwner) { + if (!sharingShare && !workerOwnsWorkOrder(workOrder, workerId)) { throw createHttpError('只能查看自己订单的流转记录', { statusCode: 403, errorCode: 'work_order_events_owner_required', @@ -345,10 +343,7 @@ export async function saveWorkerOrderNote( const worker = await getRequiredWorker(workerId) const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - const isOrderOwner = - Number(workOrder.assigned_worker_id || 0) === workerId || - Number(workOrder.last_assigned_worker_id || 0) === workerId - if (!sharingShare && !isOrderOwner) { + if (!sharingShare && !workerOwnsWorkOrder(workOrder, workerId)) { throw createHttpError('只能修改自己的订单备注', { statusCode: 403, errorCode: 'work_order_note_owner_required', @@ -380,10 +375,7 @@ export async function markWorkerOrderRead( const workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - const isOrderOwner = - Number(workOrder.assigned_worker_id || 0) === workerId || - Number(workOrder.last_assigned_worker_id || 0) === workerId - if (!sharingShare && !isOrderOwner) { + if (!sharingShare && !workerOwnsWorkOrder(workOrder, workerId)) { throw createHttpError('只能标记自己的订单为已读', { statusCode: 403, errorCode: 'work_order_read_owner_required', 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 932ed2c5..5efee579 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 @@ -73,7 +73,10 @@ import { } from '../../repositories/worker-platform/consignment-repo.js' import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js' import { updateAdminConsignmentInventory } from './consignment-admin-service.js' -import { parkWorkOrderWaitingFriendAddWithClient } from '../../repositories/worker-platform/work-order-gift-repo.js' +import { + parkWorkOrderWaitingFriendAddWithClient, + workerOwnsWorkOrder, +} from '../../repositories/worker-platform/work-order-gift-repo.js' import { assertConsignmentRevocableWorkOrderStatus, resolveRevocableConsignmentInventoryId, @@ -2308,6 +2311,10 @@ test('内部现货履约停在「待加好友」:不写 friend_added_at,也 assert.ok(sql.includes('friend_added_at = NULL'), '不能自动确认加好友') assert.ok(sql.includes('gift_available_at = NULL')) assert.ok(sql.includes('deadline_at = NULL'), '没有打手在跑,不应背任务时限') + assert.ok( + sql.includes('last_assigned_worker_id = NULL'), + '内部现货必须摘掉上一手打手,否则状态离开 open 后他会重新看到买家资料', + ) assert.ok(sql.includes("acceptance_mode = 'friend_gift'")) assert.ok(!sql.includes('friend_added_at = $2'), '不能把当前时间写成已加好友') @@ -2321,6 +2328,34 @@ test('内部现货履约停在「待加好友」:不写 friend_added_at,也 ) }) +test('内部现货履约后,上一手打手不再拥有这张工单', () => { + const internalOrder = { + assigned_worker_id: null, + last_assigned_worker_id: 183, + material_json: { + consignment: { inventoryId: 414, fulfillmentType: 'internal_stock' }, + }, + } + assert.equal(workerOwnsWorkOrder(internalOrder, 183), false) + assert.equal( + workerOwnsWorkOrder({ ...internalOrder, assigned_worker_id: 183 }, 183), + true, + '重新指派给本人后仍然是他的单', + ) + assert.equal( + workerOwnsWorkOrder( + { + assigned_worker_id: null, + last_assigned_worker_id: 183, + material_json: {}, + }, + 183, + ), + true, + '普通退回后的历史单仍可回看', + ) +}) + test('停在待加好友时保留商品规则已配的冷却时长', async () => { const calls: Array<{ sql: string; values?: unknown[] }> = [] const client = {