add:新增同一个工单同一人可以多次拼单

This commit is contained in:
2026-10-23 15:14:07 +08:00
parent bfd2325ac5
commit 1d5615f1ae
18 changed files with 2996 additions and 128 deletions
@@ -3,9 +3,13 @@ import type { Response } from 'express'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
getLatestMaterialEventAtByOrderIds,
getPendingCancelRequestOrderIds,
getPendingFeedbackOrderIds,
getWorkerFinanceRequestById,
getWorkerUserById,
getWorkOrderById,
listWorkerTimeoutEventsByOrderIds,
listWorkerWorkOrderNotes,
listWorkerWorkOrderViews,
listWorkOrderShares,
@@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
import {
isWorkOrderSharingFilled,
isWorkerOrderVipEvidencePending,
mapFinanceRequest,
mapWorkOrderAdmin,
mapWorkOrderForWorker,
mapWorkerUser,
pickWorkerRepresentativeShare,
resolveWorkerOrderStatusTabs,
resolveWorkerPermissions,
} from '../worker-platform/mappers.js'
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
@@ -336,33 +343,121 @@ async function publishWorkOrderSnapshots(input: {
})
}
// 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。
const materialEventAt =
(await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || ''
for (const workerId of new Set(
(input.workerIds || []).map((item) => Number(item)).filter(Boolean),
)) {
const worker = await getWorkerUserById(workerId)
if (!worker) continue
const [notes, views] = await Promise.all([
listWorkerWorkOrderNotes(workerId, [workOrderId]),
listWorkerWorkOrderViews(workerId, [workOrderId]),
])
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
const viewedAt =
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
const share = shares.find((item) => Number(item.worker_id) === workerId)
// 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行:
// 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错,
// 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled')
const workerShares = shares.filter((item) => Number(item.worker_id) === workerId)
const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled')
const representativeShare = pickWorkerRepresentativeShare(
activeWorkerShares.length > 0 ? activeWorkerShares : workerShares,
)
const cancelledShare = workerShares
.filter((item) => item.status === 'cancelled')
.sort((left, right) => Number(right.id) - Number(left.id))[0]
const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0
const workerOrderVisible = isOwnerCurrently
// 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。
const needsTimeoutCheck = !isOwnerCurrently
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(workerId, [workOrderId]),
listWorkerWorkOrderViews(workerId, [workOrderId]),
getPendingCancelRequestOrderIds(workerId, [workOrderId]),
getPendingFeedbackOrderIds(workerId, [workOrderId]),
needsTimeoutCheck
? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId])
: Promise.resolve(new Map<number, { createdAt: string }>()),
])
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
const view = views.find((item) => Number(item.work_order_id) === workOrderId)
const viewedAt = view?.viewed_at || null
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
const isTimedOutForMe =
!isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled))
const timeoutNotice = isTimedOutForMe
? {
isTimedOut: true,
timedOutAt:
timeoutEvent?.createdAt ||
(workOrder.deadline_at ? String(workOrder.deadline_at) : null) ||
workOrder.updated_at,
warningMessage:
(workOrder.assigned_worker_id != null &&
Number(workOrder.assigned_worker_id) !== workerId) ||
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
workOrder.status,
)
? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'
: '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。',
isTakenByOther:
(workOrder.assigned_worker_id != null &&
Number(workOrder.assigned_worker_id) !== workerId) ||
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
workOrder.status,
),
}
: undefined
const hasPendingHandling =
pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId)
const materialSeenAt = view?.material_seen_at || ''
const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder)
publishRealtimeEvent({
type: 'work_order.changed',
entityId: workOrderId,
scopes: ['worker_my_orders'],
operation: workerOrderVisible ? 'upsert' : 'remove',
data: mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
share,
note,
viewedAt,
),
data: {
...mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
representativeShare,
note,
viewedAt,
{
isTimedOutForMe,
...(timeoutNotice ? { timeoutNotice } : {}),
// 与列表一致:只带未取消份额,按 id DESC(参与先后倒序)
myShares: [...activeWorkerShares].sort(
(left, right) => Number(right.id) - Number(left.id),
),
},
),
/**
* 这张单在该打手「我的订单」里属于哪些状态页签。
* 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。
*/
workerStatuses: resolveWorkerOrderStatusTabs({
workOrderStatus: workOrder.status,
isAssignedWorker,
shares: workerShares,
hasPendingHandling,
hasTimeoutForMe: Boolean(timeoutEvent),
vipEvidencePending,
}),
problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM,
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
materialUpdated:
!isTimedOutForMe &&
Boolean(materialEventAt) &&
(!materialSeenAt ||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()),
},
workerIds: [workerId],
})
}
@@ -83,6 +83,7 @@ export {
} from './worker-finance-mappers.js'
export {
isWorkerOrderVipEvidencePending,
mapCollectLookupOrder,
mapWorkCategory,
mapWorkOrderAdmin,
@@ -91,6 +92,8 @@ export {
mapWorkOrderPublic,
mapWorkOrderShare,
mapWorkOrderSharingWorkers,
pickWorkerRepresentativeShare,
resolveWorkerOrderStatusTabs,
resolveWorkOrderOperationalStatus,
resolveWorkerShareProductName,
resolveWorkerShareStatus,
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js'
import {
isWorkerOrderVipEvidencePending,
pickWorkerRepresentativeShare,
resolveWorkerOrderStatusTabs,
summarizeWorkerShares,
} from './work-order-display-mappers.js'
function share(overrides: Record<string, unknown>): WorkOrderShareRow {
return {
id: 1,
work_order_id: 1,
worker_id: 1,
quantity: 1,
unit_reward: 10,
share_reward: 10,
share_deposit: 0,
status: 'joined',
deadline_at: null,
timeout_minutes: 0,
created_at: '2026-10-23T10:00:00.000Z',
...overrides,
} as unknown as WorkOrderShareRow
}
test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => {
const summary = summarizeWorkerShares([
share({
id: 1,
quantity: 2,
share_reward: 20,
share_deposit: 5,
status: 'joined',
created_at: '2026-10-23T10:00:00.000Z',
deadline_at: '2026-10-23T12:00:00.000Z',
}),
share({
id: 2,
quantity: 3,
share_reward: 30,
share_deposit: 5,
status: 'joined',
created_at: '2026-10-23T10:10:00.000Z',
deadline_at: '2026-10-23T11:00:00.000Z',
}),
share({
id: 3,
quantity: 9,
share_reward: 90,
share_deposit: 99,
status: 'cancelled',
created_at: '2026-10-23T09:00:00.000Z',
}),
])
assert.equal(summary.quantity, 5)
assert.equal(summary.rewardAmount, 50)
assert.equal(summary.depositAmount, 10)
// 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。
assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z')
// 父行倒计时取未完成份额中最早的截止时间。
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z')
})
test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => {
const summary = summarizeWorkerShares([
share({
id: 1,
quantity: 2,
share_reward: 20,
status: 'joined',
created_at: '2026-10-23T10:00:00.000Z',
deadline_at: null,
timeout_minutes: 30,
}),
])
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z')
})
test('只剩已取消份额时,金额归零但保留最早参与时间', () => {
const summary = summarizeWorkerShares([
share({
id: 1,
quantity: 2,
share_reward: 20,
status: 'cancelled',
created_at: '2026-10-23T09:00:00.000Z',
}),
share({
id: 2,
quantity: 3,
share_reward: 30,
status: 'cancelled',
created_at: '2026-10-23T09:30:00.000Z',
}),
])
assert.equal(summary.quantity, 0)
assert.equal(summary.rewardAmount, 0)
assert.equal(summary.depositAmount, 0)
assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z')
assert.equal(summary.earliestActiveDeadlineAt, null)
})
test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => {
const shares = [
share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }),
share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }),
share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }),
]
assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3)
// 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错)
const sameStatus = [
share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }),
share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
]
assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12)
})
test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => {
const tabs = resolveWorkerOrderStatusTabs({
workOrderStatus: 'in_progress',
isAssignedWorker: false,
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
})
assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance'])
})
test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => {
const tabs = resolveWorkerOrderStatusTabs({
workOrderStatus: 'in_progress',
isAssignedWorker: false,
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
hasPendingHandling: true,
})
assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem'])
})
test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => {
const tabs = resolveWorkerOrderStatusTabs({
workOrderStatus: 'problem',
isAssignedWorker: true,
shares: [share({ id: 1, status: 'submitted' })],
})
assert.deepEqual(tabs, ['problem'])
})
test('份额全部取消时只归已取消,不得混进待验收', () => {
const tabs = resolveWorkerOrderStatusTabs({
workOrderStatus: 'pending_acceptance',
isAssignedWorker: true,
shares: [share({ id: 1, status: 'cancelled' })],
})
assert.deepEqual(tabs, ['cancelled'])
})
test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => {
assert.deepEqual(
resolveWorkerOrderStatusTabs({
workOrderStatus: 'pending_acceptance',
isAssignedWorker: true,
shares: [],
}),
['pending_acceptance'],
)
assert.deepEqual(
resolveWorkerOrderStatusTabs({
workOrderStatus: 'pending_acceptance',
isAssignedWorker: false,
shares: [share({ id: 1, status: 'cancelled' })],
hasTimeoutForMe: true,
}),
['open'],
)
})
test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => {
const now = Date.parse('2026-10-23T10:00:00.000Z')
const base = {
status: 'accepted',
acceptance_json: {
reviewMode: 'vip_auto',
evidenceStatus: 'pending',
evidenceDueAt: '2026-10-24T10:00:00.000Z',
},
}
assert.equal(isWorkerOrderVipEvidencePending(base, now), true)
assert.equal(
isWorkerOrderVipEvidencePending(
{ ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } },
now,
),
false,
)
assert.equal(
isWorkerOrderVipEvidencePending(
{
...base,
acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' },
},
now,
),
false,
)
assert.deepEqual(
resolveWorkerOrderStatusTabs({
workOrderStatus: 'accepted',
isAssignedWorker: true,
shares: [share({ id: 1, status: 'accepted' })],
vipEvidencePending: true,
}).sort(),
['accepted', 'vip_evidence_pending'],
)
})
@@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u
return workOrderStatus
}
/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */
export type WorkerOrderStatusTab =
| 'in_progress'
| 'pending_acceptance'
| 'problem'
| 'open'
| 'accepted'
| 'cancelled'
| 'vip_evidence_pending'
/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */
const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem'])
/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */
const WORKER_SHARE_STATUS_PRIORITY: Record<string, number> = {
joined: 0,
submitted: 1,
accepted: 2,
cancelled: 3,
}
type WorkerShareLike = {
status?: unknown
created_at?: unknown
id?: unknown
}
/**
* 同一打手在一个工单里的「代表份额」。
*
* 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到
* 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个
* 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。
* 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致:
* status 优先级 ASC, created_at DESC, id DESC
*/
export function pickWorkerRepresentativeShare<T extends WorkerShareLike>(
shares: readonly T[],
): T | null {
let representative: T | null = null
for (const share of shares) {
if (!representative) {
representative = share
continue
}
const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4
const representativePriority =
WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4
if (priority !== representativePriority) {
if (priority < representativePriority) representative = share
continue
}
const createdMs = Date.parse(String(share.created_at || ''))
const representativeCreatedMs = Date.parse(String(representative.created_at || ''))
if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) {
if (createdMs !== representativeCreatedMs) {
if (createdMs > representativeCreatedMs) representative = share
continue
}
}
if (Number(share.id || 0) > Number(representative.id || 0)) representative = share
}
return representative
}
/**
* 一个工单在该打手「我的订单」里会出现在哪些状态页签。
*
* 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted),
* 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉,
* 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。
*
* 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选):
* - 母单 accepted/cancelled/problem:只看代表状态(母单状态);
* - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中);
* - 有该打手的超时事件且他已不持有订单:代表状态记 open。
*/
export function resolveWorkerOrderStatusTabs(input: {
workOrderStatus: string
isAssignedWorker: boolean
/** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */
shares?: readonly WorkerShareLike[]
/** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */
hasPendingHandling?: boolean
/** 该打手在这张单上有超时事件。 */
hasTimeoutForMe?: boolean
/** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */
vipEvidencePending?: boolean
}): WorkerOrderStatusTab[] {
const tabs = new Set<WorkerOrderStatusTab>()
const workOrderStatus = String(input.workOrderStatus || '')
if (input.vipEvidencePending) tabs.add('vip_evidence_pending')
if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) {
tabs.add(workOrderStatus as WorkerOrderStatusTab)
return [...tabs]
}
const shares = input.shares || []
const activeShares = shares.filter(
(share) =>
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
)
const representative = pickWorkerRepresentativeShare(shares)
let representativeStatus = representative
? resolveWorkerShareStatus(workOrderStatus, representative.status)
: workOrderStatus
if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) {
representativeStatus = WORK_ORDER_STATUS.OPEN
} else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) {
representativeStatus = WORK_ORDER_STATUS.PROBLEM
}
if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab)
for (const share of activeShares) {
if (share.status === 'joined') {
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
if (!input.hasPendingHandling) tabs.add('in_progress')
} else if (share.status === 'submitted') {
tabs.add('pending_acceptance')
} else if (share.status === 'accepted') {
tabs.add('accepted')
}
}
return [...tabs]
}
/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */
export function isWorkerOrderVipEvidencePending(
workOrder: Pick<WorkOrderRow, 'status' | 'acceptance_json'>,
nowMs = Date.now(),
): boolean {
if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false
const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record<string, unknown>
if (String(acceptance.reviewMode || '') !== 'vip_auto') return false
if (String(acceptance.evidenceStatus || '') !== 'pending') return false
const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || ''))
return Number.isFinite(dueAtMs) && dueAtMs > nowMs
}
export function mapWorkOrderShare(share: WorkOrderShareRow) {
return {
shareId: Number(share.id),
@@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) {
buildMyOrderCacheKey(worker.id, 'overview', 0),
() => getWorkerOrderOverview(worker.id),
)
const items = rows.map((row) => ({
const statusRows = rows.filter((row) => row.status !== 'all')
const items = statusRows.map((row) => ({
status: row.status,
orderCount: row.orderCount,
settlementAmount: row.settlementAmount,
}))
const vipEvidencePending = rows.reduce(
const vipEvidencePending = statusRows.reduce(
(summary, row) => ({
orderCount: summary.orderCount + row.vipEvidencePendingCount,
settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount,
}),
{ orderCount: 0, settlementAmount: 0 },
)
const all = items.reduce(
(summary, item) => ({
orderCount: summary.orderCount + item.orderCount,
settlementAmount: summary.settlementAmount + item.settlementAmount,
}),
{ orderCount: 0, settlementAmount: 0 },
)
// 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」,
// 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。
const allRow = rows.find((row) => row.status === 'all')
const all = allRow
? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount }
: items.reduce(
(summary, item) => ({
orderCount: summary.orderCount + item.orderCount,
settlementAmount: summary.settlementAmount + item.settlementAmount,
}),
{ orderCount: 0, settlementAmount: 0 },
)
return {
items: [
...items,