fix(consignment): 内部库存履约后不再把买家资料暴露给上一手打手

退回大厅留下的 last_assigned 在状态离开待接单后会重新可见。履约时摘掉上一手,并清掉已经漏出的内部现货工单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-05 19:18:43 +08:00
co-authored by Cursor
parent eae78aae34
commit 250c456a46
8 changed files with 131 additions and 38 deletions
@@ -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';
@@ -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'] })
@@ -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),
@@ -513,6 +513,34 @@ export async function getWorkOrderStatistics(input: ListInput = {}): Promise<Wor
return statistics
}
/**
* 上一手打手仍可回看的历史单。
*
* 大厅中的单(status=open)默认不展示,除非是超时退回。
* 内部现货没有接单打手:退回大厅留下的 last_assigned_worker_id 不能在履约改走库存后继续暴露买家资料。
* 判定与 hasInternalConsignmentFulfillment 一致。
*/
export function retainedWorkerOrderPredicate(alias: string, workerParam: string): string {
return `
${alias}.last_assigned_worker_id = ${workerParam}
AND (
${alias}.status <> '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
@@ -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 (
@@ -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),
@@ -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',
@@ -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 = {