打手端待审撤单与未处理反馈订单归入问题单分组并标注来源角标

This commit is contained in:
yml2213
2026-08-22 15:04:30 +08:00
parent 061bc2636d
commit 2224dd06d3
14 changed files with 249 additions and 41 deletions
@@ -0,0 +1,5 @@
-- 打手查看工单资料更新的时间:客服更新资料后订单展示"资料已更新"角标,打手打开详情后清除。
ALTER TABLE worker_work_order_views
ADD COLUMN IF NOT EXISTS material_seen_at TIMESTAMPTZ;
COMMENT ON COLUMN worker_work_order_views.material_seen_at IS '打手最近一次打开工单详情的时间,用于计算资料已更新角标';
@@ -2,6 +2,24 @@ import type { PoolClient } from 'pg'
import { query } from '../../db/client.js'
/** 批量查询工单最近一次客服更新资料的时间,用于打手端"资料已更新"角标。 */
export async function getLatestMaterialEventAtByOrderIds(
workOrderIds: number[],
): Promise<Map<number, string>> {
if (workOrderIds.length === 0) return new Map()
const result = await query<{ work_order_id: number; created_at: string }>(
`
SELECT DISTINCT ON (work_order_id) work_order_id, created_at
FROM work_order_events
WHERE work_order_id = ANY($1::bigint[])
AND event_type IN ('material_updated', 'material_completed')
ORDER BY work_order_id, id DESC
`,
[workOrderIds],
)
return new Map(result.rows.map((row) => [Number(row.work_order_id), String(row.created_at)]))
}
export type WorkOrderEventRow = {
id: number
work_order_id: number
@@ -122,9 +122,13 @@ function workOrderSearchExpression(alias = 'wo') {
)`
}
/** 打手订单筛选使用个人份额状态,而不是拼单母工单的 open 状态。 */
function workerOrderStatusExpression(workerShareAlias = 'worker_share') {
return `CASE
/**
* 打手订单个人状态:拼单按个人份额状态计算。
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
* 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。
*/
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
const baseExpression = `CASE
WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status
ELSE COALESCE(CASE ${workerShareAlias}.status
WHEN 'joined' THEN 'in_progress'
@@ -134,6 +138,27 @@ function workerOrderStatusExpression(workerShareAlias = 'worker_share') {
ELSE wo.status
END, wo.status)
END`
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
if (normalizedWorkerId <= 0) return baseExpression
const pendingHandlingExists = `(
EXISTS (
SELECT 1
FROM worker_order_cancel_requests wcr
WHERE wcr.work_order_id = wo.id
AND wcr.worker_id = ${normalizedWorkerId}
AND wcr.status = 'pending'
) OR EXISTS (
SELECT 1
FROM worker_order_feedbacks wf
WHERE wf.work_order_id = wo.id
AND wf.worker_id = ${normalizedWorkerId}
AND wf.status = 'pending'
)
)`
return `CASE
WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem'
ELSE ${baseExpression}
END`
}
export async function getWorkOrderByOrderItemId(
orderItemId: number | string,
@@ -343,15 +368,44 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
WITH worker_orders AS (
SELECT
CASE
WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem')
THEN CASE wos.status
WHEN 'joined' THEN 'in_progress'
WHEN 'submitted' THEN 'pending_acceptance'
WHEN 'accepted' THEN 'accepted'
WHEN 'cancelled' THEN 'cancelled'
WHEN (
CASE
WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem')
THEN CASE wos.status
WHEN 'joined' THEN 'in_progress'
WHEN 'submitted' THEN 'pending_acceptance'
WHEN 'accepted' THEN 'accepted'
WHEN 'cancelled' THEN 'cancelled'
ELSE wo.status
END
ELSE wo.status
END
ELSE wo.status
) = 'in_progress' AND (
EXISTS (
SELECT 1
FROM worker_order_cancel_requests wcr
WHERE wcr.work_order_id = wo.id
AND wcr.worker_id = $1
AND wcr.status = 'pending'
) OR EXISTS (
SELECT 1
FROM worker_order_feedbacks wf
WHERE wf.work_order_id = wo.id
AND wf.worker_id = $1
AND wf.status = 'pending'
)
) THEN 'problem'
ELSE CASE
WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem')
THEN CASE wos.status
WHEN 'joined' THEN 'in_progress'
WHEN 'submitted' THEN 'pending_acceptance'
WHEN 'accepted' THEN 'accepted'
WHEN 'cancelled' THEN 'cancelled'
ELSE wo.status
END
ELSE wo.status
END
END AS status,
wo.acceptance_json,
wos.id IS NOT NULL AS is_worker_share,
@@ -678,7 +732,9 @@ export function buildWorkOrderWhere({
params.push(normalizedStatuses)
const statusesPlaceholder = `$${params.length}`
if (workerSharingId) {
filters.push(`${workerOrderStatusExpression()} = ANY(${statusesPlaceholder}::text[])`)
filters.push(
`${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`,
)
} else {
filters.push(
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
@@ -201,6 +201,23 @@ async function applyWorkerCancelRequestPenaltyWithClient(
}
/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */
/** 批量查询打手在各工单上待审核的撤单申请,用于订单角标与问题单归类。 */
export async function getPendingCancelRequestOrderIds(
workerId: number,
workOrderIds: number[],
): Promise<Set<number>> {
if (workOrderIds.length === 0) return new Set()
const result = await query<{ work_order_id: number }>(
`
SELECT work_order_id
FROM worker_order_cancel_requests
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) AND status = 'pending'
`,
[Number(workerId), workOrderIds],
)
return new Set(result.rows.map((row) => Number(row.work_order_id)))
}
export async function createWorkerCancelRequest(input: {
workOrderId: number
workerId: number
@@ -39,6 +39,23 @@ async function getWorkerOrderFeedbackByIdWithClient(
return result.rows[0] || null
}
/** 批量查询打手在各工单上未处理的问题反馈,用于订单角标与问题单归类。 */
export async function getPendingFeedbackOrderIds(
workerId: number,
workOrderIds: number[],
): Promise<Set<number>> {
if (workOrderIds.length === 0) return new Set()
const result = await query<{ work_order_id: number }>(
`
SELECT work_order_id
FROM worker_order_feedbacks
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) AND status = 'pending'
`,
[Number(workerId), workOrderIds],
)
return new Set(result.rows.map((row) => Number(row.work_order_id)))
}
export async function createWorkerOrderFeedback(input: {
workOrderId: number
workerId: number
@@ -36,15 +36,19 @@ export async function upsertWorkerWorkOrderNote(input: {
return String(result.rows[0]?.note || '')
}
/** 查询打手已查看过的工单,用于计算"新"标识。 */
/** 查询打手已查看过的工单,用于计算"新"标识与资料已更新角标。 */
export async function listWorkerWorkOrderViews(
workerId: number | string,
workOrderIds: number[],
): Promise<Array<{ work_order_id: number; viewed_at: string }>> {
): Promise<Array<{ work_order_id: number; viewed_at: string; material_seen_at: string | null }>> {
if (workOrderIds.length === 0) return []
const result = await query<{ work_order_id: number; viewed_at: string }>(
const result = await query<{
work_order_id: number
viewed_at: string
material_seen_at: string | null
}>(
`
SELECT work_order_id, viewed_at
SELECT work_order_id, viewed_at, material_seen_at
FROM worker_work_order_views
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[])
`,
@@ -53,12 +57,27 @@ export async function listWorkerWorkOrderViews(
return result.rows
}
/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */
/**
* 首次查看工单时写入已读记录,重复调用保持原查看时间不变;
* materialSeen 为 true 时刷新资料查看时间,用于清除"资料已更新"角标。
*/
export async function markWorkerWorkOrderViewed(input: {
workOrderId: number
workerId: number
now: string
materialSeen?: boolean
}): Promise<string> {
if (input.materialSeen) {
await query(
`
INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at, material_seen_at)
VALUES ($1, $2, $3, $3)
ON CONFLICT (work_order_id, worker_id) DO UPDATE
SET material_seen_at = EXCLUDED.material_seen_at
`,
[input.workOrderId, input.workerId, input.now],
)
}
const inserted = await query<{ viewed_at: string }>(
`
INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at)
+5 -1
View File
@@ -157,7 +157,11 @@ router.post(
requireActiveWorker,
createRouteHandler(
(req) =>
markWorkerOrderRead(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)),
markWorkerOrderRead(
String(req.params.workOrderId || ''),
req.body || {},
getRequiredWorkerSession(req),
),
{
successMessage: '订单已标记为已读',
errorMessage: '标记订单已读失败',
@@ -2,6 +2,9 @@ import { runtimeConfig } from '../../config/runtime.js'
import {
createWorkerCancelRequest,
createWorkerOrderFeedback,
getLatestMaterialEventAtByOrderIds,
getPendingCancelRequestOrderIds,
getPendingFeedbackOrderIds,
getWorkOrderShare,
getWorkerCancelRequestRisk,
getWorkerOrderOverview,
@@ -61,26 +64,47 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const myShares = await listWorkerSharesByWorker(worker.id)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const workOrderIds = items.map((item) => Number(item.id))
const [notes, views] = await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
])
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
])
const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
)
const viewedAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]),
)
const materialSeenAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.material_seen_at || '')]),
)
return {
items: items.map((item) =>
mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(Number(item.id)),
noteByOrderId.get(Number(item.id)) || '',
viewedAtByOrderId.get(Number(item.id)) || null,
),
),
items: items.map((item) => {
const workOrderId = Number(item.id)
const materialEventAt = materialEventAtByOrderId.get(workOrderId) || ''
const materialSeenAt = materialSeenAtByOrderId.get(workOrderId) || ''
const materialUpdated =
Boolean(materialEventAt) &&
(!materialSeenAt ||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime())
return {
...mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(workOrderId),
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
),
// 问题单分组内的来源角标:平台标记 / 撤单审核中 / 反馈处理中。
problemMarked: item.status === 'problem',
pendingCancelRequest: pendingCancelOrderIds.has(workOrderId),
pendingFeedback: pendingFeedbackOrderIds.has(workOrderId),
materialUpdated,
}
}),
pagination: { page, pageSize, total },
}
}
@@ -319,8 +343,12 @@ export async function saveWorkerOrderNote(
}
}
/** 标记打手本人已查看工单,首次操作后不再展示“新”标识。 */
export async function markWorkerOrderRead(workOrderId: number | string, session: WorkerSession) {
/** 标记打手本人已查看工单,首次操作后不再展示“新”标识materialSeen 同时刷新资料查看时间。 */
export async function markWorkerOrderRead(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
@@ -339,6 +367,7 @@ export async function markWorkerOrderRead(workOrderId: number | string, session:
workOrderId: Number(workOrder.id),
workerId,
now: nowIso(),
materialSeen: payload.materialSeen === true || payload.materialSeen === 'true',
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),