add:新增同一个工单同一人可以多次拼单:拼单明细ui优化
This commit is contained in:
@@ -255,6 +255,9 @@ async function publishWorkOrderSnapshots(input: {
|
||||
hallChanged?: boolean
|
||||
}) {
|
||||
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
|
||||
// 调用方显式给出的受影响打手必须同步失效:下面的快照生成要先查库,
|
||||
// 若等都查完再失效,紧接着到达的「我的订单」请求会命中旧缓存(30s TTL)。
|
||||
invalidateWorkerMyOrderCaches(input.workerIds || [])
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
if (!workOrder) {
|
||||
// 工单已不存在(删除):调用方传入的打手集合仍需失效缓存,
|
||||
@@ -449,7 +452,10 @@ async function publishWorkOrderSnapshots(input: {
|
||||
hasTimeoutForMe: Boolean(timeoutEvent),
|
||||
vipEvidencePending,
|
||||
}),
|
||||
problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
problemMarked:
|
||||
!isTimedOutForMe &&
|
||||
(workOrder.status === WORK_ORDER_STATUS.PROBLEM ||
|
||||
activeWorkerShares.some((share) => Boolean(share.problem_marked_at))),
|
||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||
materialUpdated:
|
||||
|
||||
@@ -2,8 +2,10 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEvent,
|
||||
deleteWorkOrder,
|
||||
disposeWorkOrderShareProblem,
|
||||
listWorkOrderShares,
|
||||
listWorkOrders,
|
||||
markWorkOrderShareProblem,
|
||||
publishUnassignedWorkOrder,
|
||||
reopenCancelledWorkOrder,
|
||||
resolveProblemWorkOrder,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from './publish-voucher-service.js'
|
||||
import {
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderShare,
|
||||
normalizeAmountFen,
|
||||
normalizeInteger,
|
||||
normalizeOptionalId,
|
||||
@@ -436,3 +439,118 @@ export async function resolveAdminProblemWorkOrder(
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(updated) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台「拼单明细」按份额标记问题单。
|
||||
*
|
||||
* 只影响该份额持有人:母单状态/备注不动,同工单其他打手无感。
|
||||
* 备注选填;重复标记视为更新备注(幂等)。
|
||||
*/
|
||||
export async function markAdminWorkOrderShareProblem(
|
||||
workOrderId: number | string,
|
||||
shareId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const note = String(payload.note || payload.problemNote || '').trim()
|
||||
const result = await markWorkOrderShareProblem({
|
||||
workOrderId: Number(workOrderId),
|
||||
shareId: Number(shareId),
|
||||
note,
|
||||
now: nowIso(),
|
||||
actorName,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_markable') {
|
||||
throw createHttpError('当前订单状态不能按份额标记问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_share_problem_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_not_found' || !result.share) {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_share_problem_share_not_found',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_status_not_markable') {
|
||||
throw createHttpError('已撤单的份额不能标记问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_share_problem_share_status_invalid',
|
||||
})
|
||||
}
|
||||
// 只通知该份额持有人刷新「我的订单」;后台列表由快照广播覆盖。
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0),
|
||||
})
|
||||
return {
|
||||
order: result.order ? mapWorkOrderAdmin(result.order) : null,
|
||||
share: mapWorkOrderShare(result.share),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台「拼单明细」处置份额级问题单。
|
||||
*
|
||||
* 处置方式与母单处置完全一致(退回打手继续代练 / 释放押金回大厅 /
|
||||
* 取消退押金 / 取消扣押金),但只作用于这一份份额:母单状态/备注不动,
|
||||
* 同工单其他打手无感。备注必填,与母单处置同口径。
|
||||
*/
|
||||
export async function disposeAdminWorkOrderShareProblem(
|
||||
workOrderId: number | string,
|
||||
shareId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const action = normalizeProblemResolutionAction(payload.action)
|
||||
const note = String(payload.note || '').trim()
|
||||
if (!note) {
|
||||
throw createHttpError('请填写处置备注', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_problem_disposal_note_required',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await disposeWorkOrderShareProblem({
|
||||
workOrderId: Number(workOrderId),
|
||||
shareId: Number(shareId),
|
||||
action,
|
||||
note,
|
||||
now: nowIso(),
|
||||
actorName,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_disposable') {
|
||||
throw createHttpError('当前订单状态不能处置份额问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_share_problem_disposal_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_not_found' || !result.share) {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_share_problem_share_not_found',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_not_marked') {
|
||||
throw createHttpError('该份额当前不是问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_share_problem_not_marked',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_status_not_disposable') {
|
||||
throw createHttpError('已验收或已取消的份额不能处置,请走售后或单人撤单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_share_problem_share_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0),
|
||||
})
|
||||
return {
|
||||
order: result.order ? mapWorkOrderAdmin(result.order) : null,
|
||||
share: mapWorkOrderShare(result.share),
|
||||
depositAmount: result.depositAmount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,3 +218,33 @@ test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到
|
||||
['accepted', 'vip_evidence_pending'],
|
||||
)
|
||||
})
|
||||
|
||||
test('份额级问题单:被标记的份额只归问题单,同单其他份额仍按原状态归类', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'in_progress',
|
||||
isAssignedWorker: false,
|
||||
shares: [
|
||||
share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' }),
|
||||
share({ id: 2, status: 'submitted' }),
|
||||
],
|
||||
})
|
||||
assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem'])
|
||||
})
|
||||
|
||||
test('份额级问题单:唯一份额被标记后不得再出现在原状态页签', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'in_progress',
|
||||
isAssignedWorker: true,
|
||||
shares: [share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' })],
|
||||
})
|
||||
assert.deepEqual(tabs, ['problem'])
|
||||
})
|
||||
|
||||
test('份额级问题单:已验收份额被标记后只归问题单,母单仍为进行中', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'in_progress',
|
||||
isAssignedWorker: true,
|
||||
shares: [share({ id: 1, status: 'accepted', problem_marked_at: '2026-10-23T11:00:00.000Z' })],
|
||||
})
|
||||
assert.deepEqual(tabs, ['problem'])
|
||||
})
|
||||
|
||||
@@ -526,12 +526,21 @@ export function mapWorkOrderForWorker(
|
||||
} = mapped
|
||||
const isTimedOutForMe =
|
||||
options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut)
|
||||
const personalStatus = isTimedOutForMe
|
||||
? WORK_ORDER_STATUS.OPEN
|
||||
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||
|
||||
const workerShares =
|
||||
options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : []
|
||||
/**
|
||||
* 份额级问题单:该打手在本单只要有一份被后台标记,这张单就显示为问题单
|
||||
* (母单仍是 accepted / cancelled / problem 时以母单为准,与列表 SQL 同序)。
|
||||
*/
|
||||
const problemShare = workerShares.find(
|
||||
(share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at),
|
||||
)
|
||||
const personalStatus = isTimedOutForMe
|
||||
? WORK_ORDER_STATUS.OPEN
|
||||
: problemShare && !['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || ''))
|
||||
? WORK_ORDER_STATUS.PROBLEM
|
||||
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||
|
||||
const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0))
|
||||
const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
|
||||
|
||||
@@ -541,6 +550,13 @@ export function mapWorkOrderForWorker(
|
||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||
: visibleOrder.productName,
|
||||
status: personalStatus,
|
||||
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
||||
problemNote:
|
||||
String(workOrder.status || '') === WORK_ORDER_STATUS.PROBLEM
|
||||
? visibleOrder.problemNote
|
||||
: String(problemShare?.problem_note || ''),
|
||||
/** 是否为「份额级问题单」(区别于平台整单标记) */
|
||||
shareProblemMarked: Boolean(problemShare),
|
||||
rewardAmount: isTimedOutForMe
|
||||
? 0
|
||||
: hasActiveShares
|
||||
@@ -622,6 +638,8 @@ type WorkerShareLike = {
|
||||
status?: unknown
|
||||
created_at?: unknown
|
||||
id?: unknown
|
||||
/** 份额级问题单标记时间;非空表示该份额被后台标记问题单 */
|
||||
problem_marked_at?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -700,18 +718,24 @@ export function resolveWorkerOrderStatusTabs(input: {
|
||||
(share) =>
|
||||
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
|
||||
)
|
||||
// 份额级问题单:只要有一份被后台标记,这张单就归问题单(与 SQL 的 workerShareProblemExists 同义)。
|
||||
const problemShare = activeShares.find((share) => Boolean(share.problem_marked_at))
|
||||
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 (problemShare) {
|
||||
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||
} 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) {
|
||||
// 被标记问题单的份额只归问题单页签,不再按原状态出现(与 SQL 的 anyShare 分支一致)。
|
||||
if (share.problem_marked_at) continue
|
||||
if (share.status === 'joined') {
|
||||
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
|
||||
if (!input.hasPendingHandling) tabs.add('in_progress')
|
||||
@@ -748,6 +772,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
shareReward: Number(share.share_reward || 0),
|
||||
shareDeposit: Number(share.share_deposit || 0),
|
||||
status: share.status,
|
||||
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
|
||||
problemMarked: Boolean(share.problem_marked_at),
|
||||
problemNote: share.problem_note || '',
|
||||
problemMarkedAt: share.problem_marked_at || null,
|
||||
problemMarkedBy: share.problem_marked_by || '',
|
||||
acceptance: mapAcceptanceForResponse(share.acceptance_json),
|
||||
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
||||
submittedAt: share.submitted_at,
|
||||
|
||||
@@ -225,7 +225,13 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
|
||||
),
|
||||
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
|
||||
problemMarked: !isTimedOutForMe && item.status === 'problem',
|
||||
// 份额级问题单也算「平台标记」:母单状态没变,但该打手这份被后台标记了。
|
||||
problemMarked:
|
||||
!isTimedOutForMe &&
|
||||
(item.status === 'problem' ||
|
||||
activeShares.some(
|
||||
(share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at),
|
||||
)),
|
||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||
materialUpdated,
|
||||
|
||||
@@ -3364,6 +3364,61 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收
|
||||
)
|
||||
})
|
||||
|
||||
test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const markBlock = src.slice(
|
||||
src.indexOf('export async function markWorkOrderShareProblem('),
|
||||
src.indexOf('export async function disposeWorkOrderShareProblem('),
|
||||
)
|
||||
assert.ok(markBlock.length > 0, 'markWorkOrderShareProblem 片段必须存在')
|
||||
assert.ok(markBlock.includes('UPDATE work_order_shares'), '标记必须写份额表')
|
||||
assert.ok(
|
||||
!/UPDATE\s+work_orders/i.test(markBlock),
|
||||
'份额级问题单不得写母单(含 status / problem_note)',
|
||||
)
|
||||
assert.ok(
|
||||
!markBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||
'份额级问题单不得触发整单状态重算',
|
||||
)
|
||||
assert.ok(
|
||||
markBlock.includes('toStatus: workOrder.status') &&
|
||||
markBlock.includes('fromStatus: workOrder.status'),
|
||||
'标记事件不得记录母单状态迁移',
|
||||
)
|
||||
|
||||
const disposeBlock = src.slice(
|
||||
src.indexOf('export async function disposeWorkOrderShareProblem('),
|
||||
src.indexOf('export async function settleOverdueWorkOrder('),
|
||||
)
|
||||
assert.ok(disposeBlock.length > 0, 'disposeWorkOrderShareProblem 片段必须存在')
|
||||
assert.ok(disposeBlock.includes('UPDATE work_order_shares'), '处置必须写份额表')
|
||||
assert.ok(!/UPDATE\s+work_orders/i.test(disposeBlock), '份额处置不得改母单')
|
||||
assert.ok(
|
||||
!disposeBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||
'份额处置不得触发整单状态重算',
|
||||
)
|
||||
assert.ok(
|
||||
disposeBlock.includes('toStatus: workOrder.status') &&
|
||||
disposeBlock.includes('fromStatus: workOrder.status'),
|
||||
'处置事件不得记录母单状态迁移',
|
||||
)
|
||||
assert.ok(
|
||||
disposeBlock.includes("status = CASE WHEN status = 'submitted' THEN 'joined'"),
|
||||
'退回打手必须把已提交的份额恢复为进行中',
|
||||
)
|
||||
assert.ok(
|
||||
disposeBlock.includes("shouldDeduct ? 'deposit_deduction' : 'deposit_release'"),
|
||||
'取消类处置必须按方式退还或扣除押金',
|
||||
)
|
||||
assert.ok(
|
||||
disposeBlock.includes("cancelled_reason = 'problem'"),
|
||||
'取消类处置必须记录问题单处置来源',
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||
|
||||
Reference in New Issue
Block a user