From b82f9c4063db4c94b9b8c4e4f7f4a7bc0b061b4a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 11 Oct 2026 14:53:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-platform):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=8B=BC=E5=8D=95=E4=BB=BD=E9=A2=9D=E8=B6=85=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A4=84=E7=BD=AE=E6=9C=BA=E5=88=B6=E4=B8=8E=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E5=A4=A7=E5=8E=85=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因排查:此前超时扫描仅针对整单 in_progress,而拼单母单为 open 且 deadline_at 为空,导致打手超时的拼单份额无法被定时扫描命中与释放; - 定时扫描:在 settleOverdueWorkOrders 中引入 listOverdueWorkOrderShares,自动扫描超时未提交资料的拼单份额(joined); - 超时处置:新增 settleOverdueWorkOrderShare 事务处理,按母单 timeout_policy 释放或扣除押金,份额标记 cancelled,母单保持或恢复 open 状态重回抢单大厅,记录 timeout 事件并推送实时变更; - 提交防呆:在打手提交拼单验收时检测个人份额是否已超时,超时则立即执行处置并拦截提交; - 倒计时展示:在 mapWorkOrderForWorker 中根据 myShare.created_at + timeout_minutes 为拼单打手计算并展示个人履约截止时间; - 质量验证:补齐拼单截止时间计算与处置链路测试,全量 676 个测试均通过,通过 npm run check 校验。 --- .../scripts/douyin-mashida-subscribe.ts | 3 +- apps/backend/src/repositories/order-repo.ts | 8 +- .../worker-platform/work-order-query-repo.ts | 67 +++++- .../worker-platform/work-order-repo.ts | 1 + .../work-order-settlement-repo.ts | 190 ++++++++++++++++++ apps/backend/src/routes/admin/orders.ts | 1 - .../admin/platform-config/douyin-mashida.ts | 1 - .../admin/admin-order-read-helpers.ts | 4 +- .../src/services/admin/admin-read-service.ts | 4 +- .../services/admin/admin-task-read-helpers.ts | 14 +- .../platform-config/douyin-mashida-service.ts | 1 - .../src/services/claim/h5-claim-order.test.ts | 8 +- .../claim/kuaishou-cloud-claim-context.ts | 19 +- .../douyin-mashida/gateway-client.ts | 1 - .../douyin-mashida/notify-service.ts | 4 +- .../services/redeem/certificate-service.ts | 4 +- .../work-order-display-mappers.ts | 6 + .../worker-acceptance-service.ts | 24 +++ .../worker-order-timeout-service.ts | 37 +++- .../worker-platform-service.test.ts | 65 ++++++ 20 files changed, 418 insertions(+), 44 deletions(-) diff --git a/apps/backend/scripts/douyin-mashida-subscribe.ts b/apps/backend/scripts/douyin-mashida-subscribe.ts index 25fe07d0..fbc45725 100644 --- a/apps/backend/scripts/douyin-mashida-subscribe.ts +++ b/apps/backend/scripts/douyin-mashida-subscribe.ts @@ -150,7 +150,7 @@ async function api( const response = await fetch(`${apiBase}${pathName}`, { method, headers: { - 'authorization': `Bearer ${token}`, + authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}), }, body: body ? JSON.stringify(body) : undefined, @@ -174,7 +174,6 @@ function defaultAdminUser(): { username?: string; password?: string } { return {} } - /** * 打平台 messagetest:平台会**按当前订阅**把消息推回 message_url。 * 签名与消息推送同一套(我们扮演平台侧签名)。 diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 87dd6620..e9b79b6f 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -448,13 +448,7 @@ export async function updateOrderClaimStatus( WHERE id = $4 RETURNING * `, - [ - input.claimStatus, - String(input.reason || '').trim(), - updatedAt, - Number(orderId), - ], + [input.claimStatus, String(input.reason || '').trim(), updatedAt, Number(orderId)], ) return result.rows[0] || null } - diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 0e2605e5..032e761f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -326,7 +326,6 @@ export async function listWorkOrdersByOrderId(orderId: number | string): Promise return result.rows } - export async function listWorkOrders({ page = 1, pageSize = 20, @@ -731,6 +730,72 @@ export async function listOverdueWorkOrders({ return result.rows } +export interface OverdueWorkOrderShareItem { + shareId: number + workOrderId: number + workerId: number + quantity: number + shareDeposit: number + timeoutMinutes: number + timeoutPolicy: string + shareCreatedAt: string +} + +/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */ +export async function listOverdueWorkOrderShares({ + limit = 50, + workerId = 0, +}: { limit?: number; workerId?: number } = {}): Promise { + const params: unknown[] = [] + let workerClause = '' + if (workerId > 0) { + params.push(workerId) + workerClause = `AND wos.worker_id = $${params.length}` + } + params.push(limit) + const result = await query<{ + share_id: string + work_order_id: string + worker_id: string + quantity: number + share_deposit: number + timeout_minutes: number + timeout_policy: string + share_created_at: string + }>( + `SELECT + wos.id AS share_id, + wos.work_order_id, + wos.worker_id, + wos.quantity, + wos.share_deposit, + wo.timeout_minutes, + wo.timeout_policy, + wos.created_at AS share_created_at + FROM work_order_shares wos + INNER JOIN work_orders wo ON wo.id = wos.work_order_id + WHERE wos.status = 'joined' + AND wo.sharing_enabled = TRUE + AND wo.timeout_minutes > 0 + AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW() + AND wo.status = 'open' + ${workerClause} + ORDER BY wos.created_at ASC + LIMIT $${params.length}`, + params, + ) + return result.rows.map((row) => ({ + shareId: Number(row.share_id), + workOrderId: Number(row.work_order_id), + workerId: Number(row.worker_id), + quantity: Number(row.quantity), + shareDeposit: Number(row.share_deposit), + timeoutMinutes: Number(row.timeout_minutes), + timeoutPolicy: String(row.timeout_policy || 'reopen'), + shareCreatedAt: String(row.share_created_at), + })) +} + export async function countWorkerActiveOrders(workerId: number | string): Promise { const result = await query<{ total: number }>( ` diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index d28a74e0..082c44c5 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -29,4 +29,5 @@ export { acceptWorkOrderShareAndSettle, cancelWorkOrderShare, cancelWorkOrderSharing, + settleOverdueWorkOrderShare, } from './work-order-settlement-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index e044958f..6642e055 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -856,6 +856,196 @@ export async function cancelWorkOrderShare(input: { }) } +/** + * 拼单份额超时自动处置: + * 超过母单 timeout_minutes 时限且未提交材料的 joined 份额, + * 按照母单 timeout_policy 处置(默认 reopen 释放押金退回大厅,或 cancel_deduct 扣除押金)。 + */ +export async function settleOverdueWorkOrderShare(input: { + workOrderId: number + shareId: number + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' + now: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + releasedDepositAmount: number + failureReason: 'work_order_not_cancellable' | 'share_not_found' | 'share_not_overdue' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.sharing_enabled !== true || workOrder.status !== 'open') { + return { + order: workOrder, + share: null, + releasedDepositAmount: 0, + failureReason: 'work_order_not_cancellable', + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share || share.status !== 'joined') { + return { + order: workOrder, + share, + releasedDepositAmount: 0, + failureReason: 'share_not_found', + } + } + + const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + if (timeoutMinutes <= 0) { + return { + order: workOrder, + share, + releasedDepositAmount: 0, + failureReason: 'share_not_overdue', + } + } + const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000 + if (deadlineMs > new Date(input.now).getTime()) { + return { + order: workOrder, + share, + releasedDepositAmount: 0, + failureReason: 'share_not_overdue', + } + } + + const workerId = Number(share.worker_id || 0) + let releasedDepositAmount = 0 + let deductedDepositAmount = 0 + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const frozenDeposit = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (frozenDeposit > 0) { + if (input.policy === 'cancel_deduct') { + deductedDepositAmount = frozenDeposit + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit) + await client.query( + `UPDATE worker_wallets + SET frozen_deposit_amount = $1, updated_at = $2 + WHERE worker_id = $3`, + [nextFrozen, input.now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_deduction', $2, $3, $4, $5, '拼单超时处置扣除押金', $6::jsonb, $7)`, + [ + workerId, + -frozenDeposit, + Number(wallet?.available_amount || 0), + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: input.shareId, + action: 'timeout_share_deduct', + }), + input.now, + ], + ) + } else { + // reopen or cancel_release + releasedDepositAmount = frozenDeposit + const nextAvailable = Number(wallet?.available_amount || 0) + frozenDeposit + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '拼单超时退回大厅释放押金', $6::jsonb, $7)`, + [ + workerId, + frozenDeposit, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: input.shareId, + action: 'timeout_share_reopen', + }), + input.now, + ], + ) + } + } + } + + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + updated_at = $1 + WHERE id = $2`, + [input.now, input.shareId], + ) + + await client.query( + `UPDATE work_orders + SET status = 'open', + published_at = COALESCE(published_at, $1), + hall_queued_at = $1, + updated_at = $1 + WHERE id = $2`, + [input.now, input.workOrderId], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'system', + actorId: '', + eventType: `timeout_${input.policy}`, + fromStatus: 'open', + toStatus: 'open', + payloadJson: JSON.stringify({ + policy: input.policy, + workerId, + shareId: input.shareId, + quantity: Number(share.quantity || 0), + depositAmount: releasedDepositAmount || deductedDepositAmount, + isSharing: true, + }), + now: input.now, + }) + + const updatedShareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updatedShareResult.rows[0] || null, + releasedDepositAmount, + failureReason: null, + } + }) +} + /** * 母单级拼单撤单。只撤回尚未验收的 joined/submitted 份额;已验收份额保留, * 取消整单时则要求不存在已验收份额,避免绕过售后和已结算资金处理。 diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 2f70dd68..d8f99433 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -86,4 +86,3 @@ router.post( ) export default router - diff --git a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts index 14edfea2..cdf2a586 100644 --- a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts +++ b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts @@ -82,4 +82,3 @@ router.post( ) export default router - diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index 4f37e23e..e4cc500a 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -128,7 +128,9 @@ export function resolveOrderFulfillmentType({ fulfillmentType: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' canClaimH5: boolean } { - const normProvider = String(provider || '').trim().toLowerCase() + const normProvider = String(provider || '') + .trim() + .toLowerCase() const hasAutoClaimTask = tasks.some( (t) => diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 213739d3..f30e7217 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -139,7 +139,9 @@ export async function getAdminOrders( } } -function groupRowsByOrderId(rows: T[]): Map { +function groupRowsByOrderId( + rows: T[], +): Map { const grouped = new Map() for (const row of rows) { // pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。 diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index b70296c6..7672dce7 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -292,7 +292,9 @@ function resolveTaskDisplayRoleName(task: TaskRow): string { const affiliateDash = context.affiliateDash if (affiliateDash && typeof affiliateDash === 'object') { const dash = affiliateDash as Record - const name = String(dash.roleName || dash.boundRoleName || dash.game_account_role_name || '').trim() + const name = String( + dash.roleName || dash.boundRoleName || dash.game_account_role_name || '', + ).trim() if (name) return name } const kuaishouCloud = context.kuaishouCloudFulfillment @@ -314,11 +316,11 @@ function resolveTaskDisplayRoleId(task: TaskRow): string { const dash = affiliateDash as Record const id = String( dash.boundAccount || - dash.gameAccount || - dash.expectedGameAccount || - dash.game_uid || - dash.game_account || - '', + dash.gameAccount || + dash.expectedGameAccount || + dash.game_uid || + dash.game_account || + '', ).trim() if (id) return id } diff --git a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts index 92b328e9..d0b94f87 100644 --- a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts +++ b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts @@ -184,4 +184,3 @@ export function isMaskedSecretValue(value: unknown, currentSecret = ''): boolean } return false } - diff --git a/apps/backend/src/services/claim/h5-claim-order.test.ts b/apps/backend/src/services/claim/h5-claim-order.test.ts index 85f31873..8018c78c 100644 --- a/apps/backend/src/services/claim/h5-claim-order.test.ts +++ b/apps/backend/src/services/claim/h5-claim-order.test.ts @@ -1,12 +1,8 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { - getOrderClaimContext, -} from './kuaishou-cloud-claim-context.js' -import { - updateAdminOrderClaimStatus, -} from '../admin/write/order-actions.js' +import { getOrderClaimContext } from './kuaishou-cloud-claim-context.js' +import { updateAdminOrderClaimStatus } from '../admin/write/order-actions.js' test('getOrderClaimContext: 缺少订单号时抛出 400 错误', async () => { await assert.rejects( diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index f39d24f9..d3dba07f 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -1,7 +1,11 @@ import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js' import { getOrderItemById, listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' -import { findTaskByClaimTokenId, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' +import { + findTaskByClaimTokenId, + listTasksByOrderId, + updateTask, +} from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' @@ -28,7 +32,6 @@ export type ClaimContext = { orderItem: OrderItemRow } - export async function getClaimContext(token: unknown) { const normalized = String(token || '').trim() @@ -194,9 +197,7 @@ export async function getOrderClaimContext(orderNo: unknown): Promise !isTaskFinalStatus(t.task_status)) || - tasks[tasks.length - 1] + const activeTask = tasks.find((t) => !isTaskFinalStatus(t.task_status)) || tasks[tasks.length - 1] if (!activeTask) { throw createHttpError('该订单暂无可用履约任务', { @@ -205,9 +206,7 @@ export async function getOrderClaimContext(orderNo: unknown): Promise it.id === activeTask.order_item_id) || - items[0] + const orderItem = items.find((it) => it.id === activeTask.order_item_id) || items[0] if (!orderItem) { throw createHttpError('订单商品明细缺失', { @@ -227,7 +226,9 @@ export async function getOrderClaimContext(orderNo: unknown): Promise { const fallback = String(input.config.certLink || '').trim() - const claimLinkBase = String(input.config.claimLinkBase || '').trim().replace(/\/+$/, '') + const claimLinkBase = String(input.config.claimLinkBase || '') + .trim() + .replace(/\/+$/, '') if (!claimLinkBase) { return fallback } diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index d32b4b4b..6737bfc1 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -457,6 +457,12 @@ export function mapWorkOrderForWorker( assignedAt: myShare?.created_at || visibleOrder.assignedAt, submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, + deadlineAt: + myShare && Number(workOrder.timeout_minutes || 0) > 0 + ? new Date( + new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000, + ).toISOString() + : visibleOrder.deadlineAt, workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 644bdb1e..4b6291ab 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -11,6 +11,7 @@ import { createWorkOrderEvent, getWorkOrderShare, settleOverdueWorkOrder, + settleOverdueWorkOrderShare, submitWorkOrderShareAcceptance, updateWorkOrder, updateWorkOrderAcceptanceRecord, @@ -410,6 +411,29 @@ async function submitWorkerSharingAcceptance( }) } const now = nowIso() + const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + if ( + sharingShare.status === 'joined' && + timeoutMinutes > 0 && + new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now() + ) { + await settleOverdueWorkOrderShare({ + workOrderId: workOrder.id, + shareId: sharingShare.id, + policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + now, + }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrder.id), + workerIds: [Number(session.workerId)], + hallChanged: true, + }) + publishWorkerWalletRealtimeChange(Number(session.workerId)) + throw createHttpError('拼单任务已超时,系统已按超时策略处置', { + statusCode: 409, + errorCode: 'work_order_timeout_expired', + }) + } const worker = await getRequiredWorker(session.workerId) const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence const { files, imageUrls, note } = resolveAcceptanceImages( diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index 53f40f82..784c0b8c 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -1,7 +1,9 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { listOverdueWorkOrders, + listOverdueWorkOrderShares, settleOverdueWorkOrder, + settleOverdueWorkOrderShare, } from '../../repositories/worker-platform/index.js' import { nowIso } from '../../utils/time.js' import { @@ -11,10 +13,10 @@ import { import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) { - const overdue = await listOverdueWorkOrders({ - limit: Math.max(1, Number(options.limit || 50)), - workerId: Number(options.workerId || 0), - }) + const limit = Math.max(1, Number(options.limit || 50)) + const workerId = Number(options.workerId || 0) + + const overdue = await listOverdueWorkOrders({ limit, workerId }) let processedCount = 0 let skippedCount = 0 for (const workOrder of overdue) { @@ -39,8 +41,33 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI skippedCount += 1 } } + + // 处置超时未提交材料的拼单份额(status=joined) + const overdueShares = await listOverdueWorkOrderShares({ limit, workerId }) + for (const shareItem of overdueShares) { + const result = await settleOverdueWorkOrderShare({ + workOrderId: shareItem.workOrderId, + shareId: shareItem.shareId, + policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy), + now: nowIso(), + }) + if (result.share) { + processedCount += 1 + publishWorkOrderRealtimeChange({ + workOrderId: Number(shareItem.workOrderId), + workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [], + hallChanged: true, + }) + if (Number(shareItem.workerId)) { + publishWorkerWalletRealtimeChange(Number(shareItem.workerId)) + } + } else { + skippedCount += 1 + } + } + return { - checkedCount: overdue.length, + checkedCount: overdue.length + overdueShares.length, processedCount, skippedCount, } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 428f1e23..d0e2ed6b 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -3059,3 +3059,68 @@ test('已冻结打手调级或变更资料不应因未传 freezeReason 报错', '必须区分是否为新冻结,防止已冻结账号调级时抛出 freeze_reason 必填错误', ) }) + +test('mapWorkOrderForWorker 拼单工单正确计算个人份额截止时间', () => { + const workOrder = buildWorkOrderRow({ + product_name: '指挥官秘钥15个', + status: 'open', + sharing_enabled: true, + sharing_total_quantity: 15, + sharing_unit_reward: 700, + timeout_minutes: 120, + deadline_at: null, + }) + const share = buildWorkOrderShareRow({ + created_at: '2026-08-18T09:09:08.000Z', + }) + const mapped = mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(buildWorkerUserRow()), + share, + ) + + assert.equal(mapped.deadlineAt, '2026-08-18T11:09:08.000Z') +}) + +test('拼单超时处置链路完整性验证', async () => { + const { readFile } = await import('node:fs/promises') + const timeoutSrc = await readFile( + new URL('./worker-order-timeout-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + timeoutSrc.includes('listOverdueWorkOrderShares') && + timeoutSrc.includes('settleOverdueWorkOrderShare'), + 'settleOverdueWorkOrders 必须同时扫描并处置超时拼单份额', + ) + + const acceptanceSrc = await readFile( + new URL('./worker-acceptance-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + acceptanceSrc.includes('settleOverdueWorkOrderShare') && + acceptanceSrc.includes('work_order_timeout_expired'), + 'submitWorkerSharingAcceptance 必须在提交验收时拦截超时份额并处置', + ) + + const queryRepoSrc = await readFile( + new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + queryRepoSrc.includes('listOverdueWorkOrderShares') && + queryRepoSrc.includes("wos.status = 'joined'"), + 'listOverdueWorkOrderShares 必须查询超时且未提交材料的 joined 份额', + ) + + const settlementRepoSrc = await readFile( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + settlementRepoSrc.includes('settleOverdueWorkOrderShare') && + settlementRepoSrc.includes("SET status = 'cancelled'"), + 'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金', + ) +})