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

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' 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 = { export type WorkOrderEventRow = {
id: number id: number
work_order_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 WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status
ELSE COALESCE(CASE ${workerShareAlias}.status ELSE COALESCE(CASE ${workerShareAlias}.status
WHEN 'joined' THEN 'in_progress' WHEN 'joined' THEN 'in_progress'
@@ -134,6 +138,27 @@ function workerOrderStatusExpression(workerShareAlias = 'worker_share') {
ELSE wo.status ELSE wo.status
END, wo.status) END, wo.status)
END` 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( export async function getWorkOrderByOrderItemId(
orderItemId: number | string, orderItemId: number | string,
@@ -342,6 +367,8 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
` `
WITH worker_orders AS ( WITH worker_orders AS (
SELECT SELECT
CASE
WHEN (
CASE CASE
WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem') WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem')
THEN CASE wos.status THEN CASE wos.status
@@ -352,6 +379,33 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
ELSE wo.status ELSE wo.status
END END
ELSE wo.status ELSE wo.status
END
) = '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, END AS status,
wo.acceptance_json, wo.acceptance_json,
wos.id IS NOT NULL AS is_worker_share, wos.id IS NOT NULL AS is_worker_share,
@@ -678,7 +732,9 @@ export function buildWorkOrderWhere({
params.push(normalizedStatuses) params.push(normalizedStatuses)
const statusesPlaceholder = `$${params.length}` const statusesPlaceholder = `$${params.length}`
if (workerSharingId) { if (workerSharingId) {
filters.push(`${workerOrderStatusExpression()} = ANY(${statusesPlaceholder}::text[])`) filters.push(
`${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`,
)
} else { } else {
filters.push( filters.push(
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, `${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: { export async function createWorkerCancelRequest(input: {
workOrderId: number workOrderId: number
workerId: number workerId: number
@@ -39,6 +39,23 @@ async function getWorkerOrderFeedbackByIdWithClient(
return result.rows[0] || null 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: { export async function createWorkerOrderFeedback(input: {
workOrderId: number workOrderId: number
workerId: number workerId: number
@@ -36,15 +36,19 @@ export async function upsertWorkerWorkOrderNote(input: {
return String(result.rows[0]?.note || '') return String(result.rows[0]?.note || '')
} }
/** 查询打手已查看过的工单,用于计算"新"标识。 */ /** 查询打手已查看过的工单,用于计算"新"标识与资料已更新角标。 */
export async function listWorkerWorkOrderViews( export async function listWorkerWorkOrderViews(
workerId: number | string, workerId: number | string,
workOrderIds: number[], 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 [] 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 FROM worker_work_order_views
WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[])
`, `,
@@ -53,12 +57,27 @@ export async function listWorkerWorkOrderViews(
return result.rows return result.rows
} }
/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */ /**
* 首次查看工单时写入已读记录,重复调用保持原查看时间不变;
* materialSeen 为 true 时刷新资料查看时间,用于清除"资料已更新"角标。
*/
export async function markWorkerWorkOrderViewed(input: { export async function markWorkerWorkOrderViewed(input: {
workOrderId: number workOrderId: number
workerId: number workerId: number
now: string now: string
materialSeen?: boolean
}): Promise<string> { }): 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 }>( const inserted = await query<{ viewed_at: string }>(
` `
INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at) INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at)
+5 -1
View File
@@ -157,7 +157,11 @@ router.post(
requireActiveWorker, requireActiveWorker,
createRouteHandler( createRouteHandler(
(req) => (req) =>
markWorkerOrderRead(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), markWorkerOrderRead(
String(req.params.workOrderId || ''),
req.body || {},
getRequiredWorkerSession(req),
),
{ {
successMessage: '订单已标记为已读', successMessage: '订单已标记为已读',
errorMessage: '标记订单已读失败', errorMessage: '标记订单已读失败',
@@ -2,6 +2,9 @@ import { runtimeConfig } from '../../config/runtime.js'
import { import {
createWorkerCancelRequest, createWorkerCancelRequest,
createWorkerOrderFeedback, createWorkerOrderFeedback,
getLatestMaterialEventAtByOrderIds,
getPendingCancelRequestOrderIds,
getPendingFeedbackOrderIds,
getWorkOrderShare, getWorkOrderShare,
getWorkerCancelRequestRisk, getWorkerCancelRequestRisk,
getWorkerOrderOverview, getWorkerOrderOverview,
@@ -61,9 +64,13 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const myShares = await listWorkerSharesByWorker(worker.id) const myShares = await listWorkerSharesByWorker(worker.id)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const workOrderIds = items.map((item) => Number(item.id)) const workOrderIds = items.map((item) => Number(item.id))
const [notes, views] = await Promise.all([ const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds), listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds), listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
]) ])
const noteByOrderId = new Map( const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]), notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
@@ -71,16 +78,33 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const viewedAtByOrderId = new Map( const viewedAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]), 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 { return {
items: items.map((item) => items: items.map((item) => {
mapWorkOrderForWorker( 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, item,
permissions, permissions,
myShareByOrderId.get(Number(item.id)), myShareByOrderId.get(workOrderId),
noteByOrderId.get(Number(item.id)) || '', noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(Number(item.id)) || null, viewedAtByOrderId.get(workOrderId) || null,
),
), ),
// 问题单分组内的来源角标:平台标记 / 撤单审核中 / 反馈处理中。
problemMarked: item.status === 'problem',
pendingCancelRequest: pendingCancelOrderIds.has(workOrderId),
pendingFeedback: pendingFeedbackOrderIds.has(workOrderId),
materialUpdated,
}
}),
pagination: { page, pageSize, total }, pagination: { page, pageSize, total },
} }
} }
@@ -319,8 +343,12 @@ export async function saveWorkerOrderNote(
} }
} }
/** 标记打手本人已查看工单,首次操作后不再展示“新”标识。 */ /** 标记打手本人已查看工单,首次操作后不再展示“新”标识materialSeen 同时刷新资料查看时间。 */
export async function markWorkerOrderRead(workOrderId: number | string, session: WorkerSession) { export async function markWorkerOrderRead(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session) requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId) const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId) const workerId = Number(session.workerId)
@@ -339,6 +367,7 @@ export async function markWorkerOrderRead(workOrderId: number | string, session:
workOrderId: Number(workOrder.id), workOrderId: Number(workOrder.id),
workerId, workerId,
now: nowIso(), now: nowIso(),
materialSeen: payload.materialSeen === true || payload.materialSeen === 'true',
}) })
publishWorkOrderRealtimeChange({ publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id), workOrderId: Number(workOrder.id),
@@ -31,6 +31,7 @@ import {
getDisplayOrderNo, getDisplayOrderNo,
getMaterialDetailItems, getMaterialDetailItems,
getMaterialScreenshots, getMaterialScreenshots,
getOrderFlagTags,
getOrderStatusDisplay, getOrderStatusDisplay,
getSettlementLabel, getSettlementLabel,
getUniqueAcceptanceFiles, getUniqueAcceptanceFiles,
@@ -228,6 +229,7 @@ function WorkerOrderMobileCard({
const noteText = getWorkerAcceptance(order)?.note const noteText = getWorkerAcceptance(order)?.note
const workerNote = String(order.workerNote || '').trim() const workerNote = String(order.workerNote || '').trim()
const statusDisplay = getOrderStatusDisplay(order) const statusDisplay = getOrderStatusDisplay(order)
const flagTags = getOrderFlagTags(order)
return ( return (
<article className="worker-order-mobile-card"> <article className="worker-order-mobile-card">
@@ -248,6 +250,11 @@ function WorkerOrderMobileCard({
</Tag> </Tag>
) : null} ) : null}
{flagTags.map((flag) => (
<Tag key={flag.key} color={flag.color} className="worker-order-mobile-tag">
{flag.label}
</Tag>
))}
<span className="worker-order-mobile-orderno-text">{getDisplayOrderNo(order)}</span> <span className="worker-order-mobile-orderno-text">{getDisplayOrderNo(order)}</span>
</div> </div>
<div className="worker-order-mobile-header-right"> <div className="worker-order-mobile-header-right">
@@ -26,6 +26,7 @@ import {
getDisplayOrderNo, getDisplayOrderNo,
getMaterialDetailItems, getMaterialDetailItems,
getMaterialScreenshots, getMaterialScreenshots,
getOrderFlagTags,
getOrderStatusDisplay, getOrderStatusDisplay,
getUniqueAcceptanceFiles, getUniqueAcceptanceFiles,
getWorkerAcceptance, getWorkerAcceptance,
@@ -102,6 +103,7 @@ export function buildWorkerOrderTableColumns({
width: 130, width: 130,
render: (_, row) => { render: (_, row) => {
const statusDisplay = getOrderStatusDisplay(row) const statusDisplay = getOrderStatusDisplay(row)
const flagTags = getOrderFlagTags(row)
return ( return (
<div className="cell-stack"> <div className="cell-stack">
<Space wrap size={4}> <Space wrap size={4}>
@@ -115,6 +117,15 @@ export function buildWorkerOrderTableColumns({
<GiftCountdownTag giftAvailableAt={row.gift.giftAvailableAt} /> <GiftCountdownTag giftAvailableAt={row.gift.giftAvailableAt} />
) : null} ) : null}
</Space> </Space>
{flagTags.length > 0 ? (
<Space wrap size={4}>
{flagTags.map((flag) => (
<Tag key={flag.key} color={flag.color}>
{flag.label}
</Tag>
))}
</Space>
) : null}
{row.status === 'problem' && String(row.problemNote || '').trim() ? ( {row.status === 'problem' && String(row.problemNote || '').trim() ? (
<Typography.Text <Typography.Text
type="danger" type="danger"
@@ -223,11 +223,17 @@ export default function WorkerOrdersPage() {
} }
function markOrderAsRead(order: WorkOrder) { function markOrderAsRead(order: WorkOrder) {
if (!order.isUnread) return if (order.isUnread) {
queryClient.setQueriesData({ queryKey: ['worker-my-orders'] }, (current: unknown) => queryClient.setQueriesData({ queryKey: ['worker-my-orders'] }, (current: unknown) =>
patchWorkOrderReadState(current, order.workOrderId), patchWorkOrderReadState(current, order.workOrderId),
) )
void markWorkerOrderRead(order.workOrderId).catch(() => { }
// 每次打开详情都刷新资料查看时间,用于清除"资料已更新"角标。
void markWorkerOrderRead(order.workOrderId, { materialSeen: true })
.then(() => {
void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
})
.catch(() => {
void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
}) })
} }
@@ -72,6 +72,17 @@ export function getOrderStatusDisplay(order: WorkOrder): { label: string; color:
return { label: formatStatus(order.status), color: resolveStatusColor(order.status) } return { label: formatStatus(order.status), color: resolveStatusColor(order.status) }
} }
/** 问题单来源与资料更新的订单角标,桌面表格与移动卡片共用。 */
export function getOrderFlagTags(order: WorkOrder) {
const flags: Array<{ key: string; label: string; color: string }> = []
if (order.problemMarked) flags.push({ key: 'problem', label: '平台标记', color: 'red' })
if (order.pendingCancelRequest)
flags.push({ key: 'cancel', label: '撤单审核中', color: 'orange' })
if (order.pendingFeedback) flags.push({ key: 'feedback', label: '反馈处理中', color: 'orange' })
if (order.materialUpdated) flags.push({ key: 'material', label: '资料已更新', color: 'blue' })
return flags
}
export function getOrderStatusHint(order: WorkOrder) { export function getOrderStatusHint(order: WorkOrder) {
if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) { if (isFriendGiftOrder(order) && order.status === 'in_progress' && order.gift) {
const hints: Record<string, string> = { const hints: Record<string, string> = {
+5 -2
View File
@@ -270,8 +270,11 @@ export function submitWorkerOrderFeedback(workOrderId: number, payload: { conten
) )
} }
export function markWorkerOrderRead(workOrderId: number) { export function markWorkerOrderRead(workOrderId: number, options: { materialSeen?: boolean } = {}) {
return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`) return apiPost<{ viewedAt: string; isUnread: false }>(
`/api/v1/worker/orders/${workOrderId}/read`,
{ materialSeen: options.materialSeen === true },
)
} }
export function saveWorkerOrderNote(workOrderId: number, note: string) { export function saveWorkerOrderNote(workOrderId: number, note: string) {
@@ -548,6 +548,11 @@ export type WorkOrder = {
/** 打手本人尚未点击查看或操作过该工单。 */ /** 打手本人尚未点击查看或操作过该工单。 */
isUnread?: boolean isUnread?: boolean
problemNote: string problemNote: string
/** 问题单分组来源角标:平台标记 / 撤单审核中 / 反馈处理中 / 资料已更新。 */
problemMarked?: boolean
pendingCancelRequest?: boolean
pendingFeedback?: boolean
materialUpdated?: boolean
/** 取消信息,仅已取消工单返回。 */ /** 取消信息,仅已取消工单返回。 */
cancel?: { cancel?: {
reason: string reason: string